DELETE GO BUBBLETEA CRAP HOORAY

This commit is contained in:
Dax Raad
2025-11-02 18:43:17 -05:00
parent 5e86c9b791
commit f68374ad22
132 changed files with 23 additions and 28760 deletions

View File

@@ -2,29 +2,29 @@ import { SyntaxStyle, RGBA } from "@opentui/core"
import { createMemo, createSignal } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" } import aura from "./theme/aura.json" with { type: "json" }
import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" } import ayu from "./theme/ayu.json" with { type: "json" }
import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" } import catppuccin from "./theme/catppuccin.json" with { type: "json" }
import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" } import cobalt2 from "./theme/cobalt2.json" with { type: "json" }
import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" } import dracula from "./theme/dracula.json" with { type: "json" }
import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" } import everforest from "./theme/everforest.json" with { type: "json" }
import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" } import github from "./theme/github.json" with { type: "json" }
import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" } import gruvbox from "./theme/gruvbox.json" with { type: "json" }
import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" } import kanagawa from "./theme/kanagawa.json" with { type: "json" }
import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" } import material from "./theme/material.json" with { type: "json" }
import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" } import matrix from "./theme/matrix.json" with { type: "json" }
import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" } import monokai from "./theme/monokai.json" with { type: "json" }
import nightowl from "../../../../../../tui/internal/theme/themes/nightowl.json" with { type: "json" } import nightowl from "./theme/nightowl.json" with { type: "json" }
import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" } import nord from "./theme/nord.json" with { type: "json" }
import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" } import onedark from "./theme/one-dark.json" with { type: "json" }
import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" } import opencode from "./theme/opencode.json" with { type: "json" }
import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" } import palenight from "./theme/palenight.json" with { type: "json" }
import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" } import rosepine from "./theme/rosepine.json" with { type: "json" }
import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" } import solarized from "./theme/solarized.json" with { type: "json" }
import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" } import synthwave84 from "./theme/synthwave84.json" with { type: "json" }
import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" } import tokyonight from "./theme/tokyonight.json" with { type: "json" }
import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" } import vesper from "./theme/vesper.json" with { type: "json" }
import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" } import zenburn from "./theme/zenburn.json" with { type: "json" }
import { useKV } from "./kv" import { useKV } from "./kv"
type Theme = { type Theme = {

View File

@@ -1,4 +0,0 @@
opencode-test
cmd/opencode/opencode
opencode

View File

@@ -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:"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
// humanreadable 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 dont 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ; <modifier> ; <code> ~
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:])
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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 + <key> 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 ; <modifier> ; <code> ~ 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 ; <modifier> <func>
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 <modifier> <func>
for k, v := range ss3FuncKeys {
seq := "\x1bO" + xtermMod + k
key := v
key.Mod = m
table[seq] = key
}
// CSI <number> ; <modifier> ~
for k, v := range csiTildeKeys {
seq := "\x1b[" + k + ";" + xtermMod + "~"
key := v
key.Mod = m
table[seq] = key
}
// CSI 27 ; <modifier> ; <code> ~
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
}

View File

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

View File

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

View File

@@ -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 ; <modifier> ; <code> ~
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 ; <modifier> ; <code> ~ 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,155 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
/*
Package clipboard provides cross platform clipboard access and supports
macOS/Linux/Windows/Android/iOS platform. Before interacting with the
clipboard, one must call Init to assert if it is possible to use this
package:
err := clipboard.Init()
if err != nil {
panic(err)
}
The most common operations are `Read` and `Write`. To use them:
// write/read text format data of the clipboard, and
// the byte buffer regarding the text are UTF8 encoded.
clipboard.Write(clipboard.FmtText, []byte("text data"))
clipboard.Read(clipboard.FmtText)
// write/read image format data of the clipboard, and
// the byte buffer regarding the image are PNG encoded.
clipboard.Write(clipboard.FmtImage, []byte("image data"))
clipboard.Read(clipboard.FmtImage)
Note that read/write regarding image format assumes that the bytes are
PNG encoded since it serves the alpha blending purpose that might be
used in other graphical software.
In addition, `clipboard.Write` returns a channel that can receive an
empty struct as a signal, which indicates the corresponding write call
to the clipboard is outdated, meaning the clipboard has been overwritten
by others and the previously written data is lost. For instance:
changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
select {
case <-changed:
println(`"text data" is no longer available from clipboard.`)
}
You can ignore the returning channel if you don't need this type of
notification. Furthermore, when you need more than just knowing whether
clipboard data is changed, use the watcher API:
ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
for data := range ch {
// print out clipboard data whenever it is changed
println(string(data))
}
*/
package clipboard
import (
"context"
"errors"
"fmt"
"os"
"sync"
)
var (
// activate only for running tests.
debug = false
errUnavailable = errors.New("clipboard unavailable")
errUnsupported = errors.New("unsupported format")
errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
)
// Format represents the format of clipboard data.
type Format int
// All sorts of supported clipboard data
const (
// FmtText indicates plain text clipboard format
FmtText Format = iota
// FmtImage indicates image/png clipboard format
FmtImage
)
var (
// Due to the limitation on operating systems (such as darwin),
// concurrent read can even cause panic, use a global lock to
// guarantee one read at a time.
lock = sync.Mutex{}
initOnce sync.Once
initError error
)
// Init initializes the clipboard package. It returns an error
// if the clipboard is not available to use. This may happen if the
// target system lacks required dependency, such as libx11-dev in X11
// environment. For example,
//
// err := clipboard.Init()
// if err != nil {
// panic(err)
// }
//
// If Init returns an error, any subsequent Read/Write/Watch call
// may result in an unrecoverable panic.
func Init() error {
initOnce.Do(func() {
initError = initialize()
})
return initError
}
// Read returns a chunk of bytes of the clipboard data if it presents
// in the desired format t presents. Otherwise, it returns nil.
func Read(t Format) []byte {
lock.Lock()
defer lock.Unlock()
buf, err := read(t)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
}
return nil
}
return buf
}
// Write writes a given buffer to the clipboard in a specified format.
// Write returned a receive-only channel can receive an empty struct
// as a signal, which indicates the clipboard has been overwritten from
// this write.
// If format t indicates an image, then the given buf assumes
// the image data is PNG encoded.
func Write(t Format, buf []byte) <-chan struct{} {
lock.Lock()
defer lock.Unlock()
changed, err := write(t, buf)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
}
return nil
}
return changed
}
// Watch returns a receive-only channel that received the clipboard data
// whenever any change of clipboard data in the desired format happens.
//
// The returned channel will be closed if the given context is canceled.
func Watch(ctx context.Context, t Format) <-chan []byte {
return watch(ctx, t)
}

View File

@@ -1,266 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build darwin
package clipboard
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
var (
lastChangeCount int64
changeCountMu sync.Mutex
)
func initialize() error { return nil }
func read(t Format) (buf []byte, err error) {
switch t {
case FmtText:
return readText()
case FmtImage:
return readImage()
default:
return nil, errUnsupported
}
}
func readText() ([]byte, error) {
// Check if clipboard contains string data
checkScript := `
try
set clipboardTypes to (clipboard info)
repeat with aType in clipboardTypes
if (first item of aType) is string then
return "hastext"
end if
end repeat
return "notext"
on error
return "error"
end try
`
cmd := exec.Command("osascript", "-e", checkScript)
checkOut, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
checkOut = bytes.TrimSpace(checkOut)
if !bytes.Equal(checkOut, []byte("hastext")) {
return nil, errUnavailable
}
// Now get the actual text
cmd = exec.Command("osascript", "-e", "get the clipboard")
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Remove trailing newline that osascript adds
out = bytes.TrimSuffix(out, []byte("\n"))
// If clipboard was set to empty string, return nil
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func readImage() ([]byte, error) {
// AppleScript to read image data from clipboard as base64
script := `
try
set theData to the clipboard as «class PNGf»
return theData
on error
return ""
end try
`
cmd := exec.Command("osascript", "-e", script)
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Check if we got any data
out = bytes.TrimSpace(out)
if len(out) == 0 {
return nil, errUnavailable
}
// The output is in hex format (e.g., «data PNGf89504E...»)
// We need to extract and convert it
outStr := string(out)
if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
return nil, errUnavailable
}
// Extract hex data
hexData := strings.TrimPrefix(outStr, "«data PNGf")
hexData = strings.TrimSuffix(hexData, "»")
// Convert hex to bytes
buf := make([]byte, len(hexData)/2)
for i := 0; i < len(hexData); i += 2 {
b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
if err != nil {
return nil, errUnavailable
}
buf[i/2] = byte(b)
}
return buf, nil
}
// write writes the given data to clipboard and
// returns true if success or false if failed.
func write(t Format, buf []byte) (<-chan struct{}, error) {
var err error
switch t {
case FmtText:
err = writeText(buf)
case FmtImage:
err = writeImage(buf)
default:
return nil, errUnsupported
}
if err != nil {
return nil, err
}
// Update change count
changeCountMu.Lock()
lastChangeCount++
currentCount := lastChangeCount
changeCountMu.Unlock()
// use unbuffered channel to prevent goroutine leak
changed := make(chan struct{}, 1)
go func() {
for {
time.Sleep(time.Second)
changeCountMu.Lock()
if lastChangeCount != currentCount {
changeCountMu.Unlock()
changed <- struct{}{}
close(changed)
return
}
changeCountMu.Unlock()
}
}()
return changed, nil
}
func writeText(buf []byte) error {
if len(buf) == 0 {
// Clear clipboard
script := `set the clipboard to ""`
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
// Escape the text for AppleScript
text := string(buf)
text = strings.ReplaceAll(text, "\\", "\\\\")
text = strings.ReplaceAll(text, "\"", "\\\"")
script := fmt.Sprintf(`set the clipboard to "%s"`, text)
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
func writeImage(buf []byte) error {
if len(buf) == 0 {
// Clear clipboard
script := `set the clipboard to ""`
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
// Create a temporary file to store the PNG data
tmpFile, err := os.CreateTemp("", "clipboard*.png")
if err != nil {
return errUnavailable
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(buf); err != nil {
tmpFile.Close()
return errUnavailable
}
tmpFile.Close()
// Use osascript to set clipboard to the image file
script := fmt.Sprintf(`
set theFile to POSIX file "%s"
set theImage to read theFile as «class PNGf»
set the clipboard to theImage
`, tmpFile.Name())
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
return errUnavailable
}
return nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ti := time.NewTicker(time.Second)
// Get initial clipboard content
var lastContent []byte
if b := Read(t); b != nil {
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
go func() {
defer close(recv)
defer ti.Stop()
for {
select {
case <-ctx.Done():
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
// Check if content changed
if !bytes.Equal(lastContent, b) {
recv <- b
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
}
}
}()
return recv
}

View File

@@ -1,311 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build linux
package clipboard
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"time"
)
var (
// Clipboard tools in order of preference
clipboardTools = []struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}{
{
name: "xclip",
readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
writeCmd: []string{"xclip", "-selection", "clipboard"},
readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
},
{
name: "xsel",
readCmd: []string{"xsel", "--clipboard", "--output"},
writeCmd: []string{"xsel", "--clipboard", "--input"},
readImg: []string{"xsel", "--clipboard", "--output"},
writeImg: []string{"xsel", "--clipboard", "--input"},
},
{
name: "wl-copy",
readCmd: []string{"wl-paste", "-n"},
writeCmd: []string{"wl-copy"},
readImg: []string{"wl-paste", "-t", "image/png", "-n"},
writeImg: []string{"wl-copy", "-t", "image/png"},
},
}
selectedTool int = -1
toolMutex sync.Mutex
lastChangeTime time.Time
changeTimeMu sync.Mutex
)
func initialize() error {
toolMutex.Lock()
defer toolMutex.Unlock()
if selectedTool >= 0 {
return nil // Already initialized
}
order := []string{"xclip", "xsel", "wl-copy"}
if os.Getenv("WAYLAND_DISPLAY") != "" {
order = []string{"wl-copy", "xclip", "xsel"}
}
for _, name := range order {
for i, tool := range clipboardTools {
if tool.name == name {
cmd := exec.Command("which", tool.name)
if err := cmd.Run(); err == nil {
clipboardTools[i].available = true
if selectedTool < 0 {
selectedTool = i
slog.Debug("Clipboard tool found", "tool", tool.name)
}
}
break
}
}
}
if selectedTool < 0 {
slog.Warn(
"No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
)
return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
For X11 systems:
apt install -y xclip
# or
apt install -y xsel
For Wayland systems:
apt install -y wl-clipboard
If running in a headless environment, you may also need:
apt install -y xvfb
# and run:
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=:99.0`, errUnavailable)
}
return nil
}
func read(t Format) (buf []byte, err error) {
// Ensure clipboard is initialized before attempting to read
if err := initialize(); err != nil {
slog.Debug("Clipboard read failed: not initialized", "error", err)
return nil, err
}
toolMutex.Lock()
tool := clipboardTools[selectedTool]
toolMutex.Unlock()
switch t {
case FmtText:
return readText(tool)
case FmtImage:
return readImage(tool)
default:
return nil, errUnsupported
}
}
func readText(tool struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}) ([]byte, error) {
// First check if clipboard contains text
cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
out, err := cmd.Output()
if err != nil {
// Check if it's because clipboard contains non-text data
if tool.name == "xclip" {
// xclip returns error when clipboard doesn't contain requested type
checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
targets, _ := checkCmd.Output()
if bytes.Contains(targets, []byte("image/png")) &&
!bytes.Contains(targets, []byte("UTF8_STRING")) {
return nil, errUnavailable
}
}
return nil, errUnavailable
}
return out, nil
}
func readImage(tool struct {
name string
readCmd []string
writeCmd []string
readImg []string
writeImg []string
available bool
}) ([]byte, error) {
if tool.name == "xsel" {
// xsel doesn't support image types well, return error
return nil, errUnavailable
}
cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
out, err := cmd.Output()
if err != nil {
return nil, errUnavailable
}
// Verify it's PNG data
if len(out) < 8 ||
!bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
return nil, errUnavailable
}
return out, nil
}
func write(t Format, buf []byte) (<-chan struct{}, error) {
// Ensure clipboard is initialized before attempting to write
if err := initialize(); err != nil {
return nil, err
}
toolMutex.Lock()
tool := clipboardTools[selectedTool]
toolMutex.Unlock()
var cmd *exec.Cmd
switch t {
case FmtText:
if len(buf) == 0 {
// Write empty string
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader([]byte{})
} else {
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader(buf)
}
case FmtImage:
if tool.name == "xsel" {
// xsel doesn't support image types well
return nil, errUnavailable
}
if len(buf) == 0 {
// Clear clipboard
cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
cmd.Stdin = bytes.NewReader([]byte{})
} else {
cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
cmd.Stdin = bytes.NewReader(buf)
}
default:
return nil, errUnsupported
}
if err := cmd.Run(); err != nil {
return nil, errUnavailable
}
// Update change time
changeTimeMu.Lock()
lastChangeTime = time.Now()
currentTime := lastChangeTime
changeTimeMu.Unlock()
// Create change notification channel
changed := make(chan struct{}, 1)
go func() {
for {
time.Sleep(time.Second)
changeTimeMu.Lock()
if !lastChangeTime.Equal(currentTime) {
changeTimeMu.Unlock()
changed <- struct{}{}
close(changed)
return
}
changeTimeMu.Unlock()
}
}()
return changed, nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
// Ensure clipboard is initialized before starting watch
if err := initialize(); err != nil {
close(recv)
return recv
}
ti := time.NewTicker(time.Second)
// Get initial clipboard content
var lastContent []byte
if b := Read(t); b != nil {
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
go func() {
defer close(recv)
defer ti.Stop()
for {
select {
case <-ctx.Done():
return
case <-ti.C:
b := Read(t)
if b == nil {
continue
}
// Check if content changed
if !bytes.Equal(lastContent, b) {
recv <- b
lastContent = make([]byte, len(b))
copy(lastContent, b)
}
}
}
}()
return recv
}
// Helper function to check clipboard content type for xclip
func getClipboardTargets() []string {
cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
out, err := cmd.Output()
if err != nil {
return nil
}
return strings.Split(string(out), "\n")
}

View File

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

View File

@@ -1,551 +0,0 @@
// Copyright 2021 The golang.design Initiative Authors.
// All rights reserved. Use of this source code is governed
// by a MIT license that can be found in the LICENSE file.
//
// Written by Changkun Ou <changkun.de>
//go:build windows
package clipboard
// Interacting with Clipboard on Windows:
// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"reflect"
"runtime"
"syscall"
"time"
"unicode/utf16"
"unsafe"
"golang.org/x/image/bmp"
)
func initialize() error { return nil }
// readText reads the clipboard and returns the text data if presents.
// The caller is responsible for opening/closing the clipboard before
// calling this function.
func readText() (buf []byte, err error) {
hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
if hMem == 0 {
return nil, err
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return nil, err
}
defer gUnlock.Call(hMem)
// Find NUL terminator
n := 0
for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
ptr = unsafe.Pointer(uintptr(ptr) +
unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
}
var s []uint16
h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
h.Data = p
h.Len = n
h.Cap = n
return []byte(string(utf16.Decode(s))), nil
}
// writeText writes given data to the clipboard. It is the caller's
// responsibility for opening/closing the clipboard before calling
// this function.
func writeText(buf []byte) error {
r, _, err := emptyClipboard.Call()
if r == 0 {
return fmt.Errorf("failed to clear clipboard: %w", err)
}
// empty text, we are done here.
if len(buf) == 0 {
return nil
}
s, err := syscall.UTF16FromString(string(buf))
if err != nil {
return fmt.Errorf("failed to convert given string: %w", err)
}
hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
if hMem == 0 {
return fmt.Errorf("failed to alloc global memory: %w", err)
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return fmt.Errorf("failed to lock global memory: %w", err)
}
defer gUnlock.Call(hMem)
// no return value
memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
if v == 0 {
gFree.Call(hMem)
return fmt.Errorf("failed to set text to clipboard: %w", err)
}
return nil
}
// readImage reads the clipboard and returns PNG encoded image data
// if presents. The caller is responsible for opening/closing the
// clipboard before calling this function.
func readImage() ([]byte, error) {
hMem, _, err := getClipboardData.Call(cFmtDIBV5)
if hMem == 0 {
// second chance to try FmtDIB
return readImageDib()
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return nil, err
}
defer gUnlock.Call(hMem)
// inspect header information
info := (*bitmapV5Header)(unsafe.Pointer(p))
// maybe deal with other formats?
if info.BitCount != 32 {
return nil, errUnsupported
}
var data []byte
sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
sh.Data = uintptr(p)
sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
offset := int(info.Size)
stride := int(info.Width)
for y := 0; y < int(info.Height); y++ {
for x := 0; x < int(info.Width); x++ {
idx := offset + 4*(y*stride+x)
xhat := (x + int(info.Width)) % int(info.Width)
yhat := int(info.Height) - 1 - y
r := data[idx+2]
g := data[idx+1]
b := data[idx+0]
a := data[idx+3]
img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
}
}
// always use PNG encoding.
var buf bytes.Buffer
png.Encode(&buf, img)
return buf.Bytes(), nil
}
func readImageDib() ([]byte, error) {
const (
fileHeaderLen = 14
infoHeaderLen = 40
cFmtDIB = 8
)
hClipDat, _, err := getClipboardData.Call(cFmtDIB)
if err != nil {
return nil, errors.New("not dib format data: " + err.Error())
}
pMemBlk, _, err := gLock.Call(hClipDat)
if pMemBlk == 0 {
return nil, errors.New("failed to call global lock: " + err.Error())
}
defer gUnlock.Call(hClipDat)
bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
dataSize += iSizeImage
}
buf := new(bytes.Buffer)
binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
binary.Write(buf, binary.LittleEndian, uint32(dataSize))
binary.Write(buf, binary.LittleEndian, uint32(0))
const sizeof_colorbar = 0
binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
j := 0
for i := fileHeaderLen; i < int(dataSize); i++ {
binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
j++
}
return bmpToPng(buf)
}
func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
var f bytes.Buffer
original_image, err := bmp.Decode(bmpBuf)
if err != nil {
return nil, err
}
err = png.Encode(&f, original_image)
if err != nil {
return nil, err
}
return f.Bytes(), nil
}
func writeImage(buf []byte) error {
r, _, err := emptyClipboard.Call()
if r == 0 {
return fmt.Errorf("failed to clear clipboard: %w", err)
}
// empty text, we are done here.
if len(buf) == 0 {
return nil
}
img, err := png.Decode(bytes.NewReader(buf))
if err != nil {
return fmt.Errorf("input bytes is not PNG encoded: %w", err)
}
offset := unsafe.Sizeof(bitmapV5Header{})
width := img.Bounds().Dx()
height := img.Bounds().Dy()
imageSize := 4 * width * height
data := make([]byte, int(offset)+imageSize)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
idx := int(offset) + 4*(y*width+x)
r, g, b, a := img.At(x, height-1-y).RGBA()
data[idx+2] = uint8(r)
data[idx+1] = uint8(g)
data[idx+0] = uint8(b)
data[idx+3] = uint8(a)
}
}
info := bitmapV5Header{}
info.Size = uint32(offset)
info.Width = int32(width)
info.Height = int32(height)
info.Planes = 1
info.Compression = 0 // BI_RGB
info.SizeImage = uint32(4 * info.Width * info.Height)
info.RedMask = 0xff0000 // default mask
info.GreenMask = 0xff00
info.BlueMask = 0xff
info.AlphaMask = 0xff000000
info.BitCount = 32 // we only deal with 32 bpp at the moment.
// Use calibrated RGB values as Go's image/png assumes linear color space.
// Other options:
// - LCS_CALIBRATED_RGB = 0x00000000
// - LCS_sRGB = 0x73524742
// - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
info.CSType = 0x73524742
// Use GL_IMAGES for GamutMappingIntent
// Other options:
// - LCS_GM_ABS_COLORIMETRIC = 0x00000008
// - LCS_GM_BUSINESS = 0x00000001
// - LCS_GM_GRAPHICS = 0x00000002
// - LCS_GM_IMAGES = 0x00000004
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
info.Intent = 4 // LCS_GM_IMAGES
infob := make([]byte, int(unsafe.Sizeof(info)))
for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
infob[i] = v
}
copy(data[:], infob[:])
hMem, _, err := gAlloc.Call(gmemMoveable,
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
if hMem == 0 {
return fmt.Errorf("failed to alloc global memory: %w", err)
}
p, _, err := gLock.Call(hMem)
if p == 0 {
return fmt.Errorf("failed to lock global memory: %w", err)
}
defer gUnlock.Call(hMem)
memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
if v == 0 {
gFree.Call(hMem)
return fmt.Errorf("failed to set text to clipboard: %w", err)
}
return nil
}
func read(t Format) (buf []byte, err error) {
// On Windows, OpenClipboard and CloseClipboard must be executed on
// the same thread. Thus, lock the OS thread for further execution.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var format uintptr
switch t {
case FmtImage:
format = cFmtDIBV5
case FmtText:
fallthrough
default:
format = cFmtUnicodeText
}
// check if clipboard is available for the requested format
r, _, err := isClipboardFormatAvailable.Call(format)
if r == 0 {
return nil, errUnavailable
}
// try again until open clipboard succeeds
for {
r, _, _ = openClipboard.Call()
if r == 0 {
continue
}
break
}
defer closeClipboard.Call()
switch format {
case cFmtDIBV5:
return readImage()
case cFmtUnicodeText:
fallthrough
default:
return readText()
}
}
// write writes the given data to clipboard and
// returns true if success or false if failed.
func write(t Format, buf []byte) (<-chan struct{}, error) {
errch := make(chan error)
changed := make(chan struct{}, 1)
go func() {
// make sure GetClipboardSequenceNumber happens with
// OpenClipboard on the same thread.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
for {
r, _, _ := openClipboard.Call(0)
if r == 0 {
continue
}
break
}
// var param uintptr
switch t {
case FmtImage:
err := writeImage(buf)
if err != nil {
errch <- err
closeClipboard.Call()
return
}
case FmtText:
fallthrough
default:
// param = cFmtUnicodeText
err := writeText(buf)
if err != nil {
errch <- err
closeClipboard.Call()
return
}
}
// Close the clipboard otherwise other applications cannot
// paste the data.
closeClipboard.Call()
cnt, _, _ := getClipboardSequenceNumber.Call()
errch <- nil
for {
time.Sleep(time.Second)
cur, _, _ := getClipboardSequenceNumber.Call()
if cur != cnt {
changed <- struct{}{}
close(changed)
return
}
}
}()
err := <-errch
if err != nil {
return nil, err
}
return changed, nil
}
func watch(ctx context.Context, t Format) <-chan []byte {
recv := make(chan []byte, 1)
ready := make(chan struct{})
go func() {
// not sure if we are too slow or the user too fast :)
ti := time.NewTicker(time.Second)
cnt, _, _ := getClipboardSequenceNumber.Call()
ready <- struct{}{}
for {
select {
case <-ctx.Done():
close(recv)
return
case <-ti.C:
cur, _, _ := getClipboardSequenceNumber.Call()
if cnt != cur {
b := Read(t)
if b == nil {
continue
}
recv <- b
cnt = cur
}
}
}
}()
<-ready
return recv
}
const (
cFmtBitmap = 2 // Win+PrintScreen
cFmtUnicodeText = 13
cFmtDIBV5 = 17
// Screenshot taken from special shortcut is in different format (why??), see:
// https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
gmemMoveable = 0x0002
)
// BITMAPV5Header structure, see:
// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
type bitmapV5Header struct {
Size uint32
Width int32
Height int32
Planes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter int32
YPelsPerMeter int32
ClrUsed uint32
ClrImportant uint32
RedMask uint32
GreenMask uint32
BlueMask uint32
AlphaMask uint32
CSType uint32
Endpoints struct {
CiexyzRed, CiexyzGreen, CiexyzBlue struct {
CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
}
}
GammaRed uint32
GammaGreen uint32
GammaBlue uint32
Intent uint32
ProfileData uint32
ProfileSize uint32
Reserved uint32
}
type bitmapHeader struct {
Size uint32
Width uint32
Height uint32
PLanes uint16
BitCount uint16
Compression uint32
SizeImage uint32
XPelsPerMeter uint32
YPelsPerMeter uint32
ClrUsed uint32
ClrImportant uint32
}
// Calling a Windows DLL, see:
// https://github.com/golang/go/wiki/WindowsDLLs
var (
user32 = syscall.MustLoadDLL("user32")
// Opens the clipboard for examination and prevents other
// applications from modifying the clipboard content.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
openClipboard = user32.MustFindProc("OpenClipboard")
// Closes the clipboard.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
closeClipboard = user32.MustFindProc("CloseClipboard")
// Empties the clipboard and frees handles to data in the clipboard.
// The function then assigns ownership of the clipboard to the
// window that currently has the clipboard open.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
emptyClipboard = user32.MustFindProc("EmptyClipboard")
// Retrieves data from the clipboard in a specified format.
// The clipboard must have been opened previously.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
getClipboardData = user32.MustFindProc("GetClipboardData")
// Places data on the clipboard in a specified clipboard format.
// The window must be the current clipboard owner, and the
// application must have called the OpenClipboard function. (When
// responding to the WM_RENDERFORMAT message, the clipboard owner
// must not call OpenClipboard before calling SetClipboardData.)
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
setClipboardData = user32.MustFindProc("SetClipboardData")
// Determines whether the clipboard contains data in the specified format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
// Clipboard data formats are stored in an ordered list. To perform
// an enumeration of clipboard data formats, you make a series of
// calls to the EnumClipboardFormats function. For each call, the
// format parameter specifies an available clipboard format, and the
// function returns the next available clipboard format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
// Retrieves the clipboard sequence number for the current window station.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
// Registers a new clipboard format. This format can then be used as
// a valid clipboard format.
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
kernel32 = syscall.NewLazyDLL("kernel32")
// Locks a global memory object and returns a pointer to the first
// byte of the object's memory block.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
gLock = kernel32.NewProc("GlobalLock")
// Decrements the lock count associated with a memory object that was
// allocated with GMEM_MOVEABLE. This function has no effect on memory
// objects allocated with GMEM_FIXED.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
gUnlock = kernel32.NewProc("GlobalUnlock")
// Allocates the specified number of bytes from the heap.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
gAlloc = kernel32.NewProc("GlobalAlloc")
// Frees the specified global memory object and invalidates its handle.
// https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
gFree = kernel32.NewProc("GlobalFree")
memMove = kernel32.NewProc("RtlMoveMemory")
)

View File

@@ -1,423 +0,0 @@
package commands
import (
"encoding/json"
"log/slog"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode-sdk-go"
)
type ExecuteCommandMsg Command
type ExecuteCommandsMsg []Command
type CommandExecutedMsg Command
type Keybinding struct {
RequiresLeader bool
Key string
}
func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
key := k.Key
key = strings.TrimSpace(key)
return key == msg.String() && (k.RequiresLeader == leader)
}
type CommandName string
type Command struct {
Name CommandName
Description string
Keybindings []Keybinding
Trigger []string
Custom bool
}
func (c Command) Keys() []string {
var keys []string
for _, k := range c.Keybindings {
keys = append(keys, k.Key)
}
return keys
}
func (c Command) HasTrigger() bool {
return len(c.Trigger) > 0
}
func (c Command) PrimaryTrigger() string {
if len(c.Trigger) > 0 {
return c.Trigger[0]
}
return ""
}
func (c Command) MatchesTrigger(trigger string) bool {
return slices.Contains(c.Trigger, trigger)
}
type CommandRegistry map[CommandName]Command
func (r CommandRegistry) Sorted() []Command {
var commands []Command
for _, command := range r {
commands = append(commands, command)
}
slices.SortFunc(commands, func(a, b Command) int {
// Priority order: session_new, session_share, model_list, agent_list, app_help first, app_exit last
priorityOrder := map[CommandName]int{
SessionNewCommand: 0,
AppHelpCommand: 1,
SessionShareCommand: 2,
ModelListCommand: 3,
AgentListCommand: 4,
}
aPriority, aHasPriority := priorityOrder[a.Name]
bPriority, bHasPriority := priorityOrder[b.Name]
if aHasPriority && bHasPriority {
return aPriority - bPriority
}
if aHasPriority {
return -1
}
if bHasPriority {
return 1
}
if a.Name == AppExitCommand {
return 1
}
if b.Name == AppExitCommand {
return -1
}
if a.Custom && !b.Custom {
return 1
}
if !a.Custom && b.Custom {
return -1
}
return strings.Compare(string(a.Name), string(b.Name))
})
return commands
}
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
var matched []Command
for _, command := range r.Sorted() {
if command.Matches(msg, leader) {
matched = append(matched, command)
}
}
return matched
}
const (
SessionChildCycleCommand CommandName = "session_child_cycle"
SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
AgentCycleCommand CommandName = "agent_cycle"
AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
AppHelpCommand CommandName = "app_help"
SwitchAgentCommand CommandName = "switch_agent"
SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionTimelineCommand CommandName = "session_timeline"
SessionShareCommand CommandName = "session_share"
SessionUnshareCommand CommandName = "session_unshare"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
SessionExportCommand CommandName = "session_export"
ToolDetailsCommand CommandName = "tool_details"
ThinkingBlocksCommand CommandName = "thinking_blocks"
ModelListCommand CommandName = "model_list"
AgentListCommand CommandName = "agent_list"
ModelCycleRecentCommand CommandName = "model_cycle_recent"
ThemeListCommand CommandName = "theme_list"
FileListCommand CommandName = "file_list"
FileCloseCommand CommandName = "file_close"
FileSearchCommand CommandName = "file_search"
FileDiffToggleCommand CommandName = "file_diff_toggle"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesPreviousCommand CommandName = "messages_previous"
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesUndoCommand CommandName = "messages_undo"
MessagesRedoCommand CommandName = "messages_redo"
AppExitCommand CommandName = "app_exit"
)
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
for _, binding := range k.Keybindings {
if binding.Matches(msg, leader) {
return true
}
}
return false
}
func parseBindings(bindings ...string) []Keybinding {
var parsedBindings []Keybinding
for _, binding := range bindings {
if binding == "none" {
continue
}
for p := range strings.SplitSeq(binding, ",") {
requireLeader := strings.HasPrefix(p, "<leader>")
keybinding := strings.ReplaceAll(p, "<leader>", "")
keybinding = strings.TrimSpace(keybinding)
parsedBindings = append(parsedBindings, Keybinding{
RequiresLeader: requireLeader,
Key: keybinding,
})
}
}
return parsedBindings
}
func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
Description: "show help",
Keybindings: parseBindings("<leader>h"),
Trigger: []string{"help"},
},
{
Name: EditorOpenCommand,
Description: "open editor",
Keybindings: parseBindings("<leader>e"),
Trigger: []string{"editor"},
},
{
Name: SessionExportCommand,
Description: "export conversation",
Keybindings: parseBindings("<leader>x"),
Trigger: []string{"export"},
},
{
Name: SessionNewCommand,
Description: "new session",
Keybindings: parseBindings("<leader>n"),
Trigger: []string{"new", "clear"},
},
{
Name: SessionListCommand,
Description: "list sessions",
Keybindings: parseBindings("<leader>l"),
Trigger: []string{"sessions", "resume", "continue"},
},
{
Name: SessionTimelineCommand,
Description: "show session timeline",
Keybindings: parseBindings("<leader>g"),
Trigger: []string{"timeline", "history", "goto"},
},
{
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
Trigger: []string{"share"},
},
{
Name: SessionUnshareCommand,
Description: "unshare session",
Trigger: []string{"unshare"},
},
{
Name: SessionInterruptCommand,
Description: "interrupt session",
Keybindings: parseBindings("esc"),
},
{
Name: SessionCompactCommand,
Description: "compact the session",
Keybindings: parseBindings("<leader>c"),
Trigger: []string{"compact", "summarize"},
},
{
Name: SessionChildCycleCommand,
Description: "cycle to next child session",
Keybindings: parseBindings("ctrl+right"),
},
{
Name: SessionChildCycleReverseCommand,
Description: "cycle to previous child session",
Keybindings: parseBindings("ctrl+left"),
},
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
Keybindings: parseBindings("<leader>d"),
Trigger: []string{"details"},
},
{
Name: ThinkingBlocksCommand,
Description: "toggle thinking blocks",
Keybindings: parseBindings("<leader>b"),
Trigger: []string{"thinking"},
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: []string{"models"},
},
{
Name: ModelCycleRecentCommand,
Description: "next recent model",
Keybindings: parseBindings("f2"),
},
{
Name: ModelCycleRecentReverseCommand,
Description: "previous recent model",
Keybindings: parseBindings("shift+f2"),
},
{
Name: AgentListCommand,
Description: "list agents",
Keybindings: parseBindings("<leader>a"),
Trigger: []string{"agents"},
},
{
Name: AgentCycleCommand,
Description: "next agent",
Keybindings: parseBindings("tab"),
},
{
Name: AgentCycleReverseCommand,
Description: "previous agent",
Keybindings: parseBindings("shift+tab"),
},
{
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
Trigger: []string{"themes"},
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
Keybindings: parseBindings("<leader>i"),
Trigger: []string{"init"},
},
{
Name: InputClearCommand,
Description: "clear input",
Keybindings: parseBindings("ctrl+c"),
},
{
Name: InputPasteCommand,
Description: "paste content",
Keybindings: parseBindings("ctrl+v", "super+v"),
},
{
Name: InputSubmitCommand,
Description: "submit message",
Keybindings: parseBindings("enter"),
},
{
Name: InputNewlineCommand,
Description: "insert newline",
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
{
Name: MessagesPageUpCommand,
Description: "page up",
Keybindings: parseBindings("pgup"),
},
{
Name: MessagesPageDownCommand,
Description: "page down",
Keybindings: parseBindings("pgdown"),
},
{
Name: MessagesHalfPageUpCommand,
Description: "half page up",
Keybindings: parseBindings("ctrl+alt+u"),
},
{
Name: MessagesHalfPageDownCommand,
Description: "half page down",
Keybindings: parseBindings("ctrl+alt+d"),
},
{
Name: MessagesFirstCommand,
Description: "first message",
Keybindings: parseBindings("ctrl+g"),
},
{
Name: MessagesLastCommand,
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: MessagesCopyCommand,
Description: "copy message",
Keybindings: parseBindings("<leader>y"),
},
{
Name: MessagesUndoCommand,
Description: "undo last message",
Keybindings: parseBindings("<leader>u"),
Trigger: []string{"undo"},
},
{
Name: MessagesRedoCommand,
Description: "redo message",
Keybindings: parseBindings("<leader>r"),
Trigger: []string{"redo"},
},
{
Name: AppExitCommand,
Description: "exit the app",
Keybindings: parseBindings("ctrl+c", "<leader>q"),
Trigger: []string{"exit", "quit", "q"},
},
}
registry := make(CommandRegistry)
keybinds := map[string]string{}
marshalled, _ := json.Marshal(config.Keybinds)
json.Unmarshal(marshalled, &keybinds)
for _, command := range defaults {
// Remove share/unshare commands if sharing is disabled
if config.Share == opencode.ConfigShareDisabled &&
(command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
slog.Info("Removing share/unshare commands")
continue
}
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command
}
for _, command := range customCommands {
registry[CommandName(command.Name)] = Command{
Name: CommandName(command.Name),
Description: command.Description,
Trigger: []string{command.Name},
Keybindings: []Keybinding{},
Custom: true,
}
}
slog.Info("Loaded commands", "commands", registry)
return registry
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,957 +0,0 @@
package diff
import (
"bufio"
"bytes"
"fmt"
"image/color"
"io"
"regexp"
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/charmbracelet/x/ansi"
"github.com/sergi/go-diff/diffmatchpatch"
stylesi "github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// -------------------------------------------------------------------------
// Core Types
// -------------------------------------------------------------------------
// LineType represents the kind of line in a diff.
type LineType int
const (
LineContext LineType = iota // Line exists in both files
LineAdded // Line added in the new file
LineRemoved // Line removed from the old file
)
var (
ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
)
// Segment represents a portion of a line for intra-line highlighting
type Segment struct {
Start int
End int
Type LineType
Text string
}
// DiffLine represents a single line in a diff
type DiffLine struct {
OldLineNo int // Line number in old file (0 for added lines)
NewLineNo int // Line number in new file (0 for removed lines)
Kind LineType // Type of line (added, removed, context)
Content string // Content of the line
Segments []Segment // Segments for intraline highlighting
}
// Hunk represents a section of changes in a diff
type Hunk struct {
Header string
Lines []DiffLine
}
// DiffResult contains the parsed result of a diff
type DiffResult struct {
OldFile string
NewFile string
Hunks []Hunk
}
// linePair represents a pair of lines for side-by-side display
type linePair struct {
left *DiffLine
right *DiffLine
}
// UnifiedConfig configures the rendering of unified diffs
type UnifiedConfig struct {
Width int
}
// UnifiedOption modifies a UnifiedConfig
type UnifiedOption func(*UnifiedConfig)
// NewUnifiedConfig creates a UnifiedConfig with default values
func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{
Width: 80,
}
for _, opt := range opts {
opt(&config)
}
return config
}
// NewSideBySideConfig creates a SideBySideConfig with default values
func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
config := UnifiedConfig{
Width: 160,
}
for _, opt := range opts {
opt(&config)
}
return config
}
// WithWidth sets the width for unified view
func WithWidth(width int) UnifiedOption {
return func(u *UnifiedConfig) {
if width > 0 {
u.Width = width
}
}
}
// -------------------------------------------------------------------------
// Diff Parsing
// -------------------------------------------------------------------------
// ParseUnifiedDiff parses a unified diff format string into structured data
func ParseUnifiedDiff(diff string) (DiffResult, error) {
var result DiffResult
var currentHunk *Hunk
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
scanner := bufio.NewScanner(strings.NewReader(diff))
var oldLine, newLine int
inFileHeader := true
for scanner.Scan() {
line := scanner.Text()
if inFileHeader {
if strings.HasPrefix(line, "--- a/") {
result.OldFile = line[6:]
continue
}
if strings.HasPrefix(line, "+++ b/") {
result.NewFile = line[6:]
inFileHeader = false
continue
}
}
if strings.HasPrefix(line, "@@") {
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
currentHunk = &Hunk{
Header: line,
Lines: make([]DiffLine, 0, 10), // Pre-allocate
}
// Manual parsing of hunk header is faster than regex
parts := strings.Split(line, " ")
if len(parts) > 2 {
oldRange := strings.Split(parts[1][1:], ",")
newRange := strings.Split(parts[2][1:], ",")
oldLine, _ = strconv.Atoi(oldRange[0])
newLine, _ = strconv.Atoi(newRange[0])
}
continue
}
if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
continue
}
var dl DiffLine
dl.Content = line
if len(line) > 0 {
switch line[0] {
case '+':
dl.Kind = LineAdded
dl.NewLineNo = newLine
dl.Content = line[1:]
newLine++
case '-':
dl.Kind = LineRemoved
dl.OldLineNo = oldLine
dl.Content = line[1:]
oldLine++
default: // context line
dl.Kind = LineContext
dl.OldLineNo = oldLine
dl.NewLineNo = newLine
oldLine++
newLine++
}
} else { // empty context line
dl.Kind = LineContext
dl.OldLineNo = oldLine
dl.NewLineNo = newLine
oldLine++
newLine++
}
currentHunk.Lines = append(currentHunk.Lines, dl)
}
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
return result, scanner.Err()
}
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
func HighlightIntralineChanges(h *Hunk) {
var updated []DiffLine
dmp := diffmatchpatch.New()
for i := 0; i < len(h.Lines); i++ {
// Look for removed line followed by added line
if i+1 < len(h.Lines) &&
h.Lines[i].Kind == LineRemoved &&
h.Lines[i+1].Kind == LineAdded {
oldLine := h.Lines[i]
newLine := h.Lines[i+1]
// Find character-level differences
patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
patches = dmp.DiffCleanupSemantic(patches)
patches = dmp.DiffCleanupMerge(patches)
patches = dmp.DiffCleanupEfficiency(patches)
segments := make([]Segment, 0)
removeStart := 0
addStart := 0
for _, patch := range patches {
switch patch.Type {
case diffmatchpatch.DiffDelete:
segments = append(segments, Segment{
Start: removeStart,
End: removeStart + len(patch.Text),
Type: LineRemoved,
Text: patch.Text,
})
removeStart += len(patch.Text)
case diffmatchpatch.DiffInsert:
segments = append(segments, Segment{
Start: addStart,
End: addStart + len(patch.Text),
Type: LineAdded,
Text: patch.Text,
})
addStart += len(patch.Text)
default:
// Context text, no highlighting needed
removeStart += len(patch.Text)
addStart += len(patch.Text)
}
}
oldLine.Segments = segments
newLine.Segments = segments
updated = append(updated, oldLine, newLine)
i++ // Skip the next line as we've already processed it
} else {
updated = append(updated, h.Lines[i])
}
}
h.Lines = updated
}
// pairLines converts a flat list of diff lines to pairs for side-by-side display
func pairLines(lines []DiffLine) []linePair {
var pairs []linePair
i := 0
for i < len(lines) {
switch lines[i].Kind {
case LineRemoved:
// Check if the next line is an addition, if so pair them
if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
i += 2
} else {
pairs = append(pairs, linePair{left: &lines[i], right: nil})
i++
}
case LineAdded:
pairs = append(pairs, linePair{left: nil, right: &lines[i]})
i++
case LineContext:
pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
i++
}
}
return pairs
}
// -------------------------------------------------------------------------
// Syntax Highlighting
// -------------------------------------------------------------------------
// SyntaxHighlight applies syntax highlighting to text based on file extension
func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
t := theme.CurrentTheme()
// Determine the language lexer to use
l := lexers.Match(fileName)
if l == nil {
l = lexers.Analyse(source)
}
if l == nil {
l = lexers.Fallback
}
l = chroma.Coalesce(l)
// Get the formatter
f := formatters.Get(formatter)
if f == nil {
f = formatters.Fallback
}
// Dynamic theme based on current theme values
syntaxThemeXml := fmt.Sprintf(`
<style name="opencode-theme">
<!-- Base colors -->
<entry type="Background" style="bg:%s"/>
<entry type="Text" style="%s"/>
<entry type="Other" style="%s"/>
<entry type="Error" style="%s"/>
<!-- Keywords -->
<entry type="Keyword" style="%s"/>
<entry type="KeywordConstant" style="%s"/>
<entry type="KeywordDeclaration" style="%s"/>
<entry type="KeywordNamespace" style="%s"/>
<entry type="KeywordPseudo" style="%s"/>
<entry type="KeywordReserved" style="%s"/>
<entry type="KeywordType" style="%s"/>
<!-- Names -->
<entry type="Name" style="%s"/>
<entry type="NameAttribute" style="%s"/>
<entry type="NameBuiltin" style="%s"/>
<entry type="NameBuiltinPseudo" style="%s"/>
<entry type="NameClass" style="%s"/>
<entry type="NameConstant" style="%s"/>
<entry type="NameDecorator" style="%s"/>
<entry type="NameEntity" style="%s"/>
<entry type="NameException" style="%s"/>
<entry type="NameFunction" style="%s"/>
<entry type="NameLabel" style="%s"/>
<entry type="NameNamespace" style="%s"/>
<entry type="NameOther" style="%s"/>
<entry type="NameTag" style="%s"/>
<entry type="NameVariable" style="%s"/>
<entry type="NameVariableClass" style="%s"/>
<entry type="NameVariableGlobal" style="%s"/>
<entry type="NameVariableInstance" style="%s"/>
<!-- Literals -->
<entry type="Literal" style="%s"/>
<entry type="LiteralDate" style="%s"/>
<entry type="LiteralString" style="%s"/>
<entry type="LiteralStringBacktick" style="%s"/>
<entry type="LiteralStringChar" style="%s"/>
<entry type="LiteralStringDoc" style="%s"/>
<entry type="LiteralStringDouble" style="%s"/>
<entry type="LiteralStringEscape" style="%s"/>
<entry type="LiteralStringHeredoc" style="%s"/>
<entry type="LiteralStringInterpol" style="%s"/>
<entry type="LiteralStringOther" style="%s"/>
<entry type="LiteralStringRegex" style="%s"/>
<entry type="LiteralStringSingle" style="%s"/>
<entry type="LiteralStringSymbol" style="%s"/>
<!-- Numbers -->
<entry type="LiteralNumber" style="%s"/>
<entry type="LiteralNumberBin" style="%s"/>
<entry type="LiteralNumberFloat" style="%s"/>
<entry type="LiteralNumberHex" style="%s"/>
<entry type="LiteralNumberInteger" style="%s"/>
<entry type="LiteralNumberIntegerLong" style="%s"/>
<entry type="LiteralNumberOct" style="%s"/>
<!-- Operators -->
<entry type="Operator" style="%s"/>
<entry type="OperatorWord" style="%s"/>
<entry type="Punctuation" style="%s"/>
<!-- Comments -->
<entry type="Comment" style="%s"/>
<entry type="CommentHashbang" style="%s"/>
<entry type="CommentMultiline" style="%s"/>
<entry type="CommentSingle" style="%s"/>
<entry type="CommentSpecial" style="%s"/>
<entry type="CommentPreproc" style="%s"/>
<!-- Generic styles -->
<entry type="Generic" style="%s"/>
<entry type="GenericDeleted" style="%s"/>
<entry type="GenericEmph" style="italic %s"/>
<entry type="GenericError" style="%s"/>
<entry type="GenericHeading" style="bold %s"/>
<entry type="GenericInserted" style="%s"/>
<entry type="GenericOutput" style="%s"/>
<entry type="GenericPrompt" style="%s"/>
<entry type="GenericStrong" style="bold %s"/>
<entry type="GenericSubheading" style="bold %s"/>
<entry type="GenericTraceback" style="%s"/>
<entry type="GenericUnderline" style="underline"/>
<entry type="TextWhitespace" style="%s"/>
</style>
`,
getChromaColor(t.BackgroundPanel()), // Background
getChromaColor(t.Text()), // Text
getChromaColor(t.Text()), // Other
getChromaColor(t.Error()), // Error
getChromaColor(t.SyntaxKeyword()), // Keyword
getChromaColor(t.SyntaxKeyword()), // KeywordConstant
getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration
getChromaColor(t.SyntaxKeyword()), // KeywordNamespace
getChromaColor(t.SyntaxKeyword()), // KeywordPseudo
getChromaColor(t.SyntaxKeyword()), // KeywordReserved
getChromaColor(t.SyntaxType()), // KeywordType
getChromaColor(t.Text()), // Name
getChromaColor(t.SyntaxVariable()), // NameAttribute
getChromaColor(t.SyntaxType()), // NameBuiltin
getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo
getChromaColor(t.SyntaxType()), // NameClass
getChromaColor(t.SyntaxVariable()), // NameConstant
getChromaColor(t.SyntaxFunction()), // NameDecorator
getChromaColor(t.SyntaxVariable()), // NameEntity
getChromaColor(t.SyntaxType()), // NameException
getChromaColor(t.SyntaxFunction()), // NameFunction
getChromaColor(t.Text()), // NameLabel
getChromaColor(t.SyntaxType()), // NameNamespace
getChromaColor(t.SyntaxVariable()), // NameOther
getChromaColor(t.SyntaxKeyword()), // NameTag
getChromaColor(t.SyntaxVariable()), // NameVariable
getChromaColor(t.SyntaxVariable()), // NameVariableClass
getChromaColor(t.SyntaxVariable()), // NameVariableGlobal
getChromaColor(t.SyntaxVariable()), // NameVariableInstance
getChromaColor(t.SyntaxString()), // Literal
getChromaColor(t.SyntaxString()), // LiteralDate
getChromaColor(t.SyntaxString()), // LiteralString
getChromaColor(t.SyntaxString()), // LiteralStringBacktick
getChromaColor(t.SyntaxString()), // LiteralStringChar
getChromaColor(t.SyntaxString()), // LiteralStringDoc
getChromaColor(t.SyntaxString()), // LiteralStringDouble
getChromaColor(t.SyntaxString()), // LiteralStringEscape
getChromaColor(t.SyntaxString()), // LiteralStringHeredoc
getChromaColor(t.SyntaxString()), // LiteralStringInterpol
getChromaColor(t.SyntaxString()), // LiteralStringOther
getChromaColor(t.SyntaxString()), // LiteralStringRegex
getChromaColor(t.SyntaxString()), // LiteralStringSingle
getChromaColor(t.SyntaxString()), // LiteralStringSymbol
getChromaColor(t.SyntaxNumber()), // LiteralNumber
getChromaColor(t.SyntaxNumber()), // LiteralNumberBin
getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat
getChromaColor(t.SyntaxNumber()), // LiteralNumberHex
getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger
getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
getChromaColor(t.SyntaxNumber()), // LiteralNumberOct
getChromaColor(t.SyntaxOperator()), // Operator
getChromaColor(t.SyntaxKeyword()), // OperatorWord
getChromaColor(t.SyntaxPunctuation()), // Punctuation
getChromaColor(t.SyntaxComment()), // Comment
getChromaColor(t.SyntaxComment()), // CommentHashbang
getChromaColor(t.SyntaxComment()), // CommentMultiline
getChromaColor(t.SyntaxComment()), // CommentSingle
getChromaColor(t.SyntaxComment()), // CommentSpecial
getChromaColor(t.SyntaxKeyword()), // CommentPreproc
getChromaColor(t.Text()), // Generic
getChromaColor(t.Error()), // GenericDeleted
getChromaColor(t.Text()), // GenericEmph
getChromaColor(t.Error()), // GenericError
getChromaColor(t.Text()), // GenericHeading
getChromaColor(t.Success()), // GenericInserted
getChromaColor(t.TextMuted()), // GenericOutput
getChromaColor(t.Text()), // GenericPrompt
getChromaColor(t.Text()), // GenericStrong
getChromaColor(t.Text()), // GenericSubheading
getChromaColor(t.Error()), // GenericTraceback
getChromaColor(t.Text()), // TextWhitespace
)
r := strings.NewReader(syntaxThemeXml)
style := chroma.MustNewXMLStyle(r)
// Modify the style to use the provided background
s, err := style.Builder().Transform(
func(t chroma.StyleEntry) chroma.StyleEntry {
if _, ok := bg.(lipgloss.NoColor); ok {
return t
}
r, g, b, _ := bg.RGBA()
t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
return t
},
).Build()
if err != nil {
s = styles.Fallback
}
// Tokenize and format
it, err := l.Tokenise(nil, source)
if err != nil {
return err
}
return f.Format(w, s, it)
}
// getColor returns the appropriate hex color string based on terminal background
func getColor(adaptiveColor compat.AdaptiveColor) *string {
return stylesi.AdaptiveColorToString(adaptiveColor)
}
func getChromaColor(adaptiveColor compat.AdaptiveColor) string {
color := stylesi.AdaptiveColorToString(adaptiveColor)
if color == nil {
return ""
}
return *color
}
// highlightLine applies syntax highlighting to a single line
func highlightLine(fileName string, line string, bg color.Color) string {
var buf bytes.Buffer
err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
if err != nil {
return line
}
return buf.String()
}
// createStyles generates the lipgloss styles needed for rendering diffs
func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) {
removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg())
addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg())
contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg())
lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber())
return
}
// -------------------------------------------------------------------------
// Rendering Functions
// -------------------------------------------------------------------------
// applyHighlighting applies intra-line highlighting to a piece of text
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string {
// Find all ANSI sequences in the content
ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
// Build a mapping of visible character positions to their actual indices
visibleIdx := 0
ansiSequences := make(map[int]string)
lastAnsiSeq := "\x1b[0m" // Default reset sequence
for i := 0; i < len(content); {
isAnsi := false
for _, match := range ansiMatches {
if match[0] == i {
ansiSequences[visibleIdx] = content[match[0]:match[1]]
lastAnsiSeq = content[match[0]:match[1]]
i = match[1]
isAnsi = true
break
}
}
if isAnsi {
continue
}
// For non-ANSI positions, store the last ANSI sequence
if _, exists := ansiSequences[visibleIdx]; !exists {
ansiSequences[visibleIdx] = lastAnsiSeq
}
visibleIdx++
// Properly advance by UTF-8 rune, not byte
_, size := utf8.DecodeRuneInString(content[i:])
i += size
}
// Apply highlighting
var sb strings.Builder
inSelection := false
currentPos := 0
// Get the appropriate color based on terminal background
bg := getColor(highlightBg)
fg := getColor(theme.CurrentTheme().BackgroundPanel())
var bgColor color.Color
var fgColor color.Color
if bg != nil {
bgColor = lipgloss.Color(*bg)
}
if fg != nil {
fgColor = lipgloss.Color(*fg)
}
for i := 0; i < len(content); {
// Check if we're at an ANSI sequence
isAnsi := false
for _, match := range ansiMatches {
if match[0] == i {
sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
i = match[1]
isAnsi = true
break
}
}
if isAnsi {
continue
}
// Check for segment boundaries
for _, seg := range segments {
if seg.Type == segmentType {
if currentPos == seg.Start {
inSelection = true
}
if currentPos == seg.End {
inSelection = false
}
}
}
// Get current character (properly handle UTF-8)
r, size := utf8.DecodeRuneInString(content[i:])
char := string(r)
if inSelection {
// Get the current styling
currentStyle := ansiSequences[currentPos]
// Apply foreground and background highlight
if fgColor != nil {
sb.WriteString("\x1b[38;2;")
r, g, b, _ := fgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
} else {
sb.WriteString("\x1b[49m")
}
if bgColor != nil {
sb.WriteString("\x1b[48;2;")
r, g, b, _ := bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
} else {
sb.WriteString("\x1b[39m")
}
sb.WriteString(char)
// Full reset of all attributes to ensure clean state
sb.WriteString("\x1b[0m")
// Reapply the original ANSI sequence
sb.WriteString(currentStyle)
} else {
// Not in selection, just copy the character
sb.WriteString(char)
}
currentPos++
i += size
}
return sb.String()
}
// renderLinePrefix renders the line number and marker prefix for a diff line
func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string {
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker)
case LineAdded:
styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker)
case LineContext:
styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker)
default:
styledMarker = marker
}
return lineNumberStyle.Render(lineNum + " " + styledMarker)
}
// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string {
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Apply intra-line highlighting if needed
if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) {
content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor)
}
// Add a padding space for added/removed lines
if dl.Kind == LineRemoved || dl.Kind == LineAdded {
content = bgStyle.Render(" ") + content
}
// Create the final line and truncate if needed
return bgStyle.MaxHeight(1).Width(width).Render(
ansi.Truncate(
content,
width,
"...",
),
)
}
// renderUnifiedLine renders a single line in unified diff format
func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string {
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style and marker based on line type
var marker string
var bgStyle stylesi.Style
var lineNum string
var highlightColor compat.AdaptiveColor
switch dl.Kind {
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
} else {
lineNum = " "
}
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
highlightColor = t.DiffHighlightAdded() // TODO: handle "none"
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
} else {
lineNum = " "
}
case LineContext:
marker = " "
bgStyle = contextLineStyle
if dl.OldLineNo > 0 && dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo)
} else {
lineNum = " "
}
}
// Create the line prefix
prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t)
// Render the content
prefixWidth := ansi.StringWidth(prefix)
contentWidth := width - prefixWidth
content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth)
return prefix + content
}
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
func renderDiffColumnLine(
fileName string,
dl *DiffLine,
colWidth int,
isLeftColumn bool,
t theme.Theme,
) string {
if dl == nil {
contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style based on line type and column
var marker string
var bgStyle stylesi.Style
var lineNum string
var highlightColor compat.AdaptiveColor
if isLeftColumn {
// Left column logic
switch dl.Kind {
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for left column
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
}
} else {
// Right column logic
switch dl.Kind {
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
highlightColor = t.DiffHighlightAdded()
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for right column
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
}
}
// Create the line prefix
prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
// Determine if we should render content
shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
(dl.Kind == LineAdded && !isLeftColumn) ||
dl.Kind == LineContext
if !shouldRenderContent {
return bgStyle.Width(colWidth).Render("")
}
// Render the content
prefixWidth := ansi.StringWidth(prefix)
contentWidth := colWidth - prefixWidth
content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth)
return prefix + content
}
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
}
// renderRightColumn formats the right side of a side-by-side diff
func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
// RenderUnifiedHunk formats a hunk for unified display
func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
// Apply options to create the configuration
config := NewUnifiedConfig(opts...)
// Make a copy of the hunk so we don't modify the original
hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
copy(hunkCopy.Lines, h.Lines)
// Highlight changes within lines
HighlightIntralineChanges(&hunkCopy)
var sb strings.Builder
sb.Grow(len(hunkCopy.Lines) * config.Width)
util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
})
return sb.String()
}
// RenderSideBySideHunk formats a hunk for side-by-side display
func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
// Apply options to create the configuration
config := NewSideBySideConfig(opts...)
// Make a copy of the hunk so we don't modify the original
hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
copy(hunkCopy.Lines, h.Lines)
// Highlight changes within lines
HighlightIntralineChanges(&hunkCopy)
// Pair lines for side-by-side display
pairs := pairLines(hunkCopy.Lines)
// Calculate column width
colWidth := config.Width / 2
leftWidth := colWidth
rightWidth := config.Width - colWidth
var sb strings.Builder
util.WriteStringsPar(&sb, pairs, func(p linePair) string {
wg := &sync.WaitGroup{}
var leftStr, rightStr string
wg.Add(2)
go func() {
defer wg.Done()
leftStr = renderLeftColumn(fileName, p.left, leftWidth)
}()
go func() {
defer wg.Done()
rightStr = renderRightColumn(fileName, p.right, rightWidth)
}()
wg.Wait()
return leftStr + rightStr + "\n"
})
return sb.String()
}
// FormatUnifiedDiff creates a unified formatted view of a diff
func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
return RenderUnifiedHunk(filename, h, opts...)
})
return sb.String(), nil
}
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
return RenderSideBySideHunk(filename, h, opts...)
})
return sb.String(), nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More