diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 0078a369..93eae6c2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -2,29 +2,29 @@ import { SyntaxStyle, RGBA } from "@opentui/core" import { createMemo, createSignal } from "solid-js" import { useSync } from "@tui/context/sync" import { createSimpleContext } from "./helper" -import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" } -import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" } -import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" } -import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" } -import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" } -import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" } -import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" } -import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" } -import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" } -import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" } -import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" } -import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" } -import nightowl from "../../../../../../tui/internal/theme/themes/nightowl.json" with { type: "json" } -import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" } -import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" } -import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" } -import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" } -import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" } -import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" } -import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" } -import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" } -import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" } -import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" } +import aura from "./theme/aura.json" with { type: "json" } +import ayu from "./theme/ayu.json" with { type: "json" } +import catppuccin from "./theme/catppuccin.json" with { type: "json" } +import cobalt2 from "./theme/cobalt2.json" with { type: "json" } +import dracula from "./theme/dracula.json" with { type: "json" } +import everforest from "./theme/everforest.json" with { type: "json" } +import github from "./theme/github.json" with { type: "json" } +import gruvbox from "./theme/gruvbox.json" with { type: "json" } +import kanagawa from "./theme/kanagawa.json" with { type: "json" } +import material from "./theme/material.json" with { type: "json" } +import matrix from "./theme/matrix.json" with { type: "json" } +import monokai from "./theme/monokai.json" with { type: "json" } +import nightowl from "./theme/nightowl.json" with { type: "json" } +import nord from "./theme/nord.json" with { type: "json" } +import onedark from "./theme/one-dark.json" with { type: "json" } +import opencode from "./theme/opencode.json" with { type: "json" } +import palenight from "./theme/palenight.json" with { type: "json" } +import rosepine from "./theme/rosepine.json" with { type: "json" } +import solarized from "./theme/solarized.json" with { type: "json" } +import synthwave84 from "./theme/synthwave84.json" with { type: "json" } +import tokyonight from "./theme/tokyonight.json" with { type: "json" } +import vesper from "./theme/vesper.json" with { type: "json" } +import zenburn from "./theme/zenburn.json" with { type: "json" } import { useKV } from "./kv" type Theme = { diff --git a/packages/tui/internal/theme/themes/aura.json b/packages/opencode/src/cli/cmd/tui/context/theme/aura.json similarity index 100% rename from packages/tui/internal/theme/themes/aura.json rename to packages/opencode/src/cli/cmd/tui/context/theme/aura.json diff --git a/packages/tui/internal/theme/themes/ayu.json b/packages/opencode/src/cli/cmd/tui/context/theme/ayu.json similarity index 100% rename from packages/tui/internal/theme/themes/ayu.json rename to packages/opencode/src/cli/cmd/tui/context/theme/ayu.json diff --git a/packages/tui/internal/theme/themes/catppuccin.json b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json similarity index 100% rename from packages/tui/internal/theme/themes/catppuccin.json rename to packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json diff --git a/packages/tui/internal/theme/themes/cobalt2.json b/packages/opencode/src/cli/cmd/tui/context/theme/cobalt2.json similarity index 100% rename from packages/tui/internal/theme/themes/cobalt2.json rename to packages/opencode/src/cli/cmd/tui/context/theme/cobalt2.json diff --git a/packages/tui/internal/theme/themes/dracula.json b/packages/opencode/src/cli/cmd/tui/context/theme/dracula.json similarity index 100% rename from packages/tui/internal/theme/themes/dracula.json rename to packages/opencode/src/cli/cmd/tui/context/theme/dracula.json diff --git a/packages/tui/internal/theme/themes/everforest.json b/packages/opencode/src/cli/cmd/tui/context/theme/everforest.json similarity index 100% rename from packages/tui/internal/theme/themes/everforest.json rename to packages/opencode/src/cli/cmd/tui/context/theme/everforest.json diff --git a/packages/tui/internal/theme/themes/github.json b/packages/opencode/src/cli/cmd/tui/context/theme/github.json similarity index 100% rename from packages/tui/internal/theme/themes/github.json rename to packages/opencode/src/cli/cmd/tui/context/theme/github.json diff --git a/packages/tui/internal/theme/themes/gruvbox.json b/packages/opencode/src/cli/cmd/tui/context/theme/gruvbox.json similarity index 100% rename from packages/tui/internal/theme/themes/gruvbox.json rename to packages/opencode/src/cli/cmd/tui/context/theme/gruvbox.json diff --git a/packages/tui/internal/theme/themes/kanagawa.json b/packages/opencode/src/cli/cmd/tui/context/theme/kanagawa.json similarity index 100% rename from packages/tui/internal/theme/themes/kanagawa.json rename to packages/opencode/src/cli/cmd/tui/context/theme/kanagawa.json diff --git a/packages/tui/internal/theme/themes/material.json b/packages/opencode/src/cli/cmd/tui/context/theme/material.json similarity index 100% rename from packages/tui/internal/theme/themes/material.json rename to packages/opencode/src/cli/cmd/tui/context/theme/material.json diff --git a/packages/tui/internal/theme/themes/matrix.json b/packages/opencode/src/cli/cmd/tui/context/theme/matrix.json similarity index 100% rename from packages/tui/internal/theme/themes/matrix.json rename to packages/opencode/src/cli/cmd/tui/context/theme/matrix.json diff --git a/packages/tui/internal/theme/themes/monokai.json b/packages/opencode/src/cli/cmd/tui/context/theme/monokai.json similarity index 100% rename from packages/tui/internal/theme/themes/monokai.json rename to packages/opencode/src/cli/cmd/tui/context/theme/monokai.json diff --git a/packages/tui/internal/theme/themes/nightowl.json b/packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json similarity index 100% rename from packages/tui/internal/theme/themes/nightowl.json rename to packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json diff --git a/packages/tui/internal/theme/themes/nord.json b/packages/opencode/src/cli/cmd/tui/context/theme/nord.json similarity index 100% rename from packages/tui/internal/theme/themes/nord.json rename to packages/opencode/src/cli/cmd/tui/context/theme/nord.json diff --git a/packages/tui/internal/theme/themes/one-dark.json b/packages/opencode/src/cli/cmd/tui/context/theme/one-dark.json similarity index 100% rename from packages/tui/internal/theme/themes/one-dark.json rename to packages/opencode/src/cli/cmd/tui/context/theme/one-dark.json diff --git a/packages/tui/internal/theme/themes/opencode.json b/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json similarity index 100% rename from packages/tui/internal/theme/themes/opencode.json rename to packages/opencode/src/cli/cmd/tui/context/theme/opencode.json diff --git a/packages/tui/internal/theme/themes/palenight.json b/packages/opencode/src/cli/cmd/tui/context/theme/palenight.json similarity index 100% rename from packages/tui/internal/theme/themes/palenight.json rename to packages/opencode/src/cli/cmd/tui/context/theme/palenight.json diff --git a/packages/tui/internal/theme/themes/rosepine.json b/packages/opencode/src/cli/cmd/tui/context/theme/rosepine.json similarity index 100% rename from packages/tui/internal/theme/themes/rosepine.json rename to packages/opencode/src/cli/cmd/tui/context/theme/rosepine.json diff --git a/packages/tui/internal/theme/themes/solarized.json b/packages/opencode/src/cli/cmd/tui/context/theme/solarized.json similarity index 100% rename from packages/tui/internal/theme/themes/solarized.json rename to packages/opencode/src/cli/cmd/tui/context/theme/solarized.json diff --git a/packages/tui/internal/theme/themes/synthwave84.json b/packages/opencode/src/cli/cmd/tui/context/theme/synthwave84.json similarity index 100% rename from packages/tui/internal/theme/themes/synthwave84.json rename to packages/opencode/src/cli/cmd/tui/context/theme/synthwave84.json diff --git a/packages/tui/internal/theme/themes/tokyonight.json b/packages/opencode/src/cli/cmd/tui/context/theme/tokyonight.json similarity index 100% rename from packages/tui/internal/theme/themes/tokyonight.json rename to packages/opencode/src/cli/cmd/tui/context/theme/tokyonight.json diff --git a/packages/tui/internal/theme/themes/vesper.json b/packages/opencode/src/cli/cmd/tui/context/theme/vesper.json similarity index 100% rename from packages/tui/internal/theme/themes/vesper.json rename to packages/opencode/src/cli/cmd/tui/context/theme/vesper.json diff --git a/packages/tui/internal/theme/themes/zenburn.json b/packages/opencode/src/cli/cmd/tui/context/theme/zenburn.json similarity index 100% rename from packages/tui/internal/theme/themes/zenburn.json rename to packages/opencode/src/cli/cmd/tui/context/theme/zenburn.json diff --git a/packages/tui/.gitignore b/packages/tui/.gitignore deleted file mode 100644 index 541a71ae..00000000 --- a/packages/tui/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -opencode-test -cmd/opencode/opencode -opencode - diff --git a/packages/tui/.goreleaser.yml b/packages/tui/.goreleaser.yml deleted file mode 100644 index 1545199d..00000000 --- a/packages/tui/.goreleaser.yml +++ /dev/null @@ -1,77 +0,0 @@ -version: 2 -project_name: opencode -before: - hooks: -builds: - - env: - - CGO_ENABLED=0 - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: - - -s -w -X github.com/sst/opencode/internal/version.Version={{.Version}} - main: ./main.go - -archives: - - format: tar.gz - name_template: >- - opencode- - {{- if eq .Os "darwin" }}mac- - {{- else if eq .Os "windows" }}windows- - {{- else if eq .Os "linux" }}linux-{{end}} - {{- if eq .Arch "amd64" }}x86_64 - {{- else if eq .Arch "#86" }}i386 - {{- else }}{{ .Arch }}{{ end }} - {{- if .Arm }}v{{ .Arm }}{{ end }} - format_overrides: - - goos: windows - format: zip -checksum: - name_template: "checksums.txt" -snapshot: - name_template: "0.0.0-{{ .Timestamp }}" -aurs: - - name: opencode - homepage: "https://github.com/sst/opencode" - description: "terminal based agent that can build anything" - maintainers: - - "dax" - - "adam" - license: "MIT" - private_key: "{{ .Env.AUR_KEY }}" - git_url: "ssh://aur@aur.archlinux.org/opencode-bin.git" - provides: - - opencode - conflicts: - - opencode - package: |- - install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode" -brews: - - repository: - owner: sst - name: homebrew-tap -nfpms: - - maintainer: kujtimiihoxha - description: terminal based agent that can build anything - formats: - - deb - - rpm - file_name_template: >- - {{ .ProjectName }}- - {{- if eq .Os "darwin" }}mac - {{- else }}{{ .Os }}{{ end }}-{{ .Arch }} - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^doc:" - - "^test:" - - "^ci:" - - "^ignore:" - - "^example:" - - "^wip:" diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go deleted file mode 100644 index 3a7d1848..00000000 --- a/packages/tui/cmd/opencode/main.go +++ /dev/null @@ -1,175 +0,0 @@ -package main - -import ( - "context" - "io" - "log/slog" - "os" - "os/signal" - "strings" - "syscall" - - tea "github.com/charmbracelet/bubbletea/v2" - flag "github.com/spf13/pflag" - "github.com/sst/opencode-sdk-go" - "github.com/sst/opencode-sdk-go/option" - "github.com/sst/opencode-sdk-go/packages/ssestream" - "github.com/sst/opencode/internal/api" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/clipboard" - "github.com/sst/opencode/internal/decoders" - "github.com/sst/opencode/internal/tui" - "github.com/sst/opencode/internal/util" - "golang.org/x/sync/errgroup" -) - -var Version = "dev" - -func main() { - version := Version - if version != "dev" && !strings.HasPrefix(Version, "v") { - version = "v" + Version - } - - var model *string = flag.String("model", "", "model to begin with") - var prompt *string = flag.String("prompt", "", "prompt to begin with") - var agent *string = flag.String("agent", "", "agent to begin with") - var sessionID *string = flag.String("session", "", "session ID") - flag.Parse() - - url := os.Getenv("OPENCODE_SERVER") - - stat, err := os.Stdin.Stat() - if err != nil { - slog.Error("Failed to stat stdin", "error", err) - os.Exit(1) - } - - // Check if there's data piped to stdin - if (stat.Mode() & os.ModeCharDevice) == 0 { - stdin, err := io.ReadAll(os.Stdin) - if err != nil { - slog.Error("Failed to read stdin", "error", err) - os.Exit(1) - } - stdinContent := strings.TrimSpace(string(stdin)) - if stdinContent != "" { - if prompt == nil || *prompt == "" { - prompt = &stdinContent - } else { - combined := *prompt + "\n" + stdinContent - prompt = &combined - } - } - } - - // Register custom SSE decoder to handle large events (>32MB) - // This is a workaround for the bufio.Scanner token size limit in the auto-generated SDK - // See: packages/tui/internal/decoders/decoder.go - ssestream.RegisterDecoder("text/event-stream", decoders.NewUnboundedDecoder) - - httpClient := opencode.NewClient( - option.WithBaseURL(url), - ) - - var agents []opencode.Agent - var path *opencode.Path - var project *opencode.Project - - batch := errgroup.Group{} - - batch.Go(func() error { - result, err := httpClient.Project.Current(context.Background(), opencode.ProjectCurrentParams{}) - if err != nil { - return err - } - project = result - return nil - }) - - batch.Go(func() error { - result, err := httpClient.Agent.List(context.Background(), opencode.AgentListParams{}) - if err != nil { - return err - } - agents = *result - return nil - }) - - batch.Go(func() error { - result, err := httpClient.Path.Get(context.Background(), opencode.PathGetParams{}) - if err != nil { - return err - } - path = result - return nil - }) - - err = batch.Wait() - if err != nil { - panic(err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - apiHandler := util.NewAPILogHandler(ctx, httpClient, "tui", slog.LevelDebug) - logger := slog.New(apiHandler) - slog.SetDefault(logger) - - slog.Debug("TUI launched") - - go func() { - err = clipboard.Init() - if err != nil { - slog.Error("Failed to initialize clipboard", "error", err) - } - }() - - // Create main context for the application - app_, err := app.New(ctx, version, project, path, agents, httpClient, model, prompt, agent, sessionID) - if err != nil { - panic(err) - } - - tuiModel := tui.NewModel(app_).(*tui.Model) - program := tea.NewProgram( - tuiModel, - tea.WithAltScreen(), - tea.WithMouseCellMotion(), - ) - - // Set up signal handling for graceful shutdown - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT) - - go func() { - stream := httpClient.Event.ListStreaming(ctx, opencode.EventListParams{}) - for stream.Next() { - evt := stream.Current().AsUnion() - program.Send(evt) - } - if err := stream.Err(); err != nil { - slog.Error("Error streaming events", "error", err) - program.Send(err) - } - }() - - go api.Start(ctx, program, httpClient) - - // Handle signals in a separate goroutine - go func() { - sig := <-sigChan - slog.Info("Received signal, shutting down gracefully", "signal", sig) - tuiModel.Cleanup() - program.Quit() - }() - - // Run the TUI - result, err := program.Run() - if err != nil { - slog.Error("TUI error", "error", err) - } - - tuiModel.Cleanup() - slog.Info("TUI exited", "result", result) -} diff --git a/packages/tui/go.mod b/packages/tui/go.mod deleted file mode 100644 index 6ee7c1b7..00000000 --- a/packages/tui/go.mod +++ /dev/null @@ -1,99 +0,0 @@ -module github.com/sst/opencode - -go 1.24.0 - -require ( - github.com/BurntSushi/toml v1.5.0 - github.com/alecthomas/chroma/v2 v2.18.0 - github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 - github.com/charmbracelet/glamour v0.10.0 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 - github.com/charmbracelet/x/ansi v0.9.3 - github.com/fsnotify/fsnotify v1.8.0 - github.com/google/uuid v1.6.0 - github.com/lithammer/fuzzysearch v1.1.8 - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 - github.com/muesli/reflow v0.3.0 - github.com/muesli/termenv v0.16.0 - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 - github.com/sst/opencode-sdk-go v0.1.0-alpha.8 - golang.org/x/image v0.28.0 - rsc.io/qr v0.2.0 -) - -replace ( - github.com/charmbracelet/x/input => ./input - github.com/sst/opencode-sdk-go => ../sdk/go -) - -require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - -require ( - dario.cat/mergo v1.0.2 // indirect - github.com/atombender/go-jsonschema v0.20.0 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/input v0.3.7 // indirect - github.com/charmbracelet/x/windows v0.2.1 // indirect - github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect - github.com/getkin/kin-openapi v0.127.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/goccy/go-yaml v1.17.1 // indirect - github.com/invopop/yaml v0.3.1 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/sanity-io/litter v1.5.8 // indirect - github.com/sosodev/duration v1.3.1 // indirect - github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/tidwall/gjson v1.14.4 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/tools v0.34.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/gorilla/css v1.0.1 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 - github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/rivo/uniseg v0.4.7 - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/spf13/pflag v1.0.6 - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.8 // indirect - github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sync v0.15.0 - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.26.0 - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -tool ( - github.com/atombender/go-jsonschema - github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -) diff --git a/packages/tui/go.sum b/packages/tui/go.sum deleted file mode 100644 index 370ea712..00000000 --- a/packages/tui/go.sum +++ /dev/null @@ -1,313 +0,0 @@ -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4= -github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= -github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= -github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= -github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0= -github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= -github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= -github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= -github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= -github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= -github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY= -github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= -github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= -github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= -github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q= -github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= -github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= -github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= -github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg= -github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= -github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= -golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= -golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= -rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/packages/tui/input/cancelreader_other.go b/packages/tui/input/cancelreader_other.go deleted file mode 100644 index dbd22a2e..00000000 --- a/packages/tui/input/cancelreader_other.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !windows -// +build !windows - -package input - -import ( - "io" - - "github.com/muesli/cancelreader" -) - -func newCancelreader(r io.Reader, _ int) (cancelreader.CancelReader, error) { - return cancelreader.NewReader(r) //nolint:wrapcheck -} diff --git a/packages/tui/input/cancelreader_windows.go b/packages/tui/input/cancelreader_windows.go deleted file mode 100644 index 19abfce4..00000000 --- a/packages/tui/input/cancelreader_windows.go +++ /dev/null @@ -1,143 +0,0 @@ -//go:build windows -// +build windows - -package input - -import ( - "fmt" - "io" - "os" - "sync" - - xwindows "github.com/charmbracelet/x/windows" - "github.com/muesli/cancelreader" - "golang.org/x/sys/windows" -) - -type conInputReader struct { - cancelMixin - conin windows.Handle - originalMode uint32 -} - -var _ cancelreader.CancelReader = &conInputReader{} - -func newCancelreader(r io.Reader, flags int) (cancelreader.CancelReader, error) { - fallback := func(io.Reader) (cancelreader.CancelReader, error) { - return cancelreader.NewReader(r) - } - - var dummy uint32 - if f, ok := r.(cancelreader.File); !ok || f.Fd() != os.Stdin.Fd() || - // If data was piped to the standard input, it does not emit events - // anymore. We can detect this if the console mode cannot be set anymore, - // in this case, we fallback to the default cancelreader implementation. - windows.GetConsoleMode(windows.Handle(f.Fd()), &dummy) != nil { - return fallback(r) - } - - conin, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE) - if err != nil { - return fallback(r) - } - - // Discard any pending input events. - if err := xwindows.FlushConsoleInputBuffer(conin); err != nil { - return fallback(r) - } - - modes := []uint32{ - windows.ENABLE_WINDOW_INPUT, - windows.ENABLE_EXTENDED_FLAGS, - } - - // Enabling mouse mode implicitly blocks console text selection. Thus, we - // need to enable it only if the mouse mode is requested. - // In order to toggle mouse mode, the caller must recreate the reader with - // the appropriate flag toggled. - if flags&FlagMouseMode != 0 { - modes = append(modes, windows.ENABLE_MOUSE_INPUT) - } - - originalMode, err := prepareConsole(conin, modes...) - if err != nil { - return nil, fmt.Errorf("failed to prepare console input: %w", err) - } - - return &conInputReader{ - conin: conin, - originalMode: originalMode, - }, nil -} - -// Cancel implements cancelreader.CancelReader. -func (r *conInputReader) Cancel() bool { - r.setCanceled() - - return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil -} - -// Close implements cancelreader.CancelReader. -func (r *conInputReader) Close() error { - if r.originalMode != 0 { - err := windows.SetConsoleMode(r.conin, r.originalMode) - if err != nil { - return fmt.Errorf("reset console mode: %w", err) - } - } - - return nil -} - -// Read implements cancelreader.CancelReader. -func (r *conInputReader) Read(data []byte) (int, error) { - if r.isCanceled() { - return 0, cancelreader.ErrCanceled - } - - var n uint32 - if err := windows.ReadFile(r.conin, data, &n, nil); err != nil { - return int(n), fmt.Errorf("read console input: %w", err) - } - - return int(n), nil -} - -func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) { - err = windows.GetConsoleMode(input, &originalMode) - if err != nil { - return 0, fmt.Errorf("get console mode: %w", err) - } - - var newMode uint32 - for _, mode := range modes { - newMode |= mode - } - - err = windows.SetConsoleMode(input, newMode) - if err != nil { - return 0, fmt.Errorf("set console mode: %w", err) - } - - return originalMode, nil -} - -// cancelMixin represents a goroutine-safe cancelation status. -type cancelMixin struct { - unsafeCanceled bool - lock sync.Mutex -} - -func (c *cancelMixin) setCanceled() { - c.lock.Lock() - defer c.lock.Unlock() - - c.unsafeCanceled = true -} - -func (c *cancelMixin) isCanceled() bool { - c.lock.Lock() - defer c.lock.Unlock() - - return c.unsafeCanceled -} diff --git a/packages/tui/input/clipboard.go b/packages/tui/input/clipboard.go deleted file mode 100644 index 725a2d95..00000000 --- a/packages/tui/input/clipboard.go +++ /dev/null @@ -1,25 +0,0 @@ -package input - -import "github.com/charmbracelet/x/ansi" - -// ClipboardSelection represents a clipboard selection. The most common -// clipboard selections are "system" and "primary" and selections. -type ClipboardSelection = byte - -// Clipboard selections. -const ( - SystemClipboard ClipboardSelection = ansi.SystemClipboard - PrimaryClipboard ClipboardSelection = ansi.PrimaryClipboard -) - -// ClipboardEvent is a clipboard read message event. This message is emitted when -// a terminal receives an OSC52 clipboard read message event. -type ClipboardEvent struct { - Content string - Selection ClipboardSelection -} - -// String returns the string representation of the clipboard message. -func (e ClipboardEvent) String() string { - return e.Content -} diff --git a/packages/tui/input/color.go b/packages/tui/input/color.go deleted file mode 100644 index 9bcf7499..00000000 --- a/packages/tui/input/color.go +++ /dev/null @@ -1,136 +0,0 @@ -package input - -import ( - "fmt" - "image/color" - "math" -) - -// ForegroundColorEvent represents a foreground color event. This event is -// emitted when the terminal requests the terminal foreground color using -// [ansi.RequestForegroundColor]. -type ForegroundColorEvent struct{ color.Color } - -// String returns the hex representation of the color. -func (e ForegroundColorEvent) String() string { - return colorToHex(e.Color) -} - -// IsDark returns whether the color is dark. -func (e ForegroundColorEvent) IsDark() bool { - return isDarkColor(e.Color) -} - -// BackgroundColorEvent represents a background color event. This event is -// emitted when the terminal requests the terminal background color using -// [ansi.RequestBackgroundColor]. -type BackgroundColorEvent struct{ color.Color } - -// String returns the hex representation of the color. -func (e BackgroundColorEvent) String() string { - return colorToHex(e) -} - -// IsDark returns whether the color is dark. -func (e BackgroundColorEvent) IsDark() bool { - return isDarkColor(e.Color) -} - -// CursorColorEvent represents a cursor color change event. This event is -// emitted when the program requests the terminal cursor color using -// [ansi.RequestCursorColor]. -type CursorColorEvent struct{ color.Color } - -// String returns the hex representation of the color. -func (e CursorColorEvent) String() string { - return colorToHex(e) -} - -// IsDark returns whether the color is dark. -func (e CursorColorEvent) IsDark() bool { - return isDarkColor(e) -} - -type shiftable interface { - ~uint | ~uint16 | ~uint32 | ~uint64 -} - -func shift[T shiftable](x T) T { - if x > 0xff { - x >>= 8 - } - return x -} - -func colorToHex(c color.Color) string { - if c == nil { - return "" - } - r, g, b, _ := c.RGBA() - return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b)) -} - -func getMaxMin(a, b, c float64) (ma, mi float64) { - if a > b { - ma = a - mi = b - } else { - ma = b - mi = a - } - if c > ma { - ma = c - } else if c < mi { - mi = c - } - return ma, mi -} - -func round(x float64) float64 { - return math.Round(x*1000) / 1000 -} - -// rgbToHSL converts an RGB triple to an HSL triple. -func rgbToHSL(r, g, b uint8) (h, s, l float64) { - // convert uint32 pre-multiplied value to uint8 - // The r,g,b values are divided by 255 to change the range from 0..255 to 0..1: - Rnot := float64(r) / 255 - Gnot := float64(g) / 255 - Bnot := float64(b) / 255 - Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot) - Δ := Cmax - Cmin - // Lightness calculation: - l = (Cmax + Cmin) / 2 - // Hue and Saturation Calculation: - if Δ == 0 { - h = 0 - s = 0 - } else { - switch Cmax { - case Rnot: - h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6)) - case Gnot: - h = 60 * (((Bnot - Rnot) / Δ) + 2) - case Bnot: - h = 60 * (((Rnot - Gnot) / Δ) + 4) - } - if h < 0 { - h += 360 - } - - s = Δ / (1 - math.Abs((2*l)-1)) - } - - return h, round(s), round(l) -} - -// isDarkColor returns whether the given color is dark. -func isDarkColor(c color.Color) bool { - if c == nil { - return true - } - - r, g, b, _ := c.RGBA() - _, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint:gosec - return l < 0.5 -} diff --git a/packages/tui/input/cursor.go b/packages/tui/input/cursor.go deleted file mode 100644 index cf4e973d..00000000 --- a/packages/tui/input/cursor.go +++ /dev/null @@ -1,7 +0,0 @@ -package input - -import "image" - -// CursorPositionEvent represents a cursor position event. Where X is the -// zero-based column and Y is the zero-based row. -type CursorPositionEvent image.Point diff --git a/packages/tui/input/da1.go b/packages/tui/input/da1.go deleted file mode 100644 index c2cd94cf..00000000 --- a/packages/tui/input/da1.go +++ /dev/null @@ -1,18 +0,0 @@ -package input - -import "github.com/charmbracelet/x/ansi" - -// PrimaryDeviceAttributesEvent is an event that represents the terminal -// primary device attributes. -type PrimaryDeviceAttributesEvent []int - -func parsePrimaryDevAttrs(params ansi.Params) Event { - // Primary Device Attributes - da1 := make(PrimaryDeviceAttributesEvent, len(params)) - for i, p := range params { - if !p.HasMore() { - da1[i] = p.Param(0) - } - } - return da1 -} diff --git a/packages/tui/input/doc.go b/packages/tui/input/doc.go deleted file mode 100644 index 2877d496..00000000 --- a/packages/tui/input/doc.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package input provides a set of utilities for handling input events in a -// terminal environment. It includes support for reading input events, parsing -// escape sequences, and handling clipboard events. -// The package is designed to work with various terminal types and supports -// customization through flags and options. -package input diff --git a/packages/tui/input/driver.go b/packages/tui/input/driver.go deleted file mode 100644 index 1e34677a..00000000 --- a/packages/tui/input/driver.go +++ /dev/null @@ -1,192 +0,0 @@ -//nolint:unused,revive,nolintlint -package input - -import ( - "bytes" - "io" - "unicode/utf8" - - "github.com/muesli/cancelreader" -) - -// Logger is a simple logger interface. -type Logger interface { - Printf(format string, v ...any) -} - -// win32InputState is a state machine for parsing key events from the Windows -// Console API into escape sequences and utf8 runes, and keeps track of the last -// control key state to determine modifier key changes. It also keeps track of -// the last mouse button state and window size changes to determine which mouse -// buttons were released and to prevent multiple size events from firing. -type win32InputState struct { - ansiBuf [256]byte - ansiIdx int - utf16Buf [2]rune - utf16Half bool - lastCks uint32 // the last control key state for the previous event - lastMouseBtns uint32 // the last mouse button state for the previous event - lastWinsizeX, lastWinsizeY int16 // the last window size for the previous event to prevent multiple size events from firing -} - -// Reader represents an input event reader. It reads input events and parses -// escape sequences from the terminal input buffer and translates them into -// human‑readable events. -type Reader struct { - rd cancelreader.CancelReader - table map[string]Key // table is a lookup table for key sequences. - term string // $TERM - paste []byte // bracketed paste buffer; nil when disabled - buf [256]byte // read buffer - partialSeq []byte // holds incomplete escape sequences - keyState win32InputState - parser Parser - logger Logger -} - -// NewReader returns a new input event reader. -func NewReader(r io.Reader, termType string, flags int) (*Reader, error) { - d := new(Reader) - cr, err := newCancelreader(r, flags) - if err != nil { - return nil, err - } - - d.rd = cr - d.table = buildKeysTable(flags, termType) - d.term = termType - d.parser.flags = flags - return d, nil -} - -// SetLogger sets a logger for the reader. -func (d *Reader) SetLogger(l Logger) { d.logger = l } - -// Read implements io.Reader. -func (d *Reader) Read(p []byte) (int, error) { return d.rd.Read(p) } - -// Cancel cancels the underlying reader. -func (d *Reader) Cancel() bool { return d.rd.Cancel() } - -// Close closes the underlying reader. -func (d *Reader) Close() error { return d.rd.Close() } - -func (d *Reader) readEvents() ([]Event, error) { - nb, err := d.rd.Read(d.buf[:]) - if err != nil { - return nil, err - } - - var events []Event - - // Combine any partial sequence from previous read with new data. - var buf []byte - if len(d.partialSeq) > 0 { - buf = make([]byte, len(d.partialSeq)+nb) - copy(buf, d.partialSeq) - copy(buf[len(d.partialSeq):], d.buf[:nb]) - d.partialSeq = nil - } else { - buf = d.buf[:nb] - } - - // Fast path: direct lookup for simple escape sequences. - if bytes.HasPrefix(buf, []byte{0x1b}) { - if k, ok := d.table[string(buf)]; ok { - if d.logger != nil { - d.logger.Printf("input: %q", buf) - } - events = append(events, KeyPressEvent(k)) - return events, nil - } - } - - var i int - for i < len(buf) { - consumed, ev := d.parser.parseSequence(buf[i:]) - if d.logger != nil && consumed > 0 { - d.logger.Printf("input: %q", buf[i:i+consumed]) - } - - // Incomplete sequence – store remainder and exit. - if consumed == 0 && ev == nil { - rem := len(buf) - i - if rem > 0 { - d.partialSeq = make([]byte, rem) - copy(d.partialSeq, buf[i:]) - } - break - } - - // Handle bracketed paste specially so we don’t emit a paste event for - // every byte. - if d.paste != nil { - if _, ok := ev.(PasteEndEvent); !ok { - d.paste = append(d.paste, buf[i]) - i++ - continue - } - } - - switch ev.(type) { - case PasteStartEvent: - d.paste = []byte{} - case PasteEndEvent: - var paste []rune - for len(d.paste) > 0 { - r, w := utf8.DecodeRune(d.paste) - if r != utf8.RuneError { - paste = append(paste, r) - } - d.paste = d.paste[w:] - } - d.paste = nil - events = append(events, PasteEvent(paste)) - case nil: - i++ - continue - } - - if mevs, ok := ev.(MultiEvent); ok { - events = append(events, []Event(mevs)...) - } else { - events = append(events, ev) - } - i += consumed - } - - // Collapse bursts of wheel/motion events into a single event each. - events = coalesceMouseEvents(events) - return events, nil -} - -// coalesceMouseEvents reduces the volume of MouseWheelEvent and MouseMotionEvent -// objects that arrive in rapid succession by keeping only the most recent -// event in each contiguous run. -func coalesceMouseEvents(in []Event) []Event { - if len(in) < 2 { - return in - } - - out := make([]Event, 0, len(in)) - for _, ev := range in { - switch ev.(type) { - case MouseWheelEvent: - if len(out) > 0 { - if _, ok := out[len(out)-1].(MouseWheelEvent); ok { - out[len(out)-1] = ev // replace previous wheel event - continue - } - } - case MouseMotionEvent: - if len(out) > 0 { - if _, ok := out[len(out)-1].(MouseMotionEvent); ok { - out[len(out)-1] = ev // replace previous motion event - continue - } - } - } - out = append(out, ev) - } - return out -} diff --git a/packages/tui/input/driver_other.go b/packages/tui/input/driver_other.go deleted file mode 100644 index fd3df06c..00000000 --- a/packages/tui/input/driver_other.go +++ /dev/null @@ -1,17 +0,0 @@ -//go:build !windows -// +build !windows - -package input - -// ReadEvents reads input events from the terminal. -// -// It reads the events available in the input buffer and returns them. -func (d *Reader) ReadEvents() ([]Event, error) { - return d.readEvents() -} - -// parseWin32InputKeyEvent parses a Win32 input key events. This function is -// only available on Windows. -func (p *Parser) parseWin32InputKeyEvent(*win32InputState, uint16, uint16, rune, bool, uint32, uint16) Event { - return nil -} diff --git a/packages/tui/input/driver_test.go b/packages/tui/input/driver_test.go deleted file mode 100644 index affdf5b8..00000000 --- a/packages/tui/input/driver_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package input - -import ( - "io" - "strings" - "testing" -) - -func BenchmarkDriver(b *testing.B) { - input := "\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~" - rdr := strings.NewReader(input) - drv, err := NewReader(rdr, "dumb", 0) - if err != nil { - b.Fatalf("could not create driver: %v", err) - } - - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - rdr.Reset(input) - if _, err := drv.ReadEvents(); err != nil && err != io.EOF { - b.Errorf("error reading input: %v", err) - } - } -} diff --git a/packages/tui/input/driver_windows.go b/packages/tui/input/driver_windows.go deleted file mode 100644 index b9121734..00000000 --- a/packages/tui/input/driver_windows.go +++ /dev/null @@ -1,642 +0,0 @@ -//go:build windows -// +build windows - -package input - -import ( - "errors" - "fmt" - "strings" - "time" - "unicode" - "unicode/utf16" - "unicode/utf8" - - "github.com/charmbracelet/x/ansi" - xwindows "github.com/charmbracelet/x/windows" - "github.com/muesli/cancelreader" - "golang.org/x/sys/windows" -) - -// ReadEvents reads input events from the terminal. -// -// It reads the events available in the input buffer and returns them. -func (d *Reader) ReadEvents() ([]Event, error) { - events, err := d.handleConInput() - if errors.Is(err, errNotConInputReader) { - return d.readEvents() - } - return events, err -} - -var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader") - -func (d *Reader) handleConInput() ([]Event, error) { - cc, ok := d.rd.(*conInputReader) - if !ok { - return nil, errNotConInputReader - } - - var ( - events []xwindows.InputRecord - err error - ) - for { - // Peek up to 256 events, this is to allow for sequences events reported as - // key events. - events, err = peekNConsoleInputs(cc.conin, 256) - if cc.isCanceled() { - return nil, cancelreader.ErrCanceled - } - if err != nil { - return nil, fmt.Errorf("peek coninput events: %w", err) - } - if len(events) > 0 { - break - } - - // Sleep for a bit to avoid busy waiting. - time.Sleep(10 * time.Millisecond) - } - - events, err = readNConsoleInputs(cc.conin, uint32(len(events))) - if cc.isCanceled() { - return nil, cancelreader.ErrCanceled - } - if err != nil { - return nil, fmt.Errorf("read coninput events: %w", err) - } - - var evs []Event - for _, event := range events { - if e := d.parser.parseConInputEvent(event, &d.keyState); e != nil { - if multi, ok := e.(MultiEvent); ok { - evs = append(evs, multi...) - } else { - evs = append(evs, e) - } - } - } - - return evs, nil -} - -func (p *Parser) parseConInputEvent(event xwindows.InputRecord, keyState *win32InputState) Event { - switch event.EventType { - case xwindows.KEY_EVENT: - kevent := event.KeyEvent() - return p.parseWin32InputKeyEvent(keyState, kevent.VirtualKeyCode, kevent.VirtualScanCode, - kevent.Char, kevent.KeyDown, kevent.ControlKeyState, kevent.RepeatCount) - - case xwindows.WINDOW_BUFFER_SIZE_EVENT: - wevent := event.WindowBufferSizeEvent() - if wevent.Size.X != keyState.lastWinsizeX || wevent.Size.Y != keyState.lastWinsizeY { - keyState.lastWinsizeX, keyState.lastWinsizeY = wevent.Size.X, wevent.Size.Y - return WindowSizeEvent{ - Width: int(wevent.Size.X), - Height: int(wevent.Size.Y), - } - } - case xwindows.MOUSE_EVENT: - mevent := event.MouseEvent() - Event := mouseEvent(keyState.lastMouseBtns, mevent) - keyState.lastMouseBtns = mevent.ButtonState - return Event - case xwindows.FOCUS_EVENT: - fevent := event.FocusEvent() - if fevent.SetFocus { - return FocusEvent{} - } - return BlurEvent{} - case xwindows.MENU_EVENT: - // ignore - } - return nil -} - -func mouseEventButton(p, s uint32) (MouseButton, bool) { - var isRelease bool - button := MouseNone - btn := p ^ s - if btn&s == 0 { - isRelease = true - } - - if btn == 0 { - switch { - case s&xwindows.FROM_LEFT_1ST_BUTTON_PRESSED > 0: - button = MouseLeft - case s&xwindows.FROM_LEFT_2ND_BUTTON_PRESSED > 0: - button = MouseMiddle - case s&xwindows.RIGHTMOST_BUTTON_PRESSED > 0: - button = MouseRight - case s&xwindows.FROM_LEFT_3RD_BUTTON_PRESSED > 0: - button = MouseBackward - case s&xwindows.FROM_LEFT_4TH_BUTTON_PRESSED > 0: - button = MouseForward - } - return button, isRelease - } - - switch btn { - case xwindows.FROM_LEFT_1ST_BUTTON_PRESSED: // left button - button = MouseLeft - case xwindows.RIGHTMOST_BUTTON_PRESSED: // right button - button = MouseRight - case xwindows.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button - button = MouseMiddle - case xwindows.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward) - button = MouseBackward - case xwindows.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward) - button = MouseForward - } - - return button, isRelease -} - -func mouseEvent(p uint32, e xwindows.MouseEventRecord) (ev Event) { - var mod KeyMod - var isRelease bool - if e.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0 { - mod |= ModAlt - } - if e.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0 { - mod |= ModCtrl - } - if e.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0 { - mod |= ModShift - } - - m := Mouse{ - X: int(e.MousePositon.X), - Y: int(e.MousePositon.Y), - Mod: mod, - } - - wheelDirection := int16(highWord(e.ButtonState)) //nolint:gosec - switch e.EventFlags { - case 0, xwindows.DOUBLE_CLICK: - m.Button, isRelease = mouseEventButton(p, e.ButtonState) - case xwindows.MOUSE_WHEELED: - if wheelDirection > 0 { - m.Button = MouseWheelUp - } else { - m.Button = MouseWheelDown - } - case xwindows.MOUSE_HWHEELED: - if wheelDirection > 0 { - m.Button = MouseWheelRight - } else { - m.Button = MouseWheelLeft - } - case xwindows.MOUSE_MOVED: - m.Button, _ = mouseEventButton(p, e.ButtonState) - return MouseMotionEvent(m) - } - - if isWheel(m.Button) { - return MouseWheelEvent(m) - } else if isRelease { - return MouseReleaseEvent(m) - } - - return MouseClickEvent(m) -} - -func highWord(data uint32) uint16 { - return uint16((data & 0xFFFF0000) >> 16) //nolint:gosec -} - -func readNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) { - if maxEvents == 0 { - return nil, fmt.Errorf("maxEvents cannot be zero") - } - - records := make([]xwindows.InputRecord, maxEvents) - n, err := readConsoleInput(console, records) - return records[:n], err -} - -func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) { - if len(inputRecords) == 0 { - return 0, fmt.Errorf("size of input record buffer cannot be zero") - } - - var read uint32 - - err := xwindows.ReadConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec - - return read, err //nolint:wrapcheck -} - -func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) { - if len(inputRecords) == 0 { - return 0, fmt.Errorf("size of input record buffer cannot be zero") - } - - var read uint32 - - err := xwindows.PeekConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec - - return read, err //nolint:wrapcheck -} - -func peekNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) { - if maxEvents == 0 { - return nil, fmt.Errorf("maxEvents cannot be zero") - } - - records := make([]xwindows.InputRecord, maxEvents) - n, err := peekConsoleInput(console, records) - return records[:n], err -} - -// parseWin32InputKeyEvent parses a single key event from either the Windows -// Console API or win32-input-mode events. When state is nil, it means this is -// an event from win32-input-mode. Otherwise, it's a key event from the Windows -// Console API and needs a state to decode ANSI escape sequences and utf16 -// runes. -func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) (event Event) { - defer func() { - // Respect the repeat count. - if repeatCount > 1 { - var multi MultiEvent - for i := 0; i < int(repeatCount); i++ { - multi = append(multi, event) - } - event = multi - } - }() - if state != nil { - defer func() { - state.lastCks = cks - }() - } - - var utf8Buf [utf8.UTFMax]byte - var key Key - if state != nil && state.utf16Half { - state.utf16Half = false - state.utf16Buf[1] = r - codepoint := utf16.DecodeRune(state.utf16Buf[0], state.utf16Buf[1]) - rw := utf8.EncodeRune(utf8Buf[:], codepoint) - r, _ = utf8.DecodeRune(utf8Buf[:rw]) - key.Code = r - key.Text = string(r) - key.Mod = translateControlKeyState(cks) - key = ensureKeyCase(key, cks) - if keyDown { - return KeyPressEvent(key) - } - return KeyReleaseEvent(key) - } - - var baseCode rune - switch { - case vkc == 0: - // Zero means this event is either an escape code or a unicode - // codepoint. - if state != nil && state.ansiIdx == 0 && r != ansi.ESC { - // This is a unicode codepoint. - baseCode = r - break - } - - if state != nil { - // Collect ANSI escape code. - state.ansiBuf[state.ansiIdx] = byte(r) - state.ansiIdx++ - if state.ansiIdx <= 2 { - // We haven't received enough bytes to determine if this is an - // ANSI escape code. - return nil - } - if r == ansi.ESC { - // We're expecting a closing String Terminator [ansi.ST]. - return nil - } - - n, event := p.parseSequence(state.ansiBuf[:state.ansiIdx]) - if n == 0 { - return nil - } - if _, ok := event.(UnknownEvent); ok { - return nil - } - - state.ansiIdx = 0 - return event - } - case vkc == xwindows.VK_BACK: - baseCode = KeyBackspace - case vkc == xwindows.VK_TAB: - baseCode = KeyTab - case vkc == xwindows.VK_RETURN: - baseCode = KeyEnter - case vkc == xwindows.VK_SHIFT: - //nolint:nestif - if cks&xwindows.SHIFT_PRESSED != 0 { - if cks&xwindows.ENHANCED_KEY != 0 { - baseCode = KeyRightShift - } else { - baseCode = KeyLeftShift - } - } else if state != nil { - if state.lastCks&xwindows.SHIFT_PRESSED != 0 { - if state.lastCks&xwindows.ENHANCED_KEY != 0 { - baseCode = KeyRightShift - } else { - baseCode = KeyLeftShift - } - } - } - case vkc == xwindows.VK_CONTROL: - if cks&xwindows.LEFT_CTRL_PRESSED != 0 { - baseCode = KeyLeftCtrl - } else if cks&xwindows.RIGHT_CTRL_PRESSED != 0 { - baseCode = KeyRightCtrl - } else if state != nil { - if state.lastCks&xwindows.LEFT_CTRL_PRESSED != 0 { - baseCode = KeyLeftCtrl - } else if state.lastCks&xwindows.RIGHT_CTRL_PRESSED != 0 { - baseCode = KeyRightCtrl - } - } - case vkc == xwindows.VK_MENU: - if cks&xwindows.LEFT_ALT_PRESSED != 0 { - baseCode = KeyLeftAlt - } else if cks&xwindows.RIGHT_ALT_PRESSED != 0 { - baseCode = KeyRightAlt - } else if state != nil { - if state.lastCks&xwindows.LEFT_ALT_PRESSED != 0 { - baseCode = KeyLeftAlt - } else if state.lastCks&xwindows.RIGHT_ALT_PRESSED != 0 { - baseCode = KeyRightAlt - } - } - case vkc == xwindows.VK_PAUSE: - baseCode = KeyPause - case vkc == xwindows.VK_CAPITAL: - baseCode = KeyCapsLock - case vkc == xwindows.VK_ESCAPE: - baseCode = KeyEscape - case vkc == xwindows.VK_SPACE: - baseCode = KeySpace - case vkc == xwindows.VK_PRIOR: - baseCode = KeyPgUp - case vkc == xwindows.VK_NEXT: - baseCode = KeyPgDown - case vkc == xwindows.VK_END: - baseCode = KeyEnd - case vkc == xwindows.VK_HOME: - baseCode = KeyHome - case vkc == xwindows.VK_LEFT: - baseCode = KeyLeft - case vkc == xwindows.VK_UP: - baseCode = KeyUp - case vkc == xwindows.VK_RIGHT: - baseCode = KeyRight - case vkc == xwindows.VK_DOWN: - baseCode = KeyDown - case vkc == xwindows.VK_SELECT: - baseCode = KeySelect - case vkc == xwindows.VK_SNAPSHOT: - baseCode = KeyPrintScreen - case vkc == xwindows.VK_INSERT: - baseCode = KeyInsert - case vkc == xwindows.VK_DELETE: - baseCode = KeyDelete - case vkc >= '0' && vkc <= '9': - baseCode = rune(vkc) - case vkc >= 'A' && vkc <= 'Z': - // Convert to lowercase. - baseCode = rune(vkc) + 32 - case vkc == xwindows.VK_LWIN: - baseCode = KeyLeftSuper - case vkc == xwindows.VK_RWIN: - baseCode = KeyRightSuper - case vkc == xwindows.VK_APPS: - baseCode = KeyMenu - case vkc >= xwindows.VK_NUMPAD0 && vkc <= xwindows.VK_NUMPAD9: - baseCode = rune(vkc-xwindows.VK_NUMPAD0) + KeyKp0 - case vkc == xwindows.VK_MULTIPLY: - baseCode = KeyKpMultiply - case vkc == xwindows.VK_ADD: - baseCode = KeyKpPlus - case vkc == xwindows.VK_SEPARATOR: - baseCode = KeyKpComma - case vkc == xwindows.VK_SUBTRACT: - baseCode = KeyKpMinus - case vkc == xwindows.VK_DECIMAL: - baseCode = KeyKpDecimal - case vkc == xwindows.VK_DIVIDE: - baseCode = KeyKpDivide - case vkc >= xwindows.VK_F1 && vkc <= xwindows.VK_F24: - baseCode = rune(vkc-xwindows.VK_F1) + KeyF1 - case vkc == xwindows.VK_NUMLOCK: - baseCode = KeyNumLock - case vkc == xwindows.VK_SCROLL: - baseCode = KeyScrollLock - case vkc == xwindows.VK_LSHIFT: - baseCode = KeyLeftShift - case vkc == xwindows.VK_RSHIFT: - baseCode = KeyRightShift - case vkc == xwindows.VK_LCONTROL: - baseCode = KeyLeftCtrl - case vkc == xwindows.VK_RCONTROL: - baseCode = KeyRightCtrl - case vkc == xwindows.VK_LMENU: - baseCode = KeyLeftAlt - case vkc == xwindows.VK_RMENU: - baseCode = KeyRightAlt - case vkc == xwindows.VK_VOLUME_MUTE: - baseCode = KeyMute - case vkc == xwindows.VK_VOLUME_DOWN: - baseCode = KeyLowerVol - case vkc == xwindows.VK_VOLUME_UP: - baseCode = KeyRaiseVol - case vkc == xwindows.VK_MEDIA_NEXT_TRACK: - baseCode = KeyMediaNext - case vkc == xwindows.VK_MEDIA_PREV_TRACK: - baseCode = KeyMediaPrev - case vkc == xwindows.VK_MEDIA_STOP: - baseCode = KeyMediaStop - case vkc == xwindows.VK_MEDIA_PLAY_PAUSE: - baseCode = KeyMediaPlayPause - case vkc == xwindows.VK_OEM_1, vkc == xwindows.VK_OEM_PLUS, vkc == xwindows.VK_OEM_COMMA, - vkc == xwindows.VK_OEM_MINUS, vkc == xwindows.VK_OEM_PERIOD, vkc == xwindows.VK_OEM_2, - vkc == xwindows.VK_OEM_3, vkc == xwindows.VK_OEM_4, vkc == xwindows.VK_OEM_5, - vkc == xwindows.VK_OEM_6, vkc == xwindows.VK_OEM_7: - // Use the actual character provided by Windows for current keyboard layout - // instead of hardcoded US layout mappings - if !unicode.IsControl(r) && unicode.IsPrint(r) { - baseCode = r - } else { - // Fallback to original hardcoded mappings for non-printable cases - switch vkc { - case xwindows.VK_OEM_1: - baseCode = ';' - case xwindows.VK_OEM_PLUS: - baseCode = '+' - case xwindows.VK_OEM_COMMA: - baseCode = ',' - case xwindows.VK_OEM_MINUS: - baseCode = '-' - case xwindows.VK_OEM_PERIOD: - baseCode = '.' - case xwindows.VK_OEM_2: - baseCode = '/' - case xwindows.VK_OEM_3: - baseCode = '`' - case xwindows.VK_OEM_4: - baseCode = '[' - case xwindows.VK_OEM_5: - baseCode = '\\' - case xwindows.VK_OEM_6: - baseCode = ']' - case xwindows.VK_OEM_7: - baseCode = '\'' - } - } - } - - if utf16.IsSurrogate(r) { - if state != nil { - state.utf16Buf[0] = r - state.utf16Half = true - } - return nil - } - - // AltGr is left ctrl + right alt. On non-US keyboards, this is used to type - // special characters and produce printable events. - // XXX: Should this be a KeyMod? - altGr := cks&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED) == xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED - - // FIXED: Remove numlock and scroll lock states when checking for printable text - // These lock states shouldn't affect normal typing - cksForTextCheck := cks &^ (xwindows.NUMLOCK_ON | xwindows.SCROLLLOCK_ON) - - var text string - keyCode := baseCode - if !unicode.IsControl(r) { - rw := utf8.EncodeRune(utf8Buf[:], r) - keyCode, _ = utf8.DecodeRune(utf8Buf[:rw]) - if unicode.IsPrint(keyCode) && (cksForTextCheck == 0 || - cksForTextCheck == xwindows.SHIFT_PRESSED || - cksForTextCheck == xwindows.CAPSLOCK_ON || - altGr) { - // If the control key state is 0, shift is pressed, or caps lock - // then the key event is a printable event i.e. [text] is not empty. - text = string(keyCode) - } - } - - // Special case: numeric keypad divide should produce "/" text on all layouts (fix french keyboard layout) - if baseCode == KeyKpDivide { - text = "/" - } - - key.Code = keyCode - key.Text = text - key.Mod = translateControlKeyState(cks) - key.BaseCode = baseCode - key = ensureKeyCase(key, cks) - if keyDown { - return KeyPressEvent(key) - } - - return KeyReleaseEvent(key) -} - -// ensureKeyCase ensures that the key's text is in the correct case based on the -// control key state. -func ensureKeyCase(key Key, cks uint32) Key { - if len(key.Text) == 0 { - return key - } - - hasShift := cks&xwindows.SHIFT_PRESSED != 0 - hasCaps := cks&xwindows.CAPSLOCK_ON != 0 - if hasShift || hasCaps { - if unicode.IsLower(key.Code) { - key.ShiftedCode = unicode.ToUpper(key.Code) - key.Text = string(key.ShiftedCode) - } - } else { - if unicode.IsUpper(key.Code) { - key.ShiftedCode = unicode.ToLower(key.Code) - key.Text = string(key.ShiftedCode) - } - } - - return key -} - -// translateControlKeyState translates the control key state from the Windows -// Console API into a Mod bitmask. -func translateControlKeyState(cks uint32) (m KeyMod) { - if cks&xwindows.LEFT_CTRL_PRESSED != 0 || cks&xwindows.RIGHT_CTRL_PRESSED != 0 { - m |= ModCtrl - } - if cks&xwindows.LEFT_ALT_PRESSED != 0 || cks&xwindows.RIGHT_ALT_PRESSED != 0 { - m |= ModAlt - } - if cks&xwindows.SHIFT_PRESSED != 0 { - m |= ModShift - } - if cks&xwindows.CAPSLOCK_ON != 0 { - m |= ModCapsLock - } - if cks&xwindows.NUMLOCK_ON != 0 { - m |= ModNumLock - } - if cks&xwindows.SCROLLLOCK_ON != 0 { - m |= ModScrollLock - } - return -} - -//nolint:unused -func keyEventString(vkc, sc uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) string { - var s strings.Builder - s.WriteString("vkc: ") - s.WriteString(fmt.Sprintf("%d, 0x%02x", vkc, vkc)) - s.WriteString(", sc: ") - s.WriteString(fmt.Sprintf("%d, 0x%02x", sc, sc)) - s.WriteString(", r: ") - s.WriteString(fmt.Sprintf("%q", r)) - s.WriteString(", down: ") - s.WriteString(fmt.Sprintf("%v", keyDown)) - s.WriteString(", cks: [") - if cks&xwindows.LEFT_ALT_PRESSED != 0 { - s.WriteString("left alt, ") - } - if cks&xwindows.RIGHT_ALT_PRESSED != 0 { - s.WriteString("right alt, ") - } - if cks&xwindows.LEFT_CTRL_PRESSED != 0 { - s.WriteString("left ctrl, ") - } - if cks&xwindows.RIGHT_CTRL_PRESSED != 0 { - s.WriteString("right ctrl, ") - } - if cks&xwindows.SHIFT_PRESSED != 0 { - s.WriteString("shift, ") - } - if cks&xwindows.CAPSLOCK_ON != 0 { - s.WriteString("caps lock, ") - } - if cks&xwindows.NUMLOCK_ON != 0 { - s.WriteString("num lock, ") - } - if cks&xwindows.SCROLLLOCK_ON != 0 { - s.WriteString("scroll lock, ") - } - if cks&xwindows.ENHANCED_KEY != 0 { - s.WriteString("enhanced key, ") - } - s.WriteString("], repeat count: ") - s.WriteString(fmt.Sprintf("%d", repeatCount)) - return s.String() -} diff --git a/packages/tui/input/driver_windows_test.go b/packages/tui/input/driver_windows_test.go deleted file mode 100644 index 45371fd1..00000000 --- a/packages/tui/input/driver_windows_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package input - -import ( - "encoding/binary" - "image/color" - "reflect" - "testing" - "unicode/utf16" - - "github.com/charmbracelet/x/ansi" - xwindows "github.com/charmbracelet/x/windows" - "golang.org/x/sys/windows" -) - -func TestWindowsInputEvents(t *testing.T) { - cases := []struct { - name string - events []xwindows.InputRecord - expected []Event - sequence bool // indicates that the input events are ANSI sequence or utf16 - }{ - { - name: "single key event", - events: []xwindows.InputRecord{ - encodeKeyEvent(xwindows.KeyEventRecord{ - KeyDown: true, - Char: 'a', - VirtualKeyCode: 'A', - }), - }, - expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Text: "a"}}, - }, - { - name: "single key event with control key", - events: []xwindows.InputRecord{ - encodeKeyEvent(xwindows.KeyEventRecord{ - KeyDown: true, - Char: 'a', - VirtualKeyCode: 'A', - ControlKeyState: xwindows.LEFT_CTRL_PRESSED, - }), - }, - expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Mod: ModCtrl}}, - }, - { - name: "escape alt key event", - events: []xwindows.InputRecord{ - encodeKeyEvent(xwindows.KeyEventRecord{ - KeyDown: true, - Char: ansi.ESC, - VirtualKeyCode: ansi.ESC, - ControlKeyState: xwindows.LEFT_ALT_PRESSED, - }), - }, - expected: []Event{KeyPressEvent{Code: ansi.ESC, BaseCode: ansi.ESC, Mod: ModAlt}}, - }, - { - name: "single shifted key event", - events: []xwindows.InputRecord{ - encodeKeyEvent(xwindows.KeyEventRecord{ - KeyDown: true, - Char: 'A', - VirtualKeyCode: 'A', - ControlKeyState: xwindows.SHIFT_PRESSED, - }), - }, - expected: []Event{KeyPressEvent{Code: 'A', BaseCode: 'a', Text: "A", Mod: ModShift}}, - }, - { - name: "utf16 rune", - events: encodeUtf16Rune('😊'), // smiley emoji '😊' - expected: []Event{ - KeyPressEvent{Code: '😊', Text: "😊"}, - }, - sequence: true, - }, - { - name: "background color response", - events: encodeSequence("\x1b]11;rgb:ff/ff/ff\x07"), - expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}}, - sequence: true, - }, - { - name: "st terminated background color response", - events: encodeSequence("\x1b]11;rgb:ffff/ffff/ffff\x1b\\"), - expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}}, - sequence: true, - }, - { - name: "simple mouse event", - events: []xwindows.InputRecord{ - encodeMouseEvent(xwindows.MouseEventRecord{ - MousePositon: windows.Coord{X: 10, Y: 20}, - ButtonState: xwindows.FROM_LEFT_1ST_BUTTON_PRESSED, - EventFlags: 0, - }), - encodeMouseEvent(xwindows.MouseEventRecord{ - MousePositon: windows.Coord{X: 10, Y: 20}, - EventFlags: 0, - }), - }, - expected: []Event{ - MouseClickEvent{Button: MouseLeft, X: 10, Y: 20}, - MouseReleaseEvent{Button: MouseLeft, X: 10, Y: 20}, - }, - }, - { - name: "focus event", - events: []xwindows.InputRecord{ - encodeFocusEvent(xwindows.FocusEventRecord{ - SetFocus: true, - }), - encodeFocusEvent(xwindows.FocusEventRecord{ - SetFocus: false, - }), - }, - expected: []Event{ - FocusEvent{}, - BlurEvent{}, - }, - }, - { - name: "window size event", - events: []xwindows.InputRecord{ - encodeWindowBufferSizeEvent(xwindows.WindowBufferSizeRecord{ - Size: windows.Coord{X: 10, Y: 20}, - }), - }, - expected: []Event{ - WindowSizeEvent{Width: 10, Height: 20}, - }, - }, - } - - // p is the parser to parse the input events - var p Parser - - // keep track of the state of the driver to handle ANSI sequences and utf16 - var state win32InputState - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if tc.sequence { - var Event Event - for _, ev := range tc.events { - if ev.EventType != xwindows.KEY_EVENT { - t.Fatalf("expected key event, got %v", ev.EventType) - } - - key := ev.KeyEvent() - Event = p.parseWin32InputKeyEvent(&state, key.VirtualKeyCode, key.VirtualScanCode, key.Char, key.KeyDown, key.ControlKeyState, key.RepeatCount) - } - if len(tc.expected) != 1 { - t.Fatalf("expected 1 event, got %d", len(tc.expected)) - } - if !reflect.DeepEqual(Event, tc.expected[0]) { - t.Errorf("expected %v, got %v", tc.expected[0], Event) - } - } else { - if len(tc.events) != len(tc.expected) { - t.Fatalf("expected %d events, got %d", len(tc.expected), len(tc.events)) - } - for j, ev := range tc.events { - Event := p.parseConInputEvent(ev, &state) - if !reflect.DeepEqual(Event, tc.expected[j]) { - t.Errorf("expected %#v, got %#v", tc.expected[j], Event) - } - } - } - }) - } -} - -func boolToUint32(b bool) uint32 { - if b { - return 1 - } - return 0 -} - -func encodeMenuEvent(menu xwindows.MenuEventRecord) xwindows.InputRecord { - var bts [16]byte - binary.LittleEndian.PutUint32(bts[0:4], menu.CommandID) - return xwindows.InputRecord{ - EventType: xwindows.MENU_EVENT, - Event: bts, - } -} - -func encodeWindowBufferSizeEvent(size xwindows.WindowBufferSizeRecord) xwindows.InputRecord { - var bts [16]byte - binary.LittleEndian.PutUint16(bts[0:2], uint16(size.Size.X)) - binary.LittleEndian.PutUint16(bts[2:4], uint16(size.Size.Y)) - return xwindows.InputRecord{ - EventType: xwindows.WINDOW_BUFFER_SIZE_EVENT, - Event: bts, - } -} - -func encodeFocusEvent(focus xwindows.FocusEventRecord) xwindows.InputRecord { - var bts [16]byte - if focus.SetFocus { - bts[0] = 1 - } - return xwindows.InputRecord{ - EventType: xwindows.FOCUS_EVENT, - Event: bts, - } -} - -func encodeMouseEvent(mouse xwindows.MouseEventRecord) xwindows.InputRecord { - var bts [16]byte - binary.LittleEndian.PutUint16(bts[0:2], uint16(mouse.MousePositon.X)) - binary.LittleEndian.PutUint16(bts[2:4], uint16(mouse.MousePositon.Y)) - binary.LittleEndian.PutUint32(bts[4:8], mouse.ButtonState) - binary.LittleEndian.PutUint32(bts[8:12], mouse.ControlKeyState) - binary.LittleEndian.PutUint32(bts[12:16], mouse.EventFlags) - return xwindows.InputRecord{ - EventType: xwindows.MOUSE_EVENT, - Event: bts, - } -} - -func encodeKeyEvent(key xwindows.KeyEventRecord) xwindows.InputRecord { - var bts [16]byte - binary.LittleEndian.PutUint32(bts[0:4], boolToUint32(key.KeyDown)) - binary.LittleEndian.PutUint16(bts[4:6], key.RepeatCount) - binary.LittleEndian.PutUint16(bts[6:8], key.VirtualKeyCode) - binary.LittleEndian.PutUint16(bts[8:10], key.VirtualScanCode) - binary.LittleEndian.PutUint16(bts[10:12], uint16(key.Char)) - binary.LittleEndian.PutUint32(bts[12:16], key.ControlKeyState) - return xwindows.InputRecord{ - EventType: xwindows.KEY_EVENT, - Event: bts, - } -} - -// encodeSequence encodes a string of ANSI escape sequences into a slice of -// Windows input key records. -func encodeSequence(s string) (evs []xwindows.InputRecord) { - var state byte - for len(s) > 0 { - seq, _, n, newState := ansi.DecodeSequence(s, state, nil) - for i := 0; i < n; i++ { - evs = append(evs, encodeKeyEvent(xwindows.KeyEventRecord{ - KeyDown: true, - Char: rune(seq[i]), - })) - } - state = newState - s = s[n:] - } - return -} - -func encodeUtf16Rune(r rune) []xwindows.InputRecord { - r1, r2 := utf16.EncodeRune(r) - return encodeUtf16Pair(r1, r2) -} - -func encodeUtf16Pair(r1, r2 rune) []xwindows.InputRecord { - return []xwindows.InputRecord{ - encodeKeyEvent(xwindows.KeyEventRecord{ - KeyDown: true, - Char: r1, - }), - encodeKeyEvent(xwindows.KeyEventRecord{ - KeyDown: true, - Char: r2, - }), - } -} diff --git a/packages/tui/input/focus.go b/packages/tui/input/focus.go deleted file mode 100644 index 796d95f6..00000000 --- a/packages/tui/input/focus.go +++ /dev/null @@ -1,9 +0,0 @@ -package input - -// FocusEvent represents a terminal focus event. -// This occurs when the terminal gains focus. -type FocusEvent struct{} - -// BlurEvent represents a terminal blur event. -// This occurs when the terminal loses focus. -type BlurEvent struct{} diff --git a/packages/tui/input/focus_test.go b/packages/tui/input/focus_test.go deleted file mode 100644 index 2d35e476..00000000 --- a/packages/tui/input/focus_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package input - -import ( - "testing" -) - -func TestFocus(t *testing.T) { - var p Parser - _, e := p.parseSequence([]byte("\x1b[I")) - switch e.(type) { - case FocusEvent: - // ok - default: - t.Error("invalid sequence") - } -} - -func TestBlur(t *testing.T) { - var p Parser - _, e := p.parseSequence([]byte("\x1b[O")) - switch e.(type) { - case BlurEvent: - // ok - default: - t.Error("invalid sequence") - } -} diff --git a/packages/tui/input/go.mod b/packages/tui/input/go.mod deleted file mode 100644 index 36a9a92a..00000000 --- a/packages/tui/input/go.mod +++ /dev/null @@ -1,18 +0,0 @@ -module github.com/charmbracelet/x/input - -go 1.23.0 - -require ( - github.com/charmbracelet/x/ansi v0.9.3 - github.com/charmbracelet/x/windows v0.2.1 - github.com/muesli/cancelreader v0.2.2 - github.com/rivo/uniseg v0.4.7 - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e - golang.org/x/sys v0.33.0 -) - -require ( - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect -) diff --git a/packages/tui/input/go.sum b/packages/tui/input/go.sum deleted file mode 100644 index 7bc7a2eb..00000000 --- a/packages/tui/input/go.sum +++ /dev/null @@ -1,19 +0,0 @@ -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= -github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/packages/tui/input/input.go b/packages/tui/input/input.go deleted file mode 100644 index da5e4f0b..00000000 --- a/packages/tui/input/input.go +++ /dev/null @@ -1,45 +0,0 @@ -package input - -import ( - "fmt" - "strings" -) - -// Event represents a terminal event. -type Event any - -// UnknownEvent represents an unknown event. -type UnknownEvent string - -// String returns a string representation of the unknown event. -func (e UnknownEvent) String() string { - return fmt.Sprintf("%q", string(e)) -} - -// MultiEvent represents multiple messages event. -type MultiEvent []Event - -// String returns a string representation of the multiple messages event. -func (e MultiEvent) String() string { - var sb strings.Builder - for _, ev := range e { - sb.WriteString(fmt.Sprintf("%v\n", ev)) - } - return sb.String() -} - -// WindowSizeEvent is used to report the terminal size. Note that Windows does -// not have support for reporting resizes via SIGWINCH signals and relies on -// the Windows Console API to report window size changes. -type WindowSizeEvent struct { - Width int - Height int -} - -// WindowOpEvent is a window operation (XTWINOPS) report event. This is used to -// report various window operations such as reporting the window size or cell -// size. -type WindowOpEvent struct { - Op int - Args []int -} diff --git a/packages/tui/input/key.go b/packages/tui/input/key.go deleted file mode 100644 index 8d3e3ebe..00000000 --- a/packages/tui/input/key.go +++ /dev/null @@ -1,574 +0,0 @@ -package input - -import ( - "fmt" - "strings" - "unicode" - - "github.com/charmbracelet/x/ansi" -) - -const ( - // KeyExtended is a special key code used to signify that a key event - // contains multiple runes. - KeyExtended = unicode.MaxRune + 1 -) - -// Special key symbols. -const ( - - // Special keys. - - KeyUp rune = KeyExtended + iota + 1 - KeyDown - KeyRight - KeyLeft - KeyBegin - KeyFind - KeyInsert - KeyDelete - KeySelect - KeyPgUp - KeyPgDown - KeyHome - KeyEnd - - // Keypad keys. - - KeyKpEnter - KeyKpEqual - KeyKpMultiply - KeyKpPlus - KeyKpComma - KeyKpMinus - KeyKpDecimal - KeyKpDivide - KeyKp0 - KeyKp1 - KeyKp2 - KeyKp3 - KeyKp4 - KeyKp5 - KeyKp6 - KeyKp7 - KeyKp8 - KeyKp9 - - //nolint:godox - // The following are keys defined in the Kitty keyboard protocol. - // TODO: Investigate the names of these keys. - - KeyKpSep - KeyKpUp - KeyKpDown - KeyKpLeft - KeyKpRight - KeyKpPgUp - KeyKpPgDown - KeyKpHome - KeyKpEnd - KeyKpInsert - KeyKpDelete - KeyKpBegin - - // Function keys. - - KeyF1 - KeyF2 - KeyF3 - KeyF4 - KeyF5 - KeyF6 - KeyF7 - KeyF8 - KeyF9 - KeyF10 - KeyF11 - KeyF12 - KeyF13 - KeyF14 - KeyF15 - KeyF16 - KeyF17 - KeyF18 - KeyF19 - KeyF20 - KeyF21 - KeyF22 - KeyF23 - KeyF24 - KeyF25 - KeyF26 - KeyF27 - KeyF28 - KeyF29 - KeyF30 - KeyF31 - KeyF32 - KeyF33 - KeyF34 - KeyF35 - KeyF36 - KeyF37 - KeyF38 - KeyF39 - KeyF40 - KeyF41 - KeyF42 - KeyF43 - KeyF44 - KeyF45 - KeyF46 - KeyF47 - KeyF48 - KeyF49 - KeyF50 - KeyF51 - KeyF52 - KeyF53 - KeyF54 - KeyF55 - KeyF56 - KeyF57 - KeyF58 - KeyF59 - KeyF60 - KeyF61 - KeyF62 - KeyF63 - - //nolint:godox - // The following are keys defined in the Kitty keyboard protocol. - // TODO: Investigate the names of these keys. - - KeyCapsLock - KeyScrollLock - KeyNumLock - KeyPrintScreen - KeyPause - KeyMenu - - KeyMediaPlay - KeyMediaPause - KeyMediaPlayPause - KeyMediaReverse - KeyMediaStop - KeyMediaFastForward - KeyMediaRewind - KeyMediaNext - KeyMediaPrev - KeyMediaRecord - - KeyLowerVol - KeyRaiseVol - KeyMute - - KeyLeftShift - KeyLeftAlt - KeyLeftCtrl - KeyLeftSuper - KeyLeftHyper - KeyLeftMeta - KeyRightShift - KeyRightAlt - KeyRightCtrl - KeyRightSuper - KeyRightHyper - KeyRightMeta - KeyIsoLevel3Shift - KeyIsoLevel5Shift - - // Special names in C0. - - KeyBackspace = rune(ansi.DEL) - KeyTab = rune(ansi.HT) - KeyEnter = rune(ansi.CR) - KeyReturn = KeyEnter - KeyEscape = rune(ansi.ESC) - KeyEsc = KeyEscape - - // Special names in G0. - - KeySpace = rune(ansi.SP) -) - -// KeyPressEvent represents a key press event. -type KeyPressEvent Key - -// String implements [fmt.Stringer] and is quite useful for matching key -// events. For details, on what this returns see [Key.String]. -func (k KeyPressEvent) String() string { - return Key(k).String() -} - -// Keystroke returns the keystroke representation of the [Key]. While less type -// safe than looking at the individual fields, it will usually be more -// convenient and readable to use this method when matching against keys. -// -// Note that modifier keys are always printed in the following order: -// - ctrl -// - alt -// - shift -// - meta -// - hyper -// - super -// -// For example, you'll always see "ctrl+shift+alt+a" and never -// "shift+ctrl+alt+a". -func (k KeyPressEvent) Keystroke() string { - return Key(k).Keystroke() -} - -// Key returns the underlying key event. This is a syntactic sugar for casting -// the key event to a [Key]. -func (k KeyPressEvent) Key() Key { - return Key(k) -} - -// KeyReleaseEvent represents a key release event. -type KeyReleaseEvent Key - -// String implements [fmt.Stringer] and is quite useful for matching key -// events. For details, on what this returns see [Key.String]. -func (k KeyReleaseEvent) String() string { - return Key(k).String() -} - -// Keystroke returns the keystroke representation of the [Key]. While less type -// safe than looking at the individual fields, it will usually be more -// convenient and readable to use this method when matching against keys. -// -// Note that modifier keys are always printed in the following order: -// - ctrl -// - alt -// - shift -// - meta -// - hyper -// - super -// -// For example, you'll always see "ctrl+shift+alt+a" and never -// "shift+ctrl+alt+a". -func (k KeyReleaseEvent) Keystroke() string { - return Key(k).Keystroke() -} - -// Key returns the underlying key event. This is a convenience method and -// syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to -// [Key]. -func (k KeyReleaseEvent) Key() Key { - return Key(k) -} - -// KeyEvent represents a key event. This can be either a key press or a key -// release event. -type KeyEvent interface { - fmt.Stringer - - // Key returns the underlying key event. - Key() Key -} - -// Key represents a Key press or release event. It contains information about -// the Key pressed, like the runes, the type of Key, and the modifiers pressed. -// There are a couple general patterns you could use to check for key presses -// or releases: -// -// // Switch on the string representation of the key (shorter) -// switch ev := ev.(type) { -// case KeyPressEvent: -// switch ev.String() { -// case "enter": -// fmt.Println("you pressed enter!") -// case "a": -// fmt.Println("you pressed a!") -// } -// } -// -// // Switch on the key type (more foolproof) -// switch ev := ev.(type) { -// case KeyEvent: -// // catch both KeyPressEvent and KeyReleaseEvent -// switch key := ev.Key(); key.Code { -// case KeyEnter: -// fmt.Println("you pressed enter!") -// default: -// switch key.Text { -// case "a": -// fmt.Println("you pressed a!") -// } -// } -// } -// -// Note that [Key.Text] will be empty for special keys like [KeyEnter], -// [KeyTab], and for keys that don't represent printable characters like key -// combos with modifier keys. In other words, [Key.Text] is populated only for -// keys that represent printable characters shifted or unshifted (like 'a', -// 'A', '1', '!', etc.). -type Key struct { - // Text contains the actual characters received. This usually the same as - // [Key.Code]. When [Key.Text] is non-empty, it indicates that the key - // pressed represents printable character(s). - Text string - - // Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on. - Mod KeyMod - - // Code represents the key pressed. This is usually a special key like - // [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'. - Code rune - - // ShiftedCode is the actual, shifted key pressed by the user. For example, - // if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will - // be 'A' and [Key.Code] will be 'a'. - // - // In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the - // unshifted key on the keyboard. - // - // This is only available with the Kitty Keyboard Protocol or the Windows - // Console API. - ShiftedCode rune - - // BaseCode is the key pressed according to the standard PC-101 key layout. - // On international keyboards, this is the key that would be pressed if the - // keyboard was set to US PC-101 layout. - // - // For example, if the user presses 'q' on a French AZERTY keyboard, - // [Key.BaseCode] will be 'q'. - // - // This is only available with the Kitty Keyboard Protocol or the Windows - // Console API. - BaseCode rune - - // IsRepeat indicates whether the key is being held down and sending events - // repeatedly. - // - // This is only available with the Kitty Keyboard Protocol or the Windows - // Console API. - IsRepeat bool -} - -// String implements [fmt.Stringer] and is quite useful for matching key -// events. It will return the textual representation of the [Key] if there is -// one, otherwise, it will fallback to [Key.Keystroke]. -// -// For example, you'll always get "?" and instead of "shift+/" on a US ANSI -// keyboard. -func (k Key) String() string { - if len(k.Text) > 0 && k.Text != " " { - return k.Text - } - return k.Keystroke() -} - -// Keystroke returns the keystroke representation of the [Key]. While less type -// safe than looking at the individual fields, it will usually be more -// convenient and readable to use this method when matching against keys. -// -// Note that modifier keys are always printed in the following order: -// - ctrl -// - alt -// - shift -// - meta -// - hyper -// - super -// -// For example, you'll always see "ctrl+shift+alt+a" and never -// "shift+ctrl+alt+a". -func (k Key) Keystroke() string { - var sb strings.Builder - if k.Mod.Contains(ModCtrl) && k.Code != KeyLeftCtrl && k.Code != KeyRightCtrl { - sb.WriteString("ctrl+") - } - if k.Mod.Contains(ModAlt) && k.Code != KeyLeftAlt && k.Code != KeyRightAlt { - sb.WriteString("alt+") - } - if k.Mod.Contains(ModShift) && k.Code != KeyLeftShift && k.Code != KeyRightShift { - sb.WriteString("shift+") - } - if k.Mod.Contains(ModMeta) && k.Code != KeyLeftMeta && k.Code != KeyRightMeta { - sb.WriteString("meta+") - } - if k.Mod.Contains(ModHyper) && k.Code != KeyLeftHyper && k.Code != KeyRightHyper { - sb.WriteString("hyper+") - } - if k.Mod.Contains(ModSuper) && k.Code != KeyLeftSuper && k.Code != KeyRightSuper { - sb.WriteString("super+") - } - - if kt, ok := keyTypeString[k.Code]; ok { - sb.WriteString(kt) - } else { - code := k.Code - if k.BaseCode != 0 { - // If a [Key.BaseCode] is present, use it to represent a key using the standard - // PC-101 key layout. - code = k.BaseCode - } - - switch code { - case KeySpace: - // Space is the only invisible printable character. - sb.WriteString("space") - case KeyExtended: - // Write the actual text of the key when the key contains multiple - // runes. - sb.WriteString(k.Text) - default: - sb.WriteRune(code) - } - } - - return sb.String() -} - -var keyTypeString = map[rune]string{ - KeyEnter: "enter", - KeyTab: "tab", - KeyBackspace: "backspace", - KeyEscape: "esc", - KeySpace: "space", - KeyUp: "up", - KeyDown: "down", - KeyLeft: "left", - KeyRight: "right", - KeyBegin: "begin", - KeyFind: "find", - KeyInsert: "insert", - KeyDelete: "delete", - KeySelect: "select", - KeyPgUp: "pgup", - KeyPgDown: "pgdown", - KeyHome: "home", - KeyEnd: "end", - KeyKpEnter: "kpenter", - KeyKpEqual: "kpequal", - KeyKpMultiply: "kpmul", - KeyKpPlus: "kpplus", - KeyKpComma: "kpcomma", - KeyKpMinus: "kpminus", - KeyKpDecimal: "kpperiod", - KeyKpDivide: "kpdiv", - KeyKp0: "kp0", - KeyKp1: "kp1", - KeyKp2: "kp2", - KeyKp3: "kp3", - KeyKp4: "kp4", - KeyKp5: "kp5", - KeyKp6: "kp6", - KeyKp7: "kp7", - KeyKp8: "kp8", - KeyKp9: "kp9", - - // Kitty keyboard extension - KeyKpSep: "kpsep", - KeyKpUp: "kpup", - KeyKpDown: "kpdown", - KeyKpLeft: "kpleft", - KeyKpRight: "kpright", - KeyKpPgUp: "kppgup", - KeyKpPgDown: "kppgdown", - KeyKpHome: "kphome", - KeyKpEnd: "kpend", - KeyKpInsert: "kpinsert", - KeyKpDelete: "kpdelete", - KeyKpBegin: "kpbegin", - - KeyF1: "f1", - KeyF2: "f2", - KeyF3: "f3", - KeyF4: "f4", - KeyF5: "f5", - KeyF6: "f6", - KeyF7: "f7", - KeyF8: "f8", - KeyF9: "f9", - KeyF10: "f10", - KeyF11: "f11", - KeyF12: "f12", - KeyF13: "f13", - KeyF14: "f14", - KeyF15: "f15", - KeyF16: "f16", - KeyF17: "f17", - KeyF18: "f18", - KeyF19: "f19", - KeyF20: "f20", - KeyF21: "f21", - KeyF22: "f22", - KeyF23: "f23", - KeyF24: "f24", - KeyF25: "f25", - KeyF26: "f26", - KeyF27: "f27", - KeyF28: "f28", - KeyF29: "f29", - KeyF30: "f30", - KeyF31: "f31", - KeyF32: "f32", - KeyF33: "f33", - KeyF34: "f34", - KeyF35: "f35", - KeyF36: "f36", - KeyF37: "f37", - KeyF38: "f38", - KeyF39: "f39", - KeyF40: "f40", - KeyF41: "f41", - KeyF42: "f42", - KeyF43: "f43", - KeyF44: "f44", - KeyF45: "f45", - KeyF46: "f46", - KeyF47: "f47", - KeyF48: "f48", - KeyF49: "f49", - KeyF50: "f50", - KeyF51: "f51", - KeyF52: "f52", - KeyF53: "f53", - KeyF54: "f54", - KeyF55: "f55", - KeyF56: "f56", - KeyF57: "f57", - KeyF58: "f58", - KeyF59: "f59", - KeyF60: "f60", - KeyF61: "f61", - KeyF62: "f62", - KeyF63: "f63", - - // Kitty keyboard extension - KeyCapsLock: "capslock", - KeyScrollLock: "scrolllock", - KeyNumLock: "numlock", - KeyPrintScreen: "printscreen", - KeyPause: "pause", - KeyMenu: "menu", - KeyMediaPlay: "mediaplay", - KeyMediaPause: "mediapause", - KeyMediaPlayPause: "mediaplaypause", - KeyMediaReverse: "mediareverse", - KeyMediaStop: "mediastop", - KeyMediaFastForward: "mediafastforward", - KeyMediaRewind: "mediarewind", - KeyMediaNext: "medianext", - KeyMediaPrev: "mediaprev", - KeyMediaRecord: "mediarecord", - KeyLowerVol: "lowervol", - KeyRaiseVol: "raisevol", - KeyMute: "mute", - KeyLeftShift: "leftshift", - KeyLeftAlt: "leftalt", - KeyLeftCtrl: "leftctrl", - KeyLeftSuper: "leftsuper", - KeyLeftHyper: "lefthyper", - KeyLeftMeta: "leftmeta", - KeyRightShift: "rightshift", - KeyRightAlt: "rightalt", - KeyRightCtrl: "rightctrl", - KeyRightSuper: "rightsuper", - KeyRightHyper: "righthyper", - KeyRightMeta: "rightmeta", - KeyIsoLevel3Shift: "isolevel3shift", - KeyIsoLevel5Shift: "isolevel5shift", -} diff --git a/packages/tui/input/key_test.go b/packages/tui/input/key_test.go deleted file mode 100644 index b09f2f85..00000000 --- a/packages/tui/input/key_test.go +++ /dev/null @@ -1,880 +0,0 @@ -package input - -import ( - "bytes" - "context" - "errors" - "flag" - "fmt" - "image/color" - "io" - "math/rand" - "reflect" - "regexp" - "runtime" - "sort" - "strings" - "sync" - "testing" - "time" - - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/ansi/kitty" -) - -var sequences = buildKeysTable(FlagTerminfo, "dumb") - -func TestKeyString(t *testing.T) { - t.Run("alt+space", func(t *testing.T) { - k := KeyPressEvent{Code: KeySpace, Mod: ModAlt} - if got := k.String(); got != "alt+space" { - t.Fatalf(`expected a "alt+space", got %q`, got) - } - }) - - t.Run("runes", func(t *testing.T) { - k := KeyPressEvent{Code: 'a', Text: "a"} - if got := k.String(); got != "a" { - t.Fatalf(`expected an "a", got %q`, got) - } - }) - - t.Run("invalid", func(t *testing.T) { - k := KeyPressEvent{Code: 99999} - if got := k.String(); got != "𘚟" { - t.Fatalf(`expected a "unknown", got %q`, got) - } - }) - - t.Run("space", func(t *testing.T) { - k := KeyPressEvent{Code: KeySpace, Text: " "} - if got := k.String(); got != "space" { - t.Fatalf(`expected a "space", got %q`, got) - } - }) - - t.Run("shift+space", func(t *testing.T) { - k := KeyPressEvent{Code: KeySpace, Mod: ModShift} - if got := k.String(); got != "shift+space" { - t.Fatalf(`expected a "shift+space", got %q`, got) - } - }) - - t.Run("?", func(t *testing.T) { - k := KeyPressEvent{Code: '/', Mod: ModShift, Text: "?"} - if got := k.String(); got != "?" { - t.Fatalf(`expected a "?", got %q`, got) - } - }) -} - -type seqTest struct { - seq []byte - Events []Event -} - -var f3CurPosRegexp = regexp.MustCompile(`\x1b\[1;(\d+)R`) - -// buildBaseSeqTests returns sequence tests that are valid for the -// detectSequence() function. -func buildBaseSeqTests() []seqTest { - td := []seqTest{} - for seq, key := range sequences { - k := KeyPressEvent(key) - st := seqTest{seq: []byte(seq), Events: []Event{k}} - - // XXX: This is a special case to handle F3 key sequence and cursor - // position report having the same sequence. See [parseCsi] for more - // information. - if f3CurPosRegexp.MatchString(seq) { - st.Events = []Event{k, CursorPositionEvent{Y: 0, X: int(key.Mod)}} - } - td = append(td, st) - } - - // Additional special cases. - td = append(td, - // Unrecognized CSI sequence. - seqTest{ - []byte{'\x1b', '[', '-', '-', '-', '-', 'X'}, - []Event{ - UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}), - }, - }, - // A lone space character. - seqTest{ - []byte{' '}, - []Event{ - KeyPressEvent{Code: KeySpace, Text: " "}, - }, - }, - // An escape character with the alt modifier. - seqTest{ - []byte{'\x1b', ' '}, - []Event{ - KeyPressEvent{Code: KeySpace, Mod: ModAlt}, - }, - }, - ) - return td -} - -func TestParseSequence(t *testing.T) { - td := buildBaseSeqTests() - td = append(td, - // Background color. - seqTest{ - []byte("\x1b]11;rgb:1234/1234/1234\x07"), - []Event{BackgroundColorEvent{ - Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}, - }}, - }, - seqTest{ - []byte("\x1b]11;rgb:1234/1234/1234\x1b\\"), - []Event{BackgroundColorEvent{ - Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}, - }}, - }, - seqTest{ - []byte("\x1b]11;rgb:1234/1234/1234\x1b"), // Incomplete sequences are ignored. - []Event{ - UnknownEvent("\x1b]11;rgb:1234/1234/1234\x1b"), - }, - }, - - // Kitty Graphics response. - seqTest{ - []byte("\x1b_Ga=t;OK\x1b\\"), - []Event{KittyGraphicsEvent{ - Options: kitty.Options{Action: kitty.Transmit}, - Payload: []byte("OK"), - }}, - }, - seqTest{ - []byte("\x1b_Gi=99,I=13;OK\x1b\\"), - []Event{KittyGraphicsEvent{ - Options: kitty.Options{ID: 99, Number: 13}, - Payload: []byte("OK"), - }}, - }, - seqTest{ - []byte("\x1b_Gi=1337,q=1;EINVAL:your face\x1b\\"), - []Event{KittyGraphicsEvent{ - Options: kitty.Options{ID: 1337, Quite: 1}, - Payload: []byte("EINVAL:your face"), - }}, - }, - - // Xterm modifyOtherKeys CSI 27 ; ; ~ - seqTest{ - []byte("\x1b[27;3;20320~"), - []Event{KeyPressEvent{Code: '你', Mod: ModAlt}}, - }, - seqTest{ - []byte("\x1b[27;3;65~"), - []Event{KeyPressEvent{Code: 'A', Mod: ModAlt}}, - }, - seqTest{ - []byte("\x1b[27;3;8~"), - []Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}}, - }, - seqTest{ - []byte("\x1b[27;3;27~"), - []Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}}, - }, - seqTest{ - []byte("\x1b[27;3;127~"), - []Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}}, - }, - - // Xterm report window text area size. - seqTest{ - []byte("\x1b[4;24;80t"), - []Event{ - WindowOpEvent{Op: 4, Args: []int{24, 80}}, - }, - }, - - // Kitty keyboard / CSI u (fixterms) - seqTest{ - []byte("\x1b[1B"), - []Event{KeyPressEvent{Code: KeyDown}}, - }, - seqTest{ - []byte("\x1b[1;B"), - []Event{KeyPressEvent{Code: KeyDown}}, - }, - seqTest{ - []byte("\x1b[1;4B"), - []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}}, - }, - seqTest{ - []byte("\x1b[1;4:1B"), - []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}}, - }, - seqTest{ - []byte("\x1b[1;4:2B"), - []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown, IsRepeat: true}}, - }, - seqTest{ - []byte("\x1b[1;4:3B"), - []Event{KeyReleaseEvent{Mod: ModShift | ModAlt, Code: KeyDown}}, - }, - seqTest{ - []byte("\x1b[8~"), - []Event{KeyPressEvent{Code: KeyEnd}}, - }, - seqTest{ - []byte("\x1b[8;~"), - []Event{KeyPressEvent{Code: KeyEnd}}, - }, - seqTest{ - []byte("\x1b[8;10~"), - []Event{KeyPressEvent{Mod: ModShift | ModMeta, Code: KeyEnd}}, - }, - seqTest{ - []byte("\x1b[27;4u"), - []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyEscape}}, - }, - seqTest{ - []byte("\x1b[127;4u"), - []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyBackspace}}, - }, - seqTest{ - []byte("\x1b[57358;4u"), - []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyCapsLock}}, - }, - seqTest{ - []byte("\x1b[9;2u"), - []Event{KeyPressEvent{Mod: ModShift, Code: KeyTab}}, - }, - seqTest{ - []byte("\x1b[195;u"), - []Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}}, - }, - seqTest{ - []byte("\x1b[20320;2u"), - []Event{KeyPressEvent{Text: "你", Mod: ModShift, Code: '你'}}, - }, - seqTest{ - []byte("\x1b[195;:1u"), - []Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}}, - }, - seqTest{ - []byte("\x1b[195;2:3u"), - []Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}}, - }, - seqTest{ - []byte("\x1b[195;2:2u"), - []Event{KeyPressEvent{Code: 'Ã', Text: "Ã", IsRepeat: true, Mod: ModShift}}, - }, - seqTest{ - []byte("\x1b[195;2:1u"), - []Event{KeyPressEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}}, - }, - seqTest{ - []byte("\x1b[195;2:3u"), - []Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}}, - }, - seqTest{ - []byte("\x1b[97;2;65u"), - []Event{KeyPressEvent{Code: 'a', Text: "A", Mod: ModShift}}, - }, - seqTest{ - []byte("\x1b[97;;229u"), - []Event{KeyPressEvent{Code: 'a', Text: "å"}}, - }, - - // focus/blur - seqTest{ - []byte{'\x1b', '[', 'I'}, - []Event{ - FocusEvent{}, - }, - }, - seqTest{ - []byte{'\x1b', '[', 'O'}, - []Event{ - BlurEvent{}, - }, - }, - // Mouse event. - seqTest{ - []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, - []Event{ - MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp}, - }, - }, - // SGR Mouse event. - seqTest{ - []byte("\x1b[<0;33;17M"), - []Event{ - MouseClickEvent{X: 32, Y: 16, Button: MouseLeft}, - }, - }, - // Runes. - seqTest{ - []byte{'a'}, - []Event{ - KeyPressEvent{Code: 'a', Text: "a"}, - }, - }, - seqTest{ - []byte{'\x1b', 'a'}, - []Event{ - KeyPressEvent{Code: 'a', Mod: ModAlt}, - }, - }, - seqTest{ - []byte{'a', 'a', 'a'}, - []Event{ - KeyPressEvent{Code: 'a', Text: "a"}, - KeyPressEvent{Code: 'a', Text: "a"}, - KeyPressEvent{Code: 'a', Text: "a"}, - }, - }, - // Multi-byte rune. - seqTest{ - []byte("☃"), - []Event{ - KeyPressEvent{Code: '☃', Text: "☃"}, - }, - }, - seqTest{ - []byte("\x1b☃"), - []Event{ - KeyPressEvent{Code: '☃', Mod: ModAlt}, - }, - }, - // Standalone control characters. - seqTest{ - []byte{'\x1b'}, - []Event{ - KeyPressEvent{Code: KeyEscape}, - }, - }, - seqTest{ - []byte{ansi.SOH}, - []Event{ - KeyPressEvent{Code: 'a', Mod: ModCtrl}, - }, - }, - seqTest{ - []byte{'\x1b', ansi.SOH}, - []Event{ - KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt}, - }, - }, - seqTest{ - []byte{ansi.NUL}, - []Event{ - KeyPressEvent{Code: KeySpace, Mod: ModCtrl}, - }, - }, - seqTest{ - []byte{'\x1b', ansi.NUL}, - []Event{ - KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt}, - }, - }, - // C1 control characters. - seqTest{ - []byte{'\x80'}, - []Event{ - KeyPressEvent{Code: rune(0x80 - '@'), Mod: ModCtrl | ModAlt}, - }, - }, - ) - - if runtime.GOOS != "windows" { - // Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows. - // This is incorrect, but it makes our test fail if we try it out. - td = append(td, seqTest{ - []byte{'\xfe'}, - []Event{ - UnknownEvent(rune(0xfe)), - }, - }) - } - - var p Parser - for _, tc := range td { - t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) { - var events []Event - buf := tc.seq - for len(buf) > 0 { - width, Event := p.parseSequence(buf) - switch Event := Event.(type) { - case MultiEvent: - events = append(events, Event...) - default: - events = append(events, Event) - } - buf = buf[width:] - } - if !reflect.DeepEqual(tc.Events, events) { - t.Errorf("\nexpected event for %q:\n %#v\ngot:\n %#v", tc.seq, tc.Events, events) - } - }) - } -} - -func TestReadLongInput(t *testing.T) { - expect := make([]Event, 1000) - for i := range 1000 { - expect[i] = KeyPressEvent{Code: 'a', Text: "a"} - } - input := strings.Repeat("a", 1000) - drv, err := NewReader(strings.NewReader(input), "dumb", 0) - if err != nil { - t.Fatalf("unexpected input driver error: %v", err) - } - - var Events []Event - for { - events, err := drv.ReadEvents() - if err == io.EOF { - break - } - if err != nil { - t.Fatalf("unexpected input error: %v", err) - } - Events = append(Events, events...) - } - - if !reflect.DeepEqual(expect, Events) { - t.Errorf("unexpected messages, expected:\n %+v\ngot:\n %+v", expect, Events) - } -} - -func TestReadInput(t *testing.T) { - type test struct { - keyname string - in []byte - out []Event - } - testData := []test{ - { - "a", - []byte{'a'}, - []Event{ - KeyPressEvent{Code: 'a', Text: "a"}, - }, - }, - { - "space", - []byte{' '}, - []Event{ - KeyPressEvent{Code: KeySpace, Text: " "}, - }, - }, - { - "a alt+a", - []byte{'a', '\x1b', 'a'}, - []Event{ - KeyPressEvent{Code: 'a', Text: "a"}, - KeyPressEvent{Code: 'a', Mod: ModAlt}, - }, - }, - { - "a alt+a a", - []byte{'a', '\x1b', 'a', 'a'}, - []Event{ - KeyPressEvent{Code: 'a', Text: "a"}, - KeyPressEvent{Code: 'a', Mod: ModAlt}, - KeyPressEvent{Code: 'a', Text: "a"}, - }, - }, - { - "ctrl+a", - []byte{byte(ansi.SOH)}, - []Event{ - KeyPressEvent{Code: 'a', Mod: ModCtrl}, - }, - }, - { - "ctrl+a ctrl+b", - []byte{byte(ansi.SOH), byte(ansi.STX)}, - []Event{ - KeyPressEvent{Code: 'a', Mod: ModCtrl}, - KeyPressEvent{Code: 'b', Mod: ModCtrl}, - }, - }, - { - "alt+a", - []byte{byte(0x1b), 'a'}, - []Event{ - KeyPressEvent{Code: 'a', Mod: ModAlt}, - }, - }, - { - "a b c d", - []byte{'a', 'b', 'c', 'd'}, - []Event{ - KeyPressEvent{Code: 'a', Text: "a"}, - KeyPressEvent{Code: 'b', Text: "b"}, - KeyPressEvent{Code: 'c', Text: "c"}, - KeyPressEvent{Code: 'd', Text: "d"}, - }, - }, - { - "up", - []byte("\x1b[A"), - []Event{ - KeyPressEvent{Code: KeyUp}, - }, - }, - { - "wheel up", - []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, - []Event{ - MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp}, - }, - }, - { - "left motion release", - []byte{ - '\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33), - '\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33), - }, - []Event{ - MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft}, - MouseReleaseEvent{X: 64, Y: 32, Button: MouseNone}, - }, - }, - { - "shift+tab", - []byte{'\x1b', '[', 'Z'}, - []Event{ - KeyPressEvent{Code: KeyTab, Mod: ModShift}, - }, - }, - { - "enter", - []byte{'\r'}, - []Event{KeyPressEvent{Code: KeyEnter}}, - }, - { - "alt+enter", - []byte{'\x1b', '\r'}, - []Event{ - KeyPressEvent{Code: KeyEnter, Mod: ModAlt}, - }, - }, - { - "insert", - []byte{'\x1b', '[', '2', '~'}, - []Event{ - KeyPressEvent{Code: KeyInsert}, - }, - }, - { - "ctrl+alt+a", - []byte{'\x1b', byte(ansi.SOH)}, - []Event{ - KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt}, - }, - }, - { - "CSI?----X?", - []byte{'\x1b', '[', '-', '-', '-', '-', 'X'}, - []Event{UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})}, - }, - // Powershell sequences. - { - "up", - []byte{'\x1b', 'O', 'A'}, - []Event{KeyPressEvent{Code: KeyUp}}, - }, - { - "down", - []byte{'\x1b', 'O', 'B'}, - []Event{KeyPressEvent{Code: KeyDown}}, - }, - { - "right", - []byte{'\x1b', 'O', 'C'}, - []Event{KeyPressEvent{Code: KeyRight}}, - }, - { - "left", - []byte{'\x1b', 'O', 'D'}, - []Event{KeyPressEvent{Code: KeyLeft}}, - }, - { - "alt+enter", - []byte{'\x1b', '\x0d'}, - []Event{KeyPressEvent{Code: KeyEnter, Mod: ModAlt}}, - }, - { - "alt+backspace", - []byte{'\x1b', '\x7f'}, - []Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}}, - }, - { - "ctrl+space", - []byte{'\x00'}, - []Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl}}, - }, - { - "ctrl+alt+space", - []byte{'\x1b', '\x00'}, - []Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt}}, - }, - { - "esc", - []byte{'\x1b'}, - []Event{KeyPressEvent{Code: KeyEscape}}, - }, - { - "alt+esc", - []byte{'\x1b', '\x1b'}, - []Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}}, - }, - { - "a b o", - []byte{ - '\x1b', '[', '2', '0', '0', '~', - 'a', ' ', 'b', - '\x1b', '[', '2', '0', '1', '~', - 'o', - }, - []Event{ - PasteStartEvent{}, - PasteEvent("a b"), - PasteEndEvent{}, - KeyPressEvent{Code: 'o', Text: "o"}, - }, - }, - { - "a\x03\nb", - []byte{ - '\x1b', '[', '2', '0', '0', '~', - 'a', '\x03', '\n', 'b', - '\x1b', '[', '2', '0', '1', '~', - }, - []Event{ - PasteStartEvent{}, - PasteEvent("a\x03\nb"), - PasteEndEvent{}, - }, - }, - { - "?0xfe?", - []byte{'\xfe'}, - []Event{ - UnknownEvent(rune(0xfe)), - }, - }, - { - "a ?0xfe? b", - []byte{'a', '\xfe', ' ', 'b'}, - []Event{ - KeyPressEvent{Code: 'a', Text: "a"}, - UnknownEvent(rune(0xfe)), - KeyPressEvent{Code: KeySpace, Text: " "}, - KeyPressEvent{Code: 'b', Text: "b"}, - }, - }, - } - - for i, td := range testData { - t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) { - Events := testReadInputs(t, bytes.NewReader(td.in)) - var buf strings.Builder - for i, Event := range Events { - if i > 0 { - buf.WriteByte(' ') - } - if s, ok := Event.(fmt.Stringer); ok { - buf.WriteString(s.String()) - } else { - fmt.Fprintf(&buf, "%#v:%T", Event, Event) - } - } - - if len(Events) != len(td.out) { - t.Fatalf("unexpected message list length: got %d, expected %d\n got: %#v\n expected: %#v\n", len(Events), len(td.out), Events, td.out) - } - - if !reflect.DeepEqual(td.out, Events) { - t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, Events) - } - }) - } -} - -func testReadInputs(t *testing.T, input io.Reader) []Event { - // We'll check that the input reader finishes at the end - // without error. - var wg sync.WaitGroup - var inputErr error - ctx, cancel := context.WithCancel(context.Background()) - defer func() { - cancel() - wg.Wait() - if inputErr != nil && !errors.Is(inputErr, io.EOF) { - t.Fatalf("unexpected input error: %v", inputErr) - } - }() - - dr, err := NewReader(input, "dumb", 0) - if err != nil { - t.Fatalf("unexpected input driver error: %v", err) - } - - // The messages we're consuming. - EventsC := make(chan Event) - - // Start the reader in the background. - wg.Add(1) - go func() { - defer wg.Done() - var events []Event - events, inputErr = dr.ReadEvents() - out: - for _, ev := range events { - select { - case EventsC <- ev: - case <-ctx.Done(): - break out - } - } - EventsC <- nil - }() - - var Events []Event -loop: - for { - select { - case Event := <-EventsC: - if Event == nil { - // end of input marker for the test. - break loop - } - Events = append(Events, Event) - case <-time.After(2 * time.Second): - t.Errorf("timeout waiting for input event") - break loop - } - } - return Events -} - -// randTest defines the test input and expected output for a sequence -// of interleaved control sequences and control characters. -type randTest struct { - data []byte - lengths []int - names []string -} - -// seed is the random seed to randomize the input. This helps check -// that all the sequences get ultimately exercised. -var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)") - -// genRandomData generates a randomized test, with a random seed unless -// the seed flag was set. -func genRandomData(logfn func(int64), length int) randTest { - // We'll use a random source. However, we give the user the option - // to override it to a specific value for reproducibility. - s := *seed - if s == 0 { - s = time.Now().UnixNano() - } - // Inform the user so they know what to reuse to get the same data. - logfn(s) - return genRandomDataWithSeed(s, length) -} - -// genRandomDataWithSeed generates a randomized test with a fixed seed. -func genRandomDataWithSeed(s int64, length int) randTest { - src := rand.NewSource(s) - r := rand.New(src) - - // allseqs contains all the sequences, in sorted order. We sort - // to make the test deterministic (when the seed is also fixed). - type seqpair struct { - seq string - name string - } - var allseqs []seqpair - for seq, key := range sequences { - allseqs = append(allseqs, seqpair{seq, key.String()}) - } - sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq }) - - // res contains the computed test. - var res randTest - - for len(res.data) < length { - alt := r.Intn(2) - prefix := "" - esclen := 0 - if alt == 1 { - prefix = "alt+" - esclen = 1 - } - kind := r.Intn(3) - switch kind { - case 0: - // A control character. - if alt == 1 { - res.data = append(res.data, '\x1b') - } - res.data = append(res.data, 1) - res.names = append(res.names, "ctrl+"+prefix+"a") - res.lengths = append(res.lengths, 1+esclen) - - case 1, 2: - // A sequence. - seqi := r.Intn(len(allseqs)) - s := allseqs[seqi] - if strings.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") { - esclen = 0 - prefix = "" - alt = 0 - } - if alt == 1 { - res.data = append(res.data, '\x1b') - } - res.data = append(res.data, s.seq...) - if strings.HasPrefix(s.name, "ctrl+") { - prefix = "ctrl+" + prefix - } - name := prefix + strings.TrimPrefix(s.name, "ctrl+") - res.names = append(res.names, name) - res.lengths = append(res.lengths, len(s.seq)+esclen) - } - } - return res -} - -func FuzzParseSequence(f *testing.F) { - var p Parser - for seq := range sequences { - f.Add(seq) - } - f.Add("\x1b]52;?\x07") // OSC 52 - f.Add("\x1b]11;rgb:0000/0000/0000\x1b\\") // OSC 11 - f.Add("\x1bP>|charm terminal(0.1.2)\x1b\\") // DCS (XTVERSION) - f.Add("\x1b_Gi=123\x1b\\") // APC - f.Fuzz(func(t *testing.T, seq string) { - n, _ := p.parseSequence([]byte(seq)) - if n == 0 && seq != "" { - t.Errorf("expected a non-zero width for %q", seq) - } - }) -} - -// BenchmarkDetectSequenceMap benchmarks the map-based sequence -// detector. -func BenchmarkDetectSequenceMap(b *testing.B) { - var p Parser - td := genRandomDataWithSeed(123, 10000) - for i := 0; i < b.N; i++ { - for j, w := 0, 0; j < len(td.data); j += w { - w, _ = p.parseSequence(td.data[j:]) - } - } -} diff --git a/packages/tui/input/kitty.go b/packages/tui/input/kitty.go deleted file mode 100644 index 4da00b50..00000000 --- a/packages/tui/input/kitty.go +++ /dev/null @@ -1,353 +0,0 @@ -package input - -import ( - "unicode" - "unicode/utf8" - - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/ansi/kitty" -) - -// KittyGraphicsEvent represents a Kitty Graphics response event. -// -// See https://sw.kovidgoyal.net/kitty/graphics-protocol/ -type KittyGraphicsEvent struct { - Options kitty.Options - Payload []byte -} - -// KittyEnhancementsEvent represents a Kitty enhancements event. -type KittyEnhancementsEvent int - -// Kitty keyboard enhancement constants. -// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement -const ( - KittyDisambiguateEscapeCodes KittyEnhancementsEvent = 1 << iota - KittyReportEventTypes - KittyReportAlternateKeys - KittyReportAllKeysAsEscapeCodes - KittyReportAssociatedText -) - -// Contains reports whether m contains the given enhancements. -func (e KittyEnhancementsEvent) Contains(enhancements KittyEnhancementsEvent) bool { - return e&enhancements == enhancements -} - -// Kitty Clipboard Control Sequences. -var kittyKeyMap = map[int]Key{ - ansi.BS: {Code: KeyBackspace}, - ansi.HT: {Code: KeyTab}, - ansi.CR: {Code: KeyEnter}, - ansi.ESC: {Code: KeyEscape}, - ansi.DEL: {Code: KeyBackspace}, - - 57344: {Code: KeyEscape}, - 57345: {Code: KeyEnter}, - 57346: {Code: KeyTab}, - 57347: {Code: KeyBackspace}, - 57348: {Code: KeyInsert}, - 57349: {Code: KeyDelete}, - 57350: {Code: KeyLeft}, - 57351: {Code: KeyRight}, - 57352: {Code: KeyUp}, - 57353: {Code: KeyDown}, - 57354: {Code: KeyPgUp}, - 57355: {Code: KeyPgDown}, - 57356: {Code: KeyHome}, - 57357: {Code: KeyEnd}, - 57358: {Code: KeyCapsLock}, - 57359: {Code: KeyScrollLock}, - 57360: {Code: KeyNumLock}, - 57361: {Code: KeyPrintScreen}, - 57362: {Code: KeyPause}, - 57363: {Code: KeyMenu}, - 57364: {Code: KeyF1}, - 57365: {Code: KeyF2}, - 57366: {Code: KeyF3}, - 57367: {Code: KeyF4}, - 57368: {Code: KeyF5}, - 57369: {Code: KeyF6}, - 57370: {Code: KeyF7}, - 57371: {Code: KeyF8}, - 57372: {Code: KeyF9}, - 57373: {Code: KeyF10}, - 57374: {Code: KeyF11}, - 57375: {Code: KeyF12}, - 57376: {Code: KeyF13}, - 57377: {Code: KeyF14}, - 57378: {Code: KeyF15}, - 57379: {Code: KeyF16}, - 57380: {Code: KeyF17}, - 57381: {Code: KeyF18}, - 57382: {Code: KeyF19}, - 57383: {Code: KeyF20}, - 57384: {Code: KeyF21}, - 57385: {Code: KeyF22}, - 57386: {Code: KeyF23}, - 57387: {Code: KeyF24}, - 57388: {Code: KeyF25}, - 57389: {Code: KeyF26}, - 57390: {Code: KeyF27}, - 57391: {Code: KeyF28}, - 57392: {Code: KeyF29}, - 57393: {Code: KeyF30}, - 57394: {Code: KeyF31}, - 57395: {Code: KeyF32}, - 57396: {Code: KeyF33}, - 57397: {Code: KeyF34}, - 57398: {Code: KeyF35}, - 57399: {Code: KeyKp0}, - 57400: {Code: KeyKp1}, - 57401: {Code: KeyKp2}, - 57402: {Code: KeyKp3}, - 57403: {Code: KeyKp4}, - 57404: {Code: KeyKp5}, - 57405: {Code: KeyKp6}, - 57406: {Code: KeyKp7}, - 57407: {Code: KeyKp8}, - 57408: {Code: KeyKp9}, - 57409: {Code: KeyKpDecimal}, - 57410: {Code: KeyKpDivide}, - 57411: {Code: KeyKpMultiply}, - 57412: {Code: KeyKpMinus}, - 57413: {Code: KeyKpPlus}, - 57414: {Code: KeyKpEnter}, - 57415: {Code: KeyKpEqual}, - 57416: {Code: KeyKpSep}, - 57417: {Code: KeyKpLeft}, - 57418: {Code: KeyKpRight}, - 57419: {Code: KeyKpUp}, - 57420: {Code: KeyKpDown}, - 57421: {Code: KeyKpPgUp}, - 57422: {Code: KeyKpPgDown}, - 57423: {Code: KeyKpHome}, - 57424: {Code: KeyKpEnd}, - 57425: {Code: KeyKpInsert}, - 57426: {Code: KeyKpDelete}, - 57427: {Code: KeyKpBegin}, - 57428: {Code: KeyMediaPlay}, - 57429: {Code: KeyMediaPause}, - 57430: {Code: KeyMediaPlayPause}, - 57431: {Code: KeyMediaReverse}, - 57432: {Code: KeyMediaStop}, - 57433: {Code: KeyMediaFastForward}, - 57434: {Code: KeyMediaRewind}, - 57435: {Code: KeyMediaNext}, - 57436: {Code: KeyMediaPrev}, - 57437: {Code: KeyMediaRecord}, - 57438: {Code: KeyLowerVol}, - 57439: {Code: KeyRaiseVol}, - 57440: {Code: KeyMute}, - 57441: {Code: KeyLeftShift}, - 57442: {Code: KeyLeftCtrl}, - 57443: {Code: KeyLeftAlt}, - 57444: {Code: KeyLeftSuper}, - 57445: {Code: KeyLeftHyper}, - 57446: {Code: KeyLeftMeta}, - 57447: {Code: KeyRightShift}, - 57448: {Code: KeyRightCtrl}, - 57449: {Code: KeyRightAlt}, - 57450: {Code: KeyRightSuper}, - 57451: {Code: KeyRightHyper}, - 57452: {Code: KeyRightMeta}, - 57453: {Code: KeyIsoLevel3Shift}, - 57454: {Code: KeyIsoLevel5Shift}, -} - -func init() { - // These are some faulty C0 mappings some terminals such as WezTerm have - // and doesn't follow the specs. - kittyKeyMap[ansi.NUL] = Key{Code: KeySpace, Mod: ModCtrl} - for i := ansi.SOH; i <= ansi.SUB; i++ { - if _, ok := kittyKeyMap[i]; !ok { - kittyKeyMap[i] = Key{Code: rune(i + 0x60), Mod: ModCtrl} - } - } - for i := ansi.FS; i <= ansi.US; i++ { - if _, ok := kittyKeyMap[i]; !ok { - kittyKeyMap[i] = Key{Code: rune(i + 0x40), Mod: ModCtrl} - } - } -} - -const ( - kittyShift = 1 << iota - kittyAlt - kittyCtrl - kittySuper - kittyHyper - kittyMeta - kittyCapsLock - kittyNumLock -) - -func fromKittyMod(mod int) KeyMod { - var m KeyMod - if mod&kittyShift != 0 { - m |= ModShift - } - if mod&kittyAlt != 0 { - m |= ModAlt - } - if mod&kittyCtrl != 0 { - m |= ModCtrl - } - if mod&kittySuper != 0 { - m |= ModSuper - } - if mod&kittyHyper != 0 { - m |= ModHyper - } - if mod&kittyMeta != 0 { - m |= ModMeta - } - if mod&kittyCapsLock != 0 { - m |= ModCapsLock - } - if mod&kittyNumLock != 0 { - m |= ModNumLock - } - return m -} - -// parseKittyKeyboard parses a Kitty Keyboard Protocol sequence. -// -// In `CSI u`, this is parsed as: -// -// CSI codepoint ; modifiers u -// codepoint: ASCII Dec value -// -// The Kitty Keyboard Protocol extends this with optional components that can be -// enabled progressively. The full sequence is parsed as: -// -// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u -// -// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/ -func parseKittyKeyboard(params ansi.Params) (Event Event) { - var isRelease bool - var key Key - - // The index of parameters separated by semicolons ';'. Sub parameters are - // separated by colons ':'. - var paramIdx int - var sudIdx int // The sub parameter index - for _, p := range params { - // Kitty Keyboard Protocol has 3 optional components. - switch paramIdx { - case 0: - switch sudIdx { - case 0: - var foundKey bool - code := p.Param(1) // CSI u has a default value of 1 - key, foundKey = kittyKeyMap[code] - if !foundKey { - r := rune(code) - if !utf8.ValidRune(r) { - r = utf8.RuneError - } - - key.Code = r - } - - case 2: - // shifted key + base key - if b := rune(p.Param(1)); unicode.IsPrint(b) { - // XXX: When alternate key reporting is enabled, the protocol - // can return 3 things, the unicode codepoint of the key, - // the shifted codepoint of the key, and the standard - // PC-101 key layout codepoint. - // This is useful to create an unambiguous mapping of keys - // when using a different language layout. - key.BaseCode = b - } - fallthrough - - case 1: - // shifted key - if s := rune(p.Param(1)); unicode.IsPrint(s) { - // XXX: We swap keys here because we want the shifted key - // to be the Rune that is returned by the event. - // For example, shift+a should produce "A" not "a". - // In such a case, we set AltRune to the original key "a" - // and Rune to "A". - key.ShiftedCode = s - } - } - case 1: - switch sudIdx { - case 0: - mod := p.Param(1) - if mod > 1 { - key.Mod = fromKittyMod(mod - 1) - if key.Mod > ModShift { - // XXX: We need to clear the text if we have a modifier key - // other than a [ModShift] key. - key.Text = "" - } - } - - case 1: - switch p.Param(1) { - case 2: - key.IsRepeat = true - case 3: - isRelease = true - } - case 2: - } - case 2: - if code := p.Param(0); code != 0 { - key.Text += string(rune(code)) - } - } - - sudIdx++ - if !p.HasMore() { - paramIdx++ - sudIdx = 0 - } - } - - //nolint:nestif - if len(key.Text) == 0 && unicode.IsPrint(key.Code) && - (key.Mod <= ModShift || key.Mod == ModCapsLock || key.Mod == ModShift|ModCapsLock) { - if key.Mod == 0 { - key.Text = string(key.Code) - } else { - desiredCase := unicode.ToLower - if key.Mod.Contains(ModShift) || key.Mod.Contains(ModCapsLock) { - desiredCase = unicode.ToUpper - } - if key.ShiftedCode != 0 { - key.Text = string(key.ShiftedCode) - } else { - key.Text = string(desiredCase(key.Code)) - } - } - } - - if isRelease { - return KeyReleaseEvent(key) - } - - return KeyPressEvent(key) -} - -// parseKittyKeyboardExt parses a Kitty Keyboard Protocol sequence extensions -// for non CSI u sequences. This includes things like CSI A, SS3 A and others, -// and CSI ~. -func parseKittyKeyboardExt(params ansi.Params, k KeyPressEvent) Event { - // Handle Kitty keyboard protocol - if len(params) > 2 && // We have at least 3 parameters - params[0].Param(1) == 1 && // The first parameter is 1 (defaults to 1) - params[1].HasMore() { // The second parameter is a subparameter (separated by a ":") - switch params[2].Param(1) { // The third parameter is the event type (defaults to 1) - case 2: - k.IsRepeat = true - case 3: - return KeyReleaseEvent(k) - } - } - return k -} diff --git a/packages/tui/input/mod.go b/packages/tui/input/mod.go deleted file mode 100644 index c0076276..00000000 --- a/packages/tui/input/mod.go +++ /dev/null @@ -1,37 +0,0 @@ -package input - -// KeyMod represents modifier keys. -type KeyMod int - -// Modifier keys. -const ( - ModShift KeyMod = 1 << iota - ModAlt - ModCtrl - ModMeta - - // These modifiers are used with the Kitty protocol. - // XXX: Meta and Super are swapped in the Kitty protocol, - // this is to preserve compatibility with XTerm modifiers. - - ModHyper - ModSuper // Windows/Command keys - - // These are key lock states. - - ModCapsLock - ModNumLock - ModScrollLock // Defined in Windows API only -) - -// Contains reports whether m contains the given modifiers. -// -// Example: -// -// m := ModAlt | ModCtrl -// m.Contains(ModCtrl) // true -// m.Contains(ModAlt | ModCtrl) // true -// m.Contains(ModAlt | ModCtrl | ModShift) // false -func (m KeyMod) Contains(mods KeyMod) bool { - return m&mods == mods -} diff --git a/packages/tui/input/mode.go b/packages/tui/input/mode.go deleted file mode 100644 index ea1ba571..00000000 --- a/packages/tui/input/mode.go +++ /dev/null @@ -1,14 +0,0 @@ -package input - -import "github.com/charmbracelet/x/ansi" - -// ModeReportEvent is a message that represents a mode report event (DECRPM). -// -// See: https://vt100.net/docs/vt510-rm/DECRPM.html -type ModeReportEvent struct { - // Mode is the mode number. - Mode ansi.Mode - - // Value is the mode value. - Value ansi.ModeSetting -} diff --git a/packages/tui/input/mouse.go b/packages/tui/input/mouse.go deleted file mode 100644 index d97eb72e..00000000 --- a/packages/tui/input/mouse.go +++ /dev/null @@ -1,292 +0,0 @@ -package input - -import ( - "fmt" - - "github.com/charmbracelet/x/ansi" -) - -// MouseButton represents the button that was pressed during a mouse message. -type MouseButton = ansi.MouseButton - -// Mouse event buttons -// -// This is based on X11 mouse button codes. -// -// 1 = left button -// 2 = middle button (pressing the scroll wheel) -// 3 = right button -// 4 = turn scroll wheel up -// 5 = turn scroll wheel down -// 6 = push scroll wheel left -// 7 = push scroll wheel right -// 8 = 4th button (aka browser backward button) -// 9 = 5th button (aka browser forward button) -// 10 -// 11 -// -// Other buttons are not supported. -const ( - MouseNone = ansi.MouseNone - MouseLeft = ansi.MouseLeft - MouseMiddle = ansi.MouseMiddle - MouseRight = ansi.MouseRight - MouseWheelUp = ansi.MouseWheelUp - MouseWheelDown = ansi.MouseWheelDown - MouseWheelLeft = ansi.MouseWheelLeft - MouseWheelRight = ansi.MouseWheelRight - MouseBackward = ansi.MouseBackward - MouseForward = ansi.MouseForward - MouseButton10 = ansi.MouseButton10 - MouseButton11 = ansi.MouseButton11 -) - -// MouseEvent represents a mouse message. This is a generic mouse message that -// can represent any kind of mouse event. -type MouseEvent interface { - fmt.Stringer - - // Mouse returns the underlying mouse event. - Mouse() Mouse -} - -// Mouse represents a Mouse message. Use [MouseEvent] to represent all mouse -// messages. -// -// The X and Y coordinates are zero-based, with (0,0) being the upper left -// corner of the terminal. -// -// // Catch all mouse events -// switch Event := Event.(type) { -// case MouseEvent: -// m := Event.Mouse() -// fmt.Println("Mouse event:", m.X, m.Y, m) -// } -// -// // Only catch mouse click events -// switch Event := Event.(type) { -// case MouseClickEvent: -// fmt.Println("Mouse click event:", Event.X, Event.Y, Event) -// } -type Mouse struct { - X, Y int - Button MouseButton - Mod KeyMod -} - -// String returns a string representation of the mouse message. -func (m Mouse) String() (s string) { - if m.Mod.Contains(ModCtrl) { - s += "ctrl+" - } - if m.Mod.Contains(ModAlt) { - s += "alt+" - } - if m.Mod.Contains(ModShift) { - s += "shift+" - } - - str := m.Button.String() - if str == "" { - s += "unknown" - } else if str != "none" { // motion events don't have a button - s += str - } - - return s -} - -// MouseClickEvent represents a mouse button click event. -type MouseClickEvent Mouse - -// String returns a string representation of the mouse click event. -func (e MouseClickEvent) String() string { - return Mouse(e).String() -} - -// Mouse returns the underlying mouse event. This is a convenience method and -// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse -// event to [Mouse]. -func (e MouseClickEvent) Mouse() Mouse { - return Mouse(e) -} - -// MouseReleaseEvent represents a mouse button release event. -type MouseReleaseEvent Mouse - -// String returns a string representation of the mouse release event. -func (e MouseReleaseEvent) String() string { - return Mouse(e).String() -} - -// Mouse returns the underlying mouse event. This is a convenience method and -// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse -// event to [Mouse]. -func (e MouseReleaseEvent) Mouse() Mouse { - return Mouse(e) -} - -// MouseWheelEvent represents a mouse wheel message event. -type MouseWheelEvent Mouse - -// String returns a string representation of the mouse wheel event. -func (e MouseWheelEvent) String() string { - return Mouse(e).String() -} - -// Mouse returns the underlying mouse event. This is a convenience method and -// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse -// event to [Mouse]. -func (e MouseWheelEvent) Mouse() Mouse { - return Mouse(e) -} - -// MouseMotionEvent represents a mouse motion event. -type MouseMotionEvent Mouse - -// String returns a string representation of the mouse motion event. -func (e MouseMotionEvent) String() string { - m := Mouse(e) - if m.Button != 0 { - return m.String() + "+motion" - } - return m.String() + "motion" -} - -// Mouse returns the underlying mouse event. This is a convenience method and -// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse -// event to [Mouse]. -func (e MouseMotionEvent) Mouse() Mouse { - return Mouse(e) -} - -// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events -// look like: -// -// ESC [ < Cb ; Cx ; Cy (M or m) -// -// where: -// -// Cb is the encoded button code -// Cx is the x-coordinate of the mouse -// Cy is the y-coordinate of the mouse -// M is for button press, m is for button release -// -// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates -func parseSGRMouseEvent(cmd ansi.Cmd, params ansi.Params) Event { - x, _, ok := params.Param(1, 1) - if !ok { - x = 1 - } - y, _, ok := params.Param(2, 1) - if !ok { - y = 1 - } - release := cmd.Final() == 'm' - b, _, _ := params.Param(0, 0) - mod, btn, _, isMotion := parseMouseButton(b) - - // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). - x-- - y-- - - m := Mouse{X: x, Y: y, Button: btn, Mod: mod} - - // Wheel buttons don't have release events - // Motion can be reported as a release event in some terminals (Windows Terminal) - if isWheel(m.Button) { - return MouseWheelEvent(m) - } else if !isMotion && release { - return MouseReleaseEvent(m) - } else if isMotion { - return MouseMotionEvent(m) - } - return MouseClickEvent(m) -} - -const x10MouseByteOffset = 32 - -// Parse X10-encoded mouse events; the simplest kind. The last release of X10 -// was December 1986, by the way. The original X10 mouse protocol limits the Cx -// and Cy coordinates to 223 (=255-032). -// -// X10 mouse events look like: -// -// ESC [M Cb Cx Cy -// -// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking -func parseX10MouseEvent(buf []byte) Event { - v := buf[3:6] - b := int(v[0]) - if b >= x10MouseByteOffset { - // XXX: b < 32 should be impossible, but we're being defensive. - b -= x10MouseByteOffset - } - - mod, btn, isRelease, isMotion := parseMouseButton(b) - - // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). - x := int(v[1]) - x10MouseByteOffset - 1 - y := int(v[2]) - x10MouseByteOffset - 1 - - m := Mouse{X: x, Y: y, Button: btn, Mod: mod} - if isWheel(m.Button) { - return MouseWheelEvent(m) - } else if isMotion { - return MouseMotionEvent(m) - } else if isRelease { - return MouseReleaseEvent(m) - } - return MouseClickEvent(m) -} - -// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates -func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) { - // mouse bit shifts - const ( - bitShift = 0b0000_0100 - bitAlt = 0b0000_1000 - bitCtrl = 0b0001_0000 - bitMotion = 0b0010_0000 - bitWheel = 0b0100_0000 - bitAdd = 0b1000_0000 // additional buttons 8-11 - - bitsMask = 0b0000_0011 - ) - - // Modifiers - if b&bitAlt != 0 { - mod |= ModAlt - } - if b&bitCtrl != 0 { - mod |= ModCtrl - } - if b&bitShift != 0 { - mod |= ModShift - } - - if b&bitAdd != 0 { - btn = MouseBackward + MouseButton(b&bitsMask) - } else if b&bitWheel != 0 { - btn = MouseWheelUp + MouseButton(b&bitsMask) - } else { - btn = MouseLeft + MouseButton(b&bitsMask) - // X10 reports a button release as 0b0000_0011 (3) - if b&bitsMask == bitsMask { - btn = MouseNone - isRelease = true - } - } - - // Motion bit doesn't get reported for wheel events. - if b&bitMotion != 0 && !isWheel(btn) { - isMotion = true - } - - return //nolint:nakedret -} - -// isWheel returns true if the mouse event is a wheel event. -func isWheel(btn MouseButton) bool { - return btn >= MouseWheelUp && btn <= MouseWheelRight -} diff --git a/packages/tui/input/mouse_test.go b/packages/tui/input/mouse_test.go deleted file mode 100644 index d55e4148..00000000 --- a/packages/tui/input/mouse_test.go +++ /dev/null @@ -1,481 +0,0 @@ -package input - -import ( - "fmt" - "testing" - - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/ansi/parser" -) - -func TestMouseEvent_String(t *testing.T) { - tt := []struct { - name string - event Event - expected string - }{ - { - name: "unknown", - event: MouseClickEvent{Button: MouseButton(0xff)}, - expected: "unknown", - }, - { - name: "left", - event: MouseClickEvent{Button: MouseLeft}, - expected: "left", - }, - { - name: "right", - event: MouseClickEvent{Button: MouseRight}, - expected: "right", - }, - { - name: "middle", - event: MouseClickEvent{Button: MouseMiddle}, - expected: "middle", - }, - { - name: "release", - event: MouseReleaseEvent{Button: MouseNone}, - expected: "", - }, - { - name: "wheelup", - event: MouseWheelEvent{Button: MouseWheelUp}, - expected: "wheelup", - }, - { - name: "wheeldown", - event: MouseWheelEvent{Button: MouseWheelDown}, - expected: "wheeldown", - }, - { - name: "wheelleft", - event: MouseWheelEvent{Button: MouseWheelLeft}, - expected: "wheelleft", - }, - { - name: "wheelright", - event: MouseWheelEvent{Button: MouseWheelRight}, - expected: "wheelright", - }, - { - name: "motion", - event: MouseMotionEvent{Button: MouseNone}, - expected: "motion", - }, - { - name: "shift+left", - event: MouseReleaseEvent{Button: MouseLeft, Mod: ModShift}, - expected: "shift+left", - }, - { - name: "shift+left", event: MouseClickEvent{Button: MouseLeft, Mod: ModShift}, - expected: "shift+left", - }, - { - name: "ctrl+shift+left", - event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl | ModShift}, - expected: "ctrl+shift+left", - }, - { - name: "alt+left", - event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt}, - expected: "alt+left", - }, - { - name: "ctrl+left", - event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl}, - expected: "ctrl+left", - }, - { - name: "ctrl+alt+left", - event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl}, - expected: "ctrl+alt+left", - }, - { - name: "ctrl+alt+shift+left", - event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift}, - expected: "ctrl+alt+shift+left", - }, - { - name: "ignore coordinates", - event: MouseClickEvent{X: 100, Y: 200, Button: MouseLeft}, - expected: "left", - }, - { - name: "broken type", - event: MouseClickEvent{Button: MouseButton(120)}, - expected: "unknown", - }, - } - - for i := range tt { - tc := tt[i] - - t.Run(tc.name, func(t *testing.T) { - actual := fmt.Sprint(tc.event) - - if tc.expected != actual { - t.Fatalf("expected %q but got %q", - tc.expected, - actual, - ) - } - }) - } -} - -func TestParseX10MouseDownEvent(t *testing.T) { - encode := func(b byte, x, y int) []byte { - return []byte{ - '\x1b', - '[', - 'M', - byte(32) + b, - byte(x + 32 + 1), - byte(y + 32 + 1), - } - } - - tt := []struct { - name string - buf []byte - expected Event - }{ - // Position. - { - name: "zero position", - buf: encode(0b0000_0000, 0, 0), - expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft}, - }, - { - name: "max position", - buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1. - expected: MouseClickEvent{X: 222, Y: 222, Button: MouseLeft}, - }, - // Simple. - { - name: "left", - buf: encode(0b0000_0000, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft}, - }, - { - name: "left in motion", - buf: encode(0b0010_0000, 32, 16), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft}, - }, - { - name: "middle", - buf: encode(0b0000_0001, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle}, - }, - { - name: "middle in motion", - buf: encode(0b0010_0001, 32, 16), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle}, - }, - { - name: "right", - buf: encode(0b0000_0010, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight}, - }, - { - name: "right in motion", - buf: encode(0b0010_0010, 32, 16), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseRight}, - }, - { - name: "motion", - buf: encode(0b0010_0011, 32, 16), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone}, - }, - { - name: "wheel up", - buf: encode(0b0100_0000, 32, 16), - expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp}, - }, - { - name: "wheel down", - buf: encode(0b0100_0001, 32, 16), - expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown}, - }, - { - name: "wheel left", - buf: encode(0b0100_0010, 32, 16), - expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft}, - }, - { - name: "wheel right", - buf: encode(0b0100_0011, 32, 16), - expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight}, - }, - { - name: "release", - buf: encode(0b0000_0011, 32, 16), - expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseNone}, - }, - { - name: "backward", - buf: encode(0b1000_0000, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward}, - }, - { - name: "forward", - buf: encode(0b1000_0001, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward}, - }, - { - name: "button 10", - buf: encode(0b1000_0010, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton10}, - }, - { - name: "button 11", - buf: encode(0b1000_0011, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton11}, - }, - // Combinations. - { - name: "alt+right", - buf: encode(0b0000_1010, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight}, - }, - { - name: "ctrl+right", - buf: encode(0b0001_0010, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight}, - }, - { - name: "left in motion", - buf: encode(0b0010_0000, 32, 16), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft}, - }, - { - name: "alt+right in motion", - buf: encode(0b0010_1010, 32, 16), - expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight}, - }, - { - name: "ctrl+right in motion", - buf: encode(0b0011_0010, 32, 16), - expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight}, - }, - { - name: "ctrl+alt+right", - buf: encode(0b0001_1010, 32, 16), - expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight}, - }, - { - name: "ctrl+wheel up", - buf: encode(0b0101_0000, 32, 16), - expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp}, - }, - { - name: "alt+wheel down", - buf: encode(0b0100_1001, 32, 16), - expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown}, - }, - { - name: "ctrl+alt+wheel down", - buf: encode(0b0101_1001, 32, 16), - expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown}, - }, - // Overflow position. - { - name: "overflow position", - buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1. - expected: MouseMotionEvent{X: -6, Y: -33, Button: MouseLeft}, - }, - } - - for i := range tt { - tc := tt[i] - - t.Run(tc.name, func(t *testing.T) { - actual := parseX10MouseEvent(tc.buf) - - if tc.expected != actual { - t.Fatalf("expected %#v but got %#v", - tc.expected, - actual, - ) - } - }) - } -} - -func TestParseSGRMouseEvent(t *testing.T) { - type csiSequence struct { - params []ansi.Param - cmd ansi.Cmd - } - encode := func(b, x, y int, r bool) *csiSequence { - re := 'M' - if r { - re = 'm' - } - return &csiSequence{ - params: []ansi.Param{ - ansi.Param(b), - ansi.Param(x + 1), - ansi.Param(y + 1), - }, - cmd: ansi.Cmd(re) | ('<' << parser.PrefixShift), - } - } - - tt := []struct { - name string - buf *csiSequence - expected Event - }{ - // Position. - { - name: "zero position", - buf: encode(0, 0, 0, false), - expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft}, - }, - { - name: "225 position", - buf: encode(0, 225, 225, false), - expected: MouseClickEvent{X: 225, Y: 225, Button: MouseLeft}, - }, - // Simple. - { - name: "left", - buf: encode(0, 32, 16, false), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft}, - }, - { - name: "left in motion", - buf: encode(32, 32, 16, false), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft}, - }, - { - name: "left", - buf: encode(0, 32, 16, true), - expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseLeft}, - }, - { - name: "middle", - buf: encode(1, 32, 16, false), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle}, - }, - { - name: "middle in motion", - buf: encode(33, 32, 16, false), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle}, - }, - { - name: "middle", - buf: encode(1, 32, 16, true), - expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseMiddle}, - }, - { - name: "right", - buf: encode(2, 32, 16, false), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight}, - }, - { - name: "right", - buf: encode(2, 32, 16, true), - expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseRight}, - }, - { - name: "motion", - buf: encode(35, 32, 16, false), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone}, - }, - { - name: "wheel up", - buf: encode(64, 32, 16, false), - expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp}, - }, - { - name: "wheel down", - buf: encode(65, 32, 16, false), - expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown}, - }, - { - name: "wheel left", - buf: encode(66, 32, 16, false), - expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft}, - }, - { - name: "wheel right", - buf: encode(67, 32, 16, false), - expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight}, - }, - { - name: "backward", - buf: encode(128, 32, 16, false), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward}, - }, - { - name: "backward in motion", - buf: encode(160, 32, 16, false), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseBackward}, - }, - { - name: "forward", - buf: encode(129, 32, 16, false), - expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward}, - }, - { - name: "forward in motion", - buf: encode(161, 32, 16, false), - expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseForward}, - }, - // Combinations. - { - name: "alt+right", - buf: encode(10, 32, 16, false), - expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight}, - }, - { - name: "ctrl+right", - buf: encode(18, 32, 16, false), - expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight}, - }, - { - name: "ctrl+alt+right", - buf: encode(26, 32, 16, false), - expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight}, - }, - { - name: "alt+wheel", - buf: encode(73, 32, 16, false), - expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown}, - }, - { - name: "ctrl+wheel", - buf: encode(81, 32, 16, false), - expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown}, - }, - { - name: "ctrl+alt+wheel", - buf: encode(89, 32, 16, false), - expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown}, - }, - { - name: "ctrl+alt+shift+wheel", - buf: encode(93, 32, 16, false), - expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown}, - }, - } - - for i := range tt { - tc := tt[i] - - t.Run(tc.name, func(t *testing.T) { - actual := parseSGRMouseEvent(tc.buf.cmd, tc.buf.params) - if tc.expected != actual { - t.Fatalf("expected %#v but got %#v", - tc.expected, - actual, - ) - } - }) - } -} diff --git a/packages/tui/input/parse.go b/packages/tui/input/parse.go deleted file mode 100644 index ad8e2184..00000000 --- a/packages/tui/input/parse.go +++ /dev/null @@ -1,1030 +0,0 @@ -package input - -import ( - "bytes" - "encoding/base64" - "slices" - "strings" - "unicode" - "unicode/utf8" - - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/ansi/parser" - "github.com/rivo/uniseg" -) - -// Flags to control the behavior of the parser. -const ( - // When this flag is set, the driver will treat both Ctrl+Space and Ctrl+@ - // as the same key sequence. - // - // Historically, the ANSI specs generate NUL (0x00) on both the Ctrl+Space - // and Ctrl+@ key sequences. This flag allows the driver to treat both as - // the same key sequence. - FlagCtrlAt = 1 << iota - - // When this flag is set, the driver will treat the Tab key and Ctrl+I as - // the same key sequence. - // - // Historically, the ANSI specs generate HT (0x09) on both the Tab key and - // Ctrl+I. This flag allows the driver to treat both as the same key - // sequence. - FlagCtrlI - - // When this flag is set, the driver will treat the Enter key and Ctrl+M as - // the same key sequence. - // - // Historically, the ANSI specs generate CR (0x0D) on both the Enter key - // and Ctrl+M. This flag allows the driver to treat both as the same key. - FlagCtrlM - - // When this flag is set, the driver will treat Escape and Ctrl+[ as - // the same key sequence. - // - // Historically, the ANSI specs generate ESC (0x1B) on both the Escape key - // and Ctrl+[. This flag allows the driver to treat both as the same key - // sequence. - FlagCtrlOpenBracket - - // When this flag is set, the driver will send a BS (0x08 byte) character - // instead of a DEL (0x7F byte) character when the Backspace key is - // pressed. - // - // The VT100 terminal has both a Backspace and a Delete key. The VT220 - // terminal dropped the Backspace key and replaced it with the Delete key. - // Both terminals send a DEL character when the Delete key is pressed. - // Modern terminals and PCs later readded the Delete key but used a - // different key sequence, and the Backspace key was standardized to send a - // DEL character. - FlagBackspace - - // When this flag is set, the driver will recognize the Find key instead of - // treating it as a Home key. - // - // The Find key was part of the VT220 keyboard, and is no longer used in - // modern day PCs. - FlagFind - - // When this flag is set, the driver will recognize the Select key instead - // of treating it as a End key. - // - // The Symbol key was part of the VT220 keyboard, and is no longer used in - // modern day PCs. - FlagSelect - - // When this flag is set, the driver will use Terminfo databases to - // overwrite the default key sequences. - FlagTerminfo - - // When this flag is set, the driver will preserve function keys (F13-F63) - // as symbols. - // - // Since these keys are not part of today's standard 20th century keyboard, - // we treat them as F1-F12 modifier keys i.e. ctrl/shift/alt + Fn combos. - // Key definitions come from Terminfo, this flag is only useful when - // FlagTerminfo is not set. - FlagFKeys - - // When this flag is set, the driver will enable mouse mode on Windows. - // This is only useful on Windows and has no effect on other platforms. - FlagMouseMode -) - -// Parser is a parser for input escape sequences. -type Parser struct { - flags int -} - -// NewParser returns a new input parser. This is a low-level parser that parses -// escape sequences into human-readable events. -// This differs from [ansi.Parser] and [ansi.DecodeSequence] in which it -// recognizes incorrect sequences that some terminals may send. -// -// For instance, the X10 mouse protocol sends a `CSI M` sequence followed by 3 -// bytes. If the parser doesn't recognize the 3 bytes, they might be echoed to -// the terminal output causing a mess. -// -// Another example is how URxvt sends invalid sequences for modified keys using -// invalid CSI final characters like '$'. -// -// Use flags to control the behavior of ambiguous key sequences. -func NewParser(flags int) *Parser { - return &Parser{flags: flags} -} - -// parseSequence finds the first recognized event sequence and returns it along -// with its length. -// -// It will return zero and nil no sequence is recognized or when the buffer is -// empty. If a sequence is not supported, an UnknownEvent is returned. -func (p *Parser) parseSequence(buf []byte) (n int, Event Event) { - if len(buf) == 0 { - return 0, nil - } - - switch b := buf[0]; b { - case ansi.ESC: - if len(buf) == 1 { - // Escape key - return 1, KeyPressEvent{Code: KeyEscape} - } - - switch bPrime := buf[1]; bPrime { - case 'O': // Esc-prefixed SS3 - return p.parseSs3(buf) - case 'P': // Esc-prefixed DCS - return p.parseDcs(buf) - case '[': // Esc-prefixed CSI - return p.parseCsi(buf) - case ']': // Esc-prefixed OSC - return p.parseOsc(buf) - case '_': // Esc-prefixed APC - return p.parseApc(buf) - case '^': // Esc-prefixed PM - return p.parseStTerminated(ansi.PM, '^', nil)(buf) - case 'X': // Esc-prefixed SOS - return p.parseStTerminated(ansi.SOS, 'X', nil)(buf) - default: - n, e := p.parseSequence(buf[1:]) - if k, ok := e.(KeyPressEvent); ok { - k.Text = "" - k.Mod |= ModAlt - return n + 1, k - } - - // Not a key sequence, nor an alt modified key sequence. In that - // case, just report a single escape key. - return 1, KeyPressEvent{Code: KeyEscape} - } - case ansi.SS3: - return p.parseSs3(buf) - case ansi.DCS: - return p.parseDcs(buf) - case ansi.CSI: - return p.parseCsi(buf) - case ansi.OSC: - return p.parseOsc(buf) - case ansi.APC: - return p.parseApc(buf) - case ansi.PM: - return p.parseStTerminated(ansi.PM, '^', nil)(buf) - case ansi.SOS: - return p.parseStTerminated(ansi.SOS, 'X', nil)(buf) - default: - if b <= ansi.US || b == ansi.DEL || b == ansi.SP { - return 1, p.parseControl(b) - } else if b >= ansi.PAD && b <= ansi.APC { - // C1 control code - // UTF-8 never starts with a C1 control code - // Encode these as Ctrl+Alt+ - code := rune(b) - 0x40 - return 1, KeyPressEvent{Code: code, Mod: ModCtrl | ModAlt} - } - return p.parseUtf8(buf) - } -} - -func (p *Parser) parseCsi(b []byte) (int, Event) { - if len(b) == 2 && b[0] == ansi.ESC { - // short cut if this is an alt+[ key - return 2, KeyPressEvent{Text: string(rune(b[1])), Mod: ModAlt} - } - - var cmd ansi.Cmd - var params [parser.MaxParamsSize]ansi.Param - var paramsLen int - - var i int - if b[i] == ansi.CSI || b[i] == ansi.ESC { - i++ - } - if i < len(b) && b[i-1] == ansi.ESC && b[i] == '[' { - i++ - } - - // Initial CSI byte - if i < len(b) && b[i] >= '<' && b[i] <= '?' { - cmd |= ansi.Cmd(b[i]) << parser.PrefixShift - } - - // Scan parameter bytes in the range 0x30-0x3F - var j int - for j = 0; i < len(b) && paramsLen < len(params) && b[i] >= 0x30 && b[i] <= 0x3F; i, j = i+1, j+1 { - if b[i] >= '0' && b[i] <= '9' { - if params[paramsLen] == parser.MissingParam { - params[paramsLen] = 0 - } - params[paramsLen] *= 10 - params[paramsLen] += ansi.Param(b[i]) - '0' - } - if b[i] == ':' { - params[paramsLen] |= parser.HasMoreFlag - } - if b[i] == ';' || b[i] == ':' { - paramsLen++ - if paramsLen < len(params) { - // Don't overflow the params slice - params[paramsLen] = parser.MissingParam - } - } - } - - if j > 0 && paramsLen < len(params) { - // has parameters - paramsLen++ - } - - // Scan intermediate bytes in the range 0x20-0x2F - var intermed byte - for ; i < len(b) && b[i] >= 0x20 && b[i] <= 0x2F; i++ { - intermed = b[i] - } - - // Set the intermediate byte - cmd |= ansi.Cmd(intermed) << parser.IntermedShift - - // Scan final byte in the range 0x40-0x7E - if i >= len(b) { - // Incomplete sequence - return 0, nil - } - if b[i] < 0x40 || b[i] > 0x7E { - // Special case for URxvt keys - // CSI $ is an invalid sequence, but URxvt uses it for - // shift modified keys. - if b[i-1] == '$' { - n, ev := p.parseCsi(append(b[:i-1], '~')) - if k, ok := ev.(KeyPressEvent); ok { - k.Mod |= ModShift - return n, k - } - } - return i, UnknownEvent(b[:i-1]) - } - - // Add the final byte - cmd |= ansi.Cmd(b[i]) - i++ - - pa := ansi.Params(params[:paramsLen]) - switch cmd { - case 'y' | '?'<= 3 { - pa = pa[:3] - return i, parseSGRMouseEvent(cmd, pa) - } - case 'm' | '>'< R (which is modified F3) when the cursor is at the - // row 1. In this case, we report both messages. - // - // For a non ambiguous cursor position report, use - // [ansi.RequestExtendedCursorPosition] (DECXCPR) instead. - return i, MultiEvent{KeyPressEvent{Code: KeyF3, Mod: KeyMod(col - 1)}, m} - } - - return i, m - } - - if paramsLen != 0 { - break - } - - // Unmodified key F3 (CSI R) - fallthrough - case 'a', 'b', 'c', 'd', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S', 'Z': - var k KeyPressEvent - switch cmd { - case 'a', 'b', 'c', 'd': - k = KeyPressEvent{Code: KeyUp + rune(cmd-'a'), Mod: ModShift} - case 'A', 'B', 'C', 'D': - k = KeyPressEvent{Code: KeyUp + rune(cmd-'A')} - case 'E': - k = KeyPressEvent{Code: KeyBegin} - case 'F': - k = KeyPressEvent{Code: KeyEnd} - case 'H': - k = KeyPressEvent{Code: KeyHome} - case 'P', 'Q', 'R', 'S': - k = KeyPressEvent{Code: KeyF1 + rune(cmd-'P')} - case 'Z': - k = KeyPressEvent{Code: KeyTab, Mod: ModShift} - } - id, _, _ := pa.Param(0, 1) - if id == 0 { - id = 1 - } - mod, _, _ := pa.Param(1, 1) - if mod == 0 { - mod = 1 - } - if paramsLen > 1 && id == 1 && mod != -1 { - // CSI 1 ; A - k.Mod |= KeyMod(mod - 1) - } - // Don't forget to handle Kitty keyboard protocol - return i, parseKittyKeyboardExt(pa, k) - case 'M': - // Handle X10 mouse - if i+2 >= len(b) { - // Incomplete sequence - return 0, nil - } - // PERFORMANCE: Do not use append here, as it will allocate a new slice - // for every mouse event. Instead, pass a sub-slice of the original - // buffer. - return i + 3, parseX10MouseEvent(b[i-1 : i+3]) - case 'y' | '$'< 1 && mod != -1 { - k.Mod |= KeyMod(mod - 1) - } - - // Handle URxvt weird keys - switch cmd { - case '~': - // Don't forget to handle Kitty keyboard protocol - return i, parseKittyKeyboardExt(pa, k) - case '^': - k.Mod |= ModCtrl - case '@': - k.Mod |= ModCtrl | ModShift - } - - return i, k - } - - case 't': - param, _, ok := pa.Param(0, 0) - if !ok { - break - } - - var winop WindowOpEvent - winop.Op = param - for j := 1; j < paramsLen; j++ { - val, _, ok := pa.Param(j, 0) - if ok { - winop.Args = append(winop.Args, val) - } - } - - return i, winop - } - return i, UnknownEvent(b[:i]) -} - -// parseSs3 parses a SS3 sequence. -// See https://vt100.net/docs/vt220-rm/chapter4.html#S4.4.4.2 -func (p *Parser) parseSs3(b []byte) (int, Event) { - if len(b) == 2 && b[0] == ansi.ESC { - // short cut if this is an alt+O key - return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt} - } - - var i int - if b[i] == ansi.SS3 || b[i] == ansi.ESC { - i++ - } - if i < len(b) && b[i-1] == ansi.ESC && b[i] == 'O' { - i++ - } - - // Scan numbers from 0-9 - var mod int - for ; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ { - mod *= 10 - mod += int(b[i]) - '0' - } - - // Scan a GL character - // A GL character is a single byte in the range 0x21-0x7E - // See https://vt100.net/docs/vt220-rm/chapter2.html#S2.3.2 - if i >= len(b) { - // Incomplete sequence - return 0, nil - } - if b[i] < 0x21 || b[i] > 0x7E { - return i, UnknownEvent(b[:i]) - } - - // GL character(s) - gl := b[i] - i++ - - var k KeyPressEvent - switch gl { - case 'a', 'b', 'c', 'd': - k = KeyPressEvent{Code: KeyUp + rune(gl-'a'), Mod: ModCtrl} - case 'A', 'B', 'C', 'D': - k = KeyPressEvent{Code: KeyUp + rune(gl-'A')} - case 'E': - k = KeyPressEvent{Code: KeyBegin} - case 'F': - k = KeyPressEvent{Code: KeyEnd} - case 'H': - k = KeyPressEvent{Code: KeyHome} - case 'P', 'Q', 'R', 'S': - k = KeyPressEvent{Code: KeyF1 + rune(gl-'P')} - case 'M': - k = KeyPressEvent{Code: KeyKpEnter} - case 'X': - k = KeyPressEvent{Code: KeyKpEqual} - case 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y': - k = KeyPressEvent{Code: KeyKpMultiply + rune(gl-'j')} - default: - return i, UnknownEvent(b[:i]) - } - - // Handle weird SS3 Func - if mod > 0 { - k.Mod |= KeyMod(mod - 1) - } - - return i, k -} - -func (p *Parser) parseOsc(b []byte) (int, Event) { - defaultKey := func() KeyPressEvent { - return KeyPressEvent{Code: rune(b[1]), Mod: ModAlt} - } - if len(b) == 2 && b[0] == ansi.ESC { - // short cut if this is an alt+] key - return 2, defaultKey() - } - - var i int - if b[i] == ansi.OSC || b[i] == ansi.ESC { - i++ - } - if i < len(b) && b[i-1] == ansi.ESC && b[i] == ']' { - i++ - } - - // Parse OSC command - // An OSC sequence is terminated by a BEL, ESC, or ST character - var start, end int - cmd := -1 - for ; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ { - if cmd == -1 { - cmd = 0 - } else { - cmd *= 10 - } - cmd += int(b[i]) - '0' - } - - if i < len(b) && b[i] == ';' { - // mark the start of the sequence data - i++ - start = i - } - - for ; i < len(b); i++ { - // advance to the end of the sequence - if slices.Contains([]byte{ansi.BEL, ansi.ESC, ansi.ST, ansi.CAN, ansi.SUB}, b[i]) { - break - } - } - - if i >= len(b) { - // Incomplete sequence - return 0, nil - } - - end = i // end of the sequence data - i++ - - // Check 7-bit ST (string terminator) character - switch b[i-1] { - case ansi.CAN, ansi.SUB: - return i, UnknownEvent(b[:i]) - case ansi.ESC: - if i >= len(b) || b[i] != '\\' { - if cmd == -1 || (start == 0 && end == 2) { - return 2, defaultKey() - } - - // If we don't have a valid ST terminator, then this is a - // cancelled sequence and should be ignored. - return i, UnknownEvent(b[:i]) - } - - i++ - } - - if end <= start { - return i, UnknownEvent(b[:i]) - } - - // PERFORMANCE: Only allocate the data string if we know we have a handler - // for the command. This avoids allocations for unknown OSC sequences that - // can be sent in high frequency by trackpads. - switch cmd { - case 10, 11, 12: - data := string(b[start:end]) - color := ansi.XParseColor(data) - switch cmd { - case 10: - return i, ForegroundColorEvent{color} - case 11: - return i, BackgroundColorEvent{color} - case 12: - return i, CursorColorEvent{color} - } - case 52: - data := string(b[start:end]) - parts := strings.Split(data, ";") - if len(parts) == 0 { - return i, ClipboardEvent{} - } - if len(parts) != 2 || len(parts[0]) < 1 { - break - } - - b64 := parts[1] - bts, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - break - } - - sel := ClipboardSelection(parts[0][0]) //nolint:unconvert - return i, ClipboardEvent{Selection: sel, Content: string(bts)} - } - - return i, UnknownEvent(b[:i]) -} - -// parseStTerminated parses a control sequence that gets terminated by a ST character. -func (p *Parser) parseStTerminated( - intro8, intro7 byte, - fn func([]byte) Event, -) func([]byte) (int, Event) { - defaultKey := func(b []byte) (int, Event) { - switch intro8 { - case ansi.SOS: - return 2, KeyPressEvent{Code: 'x', Mod: ModShift | ModAlt} - case ansi.PM, ansi.APC: - return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt} - } - return 0, nil - } - return func(b []byte) (int, Event) { - if len(b) == 2 && b[0] == ansi.ESC { - return defaultKey(b) - } - - var i int - if b[i] == intro8 || b[i] == ansi.ESC { - i++ - } - if i < len(b) && b[i-1] == ansi.ESC && b[i] == intro7 { - i++ - } - - // Scan control sequence - // Most common control sequence is terminated by a ST character - // ST is a 7-bit string terminator character is (ESC \) - start := i - for ; i < len(b); i++ { - if slices.Contains([]byte{ansi.ESC, ansi.ST, ansi.CAN, ansi.SUB}, b[i]) { - break - } - } - - if i >= len(b) { - // Incomplete sequence - return 0, nil - } - - end := i // end of the sequence data - i++ - - // Check 7-bit ST (string terminator) character - switch b[i-1] { - case ansi.CAN, ansi.SUB: - return i, UnknownEvent(b[:i]) - case ansi.ESC: - if i >= len(b) || b[i] != '\\' { - if start == end { - return defaultKey(b) - } - - // If we don't have a valid ST terminator, then this is a - // cancelled sequence and should be ignored. - return i, UnknownEvent(b[:i]) - } - - i++ - } - - // Call the function to parse the sequence and return the result - if fn != nil { - if e := fn(b[start:end]); e != nil { - return i, e - } - } - - return i, UnknownEvent(b[:i]) - } -} - -func (p *Parser) parseDcs(b []byte) (int, Event) { - if len(b) == 2 && b[0] == ansi.ESC { - // short cut if this is an alt+P key - return 2, KeyPressEvent{Code: 'p', Mod: ModShift | ModAlt} - } - - var params [16]ansi.Param - var paramsLen int - var cmd ansi.Cmd - - // DCS sequences are introduced by DCS (0x90) or ESC P (0x1b 0x50) - var i int - if b[i] == ansi.DCS || b[i] == ansi.ESC { - i++ - } - if i < len(b) && b[i-1] == ansi.ESC && b[i] == 'P' { - i++ - } - - // initial DCS byte - if i < len(b) && b[i] >= '<' && b[i] <= '?' { - cmd |= ansi.Cmd(b[i]) << parser.PrefixShift - } - - // Scan parameter bytes in the range 0x30-0x3F - var j int - for j = 0; i < len(b) && paramsLen < len(params) && b[i] >= 0x30 && b[i] <= 0x3F; i, j = i+1, j+1 { - if b[i] >= '0' && b[i] <= '9' { - if params[paramsLen] == parser.MissingParam { - params[paramsLen] = 0 - } - params[paramsLen] *= 10 - params[paramsLen] += ansi.Param(b[i]) - '0' - } - if b[i] == ':' { - params[paramsLen] |= parser.HasMoreFlag - } - if b[i] == ';' || b[i] == ':' { - paramsLen++ - if paramsLen < len(params) { - // Don't overflow the params slice - params[paramsLen] = parser.MissingParam - } - } - } - - if j > 0 && paramsLen < len(params) { - // has parameters - paramsLen++ - } - - // Scan intermediate bytes in the range 0x20-0x2F - var intermed byte - for j := 0; i < len(b) && b[i] >= 0x20 && b[i] <= 0x2F; i, j = i+1, j+1 { - intermed = b[i] - } - - // set intermediate byte - cmd |= ansi.Cmd(intermed) << parser.IntermedShift - - // Scan final byte in the range 0x40-0x7E - if i >= len(b) { - // Incomplete sequence - return 0, nil - } - if b[i] < 0x40 || b[i] > 0x7E { - return i, UnknownEvent(b[:i]) - } - - // Add the final byte - cmd |= ansi.Cmd(b[i]) - i++ - - start := i // start of the sequence data - for ; i < len(b); i++ { - if b[i] == ansi.ST || b[i] == ansi.ESC { - break - } - } - - if i >= len(b) { - // Incomplete sequence - return 0, nil - } - - end := i // end of the sequence data - i++ - - // Check 7-bit ST (string terminator) character - if i < len(b) && b[i-1] == ansi.ESC && b[i] == '\\' { - i++ - } - - pa := ansi.Params(params[:paramsLen]) - switch cmd { - case 'r' | '+'<'< 1 { - g.Payload = parts[1] - } - return g - } - - return nil - })(b) -} - -func (p *Parser) parseUtf8(b []byte) (int, Event) { - if len(b) == 0 { - return 0, nil - } - - c := b[0] - if c <= ansi.US || c == ansi.DEL || c == ansi.SP { - // Control codes get handled by parseControl - return 1, p.parseControl(c) - } else if c > ansi.US && c < ansi.DEL { - // ASCII printable characters - code := rune(c) - k := KeyPressEvent{Code: code, Text: string(code)} - if unicode.IsUpper(code) { - // Convert upper case letters to lower case + shift modifier - k.Code = unicode.ToLower(code) - k.ShiftedCode = code - k.Mod |= ModShift - } - - return 1, k - } - - code, _ := utf8.DecodeRune(b) - if code == utf8.RuneError { - return 1, UnknownEvent(b[0]) - } - - cluster, _, _, _ := uniseg.FirstGraphemeCluster(b, -1) - // PERFORMANCE: Use RuneCount to check for multi-rune graphemes instead of - // looping over the string representation. - if utf8.RuneCount(cluster) > 1 { - code = KeyExtended - } - - return len(cluster), KeyPressEvent{Code: code, Text: string(cluster)} -} - -func (p *Parser) parseControl(b byte) Event { - switch b { - case ansi.NUL: - if p.flags&FlagCtrlAt != 0 { - return KeyPressEvent{Code: '@', Mod: ModCtrl} - } - return KeyPressEvent{Code: KeySpace, Mod: ModCtrl} - case ansi.BS: - return KeyPressEvent{Code: 'h', Mod: ModCtrl} - case ansi.HT: - if p.flags&FlagCtrlI != 0 { - return KeyPressEvent{Code: 'i', Mod: ModCtrl} - } - return KeyPressEvent{Code: KeyTab} - case ansi.CR: - if p.flags&FlagCtrlM != 0 { - return KeyPressEvent{Code: 'm', Mod: ModCtrl} - } - return KeyPressEvent{Code: KeyEnter} - case ansi.ESC: - if p.flags&FlagCtrlOpenBracket != 0 { - return KeyPressEvent{Code: '[', Mod: ModCtrl} - } - return KeyPressEvent{Code: KeyEscape} - case ansi.DEL: - if p.flags&FlagBackspace != 0 { - return KeyPressEvent{Code: KeyDelete} - } - return KeyPressEvent{Code: KeyBackspace} - case ansi.SP: - return KeyPressEvent{Code: KeySpace, Text: " "} - default: - if b >= ansi.SOH && b <= ansi.SUB { - // Use lower case letters for control codes - code := rune(b + 0x60) - return KeyPressEvent{Code: code, Mod: ModCtrl} - } else if b >= ansi.FS && b <= ansi.US { - code := rune(b + 0x40) - return KeyPressEvent{Code: code, Mod: ModCtrl} - } - return UnknownEvent(b) - } -} diff --git a/packages/tui/input/parse_test.go b/packages/tui/input/parse_test.go deleted file mode 100644 index dc892e0c..00000000 --- a/packages/tui/input/parse_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package input - -import ( - "image/color" - "reflect" - "testing" - - "github.com/charmbracelet/x/ansi" -) - -func TestParseSequence_Events(t *testing.T) { - input := []byte("\x1b\x1b[Ztest\x00\x1b]10;rgb:1234/1234/1234\x07\x1b[27;2;27~\x1b[?1049;2$y\x1b[4;1$y") - want := []Event{ - KeyPressEvent{Code: KeyTab, Mod: ModShift | ModAlt}, - KeyPressEvent{Code: 't', Text: "t"}, - KeyPressEvent{Code: 'e', Text: "e"}, - KeyPressEvent{Code: 's', Text: "s"}, - KeyPressEvent{Code: 't', Text: "t"}, - KeyPressEvent{Code: KeySpace, Mod: ModCtrl}, - ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}}, - KeyPressEvent{Code: KeyEscape, Mod: ModShift}, - ModeReportEvent{Mode: ansi.AltScreenSaveCursorMode, Value: ansi.ModeReset}, - ModeReportEvent{Mode: ansi.InsertReplaceMode, Value: ansi.ModeSet}, - } - - var p Parser - for i := 0; len(input) != 0; i++ { - if i >= len(want) { - t.Fatalf("reached end of want events") - } - n, got := p.parseSequence(input) - if !reflect.DeepEqual(got, want[i]) { - t.Errorf("got %#v (%T), want %#v (%T)", got, got, want[i], want[i]) - } - input = input[n:] - } -} - -func BenchmarkParseSequence(b *testing.B) { - var p Parser - input := []byte("\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~") - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - p.parseSequence(input) - } -} diff --git a/packages/tui/input/paste.go b/packages/tui/input/paste.go deleted file mode 100644 index 4e8fe68c..00000000 --- a/packages/tui/input/paste.go +++ /dev/null @@ -1,13 +0,0 @@ -package input - -// PasteEvent is an message that is emitted when a terminal receives pasted text -// using bracketed-paste. -type PasteEvent string - -// PasteStartEvent is an message that is emitted when the terminal starts the -// bracketed-paste text. -type PasteStartEvent struct{} - -// PasteEndEvent is an message that is emitted when the terminal ends the -// bracketed-paste text. -type PasteEndEvent struct{} diff --git a/packages/tui/input/table.go b/packages/tui/input/table.go deleted file mode 100644 index 7e81fde3..00000000 --- a/packages/tui/input/table.go +++ /dev/null @@ -1,389 +0,0 @@ -package input - -import ( - "maps" - "strconv" - - "github.com/charmbracelet/x/ansi" -) - -// buildKeysTable builds a table of key sequences and their corresponding key -// events based on the VT100/VT200, XTerm, and Urxvt terminal specs. -func buildKeysTable(flags int, term string) map[string]Key { - nul := Key{Code: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space - if flags&FlagCtrlAt != 0 { - nul = Key{Code: '@', Mod: ModCtrl} - } - - tab := Key{Code: KeyTab} // ctrl+i or tab - if flags&FlagCtrlI != 0 { - tab = Key{Code: 'i', Mod: ModCtrl} - } - - enter := Key{Code: KeyEnter} // ctrl+m or enter - if flags&FlagCtrlM != 0 { - enter = Key{Code: 'm', Mod: ModCtrl} - } - - esc := Key{Code: KeyEscape} // ctrl+[ or escape - if flags&FlagCtrlOpenBracket != 0 { - esc = Key{Code: '[', Mod: ModCtrl} // ctrl+[ or escape - } - - del := Key{Code: KeyBackspace} - if flags&FlagBackspace != 0 { - del.Code = KeyDelete - } - - find := Key{Code: KeyHome} - if flags&FlagFind != 0 { - find.Code = KeyFind - } - - sel := Key{Code: KeyEnd} - if flags&FlagSelect != 0 { - sel.Code = KeySelect - } - - // The following is a table of key sequences and their corresponding key - // events based on the VT100/VT200 terminal specs. - // - // See: https://vt100.net/docs/vt100-ug/chapter3.html#S3.2 - // See: https://vt100.net/docs/vt220-rm/chapter3.html - // - // XXX: These keys may be overwritten by other options like XTerm or - // Terminfo. - table := map[string]Key{ - // C0 control characters - string(byte(ansi.NUL)): nul, - string(byte(ansi.SOH)): {Code: 'a', Mod: ModCtrl}, - string(byte(ansi.STX)): {Code: 'b', Mod: ModCtrl}, - string(byte(ansi.ETX)): {Code: 'c', Mod: ModCtrl}, - string(byte(ansi.EOT)): {Code: 'd', Mod: ModCtrl}, - string(byte(ansi.ENQ)): {Code: 'e', Mod: ModCtrl}, - string(byte(ansi.ACK)): {Code: 'f', Mod: ModCtrl}, - string(byte(ansi.BEL)): {Code: 'g', Mod: ModCtrl}, - string(byte(ansi.BS)): {Code: 'h', Mod: ModCtrl}, - string(byte(ansi.HT)): tab, - string(byte(ansi.LF)): {Code: 'j', Mod: ModCtrl}, - string(byte(ansi.VT)): {Code: 'k', Mod: ModCtrl}, - string(byte(ansi.FF)): {Code: 'l', Mod: ModCtrl}, - string(byte(ansi.CR)): enter, - string(byte(ansi.SO)): {Code: 'n', Mod: ModCtrl}, - string(byte(ansi.SI)): {Code: 'o', Mod: ModCtrl}, - string(byte(ansi.DLE)): {Code: 'p', Mod: ModCtrl}, - string(byte(ansi.DC1)): {Code: 'q', Mod: ModCtrl}, - string(byte(ansi.DC2)): {Code: 'r', Mod: ModCtrl}, - string(byte(ansi.DC3)): {Code: 's', Mod: ModCtrl}, - string(byte(ansi.DC4)): {Code: 't', Mod: ModCtrl}, - string(byte(ansi.NAK)): {Code: 'u', Mod: ModCtrl}, - string(byte(ansi.SYN)): {Code: 'v', Mod: ModCtrl}, - string(byte(ansi.ETB)): {Code: 'w', Mod: ModCtrl}, - string(byte(ansi.CAN)): {Code: 'x', Mod: ModCtrl}, - string(byte(ansi.EM)): {Code: 'y', Mod: ModCtrl}, - string(byte(ansi.SUB)): {Code: 'z', Mod: ModCtrl}, - string(byte(ansi.ESC)): esc, - string(byte(ansi.FS)): {Code: '\\', Mod: ModCtrl}, - string(byte(ansi.GS)): {Code: ']', Mod: ModCtrl}, - string(byte(ansi.RS)): {Code: '^', Mod: ModCtrl}, - string(byte(ansi.US)): {Code: '_', Mod: ModCtrl}, - - // Special keys in G0 - string(byte(ansi.SP)): {Code: KeySpace, Text: " "}, - string(byte(ansi.DEL)): del, - - // Special keys - - "\x1b[Z": {Code: KeyTab, Mod: ModShift}, - - "\x1b[1~": find, - "\x1b[2~": {Code: KeyInsert}, - "\x1b[3~": {Code: KeyDelete}, - "\x1b[4~": sel, - "\x1b[5~": {Code: KeyPgUp}, - "\x1b[6~": {Code: KeyPgDown}, - "\x1b[7~": {Code: KeyHome}, - "\x1b[8~": {Code: KeyEnd}, - - // Normal mode - "\x1b[A": {Code: KeyUp}, - "\x1b[B": {Code: KeyDown}, - "\x1b[C": {Code: KeyRight}, - "\x1b[D": {Code: KeyLeft}, - "\x1b[E": {Code: KeyBegin}, - "\x1b[F": {Code: KeyEnd}, - "\x1b[H": {Code: KeyHome}, - "\x1b[P": {Code: KeyF1}, - "\x1b[Q": {Code: KeyF2}, - "\x1b[R": {Code: KeyF3}, - "\x1b[S": {Code: KeyF4}, - - // Application Cursor Key Mode (DECCKM) - "\x1bOA": {Code: KeyUp}, - "\x1bOB": {Code: KeyDown}, - "\x1bOC": {Code: KeyRight}, - "\x1bOD": {Code: KeyLeft}, - "\x1bOE": {Code: KeyBegin}, - "\x1bOF": {Code: KeyEnd}, - "\x1bOH": {Code: KeyHome}, - "\x1bOP": {Code: KeyF1}, - "\x1bOQ": {Code: KeyF2}, - "\x1bOR": {Code: KeyF3}, - "\x1bOS": {Code: KeyF4}, - - // Keypad Application Mode (DECKPAM) - - "\x1bOM": {Code: KeyKpEnter}, - "\x1bOX": {Code: KeyKpEqual}, - "\x1bOj": {Code: KeyKpMultiply}, - "\x1bOk": {Code: KeyKpPlus}, - "\x1bOl": {Code: KeyKpComma}, - "\x1bOm": {Code: KeyKpMinus}, - "\x1bOn": {Code: KeyKpDecimal}, - "\x1bOo": {Code: KeyKpDivide}, - "\x1bOp": {Code: KeyKp0}, - "\x1bOq": {Code: KeyKp1}, - "\x1bOr": {Code: KeyKp2}, - "\x1bOs": {Code: KeyKp3}, - "\x1bOt": {Code: KeyKp4}, - "\x1bOu": {Code: KeyKp5}, - "\x1bOv": {Code: KeyKp6}, - "\x1bOw": {Code: KeyKp7}, - "\x1bOx": {Code: KeyKp8}, - "\x1bOy": {Code: KeyKp9}, - - // Function keys - - "\x1b[11~": {Code: KeyF1}, - "\x1b[12~": {Code: KeyF2}, - "\x1b[13~": {Code: KeyF3}, - "\x1b[14~": {Code: KeyF4}, - "\x1b[15~": {Code: KeyF5}, - "\x1b[17~": {Code: KeyF6}, - "\x1b[18~": {Code: KeyF7}, - "\x1b[19~": {Code: KeyF8}, - "\x1b[20~": {Code: KeyF9}, - "\x1b[21~": {Code: KeyF10}, - "\x1b[23~": {Code: KeyF11}, - "\x1b[24~": {Code: KeyF12}, - "\x1b[25~": {Code: KeyF13}, - "\x1b[26~": {Code: KeyF14}, - "\x1b[28~": {Code: KeyF15}, - "\x1b[29~": {Code: KeyF16}, - "\x1b[31~": {Code: KeyF17}, - "\x1b[32~": {Code: KeyF18}, - "\x1b[33~": {Code: KeyF19}, - "\x1b[34~": {Code: KeyF20}, - } - - // CSI ~ sequence keys - csiTildeKeys := map[string]Key{ - "1": find, "2": {Code: KeyInsert}, - "3": {Code: KeyDelete}, "4": sel, - "5": {Code: KeyPgUp}, "6": {Code: KeyPgDown}, - "7": {Code: KeyHome}, "8": {Code: KeyEnd}, - // There are no 9 and 10 keys - "11": {Code: KeyF1}, "12": {Code: KeyF2}, - "13": {Code: KeyF3}, "14": {Code: KeyF4}, - "15": {Code: KeyF5}, "17": {Code: KeyF6}, - "18": {Code: KeyF7}, "19": {Code: KeyF8}, - "20": {Code: KeyF9}, "21": {Code: KeyF10}, - "23": {Code: KeyF11}, "24": {Code: KeyF12}, - "25": {Code: KeyF13}, "26": {Code: KeyF14}, - "28": {Code: KeyF15}, "29": {Code: KeyF16}, - "31": {Code: KeyF17}, "32": {Code: KeyF18}, - "33": {Code: KeyF19}, "34": {Code: KeyF20}, - } - - // URxvt keys - // See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes - table["\x1b[a"] = Key{Code: KeyUp, Mod: ModShift} - table["\x1b[b"] = Key{Code: KeyDown, Mod: ModShift} - table["\x1b[c"] = Key{Code: KeyRight, Mod: ModShift} - table["\x1b[d"] = Key{Code: KeyLeft, Mod: ModShift} - table["\x1bOa"] = Key{Code: KeyUp, Mod: ModCtrl} - table["\x1bOb"] = Key{Code: KeyDown, Mod: ModCtrl} - table["\x1bOc"] = Key{Code: KeyRight, Mod: ModCtrl} - table["\x1bOd"] = Key{Code: KeyLeft, Mod: ModCtrl} - //nolint:godox - // TODO: investigate if shift-ctrl arrow keys collide with DECCKM keys i.e. - // "\x1bOA", "\x1bOB", "\x1bOC", "\x1bOD" - - // URxvt modifier CSI ~ keys - for k, v := range csiTildeKeys { - key := v - // Normal (no modifier) already defined part of VT100/VT200 - // Shift modifier - key.Mod = ModShift - table["\x1b["+k+"$"] = key - // Ctrl modifier - key.Mod = ModCtrl - table["\x1b["+k+"^"] = key - // Shift-Ctrl modifier - key.Mod = ModShift | ModCtrl - table["\x1b["+k+"@"] = key - } - - // URxvt F keys - // Note: Shift + F1-F10 generates F11-F20. - // This means Shift + F1 and Shift + F2 will generate F11 and F12, the same - // applies to Ctrl + Shift F1 & F2. - // - // P.S. Don't like this? Blame URxvt, configure your terminal to use - // different escapes like XTerm, or switch to a better terminal ¯\_(ツ)_/¯ - // - // See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes - table["\x1b[23$"] = Key{Code: KeyF11, Mod: ModShift} - table["\x1b[24$"] = Key{Code: KeyF12, Mod: ModShift} - table["\x1b[25$"] = Key{Code: KeyF13, Mod: ModShift} - table["\x1b[26$"] = Key{Code: KeyF14, Mod: ModShift} - table["\x1b[28$"] = Key{Code: KeyF15, Mod: ModShift} - table["\x1b[29$"] = Key{Code: KeyF16, Mod: ModShift} - table["\x1b[31$"] = Key{Code: KeyF17, Mod: ModShift} - table["\x1b[32$"] = Key{Code: KeyF18, Mod: ModShift} - table["\x1b[33$"] = Key{Code: KeyF19, Mod: ModShift} - table["\x1b[34$"] = Key{Code: KeyF20, Mod: ModShift} - table["\x1b[11^"] = Key{Code: KeyF1, Mod: ModCtrl} - table["\x1b[12^"] = Key{Code: KeyF2, Mod: ModCtrl} - table["\x1b[13^"] = Key{Code: KeyF3, Mod: ModCtrl} - table["\x1b[14^"] = Key{Code: KeyF4, Mod: ModCtrl} - table["\x1b[15^"] = Key{Code: KeyF5, Mod: ModCtrl} - table["\x1b[17^"] = Key{Code: KeyF6, Mod: ModCtrl} - table["\x1b[18^"] = Key{Code: KeyF7, Mod: ModCtrl} - table["\x1b[19^"] = Key{Code: KeyF8, Mod: ModCtrl} - table["\x1b[20^"] = Key{Code: KeyF9, Mod: ModCtrl} - table["\x1b[21^"] = Key{Code: KeyF10, Mod: ModCtrl} - table["\x1b[23^"] = Key{Code: KeyF11, Mod: ModCtrl} - table["\x1b[24^"] = Key{Code: KeyF12, Mod: ModCtrl} - table["\x1b[25^"] = Key{Code: KeyF13, Mod: ModCtrl} - table["\x1b[26^"] = Key{Code: KeyF14, Mod: ModCtrl} - table["\x1b[28^"] = Key{Code: KeyF15, Mod: ModCtrl} - table["\x1b[29^"] = Key{Code: KeyF16, Mod: ModCtrl} - table["\x1b[31^"] = Key{Code: KeyF17, Mod: ModCtrl} - table["\x1b[32^"] = Key{Code: KeyF18, Mod: ModCtrl} - table["\x1b[33^"] = Key{Code: KeyF19, Mod: ModCtrl} - table["\x1b[34^"] = Key{Code: KeyF20, Mod: ModCtrl} - table["\x1b[23@"] = Key{Code: KeyF11, Mod: ModShift | ModCtrl} - table["\x1b[24@"] = Key{Code: KeyF12, Mod: ModShift | ModCtrl} - table["\x1b[25@"] = Key{Code: KeyF13, Mod: ModShift | ModCtrl} - table["\x1b[26@"] = Key{Code: KeyF14, Mod: ModShift | ModCtrl} - table["\x1b[28@"] = Key{Code: KeyF15, Mod: ModShift | ModCtrl} - table["\x1b[29@"] = Key{Code: KeyF16, Mod: ModShift | ModCtrl} - table["\x1b[31@"] = Key{Code: KeyF17, Mod: ModShift | ModCtrl} - table["\x1b[32@"] = Key{Code: KeyF18, Mod: ModShift | ModCtrl} - table["\x1b[33@"] = Key{Code: KeyF19, Mod: ModShift | ModCtrl} - table["\x1b[34@"] = Key{Code: KeyF20, Mod: ModShift | ModCtrl} - - // Register Alt + combinations - // XXX: this must come after URxvt but before XTerm keys to register URxvt - // keys with alt modifier - tmap := map[string]Key{} - for seq, key := range table { - key := key - key.Mod |= ModAlt - key.Text = "" // Clear runes - tmap["\x1b"+seq] = key - } - maps.Copy(table, tmap) - - // XTerm modifiers - // These are offset by 1 to be compatible with our Mod type. - // See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-PC-Style-Function-Keys - modifiers := []KeyMod{ - ModShift, // 1 - ModAlt, // 2 - ModShift | ModAlt, // 3 - ModCtrl, // 4 - ModShift | ModCtrl, // 5 - ModAlt | ModCtrl, // 6 - ModShift | ModAlt | ModCtrl, // 7 - ModMeta, // 8 - ModMeta | ModShift, // 9 - ModMeta | ModAlt, // 10 - ModMeta | ModShift | ModAlt, // 11 - ModMeta | ModCtrl, // 12 - ModMeta | ModShift | ModCtrl, // 13 - ModMeta | ModAlt | ModCtrl, // 14 - ModMeta | ModShift | ModAlt | ModCtrl, // 15 - } - - // SS3 keypad function keys - ss3FuncKeys := map[string]Key{ - // These are defined in XTerm - // Taken from Foot keymap.h and XTerm modifyOtherKeys - // https://codeberg.org/dnkl/foot/src/branch/master/keymap.h - "M": {Code: KeyKpEnter}, "X": {Code: KeyKpEqual}, - "j": {Code: KeyKpMultiply}, "k": {Code: KeyKpPlus}, - "l": {Code: KeyKpComma}, "m": {Code: KeyKpMinus}, - "n": {Code: KeyKpDecimal}, "o": {Code: KeyKpDivide}, - "p": {Code: KeyKp0}, "q": {Code: KeyKp1}, - "r": {Code: KeyKp2}, "s": {Code: KeyKp3}, - "t": {Code: KeyKp4}, "u": {Code: KeyKp5}, - "v": {Code: KeyKp6}, "w": {Code: KeyKp7}, - "x": {Code: KeyKp8}, "y": {Code: KeyKp9}, - } - - // XTerm keys - csiFuncKeys := map[string]Key{ - "A": {Code: KeyUp}, "B": {Code: KeyDown}, - "C": {Code: KeyRight}, "D": {Code: KeyLeft}, - "E": {Code: KeyBegin}, "F": {Code: KeyEnd}, - "H": {Code: KeyHome}, "P": {Code: KeyF1}, - "Q": {Code: KeyF2}, "R": {Code: KeyF3}, - "S": {Code: KeyF4}, - } - - // CSI 27 ; ; ~ keys defined in XTerm modifyOtherKeys - modifyOtherKeys := map[int]Key{ - ansi.BS: {Code: KeyBackspace}, - ansi.HT: {Code: KeyTab}, - ansi.CR: {Code: KeyEnter}, - ansi.ESC: {Code: KeyEscape}, - ansi.DEL: {Code: KeyBackspace}, - } - - for _, m := range modifiers { - // XTerm modifier offset +1 - xtermMod := strconv.Itoa(int(m) + 1) - - // CSI 1 ; - for k, v := range csiFuncKeys { - // Functions always have a leading 1 param - seq := "\x1b[1;" + xtermMod + k - key := v - key.Mod = m - table[seq] = key - } - // SS3 - for k, v := range ss3FuncKeys { - seq := "\x1bO" + xtermMod + k - key := v - key.Mod = m - table[seq] = key - } - // CSI ; ~ - for k, v := range csiTildeKeys { - seq := "\x1b[" + k + ";" + xtermMod + "~" - key := v - key.Mod = m - table[seq] = key - } - // CSI 27 ; ; ~ - for k, v := range modifyOtherKeys { - code := strconv.Itoa(k) - seq := "\x1b[27;" + xtermMod + ";" + code + "~" - key := v - key.Mod = m - table[seq] = key - } - } - - // Register terminfo keys - // XXX: this might override keys already registered in table - if flags&FlagTerminfo != 0 { - titable := buildTerminfoKeys(flags, term) - maps.Copy(table, titable) - } - - return table -} diff --git a/packages/tui/input/termcap.go b/packages/tui/input/termcap.go deleted file mode 100644 index 3502189f..00000000 --- a/packages/tui/input/termcap.go +++ /dev/null @@ -1,54 +0,0 @@ -package input - -import ( - "bytes" - "encoding/hex" - "strings" -) - -// CapabilityEvent represents a Termcap/Terminfo response event. Termcap -// responses are generated by the terminal in response to RequestTermcap -// (XTGETTCAP) requests. -// -// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands -type CapabilityEvent string - -func parseTermcap(data []byte) CapabilityEvent { - // XTGETTCAP - if len(data) == 0 { - return CapabilityEvent("") - } - - var tc strings.Builder - split := bytes.Split(data, []byte{';'}) - for _, s := range split { - parts := bytes.SplitN(s, []byte{'='}, 2) - if len(parts) == 0 { - return CapabilityEvent("") - } - - name, err := hex.DecodeString(string(parts[0])) - if err != nil || len(name) == 0 { - continue - } - - var value []byte - if len(parts) > 1 { - value, err = hex.DecodeString(string(parts[1])) - if err != nil { - continue - } - } - - if tc.Len() > 0 { - tc.WriteByte(';') - } - tc.WriteString(string(name)) - if len(value) > 0 { - tc.WriteByte('=') - tc.WriteString(string(value)) - } - } - - return CapabilityEvent(tc.String()) -} diff --git a/packages/tui/input/terminfo.go b/packages/tui/input/terminfo.go deleted file mode 100644 index a54da2c3..00000000 --- a/packages/tui/input/terminfo.go +++ /dev/null @@ -1,277 +0,0 @@ -package input - -import ( - "strings" - - "github.com/xo/terminfo" -) - -func buildTerminfoKeys(flags int, term string) map[string]Key { - table := make(map[string]Key) - ti, _ := terminfo.Load(term) - if ti == nil { - return table - } - - tiTable := defaultTerminfoKeys(flags) - - // Default keys - for name, seq := range ti.StringCapsShort() { - if !strings.HasPrefix(name, "k") || len(seq) == 0 { - continue - } - - if k, ok := tiTable[name]; ok { - table[string(seq)] = k - } - } - - // Extended keys - for name, seq := range ti.ExtStringCapsShort() { - if !strings.HasPrefix(name, "k") || len(seq) == 0 { - continue - } - - if k, ok := tiTable[name]; ok { - table[string(seq)] = k - } - } - - return table -} - -// This returns a map of terminfo keys to key events. It's a mix of ncurses -// terminfo default and user-defined key capabilities. -// Upper-case caps that are defined in the default terminfo database are -// - kNXT -// - kPRV -// - kHOM -// - kEND -// - kDC -// - kIC -// - kLFT -// - kRIT -// -// See https://man7.org/linux/man-pages/man5/terminfo.5.html -// See https://github.com/mirror/ncurses/blob/master/include/Caps-ncurses -func defaultTerminfoKeys(flags int) map[string]Key { - keys := map[string]Key{ - "kcuu1": {Code: KeyUp}, - "kUP": {Code: KeyUp, Mod: ModShift}, - "kUP3": {Code: KeyUp, Mod: ModAlt}, - "kUP4": {Code: KeyUp, Mod: ModShift | ModAlt}, - "kUP5": {Code: KeyUp, Mod: ModCtrl}, - "kUP6": {Code: KeyUp, Mod: ModShift | ModCtrl}, - "kUP7": {Code: KeyUp, Mod: ModAlt | ModCtrl}, - "kUP8": {Code: KeyUp, Mod: ModShift | ModAlt | ModCtrl}, - "kcud1": {Code: KeyDown}, - "kDN": {Code: KeyDown, Mod: ModShift}, - "kDN3": {Code: KeyDown, Mod: ModAlt}, - "kDN4": {Code: KeyDown, Mod: ModShift | ModAlt}, - "kDN5": {Code: KeyDown, Mod: ModCtrl}, - "kDN7": {Code: KeyDown, Mod: ModAlt | ModCtrl}, - "kDN6": {Code: KeyDown, Mod: ModShift | ModCtrl}, - "kDN8": {Code: KeyDown, Mod: ModShift | ModAlt | ModCtrl}, - "kcub1": {Code: KeyLeft}, - "kLFT": {Code: KeyLeft, Mod: ModShift}, - "kLFT3": {Code: KeyLeft, Mod: ModAlt}, - "kLFT4": {Code: KeyLeft, Mod: ModShift | ModAlt}, - "kLFT5": {Code: KeyLeft, Mod: ModCtrl}, - "kLFT6": {Code: KeyLeft, Mod: ModShift | ModCtrl}, - "kLFT7": {Code: KeyLeft, Mod: ModAlt | ModCtrl}, - "kLFT8": {Code: KeyLeft, Mod: ModShift | ModAlt | ModCtrl}, - "kcuf1": {Code: KeyRight}, - "kRIT": {Code: KeyRight, Mod: ModShift}, - "kRIT3": {Code: KeyRight, Mod: ModAlt}, - "kRIT4": {Code: KeyRight, Mod: ModShift | ModAlt}, - "kRIT5": {Code: KeyRight, Mod: ModCtrl}, - "kRIT6": {Code: KeyRight, Mod: ModShift | ModCtrl}, - "kRIT7": {Code: KeyRight, Mod: ModAlt | ModCtrl}, - "kRIT8": {Code: KeyRight, Mod: ModShift | ModAlt | ModCtrl}, - "kich1": {Code: KeyInsert}, - "kIC": {Code: KeyInsert, Mod: ModShift}, - "kIC3": {Code: KeyInsert, Mod: ModAlt}, - "kIC4": {Code: KeyInsert, Mod: ModShift | ModAlt}, - "kIC5": {Code: KeyInsert, Mod: ModCtrl}, - "kIC6": {Code: KeyInsert, Mod: ModShift | ModCtrl}, - "kIC7": {Code: KeyInsert, Mod: ModAlt | ModCtrl}, - "kIC8": {Code: KeyInsert, Mod: ModShift | ModAlt | ModCtrl}, - "kdch1": {Code: KeyDelete}, - "kDC": {Code: KeyDelete, Mod: ModShift}, - "kDC3": {Code: KeyDelete, Mod: ModAlt}, - "kDC4": {Code: KeyDelete, Mod: ModShift | ModAlt}, - "kDC5": {Code: KeyDelete, Mod: ModCtrl}, - "kDC6": {Code: KeyDelete, Mod: ModShift | ModCtrl}, - "kDC7": {Code: KeyDelete, Mod: ModAlt | ModCtrl}, - "kDC8": {Code: KeyDelete, Mod: ModShift | ModAlt | ModCtrl}, - "khome": {Code: KeyHome}, - "kHOM": {Code: KeyHome, Mod: ModShift}, - "kHOM3": {Code: KeyHome, Mod: ModAlt}, - "kHOM4": {Code: KeyHome, Mod: ModShift | ModAlt}, - "kHOM5": {Code: KeyHome, Mod: ModCtrl}, - "kHOM6": {Code: KeyHome, Mod: ModShift | ModCtrl}, - "kHOM7": {Code: KeyHome, Mod: ModAlt | ModCtrl}, - "kHOM8": {Code: KeyHome, Mod: ModShift | ModAlt | ModCtrl}, - "kend": {Code: KeyEnd}, - "kEND": {Code: KeyEnd, Mod: ModShift}, - "kEND3": {Code: KeyEnd, Mod: ModAlt}, - "kEND4": {Code: KeyEnd, Mod: ModShift | ModAlt}, - "kEND5": {Code: KeyEnd, Mod: ModCtrl}, - "kEND6": {Code: KeyEnd, Mod: ModShift | ModCtrl}, - "kEND7": {Code: KeyEnd, Mod: ModAlt | ModCtrl}, - "kEND8": {Code: KeyEnd, Mod: ModShift | ModAlt | ModCtrl}, - "kpp": {Code: KeyPgUp}, - "kprv": {Code: KeyPgUp}, - "kPRV": {Code: KeyPgUp, Mod: ModShift}, - "kPRV3": {Code: KeyPgUp, Mod: ModAlt}, - "kPRV4": {Code: KeyPgUp, Mod: ModShift | ModAlt}, - "kPRV5": {Code: KeyPgUp, Mod: ModCtrl}, - "kPRV6": {Code: KeyPgUp, Mod: ModShift | ModCtrl}, - "kPRV7": {Code: KeyPgUp, Mod: ModAlt | ModCtrl}, - "kPRV8": {Code: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl}, - "knp": {Code: KeyPgDown}, - "knxt": {Code: KeyPgDown}, - "kNXT": {Code: KeyPgDown, Mod: ModShift}, - "kNXT3": {Code: KeyPgDown, Mod: ModAlt}, - "kNXT4": {Code: KeyPgDown, Mod: ModShift | ModAlt}, - "kNXT5": {Code: KeyPgDown, Mod: ModCtrl}, - "kNXT6": {Code: KeyPgDown, Mod: ModShift | ModCtrl}, - "kNXT7": {Code: KeyPgDown, Mod: ModAlt | ModCtrl}, - "kNXT8": {Code: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl}, - - "kbs": {Code: KeyBackspace}, - "kcbt": {Code: KeyTab, Mod: ModShift}, - - // Function keys - // This only includes the first 12 function keys. The rest are treated - // as modifiers of the first 12. - // Take a look at XTerm modifyFunctionKeys - // - // XXX: To use unambiguous function keys, use fixterms or kitty clipboard. - // - // See https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyFunctionKeys - // See https://invisible-island.net/xterm/terminfo.html - - "kf1": {Code: KeyF1}, - "kf2": {Code: KeyF2}, - "kf3": {Code: KeyF3}, - "kf4": {Code: KeyF4}, - "kf5": {Code: KeyF5}, - "kf6": {Code: KeyF6}, - "kf7": {Code: KeyF7}, - "kf8": {Code: KeyF8}, - "kf9": {Code: KeyF9}, - "kf10": {Code: KeyF10}, - "kf11": {Code: KeyF11}, - "kf12": {Code: KeyF12}, - "kf13": {Code: KeyF1, Mod: ModShift}, - "kf14": {Code: KeyF2, Mod: ModShift}, - "kf15": {Code: KeyF3, Mod: ModShift}, - "kf16": {Code: KeyF4, Mod: ModShift}, - "kf17": {Code: KeyF5, Mod: ModShift}, - "kf18": {Code: KeyF6, Mod: ModShift}, - "kf19": {Code: KeyF7, Mod: ModShift}, - "kf20": {Code: KeyF8, Mod: ModShift}, - "kf21": {Code: KeyF9, Mod: ModShift}, - "kf22": {Code: KeyF10, Mod: ModShift}, - "kf23": {Code: KeyF11, Mod: ModShift}, - "kf24": {Code: KeyF12, Mod: ModShift}, - "kf25": {Code: KeyF1, Mod: ModCtrl}, - "kf26": {Code: KeyF2, Mod: ModCtrl}, - "kf27": {Code: KeyF3, Mod: ModCtrl}, - "kf28": {Code: KeyF4, Mod: ModCtrl}, - "kf29": {Code: KeyF5, Mod: ModCtrl}, - "kf30": {Code: KeyF6, Mod: ModCtrl}, - "kf31": {Code: KeyF7, Mod: ModCtrl}, - "kf32": {Code: KeyF8, Mod: ModCtrl}, - "kf33": {Code: KeyF9, Mod: ModCtrl}, - "kf34": {Code: KeyF10, Mod: ModCtrl}, - "kf35": {Code: KeyF11, Mod: ModCtrl}, - "kf36": {Code: KeyF12, Mod: ModCtrl}, - "kf37": {Code: KeyF1, Mod: ModShift | ModCtrl}, - "kf38": {Code: KeyF2, Mod: ModShift | ModCtrl}, - "kf39": {Code: KeyF3, Mod: ModShift | ModCtrl}, - "kf40": {Code: KeyF4, Mod: ModShift | ModCtrl}, - "kf41": {Code: KeyF5, Mod: ModShift | ModCtrl}, - "kf42": {Code: KeyF6, Mod: ModShift | ModCtrl}, - "kf43": {Code: KeyF7, Mod: ModShift | ModCtrl}, - "kf44": {Code: KeyF8, Mod: ModShift | ModCtrl}, - "kf45": {Code: KeyF9, Mod: ModShift | ModCtrl}, - "kf46": {Code: KeyF10, Mod: ModShift | ModCtrl}, - "kf47": {Code: KeyF11, Mod: ModShift | ModCtrl}, - "kf48": {Code: KeyF12, Mod: ModShift | ModCtrl}, - "kf49": {Code: KeyF1, Mod: ModAlt}, - "kf50": {Code: KeyF2, Mod: ModAlt}, - "kf51": {Code: KeyF3, Mod: ModAlt}, - "kf52": {Code: KeyF4, Mod: ModAlt}, - "kf53": {Code: KeyF5, Mod: ModAlt}, - "kf54": {Code: KeyF6, Mod: ModAlt}, - "kf55": {Code: KeyF7, Mod: ModAlt}, - "kf56": {Code: KeyF8, Mod: ModAlt}, - "kf57": {Code: KeyF9, Mod: ModAlt}, - "kf58": {Code: KeyF10, Mod: ModAlt}, - "kf59": {Code: KeyF11, Mod: ModAlt}, - "kf60": {Code: KeyF12, Mod: ModAlt}, - "kf61": {Code: KeyF1, Mod: ModShift | ModAlt}, - "kf62": {Code: KeyF2, Mod: ModShift | ModAlt}, - "kf63": {Code: KeyF3, Mod: ModShift | ModAlt}, - } - - // Preserve F keys from F13 to F63 instead of using them for F-keys - // modifiers. - if flags&FlagFKeys != 0 { - keys["kf13"] = Key{Code: KeyF13} - keys["kf14"] = Key{Code: KeyF14} - keys["kf15"] = Key{Code: KeyF15} - keys["kf16"] = Key{Code: KeyF16} - keys["kf17"] = Key{Code: KeyF17} - keys["kf18"] = Key{Code: KeyF18} - keys["kf19"] = Key{Code: KeyF19} - keys["kf20"] = Key{Code: KeyF20} - keys["kf21"] = Key{Code: KeyF21} - keys["kf22"] = Key{Code: KeyF22} - keys["kf23"] = Key{Code: KeyF23} - keys["kf24"] = Key{Code: KeyF24} - keys["kf25"] = Key{Code: KeyF25} - keys["kf26"] = Key{Code: KeyF26} - keys["kf27"] = Key{Code: KeyF27} - keys["kf28"] = Key{Code: KeyF28} - keys["kf29"] = Key{Code: KeyF29} - keys["kf30"] = Key{Code: KeyF30} - keys["kf31"] = Key{Code: KeyF31} - keys["kf32"] = Key{Code: KeyF32} - keys["kf33"] = Key{Code: KeyF33} - keys["kf34"] = Key{Code: KeyF34} - keys["kf35"] = Key{Code: KeyF35} - keys["kf36"] = Key{Code: KeyF36} - keys["kf37"] = Key{Code: KeyF37} - keys["kf38"] = Key{Code: KeyF38} - keys["kf39"] = Key{Code: KeyF39} - keys["kf40"] = Key{Code: KeyF40} - keys["kf41"] = Key{Code: KeyF41} - keys["kf42"] = Key{Code: KeyF42} - keys["kf43"] = Key{Code: KeyF43} - keys["kf44"] = Key{Code: KeyF44} - keys["kf45"] = Key{Code: KeyF45} - keys["kf46"] = Key{Code: KeyF46} - keys["kf47"] = Key{Code: KeyF47} - keys["kf48"] = Key{Code: KeyF48} - keys["kf49"] = Key{Code: KeyF49} - keys["kf50"] = Key{Code: KeyF50} - keys["kf51"] = Key{Code: KeyF51} - keys["kf52"] = Key{Code: KeyF52} - keys["kf53"] = Key{Code: KeyF53} - keys["kf54"] = Key{Code: KeyF54} - keys["kf55"] = Key{Code: KeyF55} - keys["kf56"] = Key{Code: KeyF56} - keys["kf57"] = Key{Code: KeyF57} - keys["kf58"] = Key{Code: KeyF58} - keys["kf59"] = Key{Code: KeyF59} - keys["kf60"] = Key{Code: KeyF60} - keys["kf61"] = Key{Code: KeyF61} - keys["kf62"] = Key{Code: KeyF62} - keys["kf63"] = Key{Code: KeyF63} - } - - return keys -} diff --git a/packages/tui/input/xterm.go b/packages/tui/input/xterm.go deleted file mode 100644 index b3bbc308..00000000 --- a/packages/tui/input/xterm.go +++ /dev/null @@ -1,47 +0,0 @@ -package input - -import ( - "github.com/charmbracelet/x/ansi" -) - -func parseXTermModifyOtherKeys(params ansi.Params) Event { - // XTerm modify other keys starts with ESC [ 27 ; ; ~ - xmod, _, _ := params.Param(1, 1) - xrune, _, _ := params.Param(2, 1) - mod := KeyMod(xmod - 1) - r := rune(xrune) - - switch r { - case ansi.BS: - return KeyPressEvent{Mod: mod, Code: KeyBackspace} - case ansi.HT: - return KeyPressEvent{Mod: mod, Code: KeyTab} - case ansi.CR: - return KeyPressEvent{Mod: mod, Code: KeyEnter} - case ansi.ESC: - return KeyPressEvent{Mod: mod, Code: KeyEscape} - case ansi.DEL: - return KeyPressEvent{Mod: mod, Code: KeyBackspace} - } - - // CSI 27 ; ; ~ keys defined in XTerm modifyOtherKeys - k := KeyPressEvent{Code: r, Mod: mod} - if k.Mod <= ModShift { - k.Text = string(r) - } - - return k -} - -// TerminalVersionEvent is a message that represents the terminal version. -type TerminalVersionEvent string - -// ModifyOtherKeysEvent represents a modifyOtherKeys event. -// -// 0: disable -// 1: enable mode 1 -// 2: enable mode 2 -// -// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_ -// See: https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyOtherKeys -type ModifyOtherKeysEvent uint8 diff --git a/packages/tui/internal/api/api.go b/packages/tui/internal/api/api.go deleted file mode 100644 index b4d3adee..00000000 --- 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 e0f1d992..00000000 --- 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 e716d437..00000000 --- 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 bd5086a4..00000000 --- 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 cc65eea5..00000000 --- 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 3ecd8619..00000000 --- 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 70e05bd2..00000000 --- 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 - -/* -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 ead6811f..00000000 --- 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 - -//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 10190639..00000000 --- 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 - -//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 7b3e05f6..00000000 --- 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 09fc1416..00000000 --- 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 - -//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 d552b78e..00000000 --- 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, "") - keybinding := strings.ReplaceAll(p, "", "") - 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("h"), - Trigger: []string{"help"}, - }, - { - Name: EditorOpenCommand, - Description: "open editor", - Keybindings: parseBindings("e"), - Trigger: []string{"editor"}, - }, - { - Name: SessionExportCommand, - Description: "export conversation", - Keybindings: parseBindings("x"), - Trigger: []string{"export"}, - }, - { - Name: SessionNewCommand, - Description: "new session", - Keybindings: parseBindings("n"), - Trigger: []string{"new", "clear"}, - }, - { - Name: SessionListCommand, - Description: "list sessions", - Keybindings: parseBindings("l"), - Trigger: []string{"sessions", "resume", "continue"}, - }, - { - Name: SessionTimelineCommand, - Description: "show session timeline", - Keybindings: parseBindings("g"), - Trigger: []string{"timeline", "history", "goto"}, - }, - { - Name: SessionShareCommand, - Description: "share session", - Keybindings: parseBindings("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("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("d"), - Trigger: []string{"details"}, - }, - { - Name: ThinkingBlocksCommand, - Description: "toggle thinking blocks", - Keybindings: parseBindings("b"), - Trigger: []string{"thinking"}, - }, - { - Name: ModelListCommand, - Description: "list models", - Keybindings: parseBindings("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("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("t"), - Trigger: []string{"themes"}, - }, - { - Name: ProjectInitCommand, - Description: "create/update AGENTS.md", - Keybindings: parseBindings("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("y"), - }, - { - Name: MessagesUndoCommand, - Description: "undo last message", - Keybindings: parseBindings("u"), - Trigger: []string{"undo"}, - }, - { - Name: MessagesRedoCommand, - Description: "redo message", - Keybindings: parseBindings("r"), - Trigger: []string{"redo"}, - }, - { - Name: AppExitCommand, - Description: "exit the app", - Keybindings: parseBindings("ctrl+c", "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 d25c76d8..00000000 --- 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 72e261f8..00000000 --- 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 d0087365..00000000 --- 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 dc11522c..00000000 --- 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 fac6b681..00000000 --- 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 725e2e69..00000000 --- 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 454f1a5a..00000000 --- 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 d3c81384..00000000 --- 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 801545a8..00000000 --- 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 3d52b84e..00000000 --- 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 fd578a41..00000000 --- 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 c2cbd645..00000000 --- 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 4e890b08..00000000 --- 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 15931724..00000000 --- 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 e30a1068..00000000 --- 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 b8fefd8b..00000000 --- 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 a1700c89..00000000 --- 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 c71cddc8..00000000 --- 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 f2eeb7fb..00000000 --- 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 da2e007c..00000000 --- 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(` - -`, - 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 261ba597..00000000 --- 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 a9823d0a..00000000 --- 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 25cca8cf..00000000 --- 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 09989d8e..00000000 --- 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 233bcf52..00000000 --- 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 aba80900..00000000 --- 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 1e1caf8a..00000000 --- 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 2c9aec4f..00000000 --- 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 c4fc87f8..00000000 --- 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 6e669591..00000000 --- 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 fb3c5b8b..00000000 --- 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 2de6bf61..00000000 --- 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 efb69920..00000000 --- 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 e5ad1d55..00000000 --- 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 0490b8f2..00000000 --- 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 5b10a952..00000000 --- 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 dce27ac6..00000000 --- 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 08016e31..00000000 --- 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 99b05b45..00000000 --- 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 d73c1410..00000000 --- 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 b8905f8e..00000000 --- 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 29d10f5c..00000000 --- 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 b3d2f098..00000000 --- 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 37546789..00000000 --- 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 420b96de..00000000 --- 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 8dd48cfe..00000000 --- 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 d5d27a1e..00000000 --- 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/mellow.json b/packages/tui/internal/theme/themes/mellow.json deleted file mode 100644 index f2a00a47..00000000 --- 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/tui/tui.go b/packages/tui/internal/tui/tui.go deleted file mode 100644 index 3a0bc373..00000000 --- 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 8e872e63..00000000 --- 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 b387ca65..00000000 --- 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 d24c7f97..00000000 --- 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 6512882f..00000000 --- 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 050b9634..00000000 --- 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 7b3832f9..00000000 --- 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 b6ba0db6..00000000 --- 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 b49d2e29..00000000 --- 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 ec0ffda5..00000000 --- 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 d9c503a9..00000000 --- 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 10c875fa..00000000 --- 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 -}