summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-11-02 18:43:17 -0500
committerDax Raad <[email protected]>2025-11-02 18:43:33 -0500
commitf68374ad2223ddc213bdea9519ca6a699819ee0e (patch)
tree04f0fe21b8e12cd62d7274961bb0cff64f966f40 /packages/tui/internal
parent5e86c9b7916f75c7ad227b80eab18c7c54fc8ffe (diff)
downloadopencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.tar.gz
opencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.zip
DELETE GO BUBBLETEA CRAP HOORAY
Diffstat (limited to 'packages/tui/internal')
-rw-r--r--packages/tui/internal/api/api.go41
-rw-r--r--packages/tui/internal/app/app.go963
-rw-r--r--packages/tui/internal/app/app_test.go304
-rw-r--r--packages/tui/internal/app/prompt.go283
-rw-r--r--packages/tui/internal/app/state.go174
-rw-r--r--packages/tui/internal/attachment/attachment.go178
-rw-r--r--packages/tui/internal/clipboard/clipboard.go155
-rw-r--r--packages/tui/internal/clipboard/clipboard_darwin.go266
-rw-r--r--packages/tui/internal/clipboard/clipboard_linux.go311
-rw-r--r--packages/tui/internal/clipboard/clipboard_nocgo.go25
-rw-r--r--packages/tui/internal/clipboard/clipboard_windows.go551
-rw-r--r--packages/tui/internal/commands/command.go423
-rw-r--r--packages/tui/internal/completions/agents.go75
-rw-r--r--packages/tui/internal/completions/commands.go144
-rw-r--r--packages/tui/internal/completions/files.go126
-rw-r--r--packages/tui/internal/completions/provider.go8
-rw-r--r--packages/tui/internal/completions/suggestion.go24
-rw-r--r--packages/tui/internal/completions/symbols.go119
-rw-r--r--packages/tui/internal/components/chat/cache.go62
-rw-r--r--packages/tui/internal/components/chat/editor.go906
-rw-r--r--packages/tui/internal/components/chat/message.go1031
-rw-r--r--packages/tui/internal/components/chat/messages.go1322
-rw-r--r--packages/tui/internal/components/commands/commands.go247
-rw-r--r--packages/tui/internal/components/dialog/agents.go452
-rw-r--r--packages/tui/internal/components/dialog/complete.go314
-rw-r--r--packages/tui/internal/components/dialog/help.go80
-rw-r--r--packages/tui/internal/components/dialog/models.go458
-rw-r--r--packages/tui/internal/components/dialog/search.go255
-rw-r--r--packages/tui/internal/components/dialog/session.go400
-rw-r--r--packages/tui/internal/components/dialog/theme.go132
-rw-r--r--packages/tui/internal/components/dialog/timeline.go353
-rw-r--r--packages/tui/internal/components/diff/diff.go957
-rw-r--r--packages/tui/internal/components/diff/parse.go58
-rw-r--r--packages/tui/internal/components/list/list.go436
-rw-r--r--packages/tui/internal/components/list/list_test.go249
-rw-r--r--packages/tui/internal/components/modal/modal.go145
-rw-r--r--packages/tui/internal/components/qr/qr.go56
-rw-r--r--packages/tui/internal/components/status/status.go340
-rw-r--r--packages/tui/internal/components/status/status_test.go100
-rw-r--r--packages/tui/internal/components/textarea/memoization.go125
-rw-r--r--packages/tui/internal/components/textarea/runeutil.go102
-rw-r--r--packages/tui/internal/components/textarea/textarea.go2377
-rw-r--r--packages/tui/internal/components/textarea/textarea_test.go75
-rw-r--r--packages/tui/internal/components/toast/toast.go266
-rw-r--r--packages/tui/internal/decoders/decoder.go118
-rw-r--r--packages/tui/internal/decoders/decoder_test.go194
-rw-r--r--packages/tui/internal/id/id.go96
-rw-r--r--packages/tui/internal/layout/flex.go325
-rw-r--r--packages/tui/internal/layout/layout.go32
-rw-r--r--packages/tui/internal/layout/overlay.go382
-rw-r--r--packages/tui/internal/styles/background.go17
-rw-r--r--packages/tui/internal/styles/markdown.go326
-rw-r--r--packages/tui/internal/styles/styles.go10
-rw-r--r--packages/tui/internal/styles/utilities.go295
-rw-r--r--packages/tui/internal/theme/loader.go408
-rw-r--r--packages/tui/internal/theme/loader_test.go141
-rw-r--r--packages/tui/internal/theme/manager.go229
-rw-r--r--packages/tui/internal/theme/system.go303
-rw-r--r--packages/tui/internal/theme/theme.go215
-rw-r--r--packages/tui/internal/theme/themes/aura.json69
-rw-r--r--packages/tui/internal/theme/themes/ayu.json80
-rw-r--r--packages/tui/internal/theme/themes/catppuccin.json112
-rw-r--r--packages/tui/internal/theme/themes/cobalt2.json228
-rw-r--r--packages/tui/internal/theme/themes/dracula.json219
-rw-r--r--packages/tui/internal/theme/themes/everforest.json241
-rw-r--r--packages/tui/internal/theme/themes/github.json233
-rw-r--r--packages/tui/internal/theme/themes/gruvbox.json95
-rw-r--r--packages/tui/internal/theme/themes/kanagawa.json77
-rw-r--r--packages/tui/internal/theme/themes/material.json235
-rw-r--r--packages/tui/internal/theme/themes/matrix.json77
-rw-r--r--packages/tui/internal/theme/themes/mellow.json87
-rw-r--r--packages/tui/internal/theme/themes/monokai.json221
-rw-r--r--packages/tui/internal/theme/themes/nightowl.json221
-rw-r--r--packages/tui/internal/theme/themes/nord.json223
-rw-r--r--packages/tui/internal/theme/themes/one-dark.json84
-rw-r--r--packages/tui/internal/theme/themes/opencode.json245
-rw-r--r--packages/tui/internal/theme/themes/palenight.json222
-rw-r--r--packages/tui/internal/theme/themes/rosepine.json234
-rw-r--r--packages/tui/internal/theme/themes/solarized.json223
-rw-r--r--packages/tui/internal/theme/themes/synthwave84.json226
-rw-r--r--packages/tui/internal/theme/themes/tokyonight.json243
-rw-r--r--packages/tui/internal/theme/themes/vesper.json218
-rw-r--r--packages/tui/internal/theme/themes/zenburn.json223
-rw-r--r--packages/tui/internal/tui/tui.go1636
-rw-r--r--packages/tui/internal/util/apilogger.go154
-rw-r--r--packages/tui/internal/util/color.go115
-rw-r--r--packages/tui/internal/util/concurrency.go40
-rw-r--r--packages/tui/internal/util/concurrency_test.go23
-rw-r--r--packages/tui/internal/util/file.go113
-rw-r--r--packages/tui/internal/util/ide.go31
-rw-r--r--packages/tui/internal/util/shimmer.go138
-rw-r--r--packages/tui/internal/util/util.go71
-rw-r--r--packages/tui/internal/viewport/highlight.go141
-rw-r--r--packages/tui/internal/viewport/keymap.go56
-rw-r--r--packages/tui/internal/viewport/viewport.go803
95 files changed, 0 insertions, 26216 deletions
diff --git a/packages/tui/internal/api/api.go b/packages/tui/internal/api/api.go
deleted file mode 100644
index b4d3adee2..000000000
--- a/packages/tui/internal/api/api.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package api
-
-import (
- "context"
- "encoding/json"
- "log"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode-sdk-go"
-)
-
-type Request struct {
- Path string `json:"path"`
- Body json.RawMessage `json:"body"`
-}
-
-func Start(ctx context.Context, program *tea.Program, client *opencode.Client) {
- for {
- select {
- case <-ctx.Done():
- return
- default:
- var req Request
- if err := client.Get(ctx, "/tui/control/next", nil, &req); err != nil {
- log.Printf("Error getting next request: %v", err)
- continue
- }
- program.Send(req)
- }
- }
-}
-
-func Reply(ctx context.Context, client *opencode.Client, response interface{}) tea.Cmd {
- return func() tea.Msg {
- err := client.Post(ctx, "/tui/control/response", response, nil)
- if err != nil {
- return err
- }
- return nil
- }
-}
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
deleted file mode 100644
index e0f1d9920..000000000
--- a/packages/tui/internal/app/app.go
+++ /dev/null
@@ -1,963 +0,0 @@
-package app
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "slices"
- "strings"
- "time"
-
- "log/slog"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/clipboard"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/id"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type Message struct {
- Info opencode.MessageUnion
- Parts []opencode.PartUnion
-}
-
-type App struct {
- Project opencode.Project
- Agents []opencode.Agent
- Providers []opencode.Provider
- Version string
- StatePath string
- Config *opencode.Config
- Client *opencode.Client
- State *State
- AgentIndex int
- Provider *opencode.Provider
- Model *opencode.Model
- Session *opencode.Session
- Messages []Message
- Permissions []opencode.Permission
- CurrentPermission opencode.Permission
- Commands commands.CommandRegistry
- InitialModel *string
- InitialPrompt *string
- InitialAgent *string
- InitialSession *string
- compactCancel context.CancelFunc
- IsLeaderSequence bool
- IsBashMode bool
- ScrollSpeed int
-}
-
-func (a *App) Agent() *opencode.Agent {
- return &a.Agents[a.AgentIndex]
-}
-
-type SessionCreatedMsg = struct {
- Session *opencode.Session
-}
-type SessionSelectedMsg = *opencode.Session
-type MessageRevertedMsg struct {
- Session opencode.Session
- Message Message
-}
-type SessionUnrevertedMsg struct {
- Session opencode.Session
-}
-type SessionLoadedMsg struct{}
-type ModelSelectedMsg struct {
- Provider opencode.Provider
- Model opencode.Model
-}
-
-type AgentSelectedMsg struct {
- AgentName string
-}
-
-type SessionClearedMsg struct{}
-type CompactSessionMsg struct{}
-type SendPrompt = Prompt
-type SendShell = struct {
- Command string
-}
-type SendCommand = struct {
- Command string
- Args string
-}
-type SetEditorContentMsg struct {
- Text string
-}
-type FileRenderedMsg struct {
- FilePath string
-}
-type PermissionRespondedToMsg struct {
- Response opencode.SessionPermissionRespondParamsResponse
-}
-
-func New(
- ctx context.Context,
- version string,
- project *opencode.Project,
- path *opencode.Path,
- agents []opencode.Agent,
- httpClient *opencode.Client,
- initialModel *string,
- initialPrompt *string,
- initialAgent *string,
- initialSession *string,
-) (*App, error) {
- util.RootPath = project.Worktree
- util.CwdPath, _ = os.Getwd()
-
- configInfo, err := httpClient.Config.Get(ctx, opencode.ConfigGetParams{})
- if err != nil {
- return nil, err
- }
-
- if configInfo.Keybinds.Leader == "" {
- configInfo.Keybinds.Leader = "ctrl+x"
- }
-
- appStatePath := filepath.Join(path.State, "tui")
- appState, err := LoadState(appStatePath)
- if err != nil {
- appState = NewState()
- SaveState(appStatePath, appState)
- }
-
- if appState.AgentModel == nil {
- appState.AgentModel = make(map[string]AgentModel)
- }
-
- if configInfo.Theme != "" {
- appState.Theme = configInfo.Theme
- }
-
- themeEnv := os.Getenv("OPENCODE_THEME")
- if themeEnv != "" {
- appState.Theme = themeEnv
- }
-
- agentIndex := slices.IndexFunc(agents, func(a opencode.Agent) bool {
- return a.Mode != "subagent"
- })
- var agent *opencode.Agent
- modeName := "build"
- if appState.Agent != "" {
- modeName = appState.Agent
- }
- if initialAgent != nil && *initialAgent != "" {
- modeName = *initialAgent
- }
- for i, m := range agents {
- if m.Name == modeName {
- agentIndex = i
- break
- }
- }
- agent = &agents[agentIndex]
-
- if agent.Model.ModelID != "" {
- appState.AgentModel[agent.Name] = AgentModel{
- ProviderID: agent.Model.ProviderID,
- ModelID: agent.Model.ModelID,
- }
- }
-
- if err := theme.LoadThemesFromDirectories(
- path.Config,
- util.RootPath,
- util.CwdPath,
- ); err != nil {
- slog.Warn("Failed to load themes from directories", "error", err)
- }
-
- if appState.Theme != "" {
- if appState.Theme == "system" && styles.Terminal != nil {
- theme.UpdateSystemTheme(
- styles.Terminal.Background,
- styles.Terminal.BackgroundIsDark,
- )
- }
- theme.SetTheme(appState.Theme)
- }
-
- slog.Debug("Loaded config", "config", configInfo)
-
- customCommands, err := httpClient.Command.List(ctx, opencode.CommandListParams{})
- if err != nil {
- return nil, err
- }
-
- app := &App{
- Project: *project,
- Agents: agents,
- Version: version,
- StatePath: appStatePath,
- Config: configInfo,
- State: appState,
- Client: httpClient,
- AgentIndex: agentIndex,
- Session: &opencode.Session{},
- Messages: []Message{},
- Commands: commands.LoadFromConfig(configInfo, *customCommands),
- InitialModel: initialModel,
- InitialPrompt: initialPrompt,
- InitialAgent: initialAgent,
- InitialSession: initialSession,
- ScrollSpeed: int(configInfo.Tui.ScrollSpeed),
- }
-
- return app, nil
-}
-
-func (a *App) Keybind(commandName commands.CommandName) string {
- command := a.Commands[commandName]
- if len(command.Keybindings) == 0 {
- return ""
- }
- kb := command.Keybindings[0]
- key := kb.Key
- if kb.RequiresLeader {
- key = a.Config.Keybinds.Leader + " " + kb.Key
- }
- return key
-}
-
-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]
- key := a.Keybind(commandName)
- return base(key) + muted(" "+command.Description)
-}
-
-func SetClipboard(text string) tea.Cmd {
- var cmds []tea.Cmd
- cmds = append(cmds, func() tea.Msg {
- clipboard.Write(clipboard.FmtText, []byte(text))
- return nil
- })
- // try to set the clipboard using OSC52 for terminals that support it
- cmds = append(cmds, tea.SetClipboard(text))
- return tea.Sequence(cmds...)
-}
-
-func (a *App) updateModelForNewAgent() {
- singleModelEnv := os.Getenv("OPENCODE_AGENTS_SWITCH_SINGLE_MODEL")
- isSingleModel := singleModelEnv == "1" || singleModelEnv == "true"
-
- if isSingleModel {
- return
- }
- // Set up model for the new agent
- modelID := a.Agent().Model.ModelID
- providerID := a.Agent().Model.ProviderID
- if modelID == "" {
- if model, ok := a.State.AgentModel[a.Agent().Name]; ok {
- modelID = model.ModelID
- providerID = model.ProviderID
- }
- }
-
- if modelID != "" {
- for _, provider := range a.Providers {
- if provider.ID == providerID {
- a.Provider = &provider
- for _, model := range provider.Models {
- if model.ID == modelID {
- a.Model = &model
- break
- }
- }
- break
- }
- }
- }
-}
-
-func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
- if forward {
- a.AgentIndex++
- if a.AgentIndex >= len(a.Agents) {
- a.AgentIndex = 0
- }
- } else {
- a.AgentIndex--
- if a.AgentIndex < 0 {
- a.AgentIndex = len(a.Agents) - 1
- }
- }
- if a.Agent().Mode == "subagent" {
- return a.cycleMode(forward)
- }
-
- a.updateModelForNewAgent()
-
- a.State.Agent = a.Agent().Name
- a.State.UpdateAgentUsage(a.Agent().Name)
- return a, a.SaveState()
-}
-
-func (a *App) SwitchAgent() (*App, tea.Cmd) {
- return a.cycleMode(true)
-}
-
-func (a *App) SwitchAgentReverse() (*App, tea.Cmd) {
- return a.cycleMode(false)
-}
-
-func (a *App) cycleRecentModel(forward bool) (*App, tea.Cmd) {
- recentModels := a.State.RecentlyUsedModels
- if len(recentModels) > 5 {
- recentModels = recentModels[:5]
- }
- if len(recentModels) < 2 {
- return a, toast.NewInfoToast("Need at least 2 recent models to cycle")
- }
- nextIndex := 0
- prevIndex := 0
- for i, recentModel := range recentModels {
- if a.Provider != nil && a.Model != nil && recentModel.ProviderID == a.Provider.ID &&
- recentModel.ModelID == a.Model.ID {
- nextIndex = (i + 1) % len(recentModels)
- prevIndex = (i - 1 + len(recentModels)) % len(recentModels)
- break
- }
- }
- targetIndex := nextIndex
- if !forward {
- targetIndex = prevIndex
- }
- for range recentModels {
- currentRecentModel := recentModels[targetIndex%len(recentModels)]
- provider, model := findModelByProviderAndModelID(
- a.Providers,
- currentRecentModel.ProviderID,
- currentRecentModel.ModelID,
- )
- if provider != nil && model != nil {
- a.Provider, a.Model = provider, model
- a.State.AgentModel[a.Agent().Name] = AgentModel{
- ProviderID: provider.ID,
- ModelID: model.ID,
- }
- return a, tea.Sequence(
- a.SaveState(),
- toast.NewSuccessToast(
- fmt.Sprintf("Switched to %s (%s)", model.Name, provider.Name),
- ),
- )
- }
- recentModels = append(
- recentModels[:targetIndex%len(recentModels)],
- recentModels[targetIndex%len(recentModels)+1:]...)
- if len(recentModels) < 2 {
- a.State.RecentlyUsedModels = recentModels
- return a, tea.Sequence(
- a.SaveState(),
- toast.NewInfoToast("Not enough valid recent models to cycle"),
- )
- }
- }
- a.State.RecentlyUsedModels = recentModels
- return a, toast.NewErrorToast("Recent model not found")
-}
-
-func (a *App) CycleRecentModel() (*App, tea.Cmd) {
- return a.cycleRecentModel(true)
-}
-
-func (a *App) CycleRecentModelReverse() (*App, tea.Cmd) {
- return a.cycleRecentModel(false)
-}
-
-func (a *App) SwitchToAgent(agentName string) (*App, tea.Cmd) {
- // Find the agent index by name
- for i, agent := range a.Agents {
- if agent.Name == agentName {
- a.AgentIndex = i
- break
- }
- }
-
- a.updateModelForNewAgent()
-
- a.State.Agent = a.Agent().Name
- a.State.UpdateAgentUsage(agentName)
- return a, a.SaveState()
-}
-
-// findModelByFullID finds a model by its full ID in the format "provider/model"
-func findModelByFullID(
- providers []opencode.Provider,
- fullModelID string,
-) (*opencode.Provider, *opencode.Model) {
- modelParts := strings.SplitN(fullModelID, "/", 2)
- if len(modelParts) < 2 {
- return nil, nil
- }
-
- providerID := modelParts[0]
- modelID := modelParts[1]
-
- return findModelByProviderAndModelID(providers, providerID, modelID)
-}
-
-// findModelByProviderAndModelID finds a model by provider ID and model ID
-func findModelByProviderAndModelID(
- providers []opencode.Provider,
- providerID, modelID string,
-) (*opencode.Provider, *opencode.Model) {
- for _, provider := range providers {
- if provider.ID != providerID {
- continue
- }
-
- for _, model := range provider.Models {
- if model.ID == modelID {
- return &provider, &model
- }
- }
-
- // Provider found but model not found
- return nil, nil
- }
-
- // Provider not found
- return nil, nil
-}
-
-// findProviderByID finds a provider by its ID
-func findProviderByID(providers []opencode.Provider, providerID string) *opencode.Provider {
- for _, provider := range providers {
- if provider.ID == providerID {
- return &provider
- }
- }
- return nil
-}
-
-func (a *App) InitializeProvider() tea.Cmd {
- providersResponse, err := a.Client.App.Providers(context.Background(), opencode.AppProvidersParams{})
- if err != nil {
- slog.Error("Failed to list providers", "error", err)
- // TODO: notify user
- return nil
- }
- providers := providersResponse.Providers
- if len(providers) == 0 {
- slog.Error("No providers configured")
- return nil
- }
-
- a.Providers = providers
-
- // retains backwards compatibility with old state format
- if model, ok := a.State.AgentModel[a.State.Agent]; ok {
- a.State.Provider = model.ProviderID
- a.State.Model = model.ModelID
- }
-
- var selectedProvider *opencode.Provider
- var selectedModel *opencode.Model
-
- // Priority 1: Command line --model flag (InitialModel)
- if a.InitialModel != nil && *a.InitialModel != "" {
- if provider, model := findModelByFullID(providers, *a.InitialModel); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from command line",
- "provider",
- provider.ID,
- "model",
- model.ID,
- )
- } else {
- slog.Debug("Command line model not found", "model", *a.InitialModel)
- }
- }
-
- // Priority 2: Current agent's preferred model
- if selectedProvider == nil && a.Agent().Model.ModelID != "" {
- if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from current agent",
- "provider",
- provider.ID,
- "model",
- model.ID,
- "agent",
- a.Agent().Name,
- )
- } else {
- slog.Debug("Agent model not found", "provider", a.Agent().Model.ProviderID, "model", a.Agent().Model.ModelID, "agent", a.Agent().Name)
- }
- }
-
- // Priority 3: Config file model setting
- if selectedProvider == nil && a.Config.Model != "" {
- if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID)
- } else {
- slog.Debug("Config model not found", "model", a.Config.Model)
- }
- }
-
- // Priority 4: Recent model usage (most recently used model)
- if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 {
- recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first
- if provider, model := findModelByProviderAndModelID(providers, recentUsage.ProviderID, recentUsage.ModelID); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from recent usage",
- "provider",
- provider.ID,
- "model",
- model.ID,
- )
- } else {
- slog.Debug("Recent model not found", "provider", recentUsage.ProviderID, "model", recentUsage.ModelID)
- }
- }
-
- // Priority 5: State-based model (backwards compatibility)
- if selectedProvider == nil && a.State.Provider != "" && a.State.Model != "" {
- if provider, model := findModelByProviderAndModelID(providers, a.State.Provider, a.State.Model); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug("Selected model from state", "provider", provider.ID, "model", model.ID)
- } else {
- slog.Debug("State model not found", "provider", a.State.Provider, "model", a.State.Model)
- }
- }
-
- // Priority 6: Internal priority fallback (Anthropic preferred, then first available)
- if selectedProvider == nil {
- // Try Anthropic first as internal priority
- if provider := findProviderByID(providers, "anthropic"); provider != nil {
- if model := getDefaultModel(providersResponse, *provider); model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from internal priority (Anthropic)",
- "provider",
- provider.ID,
- "model",
- model.ID,
- )
- }
- }
-
- // If Anthropic not available, use first available provider
- if selectedProvider == nil && len(providers) > 0 {
- provider := &providers[0]
- if model := getDefaultModel(providersResponse, *provider); model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from fallback (first available)",
- "provider",
- provider.ID,
- "model",
- model.ID,
- )
- }
- }
- }
-
- // Final safety check
- if selectedProvider == nil || selectedModel == nil {
- slog.Error("Failed to select any model")
- return nil
- }
-
- var cmds []tea.Cmd
- cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
- Provider: *selectedProvider,
- Model: *selectedModel,
- }))
-
- // Load initial session if provided
- if a.InitialSession != nil && *a.InitialSession != "" {
- cmds = append(cmds, func() tea.Msg {
- // Find the session by ID
- sessions, err := a.ListSessions(context.Background())
- if err != nil {
- slog.Error("Failed to list sessions for initial session", "error", err)
- return toast.NewErrorToast("Failed to load initial session")()
- }
-
- for _, session := range sessions {
- if session.ID == *a.InitialSession {
- return SessionSelectedMsg(&session)
- }
- }
-
- slog.Warn("Initial session not found", "sessionID", *a.InitialSession)
- return toast.NewErrorToast("Session not found: " + *a.InitialSession)()
- })
- }
-
- if a.InitialPrompt != nil && *a.InitialPrompt != "" {
- cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
- }
- return tea.Sequence(cmds...)
-}
-
-func getDefaultModel(
- response *opencode.AppProvidersResponse,
- provider opencode.Provider,
-) *opencode.Model {
- if match, ok := response.Default[provider.ID]; ok {
- model := provider.Models[match]
- return &model
- } else {
- for _, model := range provider.Models {
- return &model
- }
- }
- return nil
-}
-
-func (a *App) IsBusy() bool {
- if len(a.Messages) == 0 {
- return false
- }
- if a.IsCompacting() {
- return true
- }
- lastMessage := a.Messages[len(a.Messages)-1]
- if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
- return casted.Time.Completed == 0
- }
- return false
-}
-
-func (a *App) IsCompacting() bool {
- if time.Since(time.UnixMilli(int64(a.Session.Time.Compacting))) < time.Second*30 {
- return true
- }
- return false
-}
-
-func (a *App) HasAnimatingWork() bool {
- for _, msg := range a.Messages {
- switch casted := msg.Info.(type) {
- case opencode.AssistantMessage:
- if casted.Time.Completed == 0 {
- return true
- }
- }
- for _, p := range msg.Parts {
- if tp, ok := p.(opencode.ToolPart); ok {
- if tp.State.Status == opencode.ToolPartStateStatusPending {
- return true
- }
- }
- }
- }
- return false
-}
-
-func (a *App) SaveState() tea.Cmd {
- return func() tea.Msg {
- err := SaveState(a.StatePath, a.State)
- if err != nil {
- slog.Error("Failed to save state", "error", err)
- }
- return nil
- }
-}
-
-func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
- cmds := []tea.Cmd{}
-
- session, err := a.CreateSession(ctx)
- if err != nil {
- // status.Error(err.Error())
- return nil
- }
-
- a.Session = session
- cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
-
- go func() {
- _, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
- MessageID: opencode.F(id.Ascending(id.Message)),
- ProviderID: opencode.F(a.Provider.ID),
- ModelID: opencode.F(a.Model.ID),
- })
- if err != nil {
- slog.Error("Failed to initialize project", "error", err)
- // status.Error(err.Error())
- }
- }()
-
- return tea.Batch(cmds...)
-}
-
-func (a *App) CompactSession(ctx context.Context) tea.Cmd {
- if a.compactCancel != nil {
- a.compactCancel()
- }
-
- compactCtx, cancel := context.WithCancel(ctx)
- a.compactCancel = cancel
-
- go func() {
- defer func() {
- a.compactCancel = nil
- }()
-
- _, err := a.Client.Session.Summarize(
- compactCtx,
- a.Session.ID,
- opencode.SessionSummarizeParams{
- ProviderID: opencode.F(a.Provider.ID),
- ModelID: opencode.F(a.Model.ID),
- },
- )
- if err != nil {
- if compactCtx.Err() != context.Canceled {
- slog.Error("Failed to compact session", "error", err)
- }
- }
- }()
- return nil
-}
-
-func (a *App) MarkProjectInitialized(ctx context.Context) error {
- return nil
- /*
- _, err := a.Client.App.Init(ctx)
- if err != nil {
- slog.Error("Failed to mark project as initialized", "error", err)
- return err
- }
- return nil
- */
-}
-
-func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
- session, err := a.Client.Session.New(ctx, opencode.SessionNewParams{})
- if err != nil {
- return nil, err
- }
- return session, nil
-}
-
-func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
- var cmds []tea.Cmd
- if a.Session.ID == "" {
- session, err := a.CreateSession(ctx)
- if err != nil {
- return a, toast.NewErrorToast(err.Error())
- }
- a.Session = session
- cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
- }
-
- messageID := id.Ascending(id.Message)
- message := prompt.ToMessage(messageID, a.Session.ID)
-
- a.Messages = append(a.Messages, message)
-
- cmds = append(cmds, func() tea.Msg {
- _, err := a.Client.Session.Prompt(ctx, a.Session.ID, opencode.SessionPromptParams{
- Model: opencode.F(opencode.SessionPromptParamsModel{
- ProviderID: opencode.F(a.Provider.ID),
- ModelID: opencode.F(a.Model.ID),
- }),
- Agent: opencode.F(a.Agent().Name),
- MessageID: opencode.F(messageID),
- Parts: opencode.F(message.ToSessionChatParams()),
- })
- if err != nil {
- errormsg := fmt.Sprintf("failed to send message: %v", err)
- slog.Error(errormsg)
- return toast.NewErrorToast(errormsg)()
- }
- return nil
- })
-
- // The actual response will come through SSE
- // For now, just return success
- return a, tea.Batch(cmds...)
-}
-
-func (a *App) SendCommand(ctx context.Context, command string, args string) (*App, tea.Cmd) {
- var cmds []tea.Cmd
- if a.Session.ID == "" {
- session, err := a.CreateSession(ctx)
- if err != nil {
- return a, toast.NewErrorToast(err.Error())
- }
- a.Session = session
- cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
- }
-
- cmds = append(cmds, func() tea.Msg {
- params := opencode.SessionCommandParams{
- Command: opencode.F(command),
- Arguments: opencode.F(args),
- Agent: opencode.F(a.Agents[a.AgentIndex].Name),
- }
- if a.Provider != nil && a.Model != nil {
- params.Model = opencode.F(a.Provider.ID + "/" + a.Model.ID)
- }
- _, err := a.Client.Session.Command(
- context.Background(),
- a.Session.ID,
- params,
- )
- if err != nil {
- slog.Error("Failed to execute command", "error", err)
- return toast.NewErrorToast(fmt.Sprintf("Failed to execute command: %v", err))()
- }
- return nil
- })
-
- // The actual response will come through SSE
- // For now, just return success
- return a, tea.Batch(cmds...)
-}
-
-func (a *App) SendShell(ctx context.Context, command string) (*App, tea.Cmd) {
- var cmds []tea.Cmd
- if a.Session.ID == "" {
- session, err := a.CreateSession(ctx)
- if err != nil {
- return a, toast.NewErrorToast(err.Error())
- }
- a.Session = session
- cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
- }
-
- cmds = append(cmds, func() tea.Msg {
- _, err := a.Client.Session.Shell(
- context.Background(),
- a.Session.ID,
- opencode.SessionShellParams{
- Agent: opencode.F(a.Agent().Name),
- Command: opencode.F(command),
- },
- )
- if err != nil {
- slog.Error("Failed to submit shell command", "error", err)
- return toast.NewErrorToast(fmt.Sprintf("Failed to submit shell command: %v", err))()
- }
- return nil
- })
-
- // The actual response will come through SSE
- // For now, just return success
- return a, tea.Batch(cmds...)
-}
-
-func (a *App) Cancel(ctx context.Context, sessionID string) error {
- // Cancel any running compact operation
- if a.compactCancel != nil {
- a.compactCancel()
- a.compactCancel = nil
- }
-
- _, err := a.Client.Session.Abort(ctx, sessionID, opencode.SessionAbortParams{})
- if err != nil {
- slog.Error("Failed to cancel session", "error", err)
- return err
- }
- return nil
-}
-
-func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
- response, err := a.Client.Session.List(ctx, opencode.SessionListParams{})
- if err != nil {
- return nil, err
- }
- if response == nil {
- return []opencode.Session{}, nil
- }
- sessions := *response
- return sessions, nil
-}
-
-func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
- _, err := a.Client.Session.Delete(ctx, sessionID, opencode.SessionDeleteParams{})
- if err != nil {
- slog.Error("Failed to delete session", "error", err)
- return err
- }
- return nil
-}
-
-func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) error {
- _, err := a.Client.Session.Update(ctx, sessionID, opencode.SessionUpdateParams{
- Title: opencode.F(title),
- })
- if err != nil {
- slog.Error("Failed to update session", "error", err)
- return err
- }
- return nil
-}
-
-func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
- response, err := a.Client.Session.Messages(ctx, sessionId, opencode.SessionMessagesParams{})
- if err != nil {
- return nil, err
- }
- if response == nil {
- return []Message{}, nil
- }
- messages := []Message{}
- for _, message := range *response {
- msg := Message{
- Info: message.Info.AsUnion(),
- Parts: []opencode.PartUnion{},
- }
- for _, part := range message.Parts {
- msg.Parts = append(msg.Parts, part.AsUnion())
- }
- messages = append(messages, msg)
- }
- return messages, nil
-}
-
-func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
- response, err := a.Client.App.Providers(ctx, opencode.AppProvidersParams{})
- if err != nil {
- return nil, err
- }
- if response == nil {
- return []opencode.Provider{}, nil
- }
-
- providers := *response
- return providers.Providers, nil
-}
-
-// func (a *App) loadCustomKeybinds() {
-//
-// }
diff --git a/packages/tui/internal/app/app_test.go b/packages/tui/internal/app/app_test.go
deleted file mode 100644
index e716d4376..000000000
--- a/packages/tui/internal/app/app_test.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package app
-
-import (
- "testing"
-
- "github.com/sst/opencode-sdk-go"
-)
-
-// TestFindModelByFullID tests the findModelByFullID function
-func TestFindModelByFullID(t *testing.T) {
- // Create test providers with models
- providers := []opencode.Provider{
- {
- ID: "anthropic",
- Models: map[string]opencode.Model{
- "claude-3-opus-20240229": {ID: "claude-3-opus-20240229"},
- "claude-3-sonnet-20240229": {ID: "claude-3-sonnet-20240229"},
- },
- },
- {
- ID: "openai",
- Models: map[string]opencode.Model{
- "gpt-4": {ID: "gpt-4"},
- "gpt-3.5-turbo": {ID: "gpt-3.5-turbo"},
- },
- },
- }
-
- tests := []struct {
- name string
- fullModelID string
- expectedFound bool
- expectedProviderID string
- expectedModelID string
- }{
- {
- name: "valid full model ID",
- fullModelID: "anthropic/claude-3-opus-20240229",
- expectedFound: true,
- expectedProviderID: "anthropic",
- expectedModelID: "claude-3-opus-20240229",
- },
- {
- name: "valid full model ID with slash in model name",
- fullModelID: "openai/gpt-3.5-turbo",
- expectedFound: true,
- expectedProviderID: "openai",
- expectedModelID: "gpt-3.5-turbo",
- },
- {
- name: "invalid format - missing slash",
- fullModelID: "anthropic",
- expectedFound: false,
- },
- {
- name: "invalid format - empty string",
- fullModelID: "",
- expectedFound: false,
- },
- {
- name: "provider not found",
- fullModelID: "nonexistent/model",
- expectedFound: false,
- },
- {
- name: "model not found",
- fullModelID: "anthropic/nonexistent-model",
- expectedFound: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- provider, model := findModelByFullID(providers, tt.fullModelID)
-
- if tt.expectedFound {
- if provider == nil || model == nil {
- t.Errorf("Expected to find provider/model, but got nil")
- return
- }
-
- if provider.ID != tt.expectedProviderID {
- t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
- }
-
- if model.ID != tt.expectedModelID {
- t.Errorf("Expected model ID %s, got %s", tt.expectedModelID, model.ID)
- }
- } else {
- if provider != nil || model != nil {
- t.Errorf("Expected not to find provider/model, but got provider: %v, model: %v", provider, model)
- }
- }
- })
- }
-}
-
-// TestFindModelByProviderAndModelID tests the findModelByProviderAndModelID function
-func TestFindModelByProviderAndModelID(t *testing.T) {
- // Create test providers with models
- providers := []opencode.Provider{
- {
- ID: "anthropic",
- Models: map[string]opencode.Model{
- "claude-3-opus-20240229": {ID: "claude-3-opus-20240229"},
- "claude-3-sonnet-20240229": {ID: "claude-3-sonnet-20240229"},
- },
- },
- {
- ID: "openai",
- Models: map[string]opencode.Model{
- "gpt-4": {ID: "gpt-4"},
- "gpt-3.5-turbo": {ID: "gpt-3.5-turbo"},
- },
- },
- }
-
- tests := []struct {
- name string
- providerID string
- modelID string
- expectedFound bool
- expectedProviderID string
- expectedModelID string
- }{
- {
- name: "valid provider and model",
- providerID: "anthropic",
- modelID: "claude-3-opus-20240229",
- expectedFound: true,
- expectedProviderID: "anthropic",
- expectedModelID: "claude-3-opus-20240229",
- },
- {
- name: "provider not found",
- providerID: "nonexistent",
- modelID: "claude-3-opus-20240229",
- expectedFound: false,
- },
- {
- name: "model not found",
- providerID: "anthropic",
- modelID: "nonexistent-model",
- expectedFound: false,
- },
- {
- name: "both provider and model not found",
- providerID: "nonexistent",
- modelID: "nonexistent-model",
- expectedFound: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- provider, model := findModelByProviderAndModelID(providers, tt.providerID, tt.modelID)
-
- if tt.expectedFound {
- if provider == nil || model == nil {
- t.Errorf("Expected to find provider/model, but got nil")
- return
- }
-
- if provider.ID != tt.expectedProviderID {
- t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
- }
-
- if model.ID != tt.expectedModelID {
- t.Errorf("Expected model ID %s, got %s", tt.expectedModelID, model.ID)
- }
- } else {
- if provider != nil || model != nil {
- t.Errorf("Expected not to find provider/model, but got provider: %v, model: %v", provider, model)
- }
- }
- })
- }
-}
-
-// TestFindProviderByID tests the findProviderByID function
-func TestFindProviderByID(t *testing.T) {
- // Create test providers
- providers := []opencode.Provider{
- {ID: "anthropic"},
- {ID: "openai"},
- {ID: "google"},
- }
-
- tests := []struct {
- name string
- providerID string
- expectedFound bool
- expectedProviderID string
- }{
- {
- name: "provider found",
- providerID: "anthropic",
- expectedFound: true,
- expectedProviderID: "anthropic",
- },
- {
- name: "provider not found",
- providerID: "nonexistent",
- expectedFound: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- provider := findProviderByID(providers, tt.providerID)
-
- if tt.expectedFound {
- if provider == nil {
- t.Errorf("Expected to find provider, but got nil")
- return
- }
-
- if provider.ID != tt.expectedProviderID {
- t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
- }
- } else {
- if provider != nil {
- t.Errorf("Expected not to find provider, but got %v", provider)
- }
- }
- })
- }
-}
-
-// TestModelSelectionPriority tests the priority order for model selection
-func TestModelSelectionPriority(t *testing.T) {
- providers := []opencode.Provider{
- {
- ID: "anthropic",
- Models: map[string]opencode.Model{
- "claude-opus": {ID: "claude-opus"},
- },
- },
- {
- ID: "openai",
- Models: map[string]opencode.Model{
- "gpt-4": {ID: "gpt-4"},
- },
- },
- }
-
- tests := []struct {
- name string
- agentProviderID string
- agentModelID string
- configModel string
- expectedProviderID string
- expectedModelID string
- description string
- }{
- {
- name: "agent model takes priority over config",
- agentProviderID: "openai",
- agentModelID: "gpt-4",
- configModel: "anthropic/claude-opus",
- expectedProviderID: "openai",
- expectedModelID: "gpt-4",
- description: "When agent specifies a model, it should be used even if config has a different model",
- },
- {
- name: "config model used when agent has no model",
- agentProviderID: "",
- agentModelID: "",
- configModel: "anthropic/claude-opus",
- expectedProviderID: "anthropic",
- expectedModelID: "claude-opus",
- description: "When agent has no model specified, config model should be used as fallback",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var selectedProvider *opencode.Provider
- var selectedModel *opencode.Model
-
- // Simulate priority 2: Agent model check
- if tt.agentModelID != "" {
- selectedProvider, selectedModel = findModelByProviderAndModelID(providers, tt.agentProviderID, tt.agentModelID)
- }
-
- // Simulate priority 3: Config model fallback
- if selectedProvider == nil && tt.configModel != "" {
- selectedProvider, selectedModel = findModelByFullID(providers, tt.configModel)
- }
-
- if selectedProvider == nil || selectedModel == nil {
- t.Fatalf("Expected to find model, but got nil - %s", tt.description)
- }
-
- if selectedProvider.ID != tt.expectedProviderID {
- t.Errorf("Expected provider %s, got %s - %s", tt.expectedProviderID, selectedProvider.ID, tt.description)
- }
-
- if selectedModel.ID != tt.expectedModelID {
- t.Errorf("Expected model %s, got %s - %s", tt.expectedModelID, selectedModel.ID, tt.description)
- }
- })
- }
-}
diff --git a/packages/tui/internal/app/prompt.go b/packages/tui/internal/app/prompt.go
deleted file mode 100644
index bd5086a45..000000000
--- a/packages/tui/internal/app/prompt.go
+++ /dev/null
@@ -1,283 +0,0 @@
-package app
-
-import (
- "errors"
- "time"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/attachment"
- "github.com/sst/opencode/internal/id"
-)
-
-type Prompt struct {
- Text string `toml:"text"`
- Attachments []*attachment.Attachment `toml:"attachments"`
-}
-
-func (p Prompt) ToMessage(
- messageID string,
- sessionID string,
-) Message {
- message := opencode.UserMessage{
- ID: messageID,
- SessionID: sessionID,
- Role: opencode.UserMessageRoleUser,
- Time: opencode.UserMessageTime{
- Created: float64(time.Now().UnixMilli()),
- },
- }
-
- text := p.Text
- textAttachments := []*attachment.Attachment{}
- for _, attachment := range p.Attachments {
- if attachment.Type == "text" {
- textAttachments = append(textAttachments, attachment)
- }
- }
- for i := 0; i < len(textAttachments)-1; i++ {
- for j := i + 1; j < len(textAttachments); j++ {
- if textAttachments[i].StartIndex < textAttachments[j].StartIndex {
- textAttachments[i], textAttachments[j] = textAttachments[j], textAttachments[i]
- }
- }
- }
- for _, att := range textAttachments {
- if source, ok := att.GetTextSource(); ok {
- if att.StartIndex > att.EndIndex || att.EndIndex > len(text) {
- continue
- }
- text = text[:att.StartIndex] + source.Value + text[att.EndIndex:]
- }
- }
-
- parts := []opencode.PartUnion{opencode.TextPart{
- ID: id.Ascending(id.Part),
- MessageID: messageID,
- SessionID: sessionID,
- Type: opencode.TextPartTypeText,
- Text: text,
- }}
- for _, attachment := range p.Attachments {
- if attachment.Type == "agent" {
- source, _ := attachment.GetAgentSource()
- parts = append(parts, opencode.AgentPart{
- ID: id.Ascending(id.Part),
- MessageID: messageID,
- SessionID: sessionID,
- Name: source.Name,
- Source: opencode.AgentPartSource{
- Value: attachment.Display,
- Start: int64(attachment.StartIndex),
- End: int64(attachment.EndIndex),
- },
- })
- continue
- }
-
- text := opencode.FilePartSourceText{
- Start: int64(attachment.StartIndex),
- End: int64(attachment.EndIndex),
- Value: attachment.Display,
- }
- source := &opencode.FilePartSource{}
- switch attachment.Type {
- case "text":
- continue
- case "file":
- if fileSource, ok := attachment.GetFileSource(); ok {
- source = &opencode.FilePartSource{
- Text: text,
- Path: fileSource.Path,
- Type: opencode.FilePartSourceTypeFile,
- }
- }
- case "symbol":
- if symbolSource, ok := attachment.GetSymbolSource(); ok {
- source = &opencode.FilePartSource{
- Text: text,
- Path: symbolSource.Path,
- Type: opencode.FilePartSourceTypeSymbol,
- Kind: int64(symbolSource.Kind),
- Name: symbolSource.Name,
- Range: opencode.SymbolSourceRange{
- Start: opencode.SymbolSourceRangeStart{
- Line: float64(symbolSource.Range.Start.Line),
- Character: float64(symbolSource.Range.Start.Char),
- },
- End: opencode.SymbolSourceRangeEnd{
- Line: float64(symbolSource.Range.End.Line),
- Character: float64(symbolSource.Range.End.Char),
- },
- },
- }
- }
- }
- parts = append(parts, opencode.FilePart{
- ID: id.Ascending(id.Part),
- MessageID: messageID,
- SessionID: sessionID,
- Type: opencode.FilePartTypeFile,
- Filename: attachment.Filename,
- Mime: attachment.MediaType,
- URL: attachment.URL,
- Source: *source,
- })
- }
- return Message{
- Info: message,
- Parts: parts,
- }
-}
-
-func (m Message) ToPrompt() (*Prompt, error) {
- switch m.Info.(type) {
- case opencode.UserMessage:
- text := ""
- attachments := []*attachment.Attachment{}
- for _, part := range m.Parts {
- switch p := part.(type) {
- case opencode.TextPart:
- if p.Synthetic {
- continue
- }
- text += p.Text + " "
- case opencode.AgentPart:
- attachments = append(attachments, &attachment.Attachment{
- ID: p.ID,
- Type: "agent",
- Display: p.Source.Value,
- StartIndex: int(p.Source.Start),
- EndIndex: int(p.Source.End),
- Source: &attachment.AgentSource{
- Name: p.Name,
- },
- })
- case opencode.FilePart:
- switch p.Source.Type {
- case "file":
- attachments = append(attachments, &attachment.Attachment{
- ID: p.ID,
- Type: "file",
- Display: p.Source.Text.Value,
- URL: p.URL,
- Filename: p.Filename,
- MediaType: p.Mime,
- StartIndex: int(p.Source.Text.Start),
- EndIndex: int(p.Source.Text.End),
- Source: &attachment.FileSource{
- Path: p.Source.Path,
- Mime: p.Mime,
- },
- })
- case "symbol":
- r := p.Source.Range.(opencode.SymbolSourceRange)
- attachments = append(attachments, &attachment.Attachment{
- ID: p.ID,
- Type: "symbol",
- Display: p.Source.Text.Value,
- URL: p.URL,
- Filename: p.Filename,
- MediaType: p.Mime,
- StartIndex: int(p.Source.Text.Start),
- EndIndex: int(p.Source.Text.End),
- Source: &attachment.SymbolSource{
- Path: p.Source.Path,
- Name: p.Source.Name,
- Kind: int(p.Source.Kind),
- Range: attachment.SymbolRange{
- Start: attachment.Position{
- Line: int(r.Start.Line),
- Char: int(r.Start.Character),
- },
- End: attachment.Position{
- Line: int(r.End.Line),
- Char: int(r.End.Character),
- },
- },
- },
- })
- }
- }
- }
- return &Prompt{
- Text: text,
- Attachments: attachments,
- }, nil
- }
- return nil, errors.New("unknown message type")
-}
-
-func (m Message) ToSessionChatParams() []opencode.SessionPromptParamsPartUnion {
- parts := []opencode.SessionPromptParamsPartUnion{}
- for _, part := range m.Parts {
- switch p := part.(type) {
- case opencode.TextPart:
- parts = append(parts, opencode.TextPartInputParam{
- ID: opencode.F(p.ID),
- Type: opencode.F(opencode.TextPartInputTypeText),
- Text: opencode.F(p.Text),
- Synthetic: opencode.F(p.Synthetic),
- Time: opencode.F(opencode.TextPartInputTimeParam{
- Start: opencode.F(p.Time.Start),
- End: opencode.F(p.Time.End),
- }),
- })
- case opencode.FilePart:
- var source opencode.FilePartSourceUnionParam
- switch p.Source.Type {
- case "file":
- source = opencode.FileSourceParam{
- Type: opencode.F(opencode.FileSourceTypeFile),
- Path: opencode.F(p.Source.Path),
- Text: opencode.F(opencode.FilePartSourceTextParam{
- Start: opencode.F(int64(p.Source.Text.Start)),
- End: opencode.F(int64(p.Source.Text.End)),
- Value: opencode.F(p.Source.Text.Value),
- }),
- }
- case "symbol":
- source = opencode.SymbolSourceParam{
- Type: opencode.F(opencode.SymbolSourceTypeSymbol),
- Path: opencode.F(p.Source.Path),
- Name: opencode.F(p.Source.Name),
- Kind: opencode.F(p.Source.Kind),
- Range: opencode.F(opencode.SymbolSourceRangeParam{
- Start: opencode.F(opencode.SymbolSourceRangeStartParam{
- Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Line)),
- Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Character)),
- }),
- End: opencode.F(opencode.SymbolSourceRangeEndParam{
- Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Line)),
- Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Character)),
- }),
- }),
- Text: opencode.F(opencode.FilePartSourceTextParam{
- Value: opencode.F(p.Source.Text.Value),
- Start: opencode.F(p.Source.Text.Start),
- End: opencode.F(p.Source.Text.End),
- }),
- }
- }
- parts = append(parts, opencode.FilePartInputParam{
- ID: opencode.F(p.ID),
- Type: opencode.F(opencode.FilePartInputTypeFile),
- Mime: opencode.F(p.Mime),
- URL: opencode.F(p.URL),
- Filename: opencode.F(p.Filename),
- Source: opencode.F(source),
- })
- case opencode.AgentPart:
- parts = append(parts, opencode.AgentPartInputParam{
- ID: opencode.F(p.ID),
- Type: opencode.F(opencode.AgentPartInputTypeAgent),
- Name: opencode.F(p.Name),
- Source: opencode.F(opencode.AgentPartInputSourceParam{
- Value: opencode.F(p.Source.Value),
- Start: opencode.F(p.Source.Start),
- End: opencode.F(p.Source.End),
- }),
- })
- }
- }
- return parts
-}
diff --git a/packages/tui/internal/app/state.go b/packages/tui/internal/app/state.go
deleted file mode 100644
index cc65eea5e..000000000
--- a/packages/tui/internal/app/state.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package app
-
-import (
- "bufio"
- "fmt"
- "log/slog"
- "os"
- "time"
-
- "github.com/BurntSushi/toml"
-)
-
-type ModelUsage struct {
- ProviderID string `toml:"provider_id"`
- ModelID string `toml:"model_id"`
- LastUsed time.Time `toml:"last_used"`
-}
-
-type AgentUsage struct {
- AgentName string `toml:"agent_name"`
- LastUsed time.Time `toml:"last_used"`
-}
-
-type AgentModel struct {
- ProviderID string `toml:"provider_id"`
- ModelID string `toml:"model_id"`
-}
-
-type State struct {
- Theme string `toml:"theme"`
- AgentModel map[string]AgentModel `toml:"agent_model"`
- Provider string `toml:"provider"`
- Model string `toml:"model"`
- Agent string `toml:"agent"`
- RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
- RecentlyUsedAgents []AgentUsage `toml:"recently_used_agents"`
- MessageHistory []Prompt `toml:"message_history"`
- ShowToolDetails *bool `toml:"show_tool_details"`
- ShowThinkingBlocks *bool `toml:"show_thinking_blocks"`
-}
-
-func NewState() *State {
- return &State{
- Theme: "opencode",
- Agent: "build",
- AgentModel: make(map[string]AgentModel),
- RecentlyUsedModels: make([]ModelUsage, 0),
- RecentlyUsedAgents: make([]AgentUsage, 0),
- MessageHistory: make([]Prompt, 0),
- }
-}
-
-// UpdateModelUsage updates the recently used models list with the specified model
-func (s *State) UpdateModelUsage(providerID, modelID string) {
- now := time.Now()
-
- // Check if this model is already in the list
- for i, usage := range s.RecentlyUsedModels {
- if usage.ProviderID == providerID && usage.ModelID == modelID {
- s.RecentlyUsedModels[i].LastUsed = now
- usage := s.RecentlyUsedModels[i]
- copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
- s.RecentlyUsedModels[0] = usage
- return
- }
- }
-
- newUsage := ModelUsage{
- ProviderID: providerID,
- ModelID: modelID,
- LastUsed: now,
- }
-
- // Prepend to slice and limit to last 50 entries
- s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
- if len(s.RecentlyUsedModels) > 50 {
- s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
- }
-}
-
-func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
- for i, usage := range s.RecentlyUsedModels {
- if usage.ProviderID == providerID && usage.ModelID == modelID {
- s.RecentlyUsedModels = append(s.RecentlyUsedModels[:i], s.RecentlyUsedModels[i+1:]...)
- return
- }
- }
-}
-
-// UpdateAgentUsage updates the recently used agents list with the specified agent
-func (s *State) UpdateAgentUsage(agentName string) {
- now := time.Now()
-
- // Check if this agent is already in the list
- for i, usage := range s.RecentlyUsedAgents {
- if usage.AgentName == agentName {
- s.RecentlyUsedAgents[i].LastUsed = now
- usage := s.RecentlyUsedAgents[i]
- copy(s.RecentlyUsedAgents[1:i+1], s.RecentlyUsedAgents[0:i])
- s.RecentlyUsedAgents[0] = usage
- return
- }
- }
-
- newUsage := AgentUsage{
- AgentName: agentName,
- LastUsed: now,
- }
-
- // Prepend to slice and limit to last 20 entries
- s.RecentlyUsedAgents = append([]AgentUsage{newUsage}, s.RecentlyUsedAgents...)
- if len(s.RecentlyUsedAgents) > 20 {
- s.RecentlyUsedAgents = s.RecentlyUsedAgents[:20]
- }
-}
-
-func (s *State) RemoveAgentFromRecentlyUsed(agentName string) {
- for i, usage := range s.RecentlyUsedAgents {
- if usage.AgentName == agentName {
- s.RecentlyUsedAgents = append(s.RecentlyUsedAgents[:i], s.RecentlyUsedAgents[i+1:]...)
- return
- }
- }
-}
-
-func (s *State) AddPromptToHistory(prompt Prompt) {
- s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
- if len(s.MessageHistory) > 50 {
- s.MessageHistory = s.MessageHistory[:50]
- }
-}
-
-// SaveState writes the provided Config struct to the specified TOML file.
-// It will create the file if it doesn't exist, or overwrite it if it does.
-func SaveState(filePath string, state *State) error {
- file, err := os.Create(filePath)
- if err != nil {
- return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
- }
- defer file.Close()
-
- writer := bufio.NewWriter(file)
- encoder := toml.NewEncoder(writer)
- if err := encoder.Encode(state); err != nil {
- return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
- }
- if err := writer.Flush(); err != nil {
- return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
- }
-
- slog.Debug("State saved to file", "file", filePath)
- return nil
-}
-
-// LoadState loads the state from the specified TOML file.
-// It returns a pointer to the State struct and an error if any issues occur.
-func LoadState(filePath string) (*State, error) {
- var state State
- if _, err := toml.DecodeFile(filePath, &state); err != nil {
- if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
- return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
- }
- return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
- }
-
- // Restore attachment sources types that were deserialized as map[string]any
- for _, prompt := range state.MessageHistory {
- for _, att := range prompt.Attachments {
- att.RestoreSourceType()
- }
- }
-
- return &state, nil
-}
diff --git a/packages/tui/internal/attachment/attachment.go b/packages/tui/internal/attachment/attachment.go
deleted file mode 100644
index 3ecd86198..000000000
--- a/packages/tui/internal/attachment/attachment.go
+++ /dev/null
@@ -1,178 +0,0 @@
-package attachment
-
-import (
- "github.com/google/uuid"
-)
-
-type TextSource struct {
- Value string `toml:"value"`
-}
-
-type FileSource struct {
- Path string `toml:"path"`
- Mime string `toml:"mime"`
- Data []byte `toml:"data,omitempty"` // Optional for image data
-}
-
-type SymbolSource struct {
- Path string `toml:"path"`
- Name string `toml:"name"`
- Kind int `toml:"kind"`
- Range SymbolRange `toml:"range"`
-}
-
-type SymbolRange struct {
- Start Position `toml:"start"`
- End Position `toml:"end"`
-}
-
-type AgentSource struct {
- Name string `toml:"name"`
-}
-
-type Position struct {
- Line int `toml:"line"`
- Char int `toml:"char"`
-}
-
-type Attachment struct {
- ID string `toml:"id"`
- Type string `toml:"type"`
- Display string `toml:"display"`
- URL string `toml:"url"`
- Filename string `toml:"filename"`
- MediaType string `toml:"media_type"`
- StartIndex int `toml:"start_index"`
- EndIndex int `toml:"end_index"`
- Source any `toml:"source,omitempty"`
-}
-
-// NewAttachment creates a new attachment with a unique ID
-func NewAttachment() *Attachment {
- return &Attachment{
- ID: uuid.NewString(),
- }
-}
-
-func (a *Attachment) GetTextSource() (*TextSource, bool) {
- if a.Type != "text" {
- return nil, false
- }
- ts, ok := a.Source.(*TextSource)
- return ts, ok
-}
-
-// GetFileSource returns the source as FileSource if the attachment is a file type
-func (a *Attachment) GetFileSource() (*FileSource, bool) {
- if a.Type != "file" {
- return nil, false
- }
- fs, ok := a.Source.(*FileSource)
- return fs, ok
-}
-
-// GetSymbolSource returns the source as SymbolSource if the attachment is a symbol type
-func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
- if a.Type != "symbol" {
- return nil, false
- }
- ss, ok := a.Source.(*SymbolSource)
- return ss, ok
-}
-
-// GetAgentSource returns the source as AgentSource if the attachment is an agent type
-func (a *Attachment) GetAgentSource() (*AgentSource, bool) {
- if a.Type != "agent" {
- return nil, false
- }
- as, ok := a.Source.(*AgentSource)
- return as, ok
-}
-
-// FromMap creates a TextSource from a map[string]any
-func (ts *TextSource) FromMap(sourceMap map[string]any) {
- if value, ok := sourceMap["value"].(string); ok {
- ts.Value = value
- }
-}
-
-// FromMap creates a FileSource from a map[string]any
-func (fs *FileSource) FromMap(sourceMap map[string]any) {
- if path, ok := sourceMap["path"].(string); ok {
- fs.Path = path
- }
- if mime, ok := sourceMap["mime"].(string); ok {
- fs.Mime = mime
- }
- if data, ok := sourceMap["data"].([]byte); ok {
- fs.Data = data
- }
-}
-
-// FromMap creates a SymbolSource from a map[string]any
-func (ss *SymbolSource) FromMap(sourceMap map[string]any) {
- if path, ok := sourceMap["path"].(string); ok {
- ss.Path = path
- }
- if name, ok := sourceMap["name"].(string); ok {
- ss.Name = name
- }
- if kind, ok := sourceMap["kind"].(int); ok {
- ss.Kind = kind
- }
- if rangeMap, ok := sourceMap["range"].(map[string]any); ok {
- ss.Range = SymbolRange{}
- if startMap, ok := rangeMap["start"].(map[string]any); ok {
- if line, ok := startMap["line"].(int); ok {
- ss.Range.Start.Line = line
- }
- if char, ok := startMap["char"].(int); ok {
- ss.Range.Start.Char = char
- }
- }
- if endMap, ok := rangeMap["end"].(map[string]any); ok {
- if line, ok := endMap["line"].(int); ok {
- ss.Range.End.Line = line
- }
- if char, ok := endMap["char"].(int); ok {
- ss.Range.End.Char = char
- }
- }
- }
-}
-
-// FromMap creates an AgentSource from a map[string]any
-func (as *AgentSource) FromMap(sourceMap map[string]any) {
- if name, ok := sourceMap["name"].(string); ok {
- as.Name = name
- }
-}
-
-// RestoreSourceType converts a map[string]any source back to the proper type
-func (a *Attachment) RestoreSourceType() {
- if a.Source == nil {
- return
- }
-
- // Check if Source is a map[string]any
- if sourceMap, ok := a.Source.(map[string]any); ok {
- switch a.Type {
- case "text":
- ts := &TextSource{}
- ts.FromMap(sourceMap)
- a.Source = ts
- case "file":
- fs := &FileSource{}
- fs.FromMap(sourceMap)
- a.Source = fs
- case "symbol":
- ss := &SymbolSource{}
- ss.FromMap(sourceMap)
- a.Source = ss
- case "agent":
- as := &AgentSource{}
- as.FromMap(sourceMap)
- a.Source = as
- }
- }
-}
diff --git a/packages/tui/internal/clipboard/clipboard.go b/packages/tui/internal/clipboard/clipboard.go
deleted file mode 100644
index 70e05bd29..000000000
--- a/packages/tui/internal/clipboard/clipboard.go
+++ /dev/null
@@ -1,155 +0,0 @@
-// Copyright 2021 The golang.design Initiative Authors.
-// All rights reserved. Use of this source code is governed
-// by a MIT license that can be found in the LICENSE file.
-//
-// Written by Changkun Ou <changkun.de>
-
-/*
-Package clipboard provides cross platform clipboard access and supports
-macOS/Linux/Windows/Android/iOS platform. Before interacting with the
-clipboard, one must call Init to assert if it is possible to use this
-package:
-
- err := clipboard.Init()
- if err != nil {
- panic(err)
- }
-
-The most common operations are `Read` and `Write`. To use them:
-
- // write/read text format data of the clipboard, and
- // the byte buffer regarding the text are UTF8 encoded.
- clipboard.Write(clipboard.FmtText, []byte("text data"))
- clipboard.Read(clipboard.FmtText)
-
- // write/read image format data of the clipboard, and
- // the byte buffer regarding the image are PNG encoded.
- clipboard.Write(clipboard.FmtImage, []byte("image data"))
- clipboard.Read(clipboard.FmtImage)
-
-Note that read/write regarding image format assumes that the bytes are
-PNG encoded since it serves the alpha blending purpose that might be
-used in other graphical software.
-
-In addition, `clipboard.Write` returns a channel that can receive an
-empty struct as a signal, which indicates the corresponding write call
-to the clipboard is outdated, meaning the clipboard has been overwritten
-by others and the previously written data is lost. For instance:
-
- changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
-
- select {
- case <-changed:
- println(`"text data" is no longer available from clipboard.`)
- }
-
-You can ignore the returning channel if you don't need this type of
-notification. Furthermore, when you need more than just knowing whether
-clipboard data is changed, use the watcher API:
-
- ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
- for data := range ch {
- // print out clipboard data whenever it is changed
- println(string(data))
- }
-*/
-package clipboard
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "sync"
-)
-
-var (
- // activate only for running tests.
- debug = false
- errUnavailable = errors.New("clipboard unavailable")
- errUnsupported = errors.New("unsupported format")
- errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
-)
-
-// Format represents the format of clipboard data.
-type Format int
-
-// All sorts of supported clipboard data
-const (
- // FmtText indicates plain text clipboard format
- FmtText Format = iota
- // FmtImage indicates image/png clipboard format
- FmtImage
-)
-
-var (
- // Due to the limitation on operating systems (such as darwin),
- // concurrent read can even cause panic, use a global lock to
- // guarantee one read at a time.
- lock = sync.Mutex{}
- initOnce sync.Once
- initError error
-)
-
-// Init initializes the clipboard package. It returns an error
-// if the clipboard is not available to use. This may happen if the
-// target system lacks required dependency, such as libx11-dev in X11
-// environment. For example,
-//
-// err := clipboard.Init()
-// if err != nil {
-// panic(err)
-// }
-//
-// If Init returns an error, any subsequent Read/Write/Watch call
-// may result in an unrecoverable panic.
-func Init() error {
- initOnce.Do(func() {
- initError = initialize()
- })
- return initError
-}
-
-// Read returns a chunk of bytes of the clipboard data if it presents
-// in the desired format t presents. Otherwise, it returns nil.
-func Read(t Format) []byte {
- lock.Lock()
- defer lock.Unlock()
-
- buf, err := read(t)
- if err != nil {
- if debug {
- fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
- }
- return nil
- }
- return buf
-}
-
-// Write writes a given buffer to the clipboard in a specified format.
-// Write returned a receive-only channel can receive an empty struct
-// as a signal, which indicates the clipboard has been overwritten from
-// this write.
-// If format t indicates an image, then the given buf assumes
-// the image data is PNG encoded.
-func Write(t Format, buf []byte) <-chan struct{} {
- lock.Lock()
- defer lock.Unlock()
-
- changed, err := write(t, buf)
- if err != nil {
- if debug {
- fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
- }
- return nil
- }
- return changed
-}
-
-// Watch returns a receive-only channel that received the clipboard data
-// whenever any change of clipboard data in the desired format happens.
-//
-// The returned channel will be closed if the given context is canceled.
-func Watch(ctx context.Context, t Format) <-chan []byte {
- return watch(ctx, t)
-}
diff --git a/packages/tui/internal/clipboard/clipboard_darwin.go b/packages/tui/internal/clipboard/clipboard_darwin.go
deleted file mode 100644
index ead6811f1..000000000
--- a/packages/tui/internal/clipboard/clipboard_darwin.go
+++ /dev/null
@@ -1,266 +0,0 @@
-// Copyright 2021 The golang.design Initiative Authors.
-// All rights reserved. Use of this source code is governed
-// by a MIT license that can be found in the LICENSE file.
-//
-// Written by Changkun Ou <changkun.de>
-
-//go:build darwin
-
-package clipboard
-
-import (
- "bytes"
- "context"
- "fmt"
- "os"
- "os/exec"
- "strconv"
- "strings"
- "sync"
- "time"
-)
-
-var (
- lastChangeCount int64
- changeCountMu sync.Mutex
-)
-
-func initialize() error { return nil }
-
-func read(t Format) (buf []byte, err error) {
- switch t {
- case FmtText:
- return readText()
- case FmtImage:
- return readImage()
- default:
- return nil, errUnsupported
- }
-}
-
-func readText() ([]byte, error) {
- // Check if clipboard contains string data
- checkScript := `
- try
- set clipboardTypes to (clipboard info)
- repeat with aType in clipboardTypes
- if (first item of aType) is string then
- return "hastext"
- end if
- end repeat
- return "notext"
- on error
- return "error"
- end try
- `
-
- cmd := exec.Command("osascript", "-e", checkScript)
- checkOut, err := cmd.Output()
- if err != nil {
- return nil, errUnavailable
- }
-
- checkOut = bytes.TrimSpace(checkOut)
- if !bytes.Equal(checkOut, []byte("hastext")) {
- return nil, errUnavailable
- }
-
- // Now get the actual text
- cmd = exec.Command("osascript", "-e", "get the clipboard")
- out, err := cmd.Output()
- if err != nil {
- return nil, errUnavailable
- }
- // Remove trailing newline that osascript adds
- out = bytes.TrimSuffix(out, []byte("\n"))
-
- // If clipboard was set to empty string, return nil
- if len(out) == 0 {
- return nil, nil
- }
- return out, nil
-}
-func readImage() ([]byte, error) {
- // AppleScript to read image data from clipboard as base64
- script := `
- try
- set theData to the clipboard as «class PNGf»
- return theData
- on error
- return ""
- end try
- `
-
- cmd := exec.Command("osascript", "-e", script)
- out, err := cmd.Output()
- if err != nil {
- return nil, errUnavailable
- }
-
- // Check if we got any data
- out = bytes.TrimSpace(out)
- if len(out) == 0 {
- return nil, errUnavailable
- }
-
- // The output is in hex format (e.g., «data PNGf89504E...»)
- // We need to extract and convert it
- outStr := string(out)
- if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
- return nil, errUnavailable
- }
-
- // Extract hex data
- hexData := strings.TrimPrefix(outStr, "«data PNGf")
- hexData = strings.TrimSuffix(hexData, "»")
-
- // Convert hex to bytes
- buf := make([]byte, len(hexData)/2)
- for i := 0; i < len(hexData); i += 2 {
- b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
- if err != nil {
- return nil, errUnavailable
- }
- buf[i/2] = byte(b)
- }
-
- return buf, nil
-}
-
-// write writes the given data to clipboard and
-// returns true if success or false if failed.
-func write(t Format, buf []byte) (<-chan struct{}, error) {
- var err error
- switch t {
- case FmtText:
- err = writeText(buf)
- case FmtImage:
- err = writeImage(buf)
- default:
- return nil, errUnsupported
- }
-
- if err != nil {
- return nil, err
- }
-
- // Update change count
- changeCountMu.Lock()
- lastChangeCount++
- currentCount := lastChangeCount
- changeCountMu.Unlock()
-
- // use unbuffered channel to prevent goroutine leak
- changed := make(chan struct{}, 1)
- go func() {
- for {
- time.Sleep(time.Second)
- changeCountMu.Lock()
- if lastChangeCount != currentCount {
- changeCountMu.Unlock()
- changed <- struct{}{}
- close(changed)
- return
- }
- changeCountMu.Unlock()
- }
- }()
- return changed, nil
-}
-
-func writeText(buf []byte) error {
- if len(buf) == 0 {
- // Clear clipboard
- script := `set the clipboard to ""`
- cmd := exec.Command("osascript", "-e", script)
- if err := cmd.Run(); err != nil {
- return errUnavailable
- }
- return nil
- }
-
- // Escape the text for AppleScript
- text := string(buf)
- text = strings.ReplaceAll(text, "\\", "\\\\")
- text = strings.ReplaceAll(text, "\"", "\\\"")
-
- script := fmt.Sprintf(`set the clipboard to "%s"`, text)
- cmd := exec.Command("osascript", "-e", script)
- if err := cmd.Run(); err != nil {
- return errUnavailable
- }
- return nil
-}
-func writeImage(buf []byte) error {
- if len(buf) == 0 {
- // Clear clipboard
- script := `set the clipboard to ""`
- cmd := exec.Command("osascript", "-e", script)
- if err := cmd.Run(); err != nil {
- return errUnavailable
- }
- return nil
- }
-
- // Create a temporary file to store the PNG data
- tmpFile, err := os.CreateTemp("", "clipboard*.png")
- if err != nil {
- return errUnavailable
- }
- defer os.Remove(tmpFile.Name())
-
- if _, err := tmpFile.Write(buf); err != nil {
- tmpFile.Close()
- return errUnavailable
- }
- tmpFile.Close()
-
- // Use osascript to set clipboard to the image file
- script := fmt.Sprintf(`
- set theFile to POSIX file "%s"
- set theImage to read theFile as «class PNGf»
- set the clipboard to theImage
- `, tmpFile.Name())
-
- cmd := exec.Command("osascript", "-e", script)
- if err := cmd.Run(); err != nil {
- return errUnavailable
- }
- return nil
-}
-func watch(ctx context.Context, t Format) <-chan []byte {
- recv := make(chan []byte, 1)
- ti := time.NewTicker(time.Second)
-
- // Get initial clipboard content
- var lastContent []byte
- if b := Read(t); b != nil {
- lastContent = make([]byte, len(b))
- copy(lastContent, b)
- }
-
- go func() {
- defer close(recv)
- defer ti.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ti.C:
- b := Read(t)
- if b == nil {
- continue
- }
-
- // Check if content changed
- if !bytes.Equal(lastContent, b) {
- recv <- b
- lastContent = make([]byte, len(b))
- copy(lastContent, b)
- }
- }
- }
- }()
- return recv
-}
diff --git a/packages/tui/internal/clipboard/clipboard_linux.go b/packages/tui/internal/clipboard/clipboard_linux.go
deleted file mode 100644
index 101906395..000000000
--- a/packages/tui/internal/clipboard/clipboard_linux.go
+++ /dev/null
@@ -1,311 +0,0 @@
-// Copyright 2021 The golang.design Initiative Authors.
-// All rights reserved. Use of this source code is governed
-// by a MIT license that can be found in the LICENSE file.
-//
-// Written by Changkun Ou <changkun.de>
-
-//go:build linux
-
-package clipboard
-
-import (
- "bytes"
- "context"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "strings"
- "sync"
- "time"
-)
-
-var (
- // Clipboard tools in order of preference
- clipboardTools = []struct {
- name string
- readCmd []string
- writeCmd []string
- readImg []string
- writeImg []string
- available bool
- }{
- {
- name: "xclip",
- readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
- writeCmd: []string{"xclip", "-selection", "clipboard"},
- readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
- writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
- },
- {
- name: "xsel",
- readCmd: []string{"xsel", "--clipboard", "--output"},
- writeCmd: []string{"xsel", "--clipboard", "--input"},
- readImg: []string{"xsel", "--clipboard", "--output"},
- writeImg: []string{"xsel", "--clipboard", "--input"},
- },
- {
- name: "wl-copy",
- readCmd: []string{"wl-paste", "-n"},
- writeCmd: []string{"wl-copy"},
- readImg: []string{"wl-paste", "-t", "image/png", "-n"},
- writeImg: []string{"wl-copy", "-t", "image/png"},
- },
- }
-
- selectedTool int = -1
- toolMutex sync.Mutex
- lastChangeTime time.Time
- changeTimeMu sync.Mutex
-)
-
-func initialize() error {
- toolMutex.Lock()
- defer toolMutex.Unlock()
-
- if selectedTool >= 0 {
- return nil // Already initialized
- }
-
- order := []string{"xclip", "xsel", "wl-copy"}
- if os.Getenv("WAYLAND_DISPLAY") != "" {
- order = []string{"wl-copy", "xclip", "xsel"}
- }
-
- for _, name := range order {
- for i, tool := range clipboardTools {
- if tool.name == name {
- cmd := exec.Command("which", tool.name)
- if err := cmd.Run(); err == nil {
- clipboardTools[i].available = true
- if selectedTool < 0 {
- selectedTool = i
- slog.Debug("Clipboard tool found", "tool", tool.name)
- }
- }
- break
- }
- }
- }
-
- if selectedTool < 0 {
- slog.Warn(
- "No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
- )
- return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
-
-For X11 systems:
- apt install -y xclip
- # or
- apt install -y xsel
-
-For Wayland systems:
- apt install -y wl-clipboard
-
-If running in a headless environment, you may also need:
- apt install -y xvfb
- # and run:
- Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
- export DISPLAY=:99.0`, errUnavailable)
- }
-
- return nil
-}
-
-func read(t Format) (buf []byte, err error) {
- // Ensure clipboard is initialized before attempting to read
- if err := initialize(); err != nil {
- slog.Debug("Clipboard read failed: not initialized", "error", err)
- return nil, err
- }
-
- toolMutex.Lock()
- tool := clipboardTools[selectedTool]
- toolMutex.Unlock()
-
- switch t {
- case FmtText:
- return readText(tool)
- case FmtImage:
- return readImage(tool)
- default:
- return nil, errUnsupported
- }
-}
-
-func readText(tool struct {
- name string
- readCmd []string
- writeCmd []string
- readImg []string
- writeImg []string
- available bool
-}) ([]byte, error) {
- // First check if clipboard contains text
- cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
- out, err := cmd.Output()
- if err != nil {
- // Check if it's because clipboard contains non-text data
- if tool.name == "xclip" {
- // xclip returns error when clipboard doesn't contain requested type
- checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
- targets, _ := checkCmd.Output()
- if bytes.Contains(targets, []byte("image/png")) &&
- !bytes.Contains(targets, []byte("UTF8_STRING")) {
- return nil, errUnavailable
- }
- }
- return nil, errUnavailable
- }
-
- return out, nil
-}
-
-func readImage(tool struct {
- name string
- readCmd []string
- writeCmd []string
- readImg []string
- writeImg []string
- available bool
-}) ([]byte, error) {
- if tool.name == "xsel" {
- // xsel doesn't support image types well, return error
- return nil, errUnavailable
- }
-
- cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
- out, err := cmd.Output()
- if err != nil {
- return nil, errUnavailable
- }
-
- // Verify it's PNG data
- if len(out) < 8 ||
- !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
- return nil, errUnavailable
- }
-
- return out, nil
-}
-
-func write(t Format, buf []byte) (<-chan struct{}, error) {
- // Ensure clipboard is initialized before attempting to write
- if err := initialize(); err != nil {
- return nil, err
- }
-
- toolMutex.Lock()
- tool := clipboardTools[selectedTool]
- toolMutex.Unlock()
-
- var cmd *exec.Cmd
- switch t {
- case FmtText:
- if len(buf) == 0 {
- // Write empty string
- cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
- cmd.Stdin = bytes.NewReader([]byte{})
- } else {
- cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
- cmd.Stdin = bytes.NewReader(buf)
- }
- case FmtImage:
- if tool.name == "xsel" {
- // xsel doesn't support image types well
- return nil, errUnavailable
- }
- if len(buf) == 0 {
- // Clear clipboard
- cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
- cmd.Stdin = bytes.NewReader([]byte{})
- } else {
- cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
- cmd.Stdin = bytes.NewReader(buf)
- }
- default:
- return nil, errUnsupported
- }
-
- if err := cmd.Run(); err != nil {
- return nil, errUnavailable
- }
-
- // Update change time
- changeTimeMu.Lock()
- lastChangeTime = time.Now()
- currentTime := lastChangeTime
- changeTimeMu.Unlock()
-
- // Create change notification channel
- changed := make(chan struct{}, 1)
- go func() {
- for {
- time.Sleep(time.Second)
- changeTimeMu.Lock()
- if !lastChangeTime.Equal(currentTime) {
- changeTimeMu.Unlock()
- changed <- struct{}{}
- close(changed)
- return
- }
- changeTimeMu.Unlock()
- }
- }()
-
- return changed, nil
-}
-
-func watch(ctx context.Context, t Format) <-chan []byte {
- recv := make(chan []byte, 1)
-
- // Ensure clipboard is initialized before starting watch
- if err := initialize(); err != nil {
- close(recv)
- return recv
- }
-
- ti := time.NewTicker(time.Second)
-
- // Get initial clipboard content
- var lastContent []byte
- if b := Read(t); b != nil {
- lastContent = make([]byte, len(b))
- copy(lastContent, b)
- }
-
- go func() {
- defer close(recv)
- defer ti.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ti.C:
- b := Read(t)
- if b == nil {
- continue
- }
-
- // Check if content changed
- if !bytes.Equal(lastContent, b) {
- recv <- b
- lastContent = make([]byte, len(b))
- copy(lastContent, b)
- }
- }
- }
- }()
- return recv
-}
-
-// Helper function to check clipboard content type for xclip
-func getClipboardTargets() []string {
- cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
- out, err := cmd.Output()
- if err != nil {
- return nil
- }
- return strings.Split(string(out), "\n")
-}
diff --git a/packages/tui/internal/clipboard/clipboard_nocgo.go b/packages/tui/internal/clipboard/clipboard_nocgo.go
deleted file mode 100644
index 7b3e05f6c..000000000
--- a/packages/tui/internal/clipboard/clipboard_nocgo.go
+++ /dev/null
@@ -1,25 +0,0 @@
-//go:build !windows && !darwin && !linux && !cgo
-
-package clipboard
-
-import "context"
-
-func initialize() error {
- return errNoCgo
-}
-
-func read(t Format) (buf []byte, err error) {
- panic("clipboard: cannot use when CGO_ENABLED=0")
-}
-
-func readc(t string) ([]byte, error) {
- panic("clipboard: cannot use when CGO_ENABLED=0")
-}
-
-func write(t Format, buf []byte) (<-chan struct{}, error) {
- panic("clipboard: cannot use when CGO_ENABLED=0")
-}
-
-func watch(ctx context.Context, t Format) <-chan []byte {
- panic("clipboard: cannot use when CGO_ENABLED=0")
-}
diff --git a/packages/tui/internal/clipboard/clipboard_windows.go b/packages/tui/internal/clipboard/clipboard_windows.go
deleted file mode 100644
index 09fc14169..000000000
--- a/packages/tui/internal/clipboard/clipboard_windows.go
+++ /dev/null
@@ -1,551 +0,0 @@
-// Copyright 2021 The golang.design Initiative Authors.
-// All rights reserved. Use of this source code is governed
-// by a MIT license that can be found in the LICENSE file.
-//
-// Written by Changkun Ou <changkun.de>
-
-//go:build windows
-
-package clipboard
-
-// Interacting with Clipboard on Windows:
-// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
-
-import (
- "bytes"
- "context"
- "encoding/binary"
- "errors"
- "fmt"
- "image"
- "image/color"
- "image/png"
- "reflect"
- "runtime"
- "syscall"
- "time"
- "unicode/utf16"
- "unsafe"
-
- "golang.org/x/image/bmp"
-)
-
-func initialize() error { return nil }
-
-// readText reads the clipboard and returns the text data if presents.
-// The caller is responsible for opening/closing the clipboard before
-// calling this function.
-func readText() (buf []byte, err error) {
- hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
- if hMem == 0 {
- return nil, err
- }
- p, _, err := gLock.Call(hMem)
- if p == 0 {
- return nil, err
- }
- defer gUnlock.Call(hMem)
-
- // Find NUL terminator
- n := 0
- for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
- ptr = unsafe.Pointer(uintptr(ptr) +
- unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
- }
-
- var s []uint16
- h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
- h.Data = p
- h.Len = n
- h.Cap = n
- return []byte(string(utf16.Decode(s))), nil
-}
-
-// writeText writes given data to the clipboard. It is the caller's
-// responsibility for opening/closing the clipboard before calling
-// this function.
-func writeText(buf []byte) error {
- r, _, err := emptyClipboard.Call()
- if r == 0 {
- return fmt.Errorf("failed to clear clipboard: %w", err)
- }
-
- // empty text, we are done here.
- if len(buf) == 0 {
- return nil
- }
-
- s, err := syscall.UTF16FromString(string(buf))
- if err != nil {
- return fmt.Errorf("failed to convert given string: %w", err)
- }
-
- hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
- if hMem == 0 {
- return fmt.Errorf("failed to alloc global memory: %w", err)
- }
-
- p, _, err := gLock.Call(hMem)
- if p == 0 {
- return fmt.Errorf("failed to lock global memory: %w", err)
- }
- defer gUnlock.Call(hMem)
-
- // no return value
- memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
- uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
-
- v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
- if v == 0 {
- gFree.Call(hMem)
- return fmt.Errorf("failed to set text to clipboard: %w", err)
- }
-
- return nil
-}
-
-// readImage reads the clipboard and returns PNG encoded image data
-// if presents. The caller is responsible for opening/closing the
-// clipboard before calling this function.
-func readImage() ([]byte, error) {
- hMem, _, err := getClipboardData.Call(cFmtDIBV5)
- if hMem == 0 {
- // second chance to try FmtDIB
- return readImageDib()
- }
- p, _, err := gLock.Call(hMem)
- if p == 0 {
- return nil, err
- }
- defer gUnlock.Call(hMem)
-
- // inspect header information
- info := (*bitmapV5Header)(unsafe.Pointer(p))
-
- // maybe deal with other formats?
- if info.BitCount != 32 {
- return nil, errUnsupported
- }
-
- var data []byte
- sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
- sh.Data = uintptr(p)
- sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
- sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
- img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
- offset := int(info.Size)
- stride := int(info.Width)
- for y := 0; y < int(info.Height); y++ {
- for x := 0; x < int(info.Width); x++ {
- idx := offset + 4*(y*stride+x)
- xhat := (x + int(info.Width)) % int(info.Width)
- yhat := int(info.Height) - 1 - y
- r := data[idx+2]
- g := data[idx+1]
- b := data[idx+0]
- a := data[idx+3]
- img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
- }
- }
- // always use PNG encoding.
- var buf bytes.Buffer
- png.Encode(&buf, img)
- return buf.Bytes(), nil
-}
-
-func readImageDib() ([]byte, error) {
- const (
- fileHeaderLen = 14
- infoHeaderLen = 40
- cFmtDIB = 8
- )
-
- hClipDat, _, err := getClipboardData.Call(cFmtDIB)
- if err != nil {
- return nil, errors.New("not dib format data: " + err.Error())
- }
- pMemBlk, _, err := gLock.Call(hClipDat)
- if pMemBlk == 0 {
- return nil, errors.New("failed to call global lock: " + err.Error())
- }
- defer gUnlock.Call(hClipDat)
-
- bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
- dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
-
- if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
- iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
- dataSize += iSizeImage
- }
- buf := new(bytes.Buffer)
- binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
- binary.Write(buf, binary.LittleEndian, uint32(dataSize))
- binary.Write(buf, binary.LittleEndian, uint32(0))
- const sizeof_colorbar = 0
- binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
- j := 0
- for i := fileHeaderLen; i < int(dataSize); i++ {
- binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
- j++
- }
- return bmpToPng(buf)
-}
-
-func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
- var f bytes.Buffer
- original_image, err := bmp.Decode(bmpBuf)
- if err != nil {
- return nil, err
- }
- err = png.Encode(&f, original_image)
- if err != nil {
- return nil, err
- }
- return f.Bytes(), nil
-}
-
-func writeImage(buf []byte) error {
- r, _, err := emptyClipboard.Call()
- if r == 0 {
- return fmt.Errorf("failed to clear clipboard: %w", err)
- }
-
- // empty text, we are done here.
- if len(buf) == 0 {
- return nil
- }
-
- img, err := png.Decode(bytes.NewReader(buf))
- if err != nil {
- return fmt.Errorf("input bytes is not PNG encoded: %w", err)
- }
-
- offset := unsafe.Sizeof(bitmapV5Header{})
- width := img.Bounds().Dx()
- height := img.Bounds().Dy()
- imageSize := 4 * width * height
-
- data := make([]byte, int(offset)+imageSize)
- for y := 0; y < height; y++ {
- for x := 0; x < width; x++ {
- idx := int(offset) + 4*(y*width+x)
- r, g, b, a := img.At(x, height-1-y).RGBA()
- data[idx+2] = uint8(r)
- data[idx+1] = uint8(g)
- data[idx+0] = uint8(b)
- data[idx+3] = uint8(a)
- }
- }
-
- info := bitmapV5Header{}
- info.Size = uint32(offset)
- info.Width = int32(width)
- info.Height = int32(height)
- info.Planes = 1
- info.Compression = 0 // BI_RGB
- info.SizeImage = uint32(4 * info.Width * info.Height)
- info.RedMask = 0xff0000 // default mask
- info.GreenMask = 0xff00
- info.BlueMask = 0xff
- info.AlphaMask = 0xff000000
- info.BitCount = 32 // we only deal with 32 bpp at the moment.
- // Use calibrated RGB values as Go's image/png assumes linear color space.
- // Other options:
- // - LCS_CALIBRATED_RGB = 0x00000000
- // - LCS_sRGB = 0x73524742
- // - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
- // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
- info.CSType = 0x73524742
- // Use GL_IMAGES for GamutMappingIntent
- // Other options:
- // - LCS_GM_ABS_COLORIMETRIC = 0x00000008
- // - LCS_GM_BUSINESS = 0x00000001
- // - LCS_GM_GRAPHICS = 0x00000002
- // - LCS_GM_IMAGES = 0x00000004
- // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
- info.Intent = 4 // LCS_GM_IMAGES
-
- infob := make([]byte, int(unsafe.Sizeof(info)))
- for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
- infob[i] = v
- }
- copy(data[:], infob[:])
-
- hMem, _, err := gAlloc.Call(gmemMoveable,
- uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
- if hMem == 0 {
- return fmt.Errorf("failed to alloc global memory: %w", err)
- }
-
- p, _, err := gLock.Call(hMem)
- if p == 0 {
- return fmt.Errorf("failed to lock global memory: %w", err)
- }
- defer gUnlock.Call(hMem)
-
- memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
- uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
-
- v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
- if v == 0 {
- gFree.Call(hMem)
- return fmt.Errorf("failed to set text to clipboard: %w", err)
- }
-
- return nil
-}
-
-func read(t Format) (buf []byte, err error) {
- // On Windows, OpenClipboard and CloseClipboard must be executed on
- // the same thread. Thus, lock the OS thread for further execution.
- runtime.LockOSThread()
- defer runtime.UnlockOSThread()
-
- var format uintptr
- switch t {
- case FmtImage:
- format = cFmtDIBV5
- case FmtText:
- fallthrough
- default:
- format = cFmtUnicodeText
- }
-
- // check if clipboard is available for the requested format
- r, _, err := isClipboardFormatAvailable.Call(format)
- if r == 0 {
- return nil, errUnavailable
- }
-
- // try again until open clipboard succeeds
- for {
- r, _, _ = openClipboard.Call()
- if r == 0 {
- continue
- }
- break
- }
- defer closeClipboard.Call()
-
- switch format {
- case cFmtDIBV5:
- return readImage()
- case cFmtUnicodeText:
- fallthrough
- default:
- return readText()
- }
-}
-
-// write writes the given data to clipboard and
-// returns true if success or false if failed.
-func write(t Format, buf []byte) (<-chan struct{}, error) {
- errch := make(chan error)
- changed := make(chan struct{}, 1)
- go func() {
- // make sure GetClipboardSequenceNumber happens with
- // OpenClipboard on the same thread.
- runtime.LockOSThread()
- defer runtime.UnlockOSThread()
- for {
- r, _, _ := openClipboard.Call(0)
- if r == 0 {
- continue
- }
- break
- }
-
- // var param uintptr
- switch t {
- case FmtImage:
- err := writeImage(buf)
- if err != nil {
- errch <- err
- closeClipboard.Call()
- return
- }
- case FmtText:
- fallthrough
- default:
- // param = cFmtUnicodeText
- err := writeText(buf)
- if err != nil {
- errch <- err
- closeClipboard.Call()
- return
- }
- }
- // Close the clipboard otherwise other applications cannot
- // paste the data.
- closeClipboard.Call()
-
- cnt, _, _ := getClipboardSequenceNumber.Call()
- errch <- nil
- for {
- time.Sleep(time.Second)
- cur, _, _ := getClipboardSequenceNumber.Call()
- if cur != cnt {
- changed <- struct{}{}
- close(changed)
- return
- }
- }
- }()
- err := <-errch
- if err != nil {
- return nil, err
- }
- return changed, nil
-}
-
-func watch(ctx context.Context, t Format) <-chan []byte {
- recv := make(chan []byte, 1)
- ready := make(chan struct{})
- go func() {
- // not sure if we are too slow or the user too fast :)
- ti := time.NewTicker(time.Second)
- cnt, _, _ := getClipboardSequenceNumber.Call()
- ready <- struct{}{}
- for {
- select {
- case <-ctx.Done():
- close(recv)
- return
- case <-ti.C:
- cur, _, _ := getClipboardSequenceNumber.Call()
- if cnt != cur {
- b := Read(t)
- if b == nil {
- continue
- }
- recv <- b
- cnt = cur
- }
- }
- }
- }()
- <-ready
- return recv
-}
-
-const (
- cFmtBitmap = 2 // Win+PrintScreen
- cFmtUnicodeText = 13
- cFmtDIBV5 = 17
- // Screenshot taken from special shortcut is in different format (why??), see:
- // https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
- cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
- gmemMoveable = 0x0002
-)
-
-// BITMAPV5Header structure, see:
-// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
-type bitmapV5Header struct {
- Size uint32
- Width int32
- Height int32
- Planes uint16
- BitCount uint16
- Compression uint32
- SizeImage uint32
- XPelsPerMeter int32
- YPelsPerMeter int32
- ClrUsed uint32
- ClrImportant uint32
- RedMask uint32
- GreenMask uint32
- BlueMask uint32
- AlphaMask uint32
- CSType uint32
- Endpoints struct {
- CiexyzRed, CiexyzGreen, CiexyzBlue struct {
- CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
- }
- }
- GammaRed uint32
- GammaGreen uint32
- GammaBlue uint32
- Intent uint32
- ProfileData uint32
- ProfileSize uint32
- Reserved uint32
-}
-
-type bitmapHeader struct {
- Size uint32
- Width uint32
- Height uint32
- PLanes uint16
- BitCount uint16
- Compression uint32
- SizeImage uint32
- XPelsPerMeter uint32
- YPelsPerMeter uint32
- ClrUsed uint32
- ClrImportant uint32
-}
-
-// Calling a Windows DLL, see:
-// https://github.com/golang/go/wiki/WindowsDLLs
-var (
- user32 = syscall.MustLoadDLL("user32")
- // Opens the clipboard for examination and prevents other
- // applications from modifying the clipboard content.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
- openClipboard = user32.MustFindProc("OpenClipboard")
- // Closes the clipboard.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
- closeClipboard = user32.MustFindProc("CloseClipboard")
- // Empties the clipboard and frees handles to data in the clipboard.
- // The function then assigns ownership of the clipboard to the
- // window that currently has the clipboard open.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
- emptyClipboard = user32.MustFindProc("EmptyClipboard")
- // Retrieves data from the clipboard in a specified format.
- // The clipboard must have been opened previously.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
- getClipboardData = user32.MustFindProc("GetClipboardData")
- // Places data on the clipboard in a specified clipboard format.
- // The window must be the current clipboard owner, and the
- // application must have called the OpenClipboard function. (When
- // responding to the WM_RENDERFORMAT message, the clipboard owner
- // must not call OpenClipboard before calling SetClipboardData.)
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
- setClipboardData = user32.MustFindProc("SetClipboardData")
- // Determines whether the clipboard contains data in the specified format.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
- isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
- // Clipboard data formats are stored in an ordered list. To perform
- // an enumeration of clipboard data formats, you make a series of
- // calls to the EnumClipboardFormats function. For each call, the
- // format parameter specifies an available clipboard format, and the
- // function returns the next available clipboard format.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
- enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
- // Retrieves the clipboard sequence number for the current window station.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
- getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
- // Registers a new clipboard format. This format can then be used as
- // a valid clipboard format.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
- registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
-
- kernel32 = syscall.NewLazyDLL("kernel32")
-
- // Locks a global memory object and returns a pointer to the first
- // byte of the object's memory block.
- // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
- gLock = kernel32.NewProc("GlobalLock")
- // Decrements the lock count associated with a memory object that was
- // allocated with GMEM_MOVEABLE. This function has no effect on memory
- // objects allocated with GMEM_FIXED.
- // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
- gUnlock = kernel32.NewProc("GlobalUnlock")
- // Allocates the specified number of bytes from the heap.
- // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
- gAlloc = kernel32.NewProc("GlobalAlloc")
- // Frees the specified global memory object and invalidates its handle.
- // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
- gFree = kernel32.NewProc("GlobalFree")
- memMove = kernel32.NewProc("RtlMoveMemory")
-)
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
deleted file mode 100644
index d552b78ec..000000000
--- a/packages/tui/internal/commands/command.go
+++ /dev/null
@@ -1,423 +0,0 @@
-package commands
-
-import (
- "encoding/json"
- "log/slog"
- "slices"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode-sdk-go"
-)
-
-type ExecuteCommandMsg Command
-type ExecuteCommandsMsg []Command
-type CommandExecutedMsg Command
-
-type Keybinding struct {
- RequiresLeader bool
- Key string
-}
-
-func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
- key := k.Key
- key = strings.TrimSpace(key)
- return key == msg.String() && (k.RequiresLeader == leader)
-}
-
-type CommandName string
-type Command struct {
- Name CommandName
- Description string
- Keybindings []Keybinding
- Trigger []string
- Custom bool
-}
-
-func (c Command) Keys() []string {
- var keys []string
- for _, k := range c.Keybindings {
- keys = append(keys, k.Key)
- }
- return keys
-}
-
-func (c Command) HasTrigger() bool {
- return len(c.Trigger) > 0
-}
-
-func (c Command) PrimaryTrigger() string {
- if len(c.Trigger) > 0 {
- return c.Trigger[0]
- }
- return ""
-}
-
-func (c Command) MatchesTrigger(trigger string) bool {
- return slices.Contains(c.Trigger, trigger)
-}
-
-type CommandRegistry map[CommandName]Command
-
-func (r CommandRegistry) Sorted() []Command {
- var commands []Command
- for _, command := range r {
- commands = append(commands, command)
- }
- slices.SortFunc(commands, func(a, b Command) int {
- // Priority order: session_new, session_share, model_list, agent_list, app_help first, app_exit last
- priorityOrder := map[CommandName]int{
- SessionNewCommand: 0,
- AppHelpCommand: 1,
- SessionShareCommand: 2,
- ModelListCommand: 3,
- AgentListCommand: 4,
- }
-
- aPriority, aHasPriority := priorityOrder[a.Name]
- bPriority, bHasPriority := priorityOrder[b.Name]
-
- if aHasPriority && bHasPriority {
- return aPriority - bPriority
- }
- if aHasPriority {
- return -1
- }
- if bHasPriority {
- return 1
- }
- if a.Name == AppExitCommand {
- return 1
- }
- if b.Name == AppExitCommand {
- return -1
- }
- if a.Custom && !b.Custom {
- return 1
- }
- if !a.Custom && b.Custom {
- return -1
- }
-
- return strings.Compare(string(a.Name), string(b.Name))
- })
- return commands
-}
-
-func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
- var matched []Command
- for _, command := range r.Sorted() {
- if command.Matches(msg, leader) {
- matched = append(matched, command)
- }
- }
- return matched
-}
-
-const (
- SessionChildCycleCommand CommandName = "session_child_cycle"
- SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
- ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
- AgentCycleCommand CommandName = "agent_cycle"
- AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
- AppHelpCommand CommandName = "app_help"
- SwitchAgentCommand CommandName = "switch_agent"
- SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
- EditorOpenCommand CommandName = "editor_open"
- SessionNewCommand CommandName = "session_new"
- SessionListCommand CommandName = "session_list"
- SessionTimelineCommand CommandName = "session_timeline"
- SessionShareCommand CommandName = "session_share"
- SessionUnshareCommand CommandName = "session_unshare"
- SessionInterruptCommand CommandName = "session_interrupt"
- SessionCompactCommand CommandName = "session_compact"
- SessionExportCommand CommandName = "session_export"
- ToolDetailsCommand CommandName = "tool_details"
- ThinkingBlocksCommand CommandName = "thinking_blocks"
- ModelListCommand CommandName = "model_list"
- AgentListCommand CommandName = "agent_list"
- ModelCycleRecentCommand CommandName = "model_cycle_recent"
- 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"
- MessagesPageUpCommand CommandName = "messages_page_up"
- MessagesPageDownCommand CommandName = "messages_page_down"
- MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
- MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
- MessagesPreviousCommand CommandName = "messages_previous"
- MessagesNextCommand CommandName = "messages_next"
- MessagesFirstCommand CommandName = "messages_first"
- MessagesLastCommand CommandName = "messages_last"
- MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
- MessagesCopyCommand CommandName = "messages_copy"
- MessagesUndoCommand CommandName = "messages_undo"
- MessagesRedoCommand CommandName = "messages_redo"
- AppExitCommand CommandName = "app_exit"
-)
-
-func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
- for _, binding := range k.Keybindings {
- if binding.Matches(msg, leader) {
- return true
- }
- }
- return false
-}
-
-func parseBindings(bindings ...string) []Keybinding {
- var parsedBindings []Keybinding
- for _, binding := range bindings {
- if binding == "none" {
- continue
- }
- for p := range strings.SplitSeq(binding, ",") {
- requireLeader := strings.HasPrefix(p, "<leader>")
- keybinding := strings.ReplaceAll(p, "<leader>", "")
- keybinding = strings.TrimSpace(keybinding)
- parsedBindings = append(parsedBindings, Keybinding{
- RequiresLeader: requireLeader,
- Key: keybinding,
- })
- }
- }
- return parsedBindings
-}
-
-func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) CommandRegistry {
- defaults := []Command{
- {
- Name: AppHelpCommand,
- Description: "show help",
- Keybindings: parseBindings("<leader>h"),
- Trigger: []string{"help"},
- },
- {
- Name: EditorOpenCommand,
- Description: "open editor",
- Keybindings: parseBindings("<leader>e"),
- Trigger: []string{"editor"},
- },
- {
- Name: SessionExportCommand,
- Description: "export conversation",
- Keybindings: parseBindings("<leader>x"),
- Trigger: []string{"export"},
- },
- {
- Name: SessionNewCommand,
- Description: "new session",
- Keybindings: parseBindings("<leader>n"),
- Trigger: []string{"new", "clear"},
- },
- {
- Name: SessionListCommand,
- Description: "list sessions",
- Keybindings: parseBindings("<leader>l"),
- Trigger: []string{"sessions", "resume", "continue"},
- },
- {
- Name: SessionTimelineCommand,
- Description: "show session timeline",
- Keybindings: parseBindings("<leader>g"),
- Trigger: []string{"timeline", "history", "goto"},
- },
- {
- Name: SessionShareCommand,
- Description: "share session",
- Keybindings: parseBindings("<leader>s"),
- Trigger: []string{"share"},
- },
- {
- Name: SessionUnshareCommand,
- Description: "unshare session",
- Trigger: []string{"unshare"},
- },
- {
- Name: SessionInterruptCommand,
- Description: "interrupt session",
- Keybindings: parseBindings("esc"),
- },
- {
- Name: SessionCompactCommand,
- Description: "compact the session",
- Keybindings: parseBindings("<leader>c"),
- Trigger: []string{"compact", "summarize"},
- },
- {
- Name: SessionChildCycleCommand,
- Description: "cycle to next child session",
- Keybindings: parseBindings("ctrl+right"),
- },
- {
- Name: SessionChildCycleReverseCommand,
- Description: "cycle to previous child session",
- Keybindings: parseBindings("ctrl+left"),
- },
- {
- Name: ToolDetailsCommand,
- Description: "toggle tool details",
- Keybindings: parseBindings("<leader>d"),
- Trigger: []string{"details"},
- },
- {
- Name: ThinkingBlocksCommand,
- Description: "toggle thinking blocks",
- Keybindings: parseBindings("<leader>b"),
- Trigger: []string{"thinking"},
- },
- {
- Name: ModelListCommand,
- Description: "list models",
- Keybindings: parseBindings("<leader>m"),
- Trigger: []string{"models"},
- },
- {
- Name: ModelCycleRecentCommand,
- Description: "next recent model",
- Keybindings: parseBindings("f2"),
- },
- {
- Name: ModelCycleRecentReverseCommand,
- Description: "previous recent model",
- Keybindings: parseBindings("shift+f2"),
- },
- {
- Name: AgentListCommand,
- Description: "list agents",
- Keybindings: parseBindings("<leader>a"),
- Trigger: []string{"agents"},
- },
- {
- Name: AgentCycleCommand,
- Description: "next agent",
- Keybindings: parseBindings("tab"),
- },
- {
- Name: AgentCycleReverseCommand,
- Description: "previous agent",
- Keybindings: parseBindings("shift+tab"),
- },
- {
- Name: ThemeListCommand,
- Description: "list themes",
- Keybindings: parseBindings("<leader>t"),
- Trigger: []string{"themes"},
- },
- {
- Name: ProjectInitCommand,
- Description: "create/update AGENTS.md",
- Keybindings: parseBindings("<leader>i"),
- Trigger: []string{"init"},
- },
- {
- Name: InputClearCommand,
- Description: "clear input",
- Keybindings: parseBindings("ctrl+c"),
- },
- {
- Name: InputPasteCommand,
- Description: "paste content",
- Keybindings: parseBindings("ctrl+v", "super+v"),
- },
- {
- Name: InputSubmitCommand,
- Description: "submit message",
- Keybindings: parseBindings("enter"),
- },
- {
- Name: InputNewlineCommand,
- Description: "insert newline",
- Keybindings: parseBindings("shift+enter", "ctrl+j"),
- },
- {
- Name: MessagesPageUpCommand,
- Description: "page up",
- Keybindings: parseBindings("pgup"),
- },
- {
- Name: MessagesPageDownCommand,
- Description: "page down",
- Keybindings: parseBindings("pgdown"),
- },
- {
- Name: MessagesHalfPageUpCommand,
- Description: "half page up",
- Keybindings: parseBindings("ctrl+alt+u"),
- },
- {
- Name: MessagesHalfPageDownCommand,
- Description: "half page down",
- Keybindings: parseBindings("ctrl+alt+d"),
- },
-
- {
- Name: MessagesFirstCommand,
- Description: "first message",
- Keybindings: parseBindings("ctrl+g"),
- },
- {
- Name: MessagesLastCommand,
- Description: "last message",
- Keybindings: parseBindings("ctrl+alt+g"),
- },
-
- {
- Name: MessagesCopyCommand,
- Description: "copy message",
- Keybindings: parseBindings("<leader>y"),
- },
- {
- Name: MessagesUndoCommand,
- Description: "undo last message",
- Keybindings: parseBindings("<leader>u"),
- Trigger: []string{"undo"},
- },
- {
- Name: MessagesRedoCommand,
- Description: "redo message",
- Keybindings: parseBindings("<leader>r"),
- Trigger: []string{"redo"},
- },
- {
- Name: AppExitCommand,
- Description: "exit the app",
- Keybindings: parseBindings("ctrl+c", "<leader>q"),
- Trigger: []string{"exit", "quit", "q"},
- },
- }
- registry := make(CommandRegistry)
- keybinds := map[string]string{}
- marshalled, _ := json.Marshal(config.Keybinds)
- json.Unmarshal(marshalled, &keybinds)
- for _, command := range defaults {
- // Remove share/unshare commands if sharing is disabled
- if config.Share == opencode.ConfigShareDisabled &&
- (command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
- slog.Info("Removing share/unshare commands")
- continue
- }
- if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
- command.Keybindings = parseBindings(keybind)
- }
- registry[command.Name] = command
- }
- for _, command := range customCommands {
- registry[CommandName(command.Name)] = Command{
- Name: CommandName(command.Name),
- Description: command.Description,
- Trigger: []string{command.Name},
- Keybindings: []Keybinding{},
- Custom: true,
- }
- }
-
- slog.Info("Loaded commands", "commands", registry)
- return registry
-}
diff --git a/packages/tui/internal/completions/agents.go b/packages/tui/internal/completions/agents.go
deleted file mode 100644
index d25c76d89..000000000
--- a/packages/tui/internal/completions/agents.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package completions
-
-import (
- "context"
- "log/slog"
- "strings"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type agentsContextGroup struct {
- app *app.App
-}
-
-func (cg *agentsContextGroup) GetId() string {
- return "agents"
-}
-
-func (cg *agentsContextGroup) GetEmptyMessage() string {
- return "no matching agents"
-}
-
-func (cg *agentsContextGroup) GetChildEntries(
- query string,
-) ([]CompletionSuggestion, error) {
- items := make([]CompletionSuggestion, 0)
-
- query = strings.TrimSpace(query)
-
- agents, err := cg.app.Client.Agent.List(
- context.Background(),
- opencode.AgentListParams{},
- )
- if err != nil {
- slog.Error("Failed to get agent list", "error", err)
- return items, err
- }
- if agents == nil {
- return items, nil
- }
-
- for _, agent := range *agents {
- if query != "" && !strings.Contains(strings.ToLower(agent.Name), strings.ToLower(query)) {
- continue
- }
- if agent.Mode == opencode.AgentModePrimary {
- continue
- }
-
- displayFunc := func(s styles.Style) string {
- t := theme.CurrentTheme()
- muted := s.Foreground(t.TextMuted()).Render
- return s.Render(agent.Name) + muted(" (agent)")
- }
-
- item := CompletionSuggestion{
- Display: displayFunc,
- Value: agent.Name,
- ProviderID: cg.GetId(),
- RawData: agent,
- }
- items = append(items, item)
- }
-
- return items, nil
-}
-
-func NewAgentsContextGroup(app *app.App) CompletionProvider {
- return &agentsContextGroup{
- app: app,
- }
-}
diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go
deleted file mode 100644
index 72e261f82..000000000
--- a/packages/tui/internal/completions/commands.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package completions
-
-import (
- "sort"
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type CommandCompletionProvider struct {
- app *app.App
-}
-
-func NewCommandCompletionProvider(app *app.App) CompletionProvider {
- return &CommandCompletionProvider{app: app}
-}
-
-func (c *CommandCompletionProvider) GetId() string {
- return "commands"
-}
-
-func (c *CommandCompletionProvider) GetEmptyMessage() string {
- return "no matching commands"
-}
-
-func (c *CommandCompletionProvider) getCommandCompletionItem(
- cmd commands.Command,
- space int,
-) CompletionSuggestion {
- displayFunc := func(s styles.Style) string {
- t := theme.CurrentTheme()
- spacer := strings.Repeat(" ", space)
- display := " /" + cmd.PrimaryTrigger() + s.
- Foreground(t.TextMuted()).
- Render(spacer+cmd.Description)
- return display
- }
-
- value := string(cmd.Name)
- return CompletionSuggestion{
- Display: displayFunc,
- Value: value,
- ProviderID: c.GetId(),
- RawData: cmd,
- }
-}
-
-func (c *CommandCompletionProvider) GetChildEntries(
- query string,
-) ([]CompletionSuggestion, error) {
- commands := c.app.Commands
-
- space := 1
- for _, cmd := range c.app.Commands {
- if cmd.HasTrigger() && lipgloss.Width(cmd.PrimaryTrigger()) > space {
- space = lipgloss.Width(cmd.PrimaryTrigger())
- }
- }
- space += 2
-
- sorted := commands.Sorted()
- if query == "" {
- // If no query, return all commands
- items := []CompletionSuggestion{}
- for _, cmd := range sorted {
- if !cmd.HasTrigger() {
- continue
- }
- space := space - lipgloss.Width(cmd.PrimaryTrigger())
- items = append(items, c.getCommandCompletionItem(cmd, space))
- }
- return items, nil
- }
-
- var commandNames []string
- commandMap := make(map[string]CompletionSuggestion)
-
- for _, cmd := range sorted {
- if !cmd.HasTrigger() {
- continue
- }
- space := space - lipgloss.Width(cmd.PrimaryTrigger())
- for _, trigger := range cmd.Trigger {
- commandNames = append(commandNames, trigger)
- commandMap[trigger] = c.getCommandCompletionItem(cmd, space)
- }
- }
-
- matches := fuzzy.RankFindFold(query, commandNames)
-
- // Custom sort to prioritize exact matches
- sort.Slice(matches, func(i, j int) bool {
- // Check for exact match (case-insensitive)
- iExact := strings.EqualFold(matches[i].Target, query)
- jExact := strings.EqualFold(matches[j].Target, query)
-
- // Exact matches come first
- if iExact && !jExact {
- return true
- }
- if !iExact && jExact {
- return false
- }
-
- // Check for prefix match (case-insensitive)
- iPrefix := strings.HasPrefix(strings.ToLower(matches[i].Target), strings.ToLower(query))
- jPrefix := strings.HasPrefix(strings.ToLower(matches[j].Target), strings.ToLower(query))
-
- // Prefix matches come before fuzzy matches
- if iPrefix && !jPrefix {
- return true
- }
- if !iPrefix && jPrefix {
- return false
- }
-
- // Otherwise, sort by fuzzy match score (lower distance is better)
- if matches[i].Distance != matches[j].Distance {
- return matches[i].Distance < matches[j].Distance
- }
-
- // If distances are equal, sort by original index (stable sort)
- return matches[i].OriginalIndex < matches[j].OriginalIndex
- })
-
- // Convert matches to completion items, deduplicating by command name
- items := []CompletionSuggestion{}
- seen := make(map[string]bool)
- for _, match := range matches {
- if item, ok := commandMap[match.Target]; ok {
- // Use the command's value (name) as the deduplication key
- if !seen[item.Value] {
- seen[item.Value] = true
- items = append(items, item)
- }
- }
- }
- return items, nil
-}
diff --git a/packages/tui/internal/completions/files.go b/packages/tui/internal/completions/files.go
deleted file mode 100644
index d00873656..000000000
--- a/packages/tui/internal/completions/files.go
+++ /dev/null
@@ -1,126 +0,0 @@
-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/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type filesContextGroup struct {
- app *app.App
- gitFiles []CompletionSuggestion
-}
-
-func (cg *filesContextGroup) GetId() string {
- return "files"
-}
-
-func (cg *filesContextGroup) GetEmptyMessage() string {
- return "no matching files"
-}
-
-func (cg *filesContextGroup) getGitFiles() []CompletionSuggestion {
- items := make([]CompletionSuggestion, 0)
-
- status, _ := cg.app.Client.File.Status(context.Background(), opencode.FileStatusParams{})
- 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 {
- displayFunc := func(s styles.Style) string {
- t := theme.CurrentTheme()
- green := s.Foreground(t.Success()).Render
- red := s.Foreground(t.Error()).Render
- display := file.Path
- if file.Added > 0 {
- display += green(" +" + strconv.Itoa(int(file.Added)))
- }
- if file.Removed > 0 {
- display += red(" -" + strconv.Itoa(int(file.Removed)))
- }
- return display
- }
- item := CompletionSuggestion{
- Display: displayFunc,
- Value: file.Path,
- ProviderID: cg.GetId(),
- RawData: file,
- }
- items = append(items, item)
- }
- }
-
- return items
-}
-
-func (cg *filesContextGroup) GetChildEntries(
- query string,
-) ([]CompletionSuggestion, error) {
- items := make([]CompletionSuggestion, 0)
-
- query = strings.TrimSpace(query)
- if query == "" {
- items = append(items, cg.gitFiles...)
- }
-
- files, err := cg.app.Client.Find.Files(
- context.Background(),
- opencode.FindFilesParams{Query: opencode.F(query)},
- )
- if err != nil {
- slog.Error("Failed to get completion items", "error", err)
- return items, err
- }
- if files == nil {
- return items, nil
- }
-
- for _, file := range *files {
- exists := false
- for _, existing := range cg.gitFiles {
- if existing.Value == file {
- if query != "" {
- items = append(items, existing)
- }
- exists = true
- }
- }
- if !exists {
- displayFunc := func(s styles.Style) string {
- // t := theme.CurrentTheme()
- // return s.Foreground(t.Text()).Render(file)
- return s.Render(file)
- }
-
- item := CompletionSuggestion{
- Display: displayFunc,
- Value: file,
- ProviderID: cg.GetId(),
- RawData: file,
- }
- items = append(items, item)
- }
- }
-
- return items, nil
-}
-
-func NewFileContextGroup(app *app.App) CompletionProvider {
- cg := &filesContextGroup{
- app: app,
- }
- go func() {
- cg.gitFiles = cg.getGitFiles()
- }()
- return cg
-}
diff --git a/packages/tui/internal/completions/provider.go b/packages/tui/internal/completions/provider.go
deleted file mode 100644
index dc11522c3..000000000
--- a/packages/tui/internal/completions/provider.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package completions
-
-// CompletionProvider defines the interface for completion data providers
-type CompletionProvider interface {
- GetId() string
- GetChildEntries(query string) ([]CompletionSuggestion, error)
- GetEmptyMessage() string
-}
diff --git a/packages/tui/internal/completions/suggestion.go b/packages/tui/internal/completions/suggestion.go
deleted file mode 100644
index fac6b6813..000000000
--- a/packages/tui/internal/completions/suggestion.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package completions
-
-import "github.com/sst/opencode/internal/styles"
-
-// CompletionSuggestion represents a data-only completion suggestion
-// with no styling or rendering logic
-type CompletionSuggestion struct {
- // The text to be displayed in the list. May contain minimal inline
- // ANSI styling if intrinsic to the data (e.g., git diff colors).
- Display func(styles.Style) string
-
- // The value to be used when the item is selected (e.g., inserted into the editor).
- Value string
-
- // An optional, longer description to be displayed.
- Description string
-
- // The ID of the provider that generated this suggestion.
- ProviderID string
-
- // The raw, underlying data object (e.g., opencode.Symbol, commands.Command).
- // This allows the selection handler to perform rich actions.
- RawData any
-}
diff --git a/packages/tui/internal/completions/symbols.go b/packages/tui/internal/completions/symbols.go
deleted file mode 100644
index 725e2e69b..000000000
--- a/packages/tui/internal/completions/symbols.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package completions
-
-import (
- "context"
- "fmt"
- "log/slog"
- "strings"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type symbolsContextGroup struct {
- app *app.App
-}
-
-func (cg *symbolsContextGroup) GetId() string {
- return "symbols"
-}
-
-func (cg *symbolsContextGroup) GetEmptyMessage() string {
- return "no matching symbols"
-}
-
-type SymbolKind int
-
-const (
- SymbolKindFile SymbolKind = 1
- SymbolKindModule SymbolKind = 2
- SymbolKindNamespace SymbolKind = 3
- SymbolKindPackage SymbolKind = 4
- SymbolKindClass SymbolKind = 5
- SymbolKindMethod SymbolKind = 6
- SymbolKindProperty SymbolKind = 7
- SymbolKindField SymbolKind = 8
- SymbolKindConstructor SymbolKind = 9
- SymbolKindEnum SymbolKind = 10
- SymbolKindInterface SymbolKind = 11
- SymbolKindFunction SymbolKind = 12
- SymbolKindVariable SymbolKind = 13
- SymbolKindConstant SymbolKind = 14
- SymbolKindString SymbolKind = 15
- SymbolKindNumber SymbolKind = 16
- SymbolKindBoolean SymbolKind = 17
- SymbolKindArray SymbolKind = 18
- SymbolKindObject SymbolKind = 19
- SymbolKindKey SymbolKind = 20
- SymbolKindNull SymbolKind = 21
- SymbolKindEnumMember SymbolKind = 22
- SymbolKindStruct SymbolKind = 23
- SymbolKindEvent SymbolKind = 24
- SymbolKindOperator SymbolKind = 25
- SymbolKindTypeParameter SymbolKind = 26
-)
-
-func (cg *symbolsContextGroup) GetChildEntries(
- query string,
-) ([]CompletionSuggestion, error) {
- items := make([]CompletionSuggestion, 0)
-
- query = strings.TrimSpace(query)
- if query == "" {
- return items, nil
- }
-
- symbols, err := cg.app.Client.Find.Symbols(
- context.Background(),
- opencode.FindSymbolsParams{Query: opencode.F(query)},
- )
- if err != nil {
- slog.Error("Failed to get symbol completion items", "error", err)
- return items, err
- }
- if symbols == nil {
- return items, nil
- }
-
- for _, sym := range *symbols {
- parts := strings.Split(sym.Name, ".")
- lastPart := parts[len(parts)-1]
- start := int(sym.Location.Range.Start.Line)
- end := int(sym.Location.Range.End.Line)
-
- displayFunc := func(s styles.Style) string {
- t := theme.CurrentTheme()
- base := s.Foreground(t.Text()).Render
- muted := s.Foreground(t.TextMuted()).Render
- display := base(lastPart)
-
- uriParts := strings.Split(sym.Location.Uri, "/")
- lastTwoParts := uriParts[len(uriParts)-2:]
- joined := strings.Join(lastTwoParts, "/")
- display += muted(fmt.Sprintf(" %s", joined))
-
- display += muted(fmt.Sprintf(":L%d-%d", start, end))
- return display
- }
-
- value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
-
- item := CompletionSuggestion{
- Display: displayFunc,
- Value: value,
- ProviderID: cg.GetId(),
- RawData: sym,
- }
- items = append(items, item)
- }
-
- return items, nil
-}
-
-func NewSymbolsContextGroup(app *app.App) CompletionProvider {
- return &symbolsContextGroup{
- app: app,
- }
-}
diff --git a/packages/tui/internal/components/chat/cache.go b/packages/tui/internal/components/chat/cache.go
deleted file mode 100644
index 454f1a5a9..000000000
--- a/packages/tui/internal/components/chat/cache.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package chat
-
-import (
- "encoding/hex"
- "fmt"
- "hash/fnv"
- "sync"
-)
-
-// PartCache caches rendered messages to avoid re-rendering
-type PartCache struct {
- mu sync.RWMutex
- cache map[string]string
-}
-
-// NewPartCache creates a new message cache
-func NewPartCache() *PartCache {
- return &PartCache{
- cache: make(map[string]string),
- }
-}
-
-// generateKey creates a unique key for a message based on its content and rendering parameters
-func (c *PartCache) GenerateKey(params ...any) string {
- h := fnv.New64a()
- for _, param := range params {
- h.Write(fmt.Appendf(nil, ":%v", param))
- }
- return hex.EncodeToString(h.Sum(nil))
-}
-
-// Get retrieves a cached rendered message
-func (c *PartCache) Get(key string) (string, bool) {
- c.mu.RLock()
- defer c.mu.RUnlock()
-
- content, exists := c.cache[key]
- return content, exists
-}
-
-// Set stores a rendered message in the cache
-func (c *PartCache) Set(key string, content string) {
- c.mu.Lock()
- defer c.mu.Unlock()
- c.cache[key] = content
-}
-
-// Clear removes all entries from the cache
-func (c *PartCache) Clear() {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- c.cache = make(map[string]string)
-}
-
-// Size returns the number of cached entries
-func (c *PartCache) Size() int {
- c.mu.RLock()
- defer c.mu.RUnlock()
-
- return len(c.cache)
-}
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
deleted file mode 100644
index d3c813840..000000000
--- a/packages/tui/internal/components/chat/editor.go
+++ /dev/null
@@ -1,906 +0,0 @@
-package chat
-
-import (
- "encoding/base64"
- "fmt"
- "log/slog"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "unicode/utf8"
-
- "github.com/charmbracelet/bubbles/v2/spinner"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/google/uuid"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/attachment"
- "github.com/sst/opencode/internal/clipboard"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/components/dialog"
- "github.com/sst/opencode/internal/components/textarea"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type EditorComponent interface {
- tea.Model
- tea.ViewModel
- Content() string
- Cursor() *tea.Cursor
- Lines() int
- Value() string
- Length() int
- Focused() bool
- Focus() (tea.Model, tea.Cmd)
- Blur()
- Submit() (tea.Model, tea.Cmd)
- SubmitBash() (tea.Model, tea.Cmd)
- Clear() (tea.Model, tea.Cmd)
- Paste() (tea.Model, tea.Cmd)
- Newline() (tea.Model, tea.Cmd)
- SetValue(value string)
- SetValueWithAttachments(value string)
- SetInterruptKeyInDebounce(inDebounce bool)
- SetExitKeyInDebounce(inDebounce bool)
- RestoreFromHistory(index int)
- GetAttachments() []*attachment.Attachment
-}
-
-type editorComponent struct {
- app *app.App
- width int
- textarea textarea.Model
- spinner spinner.Model
- interruptKeyInDebounce bool
- exitKeyInDebounce bool
- historyIndex int // -1 means current (not in history)
- currentText string // Store current text when navigating history
- pasteCounter int
- reverted bool
-}
-
-func (m *editorComponent) Init() tea.Cmd {
- return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
-}
-
-func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- var cmd tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width - 4
- return m, nil
- case spinner.TickMsg:
- m.spinner, cmd = m.spinner.Update(msg)
- return m, cmd
- case tea.KeyPressMsg:
- // Handle up/down arrows and ctrl+p/ctrl+n for history navigation
- switch msg.String() {
- case "up", "ctrl+p":
- // Only navigate history if cursor is at the first line and column (for arrow keys)
- // or allow ctrl+p from anywhere
- if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 {
- if m.historyIndex == -1 {
- // Save current text before entering history
- m.currentText = m.textarea.Value()
- m.textarea.MoveToBegin()
- }
- // Move up in history (older messages)
- if m.historyIndex < len(m.app.State.MessageHistory)-1 {
- m.historyIndex++
- m.RestoreFromHistory(m.historyIndex)
- m.textarea.MoveToBegin()
- }
- return m, nil
- }
- case "down", "ctrl+n":
- // Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys)
- // or allow ctrl+n from anywhere if we're in history navigation
- if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 {
- // Move down in history (newer messages)
- m.historyIndex--
- if m.historyIndex == -1 {
- // Restore current text
- m.textarea.Reset()
- m.textarea.SetValue(m.currentText)
- m.currentText = ""
- } else {
- m.RestoreFromHistory(m.historyIndex)
- m.textarea.MoveToEnd()
- }
- return m, nil
- } else if m.historyIndex > -1 && msg.String() == "down" {
- m.textarea.MoveToEnd()
- return m, nil
- }
- }
- // Reset history navigation on any other input
- if m.historyIndex != -1 {
- m.historyIndex = -1
- m.currentText = ""
- }
- // Maximize editor responsiveness for printable characters
- if msg.Text != "" {
- m.reverted = false
- m.textarea, cmd = m.textarea.Update(msg)
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
- }
- case app.MessageRevertedMsg:
- if msg.Session.ID == m.app.Session.ID {
- switch msg.Message.Info.(type) {
- case opencode.UserMessage:
- prompt, err := msg.Message.ToPrompt()
- if err != nil {
- return m, toast.NewErrorToast("Failed to revert message")
- }
- m.RestoreFromPrompt(*prompt)
- m.textarea.MoveToEnd()
- m.reverted = true
- return m, nil
- }
- }
- case app.SessionUnrevertedMsg:
- if msg.Session.ID == m.app.Session.ID {
- if m.reverted {
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- return m, cmd
- }
- return m, nil
- }
- case tea.PasteMsg:
- text := string(msg)
-
- if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
- statPath := filePath
- if !filepath.IsAbs(filePath) {
- statPath = filepath.Join(util.CwdPath, filePath)
- }
- if _, err := os.Stat(statPath); err == nil {
- attachment := m.createAttachmentFromPath(filePath)
- if attachment != nil {
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
- }
- }
- }
-
- text = strings.ReplaceAll(text, "\\", "")
- text, err := strconv.Unquote(`"` + text + `"`)
- if err != nil {
- slog.Error("Failed to unquote text", "error", err)
- text := string(msg)
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
- }
- return m, nil
- }
- if _, err := os.Stat(text); err != nil {
- slog.Error("Failed to paste file", "error", err)
- text := string(msg)
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
- }
- return m, nil
- }
-
- filePath := text
-
- attachment := m.createAttachmentFromFile(filePath)
- if attachment == nil {
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
- }
- return m, nil
- }
-
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- case tea.ClipboardMsg:
- text := string(msg)
- // Check if the pasted text is long and should be summarized
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(text))
- }
- case dialog.ThemeSelectedMsg:
- m.textarea = updateTextareaStyles(m.textarea)
- m.spinner = createSpinner()
- return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick)
- case dialog.CompletionSelectedMsg:
- switch msg.Item.ProviderID {
- case "commands":
- command := msg.Item.RawData.(commands.Command)
- if command.Custom {
- m.SetValue("/" + command.PrimaryTrigger() + " ")
- return m, nil
- }
-
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- cmds = append(cmds, cmd)
-
- commandName := strings.TrimPrefix(msg.Item.Value, "/")
- cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
- return m, tea.Batch(cmds...)
- case "files":
- atIndex := m.textarea.LastRuneIndex('@')
- if atIndex == -1 {
- // Should not happen, but as a fallback, just insert.
- m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
- }
-
- // The range to replace is from the '@' up to the current cursor position.
- // Replace the search term (e.g., "@search") with an empty string first.
- cursorCol := m.textarea.CursorColumn()
- m.textarea.ReplaceRange(atIndex, cursorCol, "")
-
- // Now, insert the attachment at the position where the '@' was.
- // The cursor is now at `atIndex` after the replacement.
- filePath := msg.Item.Value
- attachment := m.createAttachmentFromPath(filePath)
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
- case "symbols":
- atIndex := m.textarea.LastRuneIndex('@')
- if atIndex == -1 {
- // Should not happen, but as a fallback, just insert.
- m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
- }
-
- cursorCol := m.textarea.CursorColumn()
- m.textarea.ReplaceRange(atIndex, cursorCol, "")
-
- symbol := msg.Item.RawData.(opencode.Symbol)
- parts := strings.Split(symbol.Name, ".")
- lastPart := parts[len(parts)-1]
- attachment := &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "symbol",
- Display: "@" + lastPart,
- URL: msg.Item.Value,
- Filename: lastPart,
- MediaType: "text/plain",
- Source: &attachment.SymbolSource{
- Path: symbol.Location.Uri,
- Name: symbol.Name,
- Kind: int(symbol.Kind),
- Range: attachment.SymbolRange{
- Start: attachment.Position{
- Line: int(symbol.Location.Range.Start.Line),
- Char: int(symbol.Location.Range.Start.Character),
- },
- End: attachment.Position{
- Line: int(symbol.Location.Range.End.Line),
- Char: int(symbol.Location.Range.End.Character),
- },
- },
- },
- }
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
- case "agents":
- atIndex := m.textarea.LastRuneIndex('@')
- if atIndex == -1 {
- // Should not happen, but as a fallback, just insert.
- m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
- }
-
- cursorCol := m.textarea.CursorColumn()
- m.textarea.ReplaceRange(atIndex, cursorCol, "")
-
- name := msg.Item.Value
- attachment := &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "agent",
- Display: "@" + name,
- Source: &attachment.AgentSource{
- Name: name,
- },
- }
-
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
-
- default:
- slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
- return m, nil
- }
- }
-
- m.spinner, cmd = m.spinner.Update(msg)
- cmds = append(cmds, cmd)
-
- m.textarea, cmd = m.textarea.Update(msg)
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
-}
-
-func (m *editorComponent) Content() string {
- width := m.width
- if m.app.Session.ID == "" {
- width = min(width, 80)
- }
-
- t := theme.CurrentTheme()
- base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
- muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
-
- promptStyle := styles.NewStyle().Foreground(t.Primary()).
- Padding(0, 0, 0, 1).
- Bold(true)
- prompt := promptStyle.Render(">")
- borderForeground := t.Border()
- if m.app.IsLeaderSequence {
- borderForeground = t.Accent()
- }
- if m.app.IsBashMode {
- borderForeground = t.Secondary()
- prompt = promptStyle.Render("!")
- }
-
- m.textarea.SetWidth(width - 6)
- textarea := lipgloss.JoinHorizontal(
- lipgloss.Top,
- prompt,
- m.textarea.View(),
- )
- textarea = styles.NewStyle().
- Background(t.BackgroundElement()).
- Width(width).
- PaddingTop(1).
- PaddingBottom(1).
- BorderStyle(lipgloss.ThickBorder()).
- BorderForeground(borderForeground).
- BorderBackground(t.Background()).
- BorderLeft(true).
- BorderRight(true).
- Render(textarea)
-
- hint := base(m.getSubmitKeyText()) + muted(" send ")
- if m.exitKeyInDebounce {
- keyText := m.getExitKeyText()
- hint = base(keyText+" again") + muted(" to exit")
- } else if m.app.IsBusy() {
- keyText := m.getInterruptKeyText()
- status := "working"
- if m.app.IsCompacting() {
- status = "compacting"
- }
- if m.app.CurrentPermission.ID != "" {
- status = "waiting for permission"
- }
- if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" {
- hint = muted(
- status,
- ) + m.spinner.View() + muted(
- " ",
- ) + base(
- keyText+" again",
- ) + muted(
- " interrupt",
- )
- } else {
- hint = muted(status) + m.spinner.View()
- if m.app.CurrentPermission.ID == "" {
- hint += muted(" ") + base(keyText) + muted(" interrupt")
- }
- }
- }
-
- model := ""
- if m.app.Model != nil {
- model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
- }
-
- space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
- spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
-
- info := hint + spacer + model
- info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
-
- content := strings.Join([]string{"", textarea, info}, "\n")
- return content
-}
-
-func (m *editorComponent) Cursor() *tea.Cursor {
- return m.textarea.Cursor()
-}
-
-func (m *editorComponent) View() string {
- width := m.width
- if m.app.Session.ID == "" {
- width = min(width, 80)
- }
-
- if m.Lines() > 1 {
- return lipgloss.Place(
- width,
- 5,
- lipgloss.Center,
- lipgloss.Center,
- "",
- styles.WhitespaceStyle(theme.CurrentTheme().Background()),
- )
- }
- return m.Content()
-}
-
-func (m *editorComponent) Focused() bool {
- return m.textarea.Focused()
-}
-
-func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
- return m, m.textarea.Focus()
-}
-
-func (m *editorComponent) Blur() {
- m.textarea.Blur()
-}
-
-func (m *editorComponent) Lines() int {
- return m.textarea.LineCount()
-}
-
-func (m *editorComponent) Value() string {
- return m.textarea.Value()
-}
-
-func (m *editorComponent) Length() int {
- return m.textarea.Length()
-}
-
-func (m *editorComponent) GetAttachments() []*attachment.Attachment {
- return m.textarea.GetAttachments()
-}
-
-func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
- value := strings.TrimSpace(m.Value())
- if value == "" {
- return m, nil
- }
-
- switch value {
- case "exit", "quit", "q", ":q":
- return m, tea.Quit
- }
-
- if len(value) > 0 && value[len(value)-1] == '\\' {
- // If the last character is a backslash, remove it and add a newline
- backslashCol := m.textarea.CurrentRowLength() - 1
- m.textarea.ReplaceRange(backslashCol, backslashCol+1, "")
- m.textarea.InsertString("\n")
- return m, nil
- }
-
- var cmds []tea.Cmd
- if strings.HasPrefix(value, "/") {
- // Expand attachments in the value to get actual content
- expandedValue := value
- attachments := m.textarea.GetAttachments()
- for _, att := range attachments {
- if att.Type == "text" && att.Source != nil {
- if textSource, ok := att.Source.(*attachment.TextSource); ok {
- expandedValue = strings.Replace(expandedValue, att.Display, textSource.Value, 1)
- }
- }
- }
-
- expandedValue = expandedValue[1:] // Remove the "/"
- commandName := strings.Split(expandedValue, " ")[0]
- command := m.app.Commands[commands.CommandName(commandName)]
- if command.Custom {
- args := ""
- if strings.HasPrefix(expandedValue, command.PrimaryTrigger()+" ") {
- args = strings.TrimPrefix(expandedValue, command.PrimaryTrigger()+" ")
- }
- cmds = append(
- cmds,
- util.CmdHandler(app.SendCommand{Command: string(command.Name), Args: args}),
- )
-
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
- }
- }
-
- attachments := m.textarea.GetAttachments()
-
- prompt := app.Prompt{Text: value, Attachments: attachments}
- m.app.State.AddPromptToHistory(prompt)
- cmds = append(cmds, m.app.SaveState())
-
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- cmds = append(cmds, cmd)
-
- cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
- return m, tea.Batch(cmds...)
-}
-
-func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) {
- command := m.textarea.Value()
- var cmds []tea.Cmd
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- cmds = append(cmds, cmd)
- cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command}))
- return m, tea.Batch(cmds...)
-}
-
-func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
- m.textarea.Reset()
- m.historyIndex = -1
- m.currentText = ""
- m.pasteCounter = 0
- return m, nil
-}
-
-func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
- imageBytes := clipboard.Read(clipboard.FmtImage)
- if imageBytes != nil {
- attachmentCount := len(m.textarea.GetAttachments())
- attachmentIndex := attachmentCount + 1
- base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
- attachment := &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "file",
- MediaType: "image/png",
- Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
- Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
- URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
- Source: &attachment.FileSource{
- Path: fmt.Sprintf("image-%d.png", attachmentIndex),
- Mime: "image/png",
- Data: imageBytes,
- },
- }
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
- }
-
- textBytes := clipboard.Read(clipboard.FmtText)
- if textBytes != nil {
- text := string(textBytes)
- // Check if the pasted text is long and should be summarized
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(text))
- }
- return m, nil
- }
-
- // fallback to reading the clipboard using OSC52
- return m, tea.ReadClipboard
-}
-
-func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
- m.textarea.Newline()
- return m, nil
-}
-
-func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
- m.interruptKeyInDebounce = inDebounce
-}
-
-func (m *editorComponent) SetValue(value string) {
- m.textarea.SetValue(value)
-}
-
-func (m *editorComponent) SetValueWithAttachments(value string) {
- m.textarea.Reset()
-
- i := 0
- for i < len(value) {
- r, size := utf8.DecodeRuneInString(value[i:])
- // Check if filepath and add attachment
- if r == '@' {
- start := i + size
- end := start
- for end < len(value) {
- nextR, nextSize := utf8.DecodeRuneInString(value[end:])
- if nextR == ' ' || nextR == '\t' || nextR == '\n' || nextR == '\r' {
- break
- }
- end += nextSize
- }
- if end > start {
- filePath := value[start:end]
- if _, err := os.Stat(filepath.Join(util.CwdPath, filePath)); err == nil {
- attachment := m.createAttachmentFromFile(filePath)
- if attachment != nil {
- m.textarea.InsertAttachment(attachment)
- i = end
- continue
- }
- }
- }
- }
-
- // Not a valid file path, insert the character normally
- m.textarea.InsertRune(r)
- i += size
- }
-}
-
-func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
- m.exitKeyInDebounce = inDebounce
-}
-
-func (m *editorComponent) getInterruptKeyText() string {
- return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
-}
-
-func (m *editorComponent) getSubmitKeyText() string {
- return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
-}
-
-func (m *editorComponent) getExitKeyText() string {
- return m.app.Commands[commands.AppExitCommand].Keys()[0]
-}
-
-// shouldSummarizePastedText determines if pasted text should be summarized
-func (m *editorComponent) shouldSummarizePastedText(text string) bool {
- if m.app.IsBashMode {
- return false
- }
-
- if m.app.Config != nil && m.app.Config.Experimental.DisablePasteSummary {
- return false
- }
-
- lines := strings.Split(text, "\n")
- lineCount := len(lines)
- charCount := len(text)
-
- // Consider text long if it has more than 3 lines or more than 150 characters
- return lineCount > 3 || charCount > 150
-}
-
-// handleLongPaste handles long pasted text by creating a summary attachment
-func (m *editorComponent) handleLongPaste(text string) {
- lines := strings.Split(text, "\n")
- lineCount := len(lines)
-
- // Increment paste counter
- m.pasteCounter++
-
- // Create attachment with full text as base64 encoded data
- fileBytes := []byte(text)
- base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
- url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
-
- fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
- displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
-
- attachment := &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "text",
- MediaType: "text/plain",
- Display: displayText,
- URL: url,
- Filename: fileName,
- Source: &attachment.TextSource{
- Value: text,
- },
- }
-
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
-}
-
-func updateTextareaStyles(ta textarea.Model) textarea.Model {
- t := theme.CurrentTheme()
- bgColor := t.BackgroundElement()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
- ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
- ta.Styles.Blurred.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
- ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
- ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
- ta.Styles.Focused.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
- ta.Styles.Attachment = styles.NewStyle().
- Foreground(t.Secondary()).
- Background(bgColor).
- Lipgloss()
- ta.Styles.SelectedAttachment = styles.NewStyle().
- Foreground(t.Text()).
- Background(t.Secondary()).
- Lipgloss()
- ta.Styles.Cursor.Color = t.Primary()
- return ta
-}
-
-func createSpinner() spinner.Model {
- t := theme.CurrentTheme()
- return spinner.New(
- spinner.WithSpinner(spinner.Ellipsis),
- spinner.WithStyle(
- styles.NewStyle().
- Background(t.Background()).
- Foreground(t.TextMuted()).
- Width(3).
- Lipgloss(),
- ),
- )
-}
-
-func NewEditorComponent(app *app.App) EditorComponent {
- s := createSpinner()
-
- ta := textarea.New()
- ta.Prompt = " "
- ta.ShowLineNumbers = false
- ta.CharLimit = -1
- ta.VirtualCursor = false
- ta = updateTextareaStyles(ta)
-
- m := &editorComponent{
- app: app,
- textarea: ta,
- spinner: s,
- interruptKeyInDebounce: false,
- historyIndex: -1,
- pasteCounter: 0,
- }
-
- return m
-}
-
-func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) {
- m.textarea.Reset()
- m.textarea.SetValue(prompt.Text)
-
- // Sort attachments by start index in reverse order (process from end to beginning)
- // This prevents index shifting issues
- attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments))
- copy(attachmentsCopy, prompt.Attachments)
-
- for i := 0; i < len(attachmentsCopy)-1; i++ {
- for j := i + 1; j < len(attachmentsCopy); j++ {
- if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
- attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
- }
- }
- }
-
- for _, att := range attachmentsCopy {
- m.textarea.SetCursorColumn(att.StartIndex)
- m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
- m.textarea.InsertAttachment(att)
- }
-}
-
-// RestoreFromHistory restores a message from history at the given index
-func (m *editorComponent) RestoreFromHistory(index int) {
- if index < 0 || index >= len(m.app.State.MessageHistory) {
- return
- }
- entry := m.app.State.MessageHistory[index]
- m.RestoreFromPrompt(entry)
-}
-
-func getMediaTypeFromExtension(ext string) string {
- switch strings.ToLower(ext) {
- case ".jpg":
- return "image/jpeg"
- case ".png", ".jpeg", ".gif", ".webp":
- return "image/" + ext[1:]
- case ".pdf":
- return "application/pdf"
- default:
- return "text/plain"
- }
-}
-
-func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
- ext := strings.ToLower(filepath.Ext(filePath))
- mediaType := getMediaTypeFromExtension(ext)
- absolutePath := filePath
- if !filepath.IsAbs(filePath) {
- absolutePath = filepath.Join(util.CwdPath, filePath)
- }
-
- // For text files, create a simple file reference
- if mediaType == "text/plain" {
- return &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "file",
- Display: "@" + filePath,
- URL: fmt.Sprintf("file://%s", absolutePath),
- Filename: filePath,
- MediaType: mediaType,
- Source: &attachment.FileSource{
- Path: absolutePath,
- Mime: mediaType,
- },
- }
- }
-
- // For binary files (images, PDFs), read and encode
- fileBytes, err := os.ReadFile(filePath)
- if err != nil {
- slog.Error("Failed to read file", "error", err)
- return nil
- }
-
- base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
- url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
- attachmentCount := len(m.textarea.GetAttachments())
- attachmentIndex := attachmentCount + 1
- label := "File"
- if strings.HasPrefix(mediaType, "image/") {
- label = "Image"
- }
- return &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "file",
- MediaType: mediaType,
- Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
- URL: url,
- Filename: filePath,
- Source: &attachment.FileSource{
- Path: absolutePath,
- Mime: mediaType,
- Data: fileBytes,
- },
- }
-}
-
-func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
- extension := filepath.Ext(filePath)
- mediaType := getMediaTypeFromExtension(extension)
- absolutePath := filePath
- if !filepath.IsAbs(filePath) {
- absolutePath = filepath.Join(util.CwdPath, filePath)
- }
- return &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "file",
- Display: "@" + filePath,
- URL: fmt.Sprintf("file://%s", absolutePath),
- Filename: filePath,
- MediaType: mediaType,
- Source: &attachment.FileSource{
- Path: absolutePath,
- Mime: mediaType,
- },
- }
-}
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
deleted file mode 100644
index 801545a88..000000000
--- a/packages/tui/internal/components/chat/message.go
+++ /dev/null
@@ -1,1031 +0,0 @@
-package chat
-
-import (
- "encoding/json"
- "fmt"
- "maps"
- "slices"
- "strings"
- "time"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/charmbracelet/x/ansi"
- "github.com/muesli/reflow/truncate"
- "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/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
- "golang.org/x/text/cases"
- "golang.org/x/text/language"
-)
-
-type blockRenderer struct {
- textColor compat.AdaptiveColor
- backgroundColor compat.AdaptiveColor
- border bool
- borderColor *compat.AdaptiveColor
- borderLeft bool
- borderRight bool
- paddingTop int
- paddingBottom int
- paddingLeft int
- paddingRight int
- marginTop int
- marginBottom int
-}
-
-type renderingOption func(*blockRenderer)
-
-func WithTextColor(color compat.AdaptiveColor) renderingOption {
- return func(c *blockRenderer) {
- c.textColor = color
- }
-}
-
-func WithBackgroundColor(color compat.AdaptiveColor) renderingOption {
- return func(c *blockRenderer) {
- c.backgroundColor = color
- }
-}
-
-func WithNoBorder() renderingOption {
- return func(c *blockRenderer) {
- c.border = false
- c.paddingLeft++
- c.paddingRight++
- }
-}
-
-func WithBorderColor(color compat.AdaptiveColor) renderingOption {
- return func(c *blockRenderer) {
- c.borderColor = &color
- }
-}
-
-func WithBorderLeft() renderingOption {
- return func(c *blockRenderer) {
- c.borderLeft = true
- c.borderRight = false
- }
-}
-
-func WithBorderRight() renderingOption {
- return func(c *blockRenderer) {
- c.borderLeft = false
- c.borderRight = true
- }
-}
-
-func WithBorderBoth(value bool) renderingOption {
- return func(c *blockRenderer) {
- if value {
- c.borderLeft = true
- c.borderRight = true
- }
- }
-}
-
-func WithMarginTop(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.marginTop = padding
- }
-}
-
-func WithMarginBottom(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.marginBottom = padding
- }
-}
-
-func WithPadding(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingTop = padding
- c.paddingBottom = padding
- c.paddingLeft = padding
- c.paddingRight = padding
- }
-}
-
-func WithPaddingLeft(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingLeft = padding
- }
-}
-
-func WithPaddingRight(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingRight = padding
- }
-}
-
-func WithPaddingTop(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingTop = padding
- }
-}
-
-func WithPaddingBottom(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingBottom = padding
- }
-}
-
-func renderContentBlock(
- app *app.App,
- content string,
- width int,
- options ...renderingOption,
-) string {
- t := theme.CurrentTheme()
- renderer := &blockRenderer{
- textColor: t.TextMuted(),
- backgroundColor: t.BackgroundPanel(),
- border: true,
- borderLeft: true,
- borderRight: false,
- paddingTop: 1,
- paddingBottom: 1,
- paddingLeft: 2,
- paddingRight: 2,
- }
- for _, option := range options {
- option(renderer)
- }
-
- borderColor := t.BackgroundPanel()
- if renderer.borderColor != nil {
- borderColor = *renderer.borderColor
- }
-
- style := styles.NewStyle().
- Foreground(renderer.textColor).
- Background(renderer.backgroundColor).
- PaddingTop(renderer.paddingTop).
- PaddingBottom(renderer.paddingBottom).
- PaddingLeft(renderer.paddingLeft).
- PaddingRight(renderer.paddingRight).
- AlignHorizontal(lipgloss.Left)
-
- if renderer.border {
- style = style.
- BorderStyle(lipgloss.ThickBorder()).
- BorderLeft(true).
- BorderRight(true).
- BorderLeftForeground(t.BackgroundPanel()).
- BorderLeftBackground(t.Background()).
- BorderRightForeground(t.BackgroundPanel()).
- BorderRightBackground(t.Background())
-
- if renderer.borderLeft {
- style = style.BorderLeftForeground(borderColor)
- }
- if renderer.borderRight {
- style = style.BorderRightForeground(borderColor)
- }
- } else {
- style = style.PaddingLeft(renderer.paddingLeft).PaddingRight(renderer.paddingRight)
- }
-
- content = style.Render(content)
- if renderer.marginTop > 0 {
- for range renderer.marginTop {
- content = "\n" + content
- }
- }
- if renderer.marginBottom > 0 {
- for range renderer.marginBottom {
- content = content + "\n"
- }
- }
-
- return content
-}
-
-func renderText(
- app *app.App,
- message opencode.MessageUnion,
- text string,
- author string,
- showToolDetails bool,
- width int,
- extra string,
- isThinking bool,
- isQueued bool,
- shimmer bool,
- fileParts []opencode.FilePart,
- agentParts []opencode.AgentPart,
- toolCalls ...opencode.ToolPart,
-) string {
- t := theme.CurrentTheme()
-
- var ts time.Time
- backgroundColor := t.BackgroundPanel()
- var content string
- switch casted := message.(type) {
- case opencode.AssistantMessage:
- backgroundColor = t.Background()
- if isThinking {
- backgroundColor = t.BackgroundPanel()
- }
- ts = time.UnixMilli(int64(casted.Time.Created))
- if casted.Time.Completed > 0 {
- ts = time.UnixMilli(int64(casted.Time.Completed))
- }
- content = util.ToMarkdown(text, width, backgroundColor)
- if isThinking {
- var label string
- if shimmer {
- label = util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent())
- } else {
- label = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking...")
- }
- label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
- content = label + "\n\n" + content
- } else if strings.TrimSpace(text) == "Generating..." {
- label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text())
- label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
- content = label
- }
- case opencode.UserMessage:
- ts = time.UnixMilli(int64(casted.Time.Created))
- base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
-
- var result strings.Builder
- lastEnd := int64(0)
-
- // Apply highlighting to filenames and base style to rest of text BEFORE wrapping
- textLen := int64(len(text))
-
- // Collect all parts to highlight (both file and agent parts)
- type highlightPart struct {
- start int64
- end int64
- color compat.AdaptiveColor
- }
- var highlights []highlightPart
-
- // Add file parts with secondary color
- for _, filePart := range fileParts {
- highlights = append(highlights, highlightPart{
- start: filePart.Source.Text.Start,
- end: filePart.Source.Text.End,
- color: t.Secondary(),
- })
- }
-
- // Add agent parts with secondary color (same as file parts)
- for _, agentPart := range agentParts {
- highlights = append(highlights, highlightPart{
- start: agentPart.Source.Start,
- end: agentPart.Source.End,
- color: t.Secondary(),
- })
- }
-
- // Sort highlights by start position
- slices.SortFunc(highlights, func(a, b highlightPart) int {
- if a.start < b.start {
- return -1
- }
- if a.start > b.start {
- return 1
- }
- return 0
- })
-
- // Merge overlapping highlights to prevent duplication
- merged := make([]highlightPart, 0)
- for _, part := range highlights {
- if len(merged) == 0 {
- merged = append(merged, part)
- continue
- }
-
- last := &merged[len(merged)-1]
- // If current part overlaps with the last one, merge them
- if part.start <= last.end {
- if part.end > last.end {
- last.end = part.end
- }
- } else {
- merged = append(merged, part)
- }
- }
-
- for _, part := range merged {
- highlight := base.Foreground(part.color)
- start, end := part.start, part.end
-
- if end > textLen {
- end = textLen
- }
- if start > textLen {
- start = textLen
- }
-
- if start > lastEnd {
- result.WriteString(base.Render(text[lastEnd:start]))
- }
- if start < end {
- result.WriteString(highlight.Render(text[start:end]))
- }
-
- lastEnd = end
- }
-
- if lastEnd < textLen {
- result.WriteString(base.Render(text[lastEnd:]))
- }
-
- // wrap styled text
- styledText := result.String()
- styledText = strings.ReplaceAll(styledText, "-", "\u2011")
- wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
- wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
- content = base.Width(width - 6).Render(wrappedText)
- if isQueued {
- queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1)
- content = queuedStyle.Render("QUEUED") + "\n\n" + content
- }
- }
-
- timestamp := ts.
- Local().
- Format("02 Jan 2006 03:04 PM")
- if time.Now().Format("02 Jan 2006") == timestamp[:11] {
- timestamp = timestamp[12:]
- }
- timestamp = styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.TextMuted()).
- Render(" (" + timestamp + ")")
-
- // Check if this is an assistant message with agent information
- var modelAndAgentSuffix string
- if assistantMsg, ok := message.(opencode.AssistantMessage); ok && assistantMsg.Mode != "" {
- // Find the agent index by name to get the correct color
- var agentIndex int
- for i, agent := range app.Agents {
- if agent.Name == assistantMsg.Mode {
- agentIndex = i
- break
- }
- }
-
- // Get agent color based on the original agent index (same as status bar)
- agentColor := util.GetAgentColor(agentIndex)
-
- // Style the agent name with the same color as status bar
- agentName := cases.Title(language.Und).String(assistantMsg.Mode)
- styledAgentName := styles.NewStyle().
- Background(backgroundColor).
- Foreground(agentColor).
- Render(agentName + " ")
- styledModelID := styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.TextMuted()).
- Render(assistantMsg.ModelID)
- modelAndAgentSuffix = styledAgentName + styledModelID
- }
-
- var info string
- if modelAndAgentSuffix != "" {
- info = modelAndAgentSuffix + timestamp
- } else {
- info = author + timestamp
- }
- if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
- for _, toolCall := range toolCalls {
- title := renderToolTitle(toolCall, width-2)
- style := styles.NewStyle()
- if toolCall.State.Status == opencode.ToolPartStateStatusError {
- style = style.Foreground(t.Error())
- }
- title = style.Render(title)
- title = "\n∟ " + title
- content = content + title
- }
- }
-
- sections := []string{content}
- if extra != "" {
- sections = append(sections, "\n"+extra+"\n")
- }
- sections = append(sections, info)
- content = strings.Join(sections, "\n")
-
- switch message.(type) {
- case opencode.UserMessage:
- borderColor := t.Secondary()
- if isQueued {
- borderColor = t.Accent()
- }
- return renderContentBlock(
- app,
- content,
- width,
- WithTextColor(t.Text()),
- WithBorderColor(borderColor),
- )
- case opencode.AssistantMessage:
- if isThinking {
- return renderContentBlock(
- app,
- content,
- width,
- WithTextColor(t.Text()),
- WithBackgroundColor(t.BackgroundPanel()),
- WithBorderColor(t.BackgroundPanel()),
- )
- }
- return renderContentBlock(
- app,
- content,
- width,
- WithNoBorder(),
- WithBackgroundColor(t.Background()),
- )
- }
- return ""
-}
-
-func renderToolDetails(
- app *app.App,
- toolCall opencode.ToolPart,
- permission opencode.Permission,
- width int,
-) string {
- measure := util.Measure("chat.renderToolDetails")
- defer measure("tool", toolCall.Tool)
- ignoredTools := []string{"todoread"}
- if slices.Contains(ignoredTools, toolCall.Tool) {
- return ""
- }
-
- if toolCall.State.Status == opencode.ToolPartStateStatusPending {
- title := renderToolTitle(toolCall, width)
- return renderContentBlock(app, title, width)
- }
-
- var result *string
- if toolCall.State.Output != "" {
- result = &toolCall.State.Output
- }
-
- toolInputMap := make(map[string]any)
- if toolCall.State.Input != nil {
- value := toolCall.State.Input
- if m, ok := value.(map[string]any); ok {
- toolInputMap = m
- keys := make([]string, 0, len(toolInputMap))
- for key := range toolInputMap {
- keys = append(keys, key)
- }
- slices.Sort(keys)
- }
- }
-
- body := ""
- t := theme.CurrentTheme()
- backgroundColor := t.BackgroundPanel()
- borderColor := t.BackgroundPanel()
- defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
- baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render
- mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render
-
- permissionContent := ""
- if permission.ID != "" {
- borderColor = t.Warning()
-
- base := styles.NewStyle().Background(backgroundColor)
- text := base.Foreground(t.Text()).Bold(true).Render
- muted := base.Foreground(t.TextMuted()).Render
- if permission.Type == "doom-loop" {
- permissionContent = permission.Title + "\n\n"
- } else {
- permissionContent = "Permission required to run this tool:\n\n"
- }
- permissionContent += text(
- "enter ",
- ) + muted(
- "accept ",
- ) + text(
- "a",
- ) + muted(
- " accept always ",
- ) + text(
- "esc",
- ) + muted(
- " reject",
- )
-
- }
-
- if permission.Metadata != nil {
- metadata, ok := toolCall.State.Metadata.(map[string]any)
- if metadata == nil || !ok {
- metadata = map[string]any{}
- }
- maps.Copy(metadata, permission.Metadata)
- toolCall.State.Metadata = metadata
- }
-
- if toolCall.State.Metadata != nil {
- metadata := toolCall.State.Metadata.(map[string]any)
- switch toolCall.Tool {
- case "read":
- var preview any
- if metadata != nil {
- preview = metadata["preview"]
- }
- if preview != nil && toolInputMap["filePath"] != nil {
- filename := toolInputMap["filePath"].(string)
- body = preview.(string)
- body = util.RenderFile(filename, body, width, util.WithTruncate(6))
- }
- case "edit":
- if filename, ok := toolInputMap["filePath"].(string); ok {
- var diffField any
- if metadata != nil {
- diffField = metadata["diff"]
- }
- if diffField != nil {
- patch := diffField.(string)
- var formattedDiff string
- if width < 120 {
- formattedDiff, _ = diff.FormatUnifiedDiff(
- filename,
- patch,
- diff.WithWidth(width-2),
- )
- } else {
- formattedDiff, _ = diff.FormatDiff(
- filename,
- patch,
- diff.WithWidth(width-2),
- )
- }
- body = strings.TrimSpace(formattedDiff)
- style := styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.TextMuted()).
- Padding(1, 2).
- Width(width - 4)
-
- if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
- diagnostics = style.Render(diagnostics)
- body += "\n" + diagnostics
- }
-
- title := renderToolTitle(toolCall, width)
- title = style.Render(title)
- content := title + "\n" + body
-
- if toolCall.State.Status == opencode.ToolPartStateStatusError {
- errorStyle := styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.Error()).
- Padding(1, 2).
- Width(width - 4)
- errorContent := errorStyle.Render(toolCall.State.Error)
- content += "\n" + errorContent
- }
-
- if permissionContent != "" {
- permissionContent = styles.NewStyle().
- Background(backgroundColor).
- Padding(1, 2).
- Render(permissionContent)
- content += "\n" + permissionContent
- }
- content = renderContentBlock(
- app,
- content,
- width,
- WithPadding(0),
- WithBorderColor(borderColor),
- WithBorderBoth(permission.ID != ""),
- )
- return content
- }
- }
- case "write":
- if filename, ok := toolInputMap["filePath"].(string); ok {
- if content, ok := toolInputMap["content"].(string); ok {
- body = util.RenderFile(filename, content, width)
- if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
- body += "\n\n" + diagnostics
- }
- }
- }
- case "bash":
- if command, ok := toolInputMap["command"].(string); ok {
- body = fmt.Sprintf("```console\n$ %s\n", command)
- output := metadata["output"]
- if output != nil {
- body += ansi.Strip(fmt.Sprintf("%s", output))
- }
- body += "```"
- body = util.ToMarkdown(body, width, backgroundColor)
- }
- case "webfetch":
- if format, ok := toolInputMap["format"].(string); ok && result != nil {
- body = *result
- body = util.TruncateHeight(body, 10)
- if format == "html" || format == "markdown" {
- body = util.ToMarkdown(body, width, backgroundColor)
- }
- }
- case "todowrite":
- todos := metadata["todos"]
- if todos != nil {
- for _, item := range todos.([]any) {
- todo := item.(map[string]any)
- content := todo["content"]
- if content == nil {
- continue
- }
- switch todo["status"] {
- case "completed":
- body += fmt.Sprintf("- [x] %s\n", content)
- case "cancelled":
- // strike through cancelled todo
- body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
- case "in_progress":
- // highlight in progress todo
- body += fmt.Sprintf("- [ ] `%s`\n", content)
- default:
- body += fmt.Sprintf("- [ ] %s\n", content)
- }
- }
- body = util.ToMarkdown(body, width, backgroundColor)
- }
- case "task":
- summary := metadata["summary"]
- if summary != nil {
- toolcalls := summary.([]any)
- steps := []string{}
- for _, item := range toolcalls {
- data, _ := json.Marshal(item)
- var toolCall opencode.ToolPart
- _ = json.Unmarshal(data, &toolCall)
- step := renderToolTitle(toolCall, width-2)
- step = "∟ " + step
- steps = append(steps, step)
- }
- body = strings.Join(steps, "\n")
-
- body += "\n\n"
-
- // Build navigation hint with proper spacing
- cycleKeybind := app.Keybind(commands.SessionChildCycleCommand)
- cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand)
-
- var navParts []string
- if cycleKeybind != "" {
- navParts = append(navParts, baseStyle(cycleKeybind))
- }
- if cycleReverseKeybind != "" {
- navParts = append(navParts, baseStyle(cycleReverseKeybind))
- }
-
- if len(navParts) > 0 {
- body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions")
- }
- }
- body = defaultStyle(body)
- default:
- if result == nil {
- empty := ""
- result = &empty
- }
- body = *result
- body = util.TruncateHeight(body, 10)
- body = defaultStyle(body)
- }
- }
-
- error := ""
- if toolCall.State.Status == opencode.ToolPartStateStatusError {
- error = toolCall.State.Error
- }
-
- if error != "" {
- errorContent := styles.NewStyle().
- Width(width - 6).
- Foreground(t.Error()).
- Background(backgroundColor).
- Render(error)
-
- if body == "" {
- body = errorContent
- } else {
- body += "\n\n" + errorContent
- }
- }
-
- if body == "" && error == "" && result != nil {
- body = *result
- body = util.TruncateHeight(body, 10)
- body = defaultStyle(body)
- }
-
- if body == "" {
- body = defaultStyle("")
- }
-
- title := renderToolTitle(toolCall, width)
- content := title + "\n\n" + body
-
- if permissionContent != "" {
- content += "\n\n\n" + permissionContent
- }
-
- return renderContentBlock(
- app,
- content,
- width,
- WithBorderColor(borderColor),
- WithBorderBoth(permission.ID != ""),
- )
-}
-
-func renderToolName(name string) string {
- switch name {
- case "bash":
- return "Shell"
- case "webfetch":
- return "Fetch"
- case "invalid":
- return "Invalid"
- default:
- normalizedName := name
- if after, ok := strings.CutPrefix(name, "opencode_"); ok {
- normalizedName = after
- }
- return cases.Title(language.Und).String(normalizedName)
- }
-}
-
-func getTodoPhase(metadata map[string]any) string {
- todos, ok := metadata["todos"].([]any)
- if !ok || len(todos) == 0 {
- return "Plan"
- }
-
- counts := map[string]int{"pending": 0, "completed": 0}
- for _, item := range todos {
- if todo, ok := item.(map[string]any); ok {
- if status, ok := todo["status"].(string); ok {
- counts[status]++
- }
- }
- }
-
- total := len(todos)
- switch {
- case counts["pending"] == total:
- return "Creating plan"
- case counts["completed"] == total:
- return "Completing plan"
- default:
- return "Updating plan"
- }
-}
-
-func getTodoTitle(toolCall opencode.ToolPart) string {
- if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
- if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
- return getTodoPhase(metadata)
- }
- }
- return "Plan"
-}
-
-func renderToolTitle(
- toolCall opencode.ToolPart,
- width int,
-) string {
- if toolCall.State.Status == opencode.ToolPartStateStatusPending {
- title := renderToolAction(toolCall.Tool)
- t := theme.CurrentTheme()
- shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
- return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny)
- }
-
- toolArgs := ""
- toolArgsMap := make(map[string]any)
- if toolCall.State.Input != nil {
- value := toolCall.State.Input
- if m, ok := value.(map[string]any); ok {
- toolArgsMap = m
-
- keys := make([]string, 0, len(toolArgsMap))
- for key := range toolArgsMap {
- keys = append(keys, key)
- }
- slices.Sort(keys)
- firstKey := ""
- if len(keys) > 0 {
- firstKey = keys[0]
- }
-
- toolArgs = renderArgs(&toolArgsMap, firstKey)
- }
- }
-
- title := renderToolName(toolCall.Tool)
- switch toolCall.Tool {
- case "read":
- toolArgs = renderArgs(&toolArgsMap, "filePath")
- title = fmt.Sprintf("%s %s", title, toolArgs)
- case "edit", "write":
- if filename, ok := toolArgsMap["filePath"].(string); ok {
- title = fmt.Sprintf("%s %s", title, util.Relative(filename))
- }
- case "bash":
- if description, ok := toolArgsMap["description"].(string); ok {
- title = fmt.Sprintf("%s %s", title, description)
- }
- case "task":
- description := toolArgsMap["description"]
- subagent := toolArgsMap["subagent_type"]
- if description != nil && subagent != nil {
- title = fmt.Sprintf("%s[%s] %s", title, subagent, description)
- } else if description != nil {
- title = fmt.Sprintf("%s %s", title, description)
- }
- case "webfetch":
- toolArgs = renderArgs(&toolArgsMap, "url")
- title = fmt.Sprintf("%s %s", title, toolArgs)
- case "todowrite":
- title = getTodoTitle(toolCall)
- case "todoread":
- return "Plan"
- case "invalid":
- if actualTool, ok := toolArgsMap["tool"].(string); ok {
- title = renderToolName(actualTool)
- }
- default:
- toolName := renderToolName(toolCall.Tool)
- title = fmt.Sprintf("%s %s", toolName, toolArgs)
- }
-
- title = truncate.StringWithTail(title, uint(width-6), "...")
- if toolCall.State.Error != "" {
- t := theme.CurrentTheme()
- title = styles.NewStyle().Foreground(t.Error()).Render(title)
- }
- return title
-}
-
-func renderToolAction(name string) string {
- switch name {
- case "task":
- return "Delegating..."
- case "bash":
- return "Writing command..."
- case "edit":
- return "Preparing edit..."
- case "webfetch":
- return "Fetching from the web..."
- case "glob":
- return "Finding files..."
- case "grep":
- return "Searching content..."
- case "list":
- return "Listing directory..."
- case "read":
- return "Reading file..."
- case "write":
- return "Preparing write..."
- case "todowrite", "todoread":
- return "Planning..."
- case "patch":
- return "Preparing patch..."
- }
- return "Working..."
-}
-
-func renderArgs(args *map[string]any, titleKey string) string {
- if args == nil || len(*args) == 0 {
- return ""
- }
-
- keys := make([]string, 0, len(*args))
- for key := range *args {
- keys = append(keys, key)
- }
- slices.Sort(keys)
-
- title := ""
- parts := []string{}
- for _, key := range keys {
- value := (*args)[key]
- if value == nil {
- continue
- }
- if key == "filePath" || key == "path" {
- if strValue, ok := value.(string); ok {
- value = util.Relative(strValue)
- }
- }
- if key == titleKey {
- title = fmt.Sprintf("%s", value)
- continue
- }
- parts = append(parts, fmt.Sprintf("%s=%v", key, value))
- }
- if len(parts) == 0 {
- return title
- }
- return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
-}
-
-// Diagnostic represents an LSP diagnostic
-type Diagnostic struct {
- Range struct {
- Start struct {
- Line int `json:"line"`
- Character int `json:"character"`
- } `json:"start"`
- } `json:"range"`
- Severity int `json:"severity"`
- Message string `json:"message"`
-}
-
-// renderDiagnostics formats LSP diagnostics for display in the TUI
-func renderDiagnostics(
- metadata map[string]any,
- filePath string,
- backgroundColor compat.AdaptiveColor,
- width int,
-) string {
- if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
- if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
- var errorDiagnostics []string
- for _, diagInterface := range fileDiagnostics {
- diagMap, ok := diagInterface.(map[string]any)
- if !ok {
- continue
- }
- // Parse the diagnostic
- var diag Diagnostic
- diagBytes, err := json.Marshal(diagMap)
- if err != nil {
- continue
- }
- if err := json.Unmarshal(diagBytes, &diag); err != nil {
- continue
- }
- // Only show error diagnostics (severity === 1)
- if diag.Severity != 1 {
- continue
- }
- line := diag.Range.Start.Line + 1 // 1-based
- column := diag.Range.Start.Character + 1 // 1-based
- errorDiagnostics = append(
- errorDiagnostics,
- fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
- )
- }
- if len(errorDiagnostics) == 0 {
- return ""
- }
- t := theme.CurrentTheme()
- var result strings.Builder
- for _, diagnostic := range errorDiagnostics {
- if result.Len() > 0 {
- result.WriteString("\n\n")
- }
- diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
- result.WriteString(
- styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.Error()).
- Render(diagnostic),
- )
- }
- return result.String()
- }
- }
- return ""
-
- // diagnosticsData should be a map[string][]Diagnostic
- // strDiagnosticsData := diagnosticsData.Raw()
- // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
- // fileDiagnostics, ok := diagnosticsMap[filePath]
- // if !ok {
- // return ""
- // }
-
- // diagnosticsList, ok := fileDiagnostics.([]any)
- // if !ok {
- // return ""
- // }
-
-}
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
deleted file mode 100644
index 3d52b84e5..000000000
--- a/packages/tui/internal/components/chat/messages.go
+++ /dev/null
@@ -1,1322 +0,0 @@
-package chat
-
-import (
- "context"
- "fmt"
- "log/slog"
- "slices"
- "sort"
- "strconv"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "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/dialog"
- "github.com/sst/opencode/internal/components/diff"
- "github.com/sst/opencode/internal/components/toast"
- "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/sst/opencode/internal/viewport"
-)
-
-type MessagesComponent interface {
- tea.Model
- tea.ViewModel
- PageUp() (tea.Model, tea.Cmd)
- PageDown() (tea.Model, tea.Cmd)
- HalfPageUp() (tea.Model, tea.Cmd)
- HalfPageDown() (tea.Model, tea.Cmd)
- ToolDetailsVisible() bool
- ThinkingBlocksVisible() bool
- GotoTop() (tea.Model, tea.Cmd)
- GotoBottom() (tea.Model, tea.Cmd)
- CopyLastMessage() (tea.Model, tea.Cmd)
- UndoLastMessage() (tea.Model, tea.Cmd)
- RedoLastMessage() (tea.Model, tea.Cmd)
- ScrollToMessage(messageID string) (tea.Model, tea.Cmd)
-}
-
-type messagesComponent struct {
- width, height int
- app *app.App
- header string
- viewport viewport.Model
- clipboard []string
- cache *PartCache
- loading bool
- showToolDetails bool
- showThinkingBlocks bool
- rendering bool
- dirty bool
- tail bool
- partCount int
- lineCount int
- selection *selection
- messagePositions map[string]int // map message ID to line position
- animating bool
-}
-
-type selection struct {
- startX int
- endX int
- startY int
- endY int
-}
-
-func (s selection) coords(offset int) *selection {
- // selecting backwards
- if s.startY > s.endY && s.endY >= 0 {
- return &selection{
- startX: max(0, s.endX-1),
- startY: s.endY - offset,
- endX: s.startX + 1,
- endY: s.startY - offset,
- }
- }
-
- // selecting backwards same line
- if s.startY == s.endY && s.startX >= s.endX {
- return &selection{
- startY: s.startY - offset,
- startX: max(0, s.endX-1),
- endY: s.endY - offset,
- endX: s.startX + 1,
- }
- }
-
- return &selection{
- startX: s.startX,
- startY: s.startY - offset,
- endX: s.endX,
- endY: s.endY - offset,
- }
-}
-
-type ToggleToolDetailsMsg struct{}
-type ToggleThinkingBlocksMsg struct{}
-type shimmerTickMsg struct{}
-
-func (m *messagesComponent) Init() tea.Cmd {
- return tea.Batch(m.viewport.Init())
-}
-
-func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case shimmerTickMsg:
- if !m.app.HasAnimatingWork() {
- m.animating = false
- return m, nil
- }
- return m, tea.Sequence(
- m.renderView(),
- tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }),
- )
- case tea.MouseClickMsg:
- slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
- y := msg.Y + m.viewport.YOffset
- if y > 0 {
- m.selection = &selection{
- startY: y,
- startX: msg.X,
- endY: -1,
- endX: -1,
- }
-
- slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY))
- return m, m.renderView()
- }
-
- case tea.MouseMotionMsg:
- if m.selection != nil {
- m.selection = &selection{
- startX: m.selection.startX,
- startY: m.selection.startY,
- endX: msg.X + 1,
- endY: msg.Y + m.viewport.YOffset,
- }
- return m, m.renderView()
- }
-
- case tea.MouseReleaseMsg:
- if m.selection != nil {
- m.selection = nil
- if len(m.clipboard) > 0 {
- content := strings.Join(m.clipboard, "\n")
- m.clipboard = []string{}
- return m, tea.Sequence(
- m.renderView(),
- app.SetClipboard(content),
- toast.NewSuccessToast("Copied to clipboard"),
- )
- }
- return m, m.renderView()
- }
- case tea.WindowSizeMsg:
- effectiveWidth := msg.Width - 4
- // Clear cache on resize since width affects rendering
- if m.width != effectiveWidth {
- m.cache.Clear()
- }
- m.width = effectiveWidth
- m.height = msg.Height - 7
- m.viewport.SetWidth(m.width)
- m.loading = true
- return m, m.renderView()
- case app.SendPrompt:
- m.viewport.GotoBottom()
- m.tail = true
- return m, nil
- case app.SendCommand:
- m.viewport.GotoBottom()
- m.tail = true
- return m, nil
- case dialog.ThemeSelectedMsg:
- m.cache.Clear()
- m.loading = true
- return m, m.renderView()
- case ToggleToolDetailsMsg:
- m.showToolDetails = !m.showToolDetails
- m.app.State.ShowToolDetails = &m.showToolDetails
- return m, tea.Batch(m.renderView(), m.app.SaveState())
- case ToggleThinkingBlocksMsg:
- m.showThinkingBlocks = !m.showThinkingBlocks
- m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
- return m, tea.Batch(m.renderView(), m.app.SaveState())
- case app.SessionLoadedMsg:
- m.tail = true
- m.loading = true
- return m, m.renderView()
- case app.SessionClearedMsg:
- m.cache.Clear()
- m.tail = true
- m.loading = true
- return m, m.renderView()
- case app.SessionUnrevertedMsg:
- if msg.Session.ID == m.app.Session.ID {
- m.cache.Clear()
- m.tail = true
- return m, m.renderView()
- }
- case app.SessionSelectedMsg:
- currentParent := m.app.Session.ParentID
- if currentParent == "" {
- currentParent = m.app.Session.ID
- }
-
- targetParent := msg.ParentID
- if targetParent == "" {
- targetParent = msg.ID
- }
-
- // Clear cache only if switching between different session families
- if currentParent != targetParent {
- m.cache.Clear()
- }
-
- m.viewport.GotoBottom()
- case app.MessageRevertedMsg:
- if msg.Session.ID == m.app.Session.ID {
- m.cache.Clear()
- m.tail = true
- return m, m.renderView()
- }
-
- case opencode.EventListResponseEventSessionUpdated:
- if msg.Properties.Info.ID == m.app.Session.ID {
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventMessageUpdated:
- if msg.Properties.Info.SessionID == m.app.Session.ID {
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventSessionError:
- if msg.Properties.SessionID == m.app.Session.ID {
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventMessagePartUpdated:
- if msg.Properties.Part.SessionID == m.app.Session.ID {
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventMessageRemoved:
- if msg.Properties.SessionID == m.app.Session.ID {
- m.cache.Clear()
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventMessagePartRemoved:
- if msg.Properties.SessionID == m.app.Session.ID {
- // Clear the cache when a part is removed to ensure proper re-rendering
- m.cache.Clear()
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventPermissionUpdated:
- m.tail = true
- return m, m.renderView()
- case opencode.EventListResponseEventPermissionReplied:
- m.tail = true
- return m, m.renderView()
- case renderCompleteMsg:
- m.partCount = msg.partCount
- m.lineCount = msg.lineCount
- m.rendering = false
- m.clipboard = msg.clipboard
- m.loading = false
- m.messagePositions = msg.messagePositions
- m.tail = m.viewport.AtBottom()
-
- // Preserve scroll across reflow
- // if the user was at bottom, keep following; otherwise restore the previous offset.
- wasAtBottom := m.viewport.AtBottom()
- prevYOffset := m.viewport.YOffset
- m.viewport = msg.viewport
- if wasAtBottom {
- m.viewport.GotoBottom()
- } else {
- m.viewport.YOffset = prevYOffset
- }
-
- m.header = msg.header
- if m.dirty {
- cmds = append(cmds, m.renderView())
- }
-
- // Start shimmer ticks if any assistant/tool is in-flight
- if !m.animating && m.app.HasAnimatingWork() {
- m.animating = true
- cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }))
- }
- }
-
- m.tail = m.viewport.AtBottom()
- viewport, cmd := m.viewport.Update(msg)
- m.viewport = viewport
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
-}
-
-type renderCompleteMsg struct {
- viewport viewport.Model
- clipboard []string
- header string
- partCount int
- lineCount int
- messagePositions map[string]int
-}
-
-func (m *messagesComponent) renderView() tea.Cmd {
- if m.rendering {
- slog.Debug("pending render, skipping")
- m.dirty = true
- return func() tea.Msg {
- return nil
- }
- }
- m.dirty = false
- m.rendering = true
-
- viewport := m.viewport
- tail := m.tail
-
- return func() tea.Msg {
- header := m.renderHeader()
- measure := util.Measure("messages.renderView")
- defer measure()
-
- t := theme.CurrentTheme()
- blocks := make([]string, 0)
- partCount := 0
- lineCount := 0
- messagePositions := make(map[string]int) // Track message ID to line position
-
- orphanedToolCalls := make([]opencode.ToolPart, 0)
-
- width := m.width // always use full width
-
- // Find the last streaming ReasoningPart to only shimmer that one
- lastStreamingReasoningID := ""
- if m.showThinkingBlocks {
- for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- {
- if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok {
- continue
- }
- parts := m.app.Messages[mi].Parts
- for pi := len(parts) - 1; pi >= 0; pi-- {
- if rp, ok := parts[pi].(opencode.ReasoningPart); ok {
- if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 {
- lastStreamingReasoningID = rp.ID
- break
- }
- }
- }
- }
- }
-
- reverted := false
- revertedMessageCount := 0
- revertedToolCount := 0
- lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
- for _, msg := range slices.Backward(m.app.Messages) {
- if assistant, ok := msg.Info.(opencode.AssistantMessage); ok {
- if assistant.Time.Completed > 0 {
- break
- }
- lastAssistantMessage = assistant.ID
- break
- }
- }
- for _, message := range m.app.Messages {
- var content string
- var cached bool
- error := ""
-
- switch casted := message.Info.(type) {
- case opencode.UserMessage:
- // Track the position of this user message
- messagePositions[casted.ID] = lineCount
-
- if casted.ID == m.app.Session.Revert.MessageID {
- reverted = true
- revertedMessageCount = 1
- revertedToolCount = 0
- continue
- }
- if reverted {
- revertedMessageCount++
- continue
- }
-
- for partIndex, part := range message.Parts {
- switch part := part.(type) {
- case opencode.TextPart:
- if part.Synthetic {
- continue
- }
- if part.Text == "" {
- continue
- }
- remainingParts := message.Parts[partIndex+1:]
- fileParts := make([]opencode.FilePart, 0)
- agentParts := make([]opencode.AgentPart, 0)
- for _, part := range remainingParts {
- switch part := part.(type) {
- case opencode.FilePart:
- if part.Source.Text.Start >= 0 && part.Source.Text.End >= part.Source.Text.Start {
- fileParts = append(fileParts, part)
- }
- case opencode.AgentPart:
- if part.Source.Start >= 0 && part.Source.End >= part.Source.Start {
- agentParts = append(agentParts, part)
- }
- }
- }
- flexItems := []layout.FlexItem{}
- if len(fileParts) > 0 {
- fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
- mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
- for _, filePart := range fileParts {
- mediaType := ""
- switch filePart.Mime {
- case "text/plain":
- mediaType = "txt"
- case "image/png", "image/jpeg", "image/gif", "image/webp":
- mediaType = "img"
- mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
- case "application/pdf":
- mediaType = "pdf"
- mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
- }
- flexItems = append(flexItems, layout.FlexItem{
- View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
- })
- }
- }
- bgColor := t.BackgroundPanel()
- files := layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Width: width - 6,
- Direction: layout.Column,
- },
- flexItems...,
- )
-
- author := m.app.Config.Username
- isQueued := casted.ID > lastAssistantMessage
- key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued)
- content, cached = m.cache.Get(key)
- if !cached {
- content = renderText(
- m.app,
- message.Info,
- part.Text,
- author,
- m.showToolDetails,
- width,
- files,
- false,
- isQueued,
- false,
- fileParts,
- agentParts,
- )
- m.cache.Set(key, content)
- }
- if content != "" {
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- }
- }
- }
-
- case opencode.AssistantMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- reverted = true
- revertedMessageCount = 1
- revertedToolCount = 0
- }
- hasTextPart := false
- hasContent := false
- for partIndex, p := range message.Parts {
- switch part := p.(type) {
- case opencode.TextPart:
- if reverted {
- continue
- }
- if strings.TrimSpace(part.Text) == "" {
- continue
- }
- hasTextPart = true
- finished := part.Time.End > 0
- remainingParts := message.Parts[partIndex+1:]
- toolCallParts := make([]opencode.ToolPart, 0)
-
- // sometimes tool calls happen without an assistant message
- // these should be included in this assistant message as well
- if len(orphanedToolCalls) > 0 {
- toolCallParts = append(toolCallParts, orphanedToolCalls...)
- orphanedToolCalls = make([]opencode.ToolPart, 0)
- }
-
- remaining := true
- for _, part := range remainingParts {
- if !remaining {
- break
- }
- switch part := part.(type) {
- case opencode.TextPart:
- // we only want tool calls associated with the current text part.
- // if we hit another text part, we're done.
- remaining = false
- case opencode.ToolPart:
- toolCallParts = append(toolCallParts, part)
- if part.State.Status != opencode.ToolPartStateStatusCompleted && part.State.Status != opencode.ToolPartStateStatusError {
- // i don't think there's a case where a tool call isn't in result state
- // and the message time is 0, but just in case
- finished = false
- }
- }
- }
-
- if finished {
- key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts)
- content, cached = m.cache.Get(key)
- if !cached {
- content = renderText(
- m.app,
- message.Info,
- part.Text,
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- false,
- false,
- false,
- []opencode.FilePart{},
- []opencode.AgentPart{},
- toolCallParts...,
- )
- m.cache.Set(key, content)
- }
- } else {
- content = renderText(
- m.app,
- message.Info,
- part.Text,
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- false,
- false,
- false,
- []opencode.FilePart{},
- []opencode.AgentPart{},
- toolCallParts...,
- )
- }
- if content != "" {
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- hasContent = true
- }
- case opencode.ToolPart:
- if reverted {
- revertedToolCount++
- continue
- }
-
- permission := opencode.Permission{}
- if m.app.CurrentPermission.CallID == part.CallID {
- permission = m.app.CurrentPermission
- }
-
- if !m.showToolDetails && permission.ID == "" {
- if !hasTextPart {
- orphanedToolCalls = append(orphanedToolCalls, part)
- }
- continue
- }
-
- if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
- key := m.cache.GenerateKey(casted.ID,
- part.ID,
- m.showToolDetails,
- width,
- permission.ID,
- )
- content, cached = m.cache.Get(key)
- if !cached {
- content = renderToolDetails(
- m.app,
- part,
- permission,
- width,
- )
- m.cache.Set(key, content)
- }
- } else {
- // if the tool call isn't finished, don't cache
- content = renderToolDetails(
- m.app,
- part,
- permission,
- width,
- )
- }
- if content != "" {
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- hasContent = true
- }
- case opencode.ReasoningPart:
- if reverted {
- continue
- }
- if !m.showThinkingBlocks {
- continue
- }
- if part.Text != "" {
- text := part.Text
- shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID
- content = renderText(
- m.app,
- message.Info,
- text,
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- true,
- false,
- shimmer,
- []opencode.FilePart{},
- []opencode.AgentPart{},
- )
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- hasContent = true
- }
- }
- }
-
- switch err := casted.Error.AsUnion().(type) {
- case nil:
- case opencode.AssistantMessageErrorMessageOutputLengthError:
- error = "Message output length exceeded"
- case opencode.AssistantMessageErrorAPIError:
- error = err.Data.Message
- case opencode.ProviderAuthError:
- error = err.Data.Message
- case opencode.MessageAbortedError:
- error = "Request was aborted"
- case opencode.UnknownError:
- error = err.Data.Message
- }
-
- if !hasContent && error == "" && !reverted && casted.Time.Completed == 0 {
- content = renderText(
- m.app,
- message.Info,
- "Generating...",
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- false,
- false,
- false,
- []opencode.FilePart{},
- []opencode.AgentPart{},
- )
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- }
- }
-
- if error != "" && !reverted {
- error = styles.NewStyle().Width(width - 6).Render(error)
- error = renderContentBlock(
- m.app,
- error,
- width,
- WithBorderColor(t.Error()),
- )
- blocks = append(blocks, error)
- lineCount += lipgloss.Height(error) + 1
- }
- }
-
- if revertedMessageCount > 0 || revertedToolCount > 0 {
- messagePlural := ""
- toolPlural := ""
- if revertedMessageCount != 1 {
- messagePlural = "s"
- }
- if revertedToolCount != 1 {
- toolPlural = "s"
- }
- revertedStyle := styles.NewStyle().
- Background(t.BackgroundPanel()).
- Foreground(t.TextMuted())
-
- content := revertedStyle.Render(fmt.Sprintf(
- "%d message%s reverted, %d tool call%s reverted",
- revertedMessageCount,
- messagePlural,
- revertedToolCount,
- toolPlural,
- ))
- hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text())
- hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand))
- hint += revertedStyle.Render(" (or /redo) to restore")
-
- content += "\n" + hint
- if m.app.Session.Revert.Diff != "" {
- t := theme.CurrentTheme()
- s := styles.NewStyle().Background(t.BackgroundPanel())
- green := s.Foreground(t.Success()).Render
- red := s.Foreground(t.Error()).Render
- content += "\n"
- stats, err := diff.ParseStats(m.app.Session.Revert.Diff)
- if err != nil {
- slog.Error("Failed to parse diff stats", "error", err)
- } else {
- var files []string
- for file := range stats {
- files = append(files, file)
- }
- sort.Strings(files)
-
- for _, file := range files {
- fileStats := stats[file]
- display := file
- if fileStats.Added > 0 {
- display += green(" +" + strconv.Itoa(int(fileStats.Added)))
- }
- if fileStats.Removed > 0 {
- display += red(" -" + strconv.Itoa(int(fileStats.Removed)))
- }
- content += "\n" + display
- }
- }
- }
-
- content = styles.NewStyle().
- Background(t.BackgroundPanel()).
- Width(width - 6).
- Render(content)
- content = renderContentBlock(
- m.app,
- content,
- width,
- WithBorderColor(t.BackgroundPanel()),
- )
- blocks = append(blocks, content)
- }
-
- if m.app.CurrentPermission.ID != "" &&
- m.app.CurrentPermission.SessionID != m.app.Session.ID {
- response, err := m.app.Client.Session.Message(
- context.Background(),
- m.app.CurrentPermission.SessionID,
- m.app.CurrentPermission.MessageID,
- opencode.SessionMessageParams{},
- )
- if err != nil || response == nil {
- slog.Error("Failed to get message from child session", "error", err)
- } else {
- for _, part := range response.Parts {
- if part.CallID == m.app.CurrentPermission.CallID {
- if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok {
- content := renderToolDetails(
- m.app,
- toolPart,
- m.app.CurrentPermission,
- width,
- )
- if content != "" {
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- }
- }
- }
- }
- }
- }
-
- final := []string{}
- clipboard := []string{}
- var selection *selection
- if m.selection != nil {
- selection = m.selection.coords(lipgloss.Height(header) + 1)
- }
- for _, block := range blocks {
- lines := strings.Split(block, "\n")
- for index, line := range lines {
- if selection == nil || index == 0 || index == len(lines)-1 {
- final = append(final, line)
- continue
- }
- y := len(final)
- if y >= selection.startY && y <= selection.endY {
- left := 3
- if y == selection.startY {
- left = selection.startX - 2
- }
- left = max(3, left)
-
- width := ansi.StringWidth(line)
- right := width - 1
- if y == selection.endY {
- right = min(selection.endX-2, right)
- }
-
- prefix := ansi.Cut(line, 0, left)
- middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
- suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width)
- clipboard = append(clipboard, middle)
- line = prefix + styles.NewStyle().
- Background(t.Accent()).
- Foreground(t.BackgroundPanel()).
- Render(ansi.Strip(middle)) +
- suffix
- }
- final = append(final, line)
- }
- y := len(final)
- if selection != nil && y >= selection.startY && y < selection.endY {
- clipboard = append(clipboard, "")
- }
- final = append(final, "")
- }
- content := "\n" + strings.Join(final, "\n")
- viewport.SetHeight(m.height - lipgloss.Height(header))
- viewport.SetContent(content)
- if tail {
- viewport.GotoBottom()
- }
-
- return renderCompleteMsg{
- header: header,
- clipboard: clipboard,
- viewport: viewport,
- partCount: partCount,
- lineCount: lineCount,
- messagePositions: messagePositions,
- }
- }
-}
-
-func (m *messagesComponent) renderHeader() string {
- if m.app.Session.ID == "" {
- return ""
- }
-
- headerWidth := m.width
-
- t := theme.CurrentTheme()
- bgColor := t.Background()
- borderColor := t.BackgroundElement()
-
- isChildSession := m.app.Session.ParentID != ""
- if isChildSession {
- bgColor = t.BackgroundElement()
- borderColor = t.Accent()
- }
-
- base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
- muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
-
- sessionInfo := ""
- tokens := float64(0)
- cost := float64(0)
- contextWindow := m.app.Model.Limit.Context
-
- for _, message := range m.app.Messages {
- if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
- cost += assistant.Cost
- usage := assistant.Tokens
- if usage.Output > 0 {
- if assistant.Summary {
- tokens = usage.Output
- continue
- }
- tokens = (usage.Input +
- usage.Cache.Read +
- usage.Cache.Write +
- usage.Output +
- usage.Reasoning)
- }
- }
- }
-
- // Check if current model is a subscription model (cost is 0 for both input and output)
- isSubscriptionModel := m.app.Model != nil &&
- m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
-
- sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
- sessionInfo = styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(bgColor).
- Render(sessionInfoText)
-
- shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
-
- navHint := ""
- if isChildSession {
- navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
- }
-
- headerTextWidth := headerWidth
- if isChildSession {
- headerTextWidth -= lipgloss.Width(navHint)
- } else if !shareEnabled {
- headerTextWidth -= lipgloss.Width(sessionInfoText)
- }
- headerText := util.ToMarkdown(
- "# "+m.app.Session.Title,
- headerTextWidth,
- bgColor,
- )
- if isChildSession {
- headerText = layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: headerTextWidth,
- },
- layout.FlexItem{
- View: headerText,
- },
- layout.FlexItem{
- View: navHint,
- },
- )
- }
-
- var items []layout.FlexItem
- if shareEnabled {
- share := base("/share") + muted(" to create a shareable link")
- if m.app.Session.Share.URL != "" {
- share = muted(m.app.Session.Share.URL + " /unshare")
- }
- items = []layout.FlexItem{{View: share}, {View: sessionInfo}}
- } else {
- items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
- }
-
- headerRow := layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: headerWidth - 6,
- },
- items...,
- )
-
- headerLines := []string{headerRow}
- if shareEnabled {
- headerLines = []string{headerText, headerRow}
- }
-
- header := strings.Join(headerLines, "\n")
- header = styles.NewStyle().
- Background(bgColor).
- Width(headerWidth).
- PaddingLeft(2).
- PaddingRight(2).
- BorderLeft(true).
- BorderRight(true).
- BorderBackground(t.Background()).
- BorderForeground(borderColor).
- BorderStyle(lipgloss.ThickBorder()).
- Render(header)
-
- return "\n" + header + "\n"
-}
-
-func formatTokensAndCost(
- tokens float64,
- contextWindow float64,
- cost float64,
- isSubscriptionModel bool,
-) string {
- // Format tokens in human-readable format (e.g., 110K, 1.2M)
- var formattedTokens string
- switch {
- case tokens >= 1_000_000:
- formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
- case tokens >= 1_000:
- formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
- default:
- formattedTokens = fmt.Sprintf("%d", int(tokens))
- }
-
- // Remove .0 suffix if present
- if strings.HasSuffix(formattedTokens, ".0K") {
- formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
- }
- if strings.HasSuffix(formattedTokens, ".0M") {
- formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
- }
-
- percentage := 0.0
- if contextWindow > 0 {
- percentage = (float64(tokens) / float64(contextWindow)) * 100
- }
-
- if isSubscriptionModel {
- return fmt.Sprintf(
- "%s/%d%%",
- formattedTokens,
- int(percentage),
- )
- }
-
- formattedCost := fmt.Sprintf("$%.2f", cost)
- return fmt.Sprintf(
- " %s/%d%% (%s)",
- formattedTokens,
- int(percentage),
- formattedCost,
- )
-}
-
-func (m *messagesComponent) View() string {
- t := theme.CurrentTheme()
- bgColor := t.Background()
-
- if m.loading {
- return lipgloss.Place(
- m.width,
- m.height,
- lipgloss.Center,
- lipgloss.Center,
- styles.NewStyle().Background(bgColor).Render(""),
- styles.WhitespaceStyle(bgColor),
- )
- }
-
- viewport := m.viewport.View()
- return styles.NewStyle().
- Background(bgColor).
- Render(m.header + "\n" + viewport)
-}
-
-func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
- m.viewport.ViewUp()
- return m, nil
-}
-
-func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
- m.viewport.ViewDown()
- return m, nil
-}
-
-func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
- m.viewport.HalfViewUp()
- return m, nil
-}
-
-func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
- m.viewport.HalfViewDown()
- return m, nil
-}
-
-func (m *messagesComponent) ToolDetailsVisible() bool {
- return m.showToolDetails
-}
-
-func (m *messagesComponent) ThinkingBlocksVisible() bool {
- return m.showThinkingBlocks
-}
-
-func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) {
- m.viewport.GotoTop()
- return m, nil
-}
-
-func (m *messagesComponent) GotoBottom() (tea.Model, tea.Cmd) {
- m.viewport.GotoBottom()
- return m, nil
-}
-
-func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
- if len(m.app.Messages) == 0 {
- return m, nil
- }
- lastMessage := m.app.Messages[len(m.app.Messages)-1]
- var lastTextPart *opencode.TextPart
- for _, part := range lastMessage.Parts {
- if p, ok := part.(opencode.TextPart); ok {
- lastTextPart = &p
- }
- }
- if lastTextPart == nil {
- return m, nil
- }
- var cmds []tea.Cmd
- cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
- cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
- return m, tea.Batch(cmds...)
-}
-
-func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) {
- after := float64(0)
- var revertedMessage app.Message
- reversedMessages := []app.Message{}
- for i := len(m.app.Messages) - 1; i >= 0; i-- {
- reversedMessages = append(reversedMessages, m.app.Messages[i])
- switch casted := m.app.Messages[i].Info.(type) {
- case opencode.UserMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- after = casted.Time.Created
- }
- case opencode.AssistantMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- after = casted.Time.Created
- }
- }
- if m.app.Session.Revert.PartID != "" {
- for _, part := range m.app.Messages[i].Parts {
- switch casted := part.(type) {
- case opencode.TextPart:
- if casted.ID == m.app.Session.Revert.PartID {
- after = casted.Time.Start
- }
- case opencode.ToolPart:
- // TODO: handle tool parts
- }
- }
- }
- }
-
- messageID := ""
- for _, msg := range reversedMessages {
- switch casted := msg.Info.(type) {
- case opencode.UserMessage:
- if after > 0 && casted.Time.Created >= after {
- continue
- }
- messageID = casted.ID
- revertedMessage = msg
- }
- if messageID != "" {
- break
- }
- }
-
- if messageID == "" {
- return m, nil
- }
-
- return m, func() tea.Msg {
- response, err := m.app.Client.Session.Revert(
- context.Background(),
- m.app.Session.ID,
- opencode.SessionRevertParams{
- MessageID: opencode.F(messageID),
- },
- )
- if err != nil {
- slog.Error("Failed to undo message", "error", err)
- return toast.NewErrorToast("Failed to undo message")()
- }
- if response == nil {
- return toast.NewErrorToast("Failed to undo message")()
- }
- return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
- }
-}
-
-func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
- // Check if there's a revert state to redo from
- if m.app.Session.Revert.MessageID == "" {
- return m, func() tea.Msg {
- return toast.NewErrorToast("Nothing to redo")
- }
- }
-
- before := float64(0)
- var revertedMessage app.Message
- for _, message := range m.app.Messages {
- switch casted := message.Info.(type) {
- case opencode.UserMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- before = casted.Time.Created
- }
- case opencode.AssistantMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- before = casted.Time.Created
- }
- }
- if m.app.Session.Revert.PartID != "" {
- for _, part := range message.Parts {
- switch casted := part.(type) {
- case opencode.TextPart:
- if casted.ID == m.app.Session.Revert.PartID {
- before = casted.Time.Start
- }
- case opencode.ToolPart:
- // TODO: handle tool parts
- }
- }
- }
- }
-
- messageID := ""
- for _, msg := range m.app.Messages {
- switch casted := msg.Info.(type) {
- case opencode.UserMessage:
- if casted.Time.Created <= before {
- continue
- }
- messageID = casted.ID
- revertedMessage = msg
- }
- if messageID != "" {
- break
- }
- }
-
- if messageID == "" {
- return m, func() tea.Msg {
- // unrevert back to original state
- response, err := m.app.Client.Session.Unrevert(
- context.Background(),
- m.app.Session.ID,
- opencode.SessionUnrevertParams{},
- )
- if err != nil {
- slog.Error("Failed to unrevert session", "error", err)
- return toast.NewErrorToast("Failed to redo message")()
- }
- if response == nil {
- return toast.NewErrorToast("Failed to redo message")()
- }
- return app.SessionUnrevertedMsg{Session: *response}
- }
- }
-
- return m, func() tea.Msg {
- // calling revert on a "later" message is like a redo
- response, err := m.app.Client.Session.Revert(
- context.Background(),
- m.app.Session.ID,
- opencode.SessionRevertParams{
- MessageID: opencode.F(messageID),
- },
- )
- if err != nil {
- slog.Error("Failed to redo message", "error", err)
- return toast.NewErrorToast("Failed to redo message")()
- }
- if response == nil {
- return toast.NewErrorToast("Failed to redo message")()
- }
- return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
- }
-}
-
-func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
- if m.messagePositions == nil {
- return m, nil
- }
-
- if position, exists := m.messagePositions[messageID]; exists {
- m.viewport.SetYOffset(position)
- m.tail = false // Stop auto-scrolling to bottom when manually navigating
- }
- return m, nil
-}
-
-func NewMessagesComponent(app *app.App) MessagesComponent {
- vp := viewport.New()
- vp.KeyMap = viewport.KeyMap{}
-
- if app.ScrollSpeed > 0 {
- vp.MouseWheelDelta = app.ScrollSpeed
- } else {
- vp.MouseWheelDelta = 2
- }
-
- // Default to showing tool details, hidden thinking blocks
- showToolDetails := true
- if app.State.ShowToolDetails != nil {
- showToolDetails = *app.State.ShowToolDetails
- }
-
- showThinkingBlocks := false
- if app.State.ShowThinkingBlocks != nil {
- showThinkingBlocks = *app.State.ShowThinkingBlocks
- }
-
- return &messagesComponent{
- app: app,
- viewport: vp,
- showToolDetails: showToolDetails,
- showThinkingBlocks: showThinkingBlocks,
- cache: NewPartCache(),
- tail: true,
- messagePositions: make(map[string]int),
- }
-}
diff --git a/packages/tui/internal/components/commands/commands.go b/packages/tui/internal/components/commands/commands.go
deleted file mode 100644
index fd578a41b..000000000
--- a/packages/tui/internal/components/commands/commands.go
+++ /dev/null
@@ -1,247 +0,0 @@
-package commands
-
-import (
- "fmt"
- "runtime"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type CommandsComponent interface {
- tea.ViewModel
- SetSize(width, height int) tea.Cmd
- SetBackgroundColor(color compat.AdaptiveColor)
-}
-
-type commandsComponent struct {
- app *app.App
- width, height int
- showKeybinds bool
- showAll bool
- showVscode bool
- background *compat.AdaptiveColor
- limit *int
-}
-
-func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
- c.width = width
- c.height = height
- return nil
-}
-
-func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
- c.background = &color
-}
-
-func (c *commandsComponent) View() string {
- t := theme.CurrentTheme()
-
- triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
- descriptionStyle := styles.NewStyle().Foreground(t.Text())
- keybindStyle := styles.NewStyle().Foreground(t.TextMuted())
-
- if c.background != nil {
- triggerStyle = triggerStyle.Background(*c.background)
- descriptionStyle = descriptionStyle.Background(*c.background)
- keybindStyle = keybindStyle.Background(*c.background)
- }
-
- var commandsToShow []commands.Command
- var triggeredCommands []commands.Command
- var untriggeredCommands []commands.Command
-
- for _, cmd := range c.app.Commands.Sorted() {
- if c.showAll || cmd.HasTrigger() {
- if cmd.HasTrigger() {
- triggeredCommands = append(triggeredCommands, cmd)
- } else if c.showAll {
- untriggeredCommands = append(untriggeredCommands, cmd)
- }
- }
- }
-
- // Combine triggered commands first, then untriggered
- commandsToShow = append(commandsToShow, triggeredCommands...)
- commandsToShow = append(commandsToShow, untriggeredCommands...)
-
- if c.limit != nil && len(commandsToShow) > *c.limit {
- commandsToShow = commandsToShow[:*c.limit]
- }
-
- if c.showVscode {
- ctrlKey := "ctrl"
- if runtime.GOOS == "darwin" {
- ctrlKey = "cmd"
- }
- commandsToShow = append(commandsToShow,
- // empty line
- // commands.Command{
- // Name: "",
- // Description: "",
- // },
- commands.Command{
- Name: commands.CommandName(util.Ide()),
- Description: "open opencode",
- Keybindings: []commands.Keybinding{
- {Key: ctrlKey + "+esc", RequiresLeader: false},
- },
- },
- commands.Command{
- Name: commands.CommandName(util.Ide()),
- Description: "reference file",
- Keybindings: []commands.Keybinding{
- {Key: ctrlKey + "+opt+k", RequiresLeader: false},
- },
- },
- )
- }
-
- if len(commandsToShow) == 0 {
- muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
- if c.showAll {
- return muted.Render("No commands available")
- }
- return muted.Render("No commands with triggers available")
- }
-
- // Calculate column widths
- maxTriggerWidth := 0
- maxDescriptionWidth := 0
- maxKeybindWidth := 0
-
- // Prepare command data
- type commandRow struct {
- trigger string
- description string
- keybinds string
- }
-
- rows := make([]commandRow, 0, len(commandsToShow))
-
- for _, cmd := range commandsToShow {
- trigger := ""
- if cmd.HasTrigger() {
- trigger = "/" + cmd.PrimaryTrigger()
- } else {
- trigger = string(cmd.Name)
- }
- description := cmd.Description
-
- // Format keybindings
- var keybindStrs []string
- if c.showKeybinds {
- for _, kb := range cmd.Keybindings {
- if kb.RequiresLeader {
- keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key)
- } else {
- keybindStrs = append(keybindStrs, kb.Key)
- }
- }
- }
- keybinds := strings.Join(keybindStrs, ", ")
-
- rows = append(rows, commandRow{
- trigger: trigger,
- description: description,
- keybinds: keybinds,
- })
-
- // Update max widths
- if len(trigger) > maxTriggerWidth {
- maxTriggerWidth = len(trigger)
- }
- if len(description) > maxDescriptionWidth {
- maxDescriptionWidth = len(description)
- }
- if len(keybinds) > maxKeybindWidth {
- maxKeybindWidth = len(keybinds)
- }
- }
-
- // Add padding between columns
- columnPadding := 3
-
- // Build the output
- var output strings.Builder
-
- maxWidth := 0
- for _, row := range rows {
- // Pad each column to align properly
- trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
- description := fmt.Sprintf("%-*s", maxDescriptionWidth, row.description)
-
- // Apply styles and combine
- line := triggerStyle.Render(trigger) +
- triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
- descriptionStyle.Render(description)
-
- if c.showKeybinds && row.keybinds != "" {
- line += keybindStyle.Render(strings.Repeat(" ", columnPadding)) +
- keybindStyle.Render(row.keybinds)
- }
-
- output.WriteString(line + "\n")
- maxWidth = max(maxWidth, lipgloss.Width(line))
- }
-
- // Remove trailing newline
- result := strings.TrimSuffix(output.String(), "\n")
- if c.background != nil {
- result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
- }
-
- return result
-}
-
-type Option func(*commandsComponent)
-
-func WithKeybinds(show bool) Option {
- return func(c *commandsComponent) {
- c.showKeybinds = show
- }
-}
-
-func WithBackground(background compat.AdaptiveColor) Option {
- return func(c *commandsComponent) {
- c.background = &background
- }
-}
-
-func WithLimit(limit int) Option {
- return func(c *commandsComponent) {
- c.limit = &limit
- }
-}
-
-func WithShowAll(showAll bool) Option {
- return func(c *commandsComponent) {
- c.showAll = showAll
- }
-}
-
-func WithVscode(showVscode bool) Option {
- return func(c *commandsComponent) {
- c.showVscode = showVscode
- }
-}
-
-func New(app *app.App, opts ...Option) CommandsComponent {
- c := &commandsComponent{
- app: app,
- background: nil,
- showKeybinds: true,
- showAll: false,
- }
- for _, opt := range opts {
- opt(c)
- }
- return c
-}
diff --git a/packages/tui/internal/components/dialog/agents.go b/packages/tui/internal/components/dialog/agents.go
deleted file mode 100644
index c2cbd6450..000000000
--- a/packages/tui/internal/components/dialog/agents.go
+++ /dev/null
@@ -1,452 +0,0 @@
-package dialog
-
-import (
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "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"
-)
-
-const (
- numVisibleAgents = 10
- minAgentDialogWidth = 40
- maxAgentDialogWidth = 60
- maxDescriptionLength = 60
- maxRecentAgents = 5
-)
-
-// AgentDialog interface for the agent selection dialog
-type AgentDialog interface {
- layout.Modal
-}
-
-type agentDialog struct {
- app *app.App
- allAgents []agentSelectItem
- width int
- height int
- modal *modal.Modal
- searchDialog *SearchDialog
- dialogWidth int
-}
-
-// agentSelectItem combines the visual improvements with code patterns
-type agentSelectItem struct {
- name string
- displayName string
- description string
- mode string // "primary", "subagent", "all"
- isCurrent bool
- agentIndex int
- agent opencode.Agent // Keep original agent for compatibility
-}
-
-func (a agentSelectItem) Render(
- selected bool,
- width int,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
- itemStyle := baseStyle.
- Background(t.BackgroundPanel()).
- Foreground(t.Text())
-
- if selected {
- // Use agent color for highlighting when selected (visual improvement)
- agentColor := util.GetAgentColor(a.agentIndex)
- itemStyle = itemStyle.Foreground(agentColor)
- }
-
- descStyle := baseStyle.
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel())
-
- // Calculate available width (accounting for padding and margins)
- availableWidth := width - 2 // Account for left padding
-
- agentName := a.displayName
-
- // Determine if agent is built-in or custom using the agent's builtIn field
- var displayText string
- if a.agent.BuiltIn {
- displayText = "(built-in)"
- } else {
- if a.description != "" {
- displayText = a.description
- } else {
- displayText = "(user)"
- }
- }
-
- separator := " - "
-
- // Calculate how much space we have for the description (visual improvement)
- nameAndSeparatorLength := len(agentName) + len(separator)
- descriptionMaxLength := availableWidth - nameAndSeparatorLength
-
- // Cap description length to the maximum allowed
- if descriptionMaxLength > maxDescriptionLength {
- descriptionMaxLength = maxDescriptionLength
- }
-
- // Truncate description if it's too long (visual improvement)
- if len(displayText) > descriptionMaxLength && descriptionMaxLength > 3 {
- displayText = displayText[:descriptionMaxLength-3] + "..."
- }
-
- namePart := itemStyle.Render(agentName)
- descPart := descStyle.Render(separator + displayText)
- combinedText := namePart + descPart
-
- return baseStyle.
- Background(t.BackgroundPanel()).
- PaddingLeft(1).
- Width(width).
- Render(combinedText)
-}
-
-func (a agentSelectItem) Selectable() bool {
- return true
-}
-
-type agentKeyMap struct {
- Enter key.Binding
- Escape key.Binding
-}
-
-var agentKeys = agentKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select agent"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-func (a *agentDialog) Init() tea.Cmd {
- a.setupAllAgents()
- return a.searchDialog.Init()
-}
-
-func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- a.width = msg.Width
- a.height = msg.Height
- a.searchDialog.SetWidth(a.dialogWidth)
- a.searchDialog.SetHeight(msg.Height)
-
- case SearchSelectionMsg:
- // Handle selection from search dialog
- if item, ok := msg.Item.(agentSelectItem); ok {
- if !item.isCurrent {
- // Switch to selected agent (using their better pattern)
- return a, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.AgentSelectedMsg{AgentName: item.name}),
- )
- }
- }
- return a, util.CmdHandler(modal.CloseModalMsg{})
- case SearchCancelledMsg:
- return a, util.CmdHandler(modal.CloseModalMsg{})
-
- case SearchRemoveItemMsg:
- if item, ok := msg.Item.(agentSelectItem); ok {
- if a.isAgentInRecentSection(item, msg.Index) {
- a.app.State.RemoveAgentFromRecentlyUsed(item.name)
- items := a.buildDisplayList(a.searchDialog.GetQuery())
- a.searchDialog.SetItems(items)
- return a, a.app.SaveState()
- }
- }
- return a, nil
-
- case SearchQueryChangedMsg:
- // Update the list based on search query
- items := a.buildDisplayList(msg.Query)
- a.searchDialog.SetItems(items)
- return a, nil
- }
-
- updatedDialog, cmd := a.searchDialog.Update(msg)
- a.searchDialog = updatedDialog.(*SearchDialog)
- return a, cmd
-}
-
-func (a *agentDialog) SetSize(width, height int) {
- a.width = width
- a.height = height
-}
-
-func (a *agentDialog) View() string {
- return a.searchDialog.View()
-}
-
-func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) int {
- maxWidth := minAgentDialogWidth
-
- for _, agent := range agents {
- // Calculate the width needed for this item: "AgentName - Description" (visual improvement)
- itemWidth := len(agent.displayName)
-
- if agent.agent.BuiltIn {
- itemWidth += len("(built-in)") + 3 // " - "
- } else {
- if agent.description != "" {
- descLength := len(agent.description)
- if descLength > maxDescriptionLength {
- descLength = maxDescriptionLength
- }
- itemWidth += descLength + 3 // " - "
- } else {
- itemWidth += len("(user)") + 3 // " - "
- }
- }
-
- if itemWidth > maxWidth {
- maxWidth = itemWidth
- }
- }
-
- maxWidth = min(maxWidth, maxAgentDialogWidth)
- return maxWidth
-}
-
-func (a *agentDialog) setupAllAgents() {
- currentAgentName := a.app.Agent().Name
-
- // Build agent items from app.Agents (no API call needed) - their pattern
- a.allAgents = make([]agentSelectItem, 0, len(a.app.Agents))
- for i, agent := range a.app.Agents {
- if agent.Mode == "subagent" {
- continue // Skip subagents entirely
- }
- isCurrent := agent.Name == currentAgentName
-
- // Create display name (capitalize first letter)
- displayName := strings.Title(agent.Name)
-
- a.allAgents = append(a.allAgents, agentSelectItem{
- name: agent.Name,
- displayName: displayName,
- description: agent.Description, // Keep for search but don't use in display
- mode: string(agent.Mode),
- isCurrent: isCurrent,
- agentIndex: i,
- agent: agent, // Keep original for compatibility
- })
- }
-
- a.sortAgents()
-
- // Calculate optimal width based on all agents (visual improvement)
- a.dialogWidth = a.calculateOptimalWidth(a.allAgents)
-
- // Ensure minimum width to prevent textinput issues
- a.dialogWidth = max(a.dialogWidth, minAgentDialogWidth)
-
- a.searchDialog = NewSearchDialog("Search agents...", numVisibleAgents)
- a.searchDialog.SetWidth(a.dialogWidth)
-
- // Build initial display list (empty query shows grouped view)
- items := a.buildDisplayList("")
- a.searchDialog.SetItems(items)
-}
-
-func (a *agentDialog) sortAgents() {
- sort.Slice(a.allAgents, func(i, j int) bool {
- agentA := a.allAgents[i]
- agentB := a.allAgents[j]
-
- // Current agent goes first (your preference)
- if agentA.name == a.app.Agent().Name {
- return true
- }
- if agentB.name == a.app.Agent().Name {
- return false
- }
-
- // Alphabetical order for all other agents
- return agentA.name < agentB.name
- })
-}
-
-// buildDisplayList creates the list items based on search query
-func (a *agentDialog) buildDisplayList(query string) []list.Item {
- if query != "" {
- // Search mode: use fuzzy matching
- return a.buildSearchResults(query)
- } else {
- // Grouped mode: show Recent agents section and alphabetical list (their pattern)
- return a.buildGroupedResults()
- }
-}
-
-// buildSearchResults creates a flat list of search results using fuzzy matching
-func (a *agentDialog) buildSearchResults(query string) []list.Item {
- agentNames := []string{}
- agentMap := make(map[string]agentSelectItem)
-
- for _, agent := range a.allAgents {
- // Only include non-subagents in search
- if agent.mode == "subagent" {
- continue
- }
- searchStr := agent.name
- agentNames = append(agentNames, searchStr)
- agentMap[searchStr] = agent
- }
-
- matches := fuzzy.RankFindFold(query, agentNames)
- sort.Sort(matches)
-
- items := []list.Item{}
- seenAgents := make(map[string]bool)
-
- for _, match := range matches {
- agent := agentMap[match.Target]
- // Create a unique key to avoid duplicates
- key := agent.name
- if seenAgents[key] {
- continue
- }
- seenAgents[key] = true
- items = append(items, agent)
- }
-
- return items
-}
-
-// buildGroupedResults creates a grouped list with Recent agents section and categorized agents
-func (a *agentDialog) buildGroupedResults() []list.Item {
- var items []list.Item
-
- // Add Recent section (their pattern)
- recentAgents := a.getRecentAgents(maxRecentAgents)
- if len(recentAgents) > 0 {
- items = append(items, list.HeaderItem("Recent"))
- for _, agent := range recentAgents {
- items = append(items, agent)
- }
- }
-
- // Create map of recent agent names for filtering
- recentAgentNames := make(map[string]bool)
- for _, recent := range recentAgents {
- recentAgentNames[recent.name] = true
- }
-
- // Only show non-subagents (primary/user) in the main section
- mainAgents := make([]agentSelectItem, 0)
- for _, agent := range a.allAgents {
- if !recentAgentNames[agent.name] {
- mainAgents = append(mainAgents, agent)
- }
- }
-
- // Sort main agents alphabetically
- sort.Slice(mainAgents, func(i, j int) bool {
- return mainAgents[i].name < mainAgents[j].name
- })
-
- // Add main agents section
- if len(mainAgents) > 0 {
- items = append(items, list.HeaderItem("Agents"))
- for _, agent := range mainAgents {
- items = append(items, agent)
- }
- }
-
- return items
-}
-
-func (a *agentDialog) Render(background string) string {
- return a.modal.Render(a.View(), background)
-}
-
-func (a *agentDialog) Close() tea.Cmd {
- return nil
-}
-
-// getRecentAgents returns the most recently used agents (their pattern)
-func (a *agentDialog) getRecentAgents(limit int) []agentSelectItem {
- var recentAgents []agentSelectItem
-
- // Get recent agents from app state
- for _, usage := range a.app.State.RecentlyUsedAgents {
- if len(recentAgents) >= limit {
- break
- }
-
- // Find the corresponding agent
- for _, agent := range a.allAgents {
- if agent.name == usage.AgentName {
- recentAgents = append(recentAgents, agent)
- break
- }
- }
- }
-
- // If no recent agents, use the current agent
- if len(recentAgents) == 0 {
- currentAgentName := a.app.Agent().Name
- for _, agent := range a.allAgents {
- if agent.name == currentAgentName {
- recentAgents = append(recentAgents, agent)
- break
- }
- }
- }
-
- return recentAgents
-}
-
-func (a *agentDialog) isAgentInRecentSection(agent agentSelectItem, index int) bool {
- // Only check if we're in grouped mode (no search query)
- if a.searchDialog.GetQuery() != "" {
- return false
- }
-
- recentAgents := a.getRecentAgents(maxRecentAgents)
- if len(recentAgents) == 0 {
- return false
- }
-
- // Index 0 is the "Recent" header, so recent agents are at indices 1 to len(recentAgents)
- if index >= 1 && index <= len(recentAgents) {
- if index-1 < len(recentAgents) {
- recentAgent := recentAgents[index-1]
- return recentAgent.name == agent.name
- }
- }
-
- return false
-}
-
-func NewAgentDialog(app *app.App) AgentDialog {
- dialog := &agentDialog{
- app: app,
- }
-
- dialog.setupAllAgents()
-
- dialog.modal = modal.New(
- modal.WithTitle("Select Agent"),
- modal.WithMaxWidth(dialog.dialogWidth+4),
- )
-
- return dialog
-}
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
deleted file mode 100644
index 4e890b081..000000000
--- a/packages/tui/internal/components/dialog/complete.go
+++ /dev/null
@@ -1,314 +0,0 @@
-package dialog
-
-import (
- "log/slog"
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textarea"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode/internal/completions"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type CompletionSelectedMsg struct {
- Item completions.CompletionSuggestion
- SearchString string
-}
-
-type CompletionDialogCompleteItemMsg struct {
- Value string
-}
-
-type CompletionDialogCloseMsg struct{}
-
-type CompletionDialog interface {
- tea.Model
- tea.ViewModel
- SetWidth(width int)
- IsEmpty() bool
-}
-
-type completionDialogComponent struct {
- query string
- providers []completions.CompletionProvider
- width int
- height int
- pseudoSearchTextArea textarea.Model
- list list.List[completions.CompletionSuggestion]
- trigger string
-}
-
-type completionDialogKeyMap struct {
- Complete key.Binding
- Cancel key.Binding
-}
-
-var completionDialogKeys = completionDialogKeyMap{
- Complete: key.NewBinding(
- key.WithKeys("tab", "enter", "right"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("space", " ", "esc", "backspace", "ctrl+h", "ctrl+c"),
- ),
-}
-
-func (c *completionDialogComponent) Init() tea.Cmd {
- return nil
-}
-
-func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
- return func() tea.Msg {
- // Collect results from all providers and preserve provider order
- type providerItems struct {
- idx int
- items []completions.CompletionSuggestion
- }
-
- itemsByProvider := make([]providerItems, 0, len(c.providers))
- providersWithResults := 0
-
- for idx, provider := range c.providers {
- items, err := provider.GetChildEntries(query)
- if err != nil {
- slog.Error(
- "Failed to get completion items",
- "provider",
- provider.GetId(),
- "error",
- err,
- )
- continue
- }
- if len(items) > 0 {
- providersWithResults++
- itemsByProvider = append(itemsByProvider, providerItems{idx: idx, items: items})
- }
- }
-
- // If there's a query, fuzzy-rank within each provider, then concatenate by provider order
- if query != "" && providersWithResults > 1 {
- t := theme.CurrentTheme()
- baseStyle := styles.NewStyle().Background(t.BackgroundElement())
-
- // Ensure stable provider order just in case
- sort.SliceStable(
- itemsByProvider,
- func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx },
- )
-
- final := make([]completions.CompletionSuggestion, 0)
- for _, entry := range itemsByProvider {
- // Build display values for fuzzy matching within this provider
- displayValues := make([]string, len(entry.items))
- for i, item := range entry.items {
- displayValues[i] = item.Display(baseStyle)
- }
-
- matches := fuzzy.RankFindFold(query, displayValues)
- sort.Sort(matches)
-
- // Reorder items for this provider based on fuzzy ranking
- ranked := make([]completions.CompletionSuggestion, 0, len(matches))
- for _, m := range matches {
- ranked = append(ranked, entry.items[m.OriginalIndex])
- }
- final = append(final, ranked...)
- }
-
- return final
- }
-
- // No query or no results: just concatenate in provider order
- all := make([]completions.CompletionSuggestion, 0)
- for _, entry := range itemsByProvider {
- all = append(all, entry.items...)
- }
- return all
- }
-}
-func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case []completions.CompletionSuggestion:
- c.list.SetItems(msg)
- case tea.KeyMsg:
- if c.pseudoSearchTextArea.Focused() {
- if !key.Matches(msg, completionDialogKeys.Complete) {
- var cmd tea.Cmd
- c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
- cmds = append(cmds, cmd)
-
- fullValue := c.pseudoSearchTextArea.Value()
- query := strings.TrimPrefix(fullValue, c.trigger)
-
- if query != c.query {
- c.query = query
- cmds = append(cmds, c.getAllCompletions(query))
- }
-
- u, cmd := c.list.Update(msg)
- c.list = u.(list.List[completions.CompletionSuggestion])
- cmds = append(cmds, cmd)
- }
-
- switch {
- case key.Matches(msg, completionDialogKeys.Complete):
- item, i := c.list.GetSelectedItem()
- if i == -1 {
- return c, nil
- }
- return c, c.complete(item)
- case key.Matches(msg, completionDialogKeys.Cancel):
- value := c.pseudoSearchTextArea.Value()
- width := lipgloss.Width(value)
- triggerWidth := lipgloss.Width(c.trigger)
-
- if msg.String() == "space" || msg.String() == " " {
- item, i := c.list.GetSelectedItem()
- if i > -1 {
- return c, c.complete(item)
- }
- // If no exact match, close the dialog
- return c, c.close()
- }
-
- // Only close on backspace when there are no characters left, unless we're back to just the trigger
- if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
- return c, c.close()
- }
- }
-
- return c, tea.Batch(cmds...)
- } else {
- cmds = append(cmds, c.getAllCompletions(""))
- cmds = append(cmds, c.pseudoSearchTextArea.Focus())
- return c, tea.Batch(cmds...)
- }
- }
-
- return c, tea.Batch(cmds...)
-}
-
-func (c *completionDialogComponent) View() string {
- t := theme.CurrentTheme()
- c.list.SetMaxWidth(c.width)
-
- return styles.NewStyle().
- Padding(0, 1).
- Foreground(t.Text()).
- Background(t.BackgroundElement()).
- BorderStyle(lipgloss.ThickBorder()).
- BorderLeft(true).
- BorderRight(true).
- BorderForeground(t.Border()).
- BorderBackground(t.Background()).
- Width(c.width).
- Render(c.list.View())
-}
-
-func (c *completionDialogComponent) SetWidth(width int) {
- c.width = width
-}
-
-func (c *completionDialogComponent) IsEmpty() bool {
- return c.list.IsEmpty()
-}
-
-func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
- value := c.pseudoSearchTextArea.Value()
- return tea.Batch(
- util.CmdHandler(CompletionSelectedMsg{
- SearchString: value,
- Item: item,
- }),
- c.close(),
- )
-}
-
-func (c *completionDialogComponent) close() tea.Cmd {
- c.pseudoSearchTextArea.Reset()
- c.pseudoSearchTextArea.Blur()
- return util.CmdHandler(CompletionDialogCloseMsg{})
-}
-
-func NewCompletionDialogComponent(
- trigger string,
- providers ...completions.CompletionProvider,
-) CompletionDialog {
- ti := textarea.New()
- ti.SetValue(trigger)
-
- // Use a generic empty message if we have multiple providers
- emptyMessage := "no matching items"
- if len(providers) == 1 {
- emptyMessage = providers[0].GetEmptyMessage()
- }
-
- // Define render function for completion suggestions
- renderFunc := func(item completions.CompletionSuggestion, selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
- style := baseStyle
-
- if selected {
- style = style.Background(t.BackgroundElement()).Foreground(t.Primary())
- } else {
- style = style.Background(t.BackgroundElement()).Foreground(t.Text())
- }
-
- // The item.Display string already has any inline colors from the provider
- truncatedStr := truncate.String(item.Display(style), uint(width-4))
- return style.Width(width - 4).Render(truncatedStr)
- }
-
- // Define selectable function - all completion suggestions are selectable
- selectableFunc := func(item completions.CompletionSuggestion) bool {
- return true
- }
-
- li := list.NewListComponent(
- list.WithItems([]completions.CompletionSuggestion{}),
- list.WithMaxVisibleHeight[completions.CompletionSuggestion](7),
- list.WithFallbackMessage[completions.CompletionSuggestion](emptyMessage),
- list.WithAlphaNumericKeys[completions.CompletionSuggestion](false),
- list.WithRenderFunc(renderFunc),
- list.WithSelectableFunc(selectableFunc),
- )
-
- c := &completionDialogComponent{
- query: "",
- providers: providers,
- pseudoSearchTextArea: ti,
- list: li,
- trigger: trigger,
- }
-
- // Load initial items from all providers
- go func() {
- allItems := make([]completions.CompletionSuggestion, 0)
- for _, provider := range providers {
- items, err := provider.GetChildEntries("")
- if err != nil {
- slog.Error(
- "Failed to get completion items",
- "provider",
- provider.GetId(),
- "error",
- err,
- )
- continue
- }
- allItems = append(allItems, items...)
- }
- li.SetItems(allItems)
- }()
-
- return c
-}
diff --git a/packages/tui/internal/components/dialog/help.go b/packages/tui/internal/components/dialog/help.go
deleted file mode 100644
index 15931724b..000000000
--- a/packages/tui/internal/components/dialog/help.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package dialog
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode/internal/app"
- commandsComponent "github.com/sst/opencode/internal/components/commands"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/viewport"
-)
-
-type helpDialog struct {
- width int
- height int
- modal *modal.Modal
- app *app.App
- commandsComponent commandsComponent.CommandsComponent
- viewport viewport.Model
-}
-
-func (h *helpDialog) Init() tea.Cmd {
- return h.viewport.Init()
-}
-
-func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- h.width = msg.Width
- h.height = msg.Height
- // Set viewport size with some padding for the modal, but cap at reasonable width
- maxWidth := min(80, msg.Width-8)
- h.viewport = viewport.New(viewport.WithWidth(maxWidth-4), viewport.WithHeight(msg.Height-6))
- h.commandsComponent.SetSize(maxWidth-4, msg.Height-6)
- }
-
- // Update viewport content
- h.viewport.SetContent(h.commandsComponent.View())
-
- // Update viewport
- var vpCmd tea.Cmd
- h.viewport, vpCmd = h.viewport.Update(msg)
- cmds = append(cmds, vpCmd)
-
- return h, tea.Batch(cmds...)
-}
-
-func (h *helpDialog) View() string {
- t := theme.CurrentTheme()
- h.commandsComponent.SetBackgroundColor(t.BackgroundPanel())
- return h.viewport.View()
-}
-
-func (h *helpDialog) Render(background string) string {
- return h.modal.Render(h.View(), background)
-}
-
-func (h *helpDialog) Close() tea.Cmd {
- return nil
-}
-
-type HelpDialog interface {
- layout.Modal
-}
-
-func NewHelpDialog(app *app.App) HelpDialog {
- vp := viewport.New(viewport.WithHeight(12))
- return &helpDialog{
- app: app,
- commandsComponent: commandsComponent.New(app,
- commandsComponent.WithBackground(theme.CurrentTheme().BackgroundPanel()),
- commandsComponent.WithShowAll(true),
- commandsComponent.WithKeybinds(true),
- ),
- modal: modal.New(modal.WithTitle("Help"), modal.WithMaxWidth(80)),
- viewport: vp,
- }
-}
diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go
deleted file mode 100644
index e30a1068e..000000000
--- a/packages/tui/internal/components/dialog/models.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package dialog
-
-import (
- "context"
- "fmt"
- "sort"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "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"
-)
-
-const (
- numVisibleModels = 10
- minDialogWidth = 40
- maxDialogWidth = 80
- maxRecentModels = 5
-)
-
-// ModelDialog interface for the model selection dialog
-type ModelDialog interface {
- layout.Modal
-}
-
-type modelDialog struct {
- app *app.App
- allModels []ModelWithProvider
- width int
- height int
- modal *modal.Modal
- searchDialog *SearchDialog
- dialogWidth int
-}
-
-type ModelWithProvider struct {
- Model opencode.Model
- Provider opencode.Provider
-}
-
-// modelItem is a custom list item for model selections
-type modelItem struct {
- model ModelWithProvider
-}
-
-func (m modelItem) Render(
- selected bool,
- width int,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
-
- itemStyle := baseStyle.
- Background(t.BackgroundPanel()).
- Foreground(t.Text())
-
- if selected {
- itemStyle = itemStyle.Foreground(t.Primary())
- }
-
- providerStyle := baseStyle.
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel())
-
- modelPart := itemStyle.Render(m.model.Model.Name)
- providerPart := providerStyle.Render(fmt.Sprintf(" %s", m.model.Provider.Name))
-
- combinedText := modelPart + providerPart
- return baseStyle.
- Background(t.BackgroundPanel()).
- PaddingLeft(1).
- Render(combinedText)
-}
-
-func (m modelItem) Selectable() bool {
- return true
-}
-
-type modelKeyMap struct {
- Enter key.Binding
- Escape key.Binding
-}
-
-var modelKeys = modelKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select model"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-func (m *modelDialog) Init() tea.Cmd {
- m.setupAllModels()
- return m.searchDialog.Init()
-}
-
-func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case SearchSelectionMsg:
- // Handle selection from search dialog
- if item, ok := msg.Item.(modelItem); ok {
- return m, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(
- app.ModelSelectedMsg{
- Provider: item.model.Provider,
- Model: item.model.Model,
- }),
- )
- }
- return m, util.CmdHandler(modal.CloseModalMsg{})
- case SearchCancelledMsg:
- return m, util.CmdHandler(modal.CloseModalMsg{})
-
- case SearchRemoveItemMsg:
- if item, ok := msg.Item.(modelItem); ok {
- if m.isModelInRecentSection(item.model, msg.Index) {
- m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
- items := m.buildDisplayList(m.searchDialog.GetQuery())
- m.searchDialog.SetItems(items)
- return m, m.app.SaveState()
- }
- }
- return m, nil
-
- case SearchQueryChangedMsg:
- // Update the list based on search query
- items := m.buildDisplayList(msg.Query)
- m.searchDialog.SetItems(items)
- return m, nil
-
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- m.searchDialog.SetWidth(m.dialogWidth)
- m.searchDialog.SetHeight(msg.Height)
- }
-
- updatedDialog, cmd := m.searchDialog.Update(msg)
- m.searchDialog = updatedDialog.(*SearchDialog)
- return m, cmd
-}
-
-func (m *modelDialog) View() string {
- return m.searchDialog.View()
-}
-
-func (m *modelDialog) calculateOptimalWidth(models []ModelWithProvider) int {
- maxWidth := minDialogWidth
-
- for _, model := range models {
- // Calculate the width needed for this item: "ModelName (ProviderName)"
- // Add 4 for the parentheses, space, and some padding
- itemWidth := len(model.Model.Name) + len(model.Provider.Name) + 4
- if itemWidth > maxWidth {
- maxWidth = itemWidth
- }
- }
-
- if maxWidth > maxDialogWidth {
- maxWidth = maxDialogWidth
- }
-
- return maxWidth
-}
-
-func (m *modelDialog) setupAllModels() {
- providers, _ := m.app.ListProviders(context.Background())
-
- m.allModels = make([]ModelWithProvider, 0)
- for _, provider := range providers {
- for _, model := range provider.Models {
- m.allModels = append(m.allModels, ModelWithProvider{
- Model: model,
- Provider: provider,
- })
- }
- }
-
- m.sortModels()
-
- // Calculate optimal width based on all models
- m.dialogWidth = m.calculateOptimalWidth(m.allModels)
-
- // Initialize search dialog
- m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
- m.searchDialog.SetWidth(m.dialogWidth)
-
- // Build initial display list (empty query shows grouped view)
- items := m.buildDisplayList("")
- m.searchDialog.SetItems(items)
-}
-
-func (m *modelDialog) sortModels() {
- sort.Slice(m.allModels, func(i, j int) bool {
- modelA := m.allModels[i]
- modelB := m.allModels[j]
-
- usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
- usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
-
- // If both have usage times, sort by most recent first
- if !usageA.IsZero() && !usageB.IsZero() {
- return usageA.After(usageB)
- }
-
- // If only one has usage time, it goes first
- if !usageA.IsZero() && usageB.IsZero() {
- return true
- }
- if usageA.IsZero() && !usageB.IsZero() {
- return false
- }
-
- // If neither has usage time, sort by release date desc if available
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
- dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
- dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
- if !dateA.IsZero() && !dateB.IsZero() {
- return dateA.After(dateB)
- }
- }
-
- // If only one has release date, it goes first
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
- return true
- }
- if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
- return false
- }
-
- // If neither has usage time nor release date, fall back to alphabetical sorting
- return modelA.Model.Name < modelB.Model.Name
- })
-}
-
-func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
- if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
- return parsed
- }
-
- return time.Time{}
-}
-
-func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
- for _, usage := range m.app.State.RecentlyUsedModels {
- if usage.ProviderID == providerID && usage.ModelID == modelID {
- return usage.LastUsed
- }
- }
- return time.Time{}
-}
-
-// buildDisplayList creates the list items based on search query
-func (m *modelDialog) buildDisplayList(query string) []list.Item {
- if query != "" {
- // Search mode: use fuzzy matching
- return m.buildSearchResults(query)
- } else {
- // Grouped mode: show Recent section and provider groups
- return m.buildGroupedResults()
- }
-}
-
-// buildSearchResults creates a flat list of search results using fuzzy matching
-func (m *modelDialog) buildSearchResults(query string) []list.Item {
- type modelMatch struct {
- model ModelWithProvider
- score int
- }
-
- modelNames := []string{}
- modelMap := make(map[string]ModelWithProvider)
-
- // Create search strings and perform fuzzy matching
- for _, model := range m.allModels {
- searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
- modelNames = append(modelNames, searchStr)
- modelMap[searchStr] = model
-
- searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
- modelNames = append(modelNames, searchStr)
- modelMap[searchStr] = model
- }
-
- matches := fuzzy.RankFindFold(query, modelNames)
- sort.Sort(matches)
-
- items := []list.Item{}
- seenModels := make(map[string]bool)
-
- for _, match := range matches {
- model := modelMap[match.Target]
- // Create a unique key to avoid duplicates
- // Include name to handle custom models with same ID but different names
- key := fmt.Sprintf("%s:%s:%s", model.Provider.ID, model.Model.ID, model.Model.Name)
- if seenModels[key] {
- continue
- }
- seenModels[key] = true
- items = append(items, modelItem{model: model})
- }
-
- return items
-}
-
-// buildGroupedResults creates a grouped list with Recent section and provider groups
-func (m *modelDialog) buildGroupedResults() []list.Item {
- var items []list.Item
-
- // Add Recent section
- recentModels := m.getRecentModels(maxRecentModels)
- if len(recentModels) > 0 {
- items = append(items, list.HeaderItem("Recent"))
- for _, model := range recentModels {
- items = append(items, modelItem{model: model})
- }
- }
-
- // Group models by provider
- providerGroups := make(map[string][]ModelWithProvider)
- for _, model := range m.allModels {
- providerName := model.Provider.Name
- providerGroups[providerName] = append(providerGroups[providerName], model)
- }
-
- // Get sorted provider names for consistent order
- var providerNames []string
- for name := range providerGroups {
- providerNames = append(providerNames, name)
- }
- sort.Strings(providerNames)
-
- // Add provider groups
- for _, providerName := range providerNames {
- models := providerGroups[providerName]
-
- // Sort models within provider group
- sort.Slice(models, func(i, j int) bool {
- modelA := models[i]
- modelB := models[j]
-
- usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
- usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
-
- // Sort by usage time first, then by release date, then alphabetically
- if !usageA.IsZero() && !usageB.IsZero() {
- return usageA.After(usageB)
- }
- if !usageA.IsZero() && usageB.IsZero() {
- return true
- }
- if usageA.IsZero() && !usageB.IsZero() {
- return false
- }
-
- // Sort by release date if available
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
- dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
- dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
- if !dateA.IsZero() && !dateB.IsZero() {
- return dateA.After(dateB)
- }
- }
-
- return modelA.Model.Name < modelB.Model.Name
- })
-
- // Add provider header
- items = append(items, list.HeaderItem(providerName))
-
- // Add models in this provider group
- for _, model := range models {
- items = append(items, modelItem{model: model})
- }
- }
-
- return items
-}
-
-// getRecentModels returns the most recently used models
-func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
- var recentModels []ModelWithProvider
-
- // Get recent models from app state
- for _, usage := range m.app.State.RecentlyUsedModels {
- if len(recentModels) >= limit {
- break
- }
-
- // Find the corresponding model
- for _, model := range m.allModels {
- if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
- recentModels = append(recentModels, model)
- break
- }
- }
- }
-
- return recentModels
-}
-
-func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int) bool {
- // Only check if we're in grouped mode (no search query)
- if m.searchDialog.GetQuery() != "" {
- return false
- }
-
- recentModels := m.getRecentModels(maxRecentModels)
- if len(recentModels) == 0 {
- return false
- }
-
- // Index 0 is the "Recent" header, so recent models are at indices 1 to len(recentModels)
- if index >= 1 && index <= len(recentModels) {
- if index-1 < len(recentModels) {
- recentModel := recentModels[index-1]
- return recentModel.Provider.ID == model.Provider.ID &&
- recentModel.Model.ID == model.Model.ID
- }
- }
-
- return false
-}
-
-func (m *modelDialog) Render(background string) string {
- return m.modal.Render(m.View(), background)
-}
-
-func (s *modelDialog) Close() tea.Cmd {
- return nil
-}
-
-func NewModelDialog(app *app.App) ModelDialog {
- dialog := &modelDialog{
- app: app,
- }
-
- dialog.setupAllModels()
-
- dialog.modal = modal.New(
- modal.WithTitle("Select Model"),
- modal.WithMaxWidth(dialog.dialogWidth+4),
- )
-
- return dialog
-}
diff --git a/packages/tui/internal/components/dialog/search.go b/packages/tui/internal/components/dialog/search.go
deleted file mode 100644
index b8fefd8b9..000000000
--- a/packages/tui/internal/components/dialog/search.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textinput"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// SearchQueryChangedMsg is emitted when the search query changes
-type SearchQueryChangedMsg struct {
- Query string
-}
-
-// SearchSelectionMsg is emitted when an item is selected
-type SearchSelectionMsg struct {
- Item any
- Index int
-}
-
-// SearchCancelledMsg is emitted when the search is cancelled
-type SearchCancelledMsg struct{}
-
-// SearchRemoveItemMsg is emitted when Ctrl+X is pressed to remove an item
-type SearchRemoveItemMsg struct {
- Item any
- Index int
-}
-
-// SearchDialog is a reusable component that combines a text input with a list
-type SearchDialog struct {
- textInput textinput.Model
- list list.List[list.Item]
- width int
- height int
- focused bool
-}
-
-type searchKeyMap struct {
- Up key.Binding
- Down key.Binding
- Enter key.Binding
- Escape key.Binding
- Remove key.Binding
-}
-
-var searchKeys = searchKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("↑", "previous item"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("↓", "next item"),
- ),
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
- Remove: key.NewBinding(
- key.WithKeys("ctrl+x"),
- key.WithHelp("ctrl+x", "remove from recent"),
- ),
-}
-
-// NewSearchDialog creates a new SearchDialog
-func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
- t := theme.CurrentTheme()
- bgColor := t.BackgroundElement()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- ti := textinput.New()
- ti.Placeholder = placeholder
- 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.Focused.Prompt = styles.NewStyle().
- Background(bgColor).
- Lipgloss()
- ti.Styles.Cursor.Color = t.Primary()
- ti.VirtualCursor = true
-
- ti.Prompt = " "
- ti.CharLimit = -1
- ti.Focus()
-
- emptyList := list.NewListComponent(
- list.WithItems([]list.Item{}),
- list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
- list.WithFallbackMessage[list.Item](" No items"),
- list.WithAlphaNumericKeys[list.Item](false),
- list.WithRenderFunc(
- func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, baseStyle)
- },
- ),
- list.WithSelectableFunc(func(item list.Item) bool {
- return item.Selectable()
- }),
- )
-
- return &SearchDialog{
- textInput: ti,
- list: emptyList,
- focused: true,
- }
-}
-
-func (s *SearchDialog) Init() tea.Cmd {
- return textinput.Blink
-}
-
-func (s *SearchDialog) updateTextInput(msg tea.Msg) []tea.Cmd {
- var cmds []tea.Cmd
- oldValue := s.textInput.Value()
- var cmd tea.Cmd
- s.textInput, cmd = s.textInput.Update(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- if newValue := s.textInput.Value(); newValue != oldValue {
- cmds = append(cmds, func() tea.Msg {
- return SearchQueryChangedMsg{Query: newValue}
- })
- }
- return cmds
-}
-
-func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.PasteMsg, tea.ClipboardMsg:
- cmds = append(cmds, s.updateTextInput(msg)...)
- case tea.KeyMsg:
- switch msg.String() {
- case "ctrl+c":
- value := s.textInput.Value()
- if value == "" {
- return s, nil
- }
- s.textInput.Reset()
- cmds = append(cmds, func() tea.Msg {
- return SearchQueryChangedMsg{Query: ""}
- })
- }
-
- switch {
- case key.Matches(msg, searchKeys.Escape):
- return s, func() tea.Msg { return SearchCancelledMsg{} }
-
- case key.Matches(msg, searchKeys.Enter):
- if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
- return s, func() tea.Msg {
- return SearchSelectionMsg{Item: selectedItem, Index: idx}
- }
- }
-
- case key.Matches(msg, searchKeys.Remove):
- if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
- return s, func() tea.Msg {
- return SearchRemoveItemMsg{Item: selectedItem, Index: idx}
- }
- }
-
- case key.Matches(msg, searchKeys.Up):
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[list.Item])
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- case key.Matches(msg, searchKeys.Down):
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[list.Item])
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- default:
- cmds = append(cmds, s.updateTextInput(msg)...)
- }
- }
-
- return s, tea.Batch(cmds...)
-}
-
-func (s *SearchDialog) View() string {
- s.list.SetMaxWidth(s.width)
- listView := s.list.View()
- listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
- textinput := s.textInput.View()
- return textinput + "\n\n" + listView
-}
-
-// SetWidth sets the width of the search dialog
-func (s *SearchDialog) SetWidth(width int) {
- s.width = width
- s.textInput.SetWidth(width - 2) // Account for padding and borders
-}
-
-// SetHeight sets the height of the search dialog
-func (s *SearchDialog) SetHeight(height int) {
- s.height = height
-}
-
-// SetItems updates the list items
-func (s *SearchDialog) SetItems(items []list.Item) {
- s.list.SetItems(items)
-}
-
-// GetQuery returns the current search query
-func (s *SearchDialog) GetQuery() string {
- return s.textInput.Value()
-}
-
-// SetQuery sets the search query
-func (s *SearchDialog) SetQuery(query string) {
- s.textInput.SetValue(query)
-}
-
-// Focus focuses the search dialog
-func (s *SearchDialog) Focus() {
- s.focused = true
- s.textInput.Focus()
-}
-
-// Blur removes focus from the search dialog
-func (s *SearchDialog) Blur() {
- s.focused = false
- s.textInput.Blur()
-}
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
deleted file mode 100644
index a1700c896..000000000
--- a/packages/tui/internal/components/dialog/session.go
+++ /dev/null
@@ -1,400 +0,0 @@
-package dialog
-
-import (
- "context"
- "strings"
-
- "slices"
-
- "github.com/charmbracelet/bubbles/v2/textinput"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// SessionDialog interface for the session switching dialog
-type SessionDialog interface {
- layout.Modal
-}
-
-// sessionItem is a custom list item for sessions that can show delete confirmation
-type sessionItem struct {
- title string
- isDeleteConfirming bool
- isCurrentSession bool
-}
-
-func (s sessionItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
-
- var text string
- if s.isDeleteConfirming {
- text = "Press again to confirm delete"
- } else {
- if s.isCurrentSession {
- text = "● " + s.title
- } else {
- text = s.title
- }
- }
-
- truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
-
- var itemStyle styles.Style
- if selected {
- if s.isDeleteConfirming {
- // Red background for delete confirmation
- itemStyle = baseStyle.
- Background(t.Error()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else if s.isCurrentSession {
- // Different style for current session when selected
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1).
- Bold(true)
- } else {
- // Normal selection
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- }
- } else {
- if s.isDeleteConfirming {
- // Red text for delete confirmation when not selected
- itemStyle = baseStyle.
- Foreground(t.Error()).
- PaddingLeft(1)
- } else if s.isCurrentSession {
- // Highlight current session when not selected
- itemStyle = baseStyle.
- Foreground(t.Primary()).
- PaddingLeft(1).
- Bold(true)
- } else {
- itemStyle = baseStyle.
- PaddingLeft(1)
- }
- }
-
- return itemStyle.Render(truncatedStr)
-}
-
-func (s sessionItem) Selectable() bool {
- return true
-}
-
-type sessionDialog struct {
- width int
- height int
- modal *modal.Modal
- sessions []opencode.Session
- list list.List[sessionItem]
- app *app.App
- deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
- renameMode bool
- renameInput textinput.Model
- renameIndex int // index of session being renamed
-}
-
-func (s *sessionDialog) Init() tea.Cmd {
- return nil
-}
-
-func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- s.width = msg.Width
- s.height = msg.Height
- s.list.SetMaxWidth(layout.Current.Container.Width - 12)
- case tea.KeyPressMsg:
- if s.renameMode {
- switch msg.String() {
- case "enter":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex {
- newTitle := s.renameInput.Value()
- if strings.TrimSpace(newTitle) != "" {
- sessionToUpdate := s.sessions[idx]
- return s, tea.Sequence(
- func() tea.Msg {
- ctx := context.Background()
- err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle)
- if err != nil {
- return toast.NewErrorToast("Failed to rename session: " + err.Error())()
- }
- s.sessions[idx].Title = newTitle
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
- return toast.NewSuccessToast("Session renamed successfully")()
- },
- )
- }
- }
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
- return s, nil
- default:
- var cmd tea.Cmd
- s.renameInput, cmd = s.renameInput.Update(msg)
- return s, cmd
- }
- } else {
- switch msg.String() {
- case "enter":
- if s.deleteConfirmation >= 0 {
- s.deleteConfirmation = -1
- s.updateListItems()
- return s, nil
- }
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- selectedSession := s.sessions[idx]
- return s, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
- )
- }
- case "n":
- return s, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.SessionClearedMsg{}),
- )
- case "r":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- s.renameMode = true
- s.renameIndex = idx
- s.setupRenameInput(s.sessions[idx].Title)
- s.modal.SetTitle("Rename Session")
- s.updateListItems()
- return s, textinput.Blink
- }
- case "x", "delete", "backspace":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- if s.deleteConfirmation == idx {
- // Second press - actually delete the session
- sessionToDelete := s.sessions[idx]
- return s, tea.Sequence(
- func() tea.Msg {
- s.sessions = slices.Delete(s.sessions, idx, idx+1)
- s.deleteConfirmation = -1
- s.updateListItems()
- return nil
- },
- s.deleteSession(sessionToDelete.ID),
- )
- } else {
- // First press - enter delete confirmation mode
- s.deleteConfirmation = idx
- s.updateListItems()
- return s, nil
- }
- }
- case "esc":
- if s.deleteConfirmation >= 0 {
- s.deleteConfirmation = -1
- s.updateListItems()
- return s, nil
- }
- }
- }
- }
-
- if !s.renameMode {
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[sessionItem])
- return s, cmd
- }
- return s, nil
-}
-
-func (s *sessionDialog) Render(background string) string {
- if s.renameMode {
- // Show rename input instead of list
- t := theme.CurrentTheme()
- renameView := s.renameInput.View()
-
- mutedStyle := styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel()).
- Render
- helpText := mutedStyle("Enter to confirm, Esc to cancel")
- helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
-
- content := strings.Join([]string{renameView, helpText}, "\n")
- return s.modal.Render(content, background)
- }
-
- listView := s.list.View()
-
- t := theme.CurrentTheme()
- keyStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundPanel()).
- Bold(true).
- Render
- mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
-
- leftHelp := keyStyle("n") + mutedStyle(" new ") + keyStyle("r") + mutedStyle(" rename")
- rightHelp := keyStyle("x/del") + mutedStyle(" delete")
-
- bgColor := t.BackgroundPanel()
- helpText := layout.Render(layout.FlexOptions{
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Width: layout.Current.Container.Width - 14,
- Background: &bgColor,
- }, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
-
- helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
-
- content := strings.Join([]string{listView, helpText}, "\n")
-
- return s.modal.Render(content, background)
-}
-
-func (s *sessionDialog) setupRenameInput(currentTitle string) {
- t := theme.CurrentTheme()
- bgColor := t.BackgroundPanel()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- s.renameInput = textinput.New()
- s.renameInput.SetValue(currentTitle)
- s.renameInput.Focus()
- s.renameInput.CharLimit = 100
- s.renameInput.SetWidth(layout.Current.Container.Width - 20)
-
- s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Blurred.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Prompt = styles.NewStyle().
- Background(bgColor).
- Lipgloss()
-}
-
-func (s *sessionDialog) updateListItems() {
- _, currentIdx := s.list.GetSelectedItem()
-
- var items []sessionItem
- for i, sess := range s.sessions {
- item := sessionItem{
- title: sess.Title,
- isDeleteConfirming: s.deleteConfirmation == i,
- isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
- }
- items = append(items, item)
- }
- s.list.SetItems(items)
- s.list.SetSelectedIndex(currentIdx)
-}
-
-func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
- return func() tea.Msg {
- ctx := context.Background()
- if err := s.app.DeleteSession(ctx, sessionID); err != nil {
- return toast.NewErrorToast("Failed to delete session: " + err.Error())()
- }
- return nil
- }
-}
-
-// ReopenSessionModalMsg is emitted when the session modal should be reopened
-type ReopenSessionModalMsg struct{}
-
-func (s *sessionDialog) Close() tea.Cmd {
- if s.renameMode {
- // If in rename mode, exit rename mode and return a command to reopen the modal
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
-
- // Return a command that will reopen the session modal
- return func() tea.Msg {
- return ReopenSessionModalMsg{}
- }
- }
- // Normal close behavior
- return nil
-}
-
-// NewSessionDialog creates a new session switching dialog
-func NewSessionDialog(app *app.App) SessionDialog {
- sessions, _ := app.ListSessions(context.Background())
-
- var filteredSessions []opencode.Session
- var items []sessionItem
- for _, sess := range sessions {
- if sess.ParentID != "" {
- continue
- }
- filteredSessions = append(filteredSessions, sess)
- items = append(items, sessionItem{
- title: sess.Title,
- isDeleteConfirming: false,
- isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
- })
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[sessionItem](10),
- list.WithFallbackMessage[sessionItem]("No sessions available"),
- list.WithAlphaNumericKeys[sessionItem](true),
- list.WithRenderFunc(
- func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- list.WithSelectableFunc(func(item sessionItem) bool {
- return true
- }),
- )
- listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
-
- return &sessionDialog{
- sessions: filteredSessions,
- list: listComponent,
- app: app,
- deleteConfirmation: -1,
- renameMode: false,
- renameIndex: -1,
- modal: modal.New(
- modal.WithTitle("Switch Session"),
- modal.WithMaxWidth(layout.Current.Container.Width-8),
- ),
- }
-}
diff --git a/packages/tui/internal/components/dialog/theme.go b/packages/tui/internal/components/dialog/theme.go
deleted file mode 100644
index c71cddc8e..000000000
--- a/packages/tui/internal/components/dialog/theme.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package dialog
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
- list "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"
-)
-
-// ThemeSelectedMsg is sent when the theme is changed
-type ThemeSelectedMsg struct {
- ThemeName string
-}
-
-// ThemeDialog interface for the theme switching dialog
-type ThemeDialog interface {
- layout.Modal
-}
-
-type themeDialog struct {
- width int
- height int
-
- modal *modal.Modal
- list list.List[list.Item]
- originalTheme string
- themeApplied bool
-}
-
-func (t *themeDialog) Init() tea.Cmd {
- return nil
-}
-
-func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- t.width = msg.Width
- t.height = msg.Height
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- if item, idx := t.list.GetSelectedItem(); idx >= 0 {
- if stringItem, ok := item.(list.StringItem); ok {
- selectedTheme := string(stringItem)
- if err := theme.SetTheme(selectedTheme); err != nil {
- // status.Error(err.Error())
- return t, nil
- }
- t.themeApplied = true
- return t, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
- )
- }
- }
-
- }
- }
-
- _, prevIdx := t.list.GetSelectedItem()
-
- var cmd tea.Cmd
- listModel, cmd := t.list.Update(msg)
- t.list = listModel.(list.List[list.Item])
-
- if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
- if stringItem, ok := item.(list.StringItem); ok {
- theme.SetTheme(string(stringItem))
- return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
- }
- }
- return t, cmd
-}
-
-func (t *themeDialog) Render(background string) string {
- return t.modal.Render(t.list.View(), background)
-}
-
-func (t *themeDialog) Close() tea.Cmd {
- if !t.themeApplied {
- theme.SetTheme(t.originalTheme)
- return util.CmdHandler(ThemeSelectedMsg{ThemeName: t.originalTheme})
- }
- return nil
-}
-
-// NewThemeDialog creates a new theme switching dialog
-func NewThemeDialog() ThemeDialog {
- themes := theme.AvailableThemes()
- currentTheme := theme.CurrentThemeName()
-
- var selectedIdx int
- for i, name := range themes {
- if name == currentTheme {
- selectedIdx = i
- }
- }
-
- // Convert themes to list items
- items := make([]list.Item, len(themes))
- for i, theme := range themes {
- items[i] = list.StringItem(theme)
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[list.Item](10),
- list.WithFallbackMessage[list.Item]("No themes available"),
- list.WithAlphaNumericKeys[list.Item](true),
- list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, baseStyle)
- }),
- list.WithSelectableFunc(func(item list.Item) bool {
- return item.Selectable()
- }),
- )
-
- // Set the initial selection to the current theme
- listComponent.SetSelectedIndex(selectedIdx)
-
- // Set the max width for the list to match the modal width
- listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
- return &themeDialog{
- list: listComponent,
- modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
- originalTheme: currentTheme,
- themeApplied: false,
- }
-}
diff --git a/packages/tui/internal/components/dialog/timeline.go b/packages/tui/internal/components/dialog/timeline.go
deleted file mode 100644
index f2eeb7fb4..000000000
--- a/packages/tui/internal/components/dialog/timeline.go
+++ /dev/null
@@ -1,353 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "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"
-)
-
-// TimelineDialog interface for the session timeline dialog
-type TimelineDialog interface {
- layout.Modal
-}
-
-// ScrollToMessageMsg is sent when a message should be scrolled to
-type ScrollToMessageMsg struct {
- MessageID string
-}
-
-// RestoreToMessageMsg is sent when conversation should be restored to a specific message
-type RestoreToMessageMsg struct {
- MessageID string
- Index int
-}
-
-// timelineItem represents a user message in the timeline list
-type timelineItem struct {
- messageID string
- content string
- timestamp time.Time
- index int // Index in the full message list
- toolCount int // Number of tools used in this message
-}
-
-func (n timelineItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
- isCurrent bool,
-) string {
- t := theme.CurrentTheme()
- infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
- textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
-
- // Add dot after timestamp if this is the current message - only apply color when not selected
- var dot string
- var dotVisualLen int
- if isCurrent {
- if selected {
- dot = "● "
- } else {
- dot = lipgloss.NewStyle().Foreground(t.Success()).Render("● ")
- }
- dotVisualLen = 2 // "● " is 2 characters wide
- }
-
- // Format timestamp - only apply color when not selected
- var timeStr string
- var timeVisualLen int
- if selected {
- timeStr = n.timestamp.Format("15:04") + " " + dot
- timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
- } else {
- timeStr = infoStyle(n.timestamp.Format("15:04")+" ") + dot
- timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
- }
-
- // Tool count display (fixed width for alignment) - only apply color when not selected
- toolInfo := ""
- toolInfoVisualLen := 0
- if n.toolCount > 0 {
- toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
- if selected {
- toolInfo = toolInfoText
- } else {
- toolInfo = infoStyle(toolInfoText)
- }
- toolInfoVisualLen = lipgloss.Width(toolInfo)
- }
-
- // Calculate available space for content
- // Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer
- reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
- contentWidth := max(width-reservedSpace, 8)
-
- truncatedContent := truncate.StringWithTail(
- strings.Split(n.content, "\n")[0],
- uint(contentWidth),
- "...",
- )
-
- // Apply normal text color to content for non-selected items
- var styledContent string
- if selected {
- styledContent = truncatedContent
- } else {
- styledContent = textStyle(truncatedContent)
- }
-
- // Create the line with proper spacing - content left-aligned, tools right-aligned
- var text string
- text = timeStr + styledContent
- if toolInfo != "" {
- bgColor := t.BackgroundPanel()
- if selected {
- bgColor = t.Primary()
- }
- text = layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: width - 2,
- },
- layout.FlexItem{
- View: text,
- },
- layout.FlexItem{
- View: toolInfo,
- },
- )
- }
-
- var itemStyle styles.Style
- if selected {
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else {
- itemStyle = baseStyle.PaddingLeft(1)
- }
-
- return itemStyle.Render(text)
-}
-
-func (n timelineItem) Selectable() bool {
- return true
-}
-
-type timelineDialog struct {
- width int
- height int
- modal *modal.Modal
- list list.List[timelineItem]
- app *app.App
-}
-
-func (n *timelineDialog) Init() tea.Cmd {
- return nil
-}
-
-func (n *timelineDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- n.width = msg.Width
- n.height = msg.Height
- n.list.SetMaxWidth(layout.Current.Container.Width - 12)
- case tea.KeyPressMsg:
- switch msg.String() {
- case "up", "down":
- // Handle navigation and immediately scroll to selected message
- var cmd tea.Cmd
- listModel, cmd := n.list.Update(msg)
- n.list = listModel.(list.List[timelineItem])
-
- // Get the newly selected item and scroll to it immediately
- if item, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, tea.Sequence(
- cmd,
- util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
- )
- }
- return n, cmd
- case "r":
- // Restore conversation to selected message
- if item, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, tea.Sequence(
- util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
- util.CmdHandler(modal.CloseModalMsg{}),
- )
- }
- case "enter":
- // Keep Enter functionality for closing the modal
- if _, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, util.CmdHandler(modal.CloseModalMsg{})
- }
- }
- }
-
- var cmd tea.Cmd
- listModel, cmd := n.list.Update(msg)
- n.list = listModel.(list.List[timelineItem])
- return n, cmd
-}
-
-func (n *timelineDialog) Render(background string) string {
- listView := n.list.View()
-
- t := theme.CurrentTheme()
- keyStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundPanel()).
- Bold(true).
- Render
- mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
-
- helpText := keyStyle(
- "↑/↓",
- ) + mutedStyle(
- " jump ",
- ) + keyStyle(
- "r",
- ) + mutedStyle(
- " restore",
- )
-
- bgColor := t.BackgroundPanel()
- helpView := styles.NewStyle().
- Background(bgColor).
- Width(layout.Current.Container.Width - 14).
- PaddingLeft(1).
- PaddingTop(1).
- Render(helpText)
-
- content := strings.Join([]string{listView, helpView}, "\n")
-
- return n.modal.Render(content, background)
-}
-
-func (n *timelineDialog) Close() tea.Cmd {
- return nil
-}
-
-// extractMessagePreview extracts a preview from message parts
-func extractMessagePreview(parts []opencode.PartUnion) string {
- for _, part := range parts {
- switch casted := part.(type) {
- case opencode.TextPart:
- text := strings.TrimSpace(casted.Text)
- if text != "" {
- return text
- }
- }
- }
- return "No text content"
-}
-
-// countToolsInResponse counts tools in the assistant's response to a user message
-func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
- count := 0
- // Look at subsequent messages to find the assistant's response
- for i := userMessageIndex + 1; i < len(messages); i++ {
- message := messages[i]
- // If we hit another user message, stop looking
- if _, isUser := message.Info.(opencode.UserMessage); isUser {
- break
- }
- // Count tools in this assistant message
- for _, part := range message.Parts {
- switch part.(type) {
- case opencode.ToolPart:
- count++
- }
- }
- }
- return count
-}
-
-// NewTimelineDialog creates a new session timeline dialog
-func NewTimelineDialog(app *app.App) TimelineDialog { // renamed from NewNavigationDialog
- var items []timelineItem
-
- // Filter to only user messages and extract relevant info
- for i, message := range app.Messages {
- if userMsg, ok := message.Info.(opencode.UserMessage); ok {
- preview := extractMessagePreview(message.Parts)
- toolCount := countToolsInResponse(app.Messages, i)
-
- items = append(items, timelineItem{
- messageID: userMsg.ID,
- content: preview,
- timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
- index: i,
- toolCount: toolCount,
- })
- }
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[timelineItem](12),
- list.WithFallbackMessage[timelineItem]("No user messages in this session"),
- list.WithAlphaNumericKeys[timelineItem](true),
- list.WithRenderFunc(
- func(item timelineItem, selected bool, width int, baseStyle styles.Style) string {
- // Determine if this item is the current message for the session
- isCurrent := false
- if app.Session.Revert.MessageID != "" {
- // When reverted, Session.Revert.MessageID contains the NEXT user message ID
- // So we need to find the previous user message to highlight the correct one
- for i, navItem := range items {
- if navItem.messageID == app.Session.Revert.MessageID && i > 0 {
- // Found the next message, so the previous one is current
- isCurrent = item.messageID == items[i-1].messageID
- break
- }
- }
- } else if len(app.Messages) > 0 {
- // If not reverted, highlight the last user message
- lastUserMsgID := ""
- for i := len(app.Messages) - 1; i >= 0; i-- {
- if userMsg, ok := app.Messages[i].Info.(opencode.UserMessage); ok {
- lastUserMsgID = userMsg.ID
- break
- }
- }
- isCurrent = item.messageID == lastUserMsgID
- }
- // Only show the dot if undo/redo/restore is available
- showDot := app.Session.Revert.MessageID != ""
- return item.Render(selected, width, false, baseStyle, isCurrent && showDot)
- },
- ),
- list.WithSelectableFunc(func(item timelineItem) bool {
- return true
- }),
- )
- listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
-
- return &timelineDialog{
- list: listComponent,
- app: app,
- modal: modal.New(
- modal.WithTitle("Session Timeline"),
- modal.WithMaxWidth(layout.Current.Container.Width-8),
- ),
- }
-}
diff --git a/packages/tui/internal/components/diff/diff.go b/packages/tui/internal/components/diff/diff.go
deleted file mode 100644
index da2e007c2..000000000
--- a/packages/tui/internal/components/diff/diff.go
+++ /dev/null
@@ -1,957 +0,0 @@
-package diff
-
-import (
- "bufio"
- "bytes"
- "fmt"
- "image/color"
- "io"
- "regexp"
- "strconv"
- "strings"
- "sync"
- "unicode/utf8"
-
- "github.com/alecthomas/chroma/v2"
- "github.com/alecthomas/chroma/v2/formatters"
- "github.com/alecthomas/chroma/v2/lexers"
- "github.com/alecthomas/chroma/v2/styles"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/charmbracelet/x/ansi"
- "github.com/sergi/go-diff/diffmatchpatch"
- stylesi "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// -------------------------------------------------------------------------
-// Core Types
-// -------------------------------------------------------------------------
-
-// LineType represents the kind of line in a diff.
-type LineType int
-
-const (
- LineContext LineType = iota // Line exists in both files
- LineAdded // Line added in the new file
- LineRemoved // Line removed from the old file
-)
-
-var (
- ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
-)
-
-// Segment represents a portion of a line for intra-line highlighting
-type Segment struct {
- Start int
- End int
- Type LineType
- Text string
-}
-
-// DiffLine represents a single line in a diff
-type DiffLine struct {
- OldLineNo int // Line number in old file (0 for added lines)
- NewLineNo int // Line number in new file (0 for removed lines)
- Kind LineType // Type of line (added, removed, context)
- Content string // Content of the line
- Segments []Segment // Segments for intraline highlighting
-}
-
-// Hunk represents a section of changes in a diff
-type Hunk struct {
- Header string
- Lines []DiffLine
-}
-
-// DiffResult contains the parsed result of a diff
-type DiffResult struct {
- OldFile string
- NewFile string
- Hunks []Hunk
-}
-
-// linePair represents a pair of lines for side-by-side display
-type linePair struct {
- left *DiffLine
- right *DiffLine
-}
-
-// UnifiedConfig configures the rendering of unified diffs
-type UnifiedConfig struct {
- Width int
-}
-
-// UnifiedOption modifies a UnifiedConfig
-type UnifiedOption func(*UnifiedConfig)
-
-// NewUnifiedConfig creates a UnifiedConfig with default values
-func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
- config := UnifiedConfig{
- 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
-}
-
-// WithWidth sets the width for unified view
-func WithWidth(width int) UnifiedOption {
- return func(u *UnifiedConfig) {
- if width > 0 {
- u.Width = width
- }
- }
-}
-
-// -------------------------------------------------------------------------
-// Diff Parsing
-// -------------------------------------------------------------------------
-
-// ParseUnifiedDiff parses a unified diff format string into structured data
-func ParseUnifiedDiff(diff string) (DiffResult, error) {
- var result DiffResult
- var currentHunk *Hunk
- result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
-
- scanner := bufio.NewScanner(strings.NewReader(diff))
- var oldLine, newLine int
- inFileHeader := true
-
- for scanner.Scan() {
- line := scanner.Text()
-
- if inFileHeader {
- if strings.HasPrefix(line, "--- a/") {
- result.OldFile = line[6:]
- continue
- }
- if strings.HasPrefix(line, "+++ b/") {
- result.NewFile = line[6:]
- inFileHeader = false
- continue
- }
- }
-
- if strings.HasPrefix(line, "@@") {
- if currentHunk != nil {
- result.Hunks = append(result.Hunks, *currentHunk)
- }
- currentHunk = &Hunk{
- Header: line,
- Lines: make([]DiffLine, 0, 10), // Pre-allocate
- }
-
- // Manual parsing of hunk header is faster than regex
- parts := strings.Split(line, " ")
- if len(parts) > 2 {
- oldRange := strings.Split(parts[1][1:], ",")
- newRange := strings.Split(parts[2][1:], ",")
- oldLine, _ = strconv.Atoi(oldRange[0])
- newLine, _ = strconv.Atoi(newRange[0])
- }
- continue
- }
-
- if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
- continue
- }
-
- var dl DiffLine
- dl.Content = line
- if len(line) > 0 {
- switch line[0] {
- case '+':
- dl.Kind = LineAdded
- dl.NewLineNo = newLine
- dl.Content = line[1:]
- newLine++
- case '-':
- dl.Kind = LineRemoved
- dl.OldLineNo = oldLine
- dl.Content = line[1:]
- oldLine++
- default: // context line
- dl.Kind = LineContext
- dl.OldLineNo = oldLine
- dl.NewLineNo = newLine
- oldLine++
- newLine++
- }
- } else { // empty context line
- dl.Kind = LineContext
- dl.OldLineNo = oldLine
- dl.NewLineNo = newLine
- oldLine++
- newLine++
- }
- currentHunk.Lines = append(currentHunk.Lines, dl)
- }
-
- if currentHunk != nil {
- result.Hunks = append(result.Hunks, *currentHunk)
- }
-
- return result, scanner.Err()
-}
-
-// HighlightIntralineChanges updates lines in a hunk to show character-level differences
-func HighlightIntralineChanges(h *Hunk) {
- var updated []DiffLine
- dmp := diffmatchpatch.New()
-
- for i := 0; i < len(h.Lines); i++ {
- // Look for removed line followed by added line
- if i+1 < len(h.Lines) &&
- h.Lines[i].Kind == LineRemoved &&
- h.Lines[i+1].Kind == LineAdded {
-
- oldLine := h.Lines[i]
- newLine := h.Lines[i+1]
-
- // Find character-level differences
- patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
- patches = dmp.DiffCleanupSemantic(patches)
- patches = dmp.DiffCleanupMerge(patches)
- patches = dmp.DiffCleanupEfficiency(patches)
-
- segments := make([]Segment, 0)
-
- removeStart := 0
- addStart := 0
- for _, patch := range patches {
- switch patch.Type {
- case diffmatchpatch.DiffDelete:
- segments = append(segments, Segment{
- Start: removeStart,
- End: removeStart + len(patch.Text),
- Type: LineRemoved,
- Text: patch.Text,
- })
- removeStart += len(patch.Text)
- case diffmatchpatch.DiffInsert:
- segments = append(segments, Segment{
- Start: addStart,
- End: addStart + len(patch.Text),
- Type: LineAdded,
- Text: patch.Text,
- })
- addStart += len(patch.Text)
- default:
- // Context text, no highlighting needed
- removeStart += len(patch.Text)
- addStart += len(patch.Text)
- }
- }
- oldLine.Segments = segments
- newLine.Segments = segments
-
- updated = append(updated, oldLine, newLine)
- i++ // Skip the next line as we've already processed it
- } else {
- updated = append(updated, h.Lines[i])
- }
- }
-
- h.Lines = updated
-}
-
-// pairLines converts a flat list of diff lines to pairs for side-by-side display
-func pairLines(lines []DiffLine) []linePair {
- var pairs []linePair
- i := 0
-
- for i < len(lines) {
- switch lines[i].Kind {
- case LineRemoved:
- // Check if the next line is an addition, if so pair them
- if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
- pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
- i += 2
- } else {
- pairs = append(pairs, linePair{left: &lines[i], right: nil})
- i++
- }
- case LineAdded:
- pairs = append(pairs, linePair{left: nil, right: &lines[i]})
- i++
- case LineContext:
- pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
- i++
- }
- }
-
- return pairs
-}
-
-// -------------------------------------------------------------------------
-// Syntax Highlighting
-// -------------------------------------------------------------------------
-
-// SyntaxHighlight applies syntax highlighting to text based on file extension
-func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
- t := theme.CurrentTheme()
-
- // Determine the language lexer to use
- l := lexers.Match(fileName)
- if l == nil {
- l = lexers.Analyse(source)
- }
- if l == nil {
- l = lexers.Fallback
- }
- l = chroma.Coalesce(l)
-
- // Get the formatter
- f := formatters.Get(formatter)
- if f == nil {
- f = formatters.Fallback
- }
-
- // Dynamic theme based on current theme values
- syntaxThemeXml := fmt.Sprintf(`
- <style name="opencode-theme">
- <!-- Base colors -->
- <entry type="Background" style="bg:%s"/>
- <entry type="Text" style="%s"/>
- <entry type="Other" style="%s"/>
- <entry type="Error" style="%s"/>
- <!-- Keywords -->
- <entry type="Keyword" style="%s"/>
- <entry type="KeywordConstant" style="%s"/>
- <entry type="KeywordDeclaration" style="%s"/>
- <entry type="KeywordNamespace" style="%s"/>
- <entry type="KeywordPseudo" style="%s"/>
- <entry type="KeywordReserved" style="%s"/>
- <entry type="KeywordType" style="%s"/>
- <!-- Names -->
- <entry type="Name" style="%s"/>
- <entry type="NameAttribute" style="%s"/>
- <entry type="NameBuiltin" style="%s"/>
- <entry type="NameBuiltinPseudo" style="%s"/>
- <entry type="NameClass" style="%s"/>
- <entry type="NameConstant" style="%s"/>
- <entry type="NameDecorator" style="%s"/>
- <entry type="NameEntity" style="%s"/>
- <entry type="NameException" style="%s"/>
- <entry type="NameFunction" style="%s"/>
- <entry type="NameLabel" style="%s"/>
- <entry type="NameNamespace" style="%s"/>
- <entry type="NameOther" style="%s"/>
- <entry type="NameTag" style="%s"/>
- <entry type="NameVariable" style="%s"/>
- <entry type="NameVariableClass" style="%s"/>
- <entry type="NameVariableGlobal" style="%s"/>
- <entry type="NameVariableInstance" style="%s"/>
- <!-- Literals -->
- <entry type="Literal" style="%s"/>
- <entry type="LiteralDate" style="%s"/>
- <entry type="LiteralString" style="%s"/>
- <entry type="LiteralStringBacktick" style="%s"/>
- <entry type="LiteralStringChar" style="%s"/>
- <entry type="LiteralStringDoc" style="%s"/>
- <entry type="LiteralStringDouble" style="%s"/>
- <entry type="LiteralStringEscape" style="%s"/>
- <entry type="LiteralStringHeredoc" style="%s"/>
- <entry type="LiteralStringInterpol" style="%s"/>
- <entry type="LiteralStringOther" style="%s"/>
- <entry type="LiteralStringRegex" style="%s"/>
- <entry type="LiteralStringSingle" style="%s"/>
- <entry type="LiteralStringSymbol" style="%s"/>
- <!-- Numbers -->
- <entry type="LiteralNumber" style="%s"/>
- <entry type="LiteralNumberBin" style="%s"/>
- <entry type="LiteralNumberFloat" style="%s"/>
- <entry type="LiteralNumberHex" style="%s"/>
- <entry type="LiteralNumberInteger" style="%s"/>
- <entry type="LiteralNumberIntegerLong" style="%s"/>
- <entry type="LiteralNumberOct" style="%s"/>
- <!-- Operators -->
- <entry type="Operator" style="%s"/>
- <entry type="OperatorWord" style="%s"/>
- <entry type="Punctuation" style="%s"/>
- <!-- Comments -->
- <entry type="Comment" style="%s"/>
- <entry type="CommentHashbang" style="%s"/>
- <entry type="CommentMultiline" style="%s"/>
- <entry type="CommentSingle" style="%s"/>
- <entry type="CommentSpecial" style="%s"/>
- <entry type="CommentPreproc" style="%s"/>
- <!-- Generic styles -->
- <entry type="Generic" style="%s"/>
- <entry type="GenericDeleted" style="%s"/>
- <entry type="GenericEmph" style="italic %s"/>
- <entry type="GenericError" style="%s"/>
- <entry type="GenericHeading" style="bold %s"/>
- <entry type="GenericInserted" style="%s"/>
- <entry type="GenericOutput" style="%s"/>
- <entry type="GenericPrompt" style="%s"/>
- <entry type="GenericStrong" style="bold %s"/>
- <entry type="GenericSubheading" style="bold %s"/>
- <entry type="GenericTraceback" style="%s"/>
- <entry type="GenericUnderline" style="underline"/>
- <entry type="TextWhitespace" style="%s"/>
-</style>
-`,
- getChromaColor(t.BackgroundPanel()), // Background
- getChromaColor(t.Text()), // Text
- getChromaColor(t.Text()), // Other
- getChromaColor(t.Error()), // Error
-
- getChromaColor(t.SyntaxKeyword()), // Keyword
- getChromaColor(t.SyntaxKeyword()), // KeywordConstant
- getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration
- getChromaColor(t.SyntaxKeyword()), // KeywordNamespace
- getChromaColor(t.SyntaxKeyword()), // KeywordPseudo
- getChromaColor(t.SyntaxKeyword()), // KeywordReserved
- getChromaColor(t.SyntaxType()), // KeywordType
-
- getChromaColor(t.Text()), // Name
- getChromaColor(t.SyntaxVariable()), // NameAttribute
- getChromaColor(t.SyntaxType()), // NameBuiltin
- getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo
- getChromaColor(t.SyntaxType()), // NameClass
- getChromaColor(t.SyntaxVariable()), // NameConstant
- getChromaColor(t.SyntaxFunction()), // NameDecorator
- getChromaColor(t.SyntaxVariable()), // NameEntity
- getChromaColor(t.SyntaxType()), // NameException
- getChromaColor(t.SyntaxFunction()), // NameFunction
- getChromaColor(t.Text()), // NameLabel
- getChromaColor(t.SyntaxType()), // NameNamespace
- getChromaColor(t.SyntaxVariable()), // NameOther
- getChromaColor(t.SyntaxKeyword()), // NameTag
- getChromaColor(t.SyntaxVariable()), // NameVariable
- getChromaColor(t.SyntaxVariable()), // NameVariableClass
- getChromaColor(t.SyntaxVariable()), // NameVariableGlobal
- getChromaColor(t.SyntaxVariable()), // NameVariableInstance
-
- getChromaColor(t.SyntaxString()), // Literal
- getChromaColor(t.SyntaxString()), // LiteralDate
- getChromaColor(t.SyntaxString()), // LiteralString
- getChromaColor(t.SyntaxString()), // LiteralStringBacktick
- getChromaColor(t.SyntaxString()), // LiteralStringChar
- getChromaColor(t.SyntaxString()), // LiteralStringDoc
- getChromaColor(t.SyntaxString()), // LiteralStringDouble
- getChromaColor(t.SyntaxString()), // LiteralStringEscape
- getChromaColor(t.SyntaxString()), // LiteralStringHeredoc
- getChromaColor(t.SyntaxString()), // LiteralStringInterpol
- getChromaColor(t.SyntaxString()), // LiteralStringOther
- getChromaColor(t.SyntaxString()), // LiteralStringRegex
- getChromaColor(t.SyntaxString()), // LiteralStringSingle
- getChromaColor(t.SyntaxString()), // LiteralStringSymbol
-
- getChromaColor(t.SyntaxNumber()), // LiteralNumber
- getChromaColor(t.SyntaxNumber()), // LiteralNumberBin
- getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat
- getChromaColor(t.SyntaxNumber()), // LiteralNumberHex
- getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger
- getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
- getChromaColor(t.SyntaxNumber()), // LiteralNumberOct
-
- getChromaColor(t.SyntaxOperator()), // Operator
- getChromaColor(t.SyntaxKeyword()), // OperatorWord
- getChromaColor(t.SyntaxPunctuation()), // Punctuation
-
- getChromaColor(t.SyntaxComment()), // Comment
- getChromaColor(t.SyntaxComment()), // CommentHashbang
- getChromaColor(t.SyntaxComment()), // CommentMultiline
- getChromaColor(t.SyntaxComment()), // CommentSingle
- getChromaColor(t.SyntaxComment()), // CommentSpecial
- getChromaColor(t.SyntaxKeyword()), // CommentPreproc
-
- getChromaColor(t.Text()), // Generic
- getChromaColor(t.Error()), // GenericDeleted
- getChromaColor(t.Text()), // GenericEmph
- getChromaColor(t.Error()), // GenericError
- getChromaColor(t.Text()), // GenericHeading
- getChromaColor(t.Success()), // GenericInserted
- getChromaColor(t.TextMuted()), // GenericOutput
- getChromaColor(t.Text()), // GenericPrompt
- getChromaColor(t.Text()), // GenericStrong
- getChromaColor(t.Text()), // GenericSubheading
- getChromaColor(t.Error()), // GenericTraceback
- getChromaColor(t.Text()), // TextWhitespace
- )
-
- r := strings.NewReader(syntaxThemeXml)
- style := chroma.MustNewXMLStyle(r)
-
- // Modify the style to use the provided background
- s, err := style.Builder().Transform(
- func(t chroma.StyleEntry) chroma.StyleEntry {
- if _, ok := bg.(lipgloss.NoColor); ok {
- return t
- }
- r, g, b, _ := bg.RGBA()
- t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
- return t
- },
- ).Build()
- if err != nil {
- s = styles.Fallback
- }
-
- // Tokenize and format
- it, err := l.Tokenise(nil, source)
- if err != nil {
- return err
- }
-
- return f.Format(w, s, it)
-}
-
-// getColor returns the appropriate hex color string based on terminal background
-func getColor(adaptiveColor compat.AdaptiveColor) *string {
- return stylesi.AdaptiveColorToString(adaptiveColor)
-}
-
-func getChromaColor(adaptiveColor compat.AdaptiveColor) string {
- color := stylesi.AdaptiveColorToString(adaptiveColor)
- if color == nil {
- return ""
- }
- return *color
-}
-
-// highlightLine applies syntax highlighting to a single line
-func highlightLine(fileName string, line string, bg color.Color) string {
- var buf bytes.Buffer
- err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
- if err != nil {
- return line
- }
- return buf.String()
-}
-
-// createStyles generates the lipgloss styles needed for rendering diffs
-func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) {
- removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg())
- addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg())
- contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg())
- lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber())
- return
-}
-
-// -------------------------------------------------------------------------
-// Rendering Functions
-// -------------------------------------------------------------------------
-
-// applyHighlighting applies intra-line highlighting to a piece of text
-func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string {
- // Find all ANSI sequences in the content
- ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
-
- // Build a mapping of visible character positions to their actual indices
- visibleIdx := 0
- ansiSequences := make(map[int]string)
- lastAnsiSeq := "\x1b[0m" // Default reset sequence
-
- for i := 0; i < len(content); {
- isAnsi := false
- for _, match := range ansiMatches {
- if match[0] == i {
- ansiSequences[visibleIdx] = content[match[0]:match[1]]
- lastAnsiSeq = content[match[0]:match[1]]
- i = match[1]
- isAnsi = true
- break
- }
- }
- if isAnsi {
- continue
- }
-
- // For non-ANSI positions, store the last ANSI sequence
- if _, exists := ansiSequences[visibleIdx]; !exists {
- ansiSequences[visibleIdx] = lastAnsiSeq
- }
- visibleIdx++
-
- // Properly advance by UTF-8 rune, not byte
- _, size := utf8.DecodeRuneInString(content[i:])
- i += size
- }
-
- // Apply highlighting
- var sb strings.Builder
- inSelection := false
- currentPos := 0
-
- // Get the appropriate color based on terminal background
- bg := getColor(highlightBg)
- fg := getColor(theme.CurrentTheme().BackgroundPanel())
- var bgColor color.Color
- var fgColor color.Color
-
- if bg != nil {
- bgColor = lipgloss.Color(*bg)
- }
- if fg != nil {
- fgColor = lipgloss.Color(*fg)
- }
- for i := 0; i < len(content); {
- // Check if we're at an ANSI sequence
- isAnsi := false
- for _, match := range ansiMatches {
- if match[0] == i {
- sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
- i = match[1]
- isAnsi = true
- break
- }
- }
- if isAnsi {
- continue
- }
-
- // Check for segment boundaries
- for _, seg := range segments {
- if seg.Type == segmentType {
- if currentPos == seg.Start {
- inSelection = true
- }
- if currentPos == seg.End {
- inSelection = false
- }
- }
- }
-
- // Get current character (properly handle UTF-8)
- r, size := utf8.DecodeRuneInString(content[i:])
- char := string(r)
-
- if inSelection {
- // Get the current styling
- currentStyle := ansiSequences[currentPos]
-
- // Apply foreground and background highlight
- if fgColor != nil {
- sb.WriteString("\x1b[38;2;")
- r, g, b, _ := fgColor.RGBA()
- sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
- } else {
- sb.WriteString("\x1b[49m")
- }
- if bgColor != nil {
- sb.WriteString("\x1b[48;2;")
- r, g, b, _ := bgColor.RGBA()
- sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
- } else {
- sb.WriteString("\x1b[39m")
- }
- sb.WriteString(char)
-
- // Full reset of all attributes to ensure clean state
- sb.WriteString("\x1b[0m")
-
- // Reapply the original ANSI sequence
- sb.WriteString(currentStyle)
- } else {
- // Not in selection, just copy the character
- sb.WriteString(char)
- }
-
- currentPos++
- i += size
- }
-
- return sb.String()
-}
-
-// renderLinePrefix renders the line number and marker prefix for a diff line
-func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string {
- // Style the marker based on line type
- var styledMarker string
- switch dl.Kind {
- case LineRemoved:
- styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker)
- case LineAdded:
- styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker)
- case LineContext:
- styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker)
- default:
- styledMarker = marker
- }
-
- return lineNumberStyle.Render(lineNum + " " + styledMarker)
-}
-
-// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
-func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string {
- // Apply syntax highlighting
- content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
-
- // Apply intra-line highlighting if needed
- if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) {
- content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor)
- }
-
- // Add a padding space for added/removed lines
- if dl.Kind == LineRemoved || dl.Kind == LineAdded {
- content = bgStyle.Render(" ") + content
- }
-
- // Create the final line and truncate if needed
- return bgStyle.MaxHeight(1).Width(width).Render(
- ansi.Truncate(
- content,
- width,
- "...",
- ),
- )
-}
-
-// renderUnifiedLine renders a single line in unified diff format
-func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string {
- removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
-
- // Determine line style and marker based on line type
- var marker string
- var bgStyle stylesi.Style
- var lineNum string
- var highlightColor compat.AdaptiveColor
-
- switch dl.Kind {
- case LineRemoved:
- marker = "-"
- bgStyle = removedLineStyle
- lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
- highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
- if dl.OldLineNo > 0 {
- lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
- } else {
- lineNum = " "
- }
- case LineAdded:
- marker = "+"
- bgStyle = addedLineStyle
- lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
- highlightColor = t.DiffHighlightAdded() // TODO: handle "none"
- if dl.NewLineNo > 0 {
- lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
- } else {
- lineNum = " "
- }
- case LineContext:
- marker = " "
- bgStyle = contextLineStyle
- if dl.OldLineNo > 0 && dl.NewLineNo > 0 {
- lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo)
- } else {
- lineNum = " "
- }
- }
-
- // Create the line prefix
- prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t)
-
- // Render the content
- prefixWidth := ansi.StringWidth(prefix)
- contentWidth := width - prefixWidth
- content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth)
-
- return prefix + content
-}
-
-// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
-func renderDiffColumnLine(
- fileName string,
- dl *DiffLine,
- colWidth int,
- isLeftColumn bool,
- t theme.Theme,
-) string {
- if dl == nil {
- contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg())
- return contextLineStyle.Width(colWidth).Render("")
- }
-
- removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
-
- // Determine line style based on line type and column
- var marker string
- var bgStyle stylesi.Style
- var lineNum string
- var highlightColor compat.AdaptiveColor
-
- if isLeftColumn {
- // Left column logic
- switch dl.Kind {
- case LineRemoved:
- marker = "-"
- bgStyle = removedLineStyle
- lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
- highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
- case LineAdded:
- marker = "?"
- bgStyle = contextLineStyle
- case LineContext:
- marker = " "
- bgStyle = contextLineStyle
- }
-
- // Format line number for left column
- if dl.OldLineNo > 0 {
- lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
- }
- } else {
- // Right column logic
- switch dl.Kind {
- case LineAdded:
- marker = "+"
- bgStyle = addedLineStyle
- lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
- highlightColor = t.DiffHighlightAdded()
- case LineRemoved:
- marker = "?"
- bgStyle = contextLineStyle
- case LineContext:
- marker = " "
- bgStyle = contextLineStyle
- }
-
- // Format line number for right column
- if dl.NewLineNo > 0 {
- lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
- }
- }
-
- // Create the line prefix
- prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
-
- // Determine if we should render content
- shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
- (dl.Kind == LineAdded && !isLeftColumn) ||
- dl.Kind == LineContext
-
- if !shouldRenderContent {
- return bgStyle.Width(colWidth).Render("")
- }
-
- // Render the content
- prefixWidth := ansi.StringWidth(prefix)
- contentWidth := colWidth - prefixWidth
- content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth)
-
- return prefix + content
-}
-
-// renderLeftColumn formats the left side of a side-by-side diff
-func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
- return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
-}
-
-// renderRightColumn formats the right side of a side-by-side diff
-func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
- return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
-}
-
-// -------------------------------------------------------------------------
-// Public API
-// -------------------------------------------------------------------------
-
-// RenderUnifiedHunk formats a hunk for unified display
-func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
- // Apply options to create the configuration
- config := NewUnifiedConfig(opts...)
-
- // Make a copy of the hunk so we don't modify the original
- hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
- copy(hunkCopy.Lines, h.Lines)
-
- // Highlight changes within lines
- HighlightIntralineChanges(&hunkCopy)
-
- var sb strings.Builder
- sb.Grow(len(hunkCopy.Lines) * config.Width)
-
- util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
- return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
- })
-
- return sb.String()
-}
-
-// RenderSideBySideHunk formats a hunk for side-by-side display
-func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
- // Apply options to create the configuration
- config := NewSideBySideConfig(opts...)
-
- // Make a copy of the hunk so we don't modify the original
- hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
- copy(hunkCopy.Lines, h.Lines)
-
- // Highlight changes within lines
- HighlightIntralineChanges(&hunkCopy)
-
- // Pair lines for side-by-side display
- pairs := pairLines(hunkCopy.Lines)
-
- // Calculate column width
- colWidth := config.Width / 2
-
- leftWidth := colWidth
- rightWidth := config.Width - colWidth
- var sb strings.Builder
-
- util.WriteStringsPar(&sb, pairs, func(p linePair) string {
- wg := &sync.WaitGroup{}
- var leftStr, rightStr string
- wg.Add(2)
- go func() {
- defer wg.Done()
- leftStr = renderLeftColumn(fileName, p.left, leftWidth)
- }()
- go func() {
- defer wg.Done()
- rightStr = renderRightColumn(fileName, p.right, rightWidth)
- }()
- wg.Wait()
- return leftStr + rightStr + "\n"
- })
-
- return sb.String()
-}
-
-// FormatUnifiedDiff creates a unified formatted view of a diff
-func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
- diffResult, err := ParseUnifiedDiff(diffText)
- if err != nil {
- return "", err
- }
-
- var sb strings.Builder
- util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
- return RenderUnifiedHunk(filename, h, opts...)
- })
-
- return sb.String(), nil
-}
-
-// FormatDiff creates a side-by-side formatted view of a diff
-func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
- diffResult, err := ParseUnifiedDiff(diffText)
- if err != nil {
- return "", err
- }
-
- var sb strings.Builder
- util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
- return RenderSideBySideHunk(filename, h, opts...)
- })
-
- return sb.String(), nil
-}
diff --git a/packages/tui/internal/components/diff/parse.go b/packages/tui/internal/components/diff/parse.go
deleted file mode 100644
index 261ba5970..000000000
--- a/packages/tui/internal/components/diff/parse.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package diff
-
-import (
- "bufio"
- "fmt"
- "strings"
-)
-
-type DiffStats struct {
- Added int
- Removed int
- Modified int
-}
-
-func ParseStats(diff string) (map[string]DiffStats, error) {
- stats := make(map[string]DiffStats)
- var currentFile string
- scanner := bufio.NewScanner(strings.NewReader(diff))
-
- for scanner.Scan() {
- line := scanner.Text()
- if strings.HasPrefix(line, "---") {
- continue
- } else if strings.HasPrefix(line, "+++") {
- parts := strings.SplitN(line, " ", 2)
- if len(parts) == 2 {
- currentFile = strings.TrimPrefix(parts[1], "b/")
- }
- continue
- }
- if strings.HasPrefix(line, "@@") {
- continue
- }
- if currentFile == "" {
- continue
- }
-
- fileStats := stats[currentFile]
- switch {
- case strings.HasPrefix(line, "+"):
- fileStats.Added++
- case strings.HasPrefix(line, "-"):
- fileStats.Removed++
- }
- stats[currentFile] = fileStats
- }
-
- if err := scanner.Err(); err != nil {
- return nil, fmt.Errorf("error reading diff string: %w", err)
- }
-
- for file, fileStats := range stats {
- fileStats.Modified = fileStats.Added + fileStats.Removed
- stats[file] = fileStats
- }
-
- return stats, nil
-}
diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go
deleted file mode 100644
index a9823d0ab..000000000
--- a/packages/tui/internal/components/list/list.go
+++ /dev/null
@@ -1,436 +0,0 @@
-package list
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// Item interface that all list items must implement
-type Item interface {
- Render(selected bool, width int, baseStyle styles.Style) string
- Selectable() bool
-}
-
-// RenderFunc defines how to render an item in the list
-type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string
-
-// SelectableFunc defines whether an item is selectable
-type SelectableFunc[T any] func(item T) bool
-
-// Options holds configuration for the list component
-type Options[T any] struct {
- items []T
- maxVisibleHeight int
- fallbackMsg string
- useAlphaNumericKeys bool
- renderItem RenderFunc[T]
- isSelectable SelectableFunc[T]
- baseStyle styles.Style
-}
-
-// Option is a function that configures the list component
-type Option[T any] func(*Options[T])
-
-// WithItems sets the initial items for the list
-func WithItems[T any](items []T) Option[T] {
- return func(o *Options[T]) {
- o.items = items
- }
-}
-
-// WithMaxVisibleHeight sets the maximum visible height in lines
-func WithMaxVisibleHeight[T any](height int) Option[T] {
- return func(o *Options[T]) {
- o.maxVisibleHeight = height
- }
-}
-
-// WithFallbackMessage sets the message to show when the list is empty
-func WithFallbackMessage[T any](msg string) Option[T] {
- return func(o *Options[T]) {
- o.fallbackMsg = msg
- }
-}
-
-// WithAlphaNumericKeys enables j/k navigation keys
-func WithAlphaNumericKeys[T any](enabled bool) Option[T] {
- return func(o *Options[T]) {
- o.useAlphaNumericKeys = enabled
- }
-}
-
-// WithRenderFunc sets the function to render items
-func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] {
- return func(o *Options[T]) {
- o.renderItem = fn
- }
-}
-
-// WithSelectableFunc sets the function to determine if items are selectable
-func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] {
- return func(o *Options[T]) {
- o.isSelectable = fn
- }
-}
-
-// WithStyle sets the base style that gets passed to render functions
-func WithStyle[T any](style styles.Style) Option[T] {
- return func(o *Options[T]) {
- o.baseStyle = style
- }
-}
-
-type List[T any] interface {
- tea.Model
- tea.ViewModel
- SetMaxWidth(maxWidth int)
- GetSelectedItem() (item T, idx int)
- SetItems(items []T)
- GetItems() []T
- SetSelectedIndex(idx int)
- SetEmptyMessage(msg string)
- IsEmpty() bool
- GetMaxVisibleHeight() int
-}
-
-type listComponent[T any] struct {
- fallbackMsg string
- items []T
- selectedIdx int
- maxWidth int
- maxVisibleHeight int
- useAlphaNumericKeys bool
- width int
- height int
- renderItem RenderFunc[T]
- isSelectable SelectableFunc[T]
- baseStyle styles.Style
-}
-
-type listKeyMap struct {
- Up key.Binding
- Down key.Binding
- UpAlpha key.Binding
- DownAlpha key.Binding
-}
-
-var simpleListKeys = listKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("↑", "previous list item"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("↓", "next list item"),
- ),
- UpAlpha: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous list item"),
- ),
- DownAlpha: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next list item"),
- ),
-}
-
-func (c *listComponent[T]) Init() tea.Cmd {
- return nil
-}
-
-func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
- c.moveUp()
- return c, nil
- case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
- c.moveDown()
- return c, nil
- }
- }
-
- return c, nil
-}
-
-// moveUp moves the selection up, skipping non-selectable items
-func (c *listComponent[T]) moveUp() {
- if len(c.items) == 0 {
- return
- }
-
- // Find the previous selectable item
- for i := c.selectedIdx - 1; i >= 0; i-- {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-
- // If no selectable item found above, wrap to the bottom
- for i := len(c.items) - 1; i > c.selectedIdx; i-- {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-}
-
-// moveDown moves the selection down, skipping non-selectable items
-func (c *listComponent[T]) moveDown() {
- if len(c.items) == 0 {
- return
- }
-
- originalIdx := c.selectedIdx
- // First try moving down from current position
- for i := c.selectedIdx + 1; i < len(c.items); i++ {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-
- // If no selectable item found below, wrap to the top
- for i := 0; i < originalIdx; i++ {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-}
-
-func (c *listComponent[T]) GetSelectedItem() (T, int) {
- if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
- return c.items[c.selectedIdx], c.selectedIdx
- }
-
- var zero T
- return zero, -1
-}
-
-func (c *listComponent[T]) SetItems(items []T) {
- c.items = items
- c.selectedIdx = 0
-
- // Ensure initial selection is on a selectable item
- if len(items) > 0 && !c.isSelectable(items[0]) {
- c.moveDown()
- }
-}
-
-func (c *listComponent[T]) GetItems() []T {
- return c.items
-}
-
-func (c *listComponent[T]) SetEmptyMessage(msg string) {
- c.fallbackMsg = msg
-}
-
-func (c *listComponent[T]) IsEmpty() bool {
- return len(c.items) == 0
-}
-
-func (c *listComponent[T]) SetMaxWidth(width int) {
- c.maxWidth = width
-}
-
-func (c *listComponent[T]) SetSelectedIndex(idx int) {
- if idx >= 0 && idx < len(c.items) {
- c.selectedIdx = idx
- }
-}
-
-func (c *listComponent[T]) GetMaxVisibleHeight() int {
- return c.maxVisibleHeight
-}
-
-func (c *listComponent[T]) View() string {
- items := c.items
- maxWidth := c.maxWidth
- if maxWidth == 0 {
- maxWidth = 80 // Default width if not set
- }
-
- if len(items) <= 0 {
- return c.fallbackMsg
- }
-
- // Calculate viewport based on actual heights
- startIdx, endIdx := c.calculateViewport()
-
- listItems := make([]string, 0, endIdx-startIdx)
-
- for i := startIdx; i < endIdx; i++ {
- item := items[i]
-
- // Special handling for HeaderItem to remove top margin on first item
- if i == startIdx {
- // Check if this is a HeaderItem
- if _, ok := any(item).(Item); ok {
- if headerItem, isHeader := any(item).(HeaderItem); isHeader {
- // Render header without top margin when it's first
- t := theme.CurrentTheme()
- truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
- headerStyle := c.baseStyle.
- Foreground(t.Accent()).
- Bold(true).
- MarginBottom(0).
- PaddingLeft(1)
- listItems = append(listItems, headerStyle.Render(truncatedStr))
- continue
- }
- }
- }
-
- title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
- listItems = append(listItems, title)
- }
-
- return strings.Join(listItems, "\n")
-}
-
-// calculateViewport determines which items to show based on available space
-func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
- items := c.items
- if len(items) == 0 {
- return 0, 0
- }
-
- // Calculate heights of all items
- itemHeights := make([]int, len(items))
- for i, item := range items {
- rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
- itemHeights[i] = lipgloss.Height(rendered)
- }
-
- // Find the range of items that fit within maxVisibleHeight
- // Start by trying to center the selected item
- start := 0
- end := len(items)
-
- // Calculate height from start to selected
- heightToSelected := 0
- for i := 0; i <= c.selectedIdx && i < len(items); i++ {
- heightToSelected += itemHeights[i]
- }
-
- // If selected item is beyond visible height, scroll to show it
- if heightToSelected > c.maxVisibleHeight {
- // Start from selected and work backwards to find start
- currentHeight := itemHeights[c.selectedIdx]
- start = c.selectedIdx
-
- for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
- currentHeight += itemHeights[i]
- start = i
- }
- }
-
- // Calculate end based on start
- currentHeight := 0
- for i := start; i < len(items); i++ {
- if currentHeight+itemHeights[i] > c.maxVisibleHeight {
- end = i
- break
- }
- currentHeight += itemHeights[i]
- }
-
- return start, end
-}
-
-func abs(x int) int {
- if x < 0 {
- return -x
- }
- return x
-}
-
-func max(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
-
-func NewListComponent[T any](opts ...Option[T]) List[T] {
- options := &Options[T]{
- baseStyle: styles.NewStyle(), // Default empty style
- }
-
- for _, opt := range opts {
- opt(options)
- }
-
- return &listComponent[T]{
- fallbackMsg: options.fallbackMsg,
- items: options.items,
- maxVisibleHeight: options.maxVisibleHeight,
- useAlphaNumericKeys: options.useAlphaNumericKeys,
- selectedIdx: 0,
- renderItem: options.renderItem,
- isSelectable: options.isSelectable,
- baseStyle: options.baseStyle,
- }
-}
-
-// StringItem is a simple implementation of Item for string values
-type StringItem string
-
-func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
-
- truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
-
- var itemStyle styles.Style
- if selected {
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else {
- itemStyle = baseStyle.
- Foreground(t.TextMuted()).
- PaddingLeft(1)
- }
-
- return itemStyle.Render(truncatedStr)
-}
-
-func (s StringItem) Selectable() bool {
- return true
-}
-
-// HeaderItem is a non-selectable header item for grouping
-type HeaderItem string
-
-func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
-
- truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
-
- headerStyle := baseStyle.
- Foreground(t.Accent()).
- Bold(true).
- MarginTop(1).
- MarginBottom(0).
- PaddingLeft(1)
-
- return headerStyle.Render(truncatedStr)
-}
-
-func (h HeaderItem) Selectable() bool {
- return false
-}
-
-// Ensure StringItem and HeaderItem implement Item
-var _ Item = StringItem("")
-var _ Item = HeaderItem("")
diff --git a/packages/tui/internal/components/list/list_test.go b/packages/tui/internal/components/list/list_test.go
deleted file mode 100644
index 25cca8cf4..000000000
--- a/packages/tui/internal/components/list/list_test.go
+++ /dev/null
@@ -1,249 +0,0 @@
-package list
-
-import (
- "testing"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode/internal/styles"
-)
-
-// testItem is a simple test implementation of ListItem
-type testItem struct {
- value string
-}
-
-func (t testItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
-) string {
- return t.value
-}
-
-func (t testItem) Selectable() bool {
- return true
-}
-
-// createTestList creates a list with test items for testing
-func createTestList() *listComponent[testItem] {
- items := []testItem{
- {value: "item1"},
- {value: "item2"},
- {value: "item3"},
- }
- list := NewListComponent(
- WithItems(items),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](false),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- return list.(*listComponent[testItem])
-}
-
-func TestArrowKeyNavigation(t *testing.T) {
- list := createTestList()
-
- // Test down arrow navigation
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ := list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after down arrow, got %d", idx)
- }
-
- // Test up arrow navigation
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ = list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after up arrow, got %d", idx)
- }
-}
-
-func TestJKKeyNavigation(t *testing.T) {
- items := []testItem{
- {value: "item1"},
- {value: "item2"},
- {value: "item3"},
- }
- // Create list with alpha keys enabled
- list := NewListComponent(
- WithItems(items),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](true),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- // Test j key (down)
- jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
- updatedModel, _ := list.Update(jKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after 'j' key, got %d", idx)
- }
-
- // Test k key (up)
- kKey := tea.KeyPressMsg{Code: 'k', Text: "k"}
- updatedModel, _ = list.Update(kKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after 'k' key, got %d", idx)
- }
-}
-
-func TestCtrlNavigation(t *testing.T) {
- list := createTestList()
-
- // Test Ctrl-N (down)
- ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
- updatedModel, _ := list.Update(ctrlN)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx)
- }
-
- // Test Ctrl-P (up)
- ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
- updatedModel, _ = list.Update(ctrlP)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx)
- }
-}
-
-func TestNavigationBoundaries(t *testing.T) {
- list := createTestList()
-
- // Test up arrow at first item (should wrap to last item)
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ := list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to wrap to index 2 when pressing up at first item, got %d", idx)
- }
-
- // Move to first item
- list.SetSelectedIndex(0)
-
- // Move to last item
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to be at index 2, got %d", idx)
- }
-
- // Test down arrow at last item (should wrap to first item)
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to wrap to index 0 when pressing down at last item, got %d", idx)
- }
-}
-
-func TestEmptyList(t *testing.T) {
- emptyList := NewListComponent(
- WithItems([]testItem{}),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](false),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- // Test navigation on empty list (should not crash)
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
- ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
-
- updatedModel, _ := emptyList.Update(downKey)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(upKey)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(ctrlN)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(ctrlP)
- emptyList = updatedModel.(*listComponent[testItem])
-
- // Verify empty list behavior
- _, idx := emptyList.GetSelectedItem()
- if idx != -1 {
- t.Errorf("Expected index -1 for empty list, got %d", idx)
- }
-
- if !emptyList.IsEmpty() {
- t.Error("Expected IsEmpty() to return true for empty list")
- }
-}
-
-func TestWrapAroundNavigation(t *testing.T) {
- list := createTestList()
-
- // Start at first item (index 0)
- _, idx := list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to start at index 0, got %d", idx)
- }
-
- // Press up arrow - should wrap to last item (index 2)
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ := list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to wrap to index 2 when pressing up from first item, got %d", idx)
- }
-
- // Press down arrow - should wrap to first item (index 0)
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to wrap to index 0 when pressing down from last item, got %d", idx)
- }
-
- // Navigate to middle and verify normal navigation still works
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected to move to index 1, got %d", idx)
- }
-}
diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go
deleted file mode 100644
index 09989d8ec..000000000
--- a/packages/tui/internal/components/modal/modal.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package modal
-
-import (
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// CloseModalMsg is a message to signal that the active modal should be closed.
-type CloseModalMsg struct{}
-
-// Modal is a reusable modal component that handles frame rendering and overlay placement
-type Modal struct {
- width int
- height int
- title string
- maxWidth int
- maxHeight int
- fitContent bool
-}
-
-// ModalOption is a function that configures a Modal
-type ModalOption func(*Modal)
-
-// WithTitle sets the modal title
-func WithTitle(title string) ModalOption {
- return func(m *Modal) {
- m.title = title
- }
-}
-
-// WithMaxWidth sets the maximum width
-func WithMaxWidth(width int) ModalOption {
- return func(m *Modal) {
- m.maxWidth = width
- m.fitContent = false
- }
-}
-
-// WithMaxHeight sets the maximum height
-func WithMaxHeight(height int) ModalOption {
- return func(m *Modal) {
- m.maxHeight = height
- }
-}
-
-func WithFitContent(fit bool) ModalOption {
- return func(m *Modal) {
- m.fitContent = fit
- }
-}
-
-// New creates a new Modal with the given options
-func New(opts ...ModalOption) *Modal {
- m := &Modal{
- maxWidth: 0,
- maxHeight: 0,
- fitContent: true,
- }
-
- for _, opt := range opts {
- opt(m)
- }
-
- return m
-}
-
-func (m *Modal) SetTitle(title string) {
- m.title = title
-}
-
-// Render renders the modal centered on the screen
-func (m *Modal) Render(contentView string, background string) string {
- t := theme.CurrentTheme()
-
- outerWidth := layout.Current.Container.Width - 8
- if m.maxWidth > 0 && outerWidth > m.maxWidth {
- outerWidth = m.maxWidth
- }
-
- if m.fitContent {
- titleWidth := lipgloss.Width(m.title)
- contentWidth := lipgloss.Width(contentView)
- largestWidth := max(titleWidth+2, contentWidth)
- outerWidth = largestWidth + 6
- }
-
- innerWidth := outerWidth - 4
-
- baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
-
- var finalContent string
- if m.title != "" {
- titleStyle := baseStyle.
- Foreground(t.Text()).
- Bold(true).
- Padding(0, 1)
-
- escStyle := baseStyle.Foreground(t.TextMuted())
- escText := escStyle.Render("esc")
-
- // Calculate position for esc text
- titleWidth := lipgloss.Width(m.title)
- escWidth := lipgloss.Width(escText)
- spacesNeeded := max(0, innerWidth-titleWidth-escWidth-2)
- spacer := strings.Repeat(" ", spacesNeeded)
- titleLine := m.title + spacer + escText
- titleLine = titleStyle.Render(titleLine)
-
- finalContent = strings.Join([]string{titleLine, "", contentView}, "\n")
- } else {
- finalContent = contentView
- }
-
- modalStyle := baseStyle.
- PaddingTop(1).
- PaddingBottom(1).
- PaddingLeft(2).
- PaddingRight(2)
-
- modalView := modalStyle.
- Width(outerWidth).
- Render(finalContent)
-
- // Calculate position for centering
- bgHeight := lipgloss.Height(background)
- bgWidth := lipgloss.Width(background)
- modalHeight := lipgloss.Height(modalView)
- modalWidth := lipgloss.Width(modalView)
-
- row := (bgHeight - modalHeight) / 2
- col := (bgWidth - modalWidth) / 2
-
- return layout.PlaceOverlay(
- col-1, // TODO: whyyyyy
- row,
- modalView,
- background,
- layout.WithOverlayBorder(),
- layout.WithOverlayBorderColor(t.BorderActive()),
- )
-}
diff --git a/packages/tui/internal/components/qr/qr.go b/packages/tui/internal/components/qr/qr.go
deleted file mode 100644
index 233bcf524..000000000
--- a/packages/tui/internal/components/qr/qr.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package qr
-
-import (
- "strings"
-
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "rsc.io/qr"
-)
-
-var tops_bottoms = []rune{' ', '▀', '▄', '█'}
-
-// Generate a text string to a QR code, which you can write to a terminal or file.
-func Generate(text string) (string, int, error) {
- code, err := qr.Encode(text, qr.Level(0))
- if err != nil {
- return "", 0, err
- }
-
- t := theme.CurrentTheme()
- if t == nil {
- return "", 0, err
- }
-
- // Create lipgloss style for QR code with theme colors
- qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
-
- var result strings.Builder
-
- // content
- for y := 0; y < code.Size-1; y += 2 {
- var line strings.Builder
- for x := 0; x < code.Size; x += 1 {
- var num int8
- if code.Black(x, y) {
- num += 1
- }
- if code.Black(x, y+1) {
- num += 2
- }
- line.WriteRune(tops_bottoms[num])
- }
- result.WriteString(qrStyle.Render(line.String()) + "\n")
- }
-
- // add lower border when required (only required when QR size is odd)
- if code.Size%2 == 1 {
- var borderLine strings.Builder
- for range code.Size {
- borderLine.WriteRune('▀')
- }
- result.WriteString(qrStyle.Render(borderLine.String()) + "\n")
- }
-
- return result.String(), code.Size, nil
-}
diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go
deleted file mode 100644
index aba80900b..000000000
--- a/packages/tui/internal/components/status/status.go
+++ /dev/null
@@ -1,340 +0,0 @@
-package status
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/fsnotify/fsnotify"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "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 GitBranchUpdatedMsg struct {
- Branch string
-}
-
-type StatusComponent interface {
- tea.Model
- tea.ViewModel
- Cleanup()
-}
-
-type statusComponent struct {
- app *app.App
- width int
- cwd string
- branch string
- watcher *fsnotify.Watcher
- done chan struct{}
- lastUpdate time.Time
-}
-
-func (m *statusComponent) Init() tea.Cmd {
- return m.startGitWatcher()
-}
-
-func (m *statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width
- return m, nil
- case GitBranchUpdatedMsg:
- if m.branch != msg.Branch {
- m.branch = msg.Branch
- }
- // Continue watching for changes (persistent watcher)
- return m, m.watchForGitChanges()
- }
- return m, nil
-}
-
-func (m *statusComponent) logo() string {
- t := theme.CurrentTheme()
- base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
- emphasis := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundElement()).
- Bold(true).
- Render
-
- open := base("open")
- code := emphasis("code")
- version := base(" " + m.app.Version)
-
- content := open + code
- if m.width > 40 {
- content += version
- }
- return styles.NewStyle().
- Background(t.BackgroundElement()).
- Padding(0, 1).
- Render(content)
-}
-
-func (m *statusComponent) collapsePath(path string, maxWidth int) string {
- if lipgloss.Width(path) <= maxWidth {
- return path
- }
-
- const ellipsis = ".."
- ellipsisLen := len(ellipsis)
-
- if maxWidth <= ellipsisLen {
- if maxWidth > 0 {
- return "..."[:maxWidth]
- }
- return ""
- }
-
- separator := string(filepath.Separator)
- parts := strings.Split(path, separator)
-
- if len(parts) == 1 {
- return path[:maxWidth-ellipsisLen] + ellipsis
- }
-
- truncatedPath := parts[len(parts)-1]
- for i := len(parts) - 2; i >= 0; i-- {
- part := parts[i]
- if len(truncatedPath)+len(separator)+len(part)+ellipsisLen > maxWidth {
- return ellipsis + separator + truncatedPath
- }
- truncatedPath = part + separator + truncatedPath
- }
- return truncatedPath
-}
-
-func (m *statusComponent) View() string {
- t := theme.CurrentTheme()
- logo := m.logo()
- logoWidth := lipgloss.Width(logo)
-
- var modeBackground compat.AdaptiveColor
- var modeForeground compat.AdaptiveColor
-
- agentColor := util.GetAgentColor(m.app.AgentIndex)
-
- if m.app.AgentIndex == 0 {
- modeBackground = t.BackgroundElement()
- modeForeground = agentColor
- } else {
- modeBackground = agentColor
- modeForeground = t.BackgroundPanel()
- }
-
- command := m.app.Commands[commands.AgentCycleCommand]
- kb := command.Keybindings[0]
- key := kb.Key
- if kb.RequiresLeader {
- key = m.app.Config.Keybinds.Leader + " " + kb.Key
- }
-
- agentStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
- agentNameStyle := agentStyle.Bold(true).Render
- agentDescStyle := agentStyle.Render
- agent := agentNameStyle(strings.ToUpper(m.app.Agent().Name)) + agentDescStyle(" AGENT")
- agent = agentStyle.
- Padding(0, 1).
- BorderLeft(true).
- BorderStyle(lipgloss.ThickBorder()).
- BorderForeground(modeBackground).
- BorderBackground(t.BackgroundPanel()).
- Render(agent)
-
- faintStyle := styles.NewStyle().
- Faint(true).
- Background(t.BackgroundPanel()).
- Foreground(t.TextMuted())
- agent = faintStyle.Render(key+" ") + agent
- modeWidth := lipgloss.Width(agent)
-
- availableWidth := m.width - logoWidth - modeWidth
- branchSuffix := ""
- if m.branch != "" {
- branchSuffix = ":" + m.branch
- }
-
- maxCwdWidth := availableWidth - lipgloss.Width(branchSuffix)
- cwdDisplay := m.collapsePath(m.cwd, maxCwdWidth)
-
- if m.branch != "" && availableWidth > lipgloss.Width(cwdDisplay)+lipgloss.Width(branchSuffix) {
- cwdDisplay += faintStyle.Render(branchSuffix)
- }
-
- cwd := styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel()).
- Padding(0, 1).
- Render(cwdDisplay)
-
- background := t.BackgroundPanel()
- status := layout.Render(
- layout.FlexOptions{
- Background: &background,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: m.width,
- },
- layout.FlexItem{
- View: logo + cwd,
- },
- layout.FlexItem{
- View: agent,
- },
- )
-
- blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
- return blank + "\n" + status
-}
-
-func (m *statusComponent) startGitWatcher() tea.Cmd {
- cmd := util.CmdHandler(
- GitBranchUpdatedMsg{Branch: getCurrentGitBranch(util.CwdPath)},
- )
- if err := m.initWatcher(); err != nil {
- return cmd
- }
- return tea.Batch(cmd, m.watchForGitChanges())
-}
-
-func (m *statusComponent) initWatcher() error {
- gitDir := filepath.Join(util.CwdPath, ".git")
- headFile := filepath.Join(gitDir, "HEAD")
- if info, err := os.Stat(gitDir); err != nil || !info.IsDir() {
- return err
- }
-
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- return err
- }
-
- if err := watcher.Add(headFile); err != nil {
- watcher.Close()
- return err
- }
-
- // Also watch the ref file if HEAD points to a ref
- refFile := getGitRefFile(util.CwdPath)
- if refFile != headFile && refFile != "" {
- if _, err := os.Stat(refFile); err == nil {
- watcher.Add(refFile) // Ignore error, HEAD watching is sufficient
- }
- }
-
- m.watcher = watcher
- m.done = make(chan struct{})
- return nil
-}
-
-func (m *statusComponent) watchForGitChanges() tea.Cmd {
- if m.watcher == nil {
- return nil
- }
-
- return tea.Cmd(func() tea.Msg {
- for {
- select {
- case event, ok := <-m.watcher.Events:
- branch := getCurrentGitBranch(util.CwdPath)
- if !ok {
- return GitBranchUpdatedMsg{Branch: branch}
- }
- if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
- // Debounce updates to prevent excessive refreshes
- now := time.Now()
- if now.Sub(m.lastUpdate) < 100*time.Millisecond {
- continue
- }
- m.lastUpdate = now
- if strings.HasSuffix(event.Name, "HEAD") {
- m.updateWatchedFiles()
- }
- return GitBranchUpdatedMsg{Branch: branch}
- }
- case <-m.watcher.Errors:
- // Continue watching even on errors
- case <-m.done:
- return GitBranchUpdatedMsg{Branch: ""}
- }
- }
- })
-}
-
-func (m *statusComponent) updateWatchedFiles() {
- if m.watcher == nil {
- return
- }
- refFile := getGitRefFile(util.CwdPath)
- headFile := filepath.Join(util.CwdPath, ".git", "HEAD")
- if refFile != headFile && refFile != "" {
- if _, err := os.Stat(refFile); err == nil {
- // Try to add the new ref file (ignore error if already watching)
- m.watcher.Add(refFile)
- }
- }
-}
-
-func getCurrentGitBranch(cwd string) string {
- cmd := exec.Command("git", "branch", "--show-current")
- cmd.Dir = cwd
- output, err := cmd.Output()
- if err != nil {
- return ""
- }
- return strings.TrimSpace(string(output))
-}
-
-func getGitRefFile(cwd string) string {
- headFile := filepath.Join(cwd, ".git", "HEAD")
- content, err := os.ReadFile(headFile)
- if err != nil {
- return ""
- }
-
- headContent := strings.TrimSpace(string(content))
- if after, ok := strings.CutPrefix(headContent, "ref: "); ok {
- // HEAD points to a ref file
- refPath := after
- return filepath.Join(cwd, ".git", refPath)
- }
-
- // HEAD contains a direct commit hash
- return headFile
-}
-
-func (m *statusComponent) Cleanup() {
- if m.done != nil {
- close(m.done)
- }
- if m.watcher != nil {
- m.watcher.Close()
- }
-}
-
-func NewStatusCmp(app *app.App) StatusComponent {
- statusComponent := &statusComponent{
- app: app,
- lastUpdate: time.Now(),
- }
-
- homePath, err := os.UserHomeDir()
- cwdPath := util.CwdPath
- if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
- cwdPath = "~" + cwdPath[len(homePath):]
- }
- statusComponent.cwd = cwdPath
-
- return statusComponent
-}
diff --git a/packages/tui/internal/components/status/status_test.go b/packages/tui/internal/components/status/status_test.go
deleted file mode 100644
index 1e1caf8ac..000000000
--- a/packages/tui/internal/components/status/status_test.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package status
-
-import (
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestGetCurrentGitBranch(t *testing.T) {
- // Test in current directory (should be a git repo)
- branch := getCurrentGitBranch(".")
- if branch == "" {
- t.Skip("Not in a git repository, skipping test")
- }
- t.Logf("Current branch: %s", branch)
-}
-
-func TestGetGitRefFile(t *testing.T) {
- // Create a temporary git directory structure for testing
- tmpDir := t.TempDir()
- gitDir := filepath.Join(tmpDir, ".git")
- err := os.MkdirAll(gitDir, 0755)
- if err != nil {
- t.Fatal(err)
- }
-
- // Test case 1: HEAD points to a ref
- headFile := filepath.Join(gitDir, "HEAD")
- err = os.WriteFile(headFile, []byte("ref: refs/heads/main\n"), 0644)
- if err != nil {
- t.Fatal(err)
- }
-
- refFile := getGitRefFile(tmpDir)
- expected := filepath.Join(gitDir, "refs", "heads", "main")
- if refFile != expected {
- t.Errorf("Expected %s, got %s", expected, refFile)
- }
-
- // Test case 2: HEAD contains a direct commit hash
- err = os.WriteFile(headFile, []byte("abc123def456\n"), 0644)
- if err != nil {
- t.Fatal(err)
- }
-
- refFile = getGitRefFile(tmpDir)
- if refFile != headFile {
- t.Errorf("Expected %s, got %s", headFile, refFile)
- }
-}
-
-func TestFileWatcherIntegration(t *testing.T) {
- // This test requires being in a git repository
- if getCurrentGitBranch(".") == "" {
- t.Skip("Not in a git repository, skipping integration test")
- }
-
- // Test that the file watcher setup doesn't crash
- tmpDir := t.TempDir()
- gitDir := filepath.Join(tmpDir, ".git")
- err := os.MkdirAll(gitDir, 0755)
- if err != nil {
- t.Fatal(err)
- }
-
- headFile := filepath.Join(gitDir, "HEAD")
- err = os.WriteFile(headFile, []byte("ref: refs/heads/main\n"), 0644)
- if err != nil {
- t.Fatal(err)
- }
-
- // Create the refs directory and file
- refsDir := filepath.Join(gitDir, "refs", "heads")
- err = os.MkdirAll(refsDir, 0755)
- if err != nil {
- t.Fatal(err)
- }
-
- mainRef := filepath.Join(refsDir, "main")
- err = os.WriteFile(mainRef, []byte("abc123def456\n"), 0644)
- if err != nil {
- t.Fatal(err)
- }
-
- // Test that we can create a watcher without crashing
- // This is a basic smoke test
- done := make(chan bool, 1)
- go func() {
- time.Sleep(100 * time.Millisecond)
- done <- true
- }()
-
- select {
- case <-done:
- // Test passed - no crash
- case <-time.After(1 * time.Second):
- t.Error("Test timed out")
- }
-}
diff --git a/packages/tui/internal/components/textarea/memoization.go b/packages/tui/internal/components/textarea/memoization.go
deleted file mode 100644
index 2c9aec4f7..000000000
--- a/packages/tui/internal/components/textarea/memoization.go
+++ /dev/null
@@ -1,125 +0,0 @@
-// Package memoization implement a simple memoization cache. It's designed to
-// improve performance in textarea.
-package textarea
-
-import (
- "container/list"
- "crypto/sha256"
- "fmt"
- "sync"
-)
-
-// Hasher is an interface that requires a Hash method. The Hash method is
-// expected to return a string representation of the hash of the object.
-type Hasher interface {
- Hash() string
-}
-
-// entry is a struct that holds a key-value pair. It is used as an element
-// in the evictionList of the MemoCache.
-type entry[T any] struct {
- key string
- value T
-}
-
-// MemoCache is a struct that represents a cache with a set capacity. It
-// uses an LRU (Least Recently Used) eviction policy. It is safe for
-// concurrent use.
-type MemoCache[H Hasher, T any] struct {
- capacity int
- mutex sync.Mutex
- cache map[string]*list.Element // The cache holding the results
- evictionList *list.List // A list to keep track of the order for LRU
- hashableItems map[string]T // This map keeps track of the original hashable items (optional)
-}
-
-// NewMemoCache is a function that creates a new MemoCache with a given
-// capacity. It returns a pointer to the created MemoCache.
-func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
- return &MemoCache[H, T]{
- capacity: capacity,
- cache: make(map[string]*list.Element),
- evictionList: list.New(),
- hashableItems: make(map[string]T),
- }
-}
-
-// Capacity is a method that returns the capacity of the MemoCache.
-func (m *MemoCache[H, T]) Capacity() int {
- return m.capacity
-}
-
-// Size is a method that returns the current size of the MemoCache. It is
-// the number of items currently stored in the cache.
-func (m *MemoCache[H, T]) Size() int {
- m.mutex.Lock()
- defer m.mutex.Unlock()
- return m.evictionList.Len()
-}
-
-// Get is a method that returns the value associated with the given
-// hashable item in the MemoCache. If there is no corresponding value, the
-// method returns nil.
-func (m *MemoCache[H, T]) Get(h H) (T, bool) {
- m.mutex.Lock()
- defer m.mutex.Unlock()
-
- hashedKey := h.Hash()
- if element, found := m.cache[hashedKey]; found {
- m.evictionList.MoveToFront(element)
- return element.Value.(*entry[T]).value, true
- }
- var result T
- return result, false
-}
-
-// Set is a method that sets the value for the given hashable item in the
-// MemoCache. If the cache is at capacity, it evicts the least recently
-// used item before adding the new item.
-func (m *MemoCache[H, T]) Set(h H, value T) {
- m.mutex.Lock()
- defer m.mutex.Unlock()
-
- hashedKey := h.Hash()
- if element, found := m.cache[hashedKey]; found {
- m.evictionList.MoveToFront(element)
- element.Value.(*entry[T]).value = value
- return
- }
-
- // Check if the cache is at capacity
- if m.evictionList.Len() >= m.capacity {
- // Evict the least recently used item from the cache
- toEvict := m.evictionList.Back()
- if toEvict != nil {
- evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
- delete(m.cache, evictedEntry.key)
- delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
- }
- }
-
- // Add the value to the cache and the evictionList
- newEntry := &entry[T]{
- key: hashedKey,
- value: value,
- }
- element := m.evictionList.PushFront(newEntry)
- m.cache[hashedKey] = element
- m.hashableItems[hashedKey] = value // if you're keeping track of original items
-}
-
-// HString is a type that implements the Hasher interface for strings.
-type HString string
-
-// Hash is a method that returns the hash of the string.
-func (h HString) Hash() string {
- return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
-}
-
-// HInt is a type that implements the Hasher interface for integers.
-type HInt int
-
-// Hash is a method that returns the hash of the integer.
-func (h HInt) Hash() string {
- return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
-}
diff --git a/packages/tui/internal/components/textarea/runeutil.go b/packages/tui/internal/components/textarea/runeutil.go
deleted file mode 100644
index c4fc87f80..000000000
--- a/packages/tui/internal/components/textarea/runeutil.go
+++ /dev/null
@@ -1,102 +0,0 @@
-// Package runeutil provides utility functions for tidying up incoming runes
-// from Key messages.
-package textarea
-
-import (
- "unicode"
- "unicode/utf8"
-)
-
-// Sanitizer is a helper for bubble widgets that want to process
-// Runes from input key messages.
-type Sanitizer interface {
- // Sanitize removes control characters from runes in a KeyRunes
- // message, and optionally replaces newline/carriage return/tabs by a
- // specified character.
- //
- // The rune array is modified in-place if possible. In that case, the
- // returned slice is the original slice shortened after the control
- // characters have been removed/translated.
- Sanitize(runes []rune) []rune
-}
-
-// NewSanitizer constructs a rune sanitizer.
-func NewSanitizer(opts ...Option) Sanitizer {
- s := sanitizer{
- replaceNewLine: []rune("\n"),
- replaceTab: []rune(" "),
- }
- for _, o := range opts {
- s = o(s)
- }
- return &s
-}
-
-// Option is the type of option that can be passed to Sanitize().
-type Option func(sanitizer) sanitizer
-
-// ReplaceTabs replaces tabs by the specified string.
-func ReplaceTabs(tabRepl string) Option {
- return func(s sanitizer) sanitizer {
- s.replaceTab = []rune(tabRepl)
- return s
- }
-}
-
-// ReplaceNewlines replaces newline characters by the specified string.
-func ReplaceNewlines(nlRepl string) Option {
- return func(s sanitizer) sanitizer {
- s.replaceNewLine = []rune(nlRepl)
- return s
- }
-}
-
-func (s *sanitizer) Sanitize(runes []rune) []rune {
- // dstrunes are where we are storing the result.
- dstrunes := runes[:0:len(runes)]
- // copied indicates whether dstrunes is an alias of runes
- // or a copy. We need a copy when dst moves past src.
- // We use this as an optimization to avoid allocating
- // a new rune slice in the common case where the output
- // is smaller or equal to the input.
- copied := false
-
- for src := 0; src < len(runes); src++ {
- r := runes[src]
- switch {
- case r == utf8.RuneError:
- // skip
-
- case r == '\r' || r == '\n':
- if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
- dst := len(dstrunes)
- dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
- copy(dstrunes, runes[:dst])
- copied = true
- }
- dstrunes = append(dstrunes, s.replaceNewLine...)
-
- case r == '\t':
- if len(dstrunes)+len(s.replaceTab) > src && !copied {
- dst := len(dstrunes)
- dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
- copy(dstrunes, runes[:dst])
- copied = true
- }
- dstrunes = append(dstrunes, s.replaceTab...)
-
- case unicode.IsControl(r):
- // Other control characters: skip.
-
- default:
- // Keep the character.
- dstrunes = append(dstrunes, runes[src])
- }
- }
- return dstrunes
-}
-
-type sanitizer struct {
- replaceNewLine []rune
- replaceTab []rune
-}
diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go
deleted file mode 100644
index 6e6695917..000000000
--- a/packages/tui/internal/components/textarea/textarea.go
+++ /dev/null
@@ -1,2377 +0,0 @@
-package textarea
-
-import (
- "crypto/sha256"
- "fmt"
- "image/color"
- "strconv"
- "strings"
- "time"
- "unicode"
-
- "slices"
-
- "github.com/charmbracelet/bubbles/v2/cursor"
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- rw "github.com/mattn/go-runewidth"
- "github.com/rivo/uniseg"
- "github.com/sst/opencode/internal/attachment"
-)
-
-const (
- minHeight = 1
- defaultHeight = 1
- defaultWidth = 40
- defaultCharLimit = 0 // no limit
- defaultMaxHeight = 99
- defaultMaxWidth = 500
-
- // XXX: in v2, make max lines dynamic and default max lines configurable.
- maxLines = 10000
-)
-
-// Helper functions for converting between runes and any slices
-
-// runesToInterfaces converts a slice of runes to a slice of interfaces
-func runesToInterfaces(runes []rune) []any {
- result := make([]any, len(runes))
- for i, r := range runes {
- result[i] = r
- }
- return result
-}
-
-// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes)
-func interfacesToRunes(items []any) []rune {
- var result []rune
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- result = append(result, val)
- case *attachment.Attachment:
- result = append(result, []rune(val.Display)...)
- }
- }
- return result
-}
-
-// copyInterfaceSlice creates a copy of an any slice
-func copyInterfaceSlice(src []any) []any {
- dst := make([]any, len(src))
- copy(dst, src)
- return dst
-}
-
-// interfacesToString converts a slice of interfaces to a string for display
-func interfacesToString(items []any) string {
- var s strings.Builder
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- s.WriteRune(val)
- case *attachment.Attachment:
- s.WriteString(val.Display)
- }
- }
- return s.String()
-}
-
-// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment.
-// This allows for proper highlighting even when the cursor is technically at the position
-// after the attachment object in the underlying slice.
-func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) {
- if m.row >= len(m.value) {
- return nil, -1, -1
- }
-
- row := m.value[m.row]
- col := m.col
-
- if col < 0 || col > len(row) {
- return nil, -1, -1
- }
-
- // Check if the cursor is at the same index as an attachment.
- if col < len(row) {
- if att, ok := row[col].(*attachment.Attachment); ok {
- return att, col, col
- }
- }
-
- // Check if the cursor is immediately after an attachment. This is a common
- // state, for example, after just inserting one.
- if col > 0 && col <= len(row) {
- if att, ok := row[col-1].(*attachment.Attachment); ok {
- return att, col - 1, col - 1
- }
- }
-
- return nil, -1, -1
-}
-
-// renderLineWithAttachments renders a line with proper attachment highlighting
-func (m Model) renderLineWithAttachments(
- items []any,
- style lipgloss.Style,
-) string {
- var s strings.Builder
- currentAttachment, _, _ := m.isAttachmentAtCursor()
-
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- s.WriteString(style.Render(string(val)))
- case *attachment.Attachment:
- // Check if this is the attachment the cursor is currently on
- if currentAttachment != nil && currentAttachment.ID == val.ID {
- // Cursor is on this attachment, highlight it
- s.WriteString(m.Styles.SelectedAttachment.Render(val.Display))
- } else {
- s.WriteString(m.Styles.Attachment.Render(val.Display))
- }
- }
- }
- return s.String()
-}
-
-// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune
-func getRuneAt(items []any, index int) rune {
- if index < 0 || index >= len(items) {
- return 0
- }
- if r, ok := items[index].(rune); ok {
- return r
- }
- return 0
-}
-
-// isSpaceAt checks if the item at index is a space rune
-func isSpaceAt(items []any, index int) bool {
- r := getRuneAt(items, index)
- return r != 0 && unicode.IsSpace(r)
-}
-
-// setRuneAt safely sets a rune at a specific position if it's a rune
-func setRuneAt(items []any, index int, r rune) {
- if index >= 0 && index < len(items) {
- if _, ok := items[index].(rune); ok {
- items[index] = r
- }
- }
-}
-
-// Internal messages for clipboard operations.
-type (
- pasteMsg string
- pasteErrMsg struct{ error }
-)
-
-// KeyMap is the key bindings for different actions within the textarea.
-type KeyMap struct {
- CharacterBackward key.Binding
- CharacterForward key.Binding
- DeleteAfterCursor key.Binding
- DeleteBeforeCursor key.Binding
- DeleteCharacterBackward key.Binding
- DeleteCharacterForward key.Binding
- DeleteWordBackward key.Binding
- DeleteWordForward key.Binding
- InsertNewline key.Binding
- LineEnd key.Binding
- LineNext key.Binding
- LinePrevious key.Binding
- LineStart key.Binding
- Paste key.Binding
- WordBackward key.Binding
- WordForward key.Binding
- InputBegin key.Binding
- InputEnd key.Binding
-
- UppercaseWordForward key.Binding
- LowercaseWordForward key.Binding
- CapitalizeWordForward key.Binding
-
- TransposeCharacterBackward key.Binding
-}
-
-// DefaultKeyMap returns the default set of key bindings for navigating and acting
-// upon the textarea.
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- CharacterForward: key.NewBinding(
- key.WithKeys("right", "ctrl+f"),
- key.WithHelp("right", "character forward"),
- ),
- CharacterBackward: key.NewBinding(
- key.WithKeys("left", "ctrl+b"),
- key.WithHelp("left", "character backward"),
- ),
- WordForward: key.NewBinding(
- key.WithKeys("alt+right", "ctrl+right", "alt+f"),
- key.WithHelp("alt+right", "word forward"),
- ),
- WordBackward: key.NewBinding(
- key.WithKeys("alt+left", "ctrl+left", "alt+b"),
- key.WithHelp("alt+left", "word backward"),
- ),
- LineNext: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("down", "next line"),
- ),
- LinePrevious: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("up", "previous line"),
- ),
- DeleteWordBackward: key.NewBinding(
- key.WithKeys("alt+backspace", "ctrl+w"),
- key.WithHelp("alt+backspace", "delete word backward"),
- ),
- DeleteWordForward: key.NewBinding(
- key.WithKeys("alt+delete", "alt+d"),
- key.WithHelp("alt+delete", "delete word forward"),
- ),
- DeleteAfterCursor: key.NewBinding(
- key.WithKeys("ctrl+k"),
- key.WithHelp("ctrl+k", "delete after cursor"),
- ),
- DeleteBeforeCursor: key.NewBinding(
- key.WithKeys("ctrl+u"),
- key.WithHelp("ctrl+u", "delete before cursor"),
- ),
- InsertNewline: key.NewBinding(
- key.WithKeys("enter", "ctrl+m"),
- key.WithHelp("enter", "insert newline"),
- ),
- DeleteCharacterBackward: key.NewBinding(
- key.WithKeys("backspace", "ctrl+h"),
- key.WithHelp("backspace", "delete character backward"),
- ),
- DeleteCharacterForward: key.NewBinding(
- key.WithKeys("delete", "ctrl+d"),
- key.WithHelp("delete", "delete character forward"),
- ),
- LineStart: key.NewBinding(
- key.WithKeys("home", "ctrl+a"),
- key.WithHelp("home", "line start"),
- ),
- LineEnd: key.NewBinding(
- key.WithKeys("end", "ctrl+e"),
- key.WithHelp("end", "line end"),
- ),
- Paste: key.NewBinding(
- key.WithKeys("ctrl+v"),
- key.WithHelp("ctrl+v", "paste"),
- ),
- InputBegin: key.NewBinding(
- key.WithKeys("alt+<", "ctrl+home"),
- key.WithHelp("alt+<", "input begin"),
- ),
- InputEnd: key.NewBinding(
- key.WithKeys("alt+>", "ctrl+end"),
- key.WithHelp("alt+>", "input end"),
- ),
-
- CapitalizeWordForward: key.NewBinding(
- key.WithKeys("alt+c"),
- key.WithHelp("alt+c", "capitalize word forward"),
- ),
- LowercaseWordForward: key.NewBinding(
- key.WithKeys("alt+l"),
- key.WithHelp("alt+l", "lowercase word forward"),
- ),
- UppercaseWordForward: key.NewBinding(
- key.WithKeys("alt+u"),
- key.WithHelp("alt+u", "uppercase word forward"),
- ),
-
- TransposeCharacterBackward: key.NewBinding(
- key.WithKeys("ctrl+t"),
- key.WithHelp("ctrl+t", "transpose character backward"),
- ),
- }
-}
-
-// LineInfo is a helper for keeping track of line information regarding
-// soft-wrapped lines.
-type LineInfo struct {
- // Width is the number of columns in the line.
- Width int
-
- // CharWidth is the number of characters in the line to account for
- // double-width runes.
- CharWidth int
-
- // Height is the number of rows in the line.
- Height int
-
- // StartColumn is the index of the first column of the line.
- StartColumn int
-
- // ColumnOffset is the number of columns that the cursor is offset from the
- // start of the line.
- ColumnOffset int
-
- // RowOffset is the number of rows that the cursor is offset from the start
- // of the line.
- RowOffset int
-
- // CharOffset is the number of characters that the cursor is offset
- // from the start of the line. This will generally be equivalent to
- // ColumnOffset, but will be different there are double-width runes before
- // the cursor.
- CharOffset int
-}
-
-// CursorStyle is the style for real and virtual cursors.
-type CursorStyle struct {
- // Style styles the cursor block.
- //
- // For real cursors, the foreground color set here will be used as the
- // cursor color.
- Color color.Color
-
- // Shape is the cursor shape. The following shapes are available:
- //
- // - tea.CursorBlock
- // - tea.CursorUnderline
- // - tea.CursorBar
- //
- // This is only used for real cursors.
- Shape tea.CursorShape
-
- // CursorBlink determines whether or not the cursor should blink.
- Blink bool
-
- // BlinkSpeed is the speed at which the virtual cursor blinks. This has no
- // effect on real cursors as well as no effect if the cursor is set not to
- // [CursorBlink].
- //
- // By default, the blink speed is set to about 500ms.
- BlinkSpeed time.Duration
-}
-
-// Styles are the styles for the textarea, separated into focused and blurred
-// states. The appropriate styles will be chosen based on the focus state of
-// the textarea.
-type Styles struct {
- Focused StyleState
- Blurred StyleState
- Cursor CursorStyle
- Attachment lipgloss.Style
- SelectedAttachment lipgloss.Style
-}
-
-// StyleState that will be applied to the text area.
-//
-// StyleState can be applied to focused and unfocused states to change the styles
-// depending on the focus state.
-//
-// For an introduction to styling with Lip Gloss see:
-// https://github.com/charmbracelet/lipgloss
-type StyleState struct {
- Base lipgloss.Style
- Text lipgloss.Style
- LineNumber lipgloss.Style
- CursorLineNumber lipgloss.Style
- CursorLine lipgloss.Style
- EndOfBuffer lipgloss.Style
- Placeholder lipgloss.Style
- Prompt lipgloss.Style
-}
-
-func (s StyleState) computedCursorLine() lipgloss.Style {
- return s.CursorLine.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedCursorLineNumber() lipgloss.Style {
- return s.CursorLineNumber.
- Inherit(s.CursorLine).
- Inherit(s.Base).
- Inline(true)
-}
-
-func (s StyleState) computedEndOfBuffer() lipgloss.Style {
- return s.EndOfBuffer.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedLineNumber() lipgloss.Style {
- return s.LineNumber.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedPlaceholder() lipgloss.Style {
- return s.Placeholder.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedPrompt() lipgloss.Style {
- return s.Prompt.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedText() lipgloss.Style {
- return s.Text.Inherit(s.Base).Inline(true)
-}
-
-// line is the input to the text wrapping function. This is stored in a struct
-// so that it can be hashed and memoized.
-type line struct {
- content []any // Contains runes and *Attachment
- width int
-}
-
-// Hash returns a hash of the line.
-func (w line) Hash() string {
- var s strings.Builder
- for _, item := range w.content {
- switch v := item.(type) {
- case rune:
- s.WriteRune(v)
- case *attachment.Attachment:
- s.WriteString(v.ID)
- }
- }
- v := fmt.Sprintf("%s:%d", s.String(), w.width)
- return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))
-}
-
-// Model is the Bubble Tea model for this text area element.
-type Model struct {
- Err error
-
- // General settings.
- cache *MemoCache[line, [][]any]
-
- // Prompt is printed at the beginning of each line.
- //
- // When changing the value of Prompt after the model has been
- // initialized, ensure that SetWidth() gets called afterwards.
- //
- // See also [SetPromptFunc] for a dynamic prompt.
- Prompt string
-
- // Placeholder is the text displayed when the user
- // hasn't entered anything yet.
- Placeholder string
-
- // ShowLineNumbers, if enabled, causes line numbers to be printed
- // after the prompt.
- ShowLineNumbers bool
-
- // EndOfBufferCharacter is displayed at the end of the input.
- EndOfBufferCharacter rune
-
- // KeyMap encodes the keybindings recognized by the widget.
- KeyMap KeyMap
-
- // Styling. FocusedStyle and BlurredStyle are used to style the textarea in
- // focused and blurred states.
- Styles Styles
-
- // virtualCursor manages the virtual cursor.
- virtualCursor cursor.Model
-
- // VirtualCursor determines whether or not to use the virtual cursor. If
- // set to false, use [Model.Cursor] to return a real cursor for rendering.
- VirtualCursor bool
-
- // CharLimit is the maximum number of characters this input element will
- // accept. If 0 or less, there's no limit.
- CharLimit int
-
- // MaxHeight is the maximum height of the text area in rows. If 0 or less,
- // there's no limit.
- MaxHeight int
-
- // MaxWidth is the maximum width of the text area in columns. If 0 or less,
- // there's no limit.
- MaxWidth int
-
- // If promptFunc is set, it replaces Prompt as a generator for
- // prompt strings at the beginning of each line.
- promptFunc func(line int) string
-
- // promptWidth is the width of the prompt.
- promptWidth int
-
- // width is the maximum number of characters that can be displayed at once.
- // If 0 or less this setting is ignored.
- width int
-
- // height is the maximum number of lines that can be displayed at once. It
- // essentially treats the text field like a vertically scrolling viewport
- // if there are more lines than the permitted height.
- height int
-
- // Underlying text value. Contains either rune or *Attachment types.
- value [][]any
-
- // focus indicates whether user input focus should be on this input
- // component. When false, ignore keyboard input and hide the cursor.
- focus bool
-
- // Cursor column (slice index).
- col int
-
- // Cursor row.
- row int
-
- // Last character offset, used to maintain state when the cursor is moved
- // vertically such that we can maintain the same navigating position.
- lastCharOffset int
-
- // rune sanitizer for input.
- rsan Sanitizer
-}
-
-// New creates a new model with default settings.
-func New() Model {
- cur := cursor.New()
-
- styles := DefaultDarkStyles()
-
- m := Model{
- CharLimit: defaultCharLimit,
- MaxHeight: defaultMaxHeight,
- MaxWidth: defaultMaxWidth,
- Prompt: lipgloss.ThickBorder().Left + " ",
- Styles: styles,
- cache: NewMemoCache[line, [][]any](maxLines),
- EndOfBufferCharacter: ' ',
- ShowLineNumbers: true,
- VirtualCursor: true,
- virtualCursor: cur,
- KeyMap: DefaultKeyMap(),
-
- value: make([][]any, minHeight, maxLines),
- focus: false,
- col: 0,
- row: 0,
- }
-
- m.SetWidth(defaultWidth)
- m.SetHeight(defaultHeight)
-
- return m
-}
-
-// DefaultStyles returns the default styles for focused and blurred states for
-// the textarea.
-func DefaultStyles(isDark bool) Styles {
- lightDark := lipgloss.LightDark(isDark)
-
- var s Styles
- s.Focused = StyleState{
- Base: lipgloss.NewStyle(),
- CursorLine: lipgloss.NewStyle().
- Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))),
- CursorLineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))),
- EndOfBuffer: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
- LineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
- Text: lipgloss.NewStyle(),
- }
- s.Blurred = StyleState{
- Base: lipgloss.NewStyle(),
- CursorLine: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
- CursorLineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- EndOfBuffer: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
- LineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
- Text: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
- }
- s.Attachment = lipgloss.NewStyle().
- Background(lipgloss.Color("11")).
- Foreground(lipgloss.Color("0"))
- s.SelectedAttachment = lipgloss.NewStyle().
- Background(lipgloss.Color("11")).
- Foreground(lipgloss.Color("0"))
- s.Cursor = CursorStyle{
- Color: lipgloss.Color("7"),
- Shape: tea.CursorBlock,
- Blink: true,
- }
- return s
-}
-
-// DefaultLightStyles returns the default styles for a light background.
-func DefaultLightStyles() Styles {
- return DefaultStyles(false)
-}
-
-// DefaultDarkStyles returns the default styles for a dark background.
-func DefaultDarkStyles() Styles {
- return DefaultStyles(true)
-}
-
-// updateVirtualCursorStyle sets styling on the virtual cursor based on the
-// textarea's style settings.
-func (m *Model) updateVirtualCursorStyle() {
- if !m.VirtualCursor {
- m.virtualCursor.SetMode(cursor.CursorHide)
- return
- }
-
- m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.Styles.Cursor.Color)
-
- // By default, the blink speed of the cursor is set to a default
- // internally.
- if m.Styles.Cursor.Blink {
- if m.Styles.Cursor.BlinkSpeed > 0 {
- m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed
- }
- m.virtualCursor.SetMode(cursor.CursorBlink)
- return
- }
- m.virtualCursor.SetMode(cursor.CursorStatic)
-}
-
-// SetValue sets the value of the text input.
-func (m *Model) SetValue(s string) {
- m.Reset()
- m.InsertString(s)
-}
-
-// InsertString inserts a string at the cursor position.
-func (m *Model) InsertString(s string) {
- m.InsertRunesFromUserInput([]rune(s))
-}
-
-// InsertRune inserts a rune at the cursor position.
-func (m *Model) InsertRune(r rune) {
- m.InsertRunesFromUserInput([]rune{r})
-}
-
-// InsertAttachment inserts an attachment at the cursor position.
-func (m *Model) InsertAttachment(att *attachment.Attachment) {
- if m.CharLimit > 0 {
- availSpace := m.CharLimit - m.Length()
- // If the char limit's been reached, cancel.
- if availSpace <= 0 {
- return
- }
- }
-
- // Insert the attachment at the current cursor position
- m.value[m.row] = append(
- m.value[m.row][:m.col],
- append([]any{att}, m.value[m.row][m.col:]...)...)
- m.col++
- m.SetCursorColumn(m.col)
-}
-
-// removeAttachmentAtCursor replaces the attachment at or immediately before the
-// cursor with its textual display and positions the cursor at the end of the
-// inserted text. Returns true if an attachment was removed.
-func (m *Model) removeAttachmentAtCursor() bool {
- att, startIdx, _ := m.isAttachmentAtCursor()
- if att == nil {
- return false
- }
- // Replace the attachment element with the display runes
- before := m.value[m.row][:startIdx]
- after := m.value[m.row][startIdx+1:]
- replacement := runesToInterfaces([]rune(att.Display))
- newRow := make([]any, 0, len(before)+len(replacement)+len(after))
- newRow = append(newRow, before...)
- newRow = append(newRow, replacement...)
- newRow = append(newRow, after...)
- m.value[m.row] = newRow
- m.col = startIdx + len(replacement)
- m.SetCursorColumn(m.col)
- return true
-}
-
-// ReplaceRange replaces text from startCol to endCol on the current row with the given string.
-// This preserves attachments outside the replaced range.
-func (m *Model) ReplaceRange(startCol, endCol int, replacement string) {
- if m.row >= len(m.value) || startCol < 0 || endCol < startCol {
- return
- }
-
- // Ensure bounds are within the current row
- rowLen := len(m.value[m.row])
- startCol = max(0, min(startCol, rowLen))
- endCol = max(startCol, min(endCol, rowLen))
-
- // Create new row content: before + replacement + after
- before := m.value[m.row][:startCol]
- after := m.value[m.row][endCol:]
- replacementRunes := runesToInterfaces([]rune(replacement))
-
- // Combine the parts
- newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after))
- newRow = append(newRow, before...)
- newRow = append(newRow, replacementRunes...)
- newRow = append(newRow, after...)
-
- m.value[m.row] = newRow
-
- // Position cursor at end of replacement
- m.col = startCol + len(replacementRunes)
- m.SetCursorColumn(m.col)
-}
-
-// CurrentRowLength returns the length of the current row.
-func (m *Model) CurrentRowLength() int {
- if m.row >= len(m.value) {
- return 0
- }
- return len(m.value[m.row])
-}
-
-// GetAttachments returns all attachments in the textarea with accurate position indices.
-func (m Model) GetAttachments() []*attachment.Attachment {
- var attachments []*attachment.Attachment
- position := 0 // Track absolute position in the text
-
- for rowIdx, row := range m.value {
- colPosition := 0 // Track position within the current row
-
- for _, item := range row {
- switch v := item.(type) {
- case *attachment.Attachment:
- // Clone the attachment to avoid modifying the original
- att := *v
- att.StartIndex = position + colPosition
- att.EndIndex = position + colPosition + len(v.Display)
- attachments = append(attachments, &att)
- colPosition += len(v.Display)
- case rune:
- colPosition++
- }
- }
-
- // Add newline character position (except for last row)
- if rowIdx < len(m.value)-1 {
- position += colPosition + 1 // +1 for newline
- } else {
- position += colPosition
- }
- }
-
- return attachments
-}
-
-// InsertRunesFromUserInput inserts runes at the current cursor position.
-func (m *Model) InsertRunesFromUserInput(runes []rune) {
- // Clean up any special characters in the input provided by the
- // clipboard. This avoids bugs due to e.g. tab characters and
- // whatnot.
- runes = m.san().Sanitize(runes)
-
- if m.CharLimit > 0 {
- availSpace := m.CharLimit - m.Length()
- // If the char limit's been reached, cancel.
- if availSpace <= 0 {
- return
- }
- // If there's not enough space to paste the whole thing cut the pasted
- // runes down so they'll fit.
- if availSpace < len(runes) {
- runes = runes[:availSpace]
- }
- }
-
- // Split the input into lines.
- var lines [][]rune
- lstart := 0
- for i := range runes {
- if runes[i] == '\n' {
- // Queue a line to become a new row in the text area below.
- // Beware to clamp the max capacity of the slice, to ensure no
- // data from different rows get overwritten when later edits
- // will modify this line.
- lines = append(lines, runes[lstart:i:i])
- lstart = i + 1
- }
- }
- if lstart <= len(runes) {
- // The last line did not end with a newline character.
- // Take it now.
- lines = append(lines, runes[lstart:])
- }
-
- // Obey the maximum line limit.
- if maxLines > 0 && len(m.value)+len(lines)-1 > maxLines {
- allowedHeight := max(0, maxLines-len(m.value)+1)
- lines = lines[:allowedHeight]
- }
-
- if len(lines) == 0 {
- // Nothing left to insert.
- return
- }
-
- // Save the remainder of the original line at the current
- // cursor position.
- tail := copyInterfaceSlice(m.value[m.row][m.col:])
-
- // Paste the first line at the current cursor position.
- m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...)
- m.col += len(lines[0])
-
- if numExtraLines := len(lines) - 1; numExtraLines > 0 {
- // Add the new lines.
- // We try to reuse the slice if there's already space.
- var newGrid [][]any
- if cap(m.value) >= len(m.value)+numExtraLines {
- // Can reuse the extra space.
- newGrid = m.value[:len(m.value)+numExtraLines]
- } else {
- // No space left; need a new slice.
- newGrid = make([][]any, len(m.value)+numExtraLines)
- copy(newGrid, m.value[:m.row+1])
- }
- // Add all the rows that were after the cursor in the original
- // grid at the end of the new grid.
- copy(newGrid[m.row+1+numExtraLines:], m.value[m.row+1:])
- m.value = newGrid
- // Insert all the new lines in the middle.
- for _, l := range lines[1:] {
- m.row++
- m.value[m.row] = runesToInterfaces(l)
- m.col = len(l)
- }
- }
-
- // Finally add the tail at the end of the last line inserted.
- m.value[m.row] = append(m.value[m.row], tail...)
-
- m.SetCursorColumn(m.col)
-}
-
-// Value returns the value of the text input.
-func (m Model) Value() string {
- if m.value == nil {
- return ""
- }
-
- var v strings.Builder
- for _, l := range m.value {
- for _, item := range l {
- switch val := item.(type) {
- case rune:
- v.WriteRune(val)
- case *attachment.Attachment:
- v.WriteString(val.Display)
- }
- }
- v.WriteByte('\n')
- }
-
- return strings.TrimSuffix(v.String(), "\n")
-}
-
-// Length returns the number of characters currently in the text input.
-func (m *Model) Length() int {
- var l int
- for _, row := range m.value {
- for _, item := range row {
- switch val := item.(type) {
- case rune:
- l += rw.RuneWidth(val)
- case *attachment.Attachment:
- l += uniseg.StringWidth(val.Display)
- }
- }
- }
- // We add len(m.value) to include the newline characters.
- return l + len(m.value) - 1
-}
-
-// LineCount returns the number of lines that are currently in the text input.
-func (m *Model) LineCount() int {
- return m.ContentHeight()
-}
-
-// Line returns the line position.
-func (m Model) Line() int {
- return m.row
-}
-
-// CursorColumn returns the cursor's column position (slice index).
-func (m Model) CursorColumn() int {
- return m.col
-}
-
-// LastRuneIndex returns the index of the last occurrence of a rune on the current line,
-// searching backwards from the current cursor position.
-// Returns -1 if the rune is not found before the cursor.
-func (m Model) LastRuneIndex(r rune) int {
- if m.row >= len(m.value) {
- return -1
- }
- // Iterate backwards from just before the cursor position
- for i := m.col - 1; i >= 0; i-- {
- if i < len(m.value[m.row]) {
- if item, ok := m.value[m.row][i].(rune); ok && item == r {
- return i
- }
- }
- }
- return -1
-}
-
-func (m *Model) Newline() {
- if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight {
- return
- }
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- m.splitLine(m.row, m.col)
-}
-
-// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index.
-// This is used to maintain the cursor's horizontal position when moving vertically.
-func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
- if row < 0 || row >= len(m.value) {
- return 0
- }
-
- offset := 0
- // Find the slice index that corresponds to the visual offset.
- for i, item := range m.value[row] {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
-
- // If the target offset falls within the current item, this is our index.
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- return i + 1
- }
- return i
- }
- offset += itemWidth
- }
-
- return len(m.value[row])
-}
-
-// CursorDown moves the cursor down by one line.
-func (m *Model) CursorDown() {
- li := m.LineInfo()
- charOffset := max(m.lastCharOffset, li.CharOffset)
- m.lastCharOffset = charOffset
-
- if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
- // Move to the next model line
- m.row++
-
- // We want to land on the first wrapped line of the new model line.
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[0]
-
- // Find position within the first wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundNextLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundNextLine:
- m.col = colInLine // startCol is 0 for the first wrapped line
- } else if li.RowOffset+1 < li.Height {
- // Move to the next wrapped line within the same model line
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[li.RowOffset+1]
-
- startCol := 0
- for i := 0; i < li.RowOffset+1; i++ {
- startCol += len(grid[i])
- }
-
- // Find position within the target wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundSameLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundSameLine:
- m.col = startCol + colInLine
- }
- m.SetCursorColumn(m.col)
-}
-
-// CursorUp moves the cursor up by one line.
-func (m *Model) CursorUp() {
- li := m.LineInfo()
- charOffset := max(m.lastCharOffset, li.CharOffset)
- m.lastCharOffset = charOffset
-
- if li.RowOffset <= 0 && m.row > 0 {
- // Move to the previous model line. We want to land on the last wrapped
- // line of the previous model line.
- m.row--
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[len(grid)-1]
-
- // Find start of last wrapped line.
- startCol := len(m.value[m.row]) - len(targetLineContent)
-
- // Find position within the last wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundPrevLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundPrevLine:
- m.col = startCol + colInLine
- } else if li.RowOffset > 0 {
- // Move to the previous wrapped line within the same model line.
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[li.RowOffset-1]
-
- startCol := 0
- for i := 0; i < li.RowOffset-1; i++ {
- startCol += len(grid[i])
- }
-
- // Find position within the target wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundSameLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundSameLine:
- m.col = startCol + colInLine
- }
- m.SetCursorColumn(m.col)
-}
-
-// SetCursorColumn moves the cursor to the given position. If the position is
-// out of bounds the cursor will be moved to the start or end accordingly.
-func (m *Model) SetCursorColumn(col int) {
- m.col = clamp(col, 0, len(m.value[m.row]))
- // Any time that we move the cursor horizontally we need to reset the last
- // offset so that the horizontal position when navigating is adjusted.
- m.lastCharOffset = 0
-}
-
-// CursorStart moves the cursor to the start of the input field.
-func (m *Model) CursorStart() {
- m.SetCursorColumn(0)
-}
-
-// CursorEnd moves the cursor to the end of the input field.
-func (m *Model) CursorEnd() {
- m.SetCursorColumn(len(m.value[m.row]))
-}
-
-func (m *Model) IsCursorAtEnd() bool {
- return m.CursorColumn() == len(m.value[m.row])
-}
-
-// Focused returns the focus state on the model.
-func (m Model) Focused() bool {
- return m.focus
-}
-
-// activeStyle returns the appropriate set of styles to use depending on
-// whether the textarea is focused or blurred.
-func (m Model) activeStyle() *StyleState {
- if m.focus {
- return &m.Styles.Focused
- }
- return &m.Styles.Blurred
-}
-
-// Focus sets the focus state on the model. When the model is in focus it can
-// receive keyboard input and the cursor will be hidden.
-func (m *Model) Focus() tea.Cmd {
- m.focus = true
- return m.virtualCursor.Focus()
-}
-
-// Blur removes the focus state on the model. When the model is blurred it can
-// not receive keyboard input and the cursor will be hidden.
-func (m *Model) Blur() {
- m.focus = false
- m.virtualCursor.Blur()
-}
-
-// Reset sets the input to its default state with no input.
-func (m *Model) Reset() {
- m.value = make([][]any, minHeight, maxLines)
- m.col = 0
- m.row = 0
- m.SetCursorColumn(0)
-}
-
-// san initializes or retrieves the rune sanitizer.
-func (m *Model) san() Sanitizer {
- if m.rsan == nil {
- // Textinput has all its input on a single line so collapse
- // newlines/tabs to single spaces.
- m.rsan = NewSanitizer()
- }
- return m.rsan
-}
-
-// deleteBeforeCursor deletes all text before the cursor. Returns whether or
-// not the cursor blink should be reset.
-func (m *Model) deleteBeforeCursor() {
- m.value[m.row] = m.value[m.row][m.col:]
- m.SetCursorColumn(0)
-}
-
-// deleteAfterCursor deletes all text after the cursor. Returns whether or not
-// the cursor blink should be reset. If input is masked delete everything after
-// the cursor so as not to reveal word breaks in the masked input.
-func (m *Model) deleteAfterCursor() {
- m.value[m.row] = m.value[m.row][:m.col]
- m.SetCursorColumn(len(m.value[m.row]))
-}
-
-// transposeLeft exchanges the runes at the cursor and immediately
-// before. No-op if the cursor is at the beginning of the line. If
-// the cursor is not at the end of the line yet, moves the cursor to
-// the right.
-func (m *Model) transposeLeft() {
- if m.col == 0 || len(m.value[m.row]) < 2 {
- return
- }
- if m.col >= len(m.value[m.row]) {
- m.SetCursorColumn(m.col - 1)
- }
- m.value[m.row][m.col-1], m.value[m.row][m.col] = m.value[m.row][m.col], m.value[m.row][m.col-1]
- if m.col < len(m.value[m.row]) {
- m.SetCursorColumn(m.col + 1)
- }
-}
-
-// deleteWordLeft deletes the word left to the cursor. Returns whether or not
-// the cursor blink should be reset.
-func (m *Model) deleteWordLeft() {
- if m.col == 0 || len(m.value[m.row]) == 0 {
- return
- }
-
- // Linter note: it's critical that we acquire the initial cursor position
- // here prior to altering it via SetCursor() below. As such, moving this
- // call into the corresponding if clause does not apply here.
- oldCol := m.col //nolint:ifshort
-
- m.SetCursorColumn(m.col - 1)
- for isSpaceAt(m.value[m.row], m.col) {
- if m.col <= 0 {
- break
- }
- // ignore series of whitespace before cursor
- m.SetCursorColumn(m.col - 1)
- }
-
- for m.col > 0 {
- if !isSpaceAt(m.value[m.row], m.col) {
- m.SetCursorColumn(m.col - 1)
- } else {
- if m.col > 0 {
- // keep the previous space
- m.SetCursorColumn(m.col + 1)
- }
- break
- }
- }
-
- if oldCol > len(m.value[m.row]) {
- m.value[m.row] = m.value[m.row][:m.col]
- } else {
- m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][oldCol:]...)
- }
-}
-
-// deleteWordRight deletes the word right to the cursor.
-func (m *Model) deleteWordRight() {
- if m.col >= len(m.value[m.row]) || len(m.value[m.row]) == 0 {
- return
- }
-
- oldCol := m.col
-
- for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) {
- // ignore series of whitespace after cursor
- m.SetCursorColumn(m.col + 1)
- }
-
- for m.col < len(m.value[m.row]) {
- if !isSpaceAt(m.value[m.row], m.col) {
- m.SetCursorColumn(m.col + 1)
- } else {
- break
- }
- }
-
- if m.col > len(m.value[m.row]) {
- m.value[m.row] = m.value[m.row][:oldCol]
- } else {
- m.value[m.row] = append(m.value[m.row][:oldCol], m.value[m.row][m.col:]...)
- }
-
- m.SetCursorColumn(oldCol)
-}
-
-// characterRight moves the cursor one character to the right.
-func (m *Model) characterRight() {
- if m.col < len(m.value[m.row]) {
- m.SetCursorColumn(m.col + 1)
- } else {
- if m.row < len(m.value)-1 {
- m.row++
- m.CursorStart()
- }
- }
-}
-
-// characterLeft moves the cursor one character to the left.
-// If insideLine is set, the cursor is moved to the last
-// character in the previous line, instead of one past that.
-func (m *Model) characterLeft(insideLine bool) {
- if m.col == 0 && m.row != 0 {
- m.row--
- m.CursorEnd()
- if !insideLine {
- return
- }
- }
- if m.col > 0 {
- m.SetCursorColumn(m.col - 1)
- }
-}
-
-// wordLeft moves the cursor one word to the left. Returns whether or not the
-// cursor blink should be reset. If input is masked, move input to the start
-// so as not to reveal word breaks in the masked input.
-func (m *Model) wordLeft() {
- for {
- m.characterLeft(true /* insideLine */)
- if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) {
- break
- }
- }
-
- for m.col > 0 {
- if isSpaceAt(m.value[m.row], m.col-1) {
- break
- }
- m.SetCursorColumn(m.col - 1)
- }
-}
-
-// wordRight moves the cursor one word to the right. Returns whether or not the
-// cursor blink should be reset. If the input is masked, move input to the end
-// so as not to reveal word breaks in the masked input.
-func (m *Model) wordRight() {
- m.doWordRight(func(int, int) { /* nothing */ })
-}
-
-func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
- // Skip spaces forward.
- for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) {
- if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) {
- // End of text.
- break
- }
- m.characterRight()
- }
-
- charIdx := 0
- for m.col < len(m.value[m.row]) {
- if isSpaceAt(m.value[m.row], m.col) {
- break
- }
- fn(charIdx, m.col)
- m.SetCursorColumn(m.col + 1)
- charIdx++
- }
-}
-
-// uppercaseRight changes the word to the right to uppercase.
-func (m *Model) uppercaseRight() {
- m.doWordRight(func(_ int, i int) {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToUpper(r)
- }
- })
-}
-
-// lowercaseRight changes the word to the right to lowercase.
-func (m *Model) lowercaseRight() {
- m.doWordRight(func(_ int, i int) {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToLower(r)
- }
- })
-}
-
-// capitalizeRight changes the word to the right to title case.
-func (m *Model) capitalizeRight() {
- m.doWordRight(func(charIdx int, i int) {
- if charIdx == 0 {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToTitle(r)
- }
- }
- })
-}
-
-// LineInfo returns the number of characters from the start of the
-// (soft-wrapped) line and the (soft-wrapped) line width.
-func (m Model) LineInfo() LineInfo {
- grid := m.memoizedWrap(m.value[m.row], m.width)
-
- // Find out which line we are currently on. This can be determined by the
- // m.col and counting the number of runes that we need to skip.
- var counter int
- for i, line := range grid {
- start := counter
- end := counter + len(line)
-
- if m.col >= start && m.col <= end {
- // This is the wrapped line the cursor is on.
-
- // Special case: if the cursor is at the end of a wrapped line,
- // and there's another wrapped line after it, the cursor should
- // be considered at the beginning of the next line.
- if m.col == end && i < len(grid)-1 {
- nextLine := grid[i+1]
- return LineInfo{
- CharOffset: 0,
- ColumnOffset: 0,
- Height: len(grid),
- RowOffset: i + 1,
- StartColumn: end,
- Width: len(nextLine),
- CharWidth: uniseg.StringWidth(interfacesToString(nextLine)),
- }
- }
-
- return LineInfo{
- CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])),
- ColumnOffset: m.col - start,
- Height: len(grid),
- RowOffset: i,
- StartColumn: start,
- Width: len(line),
- CharWidth: uniseg.StringWidth(interfacesToString(line)),
- }
- }
- counter = end
- }
- return LineInfo{}
-}
-
-// Width returns the width of the textarea.
-func (m Model) Width() int {
- return m.width
-}
-
-// MoveToBegin moves the cursor to the beginning of the input.
-func (m *Model) MoveToBegin() {
- m.row = 0
- m.SetCursorColumn(0)
-}
-
-// MoveToEnd moves the cursor to the end of the input.
-func (m *Model) MoveToEnd() {
- m.row = len(m.value) - 1
- m.SetCursorColumn(len(m.value[m.row]))
-}
-
-// SetWidth sets the width of the textarea to fit exactly within the given width.
-// This means that the textarea will account for the width of the prompt and
-// whether or not line numbers are being shown.
-//
-// Ensure that SetWidth is called after setting the Prompt and ShowLineNumbers,
-// It is important that the width of the textarea be exactly the given width
-// and no more.
-func (m *Model) SetWidth(w int) {
- // Update prompt width only if there is no prompt function as
- // [SetPromptFunc] updates the prompt width when it is called.
- if m.promptFunc == nil {
- // XXX: Do we even need this or can we calculate the prompt width
- // at render time?
- m.promptWidth = uniseg.StringWidth(m.Prompt)
- }
-
- // Add base style borders and padding to reserved outer width.
- reservedOuter := m.activeStyle().Base.GetHorizontalFrameSize()
-
- // Add prompt width to reserved inner width.
- reservedInner := m.promptWidth
-
- // Add line number width to reserved inner width.
- if m.ShowLineNumbers {
- // XXX: this was originally documented as needing "1 cell" but was,
- // in practice, effectively hardcoded to 2 cells. We can, and should,
- // reduce this to one gap and update the tests accordingly.
- const gap = 2
-
- // Number of digits plus 1 cell for the margin.
- reservedInner += numDigits(m.MaxHeight) + gap
- }
-
- // Input width must be at least one more than the reserved inner and outer
- // width. This gives us a minimum input width of 1.
- minWidth := reservedInner + reservedOuter + 1
- inputWidth := max(w, minWidth)
-
- // Input width must be no more than maximum width.
- if m.MaxWidth > 0 {
- inputWidth = min(inputWidth, m.MaxWidth)
- }
-
- // Since the width of the viewport and input area is dependent on the width of
- // borders, prompt and line numbers, we need to calculate it by subtracting
- // the reserved width from them.
-
- m.width = inputWidth - reservedOuter - reservedInner
-}
-
-// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead.
-//
-// If the function returns a prompt that is shorter than the specified
-// promptWidth, it will be padded to the left. If it returns a prompt that is
-// longer, display artifacts may occur; the caller is responsible for computing
-// an adequate promptWidth.
-func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIndex int) string) {
- m.promptFunc = fn
- m.promptWidth = promptWidth
-}
-
-// Height returns the current height of the textarea.
-func (m Model) Height() int {
- return m.height
-}
-
-// ContentHeight returns the actual height needed to display all content
-// including wrapped lines.
-func (m Model) ContentHeight() int {
- totalLines := 0
- for _, line := range m.value {
- wrappedLines := m.memoizedWrap(line, m.width)
- totalLines += len(wrappedLines)
- }
- // Ensure at least one line is shown
- if totalLines == 0 {
- totalLines = 1
- }
- return totalLines
-}
-
-// SetHeight sets the height of the textarea.
-func (m *Model) SetHeight(h int) {
- // Calculate the actual content height
- contentHeight := m.ContentHeight()
-
- // Use the content height as the actual height
- if m.MaxHeight > 0 {
- m.height = clamp(contentHeight, minHeight, m.MaxHeight)
- } else {
- m.height = max(contentHeight, minHeight)
- }
-}
-
-// Update is the Bubble Tea update loop.
-func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
- if !m.focus {
- m.virtualCursor.Blur()
- return m, nil
- }
-
- // Used to determine if the cursor should blink.
- oldRow, oldCol := m.cursorLineNumber(), m.col
-
- var cmds []tea.Cmd
-
- if m.row >= len(m.value) {
- m.value = append(m.value, make([]any, 0))
- }
- if m.value[m.row] == nil {
- m.value[m.row] = make([]any, 0)
- }
-
- if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() {
- m.cache = NewMemoCache[line, [][]any](m.MaxHeight)
- }
-
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col >= len(m.value[m.row]) {
- m.mergeLineBelow(m.row)
- break
- }
- m.deleteAfterCursor()
- case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- m.deleteBeforeCursor()
- case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
- // If the cursor is at or just after an attachment, convert it to text instead of deleting
- if att, _, _ := m.isAttachmentAtCursor(); att != nil {
- if m.removeAttachmentAtCursor() {
- break
- }
- }
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- if len(m.value[m.row]) > 0 && m.col > 0 {
- m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col)
- m.SetCursorColumn(m.col - 1)
- }
- case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
- // If the cursor is on an attachment, convert it to text instead of deleting
- if att, _, _ := m.isAttachmentAtCursor(); att != nil {
- if m.removeAttachmentAtCursor() {
- break
- }
- }
- if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
- m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1)
- }
- if m.col >= len(m.value[m.row]) {
- m.mergeLineBelow(m.row)
- break
- }
- case key.Matches(msg, m.KeyMap.DeleteWordBackward):
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- m.deleteWordLeft()
- case key.Matches(msg, m.KeyMap.DeleteWordForward):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col >= len(m.value[m.row]) {
- m.mergeLineBelow(m.row)
- break
- }
- m.deleteWordRight()
- case key.Matches(msg, m.KeyMap.InsertNewline):
- m.Newline()
- case key.Matches(msg, m.KeyMap.LineEnd):
- m.CursorEnd()
- case key.Matches(msg, m.KeyMap.LineStart):
- m.CursorStart()
- case key.Matches(msg, m.KeyMap.CharacterForward):
- m.characterRight()
- case key.Matches(msg, m.KeyMap.LineNext):
- m.CursorDown()
- case key.Matches(msg, m.KeyMap.WordForward):
- m.wordRight()
- case key.Matches(msg, m.KeyMap.CharacterBackward):
- m.characterLeft(false /* insideLine */)
- case key.Matches(msg, m.KeyMap.LinePrevious):
- m.CursorUp()
- case key.Matches(msg, m.KeyMap.WordBackward):
- m.wordLeft()
- case key.Matches(msg, m.KeyMap.InputBegin):
- m.MoveToBegin()
- case key.Matches(msg, m.KeyMap.InputEnd):
- m.MoveToEnd()
- case key.Matches(msg, m.KeyMap.LowercaseWordForward):
- m.lowercaseRight()
- case key.Matches(msg, m.KeyMap.UppercaseWordForward):
- m.uppercaseRight()
- case key.Matches(msg, m.KeyMap.CapitalizeWordForward):
- m.capitalizeRight()
- case key.Matches(msg, m.KeyMap.TransposeCharacterBackward):
- m.transposeLeft()
-
- default:
- m.InsertRunesFromUserInput([]rune(msg.Text))
- }
-
- case pasteMsg:
- m.InsertRunesFromUserInput([]rune(msg))
-
- case pasteErrMsg:
- m.Err = msg
- }
-
- var cmd tea.Cmd
- newRow, newCol := m.cursorLineNumber(), m.col
- m.virtualCursor, cmd = m.virtualCursor.Update(msg)
- if (newRow != oldRow || newCol != oldCol) && m.virtualCursor.Mode() == cursor.CursorBlink {
- m.virtualCursor.Blink = false
- cmd = m.virtualCursor.BlinkCmd()
- }
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
-}
-
-// View renders the text area in its current state.
-func (m Model) View() string {
- m.updateVirtualCursorStyle()
- if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" {
- return m.placeholderView()
- }
- m.virtualCursor.TextStyle = m.activeStyle().computedCursorLine()
-
- var (
- s strings.Builder
- style lipgloss.Style
- newLines int
- widestLineNumber int
- lineInfo = m.LineInfo()
- styles = m.activeStyle()
- )
-
- displayLine := 0
- for l, line := range m.value {
- wrappedLines := m.memoizedWrap(line, m.width)
-
- if m.row == l {
- style = styles.computedCursorLine()
- } else {
- style = styles.computedText()
- }
-
- for wl, wrappedLine := range wrappedLines {
- prompt := m.promptView(displayLine)
- prompt = styles.computedPrompt().Render(prompt)
- s.WriteString(style.Render(prompt))
- displayLine++
-
- var ln string
- if m.ShowLineNumbers {
- if wl == 0 { // normal line
- isCursorLine := m.row == l
- s.WriteString(m.lineNumberView(l+1, isCursorLine))
- } else { // soft wrapped line
- isCursorLine := m.row == l
- s.WriteString(m.lineNumberView(-1, isCursorLine))
- }
- }
-
- // Note the widest line number for padding purposes later.
- lnw := uniseg.StringWidth(ln)
- if lnw > widestLineNumber {
- widestLineNumber = lnw
- }
-
- wrappedLineStr := interfacesToString(wrappedLine)
- strwidth := uniseg.StringWidth(wrappedLineStr)
- padding := m.width - strwidth
- // If the trailing space causes the line to be wider than the
- // width, we should not draw it to the screen since it will result
- // in an extra space at the end of the line which can look off when
- // the cursor line is showing.
- if strwidth > m.width {
- // The character causing the line to be wider than the width is
- // guaranteed to be a space since any other character would
- // have been wrapped.
- wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ")
- padding = m.width - uniseg.StringWidth(wrappedLineStr)
- }
-
- if m.row == l && lineInfo.RowOffset == wl {
- // Render the part of the line before the cursor
- s.WriteString(
- m.renderLineWithAttachments(
- wrappedLine[:lineInfo.ColumnOffset],
- style,
- ),
- )
-
- if m.col >= len(line) && lineInfo.CharOffset >= m.width {
- m.virtualCursor.SetChar(" ")
- s.WriteString(m.virtualCursor.View())
- } else if lineInfo.ColumnOffset < len(wrappedLine) {
- // Render the item under the cursor
- item := wrappedLine[lineInfo.ColumnOffset]
- if att, ok := item.(*attachment.Attachment); ok {
- // Item at cursor is an attachment. Render it with the selection style.
- // This becomes the "cursor" visually.
- s.WriteString(m.Styles.SelectedAttachment.Render(att.Display))
- } else {
- // Item at cursor is a rune. Render it with the virtual cursor.
- m.virtualCursor.SetChar(string(item.(rune)))
- s.WriteString(style.Render(m.virtualCursor.View()))
- }
-
- // Render the part of the line after the cursor
- s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style))
- } else {
- // Cursor is at the end of the line
- m.virtualCursor.SetChar(" ")
- s.WriteString(style.Render(m.virtualCursor.View()))
- }
- } else {
- s.WriteString(m.renderLineWithAttachments(wrappedLine, style))
- }
-
- s.WriteString(style.Render(strings.Repeat(" ", max(0, padding))))
- s.WriteRune('\n')
- newLines++
- }
- }
-
- // Remove the trailing newline from the last line
- result := s.String()
- if len(result) > 0 && result[len(result)-1] == '\n' {
- result = result[:len(result)-1]
- }
-
- return styles.Base.Render(result)
-}
-
-// promptView renders a single line of the prompt.
-func (m Model) promptView(displayLine int) (prompt string) {
- prompt = m.Prompt
- if m.promptFunc == nil {
- return prompt
- }
- prompt = m.promptFunc(displayLine)
- width := lipgloss.Width(prompt)
- if width < m.promptWidth {
- prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt)
- }
-
- return m.activeStyle().computedPrompt().Render(prompt)
-}
-
-// lineNumberView renders the line number.
-//
-// If the argument is less than 0, a space styled as a line number is returned
-// instead. Such cases are used for soft-wrapped lines.
-//
-// The second argument indicates whether this line number is for a 'cursorline'
-// line number.
-func (m Model) lineNumberView(n int, isCursorLine bool) (str string) {
- if !m.ShowLineNumbers {
- return ""
- }
-
- if n <= 0 {
- str = " "
- } else {
- str = strconv.Itoa(n)
- }
-
- // XXX: is textStyle really necessary here?
- textStyle := m.activeStyle().computedText()
- lineNumberStyle := m.activeStyle().computedLineNumber()
- if isCursorLine {
- textStyle = m.activeStyle().computedCursorLine()
- lineNumberStyle = m.activeStyle().computedCursorLineNumber()
- }
-
- // Format line number dynamically based on the maximum number of lines.
- digits := len(strconv.Itoa(m.MaxHeight))
- str = fmt.Sprintf(" %*v ", digits, str)
-
- return textStyle.Render(lineNumberStyle.Render(str))
-}
-
-// placeholderView returns the prompt and placeholder, if any.
-func (m Model) placeholderView() string {
- var (
- s strings.Builder
- p = m.Placeholder
- styles = m.activeStyle()
- )
- // word wrap lines
- pwordwrap := ansi.Wordwrap(p, m.width, "")
- // hard wrap lines (handles lines that could not be word wrapped)
- pwrap := ansi.Hardwrap(pwordwrap, m.width, true)
- // split string by new lines
- plines := strings.Split(strings.TrimSpace(pwrap), "\n")
-
- // Only render the actual placeholder lines, not padded to m.height
- maxLines := max(len(plines), 1) // At least show one line for cursor
- for i := range maxLines {
- isLineNumber := len(plines) > i
-
- lineStyle := styles.computedPlaceholder()
- if len(plines) > i {
- lineStyle = styles.computedCursorLine()
- }
-
- // render prompt
- prompt := m.promptView(i)
- prompt = styles.computedPrompt().Render(prompt)
- s.WriteString(lineStyle.Render(prompt))
-
- // when show line numbers enabled:
- // - render line number for only the cursor line
- // - indent other placeholder lines
- // this is consistent with vim with line numbers enabled
- if m.ShowLineNumbers {
- var ln int
-
- switch {
- case i == 0:
- ln = i + 1
- fallthrough
- case len(plines) > i:
- s.WriteString(m.lineNumberView(ln, isLineNumber))
- default:
- }
- }
-
- switch {
- // first line
- case i == 0:
- // first character of first line as cursor with character
- m.virtualCursor.TextStyle = styles.computedPlaceholder()
- m.virtualCursor.SetChar(string(plines[0][0]))
- s.WriteString(lineStyle.Render(m.virtualCursor.View()))
-
- // the rest of the first line
- placeholderTail := plines[0][1:]
- gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0])))
- renderedPlaceholder := styles.computedPlaceholder().Render(placeholderTail + gap)
- s.WriteString(lineStyle.Render(renderedPlaceholder))
- // remaining lines
- case len(plines) > i:
- // current line placeholder text
- if len(plines) > i {
- placeholderLine := plines[i]
- gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i])))
- s.WriteString(lineStyle.Render(placeholderLine + gap))
- }
- default:
- // end of line buffer character
- eob := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter))
- s.WriteString(eob)
- }
-
- // terminate with new line (except for last line)
- if i < maxLines-1 {
- s.WriteRune('\n')
- }
- }
-
- return styles.Base.Render(s.String())
-}
-
-// Blink returns the blink command for the virtual cursor.
-func Blink() tea.Msg {
- return cursor.Blink()
-}
-
-// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea
-// program. This requires that [Model.VirtualCursor] is set to false.
-//
-// Note that you will almost certainly also need to adjust the offset cursor
-// position per the textarea's per the textarea's position in the terminal.
-//
-// Example:
-//
-// // In your top-level View function:
-// f := tea.NewFrame(m.textarea.View())
-// f.Cursor = m.textarea.Cursor()
-// f.Cursor.Position.X += offsetX
-// f.Cursor.Position.Y += offsetY
-func (m Model) Cursor() *tea.Cursor {
- if m.VirtualCursor {
- return nil
- }
-
- lineInfo := m.LineInfo()
- w := lipgloss.Width
- baseStyle := m.activeStyle().Base
-
- xOffset := lineInfo.CharOffset +
- w(m.promptView(0)) +
- w(m.lineNumberView(0, false)) +
- baseStyle.GetMarginLeft() +
- baseStyle.GetPaddingLeft() +
- baseStyle.GetBorderLeftSize()
-
- yOffset := m.cursorLineNumber() -
- baseStyle.GetMarginTop() +
- baseStyle.GetPaddingTop() +
- baseStyle.GetBorderTopSize()
-
- c := tea.NewCursor(xOffset, yOffset)
- c.Blink = m.Styles.Cursor.Blink
- c.Color = m.Styles.Cursor.Color
- c.Shape = m.Styles.Cursor.Shape
- return c
-}
-
-func (m Model) memoizedWrap(content []any, width int) [][]any {
- input := line{content: content, width: width}
- if v, ok := m.cache.Get(input); ok {
- return v
- }
- v := wrapInterfaces(content, width)
- m.cache.Set(input, v)
- return v
-}
-
-// cursorLineNumber returns the line number that the cursor is on.
-// This accounts for soft wrapped lines.
-func (m Model) cursorLineNumber() int {
- line := 0
- for i := range m.row {
- // Calculate the number of lines that the current line will be split
- // into.
- line += len(m.memoizedWrap(m.value[i], m.width))
- }
- line += m.LineInfo().RowOffset
- return line
-}
-
-// mergeLineBelow merges the current line the cursor is on with the line below.
-func (m *Model) mergeLineBelow(row int) {
- if row >= len(m.value)-1 {
- return
- }
-
- // To perform a merge, we will need to combine the two lines and then
- m.value[row] = append(m.value[row], m.value[row+1]...)
-
- // Shift all lines up by one
- for i := row + 1; i < len(m.value)-1; i++ {
- m.value[i] = m.value[i+1]
- }
-
- // And, remove the last line
- if len(m.value) > 0 {
- m.value = m.value[:len(m.value)-1]
- }
-}
-
-// mergeLineAbove merges the current line the cursor is on with the line above.
-func (m *Model) mergeLineAbove(row int) {
- if row <= 0 {
- return
- }
-
- m.col = len(m.value[row-1])
- m.row = m.row - 1
-
- // To perform a merge, we will need to combine the two lines and then
- m.value[row-1] = append(m.value[row-1], m.value[row]...)
-
- // Shift all lines up by one
- for i := row; i < len(m.value)-1; i++ {
- m.value[i] = m.value[i+1]
- }
-
- // And, remove the last line
- if len(m.value) > 0 {
- m.value = m.value[:len(m.value)-1]
- }
-}
-
-func (m *Model) splitLine(row, col int) {
- // To perform a split, take the current line and keep the content before
- // the cursor, take the content after the cursor and make it the content of
- // the line underneath, and shift the remaining lines down by one
- head, tailSrc := m.value[row][:col], m.value[row][col:]
- tail := copyInterfaceSlice(tailSrc)
-
- m.value = append(m.value[:row+1], m.value[row:]...)
-
- m.value[row] = head
- m.value[row+1] = tail
-
- m.col = 0
- m.row++
-}
-
-func itemWidth(item any) int {
- switch v := item.(type) {
- case rune:
- return rw.RuneWidth(v)
- case *attachment.Attachment:
- return uniseg.StringWidth(v.Display)
- }
- return 0
-}
-
-// forceWrapAttachment splits an attachment's display text across multiple lines
-func forceWrapAttachment(att *attachment.Attachment, width int) [][]any {
- if width <= 0 {
- return [][]any{{att}}
- }
-
- display := att.Display
- displayRunes := []rune(display)
-
- if len(displayRunes) <= width {
- return [][]any{{att}}
- }
-
- var lines [][]any
- start := 0
-
- for start < len(displayRunes) {
- // Calculate how many runes fit in this line
- end := start + width
- if end > len(displayRunes) {
- end = len(displayRunes)
- }
-
- // Create a wrapped attachment for this segment
- wrappedAtt := &attachment.Attachment{
- ID: att.ID,
- Type: att.Type,
- Display: string(displayRunes[start:end]),
- URL: att.URL,
- Filename: att.Filename,
- MediaType: att.MediaType,
- Source: att.Source,
- }
-
- lines = append(lines, []any{wrappedAtt})
- start = end
- }
-
- return lines
-}
-
-// forceWrapWord splits a word that's too long to fit within the given width
-func forceWrapWord(word []any, width int) [][]any {
- if width <= 0 || len(word) == 0 {
- return [][]any{word}
- }
-
- var lines [][]any
- currentLine := []any{}
- currentWidth := 0
-
- for _, item := range word {
- if att, ok := item.(*attachment.Attachment); ok {
- // Handle attachment that might be too wide
- attWidth := uniseg.StringWidth(att.Display)
-
- // If the attachment display is too wide, split it
- if attWidth > width {
- // Finish current line if it has content
- if len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- // Split the attachment display across multiple lines
- wrappedAttachment := forceWrapAttachment(att, width)
- lines = append(lines, wrappedAttachment...)
- continue
- }
-
- // If adding this attachment would exceed the width, start a new line
- if currentWidth+attWidth > width && len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- currentLine = append(currentLine, item)
- currentWidth += attWidth
- } else if r, ok := item.(rune); ok {
- itemWidth := rw.RuneWidth(r)
-
- // If adding this rune would exceed the width, start a new line
- if currentWidth+itemWidth > width && len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- currentLine = append(currentLine, item)
- currentWidth += itemWidth
- }
- }
-
- // Add the last line if it has content
- if len(currentLine) > 0 {
- lines = append(lines, currentLine)
- }
-
- return lines
-}
-
-func wrapInterfaces(content []any, width int) [][]any {
- if width <= 0 {
- return [][]any{content}
- }
-
- var (
- lines = [][]any{{}}
- word = []any{}
- wordW int
- lineW int
- spaceW int
- inSpaces bool
- )
-
- for _, item := range content {
- itemW := 0
- isSpace := false
-
- if r, ok := item.(rune); ok {
- if unicode.IsSpace(r) {
- isSpace = true
- }
- itemW = rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- itemW = uniseg.StringWidth(att.Display)
- }
-
- if isSpace {
- if !inSpaces {
- // End of a word
- if lineW > 0 && lineW+wordW > width {
- // If the word itself is too long to fit on a line, force-wrap it
- if wordW > width {
- wrappedLines := forceWrapWord(word, width)
- lines = append(lines, wrappedLines...)
- // Calculate width of the last wrapped line
- lastLine := wrappedLines[len(wrappedLines)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- } else {
- lines = append(lines, word)
- lineW = wordW
- }
- } else {
- // Check if the word needs to be force-wrapped even when it fits on the current line
- if wordW > width {
- currentLine := lines[len(lines)-1]
- wrappedWord := forceWrapWord(word, width-lineW)
- if len(wrappedWord) > 0 {
- lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
- for i := 1; i < len(wrappedWord); i++ {
- lines = append(lines, wrappedWord[i])
- }
- // Calculate width of the last wrapped line
- lastLine := wrappedWord[len(wrappedWord)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- }
- } else {
- lines[len(lines)-1] = append(lines[len(lines)-1], word...)
- lineW += wordW
- }
- }
- word = nil
- wordW = 0
- }
- inSpaces = true
- spaceW += itemW
- } else { // It's not a space, it's a character for a word.
- if inSpaces {
- // We just finished a block of spaces. Handle them now.
- lineW += spaceW
- for i := 0; i < spaceW; i++ {
- lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
- }
- if lineW > width {
- // The spaces made the line overflow. Start a new line for the upcoming word.
- lines = append(lines, []any{})
- lineW = 0
- }
- spaceW = 0
- }
- inSpaces = false
- word = append(word, item)
- wordW += itemW
- }
- }
-
- // Handle any remaining word/spaces at the end of the content.
- if wordW > 0 {
- if lineW > 0 && lineW+wordW > width {
- // If the word itself is too long to fit on a line, force-wrap it
- if wordW > width {
- wrappedLines := forceWrapWord(word, width)
- lines = append(lines, wrappedLines...)
- // Calculate width of the last wrapped line
- lastLine := wrappedLines[len(wrappedLines)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- } else {
- lines = append(lines, word)
- lineW = wordW
- }
- } else {
- // Check if the word needs to be force-wrapped even when it fits on the current line
- if wordW > width {
- currentLine := lines[len(lines)-1]
- wrappedWord := forceWrapWord(word, width-lineW)
- if len(wrappedWord) > 0 {
- lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
- for i := 1; i < len(wrappedWord); i++ {
- lines = append(lines, wrappedWord[i])
- }
- // Calculate width of the last wrapped line
- lastLine := wrappedWord[len(wrappedWord)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- }
- } else {
- lines[len(lines)-1] = append(lines[len(lines)-1], word...)
- lineW += wordW
- }
- }
- }
- if spaceW > 0 {
- // There are trailing spaces. Add them.
- for i := 0; i < spaceW; i++ {
- lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
- lineW += 1
- }
- if lineW > width {
- lines = append(lines, []any{})
- }
- }
-
- return lines
-}
-
-func repeatSpaces(n int) []rune {
- return []rune(strings.Repeat(string(' '), n))
-}
-
-// numDigits returns the number of digits in an integer.
-func numDigits(n int) int {
- if n == 0 {
- return 1
- }
- count := 0
- num := abs(n)
- for num > 0 {
- count++
- num /= 10
- }
- return count
-}
-
-func clamp(v, low, high int) int {
- if high < low {
- low, high = high, low
- }
- return min(high, max(low, v))
-}
-
-func abs(n int) int {
- if n < 0 {
- return -n
- }
- return n
-}
diff --git a/packages/tui/internal/components/textarea/textarea_test.go b/packages/tui/internal/components/textarea/textarea_test.go
deleted file mode 100644
index fb3c5b8ba..000000000
--- a/packages/tui/internal/components/textarea/textarea_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package textarea
-
-import (
- "testing"
-
- "github.com/sst/opencode/internal/attachment"
-)
-
-func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorAfterAttachment(t *testing.T) {
- m := New()
- m.InsertString("a ")
- att := &attachment.Attachment{ID: "1", Display: "@file.txt"}
- m.InsertAttachment(att)
- m.InsertString(" b")
-
- // Position cursor immediately after the attachment (index 3: 'a',' ',att,' ', 'b')
- m.SetCursorColumn(3)
-
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur")
- }
- got := m.Value()
- want := "a @file.txt b"
- if got != want {
- t.Fatalf("expected %q, got %q", want, got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorOnAttachment(t *testing.T) {
- m := New()
- m.InsertString("x ")
- att := &attachment.Attachment{ID: "2", Display: "@img.png"}
- m.InsertAttachment(att)
- m.InsertString(" y")
-
- // Position cursor on the attachment token (index 2: 'x',' ',att,' ', 'y')
- m.SetCursorColumn(2)
-
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur")
- }
- got := m.Value()
- want := "x @img.png y"
- if got != want {
- t.Fatalf("expected %q, got %q", want, got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_StartOfLine(t *testing.T) {
- m := New()
- att := &attachment.Attachment{ID: "3", Display: "@a.txt"}
- m.InsertAttachment(att)
- m.InsertString(" tail")
-
- // Position cursor immediately after the attachment at start of line (index 1)
- m.SetCursorColumn(1)
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur at start of line")
- }
- if got := m.Value(); got != "@a.txt tail" {
- t.Fatalf("unexpected value: %q", got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_NoAttachment_NoChange(t *testing.T) {
- m := New()
- m.InsertString("hello world")
- col := m.CursorColumn()
- if ok := m.removeAttachmentAtCursor(); ok {
- t.Fatalf("did not expect removal to occur")
- }
- if m.Value() != "hello world" || m.CursorColumn() != col {
- t.Fatalf("value or cursor unexpectedly changed")
- }
-}
diff --git a/packages/tui/internal/components/toast/toast.go b/packages/tui/internal/components/toast/toast.go
deleted file mode 100644
index 2de6bf619..000000000
--- a/packages/tui/internal/components/toast/toast.go
+++ /dev/null
@@ -1,266 +0,0 @@
-package toast
-
-import (
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// ShowToastMsg is a message to display a toast notification
-type ShowToastMsg struct {
- Message string
- Title *string
- Color compat.AdaptiveColor
- Duration time.Duration
-}
-
-// DismissToastMsg is a message to dismiss a specific toast
-type DismissToastMsg struct {
- ID string
-}
-
-// Toast represents a single toast notification
-type Toast struct {
- ID string
- Message string
- Title *string
- Color compat.AdaptiveColor
- CreatedAt time.Time
- Duration time.Duration
-}
-
-// ToastManager manages multiple toast notifications
-type ToastManager struct {
- toasts []Toast
-}
-
-// NewToastManager creates a new toast manager
-func NewToastManager() *ToastManager {
- return &ToastManager{
- toasts: []Toast{},
- }
-}
-
-// Init initializes the toast manager
-func (tm *ToastManager) Init() tea.Cmd {
- return nil
-}
-
-// Update handles messages for the toast manager
-func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
- switch msg := msg.(type) {
- case ShowToastMsg:
- toast := Toast{
- ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()),
- Title: msg.Title,
- Message: msg.Message,
- Color: msg.Color,
- CreatedAt: time.Now(),
- Duration: msg.Duration,
- }
-
- tm.toasts = append(tm.toasts, toast)
-
- // Return command to dismiss after duration
- return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg {
- return DismissToastMsg{ID: toast.ID}
- })
-
- case DismissToastMsg:
- var newToasts []Toast
- for _, t := range tm.toasts {
- if t.ID != msg.ID {
- newToasts = append(newToasts, t)
- }
- }
- tm.toasts = newToasts
- }
-
- return tm, nil
-}
-
-// renderSingleToast renders a single toast notification
-func (tm *ToastManager) renderSingleToast(toast Toast) string {
- t := theme.CurrentTheme()
-
- baseStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundElement()).
- Padding(1, 2)
-
- maxWidth := max(40, layout.Current.Viewport.Width/3)
- contentMaxWidth := max(maxWidth-6, 20)
-
- // Build content with wrapping
- var content strings.Builder
- if toast.Title != nil {
- titleStyle := styles.NewStyle().Foreground(toast.Color).
- Bold(true)
- content.WriteString(titleStyle.Render(*toast.Title))
- content.WriteString("\n")
- }
-
- // Wrap message text
- messageStyle := styles.NewStyle()
- contentWidth := lipgloss.Width(toast.Message)
- if contentWidth > contentMaxWidth {
- messageStyle = messageStyle.Width(contentMaxWidth)
- }
- content.WriteString(messageStyle.Render(toast.Message))
-
- // Render toast with max width
- return baseStyle.MaxWidth(maxWidth).Render(content.String())
-}
-
-// View renders all active toasts
-func (tm *ToastManager) View() string {
- if len(tm.toasts) == 0 {
- return ""
- }
-
- var toastViews []string
- for _, toast := range tm.toasts {
- toastView := tm.renderSingleToast(toast)
- toastViews = append(toastViews, toastView+"\n")
- }
-
- return strings.Join(toastViews, "\n")
-}
-
-// RenderOverlay renders the toasts as an overlay on the given background
-func (tm *ToastManager) RenderOverlay(background string) string {
- if len(tm.toasts) == 0 {
- return background
- }
-
- bgWidth := lipgloss.Width(background)
- bgHeight := lipgloss.Height(background)
- result := background
-
- // Start from top with 2 character padding
- currentY := 2
-
- // Render each toast individually
- for _, toast := range tm.toasts {
- // Render individual toast
- toastView := tm.renderSingleToast(toast)
- toastWidth := lipgloss.Width(toastView)
- toastHeight := lipgloss.Height(toastView)
-
- // Position at top-right with 2 character padding from right edge
- x := max(bgWidth-toastWidth-4, 0)
-
- // Check if toast fits vertically
- if currentY+toastHeight > bgHeight-2 {
- // No more room for toasts
- break
- }
-
- // Place this toast
- result = layout.PlaceOverlay(
- x,
- currentY,
- toastView,
- result,
- layout.WithOverlayBorder(),
- layout.WithOverlayBorderColor(toast.Color),
- )
-
- // Move down for next toast (add 1 for spacing between toasts)
- currentY += toastHeight + 1
- }
-
- return result
-}
-
-type ToastOptions struct {
- Title string
- Duration time.Duration
-}
-
-type toastOptions struct {
- title *string
- duration *time.Duration
- color *compat.AdaptiveColor
-}
-
-type ToastOption func(*toastOptions)
-
-func WithTitle(title string) ToastOption {
- return func(t *toastOptions) {
- t.title = &title
- }
-}
-func WithDuration(duration time.Duration) ToastOption {
- return func(t *toastOptions) {
- t.duration = &duration
- }
-}
-
-func WithColor(color compat.AdaptiveColor) ToastOption {
- return func(t *toastOptions) {
- t.color = &color
- }
-}
-
-func NewToast(message string, options ...ToastOption) tea.Cmd {
- t := theme.CurrentTheme()
- duration := 5 * time.Second
- color := t.Primary()
-
- opts := toastOptions{
- duration: &duration,
- color: &color,
- }
- for _, option := range options {
- option(&opts)
- }
-
- return func() tea.Msg {
- return ShowToastMsg{
- Message: message,
- Title: opts.title,
- Duration: *opts.duration,
- Color: *opts.color,
- }
- }
-}
-
-func NewInfoToast(message string, options ...ToastOption) tea.Cmd {
- options = append(options, WithColor(theme.CurrentTheme().Info()))
- return NewToast(
- message,
- options...,
- )
-}
-
-func NewSuccessToast(message string, options ...ToastOption) tea.Cmd {
- options = append(options, WithColor(theme.CurrentTheme().Success()))
- return NewToast(
- message,
- options...,
- )
-}
-
-func NewWarningToast(message string, options ...ToastOption) tea.Cmd {
- options = append(options, WithColor(theme.CurrentTheme().Warning()))
- return NewToast(
- message,
- options...,
- )
-}
-
-func NewErrorToast(message string, options ...ToastOption) tea.Cmd {
- options = append(options, WithColor(theme.CurrentTheme().Error()))
- return NewToast(
- message,
- options...,
- )
-}
diff --git a/packages/tui/internal/decoders/decoder.go b/packages/tui/internal/decoders/decoder.go
deleted file mode 100644
index efb699202..000000000
--- a/packages/tui/internal/decoders/decoder.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package decoders
-
-import (
- "bufio"
- "bytes"
- "io"
-
- "github.com/sst/opencode-sdk-go/packages/ssestream"
-)
-
-// UnboundedDecoder is an SSE decoder that uses bufio.Reader instead of bufio.Scanner
-// to avoid the 32MB token size limit. This is a workaround for large SSE events until
-// the upstream Stainless SDK is fixed.
-//
-// This decoder handles SSE events of unlimited size by reading line-by-line with
-// bufio.Reader.ReadBytes('\n'), which dynamically grows the buffer as needed.
-type UnboundedDecoder struct {
- reader *bufio.Reader
- closer io.ReadCloser
- evt ssestream.Event
- err error
-}
-
-// NewUnboundedDecoder creates a new unbounded SSE decoder with a 1MB initial buffer size
-func NewUnboundedDecoder(rc io.ReadCloser) ssestream.Decoder {
- reader := bufio.NewReaderSize(rc, 1024*1024) // 1MB initial buffer
- return &UnboundedDecoder{
- reader: reader,
- closer: rc,
- }
-}
-
-// Next reads and decodes the next SSE event from the stream
-func (d *UnboundedDecoder) Next() bool {
- if d.err != nil {
- return false
- }
-
- event := ""
- data := bytes.NewBuffer(nil)
-
- for {
- line, err := d.reader.ReadBytes('\n')
- if err != nil {
- if err == io.EOF && len(line) == 0 {
- return false
- }
- if err != io.EOF {
- d.err = err
- return false
- }
- }
-
- // Remove trailing newline characters
- line = bytes.TrimRight(line, "\r\n")
-
- // Empty line indicates end of event
- if len(line) == 0 {
- if data.Len() > 0 || event != "" {
- d.evt = ssestream.Event{
- Type: event,
- Data: data.Bytes(),
- }
- return true
- }
- continue
- }
-
- // Skip comments (lines starting with ':')
- if line[0] == ':' {
- continue
- }
-
- // Parse field
- name, value, found := bytes.Cut(line, []byte(":"))
- if !found {
- // Field with no value
- continue
- }
-
- // Remove leading space from value
- if len(value) > 0 && value[0] == ' ' {
- value = value[1:]
- }
-
- switch string(name) {
- case "":
- // An empty line in the form ": something" is a comment and should be ignored
- continue
- case "event":
- event = string(value)
- case "data":
- _, d.err = data.Write(value)
- if d.err != nil {
- return false
- }
- _, d.err = data.WriteRune('\n')
- if d.err != nil {
- return false
- }
- }
- }
-}
-
-// Event returns the current event
-func (d *UnboundedDecoder) Event() ssestream.Event {
- return d.evt
-}
-
-// Close closes the underlying reader
-func (d *UnboundedDecoder) Close() error {
- return d.closer.Close()
-}
-
-// Err returns any error that occurred during decoding
-func (d *UnboundedDecoder) Err() error {
- return d.err
-}
diff --git a/packages/tui/internal/decoders/decoder_test.go b/packages/tui/internal/decoders/decoder_test.go
deleted file mode 100644
index e5ad1d55a..000000000
--- a/packages/tui/internal/decoders/decoder_test.go
+++ /dev/null
@@ -1,194 +0,0 @@
-package decoders
-
-import (
- "bytes"
- "io"
- "strings"
- "testing"
-)
-
-func TestUnboundedDecoder_SmallEvent(t *testing.T) {
- data := "event: test\ndata: hello world\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "test" {
- t.Errorf("Expected event type 'test', got '%s'", evt.Type)
- }
- if string(evt.Data) != "hello world\n" {
- t.Errorf("Expected data 'hello world\\n', got '%s'", string(evt.Data))
- }
-
- if decoder.Next() {
- t.Error("Expected Next() to return false at end of stream")
- }
-
- if err := decoder.Err(); err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
-}
-
-func TestUnboundedDecoder_LargeEvent(t *testing.T) {
- // Create a large event (50MB)
- size := 50 * 1024 * 1024
- largeData := strings.Repeat("x", size)
-
- var buf bytes.Buffer
- buf.WriteString("event: large\n")
- buf.WriteString("data: ")
- buf.WriteString(largeData)
- buf.WriteString("\n\n")
-
- rc := io.NopCloser(&buf)
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "large" {
- t.Errorf("Expected event type 'large', got '%s'", evt.Type)
- }
-
- expectedData := largeData + "\n"
- if string(evt.Data) != expectedData {
- t.Errorf("Data size mismatch: expected %d, got %d", len(expectedData), len(evt.Data))
- }
-
- if decoder.Next() {
- t.Error("Expected Next() to return false at end of stream")
- }
-
- if err := decoder.Err(); err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
-}
-
-func TestUnboundedDecoder_MultipleEvents(t *testing.T) {
- data := "event: first\ndata: data1\n\nevent: second\ndata: data2\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- // First event
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true for first event")
- }
- evt := decoder.Event()
- if evt.Type != "first" {
- t.Errorf("Expected event type 'first', got '%s'", evt.Type)
- }
- if string(evt.Data) != "data1\n" {
- t.Errorf("Expected data 'data1\\n', got '%s'", string(evt.Data))
- }
-
- // Second event
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true for second event")
- }
- evt = decoder.Event()
- if evt.Type != "second" {
- t.Errorf("Expected event type 'second', got '%s'", evt.Type)
- }
- if string(evt.Data) != "data2\n" {
- t.Errorf("Expected data 'data2\\n', got '%s'", string(evt.Data))
- }
-
- // No more events
- if decoder.Next() {
- t.Error("Expected Next() to return false at end of stream")
- }
-
- if err := decoder.Err(); err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
-}
-
-func TestUnboundedDecoder_MultilineData(t *testing.T) {
- data := "event: multiline\ndata: line1\ndata: line2\ndata: line3\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "multiline" {
- t.Errorf("Expected event type 'multiline', got '%s'", evt.Type)
- }
-
- expectedData := "line1\nline2\nline3\n"
- if string(evt.Data) != expectedData {
- t.Errorf("Expected data '%s', got '%s'", expectedData, string(evt.Data))
- }
-}
-
-func TestUnboundedDecoder_Comments(t *testing.T) {
- data := ": this is a comment\nevent: test\n: another comment\ndata: hello\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "test" {
- t.Errorf("Expected event type 'test', got '%s'", evt.Type)
- }
- if string(evt.Data) != "hello\n" {
- t.Errorf("Expected data 'hello\\n', got '%s'", string(evt.Data))
- }
-}
-
-func TestUnboundedDecoder_NoEventType(t *testing.T) {
- data := "data: hello\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "" {
- t.Errorf("Expected empty event type, got '%s'", evt.Type)
- }
- if string(evt.Data) != "hello\n" {
- t.Errorf("Expected data 'hello\\n', got '%s'", string(evt.Data))
- }
-}
-
-func BenchmarkUnboundedDecoder_LargeEvent(b *testing.B) {
- // Create a 10MB event for benchmarking
- size := 10 * 1024 * 1024
- largeData := strings.Repeat("x", size)
-
- var buf bytes.Buffer
- buf.WriteString("event: bench\n")
- buf.WriteString("data: ")
- buf.WriteString(largeData)
- buf.WriteString("\n\n")
-
- data := buf.Bytes()
-
- b.ResetTimer()
- b.SetBytes(int64(len(data)))
-
- for i := 0; i < b.N; i++ {
- rc := io.NopCloser(bytes.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- b.Fatal("Expected Next() to return true")
- }
-
- _ = decoder.Event()
- }
-}
diff --git a/packages/tui/internal/id/id.go b/packages/tui/internal/id/id.go
deleted file mode 100644
index 0490b8f20..000000000
--- a/packages/tui/internal/id/id.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package id
-
-import (
- "crypto/rand"
- "encoding/hex"
- "fmt"
- "strings"
- "sync"
- "time"
-)
-
-const (
- PrefixSession = "ses"
- PrefixMessage = "msg"
- PrefixUser = "usr"
- PrefixPart = "prt"
-)
-
-const length = 26
-
-var (
- lastTimestamp int64
- counter int64
- mu sync.Mutex
-)
-
-type Prefix string
-
-const (
- Session Prefix = PrefixSession
- Message Prefix = PrefixMessage
- User Prefix = PrefixUser
- Part Prefix = PrefixPart
-)
-
-func ValidatePrefix(id string, prefix Prefix) bool {
- return strings.HasPrefix(id, string(prefix))
-}
-
-func Ascending(prefix Prefix, given ...string) string {
- return generateID(prefix, false, given...)
-}
-
-func Descending(prefix Prefix, given ...string) string {
- return generateID(prefix, true, given...)
-}
-
-func generateID(prefix Prefix, descending bool, given ...string) string {
- if len(given) > 0 && given[0] != "" {
- if !strings.HasPrefix(given[0], string(prefix)) {
- panic(fmt.Sprintf("ID %s does not start with %s", given[0], string(prefix)))
- }
- return given[0]
- }
-
- return generateNewID(prefix, descending)
-}
-
-func randomBase62(length int) string {
- const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
- result := make([]byte, length)
- bytes := make([]byte, length)
- rand.Read(bytes)
-
- for i := 0; i < length; i++ {
- result[i] = chars[bytes[i]%62]
- }
-
- return string(result)
-}
-
-func generateNewID(prefix Prefix, descending bool) string {
- mu.Lock()
- defer mu.Unlock()
-
- currentTimestamp := time.Now().UnixMilli()
-
- if currentTimestamp != lastTimestamp {
- lastTimestamp = currentTimestamp
- counter = 0
- }
- counter++
-
- now := uint64(currentTimestamp)*0x1000 + uint64(counter)
-
- if descending {
- now = ^now
- }
-
- timeBytes := make([]byte, 6)
- for i := 0; i < 6; i++ {
- timeBytes[i] = byte((now >> (40 - 8*i)) & 0xff)
- }
-
- return string(prefix) + "_" + hex.EncodeToString(timeBytes) + randomBase62(length-12)
-} \ No newline at end of file
diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go
deleted file mode 100644
index 5b10a9523..000000000
--- a/packages/tui/internal/layout/flex.go
+++ /dev/null
@@ -1,325 +0,0 @@
-package layout
-
-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
-
-const (
- Row Direction = iota
- Column
-)
-
-type Justify int
-
-const (
- JustifyStart Justify = iota
- JustifyEnd
- JustifyCenter
- JustifySpaceBetween
- JustifySpaceAround
-)
-
-type Align int
-
-const (
- AlignStart Align = iota
- AlignEnd
- AlignCenter
- AlignStretch // Only applicable in the cross-axis
-)
-
-type FlexOptions struct {
- Background *compat.AdaptiveColor
- Direction Direction
- Justify Justify
- Align Align
- Width int
- Height int
- Gap int
-}
-
-type FlexItem struct {
- View string
- FixedSize int // Fixed size in the main axis (width for Row, height for Column)
- Grow bool // If true, the item will grow to fill available space
-}
-
-// Render lays out a series of view strings based on flexbox-like rules.
-func Render(opts FlexOptions, items ...FlexItem) string {
- if len(items) == 0 {
- 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
- if opts.Direction == Column {
- mainAxisSize = opts.Height
- crossAxisSize = opts.Width
- }
-
- // Calculate total fixed size and count grow items
- totalFixedSize := 0
- growCount := 0
- for _, item := range items {
- if item.FixedSize > 0 {
- totalFixedSize += item.FixedSize
- } else if item.Grow {
- growCount++
- }
- }
-
- // 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-totalGapSize, 0)
-
- // Calculate size for each grow item
- growItemSize := 0
- if growCount > 0 && availableSpace > 0 {
- growItemSize = availableSpace / growCount
- }
-
- // Prepare sized views
- sizedViews := make([]string, len(items))
- actualSizes := make([]int, len(items))
-
- for i, item := range items {
- view := item.View
-
- // Determine the size for this item
- itemSize := 0
- if item.FixedSize > 0 {
- itemSize = item.FixedSize
- } else if item.Grow && growItemSize > 0 {
- itemSize = growItemSize
- } else {
- // No fixed size and not growing - use natural size
- if opts.Direction == Row {
- itemSize = lipgloss.Width(view)
- } else {
- itemSize = lipgloss.Height(view)
- }
- }
-
- // Apply size constraints
- if opts.Direction == Row {
- // For row direction, constrain width and handle height alignment
- if itemSize > 0 {
- view = styles.NewStyle().
- Background(*opts.Background).
- Width(itemSize).
- Height(crossAxisSize).
- Render(view)
- }
-
- // Apply cross-axis alignment
- switch opts.Align {
- case AlignCenter:
- view = lipgloss.PlaceVertical(
- crossAxisSize,
- lipgloss.Center,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignEnd:
- view = lipgloss.PlaceVertical(
- crossAxisSize,
- lipgloss.Bottom,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignStart:
- 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 {
- 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,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignEnd:
- view = lipgloss.PlaceHorizontal(
- crossAxisSize,
- lipgloss.Right,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignStart:
- view = lipgloss.PlaceHorizontal(
- crossAxisSize,
- lipgloss.Left,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignStretch:
- // Already stretched by Width setting above
- }
- }
-
- sizedViews[i] = view
- if opts.Direction == Row {
- actualSizes[i] = lipgloss.Width(view)
- } else {
- actualSizes[i] = lipgloss.Height(view)
- }
- }
-
- // 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)
-
- // Calculate spacing based on justification
- var spaceBefore, spaceBetween, spaceAfter int
- switch opts.Justify {
- case JustifyStart:
- spaceAfter = remainingSpace
- case JustifyEnd:
- spaceBefore = remainingSpace
- case JustifyCenter:
- spaceBefore = remainingSpace / 2
- spaceAfter = remainingSpace - spaceBefore
- case JustifySpaceBetween:
- if len(items) > 1 {
- spaceBetween = remainingSpace / (len(items) - 1)
- } else {
- spaceAfter = remainingSpace
- }
- case JustifySpaceAround:
- if len(items) > 0 {
- spaceAround := remainingSpace / (len(items) * 2)
- spaceBefore = spaceAround
- spaceAfter = spaceAround
- spaceBetween = spaceAround * 2
- }
- }
-
- // 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 {
- space := strings.Repeat(" ", spaceBefore)
- parts = append(parts, spaceStyle.Render(space))
- } else {
- // For vertical layout, add empty lines as separate parts
- for range spaceBefore {
- parts = append(parts, "")
- }
- }
- }
-
- // Add items with spacing
- for i, view := range sizedViews {
- parts = append(parts, view)
-
- // Add space between items (not after the last one)
- if i < len(sizedViews)-1 {
- // Add gap first, then any additional spacing from justification
- totalSpacing := opts.Gap + spaceBetween
- if totalSpacing > 0 {
- if opts.Direction == Row {
- space := strings.Repeat(" ", totalSpacing)
- parts = append(parts, spaceStyle.Render(space))
- } else {
- // For vertical layout, add empty lines as separate parts
- for range totalSpacing {
- parts = append(parts, "")
- }
- }
- }
- }
- }
-
- // Add space after if needed
- if spaceAfter > 0 {
- if opts.Direction == Row {
- space := strings.Repeat(" ", spaceAfter)
- parts = append(parts, spaceStyle.Render(space))
- } else {
- // For vertical layout, add empty lines as separate parts
- for range spaceAfter {
- parts = append(parts, "")
- }
- }
- }
-
- // Join the parts
- if opts.Direction == Row {
- return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
- } else {
- return lipgloss.JoinVertical(lipgloss.Left, parts...)
- }
-}
-
-// Helper function to create a simple vertical layout
-func Vertical(width, height int, items ...FlexItem) string {
- return Render(FlexOptions{
- Direction: Column,
- Width: width,
- Height: height,
- Justify: JustifyStart,
- Align: AlignStretch,
- }, items...)
-}
-
-// Helper function to create a simple horizontal layout
-func Horizontal(width, height int, items ...FlexItem) string {
- return Render(FlexOptions{
- Direction: Row,
- Width: width,
- Height: height,
- Justify: JustifyStart,
- Align: AlignStretch,
- }, items...)
-}
diff --git a/packages/tui/internal/layout/layout.go b/packages/tui/internal/layout/layout.go
deleted file mode 100644
index dce27ac68..000000000
--- a/packages/tui/internal/layout/layout.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package layout
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
-)
-
-var Current *LayoutInfo
-
-func init() {
- Current = &LayoutInfo{
- Viewport: Dimensions{Width: 80, Height: 25},
- Container: Dimensions{Width: 80, Height: 25},
- }
-}
-
-type LayoutSize string
-
-type Dimensions struct {
- Width int
- Height int
-}
-
-type LayoutInfo struct {
- Viewport Dimensions
- Container Dimensions
-}
-
-type Modal interface {
- tea.Model
- Render(background string) string
- Close() tea.Cmd
-}
diff --git a/packages/tui/internal/layout/overlay.go b/packages/tui/internal/layout/overlay.go
deleted file mode 100644
index 08016e31c..000000000
--- a/packages/tui/internal/layout/overlay.go
+++ /dev/null
@@ -1,382 +0,0 @@
-package layout
-
-import (
- "fmt"
- "regexp"
- "strings"
- "unicode/utf8"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- chAnsi "github.com/charmbracelet/x/ansi"
- "github.com/muesli/ansi"
- "github.com/muesli/reflow/truncate"
- "github.com/muesli/termenv"
- "github.com/sst/opencode/internal/util"
-)
-
-var (
- // ANSI escape sequence regex
- ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
-)
-
-// Split a string into lines, additionally returning the size of the widest line.
-func getLines(s string) (lines []string, widest int) {
- lines = strings.Split(s, "\n")
- for _, l := range lines {
- w := ansi.PrintableRuneWidth(l)
- if widest < w {
- widest = w
- }
- }
- return lines, widest
-}
-
-// overlayOptions holds configuration for overlay rendering
-type overlayOptions struct {
- whitespace *whitespace
- border bool
- borderColor *compat.AdaptiveColor
-}
-
-// OverlayOption sets options for overlay rendering
-type OverlayOption func(*overlayOptions)
-
-// PlaceOverlay places fg on top of bg.
-func PlaceOverlay(
- x, y int,
- fg, bg string,
- opts ...OverlayOption,
-) string {
- fgLines, fgWidth := getLines(fg)
- bgLines, bgWidth := getLines(bg)
- bgHeight := len(bgLines)
- fgHeight := len(fgLines)
-
- // Parse options
- options := &overlayOptions{
- whitespace: &whitespace{},
- }
- for _, opt := range opts {
- opt(options)
- }
-
- // Adjust for borders if enabled
- if options.border {
- // Add space for left and right borders
- adjustedFgWidth := fgWidth + 2
- // Adjust placement to account for borders
- x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
- y = util.Clamp(y, 0, bgHeight-fgHeight)
-
- // Pad all foreground lines to the same width for consistent borders
- for i := range fgLines {
- lineWidth := ansi.PrintableRuneWidth(fgLines[i])
- if lineWidth < fgWidth {
- fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
- }
- }
- } else {
- if fgWidth >= bgWidth && fgHeight >= bgHeight {
- // FIXME: return fg or bg?
- return fg
- }
- // TODO: allow placement outside of the bg box?
- x = util.Clamp(x, 0, bgWidth-fgWidth)
- y = util.Clamp(y, 0, bgHeight-fgHeight)
- }
-
- var b strings.Builder
- for i, bgLine := range bgLines {
- if i > 0 {
- b.WriteByte('\n')
- }
- if i < y || i >= y+fgHeight {
- b.WriteString(bgLine)
- continue
- }
-
- pos := 0
-
- // Handle left side of the line up to the overlay
- if x > 0 {
- left := truncate.String(bgLine, uint(x))
- pos = ansi.PrintableRuneWidth(left)
- b.WriteString(left)
- if pos < x {
- b.WriteString(options.whitespace.render(x - pos))
- pos = x
- }
- }
-
- // Render the overlay content with optional borders
- if options.border {
- // Get the foreground line
- fgLine := fgLines[i-y]
- fgLineWidth := ansi.PrintableRuneWidth(fgLine)
-
- // Extract the styles at the border positions
- // We need to get the style just before the border position to preserve background
- leftStyle := ansiStyle{}
- if pos > 0 {
- leftStyle = getStyleAtPosition(bgLine, pos-1)
- } else {
- leftStyle = getStyleAtPosition(bgLine, pos)
- }
- rightStyle := getStyleAtPosition(bgLine, pos+fgLineWidth)
-
- // Left border - combine background from original with border foreground
- leftSeq := combineStyles(leftStyle, options.borderColor)
- if leftSeq != "" {
- b.WriteString(leftSeq)
- }
- b.WriteString("┃")
- if leftSeq != "" {
- b.WriteString("\x1b[0m") // Reset all styles only if we applied any
- }
- pos++
-
- // Content
- b.WriteString(fgLine)
- pos += fgLineWidth
-
- // Right border - combine background from original with border foreground
- rightSeq := combineStyles(rightStyle, options.borderColor)
- if rightSeq != "" {
- b.WriteString(rightSeq)
- }
- b.WriteString("┃")
- if rightSeq != "" {
- b.WriteString("\x1b[0m") // Reset all styles only if we applied any
- }
- pos++
- } else {
- // No border, just render the content
- fgLine := fgLines[i-y]
- b.WriteString(fgLine)
- pos += ansi.PrintableRuneWidth(fgLine)
- }
-
- // Handle right side of the line after the overlay
- right := cutLeft(bgLine, pos)
- bgWidth := ansi.PrintableRuneWidth(bgLine)
- rightWidth := ansi.PrintableRuneWidth(right)
- if rightWidth <= bgWidth-pos {
- b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
- }
-
- b.WriteString(right)
- }
-
- return b.String()
-}
-
-// cutLeft cuts printable characters from the left.
-// This function is heavily based on muesli's ansi and truncate packages.
-func cutLeft(s string, cutWidth int) string {
- return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
-}
-
-// ansiStyle represents parsed ANSI style attributes
-type ansiStyle struct {
- fgColor string
- bgColor string
- attrs []string
-}
-
-// parseANSISequence parses an ANSI escape sequence into its components
-func parseANSISequence(seq string) ansiStyle {
- style := ansiStyle{}
-
- // Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
- if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
- return style
- }
-
- params := seq[2 : len(seq)-1]
- if params == "" {
- return style
- }
-
- parts := strings.Split(params, ";")
- i := 0
- for i < len(parts) {
- switch parts[i] {
- case "0": // Reset
- // Mark this as a reset by adding it to attrs
- style.attrs = append(style.attrs, "0")
- // Don't clear the style here, let the caller handle it
- case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
- style.attrs = append(style.attrs, parts[i])
- case "38": // Foreground color
- if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
- // 256 color mode
- style.fgColor = strings.Join(parts[i:i+3], ";")
- i += 2
- } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
- // RGB color mode
- style.fgColor = strings.Join(parts[i:i+5], ";")
- i += 4
- }
- case "48": // Background color
- if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
- // 256 color mode
- style.bgColor = strings.Join(parts[i:i+3], ";")
- i += 2
- } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
- // RGB color mode
- style.bgColor = strings.Join(parts[i:i+5], ";")
- i += 4
- }
- case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
- style.fgColor = parts[i]
- case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
- style.bgColor = parts[i]
- case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
- style.fgColor = parts[i]
- case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
- style.bgColor = parts[i]
- }
- i++
- }
-
- return style
-}
-
-// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
-func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
- if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
- return ""
- }
-
- var parts []string
-
- // Add attributes
- parts = append(parts, bgStyle.attrs...)
-
- // Add background color from the original style
- if bgStyle.bgColor != "" {
- parts = append(parts, bgStyle.bgColor)
- }
-
- // Add foreground color if specified
- if fgColor != nil {
- // Use the adaptive color which automatically selects based on terminal background
- // The RGBA method already handles light/dark selection
- r, g, b, _ := fgColor.RGBA()
- // RGBA returns 16-bit values, we need 8-bit
- parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
- }
-
- if len(parts) == 0 {
- return ""
- }
-
- return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
-}
-
-// getStyleAtPosition extracts the active ANSI style at a given visual position
-func getStyleAtPosition(s string, targetPos int) ansiStyle {
- visualPos := 0
- currentStyle := ansiStyle{}
-
- i := 0
- for i < len(s) && visualPos <= targetPos {
- // Check if we're at an ANSI escape sequence
- if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
- // Found an ANSI sequence at current position
- seq := s[i : i+match[1]]
- parsedStyle := parseANSISequence(seq)
-
- // Check if this is a reset sequence
- if len(parsedStyle.attrs) > 0 && parsedStyle.attrs[0] == "0" {
- // Reset all styles
- currentStyle = ansiStyle{}
- } else {
- // Update current style (merge with existing)
- if parsedStyle.fgColor != "" {
- currentStyle.fgColor = parsedStyle.fgColor
- }
- if parsedStyle.bgColor != "" {
- currentStyle.bgColor = parsedStyle.bgColor
- }
- if len(parsedStyle.attrs) > 0 {
- currentStyle.attrs = parsedStyle.attrs
- }
- }
-
- i += match[1]
- } else if i < len(s) {
- // Regular character
- if visualPos == targetPos {
- return currentStyle
- }
- _, size := utf8.DecodeRuneInString(s[i:])
- i += size
- visualPos++
- }
- }
-
- return currentStyle
-}
-
-type whitespace struct {
- style termenv.Style
- chars string
-}
-
-// Render whitespaces.
-func (w whitespace) render(width int) string {
- if w.chars == "" {
- w.chars = " "
- }
-
- r := []rune(w.chars)
- j := 0
- b := strings.Builder{}
-
- // Cycle through runes and print them into the whitespace.
- for i := 0; i < width; {
- b.WriteRune(r[j])
- j++
- if j >= len(r) {
- j = 0
- }
- i += ansi.PrintableRuneWidth(string(r[j]))
- }
-
- // Fill any extra gaps white spaces. This might be necessary if any runes
- // are more than one cell wide, which could leave a one-rune gap.
- short := width - ansi.PrintableRuneWidth(b.String())
- if short > 0 {
- b.WriteString(strings.Repeat(" ", short))
- }
-
- return w.style.Styled(b.String())
-}
-
-// WhitespaceOption sets a styling rule for rendering whitespace.
-type WhitespaceOption func(*whitespace)
-
-// WithWhitespace sets whitespace options for the overlay
-func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
- return func(o *overlayOptions) {
- for _, opt := range opts {
- opt(o.whitespace)
- }
- }
-}
-
-// WithOverlayBorder enables border rendering for the overlay
-func WithOverlayBorder() OverlayOption {
- return func(o *overlayOptions) {
- o.border = true
- }
-}
-
-// WithOverlayBorderColor sets the border color for the overlay
-func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
- return func(o *overlayOptions) {
- o.borderColor = &color
- }
-}
diff --git a/packages/tui/internal/styles/background.go b/packages/tui/internal/styles/background.go
deleted file mode 100644
index 99b05b456..000000000
--- a/packages/tui/internal/styles/background.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package styles
-
-import "image/color"
-
-type TerminalInfo struct {
- Background color.Color
- BackgroundIsDark bool
-}
-
-var Terminal *TerminalInfo
-
-func init() {
- Terminal = &TerminalInfo{
- Background: color.Black,
- BackgroundIsDark: true,
- }
-}
diff --git a/packages/tui/internal/styles/markdown.go b/packages/tui/internal/styles/markdown.go
deleted file mode 100644
index d73c14101..000000000
--- a/packages/tui/internal/styles/markdown.go
+++ /dev/null
@@ -1,326 +0,0 @@
-package styles
-
-import (
- "github.com/charmbracelet/glamour"
- "github.com/charmbracelet/glamour/ansi"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/lucasb-eyer/go-colorful"
- "github.com/sst/opencode/internal/theme"
-)
-
-const defaultMargin = 1
-
-// Helper functions for style pointers
-func boolPtr(b bool) *bool { return &b }
-func stringPtr(s string) *string { return &s }
-func uintPtr(u uint) *uint { return &u }
-
-// returns a glamour TermRenderer configured with the current theme
-func GetMarkdownRenderer(width int, backgroundColor compat.AdaptiveColor) *glamour.TermRenderer {
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(generateMarkdownStyleConfig(backgroundColor)),
- glamour.WithWordWrap(width),
- glamour.WithChromaFormatter("terminal16m"),
- )
- return r
-}
-
-// creates an ansi.StyleConfig for markdown rendering
-// using adaptive colors from the provided theme.
-func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.StyleConfig {
- t := theme.CurrentTheme()
- background := AdaptiveColorToString(backgroundColor)
-
- return ansi.StyleConfig{
- Document: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockPrefix: "",
- BlockSuffix: "",
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- },
- BlockQuote: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownBlockQuote()),
- Italic: boolPtr(true),
- Prefix: "┃ ",
- },
- Indent: uintPtr(1),
- IndentToken: stringPtr(" "),
- },
- List: ansi.StyleList{
- LevelIndent: defaultMargin,
- StyleBlock: ansi.StyleBlock{
- IndentToken: stringPtr(" "),
- StylePrimitive: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- },
- },
- Heading: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockSuffix: "\n",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H1: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "# ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H2: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "## ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H3: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "### ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H4: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "#### ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H5: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "##### ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H6: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "###### ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- Strikethrough: ansi.StylePrimitive{
- CrossedOut: boolPtr(true),
- Color: AdaptiveColorToString(t.TextMuted()),
- },
- Emph: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownEmph()),
- Italic: boolPtr(true),
- },
- Strong: ansi.StylePrimitive{
- Bold: boolPtr(true),
- Color: AdaptiveColorToString(t.MarkdownStrong()),
- },
- HorizontalRule: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownHorizontalRule()),
- Format: "\n─────────────────────────────────────────\n",
- },
- Item: ansi.StylePrimitive{
- BlockPrefix: "• ",
- Color: AdaptiveColorToString(t.MarkdownListItem()),
- },
- Enumeration: ansi.StylePrimitive{
- BlockPrefix: ". ",
- Color: AdaptiveColorToString(t.MarkdownListEnumeration()),
- },
- Task: ansi.StyleTask{
- Ticked: "[✓] ",
- Unticked: "[ ] ",
- },
- Link: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownLink()),
- Underline: boolPtr(true),
- },
- LinkText: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownLinkText()),
- Bold: boolPtr(true),
- },
- Image: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownImage()),
- Underline: boolPtr(true),
- Format: "🖼 {{.text}}",
- },
- ImageText: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownImageText()),
- Format: "{{.text}}",
- },
- Code: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownCode()),
- Prefix: "",
- Suffix: "",
- },
- },
- CodeBlock: ansi.StyleCodeBlock{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BackgroundColor: background,
- Prefix: " ",
- Color: AdaptiveColorToString(t.MarkdownCodeBlock()),
- },
- },
- Chroma: &ansi.Chroma{
- Background: ansi.StylePrimitive{
- BackgroundColor: background,
- },
- Text: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- Error: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.Error()),
- },
- Comment: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxComment()),
- },
- CommentPreproc: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- Keyword: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- KeywordReserved: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- KeywordNamespace: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- KeywordType: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxType()),
- },
- Operator: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxOperator()),
- },
- Punctuation: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxPunctuation()),
- },
- Name: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxVariable()),
- },
- NameBuiltin: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxVariable()),
- },
- NameTag: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- NameAttribute: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxFunction()),
- },
- NameClass: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxType()),
- },
- NameConstant: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxVariable()),
- },
- NameDecorator: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxFunction()),
- },
- NameFunction: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxFunction()),
- },
- LiteralNumber: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxNumber()),
- },
- LiteralString: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxString()),
- },
- LiteralStringEscape: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- GenericDeleted: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.DiffRemoved()),
- },
- GenericEmph: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownEmph()),
- Italic: boolPtr(true),
- },
- GenericInserted: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.DiffAdded()),
- },
- GenericStrong: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownStrong()),
- Bold: boolPtr(true),
- },
- GenericSubheading: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- },
- },
- },
- Table: ansi.StyleTable{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockSuffix: "\n",
- // TODO: find better way to fix markdown table renders
- BackgroundColor: stringPtr(""),
- },
- },
- CenterSeparator: stringPtr("┼"),
- ColumnSeparator: stringPtr("│"),
- RowSeparator: stringPtr("─"),
- },
- DefinitionDescription: ansi.StylePrimitive{
- BlockPrefix: "\n ❯ ",
- Color: AdaptiveColorToString(t.MarkdownLinkText()),
- },
- Text: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- Paragraph: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- },
- }
-}
-
-// AdaptiveColorToString converts a compat.AdaptiveColor to the appropriate
-// hex color string based on the current terminal background
-func AdaptiveColorToString(color compat.AdaptiveColor) *string {
- if Terminal.BackgroundIsDark {
- if _, ok := color.Dark.(lipgloss.NoColor); ok {
- return nil
- }
- c1, _ := colorful.MakeColor(color.Dark)
- return stringPtr(c1.Hex())
- }
- if _, ok := color.Light.(lipgloss.NoColor); ok {
- return nil
- }
- c1, _ := colorful.MakeColor(color.Light)
- return stringPtr(c1.Hex())
-}
diff --git a/packages/tui/internal/styles/styles.go b/packages/tui/internal/styles/styles.go
deleted file mode 100644
index b8905f8e4..000000000
--- a/packages/tui/internal/styles/styles.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package styles
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-func WhitespaceStyle(bg compat.AdaptiveColor) lipgloss.WhitespaceOption {
- return lipgloss.WithWhitespaceStyle(NewStyle().Background(bg).Lipgloss())
-}
diff --git a/packages/tui/internal/styles/utilities.go b/packages/tui/internal/styles/utilities.go
deleted file mode 100644
index 29d10f5c5..000000000
--- a/packages/tui/internal/styles/utilities.go
+++ /dev/null
@@ -1,295 +0,0 @@
-package styles
-
-import (
- "image/color"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-// IsNoColor checks if a color is the special NoColor type
-func IsNoColor(c color.Color) bool {
- _, ok := c.(lipgloss.NoColor)
- return ok
-}
-
-// Style wraps lipgloss.Style to provide a fluent API for handling "none" colors
-type Style struct {
- lipgloss.Style
-}
-
-// NewStyle creates a new Style with proper handling of "none" colors
-func NewStyle() Style {
- return Style{lipgloss.NewStyle()}
-}
-
-func (s Style) Lipgloss() lipgloss.Style {
- return s.Style
-}
-
-// Foreground sets the foreground color, handling "none" appropriately
-func (s Style) Foreground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetForeground()}
- }
- return Style{s.Style.Foreground(c)}
-}
-
-// Background sets the background color, handling "none" appropriately
-func (s Style) Background(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBackground()}
- }
- return Style{s.Style.Background(c)}
-}
-
-// BorderForeground sets the border foreground color, handling "none" appropriately
-func (s Style) BorderForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderForeground()}
- }
- return Style{s.Style.BorderForeground(c)}
-}
-
-// BorderBackground sets the border background color, handling "none" appropriately
-func (s Style) BorderBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderBackground()}
- }
- return Style{s.Style.BorderBackground(c)}
-}
-
-// BorderTopForeground sets the border top foreground color, handling "none" appropriately
-func (s Style) BorderTopForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderTopForeground()}
- }
- return Style{s.Style.BorderTopForeground(c)}
-}
-
-// BorderTopBackground sets the border top background color, handling "none" appropriately
-func (s Style) BorderTopBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderTopBackground()}
- }
- return Style{s.Style.BorderTopBackground(c)}
-}
-
-// BorderBottomForeground sets the border bottom foreground color, handling "none" appropriately
-func (s Style) BorderBottomForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderBottomForeground()}
- }
- return Style{s.Style.BorderBottomForeground(c)}
-}
-
-// BorderBottomBackground sets the border bottom background color, handling "none" appropriately
-func (s Style) BorderBottomBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderBottomBackground()}
- }
- return Style{s.Style.BorderBottomBackground(c)}
-}
-
-// BorderLeftForeground sets the border left foreground color, handling "none" appropriately
-func (s Style) BorderLeftForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderLeftForeground()}
- }
- return Style{s.Style.BorderLeftForeground(c)}
-}
-
-// BorderLeftBackground sets the border left background color, handling "none" appropriately
-func (s Style) BorderLeftBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderLeftBackground()}
- }
- return Style{s.Style.BorderLeftBackground(c)}
-}
-
-// BorderRightForeground sets the border right foreground color, handling "none" appropriately
-func (s Style) BorderRightForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderRightForeground()}
- }
- return Style{s.Style.BorderRightForeground(c)}
-}
-
-// BorderRightBackground sets the border right background color, handling "none" appropriately
-func (s Style) BorderRightBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderRightBackground()}
- }
- return Style{s.Style.BorderRightBackground(c)}
-}
-
-// Render applies the style to a string
-func (s Style) Render(str string) string {
- return s.Style.Render(str)
-}
-
-// Common lipgloss.Style method delegations for seamless usage
-
-func (s Style) Bold(v bool) Style {
- return Style{s.Style.Bold(v)}
-}
-
-func (s Style) Italic(v bool) Style {
- return Style{s.Style.Italic(v)}
-}
-
-func (s Style) Underline(v bool) Style {
- return Style{s.Style.Underline(v)}
-}
-
-func (s Style) Strikethrough(v bool) Style {
- return Style{s.Style.Strikethrough(v)}
-}
-
-func (s Style) Blink(v bool) Style {
- return Style{s.Style.Blink(v)}
-}
-
-func (s Style) Faint(v bool) Style {
- return Style{s.Style.Faint(v)}
-}
-
-func (s Style) Reverse(v bool) Style {
- return Style{s.Style.Reverse(v)}
-}
-
-func (s Style) Width(i int) Style {
- return Style{s.Style.Width(i)}
-}
-
-func (s Style) Height(i int) Style {
- return Style{s.Style.Height(i)}
-}
-
-func (s Style) Padding(i ...int) Style {
- return Style{s.Style.Padding(i...)}
-}
-
-func (s Style) PaddingTop(i int) Style {
- return Style{s.Style.PaddingTop(i)}
-}
-
-func (s Style) PaddingBottom(i int) Style {
- return Style{s.Style.PaddingBottom(i)}
-}
-
-func (s Style) PaddingLeft(i int) Style {
- return Style{s.Style.PaddingLeft(i)}
-}
-
-func (s Style) PaddingRight(i int) Style {
- return Style{s.Style.PaddingRight(i)}
-}
-
-func (s Style) Margin(i ...int) Style {
- return Style{s.Style.Margin(i...)}
-}
-
-func (s Style) MarginTop(i int) Style {
- return Style{s.Style.MarginTop(i)}
-}
-
-func (s Style) MarginBottom(i int) Style {
- return Style{s.Style.MarginBottom(i)}
-}
-
-func (s Style) MarginLeft(i int) Style {
- return Style{s.Style.MarginLeft(i)}
-}
-
-func (s Style) MarginRight(i int) Style {
- return Style{s.Style.MarginRight(i)}
-}
-
-func (s Style) Border(b lipgloss.Border, sides ...bool) Style {
- return Style{s.Style.Border(b, sides...)}
-}
-
-func (s Style) BorderStyle(b lipgloss.Border) Style {
- return Style{s.Style.BorderStyle(b)}
-}
-
-func (s Style) BorderTop(v bool) Style {
- return Style{s.Style.BorderTop(v)}
-}
-
-func (s Style) BorderBottom(v bool) Style {
- return Style{s.Style.BorderBottom(v)}
-}
-
-func (s Style) BorderLeft(v bool) Style {
- return Style{s.Style.BorderLeft(v)}
-}
-
-func (s Style) BorderRight(v bool) Style {
- return Style{s.Style.BorderRight(v)}
-}
-
-func (s Style) Align(p ...lipgloss.Position) Style {
- return Style{s.Style.Align(p...)}
-}
-
-func (s Style) AlignHorizontal(p lipgloss.Position) Style {
- return Style{s.Style.AlignHorizontal(p)}
-}
-
-func (s Style) AlignVertical(p lipgloss.Position) Style {
- return Style{s.Style.AlignVertical(p)}
-}
-
-func (s Style) Inline(v bool) Style {
- return Style{s.Style.Inline(v)}
-}
-
-func (s Style) MaxWidth(n int) Style {
- return Style{s.Style.MaxWidth(n)}
-}
-
-func (s Style) MaxHeight(n int) Style {
- return Style{s.Style.MaxHeight(n)}
-}
-
-func (s Style) TabWidth(n int) Style {
- return Style{s.Style.TabWidth(n)}
-}
-
-func (s Style) UnsetBold() Style {
- return Style{s.Style.UnsetBold()}
-}
-
-func (s Style) UnsetItalic() Style {
- return Style{s.Style.UnsetItalic()}
-}
-
-func (s Style) UnsetUnderline() Style {
- return Style{s.Style.UnsetUnderline()}
-}
-
-func (s Style) UnsetStrikethrough() Style {
- return Style{s.Style.UnsetStrikethrough()}
-}
-
-func (s Style) UnsetBlink() Style {
- return Style{s.Style.UnsetBlink()}
-}
-
-func (s Style) UnsetFaint() Style {
- return Style{s.Style.UnsetFaint()}
-}
-
-func (s Style) UnsetReverse() Style {
- return Style{s.Style.UnsetReverse()}
-}
-
-func (s Style) Copy() Style {
- return Style{s.Style}
-}
-
-func (s Style) Inherit(i Style) Style {
- return Style{s.Style.Inherit(i.Style)}
-}
diff --git a/packages/tui/internal/theme/loader.go b/packages/tui/internal/theme/loader.go
deleted file mode 100644
index b3d2f0982..000000000
--- a/packages/tui/internal/theme/loader.go
+++ /dev/null
@@ -1,408 +0,0 @@
-package theme
-
-import (
- "embed"
- "encoding/json"
- "fmt"
- "image/color"
- "os"
- "path"
- "path/filepath"
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-//go:embed themes/*.json
-var themesFS embed.FS
-
-type JSONTheme struct {
- Defs map[string]any `json:"defs,omitempty"`
- Theme map[string]any `json:"theme"`
-}
-
-type LoadedTheme struct {
- BaseTheme
- name string
-}
-
-func (t *LoadedTheme) Name() string {
- return t.name
-}
-
-type colorRef struct {
- value any
- resolved bool
-}
-
-func LoadThemesFromJSON() error {
- entries, err := themesFS.ReadDir("themes")
- if err != nil {
- return fmt.Errorf("failed to read themes directory: %w", err)
- }
-
- for _, entry := range entries {
- if !strings.HasSuffix(entry.Name(), ".json") {
- continue
- }
- themeName := strings.TrimSuffix(entry.Name(), ".json")
- data, err := themesFS.ReadFile(path.Join("themes", entry.Name()))
- if err != nil {
- return fmt.Errorf("failed to read theme file %s: %w", entry.Name(), err)
- }
- theme, err := parseJSONTheme(themeName, data)
- if err != nil {
- return fmt.Errorf("failed to parse theme %s: %w", themeName, err)
- }
- RegisterTheme(themeName, theme)
- }
-
- return nil
-}
-
-// LoadThemesFromDirectories loads themes from user directories in the correct override order.
-// The hierarchy is (from lowest to highest priority):
-// 1. Built-in themes (embedded)
-// 2. USER_CONFIG/opencode/themes/*.json
-// 3. PROJECT_ROOT/.opencode/themes/*.json
-// 4. CWD/.opencode/themes/*.json
-func LoadThemesFromDirectories(userConfig, projectRoot, cwd string) error {
- if err := LoadThemesFromJSON(); err != nil {
- return fmt.Errorf("failed to load built-in themes: %w", err)
- }
-
- dirs := []string{
- filepath.Join(userConfig, "themes"),
- filepath.Join(projectRoot, ".opencode", "themes"),
- }
- if cwd != projectRoot {
- dirs = append(dirs, filepath.Join(cwd, ".opencode", "themes"))
- }
-
- for _, dir := range dirs {
- if err := loadThemesFromDirectory(dir); err != nil {
- fmt.Printf("Warning: Failed to load themes from %s: %v\n", dir, err)
- }
- }
-
- return nil
-}
-
-func loadThemesFromDirectory(dir string) error {
- if _, err := os.Stat(dir); os.IsNotExist(err) {
- return nil // Directory doesn't exist, which is fine
- }
-
- entries, err := os.ReadDir(dir)
- if err != nil {
- return fmt.Errorf("failed to read directory: %w", err)
- }
-
- for _, entry := range entries {
- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
- continue
- }
-
- themeName := strings.TrimSuffix(entry.Name(), ".json")
- filePath := filepath.Join(dir, entry.Name())
-
- data, err := os.ReadFile(filePath)
- if err != nil {
- fmt.Printf("Warning: Failed to read theme file %s: %v\n", filePath, err)
- continue
- }
-
- theme, err := parseJSONTheme(themeName, data)
- if err != nil {
- fmt.Printf("Warning: Failed to parse theme %s: %v\n", filePath, err)
- continue
- }
-
- RegisterTheme(themeName, theme)
- }
-
- return nil
-}
-
-func parseJSONTheme(name string, data []byte) (Theme, error) {
- var jsonTheme JSONTheme
- if err := json.Unmarshal(data, &jsonTheme); err != nil {
- return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
- }
- theme := &LoadedTheme{
- name: name,
- }
- colorMap := make(map[string]*colorRef)
- for key, value := range jsonTheme.Defs {
- colorMap[key] = &colorRef{value: value, resolved: false}
- }
- for key, value := range jsonTheme.Theme {
- colorMap[key] = &colorRef{value: value, resolved: false}
- }
- resolver := &colorResolver{
- colors: colorMap,
- visited: make(map[string]bool),
- }
- for key, value := range jsonTheme.Theme {
- resolved, err := resolver.resolveColor(key, value)
- if err != nil {
- return nil, fmt.Errorf("failed to resolve color %s: %w", key, err)
- }
- adaptiveColor, err := parseResolvedColor(resolved)
- if err != nil {
- return nil, fmt.Errorf("failed to parse color %s: %w", key, err)
- }
- if err := setThemeColor(theme, key, adaptiveColor); err != nil {
- return nil, fmt.Errorf("failed to set color %s: %w", key, err)
- }
- }
-
- return theme, nil
-}
-
-type colorResolver struct {
- colors map[string]*colorRef
- visited map[string]bool
-}
-
-func (r *colorResolver) resolveColor(key string, value any) (any, error) {
- if r.visited[key] {
- return nil, fmt.Errorf("circular reference detected for color %s", key)
- }
- r.visited[key] = true
- defer func() { r.visited[key] = false }()
-
- switch v := value.(type) {
- case string:
- if strings.HasPrefix(v, "#") || v == "none" {
- return v, nil
- }
- return r.resolveReference(v)
- case float64:
- return v, nil
- case map[string]any:
- resolved := make(map[string]any)
-
- if dark, ok := v["dark"]; ok {
- resolvedDark, err := r.resolveColorValue(dark)
- if err != nil {
- return nil, fmt.Errorf("failed to resolve dark variant: %w", err)
- }
- resolved["dark"] = resolvedDark
- }
-
- if light, ok := v["light"]; ok {
- resolvedLight, err := r.resolveColorValue(light)
- if err != nil {
- return nil, fmt.Errorf("failed to resolve light variant: %w", err)
- }
- resolved["light"] = resolvedLight
- }
-
- return resolved, nil
- default:
- return nil, fmt.Errorf("invalid color value type: %T", value)
- }
-}
-
-func (r *colorResolver) resolveColorValue(value any) (any, error) {
- switch v := value.(type) {
- case string:
- if strings.HasPrefix(v, "#") || v == "none" {
- return v, nil
- }
- return r.resolveReference(v)
- case float64:
- return v, nil
- default:
- return nil, fmt.Errorf("invalid color value type: %T", value)
- }
-}
-
-func (r *colorResolver) resolveReference(ref string) (any, error) {
- colorRef, exists := r.colors[ref]
- if !exists {
- return nil, fmt.Errorf("color reference '%s' not found", ref)
- }
-
- if colorRef.resolved {
- return colorRef.value, nil
- }
-
- resolved, err := r.resolveColor(ref, colorRef.value)
- if err != nil {
- return nil, err
- }
-
- colorRef.value = resolved
- colorRef.resolved = true
-
- return resolved, nil
-}
-
-func parseResolvedColor(value any) (compat.AdaptiveColor, error) {
- switch v := value.(type) {
- case string:
- if v == "none" {
- return compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }, nil
- }
- return compat.AdaptiveColor{
- Dark: lipgloss.Color(v),
- Light: lipgloss.Color(v),
- }, nil
- case float64:
- colorStr := fmt.Sprintf("%d", int(v))
- return compat.AdaptiveColor{
- Dark: lipgloss.Color(colorStr),
- Light: lipgloss.Color(colorStr),
- }, nil
- case map[string]any:
- dark, darkOk := v["dark"]
- light, lightOk := v["light"]
-
- if !darkOk || !lightOk {
- return compat.AdaptiveColor{}, fmt.Errorf("color object must have both 'dark' and 'light' keys")
- }
- darkColor, err := parseColorValue(dark)
- if err != nil {
- return compat.AdaptiveColor{}, fmt.Errorf("failed to parse dark color: %w", err)
- }
- lightColor, err := parseColorValue(light)
- if err != nil {
- return compat.AdaptiveColor{}, fmt.Errorf("failed to parse light color: %w", err)
- }
- return compat.AdaptiveColor{
- Dark: darkColor,
- Light: lightColor,
- }, nil
- default:
- return compat.AdaptiveColor{}, fmt.Errorf("invalid resolved color type: %T", value)
- }
-}
-
-func parseColorValue(value any) (color.Color, error) {
- switch v := value.(type) {
- case string:
- if v == "none" {
- return lipgloss.NoColor{}, nil
- }
- return lipgloss.Color(v), nil
- case float64:
- return lipgloss.Color(fmt.Sprintf("%d", int(v))), nil
- default:
- return nil, fmt.Errorf("invalid color value type: %T", value)
- }
-}
-
-func setThemeColor(theme *LoadedTheme, key string, color compat.AdaptiveColor) error {
- switch key {
- case "primary":
- theme.PrimaryColor = color
- case "secondary":
- theme.SecondaryColor = color
- case "accent":
- theme.AccentColor = color
- case "error":
- theme.ErrorColor = color
- case "warning":
- theme.WarningColor = color
- case "success":
- theme.SuccessColor = color
- case "info":
- theme.InfoColor = color
- case "text":
- theme.TextColor = color
- case "textMuted":
- theme.TextMutedColor = color
- case "background":
- theme.BackgroundColor = color
- case "backgroundPanel":
- theme.BackgroundPanelColor = color
- case "backgroundElement":
- theme.BackgroundElementColor = color
- case "border":
- theme.BorderColor = color
- case "borderActive":
- theme.BorderActiveColor = color
- case "borderSubtle":
- theme.BorderSubtleColor = color
- case "diffAdded":
- theme.DiffAddedColor = color
- case "diffRemoved":
- theme.DiffRemovedColor = color
- case "diffContext":
- theme.DiffContextColor = color
- case "diffHunkHeader":
- theme.DiffHunkHeaderColor = color
- case "diffHighlightAdded":
- theme.DiffHighlightAddedColor = color
- case "diffHighlightRemoved":
- theme.DiffHighlightRemovedColor = color
- case "diffAddedBg":
- theme.DiffAddedBgColor = color
- case "diffRemovedBg":
- theme.DiffRemovedBgColor = color
- case "diffContextBg":
- theme.DiffContextBgColor = color
- case "diffLineNumber":
- theme.DiffLineNumberColor = color
- case "diffAddedLineNumberBg":
- theme.DiffAddedLineNumberBgColor = color
- case "diffRemovedLineNumberBg":
- theme.DiffRemovedLineNumberBgColor = color
- case "markdownText":
- theme.MarkdownTextColor = color
- case "markdownHeading":
- theme.MarkdownHeadingColor = color
- case "markdownLink":
- theme.MarkdownLinkColor = color
- case "markdownLinkText":
- theme.MarkdownLinkTextColor = color
- case "markdownCode":
- theme.MarkdownCodeColor = color
- case "markdownBlockQuote":
- theme.MarkdownBlockQuoteColor = color
- case "markdownEmph":
- theme.MarkdownEmphColor = color
- case "markdownStrong":
- theme.MarkdownStrongColor = color
- case "markdownHorizontalRule":
- theme.MarkdownHorizontalRuleColor = color
- case "markdownListItem":
- theme.MarkdownListItemColor = color
- case "markdownListEnumeration":
- theme.MarkdownListEnumerationColor = color
- case "markdownImage":
- theme.MarkdownImageColor = color
- case "markdownImageText":
- theme.MarkdownImageTextColor = color
- case "markdownCodeBlock":
- theme.MarkdownCodeBlockColor = color
- case "syntaxComment":
- theme.SyntaxCommentColor = color
- case "syntaxKeyword":
- theme.SyntaxKeywordColor = color
- case "syntaxFunction":
- theme.SyntaxFunctionColor = color
- case "syntaxVariable":
- theme.SyntaxVariableColor = color
- case "syntaxString":
- theme.SyntaxStringColor = color
- case "syntaxNumber":
- theme.SyntaxNumberColor = color
- case "syntaxType":
- theme.SyntaxTypeColor = color
- case "syntaxOperator":
- theme.SyntaxOperatorColor = color
- case "syntaxPunctuation":
- theme.SyntaxPunctuationColor = color
- default:
- // Ignore unknown keys for forward compatibility
- return nil
- }
- return nil
-}
diff --git a/packages/tui/internal/theme/loader_test.go b/packages/tui/internal/theme/loader_test.go
deleted file mode 100644
index 37546789b..000000000
--- a/packages/tui/internal/theme/loader_test.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package theme
-
-import (
- "os"
- "path/filepath"
- "slices"
- "testing"
-)
-
-func TestLoadThemesFromJSON(t *testing.T) {
- // Test loading themes
- err := LoadThemesFromJSON()
- if err != nil {
- t.Fatalf("Failed to load themes: %v", err)
- }
-
- // Check that themes were loaded
- themes := AvailableThemes()
- if len(themes) == 0 {
- t.Fatal("No themes were loaded")
- }
-
- // Check for expected themes
- expectedThemes := []string{"tokyonight", "opencode", "everforest", "ayu"}
- for _, expected := range expectedThemes {
- found := slices.Contains(themes, expected)
- if !found {
- t.Errorf("Expected theme %s not found", expected)
- }
- }
-
- // Test getting a specific theme
- tokyonight := GetTheme("tokyonight")
- if tokyonight == nil {
- t.Fatal("Failed to get tokyonight theme")
- }
-
- // Test theme colors
- primary := tokyonight.Primary()
- if primary.Dark == nil || primary.Light == nil {
- t.Error("Primary color not properly set")
- }
-}
-
-func TestColorReferenceResolution(t *testing.T) {
- // Load themes first
- err := LoadThemesFromJSON()
- if err != nil {
- t.Fatalf("Failed to load themes: %v", err)
- }
-
- // Test a theme that uses references (e.g., solarized uses color definitions)
- solarized := GetTheme("solarized")
- if solarized == nil {
- t.Fatal("Failed to get solarized theme")
- }
-
- // Check that color references were resolved
- primary := solarized.Primary()
- if primary.Dark == nil || primary.Light == nil {
- t.Error("Primary color reference not resolved")
- }
-
- // Check that all colors are properly resolved
- text := solarized.Text()
- if text.Dark == nil || text.Light == nil {
- t.Error("Text color reference not resolved")
- }
-}
-
-func TestLoadThemesFromDirectories(t *testing.T) {
- // Create temporary directories for testing
- tempDir := t.TempDir()
-
- userConfig := filepath.Join(tempDir, "config")
- projectRoot := filepath.Join(tempDir, "project")
- cwd := filepath.Join(tempDir, "cwd")
-
- // Create theme directories
- os.MkdirAll(filepath.Join(userConfig, "opencode", "themes"), 0755)
- os.MkdirAll(filepath.Join(projectRoot, ".opencode", "themes"), 0755)
- os.MkdirAll(filepath.Join(cwd, ".opencode", "themes"), 0755)
-
- // Create test themes with same name to test override behavior
- testTheme1 := `{
- "theme": {
- "primary": "#111111",
- "secondary": "#222222",
- "accent": "#333333",
- "text": "#ffffff",
- "textMuted": "#cccccc",
- "background": "#000000"
- }
- }`
-
- testTheme2 := `{
- "theme": {
- "primary": "#444444",
- "secondary": "#555555",
- "accent": "#666666",
- "text": "#ffffff",
- "textMuted": "#cccccc",
- "background": "#000000"
- }
- }`
-
- testTheme3 := `{
- "theme": {
- "primary": "#777777",
- "secondary": "#888888",
- "accent": "#999999",
- "text": "#ffffff",
- "textMuted": "#cccccc",
- "background": "#000000"
- }
- }`
-
- // Write themes to different directories
- os.WriteFile(filepath.Join(userConfig, "opencode", "themes", "override-test.json"), []byte(testTheme1), 0644)
- os.WriteFile(filepath.Join(projectRoot, ".opencode", "themes", "override-test.json"), []byte(testTheme2), 0644)
- os.WriteFile(filepath.Join(cwd, ".opencode", "themes", "override-test.json"), []byte(testTheme3), 0644)
-
- // Load themes
- err := LoadThemesFromDirectories(userConfig, projectRoot, cwd)
- if err != nil {
- t.Fatalf("Failed to load themes from directories: %v", err)
- }
-
- // Check that the theme from CWD (highest priority) won
- overrideTheme := GetTheme("override-test")
- if overrideTheme == nil {
- t.Fatal("Failed to get override-test theme")
- }
-
- // The primary color should be from testTheme3 (#777777)
- primary := overrideTheme.Primary()
- // We can't directly check the color value, but we can verify it was loaded
- if primary.Dark == nil || primary.Light == nil {
- t.Error("Override theme not properly loaded")
- }
-}
diff --git a/packages/tui/internal/theme/manager.go b/packages/tui/internal/theme/manager.go
deleted file mode 100644
index 420b96dea..000000000
--- a/packages/tui/internal/theme/manager.go
+++ /dev/null
@@ -1,229 +0,0 @@
-package theme
-
-import (
- "fmt"
- "image/color"
- "slices"
- "strconv"
- "strings"
- "sync"
-
- "github.com/alecthomas/chroma/v2/styles"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/charmbracelet/x/ansi"
-)
-
-// Manager handles theme registration, selection, and retrieval.
-// It maintains a registry of available themes and tracks the currently active theme.
-type Manager struct {
- themes map[string]Theme
- currentName string
- currentUsesAnsiCache bool // Cache whether current theme uses ANSI colors
- mu sync.RWMutex
-}
-
-// Global instance of the theme manager
-var globalManager = &Manager{
- themes: make(map[string]Theme),
- currentName: "",
-}
-
-// RegisterTheme adds a new theme to the registry.
-// If this is the first theme registered, it becomes the default.
-func RegisterTheme(name string, theme Theme) {
- globalManager.mu.Lock()
- defer globalManager.mu.Unlock()
-
- globalManager.themes[name] = theme
-
- // If this is the first theme, make it the default
- if globalManager.currentName == "" {
- globalManager.currentName = name
- globalManager.currentUsesAnsiCache = themeUsesAnsiColors(theme)
- }
-}
-
-// SetTheme changes the active theme to the one with the specified name.
-// Returns an error if the theme doesn't exist.
-func SetTheme(name string) error {
- globalManager.mu.Lock()
- defer globalManager.mu.Unlock()
- delete(styles.Registry, "charm")
-
- theme, exists := globalManager.themes[name]
- if !exists {
- return fmt.Errorf("theme '%s' not found", name)
- }
-
- globalManager.currentName = name
- globalManager.currentUsesAnsiCache = themeUsesAnsiColors(theme)
-
- return nil
-}
-
-// CurrentTheme returns the currently active theme.
-// If no theme is set, it returns nil.
-func CurrentTheme() Theme {
- globalManager.mu.RLock()
- defer globalManager.mu.RUnlock()
-
- if globalManager.currentName == "" {
- return nil
- }
-
- return globalManager.themes[globalManager.currentName]
-}
-
-// CurrentThemeName returns the name of the currently active theme.
-func CurrentThemeName() string {
- globalManager.mu.RLock()
- defer globalManager.mu.RUnlock()
-
- return globalManager.currentName
-}
-
-// AvailableThemes returns a list of all registered theme names.
-func AvailableThemes() []string {
- globalManager.mu.RLock()
- defer globalManager.mu.RUnlock()
-
- names := make([]string, 0, len(globalManager.themes))
- for name := range globalManager.themes {
- names = append(names, name)
- }
- slices.SortFunc(names, func(a, b string) int {
- if a == "opencode" {
- return -1
- } else if b == "opencode" {
- return 1
- }
- if a == "system" {
- return -1
- } else if b == "system" {
- return 1
- }
- return strings.Compare(a, b)
- })
- return names
-}
-
-// GetTheme returns a specific theme by name.
-// Returns nil if the theme doesn't exist.
-func GetTheme(name string) Theme {
- globalManager.mu.RLock()
- defer globalManager.mu.RUnlock()
-
- return globalManager.themes[name]
-}
-
-// UpdateSystemTheme updates the system theme with terminal background info
-func UpdateSystemTheme(terminalBg color.Color, isDark bool) {
- globalManager.mu.Lock()
- defer globalManager.mu.Unlock()
-
- dynamicTheme := NewSystemTheme(terminalBg, isDark)
- globalManager.themes["system"] = dynamicTheme
- if globalManager.currentName == "system" {
- globalManager.currentUsesAnsiCache = themeUsesAnsiColors(dynamicTheme)
- }
-}
-
-// CurrentThemeUsesAnsiColors returns true if the current theme uses ANSI 0-16 colors
-func CurrentThemeUsesAnsiColors() bool {
- // globalManager.mu.RLock()
- // defer globalManager.mu.RUnlock()
-
- return globalManager.currentUsesAnsiCache
-}
-
-// isAnsiColor checks if a color represents an ANSI 0-16 color
-func isAnsiColor(c color.Color) bool {
- if _, ok := c.(lipgloss.NoColor); ok {
- return false
- }
- if _, ok := c.(ansi.BasicColor); ok {
- return true
- }
-
- // For other color types, check if they represent ANSI colors
- // by examining their string representation
- if stringer, ok := c.(fmt.Stringer); ok {
- str := stringer.String()
- // Check if it's a numeric ANSI color (0-15)
- if num, err := strconv.Atoi(str); err == nil && num >= 0 && num <= 15 {
- return true
- }
- }
-
- return false
-}
-
-// adaptiveColorUsesAnsi checks if an AdaptiveColor uses ANSI colors
-func adaptiveColorUsesAnsi(ac compat.AdaptiveColor) bool {
- if isAnsiColor(ac.Dark) {
- return true
- }
- if isAnsiColor(ac.Light) {
- return true
- }
- return false
-}
-
-// themeUsesAnsiColors checks if a theme uses any ANSI 0-16 colors
-func themeUsesAnsiColors(theme Theme) bool {
- if theme == nil {
- return false
- }
-
- return adaptiveColorUsesAnsi(theme.Primary()) ||
- adaptiveColorUsesAnsi(theme.Secondary()) ||
- adaptiveColorUsesAnsi(theme.Accent()) ||
- adaptiveColorUsesAnsi(theme.Error()) ||
- adaptiveColorUsesAnsi(theme.Warning()) ||
- adaptiveColorUsesAnsi(theme.Success()) ||
- adaptiveColorUsesAnsi(theme.Info()) ||
- adaptiveColorUsesAnsi(theme.Text()) ||
- adaptiveColorUsesAnsi(theme.TextMuted()) ||
- adaptiveColorUsesAnsi(theme.Background()) ||
- adaptiveColorUsesAnsi(theme.BackgroundPanel()) ||
- adaptiveColorUsesAnsi(theme.BackgroundElement()) ||
- adaptiveColorUsesAnsi(theme.Border()) ||
- adaptiveColorUsesAnsi(theme.BorderActive()) ||
- adaptiveColorUsesAnsi(theme.BorderSubtle()) ||
- adaptiveColorUsesAnsi(theme.DiffAdded()) ||
- adaptiveColorUsesAnsi(theme.DiffRemoved()) ||
- adaptiveColorUsesAnsi(theme.DiffContext()) ||
- adaptiveColorUsesAnsi(theme.DiffHunkHeader()) ||
- adaptiveColorUsesAnsi(theme.DiffHighlightAdded()) ||
- adaptiveColorUsesAnsi(theme.DiffHighlightRemoved()) ||
- adaptiveColorUsesAnsi(theme.DiffAddedBg()) ||
- adaptiveColorUsesAnsi(theme.DiffRemovedBg()) ||
- adaptiveColorUsesAnsi(theme.DiffContextBg()) ||
- adaptiveColorUsesAnsi(theme.DiffLineNumber()) ||
- adaptiveColorUsesAnsi(theme.DiffAddedLineNumberBg()) ||
- adaptiveColorUsesAnsi(theme.DiffRemovedLineNumberBg()) ||
- adaptiveColorUsesAnsi(theme.MarkdownText()) ||
- adaptiveColorUsesAnsi(theme.MarkdownHeading()) ||
- adaptiveColorUsesAnsi(theme.MarkdownLink()) ||
- adaptiveColorUsesAnsi(theme.MarkdownLinkText()) ||
- adaptiveColorUsesAnsi(theme.MarkdownCode()) ||
- adaptiveColorUsesAnsi(theme.MarkdownBlockQuote()) ||
- adaptiveColorUsesAnsi(theme.MarkdownEmph()) ||
- adaptiveColorUsesAnsi(theme.MarkdownStrong()) ||
- adaptiveColorUsesAnsi(theme.MarkdownHorizontalRule()) ||
- adaptiveColorUsesAnsi(theme.MarkdownListItem()) ||
- adaptiveColorUsesAnsi(theme.MarkdownListEnumeration()) ||
- adaptiveColorUsesAnsi(theme.MarkdownImage()) ||
- adaptiveColorUsesAnsi(theme.MarkdownImageText()) ||
- adaptiveColorUsesAnsi(theme.MarkdownCodeBlock()) ||
- adaptiveColorUsesAnsi(theme.SyntaxComment()) ||
- adaptiveColorUsesAnsi(theme.SyntaxKeyword()) ||
- adaptiveColorUsesAnsi(theme.SyntaxFunction()) ||
- adaptiveColorUsesAnsi(theme.SyntaxVariable()) ||
- adaptiveColorUsesAnsi(theme.SyntaxString()) ||
- adaptiveColorUsesAnsi(theme.SyntaxNumber()) ||
- adaptiveColorUsesAnsi(theme.SyntaxType()) ||
- adaptiveColorUsesAnsi(theme.SyntaxOperator()) ||
- adaptiveColorUsesAnsi(theme.SyntaxPunctuation())
-}
diff --git a/packages/tui/internal/theme/system.go b/packages/tui/internal/theme/system.go
deleted file mode 100644
index 8dd48cfec..000000000
--- a/packages/tui/internal/theme/system.go
+++ /dev/null
@@ -1,303 +0,0 @@
-package theme
-
-import (
- "fmt"
- "image/color"
- "math"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-// SystemTheme is a dynamic theme that derives its gray scale colors
-// from the terminal's background color at runtime
-type SystemTheme struct {
- BaseTheme
- terminalBg color.Color
- terminalBgIsDark bool
-}
-
-// NewSystemTheme creates a new instance of the dynamic system theme
-func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme {
- theme := &SystemTheme{
- terminalBg: terminalBg,
- terminalBgIsDark: isDark,
- }
- theme.initializeColors()
- return theme
-}
-
-func (t *SystemTheme) Name() string {
- return "system"
-}
-
-// initializeColors sets up all theme colors
-func (t *SystemTheme) initializeColors() {
- // Generate gray scale based on terminal background
- grays := t.generateGrayScale()
-
- // Set ANSI colors for primary colors
- t.PrimaryColor = compat.AdaptiveColor{
- Dark: lipgloss.Cyan,
- Light: lipgloss.Cyan,
- }
- t.SecondaryColor = compat.AdaptiveColor{
- Dark: lipgloss.Magenta,
- Light: lipgloss.Magenta,
- }
- t.AccentColor = compat.AdaptiveColor{
- Dark: lipgloss.Cyan,
- Light: lipgloss.Cyan,
- }
-
- // Status colors using ANSI
- t.ErrorColor = compat.AdaptiveColor{
- Dark: lipgloss.Red,
- Light: lipgloss.Red,
- }
- t.WarningColor = compat.AdaptiveColor{
- Dark: lipgloss.Yellow,
- Light: lipgloss.Yellow,
- }
- t.SuccessColor = compat.AdaptiveColor{
- Dark: lipgloss.Green,
- Light: lipgloss.Green,
- }
- t.InfoColor = compat.AdaptiveColor{
- Dark: lipgloss.Cyan,
- Light: lipgloss.Cyan,
- }
-
- // Text colors
- t.TextColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- // Derive muted text color from terminal foreground
- t.TextMutedColor = t.generateMutedTextColor()
-
- // Background colors
- t.BackgroundColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.BackgroundPanelColor = grays[2]
- t.BackgroundElementColor = grays[3]
-
- // Border colors
- t.BorderSubtleColor = grays[6]
- t.BorderColor = grays[7]
- t.BorderActiveColor = grays[8]
-
- // Diff colors using ANSI colors
- t.DiffAddedColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("2"), // green
- Light: lipgloss.Color("2"),
- }
- t.DiffRemovedColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("1"), // red
- Light: lipgloss.Color("1"),
- }
- t.DiffContextColor = grays[7] // Use gray for context
- t.DiffHunkHeaderColor = grays[7]
- t.DiffHighlightAddedColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("2"), // green
- Light: lipgloss.Color("2"),
- }
- t.DiffHighlightRemovedColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("1"), // red
- Light: lipgloss.Color("1"),
- }
- // Use subtle gray backgrounds for diff
- t.DiffAddedBgColor = grays[2]
- t.DiffRemovedBgColor = grays[2]
- t.DiffContextBgColor = grays[1]
- t.DiffLineNumberColor = grays[6]
- t.DiffAddedLineNumberBgColor = grays[3]
- t.DiffRemovedLineNumberBgColor = grays[3]
-
- // Markdown colors using ANSI
- t.MarkdownTextColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.MarkdownHeadingColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.MarkdownLinkColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("4"), // blue
- Light: lipgloss.Color("4"),
- }
- t.MarkdownLinkTextColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.MarkdownCodeColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("2"), // green
- Light: lipgloss.Color("2"),
- }
- t.MarkdownBlockQuoteColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("3"), // yellow
- Light: lipgloss.Color("3"),
- }
- t.MarkdownEmphColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("3"), // yellow
- Light: lipgloss.Color("3"),
- }
- t.MarkdownStrongColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.MarkdownHorizontalRuleColor = t.BorderColor
- t.MarkdownListItemColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("4"), // blue
- Light: lipgloss.Color("4"),
- }
- t.MarkdownListEnumerationColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.MarkdownImageColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("4"), // blue
- Light: lipgloss.Color("4"),
- }
- t.MarkdownImageTextColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.MarkdownCodeBlockColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
-
- // Syntax colors
- t.SyntaxCommentColor = t.TextMutedColor // Use same as muted text
- t.SyntaxKeywordColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("5"), // magenta
- Light: lipgloss.Color("5"),
- }
- t.SyntaxFunctionColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("4"), // blue
- Light: lipgloss.Color("4"),
- }
- t.SyntaxVariableColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.SyntaxStringColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("2"), // green
- Light: lipgloss.Color("2"),
- }
- t.SyntaxNumberColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("3"), // yellow
- Light: lipgloss.Color("3"),
- }
- t.SyntaxTypeColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.SyntaxOperatorColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.SyntaxPunctuationColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
-}
-
-// generateGrayScale creates a gray scale based on the terminal background
-func (t *SystemTheme) generateGrayScale() map[int]compat.AdaptiveColor {
- grays := make(map[int]compat.AdaptiveColor)
-
- r, g, b, _ := t.terminalBg.RGBA()
- bgR := float64(r >> 8)
- bgG := float64(g >> 8)
- bgB := float64(b >> 8)
-
- luminance := 0.299*bgR + 0.587*bgG + 0.114*bgB
-
- for i := 1; i <= 12; i++ {
- var stepColor string
- factor := float64(i) / 12.0
-
- if t.terminalBgIsDark {
- if luminance < 10 {
- grayValue := int(factor * 0.4 * 255)
- stepColor = fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
- } else {
- newLum := luminance + (255-luminance)*factor*0.4
-
- ratio := newLum / luminance
- newR := math.Min(bgR*ratio, 255)
- newG := math.Min(bgG*ratio, 255)
- newB := math.Min(bgB*ratio, 255)
-
- stepColor = fmt.Sprintf("#%02x%02x%02x", int(newR), int(newG), int(newB))
- }
- } else {
- if luminance > 245 {
- grayValue := int(255 - factor*0.4*255)
- stepColor = fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
- } else {
- newLum := luminance * (1 - factor*0.4)
-
- ratio := newLum / luminance
- newR := math.Max(bgR*ratio, 0)
- newG := math.Max(bgG*ratio, 0)
- newB := math.Max(bgB*ratio, 0)
-
- stepColor = fmt.Sprintf("#%02x%02x%02x", int(newR), int(newG), int(newB))
- }
- }
-
- grays[i] = compat.AdaptiveColor{
- Dark: lipgloss.Color(stepColor),
- Light: lipgloss.Color(stepColor),
- }
- }
-
- return grays
-}
-
-// generateMutedTextColor creates a muted gray color based on the terminal background
-func (t *SystemTheme) generateMutedTextColor() compat.AdaptiveColor {
- bgR, bgG, bgB, _ := t.terminalBg.RGBA()
-
- bgRf := float64(bgR >> 8)
- bgGf := float64(bgG >> 8)
- bgBf := float64(bgB >> 8)
-
- bgLum := 0.299*bgRf + 0.587*bgGf + 0.114*bgBf
-
- var grayValue int
- if t.terminalBgIsDark {
- if bgLum < 10 {
- // Very dark/black background
- // grays[3] would be around #2e (46), so we need much lighter
- grayValue = 180 // #b4b4b4
- } else {
- // Scale up for lighter dark backgrounds
- // Ensure we're always significantly brighter than BackgroundElement
- grayValue = min(int(160+(bgLum*0.3)), 200)
- }
- } else {
- if bgLum > 245 {
- // Very light/white background
- // grays[3] would be around #f5 (245), so we need much darker
- grayValue = 75 // #4b4b4b
- } else {
- // Scale down for darker light backgrounds
- // Ensure we're always significantly darker than BackgroundElement
- grayValue = max(int(100-((255-bgLum)*0.2)), 60)
- }
- }
-
- mutedColor := fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
-
- return compat.AdaptiveColor{
- Dark: lipgloss.Color(mutedColor),
- Light: lipgloss.Color(mutedColor),
- }
-}
diff --git a/packages/tui/internal/theme/theme.go b/packages/tui/internal/theme/theme.go
deleted file mode 100644
index d5d27a1e1..000000000
--- a/packages/tui/internal/theme/theme.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-// Theme defines the interface for all UI themes in the application.
-// All colors must be defined as compat.AdaptiveColor to support
-// both light and dark terminal backgrounds.
-type Theme interface {
- Name() string
-
- // Background colors
- Background() compat.AdaptiveColor // Radix 1
- BackgroundPanel() compat.AdaptiveColor // Radix 2
- BackgroundElement() compat.AdaptiveColor // Radix 3
-
- // Border colors
- BorderSubtle() compat.AdaptiveColor // Radix 6
- Border() compat.AdaptiveColor // Radix 7
- BorderActive() compat.AdaptiveColor // Radix 8
-
- // Brand colors
- Primary() compat.AdaptiveColor // Radix 9
- Secondary() compat.AdaptiveColor
- Accent() compat.AdaptiveColor
-
- // Text colors
- TextMuted() compat.AdaptiveColor // Radix 11
- Text() compat.AdaptiveColor // Radix 12
-
- // Status colors
- Error() compat.AdaptiveColor
- Warning() compat.AdaptiveColor
- Success() compat.AdaptiveColor
- Info() compat.AdaptiveColor
-
- // Diff view colors
- DiffAdded() compat.AdaptiveColor
- DiffRemoved() compat.AdaptiveColor
- DiffContext() compat.AdaptiveColor
- DiffHunkHeader() compat.AdaptiveColor
- DiffHighlightAdded() compat.AdaptiveColor
- DiffHighlightRemoved() compat.AdaptiveColor
- DiffAddedBg() compat.AdaptiveColor
- DiffRemovedBg() compat.AdaptiveColor
- DiffContextBg() compat.AdaptiveColor
- DiffLineNumber() compat.AdaptiveColor
- DiffAddedLineNumberBg() compat.AdaptiveColor
- DiffRemovedLineNumberBg() compat.AdaptiveColor
-
- // Markdown colors
- MarkdownText() compat.AdaptiveColor
- MarkdownHeading() compat.AdaptiveColor
- MarkdownLink() compat.AdaptiveColor
- MarkdownLinkText() compat.AdaptiveColor
- MarkdownCode() compat.AdaptiveColor
- MarkdownBlockQuote() compat.AdaptiveColor
- MarkdownEmph() compat.AdaptiveColor
- MarkdownStrong() compat.AdaptiveColor
- MarkdownHorizontalRule() compat.AdaptiveColor
- MarkdownListItem() compat.AdaptiveColor
- MarkdownListEnumeration() compat.AdaptiveColor
- MarkdownImage() compat.AdaptiveColor
- MarkdownImageText() compat.AdaptiveColor
- MarkdownCodeBlock() compat.AdaptiveColor
-
- // Syntax highlighting colors
- SyntaxComment() compat.AdaptiveColor
- SyntaxKeyword() compat.AdaptiveColor
- SyntaxFunction() compat.AdaptiveColor
- SyntaxVariable() compat.AdaptiveColor
- SyntaxString() compat.AdaptiveColor
- SyntaxNumber() compat.AdaptiveColor
- SyntaxType() compat.AdaptiveColor
- SyntaxOperator() compat.AdaptiveColor
- SyntaxPunctuation() compat.AdaptiveColor
-}
-
-// BaseTheme provides a default implementation of the Theme interface
-// that can be embedded in concrete theme implementations.
-type BaseTheme struct {
- // Background colors
- BackgroundColor compat.AdaptiveColor
- BackgroundPanelColor compat.AdaptiveColor
- BackgroundElementColor compat.AdaptiveColor
-
- // Border colors
- BorderSubtleColor compat.AdaptiveColor
- BorderColor compat.AdaptiveColor
- BorderActiveColor compat.AdaptiveColor
-
- // Brand colors
- PrimaryColor compat.AdaptiveColor
- SecondaryColor compat.AdaptiveColor
- AccentColor compat.AdaptiveColor
-
- // Text colors
- TextMutedColor compat.AdaptiveColor
- TextColor compat.AdaptiveColor
-
- // Status colors
- ErrorColor compat.AdaptiveColor
- WarningColor compat.AdaptiveColor
- SuccessColor compat.AdaptiveColor
- InfoColor compat.AdaptiveColor
-
- // Diff view colors
- DiffAddedColor compat.AdaptiveColor
- DiffRemovedColor compat.AdaptiveColor
- DiffContextColor compat.AdaptiveColor
- DiffHunkHeaderColor compat.AdaptiveColor
- DiffHighlightAddedColor compat.AdaptiveColor
- DiffHighlightRemovedColor compat.AdaptiveColor
- DiffAddedBgColor compat.AdaptiveColor
- DiffRemovedBgColor compat.AdaptiveColor
- DiffContextBgColor compat.AdaptiveColor
- DiffLineNumberColor compat.AdaptiveColor
- DiffAddedLineNumberBgColor compat.AdaptiveColor
- DiffRemovedLineNumberBgColor compat.AdaptiveColor
-
- // Markdown colors
- MarkdownTextColor compat.AdaptiveColor
- MarkdownHeadingColor compat.AdaptiveColor
- MarkdownLinkColor compat.AdaptiveColor
- MarkdownLinkTextColor compat.AdaptiveColor
- MarkdownCodeColor compat.AdaptiveColor
- MarkdownBlockQuoteColor compat.AdaptiveColor
- MarkdownEmphColor compat.AdaptiveColor
- MarkdownStrongColor compat.AdaptiveColor
- MarkdownHorizontalRuleColor compat.AdaptiveColor
- MarkdownListItemColor compat.AdaptiveColor
- MarkdownListEnumerationColor compat.AdaptiveColor
- MarkdownImageColor compat.AdaptiveColor
- MarkdownImageTextColor compat.AdaptiveColor
- MarkdownCodeBlockColor compat.AdaptiveColor
-
- // Syntax highlighting colors
- SyntaxCommentColor compat.AdaptiveColor
- SyntaxKeywordColor compat.AdaptiveColor
- SyntaxFunctionColor compat.AdaptiveColor
- SyntaxVariableColor compat.AdaptiveColor
- SyntaxStringColor compat.AdaptiveColor
- SyntaxNumberColor compat.AdaptiveColor
- SyntaxTypeColor compat.AdaptiveColor
- SyntaxOperatorColor compat.AdaptiveColor
- SyntaxPunctuationColor compat.AdaptiveColor
-}
-
-// Implement the Theme interface for BaseTheme
-func (t *BaseTheme) Primary() compat.AdaptiveColor { return t.PrimaryColor }
-func (t *BaseTheme) Secondary() compat.AdaptiveColor { return t.SecondaryColor }
-func (t *BaseTheme) Accent() compat.AdaptiveColor { return t.AccentColor }
-
-func (t *BaseTheme) Error() compat.AdaptiveColor { return t.ErrorColor }
-func (t *BaseTheme) Warning() compat.AdaptiveColor { return t.WarningColor }
-func (t *BaseTheme) Success() compat.AdaptiveColor { return t.SuccessColor }
-func (t *BaseTheme) Info() compat.AdaptiveColor { return t.InfoColor }
-
-func (t *BaseTheme) Text() compat.AdaptiveColor { return t.TextColor }
-func (t *BaseTheme) TextMuted() compat.AdaptiveColor { return t.TextMutedColor }
-
-func (t *BaseTheme) Background() compat.AdaptiveColor { return t.BackgroundColor }
-func (t *BaseTheme) BackgroundPanel() compat.AdaptiveColor { return t.BackgroundPanelColor }
-func (t *BaseTheme) BackgroundElement() compat.AdaptiveColor { return t.BackgroundElementColor }
-
-func (t *BaseTheme) Border() compat.AdaptiveColor { return t.BorderColor }
-func (t *BaseTheme) BorderActive() compat.AdaptiveColor { return t.BorderActiveColor }
-func (t *BaseTheme) BorderSubtle() compat.AdaptiveColor { return t.BorderSubtleColor }
-
-func (t *BaseTheme) DiffAdded() compat.AdaptiveColor { return t.DiffAddedColor }
-func (t *BaseTheme) DiffRemoved() compat.AdaptiveColor { return t.DiffRemovedColor }
-func (t *BaseTheme) DiffContext() compat.AdaptiveColor { return t.DiffContextColor }
-func (t *BaseTheme) DiffHunkHeader() compat.AdaptiveColor { return t.DiffHunkHeaderColor }
-func (t *BaseTheme) DiffHighlightAdded() compat.AdaptiveColor { return t.DiffHighlightAddedColor }
-func (t *BaseTheme) DiffHighlightRemoved() compat.AdaptiveColor { return t.DiffHighlightRemovedColor }
-func (t *BaseTheme) DiffAddedBg() compat.AdaptiveColor { return t.DiffAddedBgColor }
-func (t *BaseTheme) DiffRemovedBg() compat.AdaptiveColor { return t.DiffRemovedBgColor }
-func (t *BaseTheme) DiffContextBg() compat.AdaptiveColor { return t.DiffContextBgColor }
-func (t *BaseTheme) DiffLineNumber() compat.AdaptiveColor { return t.DiffLineNumberColor }
-func (t *BaseTheme) DiffAddedLineNumberBg() compat.AdaptiveColor {
- return t.DiffAddedLineNumberBgColor
-}
-func (t *BaseTheme) DiffRemovedLineNumberBg() compat.AdaptiveColor {
- return t.DiffRemovedLineNumberBgColor
-}
-
-func (t *BaseTheme) MarkdownText() compat.AdaptiveColor { return t.MarkdownTextColor }
-func (t *BaseTheme) MarkdownHeading() compat.AdaptiveColor { return t.MarkdownHeadingColor }
-func (t *BaseTheme) MarkdownLink() compat.AdaptiveColor { return t.MarkdownLinkColor }
-func (t *BaseTheme) MarkdownLinkText() compat.AdaptiveColor { return t.MarkdownLinkTextColor }
-func (t *BaseTheme) MarkdownCode() compat.AdaptiveColor { return t.MarkdownCodeColor }
-func (t *BaseTheme) MarkdownBlockQuote() compat.AdaptiveColor { return t.MarkdownBlockQuoteColor }
-func (t *BaseTheme) MarkdownEmph() compat.AdaptiveColor { return t.MarkdownEmphColor }
-func (t *BaseTheme) MarkdownStrong() compat.AdaptiveColor { return t.MarkdownStrongColor }
-func (t *BaseTheme) MarkdownHorizontalRule() compat.AdaptiveColor {
- return t.MarkdownHorizontalRuleColor
-}
-func (t *BaseTheme) MarkdownListItem() compat.AdaptiveColor { return t.MarkdownListItemColor }
-func (t *BaseTheme) MarkdownListEnumeration() compat.AdaptiveColor {
- return t.MarkdownListEnumerationColor
-}
-func (t *BaseTheme) MarkdownImage() compat.AdaptiveColor { return t.MarkdownImageColor }
-func (t *BaseTheme) MarkdownImageText() compat.AdaptiveColor { return t.MarkdownImageTextColor }
-func (t *BaseTheme) MarkdownCodeBlock() compat.AdaptiveColor { return t.MarkdownCodeBlockColor }
-
-func (t *BaseTheme) SyntaxComment() compat.AdaptiveColor { return t.SyntaxCommentColor }
-func (t *BaseTheme) SyntaxKeyword() compat.AdaptiveColor { return t.SyntaxKeywordColor }
-func (t *BaseTheme) SyntaxFunction() compat.AdaptiveColor { return t.SyntaxFunctionColor }
-func (t *BaseTheme) SyntaxVariable() compat.AdaptiveColor { return t.SyntaxVariableColor }
-func (t *BaseTheme) SyntaxString() compat.AdaptiveColor { return t.SyntaxStringColor }
-func (t *BaseTheme) SyntaxNumber() compat.AdaptiveColor { return t.SyntaxNumberColor }
-func (t *BaseTheme) SyntaxType() compat.AdaptiveColor { return t.SyntaxTypeColor }
-func (t *BaseTheme) SyntaxOperator() compat.AdaptiveColor { return t.SyntaxOperatorColor }
-func (t *BaseTheme) SyntaxPunctuation() compat.AdaptiveColor { return t.SyntaxPunctuationColor }
diff --git a/packages/tui/internal/theme/themes/aura.json b/packages/tui/internal/theme/themes/aura.json
deleted file mode 100644
index e7798d520..000000000
--- a/packages/tui/internal/theme/themes/aura.json
+++ /dev/null
@@ -1,69 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkBg": "#0f0f0f",
- "darkBgPanel": "#15141b",
- "darkBorder": "#2d2d2d",
- "darkFgMuted": "#6d6d6d",
- "darkFg": "#edecee",
- "purple": "#a277ff",
- "pink": "#f694ff",
- "blue": "#82e2ff",
- "red": "#ff6767",
- "orange": "#ffca85",
- "cyan": "#61ffca",
- "green": "#9dff65"
- },
- "theme": {
- "primary": "purple",
- "secondary": "pink",
- "accent": "purple",
- "error": "red",
- "warning": "orange",
- "success": "cyan",
- "info": "purple",
- "text": "darkFg",
- "textMuted": "darkFgMuted",
- "background": "darkBg",
- "backgroundPanel": "darkBgPanel",
- "backgroundElement": "darkBgPanel",
- "border": "darkBorder",
- "borderActive": "darkFgMuted",
- "borderSubtle": "darkBorder",
- "diffAdded": "cyan",
- "diffRemoved": "red",
- "diffContext": "darkFgMuted",
- "diffHunkHeader": "darkFgMuted",
- "diffHighlightAdded": "cyan",
- "diffHighlightRemoved": "red",
- "diffAddedBg": "#354933",
- "diffRemovedBg": "#3f191a",
- "diffContextBg": "darkBgPanel",
- "diffLineNumber": "darkBorder",
- "diffAddedLineNumberBg": "#162620",
- "diffRemovedLineNumberBg": "#26161a",
- "markdownText": "darkFg",
- "markdownHeading": "purple",
- "markdownLink": "pink",
- "markdownLinkText": "purple",
- "markdownCode": "cyan",
- "markdownBlockQuote": "darkFgMuted",
- "markdownEmph": "orange",
- "markdownStrong": "purple",
- "markdownHorizontalRule": "darkFgMuted",
- "markdownListItem": "purple",
- "markdownListEnumeration": "purple",
- "markdownImage": "pink",
- "markdownImageText": "purple",
- "markdownCodeBlock": "darkFg",
- "syntaxComment": "darkFgMuted",
- "syntaxKeyword": "pink",
- "syntaxFunction": "purple",
- "syntaxVariable": "purple",
- "syntaxString": "cyan",
- "syntaxNumber": "green",
- "syntaxType": "purple",
- "syntaxOperator": "pink",
- "syntaxPunctuation": "darkFg"
- }
-}
diff --git a/packages/tui/internal/theme/themes/ayu.json b/packages/tui/internal/theme/themes/ayu.json
deleted file mode 100644
index a42fce4c4..000000000
--- a/packages/tui/internal/theme/themes/ayu.json
+++ /dev/null
@@ -1,80 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkBg": "#0B0E14",
- "darkBgAlt": "#0D1017",
- "darkLine": "#11151C",
- "darkPanel": "#0F131A",
- "darkFg": "#BFBDB6",
- "darkFgMuted": "#565B66",
- "darkGutter": "#6C7380",
- "darkTag": "#39BAE6",
- "darkFunc": "#FFB454",
- "darkEntity": "#59C2FF",
- "darkString": "#AAD94C",
- "darkRegexp": "#95E6CB",
- "darkMarkup": "#F07178",
- "darkKeyword": "#FF8F40",
- "darkSpecial": "#E6B673",
- "darkComment": "#ACB6BF",
- "darkConstant": "#D2A6FF",
- "darkOperator": "#F29668",
- "darkAdded": "#7FD962",
- "darkRemoved": "#F26D78",
- "darkAccent": "#E6B450",
- "darkError": "#D95757",
- "darkIndentActive": "#6C7380"
- },
- "theme": {
- "primary": "darkEntity",
- "secondary": "darkConstant",
- "accent": "darkAccent",
- "error": "darkError",
- "warning": "darkSpecial",
- "success": "darkAdded",
- "info": "darkTag",
- "text": "darkFg",
- "textMuted": "darkFgMuted",
- "background": "darkBg",
- "backgroundPanel": "darkPanel",
- "backgroundElement": "darkBgAlt",
- "border": "darkGutter",
- "borderActive": "darkIndentActive",
- "borderSubtle": "darkLine",
- "diffAdded": "darkAdded",
- "diffRemoved": "darkRemoved",
- "diffContext": "darkComment",
- "diffHunkHeader": "darkComment",
- "diffHighlightAdded": "darkString",
- "diffHighlightRemoved": "darkMarkup",
- "diffAddedBg": "#20303b",
- "diffRemovedBg": "#37222c",
- "diffContextBg": "darkPanel",
- "diffLineNumber": "darkGutter",
- "diffAddedLineNumberBg": "#1b2b34",
- "diffRemovedLineNumberBg": "#2d1f26",
- "markdownText": "darkFg",
- "markdownHeading": "darkConstant",
- "markdownLink": "darkEntity",
- "markdownLinkText": "darkTag",
- "markdownCode": "darkString",
- "markdownBlockQuote": "darkSpecial",
- "markdownEmph": "darkSpecial",
- "markdownStrong": "darkFunc",
- "markdownHorizontalRule": "darkFgMuted",
- "markdownListItem": "darkEntity",
- "markdownListEnumeration": "darkTag",
- "markdownImage": "darkEntity",
- "markdownImageText": "darkTag",
- "markdownCodeBlock": "darkFg",
- "syntaxComment": "darkComment",
- "syntaxKeyword": "darkKeyword",
- "syntaxFunction": "darkFunc",
- "syntaxVariable": "darkEntity",
- "syntaxString": "darkString",
- "syntaxNumber": "darkConstant",
- "syntaxType": "darkSpecial",
- "syntaxOperator": "darkOperator",
- "syntaxPunctuation": "darkFg"
- }
-}
diff --git a/packages/tui/internal/theme/themes/catppuccin.json b/packages/tui/internal/theme/themes/catppuccin.json
deleted file mode 100644
index d0fa6a11d..000000000
--- a/packages/tui/internal/theme/themes/catppuccin.json
+++ /dev/null
@@ -1,112 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "lightRosewater": "#dc8a78",
- "lightFlamingo": "#dd7878",
- "lightPink": "#ea76cb",
- "lightMauve": "#8839ef",
- "lightRed": "#d20f39",
- "lightMaroon": "#e64553",
- "lightPeach": "#fe640b",
- "lightYellow": "#df8e1d",
- "lightGreen": "#40a02b",
- "lightTeal": "#179299",
- "lightSky": "#04a5e5",
- "lightSapphire": "#209fb5",
- "lightBlue": "#1e66f5",
- "lightLavender": "#7287fd",
- "lightText": "#4c4f69",
- "lightSubtext1": "#5c5f77",
- "lightSubtext0": "#6c6f85",
- "lightOverlay2": "#7c7f93",
- "lightOverlay1": "#8c8fa1",
- "lightOverlay0": "#9ca0b0",
- "lightSurface2": "#acb0be",
- "lightSurface1": "#bcc0cc",
- "lightSurface0": "#ccd0da",
- "lightBase": "#eff1f5",
- "lightMantle": "#e6e9ef",
- "lightCrust": "#dce0e8",
- "darkRosewater": "#f5e0dc",
- "darkFlamingo": "#f2cdcd",
- "darkPink": "#f5c2e7",
- "darkMauve": "#cba6f7",
- "darkRed": "#f38ba8",
- "darkMaroon": "#eba0ac",
- "darkPeach": "#fab387",
- "darkYellow": "#f9e2af",
- "darkGreen": "#a6e3a1",
- "darkTeal": "#94e2d5",
- "darkSky": "#89dceb",
- "darkSapphire": "#74c7ec",
- "darkBlue": "#89b4fa",
- "darkLavender": "#b4befe",
- "darkText": "#cdd6f4",
- "darkSubtext1": "#bac2de",
- "darkSubtext0": "#a6adc8",
- "darkOverlay2": "#9399b2",
- "darkOverlay1": "#7f849c",
- "darkOverlay0": "#6c7086",
- "darkSurface2": "#585b70",
- "darkSurface1": "#45475a",
- "darkSurface0": "#313244",
- "darkBase": "#1e1e2e",
- "darkMantle": "#181825",
- "darkCrust": "#11111b"
- },
- "theme": {
- "primary": { "dark": "darkBlue", "light": "lightBlue" },
- "secondary": { "dark": "darkMauve", "light": "lightMauve" },
- "accent": { "dark": "darkPink", "light": "lightPink" },
- "error": { "dark": "darkRed", "light": "lightRed" },
- "warning": { "dark": "darkYellow", "light": "lightYellow" },
- "success": { "dark": "darkGreen", "light": "lightGreen" },
- "info": { "dark": "darkTeal", "light": "lightTeal" },
- "text": { "dark": "darkText", "light": "lightText" },
- "textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" },
- "background": { "dark": "darkBase", "light": "lightBase" },
- "backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
- "backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },
- "border": { "dark": "darkSurface0", "light": "lightSurface0" },
- "borderActive": { "dark": "darkSurface1", "light": "lightSurface1" },
- "borderSubtle": { "dark": "darkSurface2", "light": "lightSurface2" },
- "diffAdded": { "dark": "darkGreen", "light": "lightGreen" },
- "diffRemoved": { "dark": "darkRed", "light": "lightRed" },
- "diffContext": { "dark": "darkOverlay2", "light": "lightOverlay2" },
- "diffHunkHeader": { "dark": "darkPeach", "light": "lightPeach" },
- "diffHighlightAdded": { "dark": "darkGreen", "light": "lightGreen" },
- "diffHighlightRemoved": { "dark": "darkRed", "light": "lightRed" },
- "diffAddedBg": { "dark": "#24312b", "light": "#d6f0d9" },
- "diffRemovedBg": { "dark": "#3c2a32", "light": "#f6dfe2" },
- "diffContextBg": { "dark": "darkMantle", "light": "lightMantle" },
- "diffLineNumber": { "dark": "darkSurface1", "light": "lightSurface1" },
- "diffAddedLineNumberBg": { "dark": "#1e2a25", "light": "#c9e3cb" },
- "diffRemovedLineNumberBg": { "dark": "#32232a", "light": "#e9d3d6" },
- "markdownText": { "dark": "darkText", "light": "lightText" },
- "markdownHeading": { "dark": "darkMauve", "light": "lightMauve" },
- "markdownLink": { "dark": "darkBlue", "light": "lightBlue" },
- "markdownLinkText": { "dark": "darkSky", "light": "lightSky" },
- "markdownCode": { "dark": "darkGreen", "light": "lightGreen" },
- "markdownBlockQuote": { "dark": "darkYellow", "light": "lightYellow" },
- "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" },
- "markdownStrong": { "dark": "darkPeach", "light": "lightPeach" },
- "markdownHorizontalRule": {
- "dark": "darkSubtext0",
- "light": "lightSubtext0"
- },
- "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" },
- "markdownListEnumeration": { "dark": "darkSky", "light": "lightSky" },
- "markdownImage": { "dark": "darkBlue", "light": "lightBlue" },
- "markdownImageText": { "dark": "darkSky", "light": "lightSky" },
- "markdownCodeBlock": { "dark": "darkText", "light": "lightText" },
- "syntaxComment": { "dark": "darkOverlay2", "light": "lightOverlay2" },
- "syntaxKeyword": { "dark": "darkMauve", "light": "lightMauve" },
- "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" },
- "syntaxVariable": { "dark": "darkRed", "light": "lightRed" },
- "syntaxString": { "dark": "darkGreen", "light": "lightGreen" },
- "syntaxNumber": { "dark": "darkPeach", "light": "lightPeach" },
- "syntaxType": { "dark": "darkYellow", "light": "lightYellow" },
- "syntaxOperator": { "dark": "darkSky", "light": "lightSky" },
- "syntaxPunctuation": { "dark": "darkText", "light": "lightText" }
- }
-}
diff --git a/packages/tui/internal/theme/themes/cobalt2.json b/packages/tui/internal/theme/themes/cobalt2.json
deleted file mode 100644
index 2967eae58..000000000
--- a/packages/tui/internal/theme/themes/cobalt2.json
+++ /dev/null
@@ -1,228 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "background": "#193549",
- "backgroundAlt": "#122738",
- "backgroundPanel": "#1f4662",
- "foreground": "#ffffff",
- "foregroundMuted": "#adb7c9",
- "yellow": "#ffc600",
- "yellowBright": "#ffe14c",
- "orange": "#ff9d00",
- "orangeBright": "#ffb454",
- "mint": "#2affdf",
- "mintBright": "#7efff5",
- "blue": "#0088ff",
- "blueBright": "#5cb7ff",
- "pink": "#ff628c",
- "pinkBright": "#ff86a5",
- "green": "#9eff80",
- "greenBright": "#b9ff9f",
- "purple": "#9a5feb",
- "purpleBright": "#b88cfd",
- "red": "#ff0088",
- "redBright": "#ff5fb3"
- },
- "theme": {
- "primary": {
- "dark": "blue",
- "light": "#0066cc"
- },
- "secondary": {
- "dark": "purple",
- "light": "#7c4dff"
- },
- "accent": {
- "dark": "mint",
- "light": "#00acc1"
- },
- "error": {
- "dark": "red",
- "light": "#e91e63"
- },
- "warning": {
- "dark": "yellow",
- "light": "#ff9800"
- },
- "success": {
- "dark": "green",
- "light": "#4caf50"
- },
- "info": {
- "dark": "orange",
- "light": "#ff5722"
- },
- "text": {
- "dark": "foreground",
- "light": "#193549"
- },
- "textMuted": {
- "dark": "foregroundMuted",
- "light": "#5c6b7d"
- },
- "background": {
- "dark": "#193549",
- "light": "#ffffff"
- },
- "backgroundPanel": {
- "dark": "#122738",
- "light": "#f5f7fa"
- },
- "backgroundElement": {
- "dark": "#1f4662",
- "light": "#e8ecf1"
- },
- "border": {
- "dark": "#1f4662",
- "light": "#d3dae3"
- },
- "borderActive": {
- "dark": "blue",
- "light": "#0066cc"
- },
- "borderSubtle": {
- "dark": "#0e1e2e",
- "light": "#e8ecf1"
- },
- "diffAdded": {
- "dark": "green",
- "light": "#4caf50"
- },
- "diffRemoved": {
- "dark": "red",
- "light": "#e91e63"
- },
- "diffContext": {
- "dark": "foregroundMuted",
- "light": "#5c6b7d"
- },
- "diffHunkHeader": {
- "dark": "mint",
- "light": "#00acc1"
- },
- "diffHighlightAdded": {
- "dark": "greenBright",
- "light": "#4caf50"
- },
- "diffHighlightRemoved": {
- "dark": "redBright",
- "light": "#e91e63"
- },
- "diffAddedBg": {
- "dark": "#1a3a2a",
- "light": "#e8f5e9"
- },
- "diffRemovedBg": {
- "dark": "#3a1a2a",
- "light": "#ffebee"
- },
- "diffContextBg": {
- "dark": "#122738",
- "light": "#f5f7fa"
- },
- "diffLineNumber": {
- "dark": "#2d5a7b",
- "light": "#b0bec5"
- },
- "diffAddedLineNumberBg": {
- "dark": "#1a3a2a",
- "light": "#e8f5e9"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#3a1a2a",
- "light": "#ffebee"
- },
- "markdownText": {
- "dark": "foreground",
- "light": "#193549"
- },
- "markdownHeading": {
- "dark": "yellow",
- "light": "#ff9800"
- },
- "markdownLink": {
- "dark": "blue",
- "light": "#0066cc"
- },
- "markdownLinkText": {
- "dark": "mint",
- "light": "#00acc1"
- },
- "markdownCode": {
- "dark": "green",
- "light": "#4caf50"
- },
- "markdownBlockQuote": {
- "dark": "foregroundMuted",
- "light": "#5c6b7d"
- },
- "markdownEmph": {
- "dark": "orange",
- "light": "#ff5722"
- },
- "markdownStrong": {
- "dark": "pink",
- "light": "#e91e63"
- },
- "markdownHorizontalRule": {
- "dark": "#2d5a7b",
- "light": "#d3dae3"
- },
- "markdownListItem": {
- "dark": "blue",
- "light": "#0066cc"
- },
- "markdownListEnumeration": {
- "dark": "mint",
- "light": "#00acc1"
- },
- "markdownImage": {
- "dark": "blue",
- "light": "#0066cc"
- },
- "markdownImageText": {
- "dark": "mint",
- "light": "#00acc1"
- },
- "markdownCodeBlock": {
- "dark": "foreground",
- "light": "#193549"
- },
- "syntaxComment": {
- "dark": "#0088ff",
- "light": "#5c6b7d"
- },
- "syntaxKeyword": {
- "dark": "orange",
- "light": "#ff5722"
- },
- "syntaxFunction": {
- "dark": "yellow",
- "light": "#ff9800"
- },
- "syntaxVariable": {
- "dark": "foreground",
- "light": "#193549"
- },
- "syntaxString": {
- "dark": "green",
- "light": "#4caf50"
- },
- "syntaxNumber": {
- "dark": "pink",
- "light": "#e91e63"
- },
- "syntaxType": {
- "dark": "mint",
- "light": "#00acc1"
- },
- "syntaxOperator": {
- "dark": "orange",
- "light": "#ff5722"
- },
- "syntaxPunctuation": {
- "dark": "foreground",
- "light": "#193549"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/dracula.json b/packages/tui/internal/theme/themes/dracula.json
deleted file mode 100644
index c837a0b58..000000000
--- a/packages/tui/internal/theme/themes/dracula.json
+++ /dev/null
@@ -1,219 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "background": "#282a36",
- "currentLine": "#44475a",
- "selection": "#44475a",
- "foreground": "#f8f8f2",
- "comment": "#6272a4",
- "cyan": "#8be9fd",
- "green": "#50fa7b",
- "orange": "#ffb86c",
- "pink": "#ff79c6",
- "purple": "#bd93f9",
- "red": "#ff5555",
- "yellow": "#f1fa8c"
- },
- "theme": {
- "primary": {
- "dark": "purple",
- "light": "purple"
- },
- "secondary": {
- "dark": "pink",
- "light": "pink"
- },
- "accent": {
- "dark": "cyan",
- "light": "cyan"
- },
- "error": {
- "dark": "red",
- "light": "red"
- },
- "warning": {
- "dark": "yellow",
- "light": "yellow"
- },
- "success": {
- "dark": "green",
- "light": "green"
- },
- "info": {
- "dark": "orange",
- "light": "orange"
- },
- "text": {
- "dark": "foreground",
- "light": "#282a36"
- },
- "textMuted": {
- "dark": "comment",
- "light": "#6272a4"
- },
- "background": {
- "dark": "#282a36",
- "light": "#f8f8f2"
- },
- "backgroundPanel": {
- "dark": "#21222c",
- "light": "#e8e8e2"
- },
- "backgroundElement": {
- "dark": "currentLine",
- "light": "#d8d8d2"
- },
- "border": {
- "dark": "currentLine",
- "light": "#c8c8c2"
- },
- "borderActive": {
- "dark": "purple",
- "light": "purple"
- },
- "borderSubtle": {
- "dark": "#191a21",
- "light": "#e0e0e0"
- },
- "diffAdded": {
- "dark": "green",
- "light": "green"
- },
- "diffRemoved": {
- "dark": "red",
- "light": "red"
- },
- "diffContext": {
- "dark": "comment",
- "light": "#6272a4"
- },
- "diffHunkHeader": {
- "dark": "comment",
- "light": "#6272a4"
- },
- "diffHighlightAdded": {
- "dark": "green",
- "light": "green"
- },
- "diffHighlightRemoved": {
- "dark": "red",
- "light": "red"
- },
- "diffAddedBg": {
- "dark": "#1a3a1a",
- "light": "#e0ffe0"
- },
- "diffRemovedBg": {
- "dark": "#3a1a1a",
- "light": "#ffe0e0"
- },
- "diffContextBg": {
- "dark": "#21222c",
- "light": "#e8e8e2"
- },
- "diffLineNumber": {
- "dark": "currentLine",
- "light": "#c8c8c2"
- },
- "diffAddedLineNumberBg": {
- "dark": "#1a3a1a",
- "light": "#e0ffe0"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#3a1a1a",
- "light": "#ffe0e0"
- },
- "markdownText": {
- "dark": "foreground",
- "light": "#282a36"
- },
- "markdownHeading": {
- "dark": "purple",
- "light": "purple"
- },
- "markdownLink": {
- "dark": "cyan",
- "light": "cyan"
- },
- "markdownLinkText": {
- "dark": "pink",
- "light": "pink"
- },
- "markdownCode": {
- "dark": "green",
- "light": "green"
- },
- "markdownBlockQuote": {
- "dark": "comment",
- "light": "#6272a4"
- },
- "markdownEmph": {
- "dark": "yellow",
- "light": "yellow"
- },
- "markdownStrong": {
- "dark": "orange",
- "light": "orange"
- },
- "markdownHorizontalRule": {
- "dark": "comment",
- "light": "#6272a4"
- },
- "markdownListItem": {
- "dark": "purple",
- "light": "purple"
- },
- "markdownListEnumeration": {
- "dark": "cyan",
- "light": "cyan"
- },
- "markdownImage": {
- "dark": "cyan",
- "light": "cyan"
- },
- "markdownImageText": {
- "dark": "pink",
- "light": "pink"
- },
- "markdownCodeBlock": {
- "dark": "foreground",
- "light": "#282a36"
- },
- "syntaxComment": {
- "dark": "comment",
- "light": "#6272a4"
- },
- "syntaxKeyword": {
- "dark": "pink",
- "light": "pink"
- },
- "syntaxFunction": {
- "dark": "green",
- "light": "green"
- },
- "syntaxVariable": {
- "dark": "foreground",
- "light": "#282a36"
- },
- "syntaxString": {
- "dark": "yellow",
- "light": "yellow"
- },
- "syntaxNumber": {
- "dark": "purple",
- "light": "purple"
- },
- "syntaxType": {
- "dark": "cyan",
- "light": "cyan"
- },
- "syntaxOperator": {
- "dark": "pink",
- "light": "pink"
- },
- "syntaxPunctuation": {
- "dark": "foreground",
- "light": "#282a36"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/everforest.json b/packages/tui/internal/theme/themes/everforest.json
deleted file mode 100644
index 62dfb31ba..000000000
--- a/packages/tui/internal/theme/themes/everforest.json
+++ /dev/null
@@ -1,241 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkStep1": "#2d353b",
- "darkStep2": "#333c43",
- "darkStep3": "#343f44",
- "darkStep4": "#3d484d",
- "darkStep5": "#475258",
- "darkStep6": "#7a8478",
- "darkStep7": "#859289",
- "darkStep8": "#9da9a0",
- "darkStep9": "#a7c080",
- "darkStep10": "#83c092",
- "darkStep11": "#7a8478",
- "darkStep12": "#d3c6aa",
- "darkRed": "#e67e80",
- "darkOrange": "#e69875",
- "darkGreen": "#a7c080",
- "darkCyan": "#83c092",
- "darkYellow": "#dbbc7f",
- "lightStep1": "#fdf6e3",
- "lightStep2": "#efebd4",
- "lightStep3": "#f4f0d9",
- "lightStep4": "#efebd4",
- "lightStep5": "#e6e2cc",
- "lightStep6": "#a6b0a0",
- "lightStep7": "#939f91",
- "lightStep8": "#829181",
- "lightStep9": "#8da101",
- "lightStep10": "#35a77c",
- "lightStep11": "#a6b0a0",
- "lightStep12": "#5c6a72",
- "lightRed": "#f85552",
- "lightOrange": "#f57d26",
- "lightGreen": "#8da101",
- "lightCyan": "#35a77c",
- "lightYellow": "#dfa000"
- },
- "theme": {
- "primary": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "secondary": {
- "dark": "#7fbbb3",
- "light": "#3a94c5"
- },
- "accent": {
- "dark": "#d699b6",
- "light": "#df69ba"
- },
- "error": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "warning": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "success": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "info": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "text": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "textMuted": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "background": {
- "dark": "darkStep1",
- "light": "lightStep1"
- },
- "backgroundPanel": {
- "dark": "darkStep2",
- "light": "lightStep2"
- },
- "backgroundElement": {
- "dark": "darkStep3",
- "light": "lightStep3"
- },
- "border": {
- "dark": "darkStep7",
- "light": "lightStep7"
- },
- "borderActive": {
- "dark": "darkStep8",
- "light": "lightStep8"
- },
- "borderSubtle": {
- "dark": "darkStep6",
- "light": "lightStep6"
- },
- "diffAdded": {
- "dark": "#4fd6be",
- "light": "#1e725c"
- },
- "diffRemoved": {
- "dark": "#c53b53",
- "light": "#c53b53"
- },
- "diffContext": {
- "dark": "#828bb8",
- "light": "#7086b5"
- },
- "diffHunkHeader": {
- "dark": "#828bb8",
- "light": "#7086b5"
- },
- "diffHighlightAdded": {
- "dark": "#b8db87",
- "light": "#4db380"
- },
- "diffHighlightRemoved": {
- "dark": "#e26a75",
- "light": "#f52a65"
- },
- "diffAddedBg": {
- "dark": "#20303b",
- "light": "#d5e5d5"
- },
- "diffRemovedBg": {
- "dark": "#37222c",
- "light": "#f7d8db"
- },
- "diffContextBg": {
- "dark": "darkStep2",
- "light": "lightStep2"
- },
- "diffLineNumber": {
- "dark": "darkStep3",
- "light": "lightStep3"
- },
- "diffAddedLineNumberBg": {
- "dark": "#1b2b34",
- "light": "#c5d5c5"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#2d1f26",
- "light": "#e7c8cb"
- },
- "markdownText": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "markdownHeading": {
- "dark": "#d699b6",
- "light": "#df69ba"
- },
- "markdownLink": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownLinkText": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownCode": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "markdownBlockQuote": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "markdownEmph": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "markdownStrong": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "markdownHorizontalRule": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "markdownListItem": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownListEnumeration": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownImage": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownImageText": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownCodeBlock": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "syntaxComment": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "syntaxKeyword": {
- "dark": "#d699b6",
- "light": "#df69ba"
- },
- "syntaxFunction": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "syntaxVariable": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "syntaxString": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "syntaxNumber": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "syntaxType": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "syntaxOperator": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "syntaxPunctuation": {
- "dark": "darkStep12",
- "light": "lightStep12"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/github.json b/packages/tui/internal/theme/themes/github.json
deleted file mode 100644
index 99a80879e..000000000
--- a/packages/tui/internal/theme/themes/github.json
+++ /dev/null
@@ -1,233 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkBg": "#0d1117",
- "darkBgAlt": "#010409",
- "darkBgPanel": "#161b22",
- "darkFg": "#c9d1d9",
- "darkFgMuted": "#8b949e",
- "darkBlue": "#58a6ff",
- "darkGreen": "#3fb950",
- "darkRed": "#f85149",
- "darkOrange": "#d29922",
- "darkPurple": "#bc8cff",
- "darkPink": "#ff7b72",
- "darkYellow": "#e3b341",
- "darkCyan": "#39c5cf",
- "lightBg": "#ffffff",
- "lightBgAlt": "#f6f8fa",
- "lightBgPanel": "#f0f3f6",
- "lightFg": "#24292f",
- "lightFgMuted": "#57606a",
- "lightBlue": "#0969da",
- "lightGreen": "#1a7f37",
- "lightRed": "#cf222e",
- "lightOrange": "#bc4c00",
- "lightPurple": "#8250df",
- "lightPink": "#bf3989",
- "lightYellow": "#9a6700",
- "lightCyan": "#1b7c83"
- },
- "theme": {
- "primary": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "secondary": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "accent": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "error": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "warning": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "success": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "info": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "text": {
- "dark": "darkFg",
- "light": "lightFg"
- },
- "textMuted": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "background": {
- "dark": "darkBg",
- "light": "lightBg"
- },
- "backgroundPanel": {
- "dark": "darkBgAlt",
- "light": "lightBgAlt"
- },
- "backgroundElement": {
- "dark": "darkBgPanel",
- "light": "lightBgPanel"
- },
- "border": {
- "dark": "#30363d",
- "light": "#d0d7de"
- },
- "borderActive": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "borderSubtle": {
- "dark": "#21262d",
- "light": "#d8dee4"
- },
- "diffAdded": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "diffRemoved": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "diffContext": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "diffHunkHeader": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "diffHighlightAdded": {
- "dark": "#3fb950",
- "light": "#1a7f37"
- },
- "diffHighlightRemoved": {
- "dark": "#f85149",
- "light": "#cf222e"
- },
- "diffAddedBg": {
- "dark": "#033a16",
- "light": "#dafbe1"
- },
- "diffRemovedBg": {
- "dark": "#67060c",
- "light": "#ffebe9"
- },
- "diffContextBg": {
- "dark": "darkBgAlt",
- "light": "lightBgAlt"
- },
- "diffLineNumber": {
- "dark": "#484f58",
- "light": "#afb8c1"
- },
- "diffAddedLineNumberBg": {
- "dark": "#033a16",
- "light": "#dafbe1"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#67060c",
- "light": "#ffebe9"
- },
- "markdownText": {
- "dark": "darkFg",
- "light": "lightFg"
- },
- "markdownHeading": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "markdownLink": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "markdownLinkText": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownCode": {
- "dark": "darkPink",
- "light": "lightPink"
- },
- "markdownBlockQuote": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "markdownEmph": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "markdownStrong": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "markdownHorizontalRule": {
- "dark": "#30363d",
- "light": "#d0d7de"
- },
- "markdownListItem": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "markdownListEnumeration": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownImage": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "markdownImageText": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownCodeBlock": {
- "dark": "darkFg",
- "light": "lightFg"
- },
- "syntaxComment": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "syntaxKeyword": {
- "dark": "darkPink",
- "light": "lightRed"
- },
- "syntaxFunction": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "syntaxVariable": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "syntaxString": {
- "dark": "darkCyan",
- "light": "lightBlue"
- },
- "syntaxNumber": {
- "dark": "darkBlue",
- "light": "lightCyan"
- },
- "syntaxType": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "syntaxOperator": {
- "dark": "darkPink",
- "light": "lightRed"
- },
- "syntaxPunctuation": {
- "dark": "darkFg",
- "light": "lightFg"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/gruvbox.json b/packages/tui/internal/theme/themes/gruvbox.json
deleted file mode 100644
index c3101b565..000000000
--- a/packages/tui/internal/theme/themes/gruvbox.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkBg0": "#282828",
- "darkBg1": "#3c3836",
- "darkBg2": "#504945",
- "darkBg3": "#665c54",
- "darkFg0": "#fbf1c7",
- "darkFg1": "#ebdbb2",
- "darkGray": "#928374",
- "darkRed": "#cc241d",
- "darkGreen": "#98971a",
- "darkYellow": "#d79921",
- "darkBlue": "#458588",
- "darkPurple": "#b16286",
- "darkAqua": "#689d6a",
- "darkOrange": "#d65d0e",
- "darkRedBright": "#fb4934",
- "darkGreenBright": "#b8bb26",
- "darkYellowBright": "#fabd2f",
- "darkBlueBright": "#83a598",
- "darkPurpleBright": "#d3869b",
- "darkAquaBright": "#8ec07c",
- "darkOrangeBright": "#fe8019",
- "lightBg0": "#fbf1c7",
- "lightBg1": "#ebdbb2",
- "lightBg2": "#d5c4a1",
- "lightBg3": "#bdae93",
- "lightFg0": "#282828",
- "lightFg1": "#3c3836",
- "lightGray": "#7c6f64",
- "lightRed": "#9d0006",
- "lightGreen": "#79740e",
- "lightYellow": "#b57614",
- "lightBlue": "#076678",
- "lightPurple": "#8f3f71",
- "lightAqua": "#427b58",
- "lightOrange": "#af3a03"
- },
- "theme": {
- "primary": { "dark": "darkBlueBright", "light": "lightBlue" },
- "secondary": { "dark": "darkPurpleBright", "light": "lightPurple" },
- "accent": { "dark": "darkAquaBright", "light": "lightAqua" },
- "error": { "dark": "darkRedBright", "light": "lightRed" },
- "warning": { "dark": "darkOrangeBright", "light": "lightOrange" },
- "success": { "dark": "darkGreenBright", "light": "lightGreen" },
- "info": { "dark": "darkYellowBright", "light": "lightYellow" },
- "text": { "dark": "darkFg1", "light": "lightFg1" },
- "textMuted": { "dark": "darkGray", "light": "lightGray" },
- "background": { "dark": "darkBg0", "light": "lightBg0" },
- "backgroundPanel": { "dark": "darkBg1", "light": "lightBg1" },
- "backgroundElement": { "dark": "darkBg2", "light": "lightBg2" },
- "border": { "dark": "darkBg3", "light": "lightBg3" },
- "borderActive": { "dark": "darkFg1", "light": "lightFg1" },
- "borderSubtle": { "dark": "darkBg2", "light": "lightBg2" },
- "diffAdded": { "dark": "darkGreen", "light": "lightGreen" },
- "diffRemoved": { "dark": "darkRed", "light": "lightRed" },
- "diffContext": { "dark": "darkGray", "light": "lightGray" },
- "diffHunkHeader": { "dark": "darkAqua", "light": "lightAqua" },
- "diffHighlightAdded": { "dark": "darkGreenBright", "light": "lightGreen" },
- "diffHighlightRemoved": { "dark": "darkRedBright", "light": "lightRed" },
- "diffAddedBg": { "dark": "#32302f", "light": "#e2e0b5" },
- "diffRemovedBg": { "dark": "#322929", "light": "#e9d8d5" },
- "diffContextBg": { "dark": "darkBg1", "light": "lightBg1" },
- "diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" },
- "diffAddedLineNumberBg": { "dark": "#2a2827", "light": "#d4d2a9" },
- "diffRemovedLineNumberBg": { "dark": "#2a2222", "light": "#d8cbc8" },
- "markdownText": { "dark": "darkFg1", "light": "lightFg1" },
- "markdownHeading": { "dark": "darkBlueBright", "light": "lightBlue" },
- "markdownLink": { "dark": "darkAquaBright", "light": "lightAqua" },
- "markdownLinkText": { "dark": "darkGreenBright", "light": "lightGreen" },
- "markdownCode": { "dark": "darkYellowBright", "light": "lightYellow" },
- "markdownBlockQuote": { "dark": "darkGray", "light": "lightGray" },
- "markdownEmph": { "dark": "darkPurpleBright", "light": "lightPurple" },
- "markdownStrong": { "dark": "darkOrangeBright", "light": "lightOrange" },
- "markdownHorizontalRule": { "dark": "darkGray", "light": "lightGray" },
- "markdownListItem": { "dark": "darkBlueBright", "light": "lightBlue" },
- "markdownListEnumeration": {
- "dark": "darkAquaBright",
- "light": "lightAqua"
- },
- "markdownImage": { "dark": "darkAquaBright", "light": "lightAqua" },
- "markdownImageText": { "dark": "darkGreenBright", "light": "lightGreen" },
- "markdownCodeBlock": { "dark": "darkFg1", "light": "lightFg1" },
- "syntaxComment": { "dark": "darkGray", "light": "lightGray" },
- "syntaxKeyword": { "dark": "darkRedBright", "light": "lightRed" },
- "syntaxFunction": { "dark": "darkGreenBright", "light": "lightGreen" },
- "syntaxVariable": { "dark": "darkBlueBright", "light": "lightBlue" },
- "syntaxString": { "dark": "darkYellowBright", "light": "lightYellow" },
- "syntaxNumber": { "dark": "darkPurpleBright", "light": "lightPurple" },
- "syntaxType": { "dark": "darkAquaBright", "light": "lightAqua" },
- "syntaxOperator": { "dark": "darkOrangeBright", "light": "lightOrange" },
- "syntaxPunctuation": { "dark": "darkFg1", "light": "lightFg1" }
- }
-}
diff --git a/packages/tui/internal/theme/themes/kanagawa.json b/packages/tui/internal/theme/themes/kanagawa.json
deleted file mode 100644
index 91a784014..000000000
--- a/packages/tui/internal/theme/themes/kanagawa.json
+++ /dev/null
@@ -1,77 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "sumiInk0": "#1F1F28",
- "sumiInk1": "#2A2A37",
- "sumiInk2": "#363646",
- "sumiInk3": "#54546D",
- "fujiWhite": "#DCD7BA",
- "oldWhite": "#C8C093",
- "fujiGray": "#727169",
- "oniViolet": "#957FB8",
- "crystalBlue": "#7E9CD8",
- "carpYellow": "#C38D9D",
- "sakuraPink": "#D27E99",
- "waveAqua": "#76946A",
- "roninYellow": "#D7A657",
- "dragonRed": "#E82424",
- "lotusGreen": "#98BB6C",
- "waveBlue": "#2D4F67",
- "lightBg": "#F2E9DE",
- "lightPaper": "#EAE4D7",
- "lightText": "#54433A",
- "lightGray": "#9E9389"
- },
- "theme": {
- "primary": { "dark": "crystalBlue", "light": "waveBlue" },
- "secondary": { "dark": "oniViolet", "light": "oniViolet" },
- "accent": { "dark": "sakuraPink", "light": "sakuraPink" },
- "error": { "dark": "dragonRed", "light": "dragonRed" },
- "warning": { "dark": "roninYellow", "light": "roninYellow" },
- "success": { "dark": "lotusGreen", "light": "lotusGreen" },
- "info": { "dark": "waveAqua", "light": "waveAqua" },
- "text": { "dark": "fujiWhite", "light": "lightText" },
- "textMuted": { "dark": "fujiGray", "light": "lightGray" },
- "background": { "dark": "sumiInk0", "light": "lightBg" },
- "backgroundPanel": { "dark": "sumiInk1", "light": "lightPaper" },
- "backgroundElement": { "dark": "sumiInk2", "light": "#E3DCD2" },
- "border": { "dark": "sumiInk3", "light": "#D4CBBF" },
- "borderActive": { "dark": "carpYellow", "light": "carpYellow" },
- "borderSubtle": { "dark": "sumiInk2", "light": "#DCD4C9" },
- "diffAdded": { "dark": "lotusGreen", "light": "lotusGreen" },
- "diffRemoved": { "dark": "dragonRed", "light": "dragonRed" },
- "diffContext": { "dark": "fujiGray", "light": "lightGray" },
- "diffHunkHeader": { "dark": "waveBlue", "light": "waveBlue" },
- "diffHighlightAdded": { "dark": "#A9D977", "light": "#89AF5B" },
- "diffHighlightRemoved": { "dark": "#F24A4A", "light": "#D61F1F" },
- "diffAddedBg": { "dark": "#252E25", "light": "#EAF3E4" },
- "diffRemovedBg": { "dark": "#362020", "light": "#FBE6E6" },
- "diffContextBg": { "dark": "sumiInk1", "light": "lightPaper" },
- "diffLineNumber": { "dark": "sumiInk3", "light": "#C7BEB4" },
- "diffAddedLineNumberBg": { "dark": "#202820", "light": "#DDE8D6" },
- "diffRemovedLineNumberBg": { "dark": "#2D1C1C", "light": "#F2DADA" },
- "markdownText": { "dark": "fujiWhite", "light": "lightText" },
- "markdownHeading": { "dark": "oniViolet", "light": "oniViolet" },
- "markdownLink": { "dark": "crystalBlue", "light": "waveBlue" },
- "markdownLinkText": { "dark": "waveAqua", "light": "waveAqua" },
- "markdownCode": { "dark": "lotusGreen", "light": "lotusGreen" },
- "markdownBlockQuote": { "dark": "fujiGray", "light": "lightGray" },
- "markdownEmph": { "dark": "carpYellow", "light": "carpYellow" },
- "markdownStrong": { "dark": "roninYellow", "light": "roninYellow" },
- "markdownHorizontalRule": { "dark": "fujiGray", "light": "lightGray" },
- "markdownListItem": { "dark": "crystalBlue", "light": "waveBlue" },
- "markdownListEnumeration": { "dark": "waveAqua", "light": "waveAqua" },
- "markdownImage": { "dark": "crystalBlue", "light": "waveBlue" },
- "markdownImageText": { "dark": "waveAqua", "light": "waveAqua" },
- "markdownCodeBlock": { "dark": "fujiWhite", "light": "lightText" },
- "syntaxComment": { "dark": "fujiGray", "light": "lightGray" },
- "syntaxKeyword": { "dark": "oniViolet", "light": "oniViolet" },
- "syntaxFunction": { "dark": "crystalBlue", "light": "waveBlue" },
- "syntaxVariable": { "dark": "fujiWhite", "light": "lightText" },
- "syntaxString": { "dark": "lotusGreen", "light": "lotusGreen" },
- "syntaxNumber": { "dark": "roninYellow", "light": "roninYellow" },
- "syntaxType": { "dark": "carpYellow", "light": "carpYellow" },
- "syntaxOperator": { "dark": "sakuraPink", "light": "sakuraPink" },
- "syntaxPunctuation": { "dark": "fujiWhite", "light": "lightText" }
- }
-}
diff --git a/packages/tui/internal/theme/themes/material.json b/packages/tui/internal/theme/themes/material.json
deleted file mode 100644
index c3a106808..000000000
--- a/packages/tui/internal/theme/themes/material.json
+++ /dev/null
@@ -1,235 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkBg": "#263238",
- "darkBgAlt": "#1e272c",
- "darkBgPanel": "#37474f",
- "darkFg": "#eeffff",
- "darkFgMuted": "#546e7a",
- "darkRed": "#f07178",
- "darkPink": "#f78c6c",
- "darkOrange": "#ffcb6b",
- "darkYellow": "#ffcb6b",
- "darkGreen": "#c3e88d",
- "darkCyan": "#89ddff",
- "darkBlue": "#82aaff",
- "darkPurple": "#c792ea",
- "darkViolet": "#bb80b3",
- "lightBg": "#fafafa",
- "lightBgAlt": "#f5f5f5",
- "lightBgPanel": "#e7e7e8",
- "lightFg": "#263238",
- "lightFgMuted": "#90a4ae",
- "lightRed": "#e53935",
- "lightPink": "#ec407a",
- "lightOrange": "#f4511e",
- "lightYellow": "#ffb300",
- "lightGreen": "#91b859",
- "lightCyan": "#39adb5",
- "lightBlue": "#6182b8",
- "lightPurple": "#7c4dff",
- "lightViolet": "#945eb8"
- },
- "theme": {
- "primary": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "secondary": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "accent": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "error": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "warning": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "success": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "info": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "text": {
- "dark": "darkFg",
- "light": "lightFg"
- },
- "textMuted": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "background": {
- "dark": "darkBg",
- "light": "lightBg"
- },
- "backgroundPanel": {
- "dark": "darkBgAlt",
- "light": "lightBgAlt"
- },
- "backgroundElement": {
- "dark": "darkBgPanel",
- "light": "lightBgPanel"
- },
- "border": {
- "dark": "#37474f",
- "light": "#e0e0e0"
- },
- "borderActive": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "borderSubtle": {
- "dark": "#1e272c",
- "light": "#eeeeee"
- },
- "diffAdded": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "diffRemoved": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "diffContext": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "diffHunkHeader": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "diffHighlightAdded": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "diffHighlightRemoved": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "diffAddedBg": {
- "dark": "#2e3c2b",
- "light": "#e8f5e9"
- },
- "diffRemovedBg": {
- "dark": "#3c2b2b",
- "light": "#ffebee"
- },
- "diffContextBg": {
- "dark": "darkBgAlt",
- "light": "lightBgAlt"
- },
- "diffLineNumber": {
- "dark": "#37474f",
- "light": "#cfd8dc"
- },
- "diffAddedLineNumberBg": {
- "dark": "#2e3c2b",
- "light": "#e8f5e9"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#3c2b2b",
- "light": "#ffebee"
- },
- "markdownText": {
- "dark": "darkFg",
- "light": "lightFg"
- },
- "markdownHeading": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "markdownLink": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownLinkText": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "markdownCode": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "markdownBlockQuote": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "markdownEmph": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "markdownStrong": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "markdownHorizontalRule": {
- "dark": "#37474f",
- "light": "#e0e0e0"
- },
- "markdownListItem": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "markdownListEnumeration": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownImage": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownImageText": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "markdownCodeBlock": {
- "dark": "darkFg",
- "light": "lightFg"
- },
- "syntaxComment": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "syntaxKeyword": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "syntaxFunction": {
- "dark": "darkBlue",
- "light": "lightBlue"
- },
- "syntaxVariable": {
- "dark": "darkFg",
- "light": "lightFg"
- },
- "syntaxString": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "syntaxNumber": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "syntaxType": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "syntaxOperator": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "syntaxPunctuation": {
- "dark": "darkFg",
- "light": "lightFg"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/matrix.json b/packages/tui/internal/theme/themes/matrix.json
deleted file mode 100644
index 354946284..000000000
--- a/packages/tui/internal/theme/themes/matrix.json
+++ /dev/null
@@ -1,77 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "matrixInk0": "#0a0e0a",
- "matrixInk1": "#0e130d",
- "matrixInk2": "#141c12",
- "matrixInk3": "#1e2a1b",
- "rainGreen": "#2eff6a",
- "rainGreenDim": "#1cc24b",
- "rainGreenHi": "#62ff94",
- "rainCyan": "#00efff",
- "rainTeal": "#24f6d9",
- "rainPurple": "#c770ff",
- "rainOrange": "#ffa83d",
- "alertRed": "#ff4b4b",
- "alertYellow": "#e6ff57",
- "alertBlue": "#30b3ff",
- "rainGray": "#8ca391",
- "lightBg": "#eef3ea",
- "lightPaper": "#e4ebe1",
- "lightInk1": "#dae1d7",
- "lightText": "#203022",
- "lightGray": "#748476"
- },
- "theme": {
- "primary": { "dark": "rainGreen", "light": "rainGreenDim" },
- "secondary": { "dark": "rainCyan", "light": "rainTeal" },
- "accent": { "dark": "rainPurple", "light": "rainPurple" },
- "error": { "dark": "alertRed", "light": "alertRed" },
- "warning": { "dark": "alertYellow", "light": "alertYellow" },
- "success": { "dark": "rainGreenHi", "light": "rainGreenDim" },
- "info": { "dark": "alertBlue", "light": "alertBlue" },
- "text": { "dark": "rainGreenHi", "light": "lightText" },
- "textMuted": { "dark": "rainGray", "light": "lightGray" },
- "background": { "dark": "matrixInk0", "light": "lightBg" },
- "backgroundPanel": { "dark": "matrixInk1", "light": "lightPaper" },
- "backgroundElement": { "dark": "matrixInk2", "light": "lightInk1" },
- "border": { "dark": "matrixInk3", "light": "lightGray" },
- "borderActive": { "dark": "rainGreen", "light": "rainGreenDim" },
- "borderSubtle": { "dark": "matrixInk2", "light": "lightInk1" },
- "diffAdded": { "dark": "rainGreenDim", "light": "rainGreenDim" },
- "diffRemoved": { "dark": "alertRed", "light": "alertRed" },
- "diffContext": { "dark": "rainGray", "light": "lightGray" },
- "diffHunkHeader": { "dark": "alertBlue", "light": "alertBlue" },
- "diffHighlightAdded": { "dark": "#77ffaf", "light": "#5dac7e" },
- "diffHighlightRemoved": { "dark": "#ff7171", "light": "#d53a3a" },
- "diffAddedBg": { "dark": "#132616", "light": "#e0efde" },
- "diffRemovedBg": { "dark": "#261212", "light": "#f9e5e5" },
- "diffContextBg": { "dark": "matrixInk1", "light": "lightPaper" },
- "diffLineNumber": { "dark": "matrixInk3", "light": "lightGray" },
- "diffAddedLineNumberBg": { "dark": "#0f1b11", "light": "#d6e7d2" },
- "diffRemovedLineNumberBg": { "dark": "#1b1414", "light": "#f2d2d2" },
- "markdownText": { "dark": "rainGreenHi", "light": "lightText" },
- "markdownHeading": { "dark": "rainCyan", "light": "rainTeal" },
- "markdownLink": { "dark": "alertBlue", "light": "alertBlue" },
- "markdownLinkText": { "dark": "rainTeal", "light": "rainTeal" },
- "markdownCode": { "dark": "rainGreenDim", "light": "rainGreenDim" },
- "markdownBlockQuote": { "dark": "rainGray", "light": "lightGray" },
- "markdownEmph": { "dark": "rainOrange", "light": "rainOrange" },
- "markdownStrong": { "dark": "alertYellow", "light": "alertYellow" },
- "markdownHorizontalRule": { "dark": "rainGray", "light": "lightGray" },
- "markdownListItem": { "dark": "alertBlue", "light": "alertBlue" },
- "markdownListEnumeration": { "dark": "rainTeal", "light": "rainTeal" },
- "markdownImage": { "dark": "alertBlue", "light": "alertBlue" },
- "markdownImageText": { "dark": "rainTeal", "light": "rainTeal" },
- "markdownCodeBlock": { "dark": "rainGreenHi", "light": "lightText" },
- "syntaxComment": { "dark": "rainGray", "light": "lightGray" },
- "syntaxKeyword": { "dark": "rainPurple", "light": "rainPurple" },
- "syntaxFunction": { "dark": "alertBlue", "light": "alertBlue" },
- "syntaxVariable": { "dark": "rainGreenHi", "light": "lightText" },
- "syntaxString": { "dark": "rainGreenDim", "light": "rainGreenDim" },
- "syntaxNumber": { "dark": "rainOrange", "light": "rainOrange" },
- "syntaxType": { "dark": "alertYellow", "light": "alertYellow" },
- "syntaxOperator": { "dark": "rainTeal", "light": "rainTeal" },
- "syntaxPunctuation": { "dark": "rainGreenHi", "light": "lightText" }
- }
-}
diff --git a/packages/tui/internal/theme/themes/mellow.json b/packages/tui/internal/theme/themes/mellow.json
deleted file mode 100644
index f2a00a47f..000000000
--- a/packages/tui/internal/theme/themes/mellow.json
+++ /dev/null
@@ -1,87 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "dark_bg": "#161617",
- "dark_fg": "#c9c7cd",
- "dark_bg_dark": "#131314",
-
- "dark_black": "#27272a",
- "dark_red": "#f5a191",
- "dark_green": "#90b99f",
- "dark_yellow": "#e6b99d",
- "dark_blue": "#aca1cf",
- "dark_magenta": "#e29eca",
- "dark_cyan": "#ea83a5",
- "dark_white": "#c1c0d4",
-
- "dark_bright_black": "#353539",
- "dark_bright_red": "#ffae9f",
- "dark_bright_green": "#9dc6ac",
- "dark_bright_yellow": "#f0c5a9",
- "dark_bright_blue": "#b9aeda",
- "dark_bright_magenta": "#ecaad6",
- "dark_bright_cyan": "#f591b2",
- "dark_bright_white": "#cac9dd",
-
- "dark_gray00": "#18181a",
- "dark_gray01": "#1b1b1d",
- "dark_gray02": "#2a2a2d",
- "dark_gray03": "#3e3e43",
- "dark_gray04": "#57575f",
- "dark_gray05": "#757581",
- "dark_gray06": "#9998a8",
- "dark_gray07": "#c1c0d4"
- },
- "theme": {
- "primary": "dark_cyan",
- "secondary": "dark_cyan",
- "accent": "dark_blue",
- "error": "dark_cyan",
- "warning": "dark_yellow",
- "success": "dark_green",
- "info": "dark_blue",
- "text": "dark_fg",
- "textMuted": "dark_white",
- "background": "dark_bg",
- "backgroundPanel": "dark_gray01",
- "backgroundElement": "dark_gray02",
- "border": "dark_gray02",
- "borderActive": "dark_gray01",
- "borderSubtle": "dark_gray00",
- "diffAdded": "dark_black",
- "diffRemoved": "dark_black",
- "diffContext": "dark_fg",
- "diffHunkHeader": "dark_magenta",
- "diffHighlightAdded": "dark_bright_green",
- "diffHighlightRemoved": "dark_bright_red",
- "diffAddedBg": "dark_green",
- "diffRemovedBg": "dark_red",
- "diffContextBg": "dark_gray00",
- "diffLineNumber": "diffContextBg",
- "diffAddedLineNumberBg": "dark_green",
- "diffRemovedLineNumberBg": "dark_red",
- "markdownText": "dark_fg",
- "markdownHeading": "dark_gray06",
- "markdownLink": "dark_blue",
- "markdownLinkText": "dark_cyan",
- "markdownCode": "dark_bright_green",
- "markdownBlockQuote": "dark_gray00",
- "markdownEmph": "dark_bright_yellow",
- "markdownStrong": "dark_bright_red",
- "markdownHorizontalRule": "markdownText",
- "markdownListItem": "dark_blue",
- "markdownListEnumeration": "dark_bright_blue",
- "markdownImage": "markdownLink",
- "markdownImageText": "markdownLinkText",
- "markdownCodeBlock": "dark_fg",
- "syntaxComment": "dark_gray05",
- "syntaxKeyword": "dark_blue",
- "syntaxFunction": "dark_gray07",
- "syntaxVariable": "dark_fg",
- "syntaxString": "dark_green",
- "syntaxNumber": "dark_magenta",
- "syntaxType": "dark_magenta",
- "syntaxOperator": "dark_yellow",
- "syntaxPunctuation": "dark_gray06"
- }
-}
diff --git a/packages/tui/internal/theme/themes/monokai.json b/packages/tui/internal/theme/themes/monokai.json
deleted file mode 100644
index 09637a1e2..000000000
--- a/packages/tui/internal/theme/themes/monokai.json
+++ /dev/null
@@ -1,221 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "background": "#272822",
- "backgroundAlt": "#1e1f1c",
- "backgroundPanel": "#3e3d32",
- "foreground": "#f8f8f2",
- "comment": "#75715e",
- "red": "#f92672",
- "orange": "#fd971f",
- "lightOrange": "#e69f66",
- "yellow": "#e6db74",
- "green": "#a6e22e",
- "cyan": "#66d9ef",
- "blue": "#66d9ef",
- "purple": "#ae81ff",
- "pink": "#f92672"
- },
- "theme": {
- "primary": {
- "dark": "cyan",
- "light": "blue"
- },
- "secondary": {
- "dark": "purple",
- "light": "purple"
- },
- "accent": {
- "dark": "green",
- "light": "green"
- },
- "error": {
- "dark": "red",
- "light": "red"
- },
- "warning": {
- "dark": "yellow",
- "light": "orange"
- },
- "success": {
- "dark": "green",
- "light": "green"
- },
- "info": {
- "dark": "orange",
- "light": "orange"
- },
- "text": {
- "dark": "foreground",
- "light": "#272822"
- },
- "textMuted": {
- "dark": "comment",
- "light": "#75715e"
- },
- "background": {
- "dark": "#272822",
- "light": "#fafafa"
- },
- "backgroundPanel": {
- "dark": "#1e1f1c",
- "light": "#f0f0f0"
- },
- "backgroundElement": {
- "dark": "#3e3d32",
- "light": "#e0e0e0"
- },
- "border": {
- "dark": "#3e3d32",
- "light": "#d0d0d0"
- },
- "borderActive": {
- "dark": "cyan",
- "light": "blue"
- },
- "borderSubtle": {
- "dark": "#1e1f1c",
- "light": "#e8e8e8"
- },
- "diffAdded": {
- "dark": "green",
- "light": "green"
- },
- "diffRemoved": {
- "dark": "red",
- "light": "red"
- },
- "diffContext": {
- "dark": "comment",
- "light": "#75715e"
- },
- "diffHunkHeader": {
- "dark": "comment",
- "light": "#75715e"
- },
- "diffHighlightAdded": {
- "dark": "green",
- "light": "green"
- },
- "diffHighlightRemoved": {
- "dark": "red",
- "light": "red"
- },
- "diffAddedBg": {
- "dark": "#1a3a1a",
- "light": "#e0ffe0"
- },
- "diffRemovedBg": {
- "dark": "#3a1a1a",
- "light": "#ffe0e0"
- },
- "diffContextBg": {
- "dark": "#1e1f1c",
- "light": "#f0f0f0"
- },
- "diffLineNumber": {
- "dark": "#3e3d32",
- "light": "#d0d0d0"
- },
- "diffAddedLineNumberBg": {
- "dark": "#1a3a1a",
- "light": "#e0ffe0"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#3a1a1a",
- "light": "#ffe0e0"
- },
- "markdownText": {
- "dark": "foreground",
- "light": "#272822"
- },
- "markdownHeading": {
- "dark": "pink",
- "light": "pink"
- },
- "markdownLink": {
- "dark": "cyan",
- "light": "blue"
- },
- "markdownLinkText": {
- "dark": "purple",
- "light": "purple"
- },
- "markdownCode": {
- "dark": "green",
- "light": "green"
- },
- "markdownBlockQuote": {
- "dark": "comment",
- "light": "#75715e"
- },
- "markdownEmph": {
- "dark": "yellow",
- "light": "orange"
- },
- "markdownStrong": {
- "dark": "orange",
- "light": "orange"
- },
- "markdownHorizontalRule": {
- "dark": "comment",
- "light": "#75715e"
- },
- "markdownListItem": {
- "dark": "cyan",
- "light": "blue"
- },
- "markdownListEnumeration": {
- "dark": "purple",
- "light": "purple"
- },
- "markdownImage": {
- "dark": "cyan",
- "light": "blue"
- },
- "markdownImageText": {
- "dark": "purple",
- "light": "purple"
- },
- "markdownCodeBlock": {
- "dark": "foreground",
- "light": "#272822"
- },
- "syntaxComment": {
- "dark": "comment",
- "light": "#75715e"
- },
- "syntaxKeyword": {
- "dark": "pink",
- "light": "pink"
- },
- "syntaxFunction": {
- "dark": "green",
- "light": "green"
- },
- "syntaxVariable": {
- "dark": "foreground",
- "light": "#272822"
- },
- "syntaxString": {
- "dark": "yellow",
- "light": "orange"
- },
- "syntaxNumber": {
- "dark": "purple",
- "light": "purple"
- },
- "syntaxType": {
- "dark": "cyan",
- "light": "blue"
- },
- "syntaxOperator": {
- "dark": "pink",
- "light": "pink"
- },
- "syntaxPunctuation": {
- "dark": "foreground",
- "light": "#272822"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/nightowl.json b/packages/tui/internal/theme/themes/nightowl.json
deleted file mode 100644
index 8eff42c5f..000000000
--- a/packages/tui/internal/theme/themes/nightowl.json
+++ /dev/null
@@ -1,221 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "nightOwlBg": "#011627",
- "nightOwlFg": "#d6deeb",
- "nightOwlBlue": "#82AAFF",
- "nightOwlCyan": "#7fdbca",
- "nightOwlGreen": "#c5e478",
- "nightOwlYellow": "#ecc48d",
- "nightOwlOrange": "#F78C6C",
- "nightOwlRed": "#EF5350",
- "nightOwlPink": "#ff5874",
- "nightOwlPurple": "#c792ea",
- "nightOwlMuted": "#5f7e97",
- "nightOwlGray": "#637777",
- "nightOwlLightGray": "#89a4bb",
- "nightOwlPanel": "#0b253a"
- },
- "theme": {
- "primary": {
- "dark": "nightOwlBlue",
- "light": "nightOwlBlue"
- },
- "secondary": {
- "dark": "nightOwlCyan",
- "light": "nightOwlCyan"
- },
- "accent": {
- "dark": "nightOwlPurple",
- "light": "nightOwlPurple"
- },
- "error": {
- "dark": "nightOwlRed",
- "light": "nightOwlRed"
- },
- "warning": {
- "dark": "nightOwlYellow",
- "light": "nightOwlYellow"
- },
- "success": {
- "dark": "nightOwlGreen",
- "light": "nightOwlGreen"
- },
- "info": {
- "dark": "nightOwlBlue",
- "light": "nightOwlBlue"
- },
- "text": {
- "dark": "nightOwlFg",
- "light": "nightOwlFg"
- },
- "textMuted": {
- "dark": "nightOwlMuted",
- "light": "nightOwlMuted"
- },
- "background": {
- "dark": "nightOwlBg",
- "light": "nightOwlBg"
- },
- "backgroundPanel": {
- "dark": "nightOwlPanel",
- "light": "nightOwlPanel"
- },
- "backgroundElement": {
- "dark": "nightOwlPanel",
- "light": "nightOwlPanel"
- },
- "border": {
- "dark": "nightOwlMuted",
- "light": "nightOwlMuted"
- },
- "borderActive": {
- "dark": "nightOwlBlue",
- "light": "nightOwlBlue"
- },
- "borderSubtle": {
- "dark": "nightOwlMuted",
- "light": "nightOwlMuted"
- },
- "diffAdded": {
- "dark": "nightOwlGreen",
- "light": "nightOwlGreen"
- },
- "diffRemoved": {
- "dark": "nightOwlRed",
- "light": "nightOwlRed"
- },
- "diffContext": {
- "dark": "nightOwlMuted",
- "light": "nightOwlMuted"
- },
- "diffHunkHeader": {
- "dark": "nightOwlMuted",
- "light": "nightOwlMuted"
- },
- "diffHighlightAdded": {
- "dark": "nightOwlGreen",
- "light": "nightOwlGreen"
- },
- "diffHighlightRemoved": {
- "dark": "nightOwlRed",
- "light": "nightOwlRed"
- },
- "diffAddedBg": {
- "dark": "#0a2e1a",
- "light": "#0a2e1a"
- },
- "diffRemovedBg": {
- "dark": "#2d1b1b",
- "light": "#2d1b1b"
- },
- "diffContextBg": {
- "dark": "nightOwlPanel",
- "light": "nightOwlPanel"
- },
- "diffLineNumber": {
- "dark": "nightOwlMuted",
- "light": "nightOwlMuted"
- },
- "diffAddedLineNumberBg": {
- "dark": "#0a2e1a",
- "light": "#0a2e1a"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#2d1b1b",
- "light": "#2d1b1b"
- },
- "markdownText": {
- "dark": "nightOwlFg",
- "light": "nightOwlFg"
- },
- "markdownHeading": {
- "dark": "nightOwlBlue",
- "light": "nightOwlBlue"
- },
- "markdownLink": {
- "dark": "nightOwlCyan",
- "light": "nightOwlCyan"
- },
- "markdownLinkText": {
- "dark": "nightOwlBlue",
- "light": "nightOwlBlue"
- },
- "markdownCode": {
- "dark": "nightOwlGreen",
- "light": "nightOwlGreen"
- },
- "markdownBlockQuote": {
- "dark": "nightOwlMuted",
- "light": "nightOwlMuted"
- },
- "markdownEmph": {
- "dark": "nightOwlPurple",
- "light": "nightOwlPurple"
- },
- "markdownStrong": {
- "dark": "nightOwlYellow",
- "light": "nightOwlYellow"
- },
- "markdownHorizontalRule": {
- "dark": "nightOwlMuted",
- "light": "nightOwlMuted"
- },
- "markdownListItem": {
- "dark": "nightOwlBlue",
- "light": "nightOwlBlue"
- },
- "markdownListEnumeration": {
- "dark": "nightOwlCyan",
- "light": "nightOwlCyan"
- },
- "markdownImage": {
- "dark": "nightOwlCyan",
- "light": "nightOwlCyan"
- },
- "markdownImageText": {
- "dark": "nightOwlBlue",
- "light": "nightOwlBlue"
- },
- "markdownCodeBlock": {
- "dark": "nightOwlFg",
- "light": "nightOwlFg"
- },
- "syntaxComment": {
- "dark": "nightOwlGray",
- "light": "nightOwlGray"
- },
- "syntaxKeyword": {
- "dark": "nightOwlPurple",
- "light": "nightOwlPurple"
- },
- "syntaxFunction": {
- "dark": "nightOwlBlue",
- "light": "nightOwlBlue"
- },
- "syntaxVariable": {
- "dark": "nightOwlFg",
- "light": "nightOwlFg"
- },
- "syntaxString": {
- "dark": "nightOwlYellow",
- "light": "nightOwlYellow"
- },
- "syntaxNumber": {
- "dark": "nightOwlOrange",
- "light": "nightOwlOrange"
- },
- "syntaxType": {
- "dark": "nightOwlGreen",
- "light": "nightOwlGreen"
- },
- "syntaxOperator": {
- "dark": "nightOwlCyan",
- "light": "nightOwlCyan"
- },
- "syntaxPunctuation": {
- "dark": "nightOwlFg",
- "light": "nightOwlFg"
- }
- }
-} \ No newline at end of file
diff --git a/packages/tui/internal/theme/themes/nord.json b/packages/tui/internal/theme/themes/nord.json
deleted file mode 100644
index 4a525382a..000000000
--- a/packages/tui/internal/theme/themes/nord.json
+++ /dev/null
@@ -1,223 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "nord0": "#2E3440",
- "nord1": "#3B4252",
- "nord2": "#434C5E",
- "nord3": "#4C566A",
- "nord4": "#D8DEE9",
- "nord5": "#E5E9F0",
- "nord6": "#ECEFF4",
- "nord7": "#8FBCBB",
- "nord8": "#88C0D0",
- "nord9": "#81A1C1",
- "nord10": "#5E81AC",
- "nord11": "#BF616A",
- "nord12": "#D08770",
- "nord13": "#EBCB8B",
- "nord14": "#A3BE8C",
- "nord15": "#B48EAD"
- },
- "theme": {
- "primary": {
- "dark": "nord8",
- "light": "nord10"
- },
- "secondary": {
- "dark": "nord9",
- "light": "nord9"
- },
- "accent": {
- "dark": "nord7",
- "light": "nord7"
- },
- "error": {
- "dark": "nord11",
- "light": "nord11"
- },
- "warning": {
- "dark": "nord12",
- "light": "nord12"
- },
- "success": {
- "dark": "nord14",
- "light": "nord14"
- },
- "info": {
- "dark": "nord8",
- "light": "nord10"
- },
- "text": {
- "dark": "nord6",
- "light": "nord0"
- },
- "textMuted": {
- "dark": "#8B95A7",
- "light": "nord1"
- },
- "background": {
- "dark": "nord0",
- "light": "nord6"
- },
- "backgroundPanel": {
- "dark": "nord1",
- "light": "nord5"
- },
- "backgroundElement": {
- "dark": "nord2",
- "light": "nord4"
- },
- "border": {
- "dark": "nord2",
- "light": "nord3"
- },
- "borderActive": {
- "dark": "nord3",
- "light": "nord2"
- },
- "borderSubtle": {
- "dark": "nord2",
- "light": "nord3"
- },
- "diffAdded": {
- "dark": "nord14",
- "light": "nord14"
- },
- "diffRemoved": {
- "dark": "nord11",
- "light": "nord11"
- },
- "diffContext": {
- "dark": "#8B95A7",
- "light": "nord3"
- },
- "diffHunkHeader": {
- "dark": "#8B95A7",
- "light": "nord3"
- },
- "diffHighlightAdded": {
- "dark": "nord14",
- "light": "nord14"
- },
- "diffHighlightRemoved": {
- "dark": "nord11",
- "light": "nord11"
- },
- "diffAddedBg": {
- "dark": "#3B4252",
- "light": "#E5E9F0"
- },
- "diffRemovedBg": {
- "dark": "#3B4252",
- "light": "#E5E9F0"
- },
- "diffContextBg": {
- "dark": "nord1",
- "light": "nord5"
- },
- "diffLineNumber": {
- "dark": "nord2",
- "light": "nord4"
- },
- "diffAddedLineNumberBg": {
- "dark": "#3B4252",
- "light": "#E5E9F0"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#3B4252",
- "light": "#E5E9F0"
- },
- "markdownText": {
- "dark": "nord4",
- "light": "nord0"
- },
- "markdownHeading": {
- "dark": "nord8",
- "light": "nord10"
- },
- "markdownLink": {
- "dark": "nord9",
- "light": "nord9"
- },
- "markdownLinkText": {
- "dark": "nord7",
- "light": "nord7"
- },
- "markdownCode": {
- "dark": "nord14",
- "light": "nord14"
- },
- "markdownBlockQuote": {
- "dark": "#8B95A7",
- "light": "nord3"
- },
- "markdownEmph": {
- "dark": "nord12",
- "light": "nord12"
- },
- "markdownStrong": {
- "dark": "nord13",
- "light": "nord13"
- },
- "markdownHorizontalRule": {
- "dark": "#8B95A7",
- "light": "nord3"
- },
- "markdownListItem": {
- "dark": "nord8",
- "light": "nord10"
- },
- "markdownListEnumeration": {
- "dark": "nord7",
- "light": "nord7"
- },
- "markdownImage": {
- "dark": "nord9",
- "light": "nord9"
- },
- "markdownImageText": {
- "dark": "nord7",
- "light": "nord7"
- },
- "markdownCodeBlock": {
- "dark": "nord4",
- "light": "nord0"
- },
- "syntaxComment": {
- "dark": "#8B95A7",
- "light": "nord3"
- },
- "syntaxKeyword": {
- "dark": "nord9",
- "light": "nord9"
- },
- "syntaxFunction": {
- "dark": "nord8",
- "light": "nord8"
- },
- "syntaxVariable": {
- "dark": "nord7",
- "light": "nord7"
- },
- "syntaxString": {
- "dark": "nord14",
- "light": "nord14"
- },
- "syntaxNumber": {
- "dark": "nord15",
- "light": "nord15"
- },
- "syntaxType": {
- "dark": "nord7",
- "light": "nord7"
- },
- "syntaxOperator": {
- "dark": "nord9",
- "light": "nord9"
- },
- "syntaxPunctuation": {
- "dark": "nord4",
- "light": "nord0"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/one-dark.json b/packages/tui/internal/theme/themes/one-dark.json
deleted file mode 100644
index 73b24e929..000000000
--- a/packages/tui/internal/theme/themes/one-dark.json
+++ /dev/null
@@ -1,84 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkBg": "#282c34",
- "darkBgAlt": "#21252b",
- "darkBgPanel": "#353b45",
- "darkFg": "#abb2bf",
- "darkFgMuted": "#5c6370",
- "darkPurple": "#c678dd",
- "darkBlue": "#61afef",
- "darkRed": "#e06c75",
- "darkGreen": "#98c379",
- "darkYellow": "#e5c07b",
- "darkOrange": "#d19a66",
- "darkCyan": "#56b6c2",
- "lightBg": "#fafafa",
- "lightBgAlt": "#f0f0f1",
- "lightBgPanel": "#eaeaeb",
- "lightFg": "#383a42",
- "lightFgMuted": "#a0a1a7",
- "lightPurple": "#a626a4",
- "lightBlue": "#4078f2",
- "lightRed": "#e45649",
- "lightGreen": "#50a14f",
- "lightYellow": "#c18401",
- "lightOrange": "#986801",
- "lightCyan": "#0184bc"
- },
- "theme": {
- "primary": { "dark": "darkBlue", "light": "lightBlue" },
- "secondary": { "dark": "darkPurple", "light": "lightPurple" },
- "accent": { "dark": "darkCyan", "light": "lightCyan" },
- "error": { "dark": "darkRed", "light": "lightRed" },
- "warning": { "dark": "darkYellow", "light": "lightYellow" },
- "success": { "dark": "darkGreen", "light": "lightGreen" },
- "info": { "dark": "darkOrange", "light": "lightOrange" },
- "text": { "dark": "darkFg", "light": "lightFg" },
- "textMuted": { "dark": "darkFgMuted", "light": "lightFgMuted" },
- "background": { "dark": "darkBg", "light": "lightBg" },
- "backgroundPanel": { "dark": "darkBgAlt", "light": "lightBgAlt" },
- "backgroundElement": { "dark": "darkBgPanel", "light": "lightBgPanel" },
- "border": { "dark": "#393f4a", "light": "#d1d1d2" },
- "borderActive": { "dark": "darkBlue", "light": "lightBlue" },
- "borderSubtle": { "dark": "#2c313a", "light": "#e0e0e1" },
- "diffAdded": { "dark": "darkGreen", "light": "lightGreen" },
- "diffRemoved": { "dark": "darkRed", "light": "lightRed" },
- "diffContext": { "dark": "darkFgMuted", "light": "lightFgMuted" },
- "diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" },
- "diffHighlightAdded": { "dark": "#aad482", "light": "#489447" },
- "diffHighlightRemoved": { "dark": "#e8828b", "light": "#d65145" },
- "diffAddedBg": { "dark": "#2c382b", "light": "#eafbe9" },
- "diffRemovedBg": { "dark": "#3a2d2f", "light": "#fce9e8" },
- "diffContextBg": { "dark": "darkBgAlt", "light": "lightBgAlt" },
- "diffLineNumber": { "dark": "#495162", "light": "#c9c9ca" },
- "diffAddedLineNumberBg": { "dark": "#283427", "light": "#e1f3df" },
- "diffRemovedLineNumberBg": { "dark": "#36292b", "light": "#f5e2e1" },
- "markdownText": { "dark": "darkFg", "light": "lightFg" },
- "markdownHeading": { "dark": "darkPurple", "light": "lightPurple" },
- "markdownLink": { "dark": "darkBlue", "light": "lightBlue" },
- "markdownLinkText": { "dark": "darkCyan", "light": "lightCyan" },
- "markdownCode": { "dark": "darkGreen", "light": "lightGreen" },
- "markdownBlockQuote": { "dark": "darkFgMuted", "light": "lightFgMuted" },
- "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" },
- "markdownStrong": { "dark": "darkOrange", "light": "lightOrange" },
- "markdownHorizontalRule": {
- "dark": "darkFgMuted",
- "light": "lightFgMuted"
- },
- "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" },
- "markdownListEnumeration": { "dark": "darkCyan", "light": "lightCyan" },
- "markdownImage": { "dark": "darkBlue", "light": "lightBlue" },
- "markdownImageText": { "dark": "darkCyan", "light": "lightCyan" },
- "markdownCodeBlock": { "dark": "darkFg", "light": "lightFg" },
- "syntaxComment": { "dark": "darkFgMuted", "light": "lightFgMuted" },
- "syntaxKeyword": { "dark": "darkPurple", "light": "lightPurple" },
- "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" },
- "syntaxVariable": { "dark": "darkRed", "light": "lightRed" },
- "syntaxString": { "dark": "darkGreen", "light": "lightGreen" },
- "syntaxNumber": { "dark": "darkOrange", "light": "lightOrange" },
- "syntaxType": { "dark": "darkYellow", "light": "lightYellow" },
- "syntaxOperator": { "dark": "darkCyan", "light": "lightCyan" },
- "syntaxPunctuation": { "dark": "darkFg", "light": "lightFg" }
- }
-}
diff --git a/packages/tui/internal/theme/themes/opencode.json b/packages/tui/internal/theme/themes/opencode.json
deleted file mode 100644
index 8f585a450..000000000
--- a/packages/tui/internal/theme/themes/opencode.json
+++ /dev/null
@@ -1,245 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkStep1": "#0a0a0a",
- "darkStep2": "#141414",
- "darkStep3": "#1e1e1e",
- "darkStep4": "#282828",
- "darkStep5": "#323232",
- "darkStep6": "#3c3c3c",
- "darkStep7": "#484848",
- "darkStep8": "#606060",
- "darkStep9": "#fab283",
- "darkStep10": "#ffc09f",
- "darkStep11": "#808080",
- "darkStep12": "#eeeeee",
- "darkSecondary": "#5c9cf5",
- "darkAccent": "#9d7cd8",
- "darkRed": "#e06c75",
- "darkOrange": "#f5a742",
- "darkGreen": "#7fd88f",
- "darkCyan": "#56b6c2",
- "darkYellow": "#e5c07b",
- "lightStep1": "#ffffff",
- "lightStep2": "#fafafa",
- "lightStep3": "#f5f5f5",
- "lightStep4": "#ebebeb",
- "lightStep5": "#e1e1e1",
- "lightStep6": "#d4d4d4",
- "lightStep7": "#b8b8b8",
- "lightStep8": "#a0a0a0",
- "lightStep9": "#3b7dd8",
- "lightStep10": "#2968c3",
- "lightStep11": "#8a8a8a",
- "lightStep12": "#1a1a1a",
- "lightSecondary": "#7b5bb6",
- "lightAccent": "#d68c27",
- "lightRed": "#d1383d",
- "lightOrange": "#d68c27",
- "lightGreen": "#3d9a57",
- "lightCyan": "#318795",
- "lightYellow": "#b0851f"
- },
- "theme": {
- "primary": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "secondary": {
- "dark": "darkSecondary",
- "light": "lightSecondary"
- },
- "accent": {
- "dark": "darkAccent",
- "light": "lightAccent"
- },
- "error": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "warning": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "success": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "info": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "text": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "textMuted": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "background": {
- "dark": "darkStep1",
- "light": "lightStep1"
- },
- "backgroundPanel": {
- "dark": "darkStep2",
- "light": "lightStep2"
- },
- "backgroundElement": {
- "dark": "darkStep3",
- "light": "lightStep3"
- },
- "border": {
- "dark": "darkStep7",
- "light": "lightStep7"
- },
- "borderActive": {
- "dark": "darkStep8",
- "light": "lightStep8"
- },
- "borderSubtle": {
- "dark": "darkStep6",
- "light": "lightStep6"
- },
- "diffAdded": {
- "dark": "#4fd6be",
- "light": "#1e725c"
- },
- "diffRemoved": {
- "dark": "#c53b53",
- "light": "#c53b53"
- },
- "diffContext": {
- "dark": "#828bb8",
- "light": "#7086b5"
- },
- "diffHunkHeader": {
- "dark": "#828bb8",
- "light": "#7086b5"
- },
- "diffHighlightAdded": {
- "dark": "#b8db87",
- "light": "#4db380"
- },
- "diffHighlightRemoved": {
- "dark": "#e26a75",
- "light": "#f52a65"
- },
- "diffAddedBg": {
- "dark": "#20303b",
- "light": "#d5e5d5"
- },
- "diffRemovedBg": {
- "dark": "#37222c",
- "light": "#f7d8db"
- },
- "diffContextBg": {
- "dark": "darkStep2",
- "light": "lightStep2"
- },
- "diffLineNumber": {
- "dark": "darkStep3",
- "light": "lightStep3"
- },
- "diffAddedLineNumberBg": {
- "dark": "#1b2b34",
- "light": "#c5d5c5"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#2d1f26",
- "light": "#e7c8cb"
- },
- "markdownText": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "markdownHeading": {
- "dark": "darkAccent",
- "light": "lightAccent"
- },
- "markdownLink": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownLinkText": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownCode": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "markdownBlockQuote": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "markdownEmph": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "markdownStrong": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "markdownHorizontalRule": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "markdownListItem": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownListEnumeration": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownImage": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownImageText": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownCodeBlock": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "syntaxComment": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "syntaxKeyword": {
- "dark": "darkAccent",
- "light": "lightAccent"
- },
- "syntaxFunction": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "syntaxVariable": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "syntaxString": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "syntaxNumber": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "syntaxType": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "syntaxOperator": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "syntaxPunctuation": {
- "dark": "darkStep12",
- "light": "lightStep12"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/palenight.json b/packages/tui/internal/theme/themes/palenight.json
deleted file mode 100644
index 79f7c59e8..000000000
--- a/packages/tui/internal/theme/themes/palenight.json
+++ /dev/null
@@ -1,222 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "background": "#292d3e",
- "backgroundAlt": "#1e2132",
- "backgroundPanel": "#32364a",
- "foreground": "#a6accd",
- "foregroundBright": "#bfc7d5",
- "comment": "#676e95",
- "red": "#f07178",
- "orange": "#f78c6c",
- "yellow": "#ffcb6b",
- "green": "#c3e88d",
- "cyan": "#89ddff",
- "blue": "#82aaff",
- "purple": "#c792ea",
- "magenta": "#ff5370",
- "pink": "#f07178"
- },
- "theme": {
- "primary": {
- "dark": "blue",
- "light": "#4976eb"
- },
- "secondary": {
- "dark": "purple",
- "light": "#a854f2"
- },
- "accent": {
- "dark": "cyan",
- "light": "#00acc1"
- },
- "error": {
- "dark": "red",
- "light": "#e53935"
- },
- "warning": {
- "dark": "yellow",
- "light": "#ffb300"
- },
- "success": {
- "dark": "green",
- "light": "#91b859"
- },
- "info": {
- "dark": "orange",
- "light": "#f4511e"
- },
- "text": {
- "dark": "foreground",
- "light": "#292d3e"
- },
- "textMuted": {
- "dark": "comment",
- "light": "#8796b0"
- },
- "background": {
- "dark": "#292d3e",
- "light": "#fafafa"
- },
- "backgroundPanel": {
- "dark": "#1e2132",
- "light": "#f5f5f5"
- },
- "backgroundElement": {
- "dark": "#32364a",
- "light": "#e7e7e8"
- },
- "border": {
- "dark": "#32364a",
- "light": "#e0e0e0"
- },
- "borderActive": {
- "dark": "blue",
- "light": "#4976eb"
- },
- "borderSubtle": {
- "dark": "#1e2132",
- "light": "#eeeeee"
- },
- "diffAdded": {
- "dark": "green",
- "light": "#91b859"
- },
- "diffRemoved": {
- "dark": "red",
- "light": "#e53935"
- },
- "diffContext": {
- "dark": "comment",
- "light": "#8796b0"
- },
- "diffHunkHeader": {
- "dark": "cyan",
- "light": "#00acc1"
- },
- "diffHighlightAdded": {
- "dark": "green",
- "light": "#91b859"
- },
- "diffHighlightRemoved": {
- "dark": "red",
- "light": "#e53935"
- },
- "diffAddedBg": {
- "dark": "#2e3c2b",
- "light": "#e8f5e9"
- },
- "diffRemovedBg": {
- "dark": "#3c2b2b",
- "light": "#ffebee"
- },
- "diffContextBg": {
- "dark": "#1e2132",
- "light": "#f5f5f5"
- },
- "diffLineNumber": {
- "dark": "#444760",
- "light": "#cfd8dc"
- },
- "diffAddedLineNumberBg": {
- "dark": "#2e3c2b",
- "light": "#e8f5e9"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#3c2b2b",
- "light": "#ffebee"
- },
- "markdownText": {
- "dark": "foreground",
- "light": "#292d3e"
- },
- "markdownHeading": {
- "dark": "purple",
- "light": "#a854f2"
- },
- "markdownLink": {
- "dark": "blue",
- "light": "#4976eb"
- },
- "markdownLinkText": {
- "dark": "cyan",
- "light": "#00acc1"
- },
- "markdownCode": {
- "dark": "green",
- "light": "#91b859"
- },
- "markdownBlockQuote": {
- "dark": "comment",
- "light": "#8796b0"
- },
- "markdownEmph": {
- "dark": "yellow",
- "light": "#ffb300"
- },
- "markdownStrong": {
- "dark": "orange",
- "light": "#f4511e"
- },
- "markdownHorizontalRule": {
- "dark": "comment",
- "light": "#8796b0"
- },
- "markdownListItem": {
- "dark": "blue",
- "light": "#4976eb"
- },
- "markdownListEnumeration": {
- "dark": "cyan",
- "light": "#00acc1"
- },
- "markdownImage": {
- "dark": "blue",
- "light": "#4976eb"
- },
- "markdownImageText": {
- "dark": "cyan",
- "light": "#00acc1"
- },
- "markdownCodeBlock": {
- "dark": "foreground",
- "light": "#292d3e"
- },
- "syntaxComment": {
- "dark": "comment",
- "light": "#8796b0"
- },
- "syntaxKeyword": {
- "dark": "purple",
- "light": "#a854f2"
- },
- "syntaxFunction": {
- "dark": "blue",
- "light": "#4976eb"
- },
- "syntaxVariable": {
- "dark": "foreground",
- "light": "#292d3e"
- },
- "syntaxString": {
- "dark": "green",
- "light": "#91b859"
- },
- "syntaxNumber": {
- "dark": "orange",
- "light": "#f4511e"
- },
- "syntaxType": {
- "dark": "yellow",
- "light": "#ffb300"
- },
- "syntaxOperator": {
- "dark": "cyan",
- "light": "#00acc1"
- },
- "syntaxPunctuation": {
- "dark": "foreground",
- "light": "#292d3e"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/rosepine.json b/packages/tui/internal/theme/themes/rosepine.json
deleted file mode 100644
index 444cdbd13..000000000
--- a/packages/tui/internal/theme/themes/rosepine.json
+++ /dev/null
@@ -1,234 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "base": "#191724",
- "surface": "#1f1d2e",
- "overlay": "#26233a",
- "muted": "#6e6a86",
- "subtle": "#908caa",
- "text": "#e0def4",
- "love": "#eb6f92",
- "gold": "#f6c177",
- "rose": "#ebbcba",
- "pine": "#31748f",
- "foam": "#9ccfd8",
- "iris": "#c4a7e7",
- "highlightLow": "#21202e",
- "highlightMed": "#403d52",
- "highlightHigh": "#524f67",
- "moonBase": "#232136",
- "moonSurface": "#2a273f",
- "moonOverlay": "#393552",
- "moonMuted": "#6e6a86",
- "moonSubtle": "#908caa",
- "moonText": "#e0def4",
- "dawnBase": "#faf4ed",
- "dawnSurface": "#fffaf3",
- "dawnOverlay": "#f2e9e1",
- "dawnMuted": "#9893a5",
- "dawnSubtle": "#797593",
- "dawnText": "#575279"
- },
- "theme": {
- "primary": {
- "dark": "foam",
- "light": "pine"
- },
- "secondary": {
- "dark": "iris",
- "light": "#907aa9"
- },
- "accent": {
- "dark": "rose",
- "light": "#d7827e"
- },
- "error": {
- "dark": "love",
- "light": "#b4637a"
- },
- "warning": {
- "dark": "gold",
- "light": "#ea9d34"
- },
- "success": {
- "dark": "pine",
- "light": "#286983"
- },
- "info": {
- "dark": "foam",
- "light": "#56949f"
- },
- "text": {
- "dark": "#e0def4",
- "light": "#575279"
- },
- "textMuted": {
- "dark": "muted",
- "light": "dawnMuted"
- },
- "background": {
- "dark": "base",
- "light": "dawnBase"
- },
- "backgroundPanel": {
- "dark": "surface",
- "light": "dawnSurface"
- },
- "backgroundElement": {
- "dark": "overlay",
- "light": "dawnOverlay"
- },
- "border": {
- "dark": "highlightMed",
- "light": "#dfdad9"
- },
- "borderActive": {
- "dark": "foam",
- "light": "pine"
- },
- "borderSubtle": {
- "dark": "highlightLow",
- "light": "#f4ede8"
- },
- "diffAdded": {
- "dark": "pine",
- "light": "#286983"
- },
- "diffRemoved": {
- "dark": "love",
- "light": "#b4637a"
- },
- "diffContext": {
- "dark": "muted",
- "light": "dawnMuted"
- },
- "diffHunkHeader": {
- "dark": "iris",
- "light": "#907aa9"
- },
- "diffHighlightAdded": {
- "dark": "pine",
- "light": "#286983"
- },
- "diffHighlightRemoved": {
- "dark": "love",
- "light": "#b4637a"
- },
- "diffAddedBg": {
- "dark": "#1f2d3a",
- "light": "#e5f2f3"
- },
- "diffRemovedBg": {
- "dark": "#3a1f2d",
- "light": "#fce5e8"
- },
- "diffContextBg": {
- "dark": "surface",
- "light": "dawnSurface"
- },
- "diffLineNumber": {
- "dark": "muted",
- "light": "dawnMuted"
- },
- "diffAddedLineNumberBg": {
- "dark": "#1f2d3a",
- "light": "#e5f2f3"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#3a1f2d",
- "light": "#fce5e8"
- },
- "markdownText": {
- "dark": "#e0def4",
- "light": "#575279"
- },
- "markdownHeading": {
- "dark": "iris",
- "light": "#907aa9"
- },
- "markdownLink": {
- "dark": "foam",
- "light": "pine"
- },
- "markdownLinkText": {
- "dark": "rose",
- "light": "#d7827e"
- },
- "markdownCode": {
- "dark": "pine",
- "light": "#286983"
- },
- "markdownBlockQuote": {
- "dark": "muted",
- "light": "dawnMuted"
- },
- "markdownEmph": {
- "dark": "gold",
- "light": "#ea9d34"
- },
- "markdownStrong": {
- "dark": "love",
- "light": "#b4637a"
- },
- "markdownHorizontalRule": {
- "dark": "highlightMed",
- "light": "#dfdad9"
- },
- "markdownListItem": {
- "dark": "foam",
- "light": "pine"
- },
- "markdownListEnumeration": {
- "dark": "rose",
- "light": "#d7827e"
- },
- "markdownImage": {
- "dark": "foam",
- "light": "pine"
- },
- "markdownImageText": {
- "dark": "rose",
- "light": "#d7827e"
- },
- "markdownCodeBlock": {
- "dark": "#e0def4",
- "light": "#575279"
- },
- "syntaxComment": {
- "dark": "muted",
- "light": "dawnMuted"
- },
- "syntaxKeyword": {
- "dark": "pine",
- "light": "#286983"
- },
- "syntaxFunction": {
- "dark": "rose",
- "light": "#d7827e"
- },
- "syntaxVariable": {
- "dark": "#e0def4",
- "light": "#575279"
- },
- "syntaxString": {
- "dark": "gold",
- "light": "#ea9d34"
- },
- "syntaxNumber": {
- "dark": "iris",
- "light": "#907aa9"
- },
- "syntaxType": {
- "dark": "foam",
- "light": "#56949f"
- },
- "syntaxOperator": {
- "dark": "subtle",
- "light": "dawnSubtle"
- },
- "syntaxPunctuation": {
- "dark": "subtle",
- "light": "dawnSubtle"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/solarized.json b/packages/tui/internal/theme/themes/solarized.json
deleted file mode 100644
index e4de11367..000000000
--- a/packages/tui/internal/theme/themes/solarized.json
+++ /dev/null
@@ -1,223 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "base03": "#002b36",
- "base02": "#073642",
- "base01": "#586e75",
- "base00": "#657b83",
- "base0": "#839496",
- "base1": "#93a1a1",
- "base2": "#eee8d5",
- "base3": "#fdf6e3",
- "yellow": "#b58900",
- "orange": "#cb4b16",
- "red": "#dc322f",
- "magenta": "#d33682",
- "violet": "#6c71c4",
- "blue": "#268bd2",
- "cyan": "#2aa198",
- "green": "#859900"
- },
- "theme": {
- "primary": {
- "dark": "blue",
- "light": "blue"
- },
- "secondary": {
- "dark": "violet",
- "light": "violet"
- },
- "accent": {
- "dark": "cyan",
- "light": "cyan"
- },
- "error": {
- "dark": "red",
- "light": "red"
- },
- "warning": {
- "dark": "yellow",
- "light": "yellow"
- },
- "success": {
- "dark": "green",
- "light": "green"
- },
- "info": {
- "dark": "orange",
- "light": "orange"
- },
- "text": {
- "dark": "base0",
- "light": "base00"
- },
- "textMuted": {
- "dark": "base01",
- "light": "base1"
- },
- "background": {
- "dark": "base03",
- "light": "base3"
- },
- "backgroundPanel": {
- "dark": "base02",
- "light": "base2"
- },
- "backgroundElement": {
- "dark": "#073642",
- "light": "#eee8d5"
- },
- "border": {
- "dark": "base02",
- "light": "base2"
- },
- "borderActive": {
- "dark": "base01",
- "light": "base1"
- },
- "borderSubtle": {
- "dark": "#073642",
- "light": "#eee8d5"
- },
- "diffAdded": {
- "dark": "green",
- "light": "green"
- },
- "diffRemoved": {
- "dark": "red",
- "light": "red"
- },
- "diffContext": {
- "dark": "base01",
- "light": "base1"
- },
- "diffHunkHeader": {
- "dark": "base01",
- "light": "base1"
- },
- "diffHighlightAdded": {
- "dark": "green",
- "light": "green"
- },
- "diffHighlightRemoved": {
- "dark": "red",
- "light": "red"
- },
- "diffAddedBg": {
- "dark": "#073642",
- "light": "#eee8d5"
- },
- "diffRemovedBg": {
- "dark": "#073642",
- "light": "#eee8d5"
- },
- "diffContextBg": {
- "dark": "base02",
- "light": "base2"
- },
- "diffLineNumber": {
- "dark": "base01",
- "light": "base1"
- },
- "diffAddedLineNumberBg": {
- "dark": "#073642",
- "light": "#eee8d5"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#073642",
- "light": "#eee8d5"
- },
- "markdownText": {
- "dark": "base0",
- "light": "base00"
- },
- "markdownHeading": {
- "dark": "blue",
- "light": "blue"
- },
- "markdownLink": {
- "dark": "cyan",
- "light": "cyan"
- },
- "markdownLinkText": {
- "dark": "violet",
- "light": "violet"
- },
- "markdownCode": {
- "dark": "green",
- "light": "green"
- },
- "markdownBlockQuote": {
- "dark": "base01",
- "light": "base1"
- },
- "markdownEmph": {
- "dark": "yellow",
- "light": "yellow"
- },
- "markdownStrong": {
- "dark": "orange",
- "light": "orange"
- },
- "markdownHorizontalRule": {
- "dark": "base01",
- "light": "base1"
- },
- "markdownListItem": {
- "dark": "blue",
- "light": "blue"
- },
- "markdownListEnumeration": {
- "dark": "cyan",
- "light": "cyan"
- },
- "markdownImage": {
- "dark": "cyan",
- "light": "cyan"
- },
- "markdownImageText": {
- "dark": "violet",
- "light": "violet"
- },
- "markdownCodeBlock": {
- "dark": "base0",
- "light": "base00"
- },
- "syntaxComment": {
- "dark": "base01",
- "light": "base1"
- },
- "syntaxKeyword": {
- "dark": "green",
- "light": "green"
- },
- "syntaxFunction": {
- "dark": "blue",
- "light": "blue"
- },
- "syntaxVariable": {
- "dark": "cyan",
- "light": "cyan"
- },
- "syntaxString": {
- "dark": "cyan",
- "light": "cyan"
- },
- "syntaxNumber": {
- "dark": "magenta",
- "light": "magenta"
- },
- "syntaxType": {
- "dark": "yellow",
- "light": "yellow"
- },
- "syntaxOperator": {
- "dark": "green",
- "light": "green"
- },
- "syntaxPunctuation": {
- "dark": "base0",
- "light": "base00"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/synthwave84.json b/packages/tui/internal/theme/themes/synthwave84.json
deleted file mode 100644
index d25bf3b49..000000000
--- a/packages/tui/internal/theme/themes/synthwave84.json
+++ /dev/null
@@ -1,226 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "background": "#262335",
- "backgroundAlt": "#1e1a29",
- "backgroundPanel": "#2a2139",
- "foreground": "#ffffff",
- "foregroundMuted": "#848bbd",
- "pink": "#ff7edb",
- "pinkBright": "#ff92df",
- "cyan": "#36f9f6",
- "cyanBright": "#72f1f8",
- "yellow": "#fede5d",
- "yellowBright": "#fff95d",
- "orange": "#ff8b39",
- "orangeBright": "#ff9f43",
- "purple": "#b084eb",
- "purpleBright": "#c792ea",
- "red": "#fe4450",
- "redBright": "#ff5e5b",
- "green": "#72f1b8",
- "greenBright": "#97f1d8"
- },
- "theme": {
- "primary": {
- "dark": "cyan",
- "light": "#00bcd4"
- },
- "secondary": {
- "dark": "pink",
- "light": "#e91e63"
- },
- "accent": {
- "dark": "purple",
- "light": "#9c27b0"
- },
- "error": {
- "dark": "red",
- "light": "#f44336"
- },
- "warning": {
- "dark": "yellow",
- "light": "#ff9800"
- },
- "success": {
- "dark": "green",
- "light": "#4caf50"
- },
- "info": {
- "dark": "orange",
- "light": "#ff5722"
- },
- "text": {
- "dark": "foreground",
- "light": "#262335"
- },
- "textMuted": {
- "dark": "foregroundMuted",
- "light": "#5c5c8a"
- },
- "background": {
- "dark": "#262335",
- "light": "#fafafa"
- },
- "backgroundPanel": {
- "dark": "#1e1a29",
- "light": "#f5f5f5"
- },
- "backgroundElement": {
- "dark": "#2a2139",
- "light": "#eeeeee"
- },
- "border": {
- "dark": "#495495",
- "light": "#e0e0e0"
- },
- "borderActive": {
- "dark": "cyan",
- "light": "#00bcd4"
- },
- "borderSubtle": {
- "dark": "#241b2f",
- "light": "#f0f0f0"
- },
- "diffAdded": {
- "dark": "green",
- "light": "#4caf50"
- },
- "diffRemoved": {
- "dark": "red",
- "light": "#f44336"
- },
- "diffContext": {
- "dark": "foregroundMuted",
- "light": "#5c5c8a"
- },
- "diffHunkHeader": {
- "dark": "purple",
- "light": "#9c27b0"
- },
- "diffHighlightAdded": {
- "dark": "greenBright",
- "light": "#4caf50"
- },
- "diffHighlightRemoved": {
- "dark": "redBright",
- "light": "#f44336"
- },
- "diffAddedBg": {
- "dark": "#1a3a2a",
- "light": "#e8f5e9"
- },
- "diffRemovedBg": {
- "dark": "#3a1a2a",
- "light": "#ffebee"
- },
- "diffContextBg": {
- "dark": "#1e1a29",
- "light": "#f5f5f5"
- },
- "diffLineNumber": {
- "dark": "#495495",
- "light": "#b0b0b0"
- },
- "diffAddedLineNumberBg": {
- "dark": "#1a3a2a",
- "light": "#e8f5e9"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#3a1a2a",
- "light": "#ffebee"
- },
- "markdownText": {
- "dark": "foreground",
- "light": "#262335"
- },
- "markdownHeading": {
- "dark": "pink",
- "light": "#e91e63"
- },
- "markdownLink": {
- "dark": "cyan",
- "light": "#00bcd4"
- },
- "markdownLinkText": {
- "dark": "purple",
- "light": "#9c27b0"
- },
- "markdownCode": {
- "dark": "green",
- "light": "#4caf50"
- },
- "markdownBlockQuote": {
- "dark": "foregroundMuted",
- "light": "#5c5c8a"
- },
- "markdownEmph": {
- "dark": "yellow",
- "light": "#ff9800"
- },
- "markdownStrong": {
- "dark": "orange",
- "light": "#ff5722"
- },
- "markdownHorizontalRule": {
- "dark": "#495495",
- "light": "#e0e0e0"
- },
- "markdownListItem": {
- "dark": "cyan",
- "light": "#00bcd4"
- },
- "markdownListEnumeration": {
- "dark": "purple",
- "light": "#9c27b0"
- },
- "markdownImage": {
- "dark": "cyan",
- "light": "#00bcd4"
- },
- "markdownImageText": {
- "dark": "purple",
- "light": "#9c27b0"
- },
- "markdownCodeBlock": {
- "dark": "foreground",
- "light": "#262335"
- },
- "syntaxComment": {
- "dark": "foregroundMuted",
- "light": "#5c5c8a"
- },
- "syntaxKeyword": {
- "dark": "pink",
- "light": "#e91e63"
- },
- "syntaxFunction": {
- "dark": "orange",
- "light": "#ff5722"
- },
- "syntaxVariable": {
- "dark": "foreground",
- "light": "#262335"
- },
- "syntaxString": {
- "dark": "yellow",
- "light": "#ff9800"
- },
- "syntaxNumber": {
- "dark": "purple",
- "light": "#9c27b0"
- },
- "syntaxType": {
- "dark": "cyan",
- "light": "#00bcd4"
- },
- "syntaxOperator": {
- "dark": "pink",
- "light": "#e91e63"
- },
- "syntaxPunctuation": {
- "dark": "foreground",
- "light": "#262335"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/tokyonight.json b/packages/tui/internal/theme/themes/tokyonight.json
deleted file mode 100644
index 1c9503a42..000000000
--- a/packages/tui/internal/theme/themes/tokyonight.json
+++ /dev/null
@@ -1,243 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "darkStep1": "#1a1b26",
- "darkStep2": "#1e2030",
- "darkStep3": "#222436",
- "darkStep4": "#292e42",
- "darkStep5": "#3b4261",
- "darkStep6": "#545c7e",
- "darkStep7": "#737aa2",
- "darkStep8": "#9099b2",
- "darkStep9": "#82aaff",
- "darkStep10": "#89b4fa",
- "darkStep11": "#828bb8",
- "darkStep12": "#c8d3f5",
- "darkRed": "#ff757f",
- "darkOrange": "#ff966c",
- "darkYellow": "#ffc777",
- "darkGreen": "#c3e88d",
- "darkCyan": "#86e1fc",
- "darkPurple": "#c099ff",
- "lightStep1": "#e1e2e7",
- "lightStep2": "#d5d6db",
- "lightStep3": "#c8c9ce",
- "lightStep4": "#b9bac1",
- "lightStep5": "#a8aecb",
- "lightStep6": "#9699a8",
- "lightStep7": "#737a8c",
- "lightStep8": "#5a607d",
- "lightStep9": "#2e7de9",
- "lightStep10": "#1a6ce7",
- "lightStep11": "#8990a3",
- "lightStep12": "#3760bf",
- "lightRed": "#f52a65",
- "lightOrange": "#b15c00",
- "lightYellow": "#8c6c3e",
- "lightGreen": "#587539",
- "lightCyan": "#007197",
- "lightPurple": "#9854f1"
- },
- "theme": {
- "primary": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "secondary": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "accent": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "error": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "warning": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "success": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "info": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "text": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "textMuted": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "background": {
- "dark": "darkStep1",
- "light": "lightStep1"
- },
- "backgroundPanel": {
- "dark": "darkStep2",
- "light": "lightStep2"
- },
- "backgroundElement": {
- "dark": "darkStep3",
- "light": "lightStep3"
- },
- "border": {
- "dark": "darkStep7",
- "light": "lightStep7"
- },
- "borderActive": {
- "dark": "darkStep8",
- "light": "lightStep8"
- },
- "borderSubtle": {
- "dark": "darkStep6",
- "light": "lightStep6"
- },
- "diffAdded": {
- "dark": "#4fd6be",
- "light": "#1e725c"
- },
- "diffRemoved": {
- "dark": "#c53b53",
- "light": "#c53b53"
- },
- "diffContext": {
- "dark": "#828bb8",
- "light": "#7086b5"
- },
- "diffHunkHeader": {
- "dark": "#828bb8",
- "light": "#7086b5"
- },
- "diffHighlightAdded": {
- "dark": "#b8db87",
- "light": "#4db380"
- },
- "diffHighlightRemoved": {
- "dark": "#e26a75",
- "light": "#f52a65"
- },
- "diffAddedBg": {
- "dark": "#20303b",
- "light": "#d5e5d5"
- },
- "diffRemovedBg": {
- "dark": "#37222c",
- "light": "#f7d8db"
- },
- "diffContextBg": {
- "dark": "darkStep2",
- "light": "lightStep2"
- },
- "diffLineNumber": {
- "dark": "darkStep3",
- "light": "lightStep3"
- },
- "diffAddedLineNumberBg": {
- "dark": "#1b2b34",
- "light": "#c5d5c5"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#2d1f26",
- "light": "#e7c8cb"
- },
- "markdownText": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "markdownHeading": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "markdownLink": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownLinkText": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownCode": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "markdownBlockQuote": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "markdownEmph": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "markdownStrong": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "markdownHorizontalRule": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "markdownListItem": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownListEnumeration": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownImage": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "markdownImageText": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "markdownCodeBlock": {
- "dark": "darkStep12",
- "light": "lightStep12"
- },
- "syntaxComment": {
- "dark": "darkStep11",
- "light": "lightStep11"
- },
- "syntaxKeyword": {
- "dark": "darkPurple",
- "light": "lightPurple"
- },
- "syntaxFunction": {
- "dark": "darkStep9",
- "light": "lightStep9"
- },
- "syntaxVariable": {
- "dark": "darkRed",
- "light": "lightRed"
- },
- "syntaxString": {
- "dark": "darkGreen",
- "light": "lightGreen"
- },
- "syntaxNumber": {
- "dark": "darkOrange",
- "light": "lightOrange"
- },
- "syntaxType": {
- "dark": "darkYellow",
- "light": "lightYellow"
- },
- "syntaxOperator": {
- "dark": "darkCyan",
- "light": "lightCyan"
- },
- "syntaxPunctuation": {
- "dark": "darkStep12",
- "light": "lightStep12"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/vesper.json b/packages/tui/internal/theme/themes/vesper.json
deleted file mode 100644
index 758c8f20c..000000000
--- a/packages/tui/internal/theme/themes/vesper.json
+++ /dev/null
@@ -1,218 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "vesperBg": "#101010",
- "vesperFg": "#FFF",
- "vesperComment": "#8b8b8b",
- "vesperKeyword": "#A0A0A0",
- "vesperFunction": "#FFC799",
- "vesperString": "#99FFE4",
- "vesperNumber": "#FFC799",
- "vesperError": "#FF8080",
- "vesperWarning": "#FFC799",
- "vesperSuccess": "#99FFE4",
- "vesperMuted": "#A0A0A0"
- },
- "theme": {
- "primary": {
- "dark": "#FFC799",
- "light": "#FFC799"
- },
- "secondary": {
- "dark": "#99FFE4",
- "light": "#99FFE4"
- },
- "accent": {
- "dark": "#FFC799",
- "light": "#FFC799"
- },
- "error": {
- "dark": "vesperError",
- "light": "vesperError"
- },
- "warning": {
- "dark": "vesperWarning",
- "light": "vesperWarning"
- },
- "success": {
- "dark": "vesperSuccess",
- "light": "vesperSuccess"
- },
- "info": {
- "dark": "#FFC799",
- "light": "#FFC799"
- },
- "text": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "textMuted": {
- "dark": "vesperMuted",
- "light": "vesperMuted"
- },
- "background": {
- "dark": "vesperBg",
- "light": "#FFF"
- },
- "backgroundPanel": {
- "dark": "vesperBg",
- "light": "#F0F0F0"
- },
- "backgroundElement": {
- "dark": "vesperBg",
- "light": "#E0E0E0"
- },
- "border": {
- "dark": "#282828",
- "light": "#D0D0D0"
- },
- "borderActive": {
- "dark": "#FFC799",
- "light": "#FFC799"
- },
- "borderSubtle": {
- "dark": "#1C1C1C",
- "light": "#E8E8E8"
- },
- "diffAdded": {
- "dark": "vesperSuccess",
- "light": "vesperSuccess"
- },
- "diffRemoved": {
- "dark": "vesperError",
- "light": "vesperError"
- },
- "diffContext": {
- "dark": "vesperMuted",
- "light": "vesperMuted"
- },
- "diffHunkHeader": {
- "dark": "vesperMuted",
- "light": "vesperMuted"
- },
- "diffHighlightAdded": {
- "dark": "vesperSuccess",
- "light": "vesperSuccess"
- },
- "diffHighlightRemoved": {
- "dark": "vesperError",
- "light": "vesperError"
- },
- "diffAddedBg": {
- "dark": "#0d2818",
- "light": "#e8f5e8"
- },
- "diffRemovedBg": {
- "dark": "#281a1a",
- "light": "#f5e8e8"
- },
- "diffContextBg": {
- "dark": "vesperBg",
- "light": "#F8F8F8"
- },
- "diffLineNumber": {
- "dark": "#505050",
- "light": "#808080"
- },
- "diffAddedLineNumberBg": {
- "dark": "#0d2818",
- "light": "#e8f5e8"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#281a1a",
- "light": "#f5e8e8"
- },
- "markdownText": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "markdownHeading": {
- "dark": "#FFC799",
- "light": "#FFC799"
- },
- "markdownLink": {
- "dark": "#FFC799",
- "light": "#FFC799"
- },
- "markdownLinkText": {
- "dark": "vesperMuted",
- "light": "vesperMuted"
- },
- "markdownCode": {
- "dark": "vesperMuted",
- "light": "vesperMuted"
- },
- "markdownBlockQuote": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "markdownEmph": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "markdownStrong": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "markdownHorizontalRule": {
- "dark": "#65737E",
- "light": "#65737E"
- },
- "markdownListItem": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "markdownListEnumeration": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "markdownImage": {
- "dark": "#FFC799",
- "light": "#FFC799"
- },
- "markdownImageText": {
- "dark": "vesperMuted",
- "light": "vesperMuted"
- },
- "markdownCodeBlock": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "syntaxComment": {
- "dark": "vesperComment",
- "light": "vesperComment"
- },
- "syntaxKeyword": {
- "dark": "vesperKeyword",
- "light": "vesperKeyword"
- },
- "syntaxFunction": {
- "dark": "vesperFunction",
- "light": "vesperFunction"
- },
- "syntaxVariable": {
- "dark": "vesperFg",
- "light": "vesperBg"
- },
- "syntaxString": {
- "dark": "vesperString",
- "light": "vesperString"
- },
- "syntaxNumber": {
- "dark": "vesperNumber",
- "light": "vesperNumber"
- },
- "syntaxType": {
- "dark": "vesperFunction",
- "light": "vesperFunction"
- },
- "syntaxOperator": {
- "dark": "vesperKeyword",
- "light": "vesperKeyword"
- },
- "syntaxPunctuation": {
- "dark": "vesperFg",
- "light": "vesperBg"
- }
- }
-}
diff --git a/packages/tui/internal/theme/themes/zenburn.json b/packages/tui/internal/theme/themes/zenburn.json
deleted file mode 100644
index c4475923b..000000000
--- a/packages/tui/internal/theme/themes/zenburn.json
+++ /dev/null
@@ -1,223 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "bg": "#3f3f3f",
- "bgAlt": "#4f4f4f",
- "bgPanel": "#5f5f5f",
- "fg": "#dcdccc",
- "fgMuted": "#9f9f9f",
- "red": "#cc9393",
- "redBright": "#dca3a3",
- "green": "#7f9f7f",
- "greenBright": "#8fb28f",
- "yellow": "#f0dfaf",
- "yellowDim": "#e0cf9f",
- "blue": "#8cd0d3",
- "blueDim": "#7cb8bb",
- "magenta": "#dc8cc3",
- "cyan": "#93e0e3",
- "orange": "#dfaf8f"
- },
- "theme": {
- "primary": {
- "dark": "blue",
- "light": "#5f7f8f"
- },
- "secondary": {
- "dark": "magenta",
- "light": "#8f5f8f"
- },
- "accent": {
- "dark": "cyan",
- "light": "#5f8f8f"
- },
- "error": {
- "dark": "red",
- "light": "#8f5f5f"
- },
- "warning": {
- "dark": "yellow",
- "light": "#8f8f5f"
- },
- "success": {
- "dark": "green",
- "light": "#5f8f5f"
- },
- "info": {
- "dark": "orange",
- "light": "#8f7f5f"
- },
- "text": {
- "dark": "fg",
- "light": "#3f3f3f"
- },
- "textMuted": {
- "dark": "fgMuted",
- "light": "#6f6f6f"
- },
- "background": {
- "dark": "bg",
- "light": "#ffffef"
- },
- "backgroundPanel": {
- "dark": "bgAlt",
- "light": "#f5f5e5"
- },
- "backgroundElement": {
- "dark": "bgPanel",
- "light": "#ebebdb"
- },
- "border": {
- "dark": "#5f5f5f",
- "light": "#d0d0c0"
- },
- "borderActive": {
- "dark": "blue",
- "light": "#5f7f8f"
- },
- "borderSubtle": {
- "dark": "#4f4f4f",
- "light": "#e0e0d0"
- },
- "diffAdded": {
- "dark": "green",
- "light": "#5f8f5f"
- },
- "diffRemoved": {
- "dark": "red",
- "light": "#8f5f5f"
- },
- "diffContext": {
- "dark": "fgMuted",
- "light": "#6f6f6f"
- },
- "diffHunkHeader": {
- "dark": "cyan",
- "light": "#5f8f8f"
- },
- "diffHighlightAdded": {
- "dark": "greenBright",
- "light": "#5f8f5f"
- },
- "diffHighlightRemoved": {
- "dark": "redBright",
- "light": "#8f5f5f"
- },
- "diffAddedBg": {
- "dark": "#4f5f4f",
- "light": "#efffef"
- },
- "diffRemovedBg": {
- "dark": "#5f4f4f",
- "light": "#ffefef"
- },
- "diffContextBg": {
- "dark": "bgAlt",
- "light": "#f5f5e5"
- },
- "diffLineNumber": {
- "dark": "#6f6f6f",
- "light": "#b0b0a0"
- },
- "diffAddedLineNumberBg": {
- "dark": "#4f5f4f",
- "light": "#efffef"
- },
- "diffRemovedLineNumberBg": {
- "dark": "#5f4f4f",
- "light": "#ffefef"
- },
- "markdownText": {
- "dark": "fg",
- "light": "#3f3f3f"
- },
- "markdownHeading": {
- "dark": "yellow",
- "light": "#8f8f5f"
- },
- "markdownLink": {
- "dark": "blue",
- "light": "#5f7f8f"
- },
- "markdownLinkText": {
- "dark": "cyan",
- "light": "#5f8f8f"
- },
- "markdownCode": {
- "dark": "green",
- "light": "#5f8f5f"
- },
- "markdownBlockQuote": {
- "dark": "fgMuted",
- "light": "#6f6f6f"
- },
- "markdownEmph": {
- "dark": "yellowDim",
- "light": "#8f8f5f"
- },
- "markdownStrong": {
- "dark": "orange",
- "light": "#8f7f5f"
- },
- "markdownHorizontalRule": {
- "dark": "fgMuted",
- "light": "#6f6f6f"
- },
- "markdownListItem": {
- "dark": "blue",
- "light": "#5f7f8f"
- },
- "markdownListEnumeration": {
- "dark": "cyan",
- "light": "#5f8f8f"
- },
- "markdownImage": {
- "dark": "blue",
- "light": "#5f7f8f"
- },
- "markdownImageText": {
- "dark": "cyan",
- "light": "#5f8f8f"
- },
- "markdownCodeBlock": {
- "dark": "fg",
- "light": "#3f3f3f"
- },
- "syntaxComment": {
- "dark": "#7f9f7f",
- "light": "#5f7f5f"
- },
- "syntaxKeyword": {
- "dark": "yellow",
- "light": "#8f8f5f"
- },
- "syntaxFunction": {
- "dark": "blue",
- "light": "#5f7f8f"
- },
- "syntaxVariable": {
- "dark": "fg",
- "light": "#3f3f3f"
- },
- "syntaxString": {
- "dark": "red",
- "light": "#8f5f5f"
- },
- "syntaxNumber": {
- "dark": "greenBright",
- "light": "#5f8f5f"
- },
- "syntaxType": {
- "dark": "cyan",
- "light": "#5f8f8f"
- },
- "syntaxOperator": {
- "dark": "yellow",
- "light": "#8f8f5f"
- },
- "syntaxPunctuation": {
- "dark": "fg",
- "light": "#3f3f3f"
- }
- }
-}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
deleted file mode 100644
index 3a0bc3730..000000000
--- a/packages/tui/internal/tui/tui.go
+++ /dev/null
@@ -1,1636 +0,0 @@
-package tui
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "slices"
- "strings"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/api"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/completions"
- "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/modal"
- "github.com/sst/opencode/internal/components/status"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
-type InterruptDebounceTimeoutMsg struct{}
-
-// ExitDebounceTimeoutMsg is sent when the exit key debounce timeout expires
-type ExitDebounceTimeoutMsg struct{}
-
-// InterruptKeyState tracks the state of interrupt key presses for debouncing
-type InterruptKeyState int
-
-// ExitKeyState tracks the state of exit key presses for debouncing
-type ExitKeyState int
-
-const (
- InterruptKeyIdle InterruptKeyState = iota
- InterruptKeyFirstPress
-)
-
-const (
- ExitKeyIdle ExitKeyState = iota
- ExitKeyFirstPress
-)
-
-const interruptDebounceTimeout = 1 * time.Second
-const exitDebounceTimeout = 1 * time.Second
-
-type Model struct {
- tea.Model
- tea.CursorModel
- width, height int
- app *app.App
- modal layout.Modal
- status status.StatusComponent
- editor chat.EditorComponent
- messages chat.MessagesComponent
- completions dialog.CompletionDialog
- commandProvider completions.CompletionProvider
- fileProvider completions.CompletionProvider
- symbolsProvider completions.CompletionProvider
- agentsProvider completions.CompletionProvider
- showCompletionDialog bool
- leaderBinding *key.Binding
- toastManager *toast.ToastManager
- interruptKeyState InterruptKeyState
- exitKeyState ExitKeyState
- messagesRight bool
-}
-
-func (a Model) Init() tea.Cmd {
- var cmds []tea.Cmd
- // https://github.com/charmbracelet/bubbletea/issues/1440
- // https://github.com/sst/opencode/issues/127
- if !util.IsWsl() {
- cmds = append(cmds, tea.RequestBackgroundColor)
- }
- cmds = append(cmds, a.app.InitializeProvider())
- cmds = append(cmds, a.editor.Init())
- cmds = append(cmds, a.messages.Init())
- cmds = append(cmds, a.status.Init())
- cmds = append(cmds, a.completions.Init())
- cmds = append(cmds, a.toastManager.Init())
-
- return tea.Batch(cmds...)
-}
-
-func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- keyString := msg.String()
-
- if a.app.CurrentPermission.ID != "" {
- if keyString == "enter" || keyString == "esc" || keyString == "a" {
- sessionID := a.app.CurrentPermission.SessionID
- permissionID := a.app.CurrentPermission.ID
- a.editor.Focus()
- a.app.Permissions = a.app.Permissions[1:]
- if len(a.app.Permissions) > 0 {
- a.app.CurrentPermission = a.app.Permissions[0]
- } else {
- a.app.CurrentPermission = opencode.Permission{}
- }
- response := opencode.SessionPermissionRespondParamsResponseOnce
- switch keyString {
- case "enter":
- response = opencode.SessionPermissionRespondParamsResponseOnce
- case "a":
- response = opencode.SessionPermissionRespondParamsResponseAlways
- case "esc":
- response = opencode.SessionPermissionRespondParamsResponseReject
- }
-
- return a, func() tea.Msg {
- resp, err := a.app.Client.Session.Permissions.Respond(
- context.Background(),
- sessionID,
- permissionID,
- opencode.SessionPermissionRespondParams{Response: opencode.F(response)},
- )
- if err != nil {
- slog.Error("Failed to respond to permission request", "error", err)
- return toast.NewErrorToast("Failed to respond to permission request")()
- }
- slog.Debug("Responded to permission request", "response", resp)
- return nil
- }
- }
- }
-
- if a.app.IsBashMode {
- if keyString == "backspace" && a.editor.Length() == 0 {
- a.app.IsBashMode = false
- return a, nil
- }
-
- if keyString == "enter" || keyString == "esc" || keyString == "ctrl+c" {
- a.app.IsBashMode = false
- if keyString == "enter" {
- updated, cmd := a.editor.SubmitBash()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- }
- return a, tea.Batch(cmds...)
- }
- }
-
- // 1. Handle active modal
- if a.modal != nil {
- switch keyString {
- // Escape closes current modal, but give modal a chance to handle it first
- case "esc":
- // give the modal a chance to handle the esc
- 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
- 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
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- return a, cmd
- }
-
- // 2. Check for commands that require leader
- if a.app.IsLeaderSequence {
- matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
- a.app.IsLeaderSequence = false
- if len(matches) > 0 {
- return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
- }
- }
-
- // 3. Handle completions trigger
- if keyString == "/" &&
- !a.showCompletionDialog &&
- a.editor.Value() == "" &&
- !a.app.IsBashMode {
- a.showCompletionDialog = true
-
- updated, cmd := a.editor.Update(msg)
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- // Set command provider for command completion
- a.completions = dialog.NewCompletionDialogComponent("/", a.commandProvider)
- updated, cmd = a.completions.Update(msg)
- a.completions = updated.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
-
- return a, tea.Sequence(cmds...)
- }
-
- // Handle file completions trigger
- if keyString == "@" &&
- !a.showCompletionDialog &&
- !a.app.IsBashMode {
- a.showCompletionDialog = true
-
- updated, cmd := a.editor.Update(msg)
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- // Set file, symbols, and agents providers for @ completion
- a.completions = dialog.NewCompletionDialogComponent("@", a.agentsProvider, a.fileProvider, a.symbolsProvider)
- updated, cmd = a.completions.Update(msg)
- a.completions = updated.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
-
- return a, tea.Sequence(cmds...)
- }
-
- if keyString == "!" && a.editor.Value() == "" {
- a.app.IsBashMode = true
- return a, nil
- }
-
- if a.showCompletionDialog {
- switch keyString {
- case "tab", "enter", "esc", "ctrl+c", "up", "down", "ctrl+p", "ctrl+n":
- updated, cmd := a.completions.Update(msg)
- a.completions = updated.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
- }
-
- updated, cmd := a.editor.Update(msg)
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- updated, cmd = a.completions.Update(msg)
- a.completions = updated.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
-
- return a, tea.Batch(cmds...)
- }
-
- // 4. Maximize editor responsiveness for printable characters
- if msg.Text != "" {
- updated, cmd := a.editor.Update(msg)
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
- }
-
- // 5. Check for leader key activation
- if a.leaderBinding != nil &&
- !a.app.IsLeaderSequence &&
- key.Matches(msg, *a.leaderBinding) {
- a.app.IsLeaderSequence = true
- return a, nil
- }
-
- // 6 Handle input clear command
- inputClearCommand := a.app.Commands[commands.InputClearCommand]
- if inputClearCommand.Matches(msg, a.app.IsLeaderSequence) && a.editor.Length() > 0 {
- return a, util.CmdHandler(commands.ExecuteCommandMsg(inputClearCommand))
- }
-
- // 7. Handle interrupt key debounce for session interrupt
- interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
- if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() {
- switch a.interruptKeyState {
- case InterruptKeyIdle:
- // First interrupt key press - start debounce timer
- a.interruptKeyState = InterruptKeyFirstPress
- a.editor.SetInterruptKeyInDebounce(true)
- return a, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg {
- return InterruptDebounceTimeoutMsg{}
- })
- case InterruptKeyFirstPress:
- // Second interrupt key press within timeout - actually interrupt
- a.interruptKeyState = InterruptKeyIdle
- a.editor.SetInterruptKeyInDebounce(false)
- return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand))
- }
- }
-
- // 8. Handle exit key debounce for app exit when using non-leader command
- exitCommand := a.app.Commands[commands.AppExitCommand]
- if exitCommand.Matches(msg, a.app.IsLeaderSequence) {
- switch a.exitKeyState {
- case ExitKeyIdle:
- // First exit key press - start debounce timer
- a.exitKeyState = ExitKeyFirstPress
- a.editor.SetExitKeyInDebounce(true)
- return a, tea.Tick(exitDebounceTimeout, func(t time.Time) tea.Msg {
- return ExitDebounceTimeoutMsg{}
- })
- case ExitKeyFirstPress:
- // Second exit key press within timeout - actually exit
- a.exitKeyState = ExitKeyIdle
- a.editor.SetExitKeyInDebounce(false)
- return a, util.CmdHandler(commands.ExecuteCommandMsg(exitCommand))
- }
- }
-
- // 9. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce)
- matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
- if len(matches) > 0 {
- // Skip interrupt key if we're in debounce mode and app is busy
- if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
- return a, nil
- }
- return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
- }
-
- // Fallback: suspend if ctrl+z is pressed and no user keybind matched
- if keyString == "ctrl+z" {
- return a, tea.Suspend
- }
-
- // 10. Fallback to editor. This is for other characters like backspace, tab, etc.
- updatedEditor, cmd := a.editor.Update(msg)
- a.editor = updatedEditor.(chat.EditorComponent)
- return a, cmd
- case tea.MouseWheelMsg:
- if a.modal != nil {
- u, cmd := a.modal.Update(msg)
- a.modal = u.(layout.Modal)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
- }
-
- updated, cmd := a.messages.Update(msg)
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
- case tea.BackgroundColorMsg:
- styles.Terminal = &styles.TerminalInfo{
- Background: msg.Color,
- BackgroundIsDark: msg.IsDark(),
- }
- slog.Debug("Background color", "color", msg.String(), "isDark", msg.IsDark())
- return a, func() tea.Msg {
- theme.UpdateSystemTheme(
- styles.Terminal.Background,
- styles.Terminal.BackgroundIsDark,
- )
- return dialog.ThemeSelectedMsg{
- ThemeName: theme.CurrentThemeName(),
- }
- }
- case modal.CloseModalMsg:
- a.editor.Focus()
- var cmd tea.Cmd
- if a.modal != nil {
- cmd = a.modal.Close()
- }
- a.modal = nil
- return a, cmd
- case dialog.ReopenSessionModalMsg:
- // Reopen the session modal (used when exiting rename mode)
- sessionDialog := dialog.NewSessionDialog(a.app)
- a.modal = sessionDialog
- return a, nil
- case commands.ExecuteCommandMsg:
- updated, cmd := a.executeCommand(commands.Command(msg))
- return updated, cmd
- case commands.ExecuteCommandsMsg:
- for _, command := range msg {
- updated, cmd := a.executeCommand(command)
- if cmd != nil {
- return updated, cmd
- }
- }
- case error:
- return a, toast.NewErrorToast(msg.Error())
- case app.SendPrompt:
- a.showCompletionDialog = false
- // If we're in a child session, switch back to parent before sending prompt
- if a.app.Session.ParentID != "" {
- parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return a, toast.NewErrorToast("Failed to get parent session")
- }
- a.app.Session = parentSession
- a.app, cmd = a.app.SendPrompt(context.Background(), msg)
- cmds = append(cmds, tea.Sequence(
- util.CmdHandler(app.SessionSelectedMsg(parentSession)),
- cmd,
- ))
- } else {
- a.app, cmd = a.app.SendPrompt(context.Background(), msg)
- cmds = append(cmds, cmd)
- }
- case app.SendCommand:
- // If we're in a child session, switch back to parent before sending prompt
- if a.app.Session.ParentID != "" {
- parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return a, toast.NewErrorToast("Failed to get parent session")
- }
- a.app.Session = parentSession
- a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args)
- cmds = append(cmds, tea.Sequence(
- util.CmdHandler(app.SessionSelectedMsg(parentSession)),
- cmd,
- ))
- } else {
- a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args)
- cmds = append(cmds, cmd)
- }
- case app.SendShell:
- // If we're in a child session, switch back to parent before sending prompt
- if a.app.Session.ParentID != "" {
- parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return a, toast.NewErrorToast("Failed to get parent session")
- }
- a.app.Session = parentSession
- a.app, cmd = a.app.SendShell(context.Background(), msg.Command)
- cmds = append(cmds, tea.Sequence(
- util.CmdHandler(app.SessionSelectedMsg(parentSession)),
- cmd,
- ))
- } else {
- a.app, cmd = a.app.SendShell(context.Background(), msg.Command)
- cmds = append(cmds, cmd)
- }
- case app.SetEditorContentMsg:
- // Set the editor content without sending
- a.editor.SetValueWithAttachments(msg.Text)
- updated, cmd := a.editor.Focus()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case app.SessionClearedMsg:
- a.app.Session = &opencode.Session{}
- a.app.Messages = []app.Message{}
- case dialog.CompletionDialogCloseMsg:
- a.showCompletionDialog = false
- case opencode.EventListResponseEventInstallationUpdated:
- return a, toast.NewSuccessToast(
- "opencode updated to "+msg.Properties.Version+", restart to apply.",
- toast.WithTitle("New version installed"),
- )
- /*
- case opencode.EventListResponseEventIdeInstalled:
- return a, toast.NewSuccessToast(
- "Installed the opencode extension in "+msg.Properties.Ide,
- toast.WithTitle(msg.Properties.Ide+" extension installed"),
- )
- */
- case opencode.EventListResponseEventSessionDeleted:
- if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
- a.app.Session = &opencode.Session{}
- a.app.Messages = []app.Message{}
- }
- return a, toast.NewSuccessToast("Session deleted successfully")
- case opencode.EventListResponseEventSessionUpdated:
- if msg.Properties.Info.ID == a.app.Session.ID {
- a.app.Session = &msg.Properties.Info
- }
- case opencode.EventListResponseEventMessagePartUpdated:
- slog.Debug("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID)
- if msg.Properties.Part.SessionID == a.app.Session.ID {
- messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
- switch casted := m.Info.(type) {
- case opencode.UserMessage:
- return casted.ID == msg.Properties.Part.MessageID
- case opencode.AssistantMessage:
- return casted.ID == msg.Properties.Part.MessageID
- }
- return false
- })
- if messageIndex > -1 {
- message := a.app.Messages[messageIndex]
- partIndex := slices.IndexFunc(message.Parts, func(p opencode.PartUnion) bool {
- switch casted := p.(type) {
- case opencode.TextPart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.ReasoningPart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.FilePart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.ToolPart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.StepStartPart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.StepFinishPart:
- return casted.ID == msg.Properties.Part.ID
- }
- return false
- })
- if partIndex > -1 {
- message.Parts[partIndex] = msg.Properties.Part.AsUnion()
- }
- if partIndex == -1 {
- message.Parts = append(message.Parts, msg.Properties.Part.AsUnion())
- }
- a.app.Messages[messageIndex] = message
- }
- }
- case opencode.EventListResponseEventMessagePartRemoved:
- slog.Debug("message part removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID, "part", msg.Properties.PartID)
- if msg.Properties.SessionID == a.app.Session.ID {
- messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
- switch casted := m.Info.(type) {
- case opencode.UserMessage:
- return casted.ID == msg.Properties.MessageID
- case opencode.AssistantMessage:
- return casted.ID == msg.Properties.MessageID
- }
- return false
- })
- if messageIndex > -1 {
- message := a.app.Messages[messageIndex]
- partIndex := slices.IndexFunc(message.Parts, func(p opencode.PartUnion) bool {
- switch casted := p.(type) {
- case opencode.TextPart:
- return casted.ID == msg.Properties.PartID
- case opencode.ReasoningPart:
- return casted.ID == msg.Properties.PartID
- case opencode.FilePart:
- return casted.ID == msg.Properties.PartID
- case opencode.ToolPart:
- return casted.ID == msg.Properties.PartID
- case opencode.StepStartPart:
- return casted.ID == msg.Properties.PartID
- case opencode.StepFinishPart:
- return casted.ID == msg.Properties.PartID
- }
- return false
- })
- if partIndex > -1 {
- // Remove the part at partIndex
- message.Parts = append(message.Parts[:partIndex], message.Parts[partIndex+1:]...)
- a.app.Messages[messageIndex] = message
- }
- }
- }
- case opencode.EventListResponseEventMessageRemoved:
- slog.Debug("message removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID)
- if msg.Properties.SessionID == a.app.Session.ID {
- messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
- switch casted := m.Info.(type) {
- case opencode.UserMessage:
- return casted.ID == msg.Properties.MessageID
- case opencode.AssistantMessage:
- return casted.ID == msg.Properties.MessageID
- }
- return false
- })
- if messageIndex > -1 {
- a.app.Messages = append(a.app.Messages[:messageIndex], a.app.Messages[messageIndex+1:]...)
- }
- }
- case opencode.EventListResponseEventMessageUpdated:
- if msg.Properties.Info.SessionID == a.app.Session.ID {
- matchIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
- switch casted := m.Info.(type) {
- case opencode.UserMessage:
- return casted.ID == msg.Properties.Info.ID
- case opencode.AssistantMessage:
- return casted.ID == msg.Properties.Info.ID
- }
- return false
- })
-
- if matchIndex > -1 {
- match := a.app.Messages[matchIndex]
- a.app.Messages[matchIndex] = app.Message{
- Info: msg.Properties.Info.AsUnion(),
- Parts: match.Parts,
- }
- }
-
- if matchIndex == -1 {
- // Extract the new message ID
- var newMessageID string
- switch casted := msg.Properties.Info.AsUnion().(type) {
- case opencode.UserMessage:
- newMessageID = casted.ID
- case opencode.AssistantMessage:
- newMessageID = casted.ID
- }
-
- // Find the correct insertion index by scanning backwards
- // Most messages are added to the end, so start from the end
- insertIndex := len(a.app.Messages)
- for i := len(a.app.Messages) - 1; i >= 0; i-- {
- var existingID string
- switch casted := a.app.Messages[i].Info.(type) {
- case opencode.UserMessage:
- existingID = casted.ID
- case opencode.AssistantMessage:
- existingID = casted.ID
- }
- if existingID < newMessageID {
- insertIndex = i + 1
- break
- }
- }
-
- // Create the new message
- newMessage := app.Message{
- Info: msg.Properties.Info.AsUnion(),
- Parts: []opencode.PartUnion{},
- }
-
- // Insert at the correct position
- a.app.Messages = append(a.app.Messages[:insertIndex], append([]app.Message{newMessage}, a.app.Messages[insertIndex:]...)...)
- }
- }
- case opencode.EventListResponseEventPermissionUpdated:
- slog.Debug("permission updated", "session", msg.Properties.SessionID, "permission", msg.Properties.ID)
- a.app.Permissions = append(a.app.Permissions, msg.Properties)
- a.app.CurrentPermission = a.app.Permissions[0]
- a.editor.Blur()
- case opencode.EventListResponseEventPermissionReplied:
- index := slices.IndexFunc(a.app.Permissions, func(p opencode.Permission) bool {
- return p.ID == msg.Properties.PermissionID
- })
- if index > -1 {
- a.app.Permissions = append(a.app.Permissions[:index], a.app.Permissions[index+1:]...)
- }
- if a.app.CurrentPermission.ID == msg.Properties.PermissionID {
- if len(a.app.Permissions) > 0 {
- a.app.CurrentPermission = a.app.Permissions[0]
- } else {
- a.app.CurrentPermission = opencode.Permission{}
- }
- }
- case opencode.EventListResponseEventSessionError:
- switch err := msg.Properties.Error.AsUnion().(type) {
- case nil:
- // No error details provided
- case opencode.ProviderAuthError:
- slog.Error("Failed to authenticate with provider", "error", err.Data.Message)
- return a, toast.NewErrorToast("Provider error: " + err.Data.Message)
- case opencode.UnknownError:
- 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.EventListResponseEventSessionErrorPropertiesErrorAPIError:
- slog.Error("API error", "message", err.Data.Message, "statusCode", err.Data.StatusCode)
- return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
- case opencode.MessageAbortedError:
- // Message was aborted - this is expected when user cancels, so just log it
- slog.Debug("Message aborted", "message", err.Data.Message)
- case opencode.EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError:
- slog.Error("Message output length error")
- return a, toast.NewErrorToast("Message output length exceeded limit")
- default:
- // Handle any unhandled error types
- slog.Error("Unhandled session error type", "type", fmt.Sprintf("%T", err))
- return a, toast.NewErrorToast("An unexpected error occurred")
- }
- case opencode.EventListResponseEventSessionCompacted:
- if msg.Properties.SessionID == a.app.Session.ID {
- return a, toast.NewSuccessToast("Session compacted successfully")
- }
- case tea.WindowSizeMsg:
- msg.Height -= 2 // Make space for the status bar
- a.width, a.height = msg.Width, msg.Height
- container := min(a.width, 86)
- layout.Current = &layout.LayoutInfo{
- Viewport: layout.Dimensions{
- Width: a.width,
- Height: a.height,
- },
- Container: layout.Dimensions{
- Width: container,
- },
- }
- case app.SessionSelectedMsg:
- updated, cmd := a.messages.Update(msg)
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
-
- messages, err := a.app.ListMessages(context.Background(), msg.ID)
- if err != nil {
- slog.Error("Failed to list messages", "error", err.Error())
- return a, toast.NewErrorToast("Failed to open session")
- }
- a.app.Session = msg
- a.app.Messages = messages
- cmds = append(cmds, util.CmdHandler(app.SessionLoadedMsg{}))
- return a, tea.Batch(cmds...)
- case app.SessionCreatedMsg:
- a.app.Session = msg.Session
- case dialog.ScrollToMessageMsg:
- updated, cmd := a.messages.ScrollToMessage(msg.MessageID)
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case dialog.RestoreToMessageMsg:
- cmd := func() tea.Msg {
- // Find next user message after target
- var nextMessageID string
- for i := msg.Index + 1; i < len(a.app.Messages); i++ {
- if userMsg, ok := a.app.Messages[i].Info.(opencode.UserMessage); ok {
- nextMessageID = userMsg.ID
- break
- }
- }
-
- var response *opencode.Session
- var err error
-
- if nextMessageID == "" {
- // Last message - use unrevert to restore full conversation
- response, err = a.app.Client.Session.Unrevert(context.Background(), a.app.Session.ID, opencode.SessionUnrevertParams{})
- } else {
- // Revert to next message to make target the last visible
- response, err = a.app.Client.Session.Revert(context.Background(), a.app.Session.ID,
- opencode.SessionRevertParams{MessageID: opencode.F(nextMessageID)})
- }
-
- if err != nil || response == nil {
- return toast.NewErrorToast("Failed to restore to message")
- }
- return app.MessageRevertedMsg{Session: *response, Message: app.Message{}}
- }
- cmds = append(cmds, cmd)
- case app.MessageRevertedMsg:
- if msg.Session.ID == a.app.Session.ID {
- a.app.Session = &msg.Session
- }
- case app.ModelSelectedMsg:
- a.app.Provider = &msg.Provider
- a.app.Model = &msg.Model
- a.app.State.AgentModel[a.app.Agent().Name] = app.AgentModel{
- ProviderID: msg.Provider.ID,
- ModelID: msg.Model.ID,
- }
- a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
- cmds = append(cmds, a.app.SaveState())
- case app.AgentSelectedMsg:
- updated, cmd := a.app.SwitchToAgent(msg.AgentName)
- a.app = updated
- cmds = append(cmds, cmd)
- case dialog.ThemeSelectedMsg:
- a.app.State.Theme = msg.ThemeName
- cmds = append(cmds, a.app.SaveState())
- case toast.ShowToastMsg:
- tm, cmd := a.toastManager.Update(msg)
- a.toastManager = tm
- cmds = append(cmds, cmd)
- case toast.DismissToastMsg:
- tm, cmd := a.toastManager.Update(msg)
- a.toastManager = tm
- cmds = append(cmds, cmd)
- case InterruptDebounceTimeoutMsg:
- // Reset interrupt key state after timeout
- a.interruptKeyState = InterruptKeyIdle
- a.editor.SetInterruptKeyInDebounce(false)
- case ExitDebounceTimeoutMsg:
- // Reset exit key state after timeout
- a.exitKeyState = ExitKeyIdle
- a.editor.SetExitKeyInDebounce(false)
- case tea.PasteMsg, tea.ClipboardMsg:
- // Paste events: prioritize modal if active, otherwise editor
- if a.modal != nil {
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- return a, cmd
- } else {
- updatedEditor, cmd := a.editor.Update(msg)
- a.editor = updatedEditor.(chat.EditorComponent)
- return a, cmd
- }
-
- // API
- case api.Request:
- slog.Info("api", "path", msg.Path)
- var response any = true
- switch msg.Path {
- case "/tui/open-help":
- helpDialog := dialog.NewHelpDialog(a.app)
- a.modal = helpDialog
- case "/tui/open-sessions":
- sessionDialog := dialog.NewSessionDialog(a.app)
- a.modal = sessionDialog
- case "/tui/open-timeline":
- navigationDialog := dialog.NewTimelineDialog(a.app)
- a.modal = navigationDialog
- case "/tui/open-themes":
- themeDialog := dialog.NewThemeDialog()
- a.modal = themeDialog
- case "/tui/open-models":
- modelDialog := dialog.NewModelDialog(a.app)
- a.modal = modelDialog
- case "/tui/append-prompt":
- var body struct {
- Text string `json:"text"`
- }
- json.Unmarshal((msg.Body), &body)
- existing := a.editor.Value()
- text := body.Text
- if existing != "" && !strings.HasSuffix(existing, " ") {
- text = " " + text
- }
- a.editor.SetValueWithAttachments(existing + text + " ")
- case "/tui/submit-prompt":
- updated, cmd := a.editor.Submit()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case "/tui/clear-prompt":
- updated, cmd := a.editor.Clear()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case "/tui/execute-command":
- var body struct {
- Command string `json:"command"`
- }
- json.Unmarshal((msg.Body), &body)
- command := commands.Command{}
- for _, cmd := range a.app.Commands {
- if string(cmd.Name) == body.Command {
- command = cmd
- break
- }
- }
- if command.Name == "" {
- slog.Error("Invalid command passed to /tui/execute-command", "command", body.Command)
- return a, nil
- }
- updated, cmd := a.executeCommand(commands.Command(command))
- a = updated.(Model)
- cmds = append(cmds, cmd)
- case "/tui/show-toast":
- var body struct {
- Title string `json:"title,omitempty"`
- Message string `json:"message"`
- Variant string `json:"variant"`
- }
- json.Unmarshal((msg.Body), &body)
-
- var toastCmd tea.Cmd
- switch body.Variant {
- case "info":
- if body.Title != "" {
- toastCmd = toast.NewInfoToast(body.Message, toast.WithTitle(body.Title))
- } else {
- toastCmd = toast.NewInfoToast(body.Message)
- }
- case "success":
- if body.Title != "" {
- toastCmd = toast.NewSuccessToast(body.Message, toast.WithTitle(body.Title))
- } else {
- toastCmd = toast.NewSuccessToast(body.Message)
- }
- case "warning":
- if body.Title != "" {
- toastCmd = toast.NewErrorToast(body.Message, toast.WithTitle(body.Title))
- } else {
- toastCmd = toast.NewErrorToast(body.Message)
- }
- case "error":
- if body.Title != "" {
- toastCmd = toast.NewErrorToast(body.Message, toast.WithTitle(body.Title))
- } else {
- toastCmd = toast.NewErrorToast(body.Message)
- }
- default:
- slog.Error("Invalid toast variant", "variant", body.Variant)
- return a, nil
- }
- cmds = append(cmds, toastCmd)
-
- default:
- break
- }
- cmds = append(cmds, api.Reply(context.Background(), a.app.Client, response))
- }
-
- s, cmd := a.status.Update(msg)
- cmds = append(cmds, cmd)
- a.status = s.(status.StatusComponent)
-
- updatedEditor, cmd := a.editor.Update(msg)
- a.editor = updatedEditor.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- updatedMessages, cmd := a.messages.Update(msg)
- a.messages = updatedMessages.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
-
- if a.modal != nil {
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- cmds = append(cmds, cmd)
- }
-
- if a.showCompletionDialog {
- u, cmd := a.completions.Update(msg)
- a.completions = u.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
- }
-
- return a, tea.Batch(cmds...)
-}
-
-func (a Model) View() (string, *tea.Cursor) {
- t := theme.CurrentTheme()
-
- var mainLayout string
-
- var editorX int
- var editorY int
- if a.app.Session.ID == "" {
- mainLayout, editorX, editorY = a.home()
- } else {
- mainLayout, editorX, editorY = a.chat()
- }
- mainLayout = styles.NewStyle().
- Background(t.Background()).
- Padding(0, 2).
- Render(mainLayout)
- 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)
- }
-
- cursor := a.editor.Cursor()
- cursor.Position.X += editorX
- cursor.Position.Y += editorY
-
- return mainLayout + "\n" + a.status.View(), cursor
-}
-
-func (a Model) Cleanup() {
- a.status.Cleanup()
-}
-
-func (a Model) home() (string, int, int) {
- t := theme.CurrentTheme()
- effectiveWidth := a.width - 4
- baseStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
- base := baseStyle.Render
- muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
-
- open := `
-
-█▀▀█ █▀▀█ █▀▀█ █▀▀▄
-█░░█ █░░█ █▀▀▀ █░░█
-▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ `
-
- code := `
- ▄
-█▀▀▀ █▀▀█ █▀▀█ █▀▀█
-█░░░ █░░█ █░░█ █▀▀▀
-▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`
-
- logo := lipgloss.JoinHorizontal(
- lipgloss.Top,
- muted(open),
- base(code),
- )
- // cwd := app.Info.Path.Cwd
- // config := app.Info.Path.Config
-
- versionStyle := styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.Background()).
- Width(lipgloss.Width(logo)).
- Align(lipgloss.Right)
- version := versionStyle.Render(a.app.Version)
-
- logoAndVersion := strings.Join([]string{logo, version}, "\n")
- logoAndVersion = lipgloss.PlaceHorizontal(
- effectiveWidth,
- lipgloss.Center,
- logoAndVersion,
- styles.WhitespaceStyle(t.Background()),
- )
-
- // Use limit of 4 for vscode, 6 for others
- limit := 5
- if util.IsVSCode() {
- limit = 3
- }
-
- showVscode := util.IsVSCode()
- commandsView := cmdcomp.New(
- a.app,
- cmdcomp.WithBackground(t.Background()),
- cmdcomp.WithLimit(limit),
- cmdcomp.WithVscode(showVscode),
- )
- cmds := lipgloss.PlaceHorizontal(
- effectiveWidth,
- lipgloss.Center,
- commandsView.View(),
- styles.WhitespaceStyle(t.Background()),
- )
-
- lines := []string{}
- lines = append(lines, "")
- lines = append(lines, logoAndVersion)
- lines = append(lines, "")
- lines = append(lines, cmds)
- lines = append(lines, "")
- lines = append(lines, "")
-
- mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
-
- editorView := a.editor.View()
- editorWidth := lipgloss.Width(editorView)
- editorView = lipgloss.PlaceHorizontal(
- effectiveWidth,
- lipgloss.Center,
- editorView,
- styles.WhitespaceStyle(t.Background()),
- )
- lines = append(lines, editorView)
-
- editorLines := a.editor.Lines()
-
- mainLayout := lipgloss.Place(
- effectiveWidth,
- a.height,
- lipgloss.Center,
- lipgloss.Center,
- baseStyle.Render(strings.Join(lines, "\n")),
- styles.WhitespaceStyle(t.Background()),
- )
-
- editorX := max(0, (effectiveWidth-editorWidth)/2)
- editorY := (a.height / 2) + (mainHeight / 2) - 3
- editorYDelta := 3
-
- if editorLines > 1 {
- editorYDelta = 2
- content := a.editor.Content()
- editorHeight := lipgloss.Height(content)
-
- if editorY+editorHeight > a.height {
- difference := (editorY + editorHeight) - a.height
- editorY -= difference
- }
- mainLayout = layout.PlaceOverlay(
- editorX,
- editorY,
- content,
- mainLayout,
- )
- }
-
- if a.showCompletionDialog {
- a.completions.SetWidth(editorWidth)
- overlay := a.completions.View()
- overlayHeight := lipgloss.Height(overlay)
-
- mainLayout = layout.PlaceOverlay(
- editorX,
- editorY-overlayHeight+2,
- overlay,
- mainLayout,
- )
- }
-
- return mainLayout, editorX + 5, editorY + editorYDelta
-}
-
-func (a Model) chat() (string, int, int) {
- effectiveWidth := a.width - 4
- t := theme.CurrentTheme()
- editorView := a.editor.View()
- lines := a.editor.Lines()
- messagesView := a.messages.View()
-
- editorWidth := lipgloss.Width(editorView)
- editorHeight := max(lines, 5)
- editorView = lipgloss.PlaceHorizontal(
- effectiveWidth,
- lipgloss.Center,
- editorView,
- styles.WhitespaceStyle(t.Background()),
- )
-
- mainLayout := messagesView + "\n" + editorView
- editorX := max(0, (effectiveWidth-editorWidth)/2)
- editorY := a.height - editorHeight
-
- if lines > 1 {
- content := a.editor.Content()
- editorHeight := lipgloss.Height(content)
- if editorY+editorHeight > a.height {
- difference := (editorY + editorHeight) - a.height
- editorY -= difference
- }
- mainLayout = layout.PlaceOverlay(
- editorX,
- editorY,
- content,
- 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, editorX + 5, editorY + 2
-}
-
-func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
- cmds := []tea.Cmd{
- util.CmdHandler(commands.CommandExecutedMsg(command)),
- }
- switch command.Name {
- case commands.AppHelpCommand:
- helpDialog := dialog.NewHelpDialog(a.app)
- a.modal = helpDialog
- case commands.AgentCycleCommand:
- updated, cmd := a.app.SwitchAgent()
- a.app = updated
- cmds = append(cmds, cmd)
- case commands.AgentCycleReverseCommand:
- updated, cmd := a.app.SwitchAgentReverse()
- a.app = updated
- cmds = append(cmds, cmd)
- case commands.EditorOpenCommand:
- if a.app.IsBusy() {
- // status.Warn("Agent is working, please wait...")
- return a, nil
- }
- editor := util.GetEditor()
- if editor == "" {
- return a, toast.NewErrorToast("No editor found. Set EDITOR environment variable (e.g., export EDITOR=vim)")
- }
-
- value := a.editor.Value()
-
- // Expand text attachments before opening editor
- for _, att := range a.editor.GetAttachments() {
- if textSource, ok := att.GetTextSource(); ok {
- value = strings.Replace(value, att.Display, textSource.Value, 1)
- }
- }
-
- updated, cmd := a.editor.Clear()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- tmpfile, err := os.CreateTemp("", "msg_*.md")
- tmpfile.WriteString(value)
- if err != nil {
- slog.Error("Failed to create temp file", "error", err)
- return a, toast.NewErrorToast("Something went wrong, couldn't open editor")
- }
- tmpfile.Close()
- parts := strings.Fields(editor)
- c := exec.Command(parts[0], append(parts[1:], tmpfile.Name())...) //nolint:gosec
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- cmd = tea.ExecProcess(c, func(err error) tea.Msg {
- if err != nil {
- slog.Error("Failed to open editor", "error", err)
- return nil
- }
- content, err := os.ReadFile(tmpfile.Name())
- if err != nil {
- slog.Error("Failed to read file", "error", err)
- return nil
- }
- if len(content) == 0 {
- slog.Warn("Message is empty")
- return nil
- }
- os.Remove(tmpfile.Name())
- return app.SetEditorContentMsg{
- Text: string(content),
- }
- })
- cmds = append(cmds, cmd)
- case commands.SessionNewCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
-
- case commands.SessionListCommand:
- sessionDialog := dialog.NewSessionDialog(a.app)
- a.modal = sessionDialog
- case commands.SessionTimelineCommand:
- if a.app.Session.ID == "" {
- return a, toast.NewErrorToast("No active session")
- }
- navigationDialog := dialog.NewTimelineDialog(a.app)
- a.modal = navigationDialog
- case commands.SessionShareCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- response, err := a.app.Client.Session.Share(
- context.Background(),
- a.app.Session.ID,
- opencode.SessionShareParams{},
- )
- if err != nil {
- slog.Error("Failed to share session", "error", err)
- return a, toast.NewErrorToast("Failed to share session")
- }
- shareUrl := response.Share.URL
- cmds = append(cmds, app.SetClipboard(shareUrl))
- cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
- case commands.SessionUnshareCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- _, err := a.app.Client.Session.Unshare(
- context.Background(),
- a.app.Session.ID,
- opencode.SessionUnshareParams{},
- )
- if err != nil {
- slog.Error("Failed to unshare session", "error", err)
- return a, toast.NewErrorToast("Failed to unshare session")
- }
- a.app.Session.Share.URL = ""
- cmds = append(cmds, toast.NewSuccessToast("Session unshared successfully"))
- case commands.SessionInterruptCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- a.app.Cancel(context.Background(), a.app.Session.ID)
- return a, nil
- case commands.SessionCompactCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- // TODO: block until compaction is complete
- a.app.CompactSession(context.Background())
- case commands.SessionChildCycleCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- cmds = append(cmds, func() tea.Msg {
- parentSessionID := a.app.Session.ID
- var parentSession *opencode.Session
- if a.app.Session.ParentID != "" {
- parentSessionID = a.app.Session.ParentID
- session, err := a.app.Client.Session.Get(
- context.Background(),
- parentSessionID,
- opencode.SessionGetParams{},
- )
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return toast.NewErrorToast("Failed to get parent session")
- }
- parentSession = session
- } else {
- parentSession = a.app.Session
- }
-
- children, err := a.app.Client.Session.Children(
- context.Background(),
- parentSessionID,
- opencode.SessionChildrenParams{},
- )
- if err != nil {
- slog.Error("Failed to get session children", "error", err)
- return toast.NewErrorToast("Failed to get session children")
- }
-
- // Reverse sort the children (newest first)
- slices.Reverse(*children)
-
- // Create combined array: [parent, child1, child2, ...]
- sessions := []*opencode.Session{parentSession}
- for i := range *children {
- sessions = append(sessions, &(*children)[i])
- }
-
- if len(sessions) == 1 {
- return toast.NewInfoToast("No child sessions available")
- }
-
- // Find current session index in combined array
- currentIndex := -1
- for i, session := range sessions {
- if session.ID == a.app.Session.ID {
- currentIndex = i
- break
- }
- }
-
- // If session not found, default to parent (shouldn't happen)
- if currentIndex == -1 {
- currentIndex = 0
- }
-
- // Cycle to next session (parent or child)
- nextIndex := (currentIndex + 1) % len(sessions)
- nextSession := sessions[nextIndex]
-
- return app.SessionSelectedMsg(nextSession)
- })
- case commands.SessionChildCycleReverseCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- cmds = append(cmds, func() tea.Msg {
- parentSessionID := a.app.Session.ID
- var parentSession *opencode.Session
- if a.app.Session.ParentID != "" {
- parentSessionID = a.app.Session.ParentID
- session, err := a.app.Client.Session.Get(
- context.Background(),
- parentSessionID,
- opencode.SessionGetParams{},
- )
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return toast.NewErrorToast("Failed to get parent session")
- }
- parentSession = session
- } else {
- parentSession = a.app.Session
- }
-
- children, err := a.app.Client.Session.Children(
- context.Background(),
- parentSessionID,
- opencode.SessionChildrenParams{},
- )
- if err != nil {
- slog.Error("Failed to get session children", "error", err)
- return toast.NewErrorToast("Failed to get session children")
- }
-
- // Reverse sort the children (newest first)
- slices.Reverse(*children)
-
- // Create combined array: [parent, child1, child2, ...]
- sessions := []*opencode.Session{parentSession}
- for i := range *children {
- sessions = append(sessions, &(*children)[i])
- }
-
- if len(sessions) == 1 {
- return toast.NewInfoToast("No child sessions available")
- }
-
- // Find current session index in combined array
- currentIndex := -1
- for i, session := range sessions {
- if session.ID == a.app.Session.ID {
- currentIndex = i
- break
- }
- }
-
- // If session not found, default to parent (shouldn't happen)
- if currentIndex == -1 {
- currentIndex = 0
- }
-
- // Cycle to previous session (parent or child)
- nextIndex := (currentIndex - 1 + len(sessions)) % len(sessions)
- nextSession := sessions[nextIndex]
-
- return app.SessionSelectedMsg(nextSession)
- })
- case commands.SessionExportCommand:
- if a.app.Session.ID == "" {
- return a, toast.NewErrorToast("No active session to export.")
- }
-
- // Use current conversation history
- messages := a.app.Messages
- if len(messages) == 0 {
- return a, toast.NewInfoToast("No messages to export.")
- }
-
- // Format to Markdown
- markdownContent := formatConversationToMarkdown(messages)
-
- editor := util.GetEditor()
- if editor == "" {
- return a, toast.NewErrorToast("No editor found. Set EDITOR environment variable (e.g., export EDITOR=vim)")
- }
-
- // Create and write to temp file
- tmpfile, err := os.CreateTemp("", "conversation-*.md")
- if err != nil {
- slog.Error("Failed to create temp file", "error", err)
- return a, toast.NewErrorToast("Failed to create temporary file.")
- }
-
- _, err = tmpfile.WriteString(markdownContent)
- if err != nil {
- slog.Error("Failed to write to temp file", "error", err)
- tmpfile.Close()
- os.Remove(tmpfile.Name())
- return a, toast.NewErrorToast("Failed to write conversation to file.")
- }
- tmpfile.Close()
-
- // Open in editor
- parts := strings.Fields(editor)
- c := exec.Command(parts[0], append(parts[1:], tmpfile.Name())...) //nolint:gosec
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- cmd = tea.ExecProcess(c, func(err error) tea.Msg {
- if err != nil {
- slog.Error("Failed to open editor for conversation", "error", err)
- }
- // Clean up the file after editor closes
- os.Remove(tmpfile.Name())
- return nil
- })
- cmds = append(cmds, cmd)
- case commands.ToolDetailsCommand:
- message := "Tool details are now visible"
- if a.messages.ToolDetailsVisible() {
- message = "Tool details are now hidden"
- }
- cmds = append(cmds, util.CmdHandler(chat.ToggleToolDetailsMsg{}))
- cmds = append(cmds, toast.NewInfoToast(message))
- case commands.ThinkingBlocksCommand:
- message := "Thinking blocks are now visible"
- if a.messages.ThinkingBlocksVisible() {
- message = "Thinking blocks are now hidden"
- }
- cmds = append(cmds, util.CmdHandler(chat.ToggleThinkingBlocksMsg{}))
- cmds = append(cmds, toast.NewInfoToast(message))
- case commands.ModelListCommand:
- modelDialog := dialog.NewModelDialog(a.app)
- a.modal = modelDialog
-
- case commands.AgentListCommand:
- agentDialog := dialog.NewAgentDialog(a.app)
- a.modal = agentDialog
- case commands.ModelCycleRecentCommand:
- slog.Debug("ModelCycleRecentCommand triggered")
- updated, cmd := a.app.CycleRecentModel()
- a.app = updated
- cmds = append(cmds, cmd)
- case commands.ModelCycleRecentReverseCommand:
- updated, cmd := a.app.CycleRecentModelReverse()
- a.app = updated
- cmds = append(cmds, cmd)
- case commands.ThemeListCommand:
- themeDialog := dialog.NewThemeDialog()
- a.modal = themeDialog
- case commands.ProjectInitCommand:
- cmds = append(cmds, a.app.InitializeProject(context.Background()))
- case commands.InputClearCommand:
- if a.editor.Value() == "" {
- return a, nil
- }
- updated, cmd := a.editor.Clear()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case commands.InputPasteCommand:
- updated, cmd := a.editor.Paste()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case commands.InputSubmitCommand:
- updated, cmd := a.editor.Submit()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case commands.InputNewlineCommand:
- updated, cmd := a.editor.Newline()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesFirstCommand:
- updated, cmd := a.messages.GotoTop()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesLastCommand:
- updated, cmd := a.messages.GotoBottom()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesPageUpCommand:
- updated, cmd := a.messages.PageUp()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesPageDownCommand:
- updated, cmd := a.messages.PageDown()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesHalfPageUpCommand:
- updated, cmd := a.messages.HalfPageUp()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesHalfPageDownCommand:
- updated, cmd := a.messages.HalfPageDown()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesCopyCommand:
- updated, cmd := a.messages.CopyLastMessage()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesUndoCommand:
- updated, cmd := a.messages.UndoLastMessage()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesRedoCommand:
- updated, cmd := a.messages.RedoLastMessage()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.AppExitCommand:
- return a, tea.Quit
- }
- return a, tea.Batch(cmds...)
-}
-
-func NewModel(app *app.App) tea.Model {
- commandProvider := completions.NewCommandCompletionProvider(app)
- fileProvider := completions.NewFileContextGroup(app)
- symbolsProvider := completions.NewSymbolsContextGroup(app)
- agentsProvider := completions.NewAgentsContextGroup(app)
-
- messages := chat.NewMessagesComponent(app)
- editor := chat.NewEditorComponent(app)
- completions := dialog.NewCompletionDialogComponent("/", commandProvider)
-
- var leaderBinding *key.Binding
- if app.Config.Keybinds.Leader != "" {
- binding := key.NewBinding(key.WithKeys(app.Config.Keybinds.Leader))
- leaderBinding = &binding
- }
-
- model := &Model{
- status: status.NewStatusCmp(app),
- app: app,
- editor: editor,
- messages: messages,
- completions: completions,
- commandProvider: commandProvider,
- fileProvider: fileProvider,
- symbolsProvider: symbolsProvider,
- agentsProvider: agentsProvider,
- leaderBinding: leaderBinding,
- showCompletionDialog: false,
- toastManager: toast.NewToastManager(),
- interruptKeyState: InterruptKeyIdle,
- exitKeyState: ExitKeyIdle,
- }
-
- return model
-}
-
-func formatConversationToMarkdown(messages []app.Message) string {
- var builder strings.Builder
-
- builder.WriteString("# Conversation History\n\n")
-
- for _, msg := range messages {
- builder.WriteString("---\n\n")
-
- var role string
- var timestamp time.Time
-
- switch info := msg.Info.(type) {
- case opencode.UserMessage:
- role = "User"
- timestamp = time.UnixMilli(int64(info.Time.Created))
- case opencode.AssistantMessage:
- role = "Assistant"
- timestamp = time.UnixMilli(int64(info.Time.Created))
- default:
- continue
- }
-
- builder.WriteString(
- fmt.Sprintf("**%s** (*%s*)\n\n", role, timestamp.Format("2006-01-02 15:04:05")),
- )
-
- for _, part := range msg.Parts {
- switch p := part.(type) {
- case opencode.TextPart:
- builder.WriteString(p.Text + "\n\n")
- case opencode.FilePart:
- builder.WriteString(fmt.Sprintf("[File: %s]\n\n", p.Filename))
- case opencode.ToolPart:
- builder.WriteString(fmt.Sprintf("[Tool: %s]\n\n", p.Tool))
- }
- }
- }
-
- return builder.String()
-}
diff --git a/packages/tui/internal/util/apilogger.go b/packages/tui/internal/util/apilogger.go
deleted file mode 100644
index 8e872e63a..000000000
--- a/packages/tui/internal/util/apilogger.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package util
-
-import (
- "context"
- "fmt"
- "log/slog"
- "reflect"
- "sync"
-
- opencode "github.com/sst/opencode-sdk-go"
-)
-
-func sanitizeValue(val any) any {
- if val == nil {
- return nil
- }
-
- if err, ok := val.(error); ok {
- return err.Error()
- }
-
- v := reflect.ValueOf(val)
- if v.Kind() == reflect.Interface && !v.IsNil() {
- return fmt.Sprintf("%T", val)
- }
-
- return val
-}
-
-type APILogHandler struct {
- client *opencode.Client
- service string
- level slog.Level
- attrs []slog.Attr
- groups []string
- mu sync.Mutex
- queue chan opencode.AppLogParams
-}
-
-func NewAPILogHandler(ctx context.Context, client *opencode.Client, service string, level slog.Level) *APILogHandler {
- result := &APILogHandler{
- client: client,
- service: service,
- level: level,
- attrs: make([]slog.Attr, 0),
- groups: make([]string, 0),
- queue: make(chan opencode.AppLogParams, 100_000),
- }
- go func() {
- for {
- select {
- case <-ctx.Done():
- return
- case params := <-result.queue:
- _, err := client.App.Log(context.Background(), params)
- if err != nil {
- slog.Error("Failed to log to API", "error", err)
- }
- }
- }
- }()
- return result
-}
-
-func (h *APILogHandler) Enabled(_ context.Context, level slog.Level) bool {
- return level >= h.level
-}
-
-func (h *APILogHandler) Handle(ctx context.Context, r slog.Record) error {
- var apiLevel opencode.AppLogParamsLevel
- switch r.Level {
- case slog.LevelDebug:
- apiLevel = opencode.AppLogParamsLevelDebug
- case slog.LevelInfo:
- apiLevel = opencode.AppLogParamsLevelInfo
- case slog.LevelWarn:
- apiLevel = opencode.AppLogParamsLevelWarn
- case slog.LevelError:
- apiLevel = opencode.AppLogParamsLevelError
- default:
- apiLevel = opencode.AppLogParamsLevelInfo
- }
-
- extra := make(map[string]any)
-
- h.mu.Lock()
- for _, attr := range h.attrs {
- val := attr.Value.Any()
- extra[attr.Key] = sanitizeValue(val)
- }
- h.mu.Unlock()
-
- r.Attrs(func(attr slog.Attr) bool {
- val := attr.Value.Any()
- extra[attr.Key] = sanitizeValue(val)
- return true
- })
-
- params := opencode.AppLogParams{
- Service: opencode.F(h.service),
- Level: opencode.F(apiLevel),
- Message: opencode.F(r.Message),
- }
-
- if len(extra) > 0 {
- params.Extra = opencode.F(extra)
- }
-
- h.queue <- params
-
- return nil
-}
-
-// WithAttrs returns a new Handler whose attributes consist of
-// both the receiver's attributes and the arguments.
-func (h *APILogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
- h.mu.Lock()
- defer h.mu.Unlock()
-
- newHandler := &APILogHandler{
- client: h.client,
- service: h.service,
- level: h.level,
- attrs: make([]slog.Attr, len(h.attrs)+len(attrs)),
- groups: make([]string, len(h.groups)),
- }
-
- copy(newHandler.attrs, h.attrs)
- copy(newHandler.attrs[len(h.attrs):], attrs)
- copy(newHandler.groups, h.groups)
-
- return newHandler
-}
-
-// WithGroup returns a new Handler with the given group appended to
-// the receiver's existing groups.
-func (h *APILogHandler) WithGroup(name string) slog.Handler {
- h.mu.Lock()
- defer h.mu.Unlock()
-
- newHandler := &APILogHandler{
- client: h.client,
- service: h.service,
- level: h.level,
- attrs: make([]slog.Attr, len(h.attrs)),
- groups: make([]string, len(h.groups)+1),
- }
-
- copy(newHandler.attrs, h.attrs)
- copy(newHandler.groups, h.groups)
- newHandler.groups[len(h.groups)] = name
-
- return newHandler
-}
diff --git a/packages/tui/internal/util/color.go b/packages/tui/internal/util/color.go
deleted file mode 100644
index b387ca655..000000000
--- a/packages/tui/internal/util/color.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package util
-
-import (
- "regexp"
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/theme"
-)
-
-var csiRE *regexp.Regexp
-
-func init() {
- csiRE = regexp.MustCompile(`\x1b\[([0-9;]+)m`)
-}
-
-var targetFGMap = map[string]string{
- "0;0;0": "\x1b[30m", // Black
- "128;0;0": "\x1b[31m", // Red
- "0;128;0": "\x1b[32m", // Green
- "128;128;0": "\x1b[33m", // Yellow
- "0;0;128": "\x1b[34m", // Blue
- "128;0;128": "\x1b[35m", // Magenta
- "0;128;128": "\x1b[36m", // Cyan
- "192;192;192": "\x1b[37m", // White (light grey)
- "128;128;128": "\x1b[90m", // Bright Black (dark grey)
- "255;0;0": "\x1b[91m", // Bright Red
- "0;255;0": "\x1b[92m", // Bright Green
- "255;255;0": "\x1b[93m", // Bright Yellow
- "0;0;255": "\x1b[94m", // Bright Blue
- "255;0;255": "\x1b[95m", // Bright Magenta
- "0;255;255": "\x1b[96m", // Bright Cyan
- "255;255;255": "\x1b[97m", // Bright White
-}
-
-var targetBGMap = map[string]string{
- "0;0;0": "\x1b[40m",
- "128;0;0": "\x1b[41m",
- "0;128;0": "\x1b[42m",
- "128;128;0": "\x1b[43m",
- "0;0;128": "\x1b[44m",
- "128;0;128": "\x1b[45m",
- "0;128;128": "\x1b[46m",
- "192;192;192": "\x1b[47m",
- "128;128;128": "\x1b[100m",
- "255;0;0": "\x1b[101m",
- "0;255;0": "\x1b[102m",
- "255;255;0": "\x1b[103m",
- "0;0;255": "\x1b[104m",
- "255;0;255": "\x1b[105m",
- "0;255;255": "\x1b[106m",
- "255;255;255": "\x1b[107m",
-}
-
-func ConvertRGBToAnsi16Colors(s string) string {
- return csiRE.ReplaceAllStringFunc(s, func(seq string) string {
- params := strings.Split(csiRE.FindStringSubmatch(seq)[1], ";")
- out := make([]string, 0, len(params))
-
- for i := 0; i < len(params); {
- // Detect “38 | 48 ; 2 ; r ; g ; b ( ; alpha? )”
- if (params[i] == "38" || params[i] == "48") &&
- i+4 < len(params) &&
- params[i+1] == "2" {
-
- key := strings.Join(params[i+2:i+5], ";")
- var repl string
- if params[i] == "38" {
- repl = targetFGMap[key]
- } else {
- repl = targetBGMap[key]
- }
-
- if repl != "" { // exact RGB hit
- out = append(out, repl[2:len(repl)-1])
- i += 5 // skip 38/48;2;r;g;b
-
- // if i == len(params)-1 && looksLikeByte(params[i]) {
- // i++ // swallow the alpha byte
- // }
- continue
- }
- }
- // Normal token — keep verbatim.
- out = append(out, params[i])
- i++
- }
-
- return "\x1b[" + strings.Join(out, ";") + "m"
- })
-}
-
-// func looksLikeByte(tok string) bool {
-// v, err := strconv.Atoi(tok)
-// return err == nil && v >= 0 && v <= 255
-// }
-
-// GetAgentColor returns the color for a given agent index, matching the status bar colors
-func GetAgentColor(agentIndex int) compat.AdaptiveColor {
- t := theme.CurrentTheme()
- agentColors := []compat.AdaptiveColor{
- t.TextMuted(),
- t.Secondary(),
- t.Accent(),
- t.Success(),
- t.Warning(),
- t.Primary(),
- t.Error(),
- }
-
- if agentIndex >= 0 && agentIndex < len(agentColors) {
- return agentColors[agentIndex]
- }
- return t.Secondary() // default fallback
-}
diff --git a/packages/tui/internal/util/concurrency.go b/packages/tui/internal/util/concurrency.go
deleted file mode 100644
index d24c7f974..000000000
--- a/packages/tui/internal/util/concurrency.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package util
-
-import (
- "strings"
-)
-
-func mapParallel[in, out any](items []in, fn func(in) out) chan out {
- mapChans := make([]chan out, 0, len(items))
-
- for _, v := range items {
- ch := make(chan out)
- mapChans = append(mapChans, ch)
- go func() {
- defer close(ch)
- ch <- fn(v)
- }()
- }
-
- resultChan := make(chan out)
-
- go func() {
- defer close(resultChan)
- for _, ch := range mapChans {
- v := <-ch
- resultChan <- v
- }
- }()
-
- return resultChan
-}
-
-// WriteStringsPar allows to iterate over a list and compute strings in parallel,
-// yet write them in order.
-func WriteStringsPar[a any](sb *strings.Builder, items []a, fn func(a) string) {
- ch := mapParallel(items, fn)
-
- for v := range ch {
- sb.WriteString(v)
- }
-}
diff --git a/packages/tui/internal/util/concurrency_test.go b/packages/tui/internal/util/concurrency_test.go
deleted file mode 100644
index 6512882f5..000000000
--- a/packages/tui/internal/util/concurrency_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package util_test
-
-import (
- "strconv"
- "strings"
- "testing"
- "time"
-
- "github.com/sst/opencode/internal/util"
-)
-
-func TestWriteStringsPar(t *testing.T) {
- items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
- sb := strings.Builder{}
- util.WriteStringsPar(&sb, items, func(i int) string {
- // sleep for the inverse duration so that later items finish first
- time.Sleep(time.Duration(10-i) * time.Millisecond)
- return strconv.Itoa(i)
- })
- if sb.String() != "0123456789" {
- t.Fatalf("expected 0123456789, got %s", sb.String())
- }
-}
diff --git a/packages/tui/internal/util/file.go b/packages/tui/internal/util/file.go
deleted file mode 100644
index 050b96343..000000000
--- a/packages/tui/internal/util/file.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package util
-
-import (
- "fmt"
- "path/filepath"
- "regexp"
- "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-6, backgroundColor)
- content = strings.ReplaceAll(content, RootPath+"/", "")
- hyphenRegex := regexp.MustCompile(`-([^ \-|]|$)`)
- content = hyphenRegex.ReplaceAllString(content, "\u2011$1")
- 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")
- content = strings.ReplaceAll(content, "\u2011", "-")
- return strings.TrimSuffix(content, "\n")
-}
diff --git a/packages/tui/internal/util/ide.go b/packages/tui/internal/util/ide.go
deleted file mode 100644
index 7b3832f9b..000000000
--- a/packages/tui/internal/util/ide.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package util
-
-import (
- "os"
- "strings"
-)
-
-var SUPPORTED_IDES = []struct {
- Search string
- ShortName string
-}{
- {"Windsurf", "Windsurf"},
- {"Visual Studio Code", "vscode"},
- {"Cursor", "Cursor"},
- {"VSCodium", "VSCodium"},
-}
-
-func IsVSCode() bool {
- return os.Getenv("OPENCODE_CALLER") == "vscode"
-}
-
-func Ide() string {
- for _, ide := range SUPPORTED_IDES {
- if strings.Contains(os.Getenv("GIT_ASKPASS"), ide.Search) {
- return ide.ShortName
- }
- }
-
- return "unknown"
-}
-
diff --git a/packages/tui/internal/util/shimmer.go b/packages/tui/internal/util/shimmer.go
deleted file mode 100644
index b6ba0db64..000000000
--- a/packages/tui/internal/util/shimmer.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package util
-
-import (
- "math"
- "os"
- "strings"
- "time"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/styles"
-)
-
-var (
- shimmerStart = time.Now()
- trueColorSupport = hasTrueColor()
-)
-
-// Shimmer renders text with a moving foreground highlight.
-// bg is the background color, dim is the base text color, bright is the highlight color.
-func Shimmer(s string, bg compat.AdaptiveColor, _ compat.AdaptiveColor, _ compat.AdaptiveColor) string {
- if s == "" {
- return ""
- }
-
- runes := []rune(s)
- n := len(runes)
- if n == 0 {
- return s
- }
-
- pad := 10
- period := float64(n + pad*2)
- sweep := 2.5
- elapsed := time.Since(shimmerStart).Seconds()
- pos := (math.Mod(elapsed, sweep) / sweep) * period
-
- half := 2.0
-
- type seg struct {
- useHex bool
- hex string
- bold bool
- faint bool
- text string
- }
- segs := make([]seg, 0, n/4)
-
- useHex := trueColorSupport
- for i, r := range runes {
- ip := float64(i + pad)
- dist := math.Abs(ip - pos)
-
- bold := false
- faint := true
- hex := ""
-
- if dist <= half {
- // Simple 3-level brightness based on distance
- if dist <= half/3 {
- // Center: brightest
- bold = true
- faint = false
- if useHex {
- hex = "#ffffff"
- }
- } else {
- // Edge: medium bright
- bold = false
- faint = false
- if useHex {
- hex = "#cccccc"
- }
- }
- }
-
- if len(segs) == 0 ||
- segs[len(segs)-1].useHex != useHex ||
- segs[len(segs)-1].hex != hex ||
- segs[len(segs)-1].bold != bold ||
- segs[len(segs)-1].faint != faint {
- segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)})
- } else {
- segs[len(segs)-1].text += string(r)
- }
- }
-
- baseStyle := styles.NewStyle().Background(bg)
- var b strings.Builder
- b.Grow(len(s) * 2)
- for _, g := range segs {
- st := baseStyle
- if g.useHex && g.hex != "" {
- c := compat.AdaptiveColor{Dark: lipgloss.Color(g.hex), Light: lipgloss.Color(g.hex)}
- st = st.Foreground(c)
- }
- if g.bold {
- st = st.Bold(true)
- }
- if g.faint {
- st = st.Faint(true)
- }
- b.WriteString(st.Render(g.text))
- }
- return b.String()
-}
-
-func hasTrueColor() bool {
- c := strings.ToLower(os.Getenv("COLORTERM"))
- return strings.Contains(c, "truecolor") || strings.Contains(c, "24bit")
-}
-
-func rgbHex(r, g, b int) string {
- if r < 0 {
- r = 0
- }
- if r > 255 {
- r = 255
- }
- if g < 0 {
- g = 0
- }
- if g > 255 {
- g = 255
- }
- if b < 0 {
- b = 0
- }
- if b > 255 {
- b = 255
- }
- return "#" + hex2(r) + hex2(g) + hex2(b)
-}
-
-func hex2(v int) string {
- const digits = "0123456789abcdef"
- return string([]byte{digits[(v>>4)&0xF], digits[v&0xF]})
-}
diff --git a/packages/tui/internal/util/util.go b/packages/tui/internal/util/util.go
deleted file mode 100644
index b49d2e292..000000000
--- a/packages/tui/internal/util/util.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package util
-
-import (
- "log/slog"
- "os"
- "os/exec"
- "runtime"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
-)
-
-func CmdHandler(msg tea.Msg) tea.Cmd {
- return func() tea.Msg {
- return msg
- }
-}
-
-func Clamp(v, low, high int) int {
- // Swap if needed to ensure low <= high
- if high < low {
- low, high = high, low
- }
- return min(high, max(low, v))
-}
-
-func IsWsl() bool {
- // Check for WSL environment variables
- if os.Getenv("WSL_DISTRO_NAME") != "" {
- return true
- }
-
- // Check /proc/version for WSL signature
- if data, err := os.ReadFile("/proc/version"); err == nil {
- version := strings.ToLower(string(data))
- return strings.Contains(version, "microsoft") || strings.Contains(version, "wsl")
- }
-
- return false
-}
-
-func Measure(tag string) func(...any) {
- startTime := time.Now()
- return func(args ...any) {
- args = append(args, []any{"timeTakenMs", time.Since(startTime).Milliseconds()}...)
- slog.Debug(tag, args...)
- }
-}
-
-func GetEditor() string {
- if editor := os.Getenv("VISUAL"); editor != "" {
- return editor
- }
- if editor := os.Getenv("EDITOR"); editor != "" {
- return editor
- }
-
- commonEditors := []string{"vim", "nvim", "zed", "code", "cursor", "vi", "nano"}
- if runtime.GOOS == "windows" {
- commonEditors = []string{"vim", "nvim", "zed", "code.cmd", "cursor.cmd", "notepad.exe", "vi", "nano"}
- }
-
- for _, editor := range commonEditors {
- if _, err := exec.LookPath(editor); err == nil {
- return editor
- }
- }
-
- return ""
-}
diff --git a/packages/tui/internal/viewport/highlight.go b/packages/tui/internal/viewport/highlight.go
deleted file mode 100644
index ec0ffda56..000000000
--- a/packages/tui/internal/viewport/highlight.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package viewport
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- "github.com/rivo/uniseg"
-)
-
-// parseMatches converts the given matches into highlight ranges.
-//
-// Assumptions:
-// - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return
-// - matches were made against the given content
-// - matches are in order
-// - matches do not overlap
-// - content is line terminated with \n only
-//
-// We'll then convert the ranges into [highlightInfo]s, which hold the starting
-// line and the grapheme positions.
-func parseMatches(
- content string,
- matches [][]int,
-) []highlightInfo {
- if len(matches) == 0 {
- return nil
- }
-
- line := 0
- graphemePos := 0
- previousLinesOffset := 0
- bytePos := 0
-
- highlights := make([]highlightInfo, 0, len(matches))
- gr := uniseg.NewGraphemes(ansi.Strip(content))
-
- for _, match := range matches {
- byteStart, byteEnd := match[0], match[1]
-
- // hilight for this match:
- hi := highlightInfo{
- lines: map[int][2]int{},
- }
-
- // find the beginning of this byte range, setup current line and
- // grapheme position.
- for byteStart > bytePos {
- if !gr.Next() {
- break
- }
- if content[bytePos] == '\n' {
- previousLinesOffset = graphemePos + 1
- line++
- }
- graphemePos += max(1, gr.Width())
- bytePos += len(gr.Str())
- }
-
- hi.lineStart = line
- hi.lineEnd = line
-
- graphemeStart := graphemePos
-
- // loop until we find the end
- for byteEnd > bytePos {
- if !gr.Next() {
- break
- }
-
- // if it ends with a new line, add the range, increase line, and continue
- if content[bytePos] == '\n' {
- colstart := max(0, graphemeStart-previousLinesOffset)
- colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself
-
- if colend > colstart {
- hi.lines[line] = [2]int{colstart, colend}
- hi.lineEnd = line
- }
-
- previousLinesOffset = graphemePos + 1
- line++
- }
-
- graphemePos += max(1, gr.Width())
- bytePos += len(gr.Str())
- }
-
- // we found it!, add highlight and continue
- if bytePos == byteEnd {
- colstart := max(0, graphemeStart-previousLinesOffset)
- colend := max(graphemePos-previousLinesOffset, colstart)
-
- if colend > colstart {
- hi.lines[line] = [2]int{colstart, colend}
- hi.lineEnd = line
- }
- }
-
- highlights = append(highlights, hi)
- }
-
- return highlights
-}
-
-type highlightInfo struct {
- // in which line this highlight starts and ends
- lineStart, lineEnd int
-
- // the grapheme highlight ranges for each of these lines
- lines map[int][2]int
-}
-
-// coords returns the line x column of this highlight.
-func (hi highlightInfo) coords() (int, int, int) {
- for i := hi.lineStart; i <= hi.lineEnd; i++ {
- hl, ok := hi.lines[i]
- if !ok {
- continue
- }
- return i, hl[0], hl[1]
- }
- return hi.lineStart, 0, 0
-}
-
-func makeHighlightRanges(
- highlights []highlightInfo,
- line int,
- style lipgloss.Style,
-) []lipgloss.Range {
- result := []lipgloss.Range{}
- for _, hi := range highlights {
- lihi, ok := hi.lines[line]
- if !ok {
- continue
- }
- if lihi == [2]int{} {
- continue
- }
- result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style))
- }
- return result
-}
diff --git a/packages/tui/internal/viewport/keymap.go b/packages/tui/internal/viewport/keymap.go
deleted file mode 100644
index d9c503a9f..000000000
--- a/packages/tui/internal/viewport/keymap.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package viewport
-
-import "github.com/charmbracelet/bubbles/v2/key"
-
-// KeyMap defines the keybindings for the viewport. Note that you don't
-// necessary need to use keybindings at all; the viewport can be controlled
-// programmatically with methods like Model.LineDown(1). See the GoDocs for
-// details.
-type KeyMap struct {
- PageDown key.Binding
- PageUp key.Binding
- HalfPageUp key.Binding
- HalfPageDown key.Binding
- Down key.Binding
- Up key.Binding
- Left key.Binding
- Right key.Binding
-}
-
-// DefaultKeyMap returns a set of pager-like default keybindings.
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- PageDown: key.NewBinding(
- key.WithKeys("pgdown", "space", "f"),
- key.WithHelp("f/pgdn", "page down"),
- ),
- PageUp: key.NewBinding(
- key.WithKeys("pgup", "b"),
- key.WithHelp("b/pgup", "page up"),
- ),
- HalfPageUp: key.NewBinding(
- key.WithKeys("u", "ctrl+u"),
- key.WithHelp("u", "½ page up"),
- ),
- HalfPageDown: key.NewBinding(
- key.WithKeys("d", "ctrl+d"),
- key.WithHelp("d", "½ page down"),
- ),
- Up: key.NewBinding(
- key.WithKeys("up", "k"),
- key.WithHelp("↑/k", "up"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "j"),
- key.WithHelp("↓/j", "down"),
- ),
- Left: key.NewBinding(
- key.WithKeys("left", "h"),
- key.WithHelp("←/h", "move left"),
- ),
- Right: key.NewBinding(
- key.WithKeys("right", "l"),
- key.WithHelp("→/l", "move right"),
- ),
- }
-}
diff --git a/packages/tui/internal/viewport/viewport.go b/packages/tui/internal/viewport/viewport.go
deleted file mode 100644
index 10c875fab..000000000
--- a/packages/tui/internal/viewport/viewport.go
+++ /dev/null
@@ -1,803 +0,0 @@
-package viewport
-
-import (
- "math"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-)
-
-const (
- defaultHorizontalStep = 6
-)
-
-// Option is a configuration option that works in conjunction with [New]. For
-// example:
-//
-// timer := New(WithWidth(10, WithHeight(5)))
-type Option func(*Model)
-
-// WithWidth is an initialization option that sets the width of the
-// viewport. Pass as an argument to [New].
-func WithWidth(w int) Option {
- return func(m *Model) {
- m.width = w
- }
-}
-
-// WithHeight is an initialization option that sets the height of the
-// viewport. Pass as an argument to [New].
-func WithHeight(h int) Option {
- return func(m *Model) {
- m.height = h
- }
-}
-
-// New returns a new model with the given width and height as well as default
-// key mappings.
-func New(opts ...Option) (m Model) {
- for _, opt := range opts {
- opt(&m)
- }
- m.setInitialValues()
- m.memo = &Memo{}
- return m
-}
-
-type Memo struct {
- dirty bool
- cache string
-}
-
-func (m *Memo) View(render func() string) string {
- if m.dirty {
- // slog.Debug("memo dirty")
- m.cache = render()
- m.dirty = false
- return m.cache
- }
- // slog.Debug("memo cache")
- return m.cache
-}
-
-func (m *Memo) Invalidate() {
- m.dirty = true
-}
-
-// Model is the Bubble Tea model for this viewport element.
-type Model struct {
- memo *Memo
- width int
- height int
- KeyMap KeyMap
-
- // Whether or not to wrap text. If false, it'll allow horizontal scrolling
- // instead.
- SoftWrap bool
-
- // Whether or not to fill to the height of the viewport with empty lines.
- FillHeight bool
-
- // Whether or not to respond to the mouse. The mouse must be enabled in
- // Bubble Tea for this to work. For details, see the Bubble Tea docs.
- MouseWheelEnabled bool
-
- // The number of lines the mouse wheel will scroll. By default, this is 3.
- MouseWheelDelta int
-
- // YOffset is the vertical scroll position.
- YOffset int
-
- // xOffset is the horizontal scroll position.
- xOffset int
-
- // horizontalStep is the number of columns we move left or right during a
- // default horizontal scroll.
- horizontalStep int
-
- // YPosition is the position of the viewport in relation to the terminal
- // window. It's used in high performance rendering only.
- YPosition int
-
- // Style applies a lipgloss style to the viewport. Realistically, it's most
- // useful for setting borders, margins and padding.
- Style lipgloss.Style
-
- // LeftGutterFunc allows to define a [GutterFunc] that adds a column into
- // the left of the viewport, which is kept when horizontal scrolling.
- // This can be used for things like line numbers, selection indicators,
- // show statuses, etc.
- LeftGutterFunc GutterFunc
-
- initialized bool
- lines []string
- longestLineWidth int
-
- // HighlightStyle highlights the ranges set with [SetHighligths].
- HighlightStyle lipgloss.Style
-
- // SelectedHighlightStyle highlights the highlight range focused during
- // navigation.
- // Use [SetHighligths] to set the highlight ranges, and [HightlightNext]
- // and [HihglightPrevious] to navigate.
- SelectedHighlightStyle lipgloss.Style
-
- // StyleLineFunc allows to return a [lipgloss.Style] for each line.
- // The argument is the line index.
- StyleLineFunc func(int) lipgloss.Style
-
- highlights []highlightInfo
- hiIdx int
-}
-
-// GutterFunc can be implemented and set into [Model.LeftGutterFunc].
-//
-// Example implementation showing line numbers:
-//
-// func(info GutterContext) string {
-// if info.Soft {
-// return " │ "
-// }
-// if info.Index >= info.TotalLines {
-// return " ~ │ "
-// }
-// return fmt.Sprintf("%4d │ ", info.Index+1)
-// }
-type GutterFunc func(GutterContext) string
-
-// NoGutter is the default gutter used.
-var NoGutter = func(GutterContext) string { return "" }
-
-// GutterContext provides context to a [GutterFunc].
-type GutterContext struct {
- Index int
- TotalLines int
- Soft bool
-}
-
-func (m *Model) setInitialValues() {
- m.KeyMap = DefaultKeyMap()
- m.MouseWheelEnabled = true
- m.MouseWheelDelta = 3
- m.initialized = true
- m.horizontalStep = defaultHorizontalStep
- m.LeftGutterFunc = NoGutter
-}
-
-// Init exists to satisfy the tea.Model interface for composability purposes.
-func (m Model) Init() tea.Cmd {
- return nil
-}
-
-// Height returns the height of the viewport.
-func (m Model) Height() int {
- return m.height
-}
-
-// SetHeight sets the height of the viewport.
-func (m *Model) SetHeight(h int) {
- m.height = h
- m.memo.Invalidate()
-}
-
-// Width returns the width of the viewport.
-func (m Model) Width() int {
- return m.width
-}
-
-// SetWidth sets the width of the viewport.
-func (m *Model) SetWidth(w int) {
- m.width = w
- m.memo.Invalidate()
-}
-
-// AtTop returns whether or not the viewport is at the very top position.
-func (m Model) AtTop() bool {
- return m.YOffset <= 0
-}
-
-// AtBottom returns whether or not the viewport is at or past the very bottom
-// position.
-func (m Model) AtBottom() bool {
- return m.YOffset >= m.maxYOffset()
-}
-
-// PastBottom returns whether or not the viewport is scrolled beyond the last
-// line. This can happen when adjusting the viewport height.
-func (m Model) PastBottom() bool {
- return m.YOffset > m.maxYOffset()
-}
-
-// ScrollPercent returns the amount scrolled as a float between 0 and 1.
-func (m Model) ScrollPercent() float64 {
- count := m.lineCount()
- if m.Height() >= count {
- return 1.0
- }
- y := float64(m.YOffset)
- h := float64(m.Height())
- t := float64(count)
- v := y / (t - h)
- return math.Max(0.0, math.Min(1.0, v))
-}
-
-// HorizontalScrollPercent returns the amount horizontally scrolled as a float
-// between 0 and 1.
-func (m Model) HorizontalScrollPercent() float64 {
- if m.xOffset >= m.longestLineWidth-m.Width() {
- return 1.0
- }
- y := float64(m.xOffset)
- h := float64(m.Width())
- t := float64(m.longestLineWidth)
- v := y / (t - h)
- return math.Max(0.0, math.Min(1.0, v))
-}
-
-// SetContent set the pager's text content.
-// Line endings will be normalized to '\n'.
-func (m *Model) SetContent(s string) {
- s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings
- m.SetContentLines(strings.Split(s, "\n"))
- m.memo.Invalidate()
-}
-
-// SetContentLines allows to set the lines to be shown instead of the content.
-// If a given line has a \n in it, it'll be considered a [Model.SoftWrap].
-// See also [Model.SetContent].
-func (m *Model) SetContentLines(lines []string) {
- // if there's no content, set content to actual nil instead of one empty
- // line.
- m.lines = lines
- if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 {
- m.lines = nil
- }
- m.longestLineWidth = maxLineWidth(m.lines)
- m.ClearHighlights()
-
- if m.YOffset > m.maxYOffset() {
- m.GotoBottom()
- }
- m.memo.Invalidate()
-}
-
-// GetContent returns the entire content as a single string.
-// Line endings are normalized to '\n'.
-func (m Model) GetContent() string {
- return strings.Join(m.lines, "\n")
-}
-
-// calculateLine taking soft wrapping into account, returns the total viewable
-// lines and the real-line index for the given yoffset.
-func (m Model) calculateLine(yoffset int) (total, idx int) {
- if !m.SoftWrap {
- for i, line := range m.lines {
- adjust := max(1, lipgloss.Height(line))
- if yoffset >= total && yoffset < total+adjust {
- idx = i
- }
- total += adjust
- }
- if yoffset >= total {
- idx = len(m.lines)
- }
- return total, idx
- }
-
- maxWidth := m.maxWidth()
- var gutterSize int
- if m.LeftGutterFunc != nil {
- gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
- }
- for i, line := range m.lines {
- adjust := max(1, lipgloss.Width(line)/(maxWidth-gutterSize))
- if yoffset >= total && yoffset < total+adjust {
- idx = i
- }
- total += adjust
- }
- if yoffset >= total {
- idx = len(m.lines)
- }
- return total, idx
-}
-
-// lineToIndex taking soft wrappign into account, return the real line index
-// for the given line.
-func (m Model) lineToIndex(y int) int {
- _, idx := m.calculateLine(y)
- return idx
-}
-
-// lineCount taking soft wrapping into account, return the total viewable line
-// count (real lines + soft wrapped line).
-func (m Model) lineCount() int {
- total, _ := m.calculateLine(0)
- return total
-}
-
-// maxYOffset returns the maximum possible value of the y-offset based on the
-// viewport's content and set height.
-func (m Model) maxYOffset() int {
- return max(0, m.lineCount()-m.Height()+m.Style.GetVerticalFrameSize())
-}
-
-// maxXOffset returns the maximum possible value of the x-offset based on the
-// viewport's content and set width.
-func (m Model) maxXOffset() int {
- return max(0, m.longestLineWidth-m.Width())
-}
-
-func (m Model) maxWidth() int {
- var gutterSize int
- if m.LeftGutterFunc != nil {
- gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
- }
- return m.Width() -
- m.Style.GetHorizontalFrameSize() -
- gutterSize
-}
-
-func (m Model) maxHeight() int {
- return m.Height() - m.Style.GetVerticalFrameSize()
-}
-
-// visibleLines returns the lines that should currently be visible in the
-// viewport.
-func (m Model) visibleLines() (lines []string) {
- maxHeight := m.maxHeight()
- maxWidth := m.maxWidth()
-
- if m.lineCount() > 0 {
- pos := m.lineToIndex(m.YOffset)
- top := max(0, pos)
- bottom := clamp(pos+maxHeight, top, len(m.lines))
- lines = make([]string, bottom-top)
- copy(lines, m.lines[top:bottom])
- lines = m.styleLines(lines, top)
- lines = m.highlightLines(lines, top)
- }
-
- for m.FillHeight && len(lines) < maxHeight {
- lines = append(lines, "")
- }
-
- // if longest line fit within width, no need to do anything else.
- if (m.xOffset == 0 && m.longestLineWidth <= maxWidth) || maxWidth == 0 {
- return m.setupGutter(lines)
- }
-
- if m.SoftWrap {
- return m.softWrap(lines, maxWidth)
- }
-
- for i, line := range lines {
- sublines := strings.Split(line, "\n") // will only have more than 1 if caller used [Model.SetContentLines].
- for j := range sublines {
- sublines[j] = ansi.Cut(sublines[j], m.xOffset, m.xOffset+maxWidth)
- }
- lines[i] = strings.Join(sublines, "\n")
- }
- return m.setupGutter(lines)
-}
-
-// styleLines styles the lines using [Model.StyleLineFunc].
-func (m Model) styleLines(lines []string, offset int) []string {
- if m.StyleLineFunc == nil {
- return lines
- }
- for i := range lines {
- lines[i] = m.StyleLineFunc(i + offset).Render(lines[i])
- }
- return lines
-}
-
-// highlightLines highlights the lines with [Model.HighlightStyle] and
-// [Model.SelectedHighlightStyle].
-func (m Model) highlightLines(lines []string, offset int) []string {
- if len(m.highlights) == 0 {
- return lines
- }
- for i := range lines {
- ranges := makeHighlightRanges(
- m.highlights,
- i+offset,
- m.HighlightStyle,
- )
- lines[i] = lipgloss.StyleRanges(lines[i], ranges...)
- if m.hiIdx < 0 {
- continue
- }
- sel := m.highlights[m.hiIdx]
- if hi, ok := sel.lines[i+offset]; ok {
- lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange(
- hi[0],
- hi[1],
- m.SelectedHighlightStyle,
- ))
- }
- }
- return lines
-}
-
-func (m Model) softWrap(lines []string, maxWidth int) []string {
- var wrappedLines []string
- total := m.TotalLineCount()
- for i, line := range lines {
- idx := 0
- for ansi.StringWidth(line) >= idx {
- truncatedLine := ansi.Cut(line, idx, maxWidth+idx)
- if m.LeftGutterFunc != nil {
- truncatedLine = m.LeftGutterFunc(GutterContext{
- Index: i + m.YOffset,
- TotalLines: total,
- Soft: idx > 0,
- }) + truncatedLine
- }
- wrappedLines = append(wrappedLines, truncatedLine)
- idx += maxWidth
- }
- }
- return wrappedLines
-}
-
-// setupGutter sets up the left gutter using [Moddel.LeftGutterFunc].
-func (m Model) setupGutter(lines []string) []string {
- if m.LeftGutterFunc == nil {
- return lines
- }
-
- offset := max(0, m.lineToIndex(m.YOffset))
- total := m.TotalLineCount()
- result := make([]string, len(lines))
- for i := range lines {
- var line []string
- for j, realLine := range strings.Split(lines[i], "\n") {
- line = append(line, m.LeftGutterFunc(GutterContext{
- Index: i + offset,
- TotalLines: total,
- Soft: j > 0,
- })+realLine)
- }
- result[i] = strings.Join(line, "\n")
- }
- m.memo.Invalidate()
- return result
-}
-
-// SetYOffset sets the Y offset.
-func (m *Model) SetYOffset(n int) {
- m.YOffset = clamp(n, 0, m.maxYOffset())
- m.memo.Invalidate()
-}
-
-// SetXOffset sets the X offset.
-// No-op when soft wrap is enabled.
-func (m *Model) SetXOffset(n int) {
- if m.SoftWrap {
- return
- }
- m.xOffset = clamp(n, 0, m.maxXOffset())
- m.memo.Invalidate()
-}
-
-// EnsureVisible ensures that the given line and column are in the viewport.
-func (m *Model) EnsureVisible(line, colstart, colend int) {
- maxWidth := m.maxWidth()
- if colend <= maxWidth {
- m.SetXOffset(0)
- } else {
- m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural
- }
-
- if line < m.YOffset || line >= m.YOffset+m.maxHeight() {
- m.SetYOffset(line)
- }
-
- m.visibleLines()
-}
-
-// ViewDown moves the view down by the number of lines in the viewport.
-// Basically, "page down".
-func (m *Model) ViewDown() {
- if m.AtBottom() {
- return
- }
-
- m.LineDown(m.Height())
- m.memo.Invalidate()
-}
-
-// ViewUp moves the view up by one height of the viewport. Basically, "page up".
-func (m *Model) ViewUp() {
- if m.AtTop() {
- return
- }
-
- m.LineUp(m.Height())
- m.memo.Invalidate()
-}
-
-// HalfViewDown moves the view down by half the height of the viewport.
-func (m *Model) HalfViewDown() {
- if m.AtBottom() {
- return
- }
-
- m.LineDown(m.Height() / 2) //nolint:mnd
- m.memo.Invalidate()
-}
-
-// HalfViewUp moves the view up by half the height of the viewport.
-func (m *Model) HalfViewUp() {
- if m.AtTop() {
- return
- }
-
- m.LineUp(m.Height() / 2) //nolint:mnd
- m.memo.Invalidate()
-}
-
-// LineDown moves the view down by the given number of lines.
-func (m *Model) LineDown(n int) {
- if m.AtBottom() || n == 0 || len(m.lines) == 0 {
- return
- }
-
- // Make sure the number of lines by which we're going to scroll isn't
- // greater than the number of lines we actually have left before we reach
- // the bottom.
- m.SetYOffset(m.YOffset + n)
- m.hiIdx = m.findNearedtMatch()
- m.memo.Invalidate()
-}
-
-// LineUp moves the view down by the given number of lines. Returns the new
-// lines to show.
-func (m *Model) LineUp(n int) {
- if m.AtTop() || n == 0 || len(m.lines) == 0 {
- return
- }
-
- // Make sure the number of lines by which we're going to scroll isn't
- // greater than the number of lines we are from the top.
- m.SetYOffset(m.YOffset - n)
- m.hiIdx = m.findNearedtMatch()
- m.memo.Invalidate()
-}
-
-// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
-func (m Model) TotalLineCount() int {
- return m.lineCount()
-}
-
-// VisibleLineCount returns the number of the visible lines within the viewport.
-func (m Model) VisibleLineCount() int {
- return len(m.visibleLines())
-}
-
-// GotoTop sets the viewport to the top position.
-func (m *Model) GotoTop() (lines []string) {
- if m.AtTop() {
- return nil
- }
-
- m.SetYOffset(0)
- m.hiIdx = m.findNearedtMatch()
- m.memo.Invalidate()
- return m.visibleLines()
-}
-
-// GotoBottom sets the viewport to the bottom position.
-func (m *Model) GotoBottom() (lines []string) {
- m.SetYOffset(m.maxYOffset())
- m.hiIdx = m.findNearedtMatch()
- m.memo.Invalidate()
- return m.visibleLines()
-}
-
-// SetHorizontalStep sets the amount of cells that the viewport moves in the
-// default viewport keymapping. If set to 0 or less, horizontal scrolling is
-// disabled.
-func (m *Model) SetHorizontalStep(n int) {
- if n < 0 {
- n = 0
- }
-
- m.horizontalStep = n
- m.memo.Invalidate()
-}
-
-// MoveLeft moves the viewport to the left by the given number of columns.
-func (m *Model) MoveLeft(cols int) {
- m.xOffset -= cols
- if m.xOffset < 0 {
- m.xOffset = 0
- m.memo.Invalidate()
- }
-}
-
-// MoveRight moves viewport to the right by the given number of columns.
-func (m *Model) MoveRight(cols int) {
- // prevents over scrolling to the right
- w := m.maxWidth()
- if m.xOffset > m.longestLineWidth-w {
- return
- }
- m.xOffset += cols
-}
-
-// Resets lines indent to zero.
-func (m *Model) ResetIndent() {
- m.xOffset = 0
- m.memo.Invalidate()
-}
-
-// SetHighlights sets ranges of characters to highlight.
-// For instance, `[]int{[]int{2, 10}, []int{20, 30}}` will highlight characters
-// 2 to 10 and 20 to 30.
-// Note that highlights are not expected to transpose each other, and are also
-// expected to be in order.
-// Use [Model.SetHighlights] to set the highlight ranges, and
-// [Model.HighlightNext] and [Model.HighlightPrevious] to navigate.
-// Use [Model.ClearHighlights] to remove all highlights.
-func (m *Model) SetHighlights(matches [][]int) {
- if len(matches) == 0 || len(m.lines) == 0 {
- return
- }
- m.highlights = parseMatches(m.GetContent(), matches)
- m.hiIdx = m.findNearedtMatch()
- m.showHighlight()
- m.memo.Invalidate()
-}
-
-// ClearHighlights clears previously set highlights.
-func (m *Model) ClearHighlights() {
- m.highlights = nil
- m.hiIdx = -1
- m.memo.Invalidate()
-}
-
-func (m *Model) showHighlight() {
- if m.hiIdx == -1 {
- return
- }
- line, colstart, colend := m.highlights[m.hiIdx].coords()
- m.EnsureVisible(line, colstart, colend)
- m.memo.Invalidate()
-}
-
-// HighlightNext highlights the next match.
-func (m *Model) HighlightNext() {
- if m.highlights == nil {
- return
- }
-
- m.hiIdx = (m.hiIdx + 1) % len(m.highlights)
- m.showHighlight()
- m.memo.Invalidate()
-}
-
-// HighlightPrevious highlights the previous match.
-func (m *Model) HighlightPrevious() {
- if m.highlights == nil {
- return
- }
-
- m.hiIdx = (m.hiIdx - 1 + len(m.highlights)) % len(m.highlights)
- m.showHighlight()
- m.memo.Invalidate()
-}
-
-func (m Model) findNearedtMatch() int {
- for i, match := range m.highlights {
- if match.lineStart >= m.YOffset {
- return i
- }
- }
- return -1
-}
-
-// Update handles standard message-based viewport updates.
-func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
- m = m.updateAsModel(msg)
- return m, nil
-}
-
-// Author's note: this method has been broken out to make it easier to
-// potentially transition Update to satisfy tea.Model.
-func (m Model) updateAsModel(msg tea.Msg) Model {
- if !m.initialized {
- m.setInitialValues()
- }
-
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, m.KeyMap.PageDown):
- m.ViewDown()
-
- case key.Matches(msg, m.KeyMap.PageUp):
- m.ViewUp()
-
- case key.Matches(msg, m.KeyMap.HalfPageDown):
- m.HalfViewDown()
-
- case key.Matches(msg, m.KeyMap.HalfPageUp):
- m.HalfViewUp()
-
- case key.Matches(msg, m.KeyMap.Down):
- m.LineDown(1)
-
- case key.Matches(msg, m.KeyMap.Up):
- m.LineUp(1)
-
- case key.Matches(msg, m.KeyMap.Left):
- m.MoveLeft(m.horizontalStep)
-
- case key.Matches(msg, m.KeyMap.Right):
- m.MoveRight(m.horizontalStep)
- }
-
- case tea.MouseWheelMsg:
- if !m.MouseWheelEnabled {
- break
- }
-
- switch msg.Button {
- case tea.MouseWheelDown:
- m.LineDown(m.MouseWheelDelta)
-
- case tea.MouseWheelUp:
- m.LineUp(m.MouseWheelDelta)
- }
- }
-
- return m
-}
-
-// View renders the viewport into a string.
-func (m *Model) render() {
-}
-
-func (m Model) View() string {
- return m.memo.View(func() string {
- w, h := m.Width(), m.Height()
- if sw := m.Style.GetWidth(); sw != 0 {
- w = min(w, sw)
- }
- if sh := m.Style.GetHeight(); sh != 0 {
- h = min(h, sh)
- }
- contentWidth := w - m.Style.GetHorizontalFrameSize()
- contentHeight := h - m.Style.GetVerticalFrameSize()
- visible := m.visibleLines()
- contents := lipgloss.NewStyle().
- Width(contentWidth). // pad to width.
- Height(contentHeight). // pad to height.
- MaxHeight(contentHeight). // truncate height if taller.
- MaxWidth(contentWidth). // truncate width if wider.
- Render(strings.Join(visible, "\n"))
- return m.Style.
- UnsetWidth().UnsetHeight(). // Style size already applied in contents.
- Render(contents)
- })
-}
-
-func clamp(v, low, high int) int {
- if high < low {
- low, high = high, low
- }
- return min(high, max(low, v))
-}
-
-func maxLineWidth(lines []string) int {
- result := 0
- for _, line := range lines {
- result = max(result, lipgloss.Width(line))
- }
- return result
-}