mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-20 17:24:22 +01:00
DELETE GO BUBBLETEA CRAP HOORAY
This commit is contained in:
@@ -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 = {
|
||||||
|
|||||||
4
packages/tui/.gitignore
vendored
4
packages/tui/.gitignore
vendored
@@ -1,4 +0,0 @@
|
|||||||
opencode-test
|
|
||||||
cmd/opencode/opencode
|
|
||||||
opencode
|
|
||||||
|
|
||||||
@@ -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:"
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
//nolint:unused,revive,nolintlint
|
|
||||||
package input
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/muesli/cancelreader"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Logger is a simple logger interface.
|
|
||||||
type Logger interface {
|
|
||||||
Printf(format string, v ...any)
|
|
||||||
}
|
|
||||||
|
|
||||||
// win32InputState is a state machine for parsing key events from the Windows
|
|
||||||
// Console API into escape sequences and utf8 runes, and keeps track of the last
|
|
||||||
// control key state to determine modifier key changes. It also keeps track of
|
|
||||||
// the last mouse button state and window size changes to determine which mouse
|
|
||||||
// buttons were released and to prevent multiple size events from firing.
|
|
||||||
type win32InputState struct {
|
|
||||||
ansiBuf [256]byte
|
|
||||||
ansiIdx int
|
|
||||||
utf16Buf [2]rune
|
|
||||||
utf16Half bool
|
|
||||||
lastCks uint32 // the last control key state for the previous event
|
|
||||||
lastMouseBtns uint32 // the last mouse button state for the previous event
|
|
||||||
lastWinsizeX, lastWinsizeY int16 // the last window size for the previous event to prevent multiple size events from firing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reader represents an input event reader. It reads input events and parses
|
|
||||||
// escape sequences from the terminal input buffer and translates them into
|
|
||||||
// human‑readable events.
|
|
||||||
type Reader struct {
|
|
||||||
rd cancelreader.CancelReader
|
|
||||||
table map[string]Key // table is a lookup table for key sequences.
|
|
||||||
term string // $TERM
|
|
||||||
paste []byte // bracketed paste buffer; nil when disabled
|
|
||||||
buf [256]byte // read buffer
|
|
||||||
partialSeq []byte // holds incomplete escape sequences
|
|
||||||
keyState win32InputState
|
|
||||||
parser Parser
|
|
||||||
logger Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewReader returns a new input event reader.
|
|
||||||
func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
|
|
||||||
d := new(Reader)
|
|
||||||
cr, err := newCancelreader(r, flags)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
d.rd = cr
|
|
||||||
d.table = buildKeysTable(flags, termType)
|
|
||||||
d.term = termType
|
|
||||||
d.parser.flags = flags
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLogger sets a logger for the reader.
|
|
||||||
func (d *Reader) SetLogger(l Logger) { d.logger = l }
|
|
||||||
|
|
||||||
// Read implements io.Reader.
|
|
||||||
func (d *Reader) Read(p []byte) (int, error) { return d.rd.Read(p) }
|
|
||||||
|
|
||||||
// Cancel cancels the underlying reader.
|
|
||||||
func (d *Reader) Cancel() bool { return d.rd.Cancel() }
|
|
||||||
|
|
||||||
// Close closes the underlying reader.
|
|
||||||
func (d *Reader) Close() error { return d.rd.Close() }
|
|
||||||
|
|
||||||
func (d *Reader) readEvents() ([]Event, error) {
|
|
||||||
nb, err := d.rd.Read(d.buf[:])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var events []Event
|
|
||||||
|
|
||||||
// Combine any partial sequence from previous read with new data.
|
|
||||||
var buf []byte
|
|
||||||
if len(d.partialSeq) > 0 {
|
|
||||||
buf = make([]byte, len(d.partialSeq)+nb)
|
|
||||||
copy(buf, d.partialSeq)
|
|
||||||
copy(buf[len(d.partialSeq):], d.buf[:nb])
|
|
||||||
d.partialSeq = nil
|
|
||||||
} else {
|
|
||||||
buf = d.buf[:nb]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast path: direct lookup for simple escape sequences.
|
|
||||||
if bytes.HasPrefix(buf, []byte{0x1b}) {
|
|
||||||
if k, ok := d.table[string(buf)]; ok {
|
|
||||||
if d.logger != nil {
|
|
||||||
d.logger.Printf("input: %q", buf)
|
|
||||||
}
|
|
||||||
events = append(events, KeyPressEvent(k))
|
|
||||||
return events, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var i int
|
|
||||||
for i < len(buf) {
|
|
||||||
consumed, ev := d.parser.parseSequence(buf[i:])
|
|
||||||
if d.logger != nil && consumed > 0 {
|
|
||||||
d.logger.Printf("input: %q", buf[i:i+consumed])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incomplete sequence – store remainder and exit.
|
|
||||||
if consumed == 0 && ev == nil {
|
|
||||||
rem := len(buf) - i
|
|
||||||
if rem > 0 {
|
|
||||||
d.partialSeq = make([]byte, rem)
|
|
||||||
copy(d.partialSeq, buf[i:])
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle bracketed paste specially so we don’t emit a paste event for
|
|
||||||
// every byte.
|
|
||||||
if d.paste != nil {
|
|
||||||
if _, ok := ev.(PasteEndEvent); !ok {
|
|
||||||
d.paste = append(d.paste, buf[i])
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ev.(type) {
|
|
||||||
case PasteStartEvent:
|
|
||||||
d.paste = []byte{}
|
|
||||||
case PasteEndEvent:
|
|
||||||
var paste []rune
|
|
||||||
for len(d.paste) > 0 {
|
|
||||||
r, w := utf8.DecodeRune(d.paste)
|
|
||||||
if r != utf8.RuneError {
|
|
||||||
paste = append(paste, r)
|
|
||||||
}
|
|
||||||
d.paste = d.paste[w:]
|
|
||||||
}
|
|
||||||
d.paste = nil
|
|
||||||
events = append(events, PasteEvent(paste))
|
|
||||||
case nil:
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if mevs, ok := ev.(MultiEvent); ok {
|
|
||||||
events = append(events, []Event(mevs)...)
|
|
||||||
} else {
|
|
||||||
events = append(events, ev)
|
|
||||||
}
|
|
||||||
i += consumed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse bursts of wheel/motion events into a single event each.
|
|
||||||
events = coalesceMouseEvents(events)
|
|
||||||
return events, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// coalesceMouseEvents reduces the volume of MouseWheelEvent and MouseMotionEvent
|
|
||||||
// objects that arrive in rapid succession by keeping only the most recent
|
|
||||||
// event in each contiguous run.
|
|
||||||
func coalesceMouseEvents(in []Event) []Event {
|
|
||||||
if len(in) < 2 {
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]Event, 0, len(in))
|
|
||||||
for _, ev := range in {
|
|
||||||
switch ev.(type) {
|
|
||||||
case MouseWheelEvent:
|
|
||||||
if len(out) > 0 {
|
|
||||||
if _, ok := out[len(out)-1].(MouseWheelEvent); ok {
|
|
||||||
out[len(out)-1] = ev // replace previous wheel event
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case MouseMotionEvent:
|
|
||||||
if len(out) > 0 {
|
|
||||||
if _, ok := out[len(out)-1].(MouseMotionEvent); ok {
|
|
||||||
out[len(out)-1] = ev // replace previous motion event
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, ev)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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{}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
}
|
|
||||||
@@ -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:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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{}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() {
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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("")
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user