Browse Source

DELETE GO BUBBLETEA CRAP HOORAY

Dax Raad 3 months ago
parent
commit
f68374ad22
100 changed files with 23 additions and 19441 deletions
  1. 23 23
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  2. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/aura.json
  3. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/ayu.json
  4. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json
  5. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/cobalt2.json
  6. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/dracula.json
  7. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/everforest.json
  8. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/github.json
  9. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/gruvbox.json
  10. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/kanagawa.json
  11. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/material.json
  12. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/matrix.json
  13. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/monokai.json
  14. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json
  15. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/nord.json
  16. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/one-dark.json
  17. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/opencode.json
  18. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/palenight.json
  19. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/rosepine.json
  20. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/solarized.json
  21. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/synthwave84.json
  22. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/tokyonight.json
  23. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/vesper.json
  24. 0 0
      packages/opencode/src/cli/cmd/tui/context/theme/zenburn.json
  25. 0 4
      packages/tui/.gitignore
  26. 0 77
      packages/tui/.goreleaser.yml
  27. 0 175
      packages/tui/cmd/opencode/main.go
  28. 0 99
      packages/tui/go.mod
  29. 0 313
      packages/tui/go.sum
  30. 0 14
      packages/tui/input/cancelreader_other.go
  31. 0 143
      packages/tui/input/cancelreader_windows.go
  32. 0 25
      packages/tui/input/clipboard.go
  33. 0 136
      packages/tui/input/color.go
  34. 0 7
      packages/tui/input/cursor.go
  35. 0 18
      packages/tui/input/da1.go
  36. 0 6
      packages/tui/input/doc.go
  37. 0 192
      packages/tui/input/driver.go
  38. 0 17
      packages/tui/input/driver_other.go
  39. 0 25
      packages/tui/input/driver_test.go
  40. 0 642
      packages/tui/input/driver_windows.go
  41. 0 271
      packages/tui/input/driver_windows_test.go
  42. 0 9
      packages/tui/input/focus.go
  43. 0 27
      packages/tui/input/focus_test.go
  44. 0 18
      packages/tui/input/go.mod
  45. 0 19
      packages/tui/input/go.sum
  46. 0 45
      packages/tui/input/input.go
  47. 0 574
      packages/tui/input/key.go
  48. 0 880
      packages/tui/input/key_test.go
  49. 0 353
      packages/tui/input/kitty.go
  50. 0 37
      packages/tui/input/mod.go
  51. 0 14
      packages/tui/input/mode.go
  52. 0 292
      packages/tui/input/mouse.go
  53. 0 481
      packages/tui/input/mouse_test.go
  54. 0 1030
      packages/tui/input/parse.go
  55. 0 47
      packages/tui/input/parse_test.go
  56. 0 13
      packages/tui/input/paste.go
  57. 0 389
      packages/tui/input/table.go
  58. 0 54
      packages/tui/input/termcap.go
  59. 0 277
      packages/tui/input/terminfo.go
  60. 0 47
      packages/tui/input/xterm.go
  61. 0 41
      packages/tui/internal/api/api.go
  62. 0 963
      packages/tui/internal/app/app.go
  63. 0 304
      packages/tui/internal/app/app_test.go
  64. 0 283
      packages/tui/internal/app/prompt.go
  65. 0 174
      packages/tui/internal/app/state.go
  66. 0 178
      packages/tui/internal/attachment/attachment.go
  67. 0 155
      packages/tui/internal/clipboard/clipboard.go
  68. 0 266
      packages/tui/internal/clipboard/clipboard_darwin.go
  69. 0 311
      packages/tui/internal/clipboard/clipboard_linux.go
  70. 0 25
      packages/tui/internal/clipboard/clipboard_nocgo.go
  71. 0 551
      packages/tui/internal/clipboard/clipboard_windows.go
  72. 0 423
      packages/tui/internal/commands/command.go
  73. 0 75
      packages/tui/internal/completions/agents.go
  74. 0 144
      packages/tui/internal/completions/commands.go
  75. 0 126
      packages/tui/internal/completions/files.go
  76. 0 8
      packages/tui/internal/completions/provider.go
  77. 0 24
      packages/tui/internal/completions/suggestion.go
  78. 0 119
      packages/tui/internal/completions/symbols.go
  79. 0 62
      packages/tui/internal/components/chat/cache.go
  80. 0 906
      packages/tui/internal/components/chat/editor.go
  81. 0 1031
      packages/tui/internal/components/chat/message.go
  82. 0 1322
      packages/tui/internal/components/chat/messages.go
  83. 0 247
      packages/tui/internal/components/commands/commands.go
  84. 0 452
      packages/tui/internal/components/dialog/agents.go
  85. 0 314
      packages/tui/internal/components/dialog/complete.go
  86. 0 80
      packages/tui/internal/components/dialog/help.go
  87. 0 458
      packages/tui/internal/components/dialog/models.go
  88. 0 255
      packages/tui/internal/components/dialog/search.go
  89. 0 400
      packages/tui/internal/components/dialog/session.go
  90. 0 132
      packages/tui/internal/components/dialog/theme.go
  91. 0 353
      packages/tui/internal/components/dialog/timeline.go
  92. 0 957
      packages/tui/internal/components/diff/diff.go
  93. 0 58
      packages/tui/internal/components/diff/parse.go
  94. 0 436
      packages/tui/internal/components/list/list.go
  95. 0 249
      packages/tui/internal/components/list/list_test.go
  96. 0 145
      packages/tui/internal/components/modal/modal.go
  97. 0 56
      packages/tui/internal/components/qr/qr.go
  98. 0 340
      packages/tui/internal/components/status/status.go
  99. 0 100
      packages/tui/internal/components/status/status_test.go
  100. 0 125
      packages/tui/internal/components/textarea/memoization.go

+ 23 - 23
packages/opencode/src/cli/cmd/tui/context/theme.tsx

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

+ 0 - 0
packages/tui/internal/theme/themes/aura.json → packages/opencode/src/cli/cmd/tui/context/theme/aura.json


+ 0 - 0
packages/tui/internal/theme/themes/ayu.json → packages/opencode/src/cli/cmd/tui/context/theme/ayu.json


+ 0 - 0
packages/tui/internal/theme/themes/catppuccin.json → packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json


+ 0 - 0
packages/tui/internal/theme/themes/cobalt2.json → packages/opencode/src/cli/cmd/tui/context/theme/cobalt2.json


+ 0 - 0
packages/tui/internal/theme/themes/dracula.json → packages/opencode/src/cli/cmd/tui/context/theme/dracula.json


+ 0 - 0
packages/tui/internal/theme/themes/everforest.json → packages/opencode/src/cli/cmd/tui/context/theme/everforest.json


+ 0 - 0
packages/tui/internal/theme/themes/github.json → packages/opencode/src/cli/cmd/tui/context/theme/github.json


+ 0 - 0
packages/tui/internal/theme/themes/gruvbox.json → packages/opencode/src/cli/cmd/tui/context/theme/gruvbox.json


+ 0 - 0
packages/tui/internal/theme/themes/kanagawa.json → packages/opencode/src/cli/cmd/tui/context/theme/kanagawa.json


+ 0 - 0
packages/tui/internal/theme/themes/material.json → packages/opencode/src/cli/cmd/tui/context/theme/material.json


+ 0 - 0
packages/tui/internal/theme/themes/matrix.json → packages/opencode/src/cli/cmd/tui/context/theme/matrix.json


+ 0 - 0
packages/tui/internal/theme/themes/monokai.json → packages/opencode/src/cli/cmd/tui/context/theme/monokai.json


+ 0 - 0
packages/tui/internal/theme/themes/nightowl.json → packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json


+ 0 - 0
packages/tui/internal/theme/themes/nord.json → packages/opencode/src/cli/cmd/tui/context/theme/nord.json


+ 0 - 0
packages/tui/internal/theme/themes/one-dark.json → packages/opencode/src/cli/cmd/tui/context/theme/one-dark.json


+ 0 - 0
packages/tui/internal/theme/themes/opencode.json → packages/opencode/src/cli/cmd/tui/context/theme/opencode.json


+ 0 - 0
packages/tui/internal/theme/themes/palenight.json → packages/opencode/src/cli/cmd/tui/context/theme/palenight.json


+ 0 - 0
packages/tui/internal/theme/themes/rosepine.json → packages/opencode/src/cli/cmd/tui/context/theme/rosepine.json


+ 0 - 0
packages/tui/internal/theme/themes/solarized.json → packages/opencode/src/cli/cmd/tui/context/theme/solarized.json


+ 0 - 0
packages/tui/internal/theme/themes/synthwave84.json → packages/opencode/src/cli/cmd/tui/context/theme/synthwave84.json


+ 0 - 0
packages/tui/internal/theme/themes/tokyonight.json → packages/opencode/src/cli/cmd/tui/context/theme/tokyonight.json


+ 0 - 0
packages/tui/internal/theme/themes/vesper.json → packages/opencode/src/cli/cmd/tui/context/theme/vesper.json


+ 0 - 0
packages/tui/internal/theme/themes/zenburn.json → packages/opencode/src/cli/cmd/tui/context/theme/zenburn.json


+ 0 - 4
packages/tui/.gitignore

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

+ 0 - 77
packages/tui/.goreleaser.yml

@@ -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://[email protected]/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:"

+ 0 - 175
packages/tui/cmd/opencode/main.go

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

+ 0 - 99
packages/tui/go.mod

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

+ 0 - 313
packages/tui/go.sum

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

+ 0 - 14
packages/tui/input/cancelreader_other.go

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

+ 0 - 143
packages/tui/input/cancelreader_windows.go

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

+ 0 - 25
packages/tui/input/clipboard.go

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

+ 0 - 136
packages/tui/input/color.go

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

+ 0 - 7
packages/tui/input/cursor.go

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

+ 0 - 18
packages/tui/input/da1.go

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

+ 0 - 6
packages/tui/input/doc.go

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

+ 0 - 192
packages/tui/input/driver.go

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

+ 0 - 17
packages/tui/input/driver_other.go

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

+ 0 - 25
packages/tui/input/driver_test.go

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

+ 0 - 642
packages/tui/input/driver_windows.go

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

+ 0 - 271
packages/tui/input/driver_windows_test.go

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

+ 0 - 9
packages/tui/input/focus.go

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

+ 0 - 27
packages/tui/input/focus_test.go

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

+ 0 - 18
packages/tui/input/go.mod

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

+ 0 - 19
packages/tui/input/go.sum

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

+ 0 - 45
packages/tui/input/input.go

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

+ 0 - 574
packages/tui/input/key.go

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

+ 0 - 880
packages/tui/input/key_test.go

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

+ 0 - 353
packages/tui/input/kitty.go

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

+ 0 - 37
packages/tui/input/mod.go

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

+ 0 - 14
packages/tui/input/mode.go

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

+ 0 - 292
packages/tui/input/mouse.go

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

+ 0 - 481
packages/tui/input/mouse_test.go

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

+ 0 - 1030
packages/tui/input/parse.go

@@ -1,1030 +0,0 @@
-package input
-
-import (
-	"bytes"
-	"encoding/base64"
-	"slices"
-	"strings"
-	"unicode"
-	"unicode/utf8"
-
-	"github.com/charmbracelet/x/ansi"
-	"github.com/charmbracelet/x/ansi/parser"
-	"github.com/rivo/uniseg"
-)
-
-// Flags to control the behavior of the parser.
-const (
-	// When this flag is set, the driver will treat both Ctrl+Space and Ctrl+@
-	// as the same key sequence.
-	//
-	// Historically, the ANSI specs generate NUL (0x00) on both the Ctrl+Space
-	// and Ctrl+@ key sequences. This flag allows the driver to treat both as
-	// the same key sequence.
-	FlagCtrlAt = 1 << iota
-
-	// When this flag is set, the driver will treat the Tab key and Ctrl+I as
-	// the same key sequence.
-	//
-	// Historically, the ANSI specs generate HT (0x09) on both the Tab key and
-	// Ctrl+I. This flag allows the driver to treat both as the same key
-	// sequence.
-	FlagCtrlI
-
-	// When this flag is set, the driver will treat the Enter key and Ctrl+M as
-	// the same key sequence.
-	//
-	// Historically, the ANSI specs generate CR (0x0D) on both the Enter key
-	// and Ctrl+M. This flag allows the driver to treat both as the same key.
-	FlagCtrlM
-
-	// When this flag is set, the driver will treat Escape and Ctrl+[ as
-	// the same key sequence.
-	//
-	// Historically, the ANSI specs generate ESC (0x1B) on both the Escape key
-	// and Ctrl+[. This flag allows the driver to treat both as the same key
-	// sequence.
-	FlagCtrlOpenBracket
-
-	// When this flag is set, the driver will send a BS (0x08 byte) character
-	// instead of a DEL (0x7F byte) character when the Backspace key is
-	// pressed.
-	//
-	// The VT100 terminal has both a Backspace and a Delete key. The VT220
-	// terminal dropped the Backspace key and replaced it with the Delete key.
-	// Both terminals send a DEL character when the Delete key is pressed.
-	// Modern terminals and PCs later readded the Delete key but used a
-	// different key sequence, and the Backspace key was standardized to send a
-	// DEL character.
-	FlagBackspace
-
-	// When this flag is set, the driver will recognize the Find key instead of
-	// treating it as a Home key.
-	//
-	// The Find key was part of the VT220 keyboard, and is no longer used in
-	// modern day PCs.
-	FlagFind
-
-	// When this flag is set, the driver will recognize the Select key instead
-	// of treating it as a End key.
-	//
-	// The Symbol key was part of the VT220 keyboard, and is no longer used in
-	// modern day PCs.
-	FlagSelect
-
-	// When this flag is set, the driver will use Terminfo databases to
-	// overwrite the default key sequences.
-	FlagTerminfo
-
-	// When this flag is set, the driver will preserve function keys (F13-F63)
-	// as symbols.
-	//
-	// Since these keys are not part of today's standard 20th century keyboard,
-	// we treat them as F1-F12 modifier keys i.e. ctrl/shift/alt + Fn combos.
-	// Key definitions come from Terminfo, this flag is only useful when
-	// FlagTerminfo is not set.
-	FlagFKeys
-
-	// When this flag is set, the driver will enable mouse mode on Windows.
-	// This is only useful on Windows and has no effect on other platforms.
-	FlagMouseMode
-)
-
-// Parser is a parser for input escape sequences.
-type Parser struct {
-	flags int
-}
-
-// NewParser returns a new input parser. This is a low-level parser that parses
-// escape sequences into human-readable events.
-// This differs from [ansi.Parser] and [ansi.DecodeSequence] in which it
-// recognizes incorrect sequences that some terminals may send.
-//
-// For instance, the X10 mouse protocol sends a `CSI M` sequence followed by 3
-// bytes. If the parser doesn't recognize the 3 bytes, they might be echoed to
-// the terminal output causing a mess.
-//
-// Another example is how URxvt sends invalid sequences for modified keys using
-// invalid CSI final characters like '$'.
-//
-// Use flags to control the behavior of ambiguous key sequences.
-func NewParser(flags int) *Parser {
-	return &Parser{flags: flags}
-}
-
-// parseSequence finds the first recognized event sequence and returns it along
-// with its length.
-//
-// It will return zero and nil no sequence is recognized or when the buffer is
-// empty. If a sequence is not supported, an UnknownEvent is returned.
-func (p *Parser) parseSequence(buf []byte) (n int, Event Event) {
-	if len(buf) == 0 {
-		return 0, nil
-	}
-
-	switch b := buf[0]; b {
-	case ansi.ESC:
-		if len(buf) == 1 {
-			// Escape key
-			return 1, KeyPressEvent{Code: KeyEscape}
-		}
-
-		switch bPrime := buf[1]; bPrime {
-		case 'O': // Esc-prefixed SS3
-			return p.parseSs3(buf)
-		case 'P': // Esc-prefixed DCS
-			return p.parseDcs(buf)
-		case '[': // Esc-prefixed CSI
-			return p.parseCsi(buf)
-		case ']': // Esc-prefixed OSC
-			return p.parseOsc(buf)
-		case '_': // Esc-prefixed APC
-			return p.parseApc(buf)
-		case '^': // Esc-prefixed PM
-			return p.parseStTerminated(ansi.PM, '^', nil)(buf)
-		case 'X': // Esc-prefixed SOS
-			return p.parseStTerminated(ansi.SOS, 'X', nil)(buf)
-		default:
-			n, e := p.parseSequence(buf[1:])
-			if k, ok := e.(KeyPressEvent); ok {
-				k.Text = ""
-				k.Mod |= ModAlt
-				return n + 1, k
-			}
-
-			// Not a key sequence, nor an alt modified key sequence. In that
-			// case, just report a single escape key.
-			return 1, KeyPressEvent{Code: KeyEscape}
-		}
-	case ansi.SS3:
-		return p.parseSs3(buf)
-	case ansi.DCS:
-		return p.parseDcs(buf)
-	case ansi.CSI:
-		return p.parseCsi(buf)
-	case ansi.OSC:
-		return p.parseOsc(buf)
-	case ansi.APC:
-		return p.parseApc(buf)
-	case ansi.PM:
-		return p.parseStTerminated(ansi.PM, '^', nil)(buf)
-	case ansi.SOS:
-		return p.parseStTerminated(ansi.SOS, 'X', nil)(buf)
-	default:
-		if b <= ansi.US || b == ansi.DEL || b == ansi.SP {
-			return 1, p.parseControl(b)
-		} else if b >= ansi.PAD && b <= ansi.APC {
-			// C1 control code
-			// UTF-8 never starts with a C1 control code
-			// Encode these as Ctrl+Alt+<code - 0x40>
-			code := rune(b) - 0x40
-			return 1, KeyPressEvent{Code: code, Mod: ModCtrl | ModAlt}
-		}
-		return p.parseUtf8(buf)
-	}
-}
-
-func (p *Parser) parseCsi(b []byte) (int, Event) {
-	if len(b) == 2 && b[0] == ansi.ESC {
-		// short cut if this is an alt+[ key
-		return 2, KeyPressEvent{Text: string(rune(b[1])), Mod: ModAlt}
-	}
-
-	var cmd ansi.Cmd
-	var params [parser.MaxParamsSize]ansi.Param
-	var paramsLen int
-
-	var i int
-	if b[i] == ansi.CSI || b[i] == ansi.ESC {
-		i++
-	}
-	if i < len(b) && b[i-1] == ansi.ESC && b[i] == '[' {
-		i++
-	}
-
-	// Initial CSI byte
-	if i < len(b) && b[i] >= '<' && b[i] <= '?' {
-		cmd |= ansi.Cmd(b[i]) << parser.PrefixShift
-	}
-
-	// Scan parameter bytes in the range 0x30-0x3F
-	var j int
-	for j = 0; i < len(b) && paramsLen < len(params) && b[i] >= 0x30 && b[i] <= 0x3F; i, j = i+1, j+1 {
-		if b[i] >= '0' && b[i] <= '9' {
-			if params[paramsLen] == parser.MissingParam {
-				params[paramsLen] = 0
-			}
-			params[paramsLen] *= 10
-			params[paramsLen] += ansi.Param(b[i]) - '0'
-		}
-		if b[i] == ':' {
-			params[paramsLen] |= parser.HasMoreFlag
-		}
-		if b[i] == ';' || b[i] == ':' {
-			paramsLen++
-			if paramsLen < len(params) {
-				// Don't overflow the params slice
-				params[paramsLen] = parser.MissingParam
-			}
-		}
-	}
-
-	if j > 0 && paramsLen < len(params) {
-		// has parameters
-		paramsLen++
-	}
-
-	// Scan intermediate bytes in the range 0x20-0x2F
-	var intermed byte
-	for ; i < len(b) && b[i] >= 0x20 && b[i] <= 0x2F; i++ {
-		intermed = b[i]
-	}
-
-	// Set the intermediate byte
-	cmd |= ansi.Cmd(intermed) << parser.IntermedShift
-
-	// Scan final byte in the range 0x40-0x7E
-	if i >= len(b) {
-		// Incomplete sequence
-		return 0, nil
-	}
-	if b[i] < 0x40 || b[i] > 0x7E {
-		// Special case for URxvt keys
-		// CSI <number> $ is an invalid sequence, but URxvt uses it for
-		// shift modified keys.
-		if b[i-1] == '$' {
-			n, ev := p.parseCsi(append(b[:i-1], '~'))
-			if k, ok := ev.(KeyPressEvent); ok {
-				k.Mod |= ModShift
-				return n, k
-			}
-		}
-		return i, UnknownEvent(b[:i-1])
-	}
-
-	// Add the final byte
-	cmd |= ansi.Cmd(b[i])
-	i++
-
-	pa := ansi.Params(params[:paramsLen])
-	switch cmd {
-	case 'y' | '?'<<parser.PrefixShift | '$'<<parser.IntermedShift:
-		// Report Mode (DECRPM)
-		mode, _, ok := pa.Param(0, -1)
-		if !ok || mode == -1 {
-			break
-		}
-		value, _, ok := pa.Param(1, -1)
-		if !ok || value == -1 {
-			break
-		}
-		return i, ModeReportEvent{Mode: ansi.DECMode(mode), Value: ansi.ModeSetting(value)}
-	case 'c' | '?'<<parser.PrefixShift:
-		// Primary Device Attributes
-		return i, parsePrimaryDevAttrs(pa)
-	case 'u' | '?'<<parser.PrefixShift:
-		// Kitty keyboard flags
-		flags, _, ok := pa.Param(0, -1)
-		if !ok || flags == -1 {
-			break
-		}
-		return i, KittyEnhancementsEvent(flags)
-	case 'R' | '?'<<parser.PrefixShift:
-		// This report may return a third parameter representing the page
-		// number, but we don't really need it.
-		row, _, ok := pa.Param(0, 1)
-		if !ok {
-			break
-		}
-		col, _, ok := pa.Param(1, 1)
-		if !ok {
-			break
-		}
-		return i, CursorPositionEvent{Y: row - 1, X: col - 1}
-	case 'm' | '<'<<parser.PrefixShift, 'M' | '<'<<parser.PrefixShift:
-		// Handle SGR mouse
-		if paramsLen >= 3 {
-			pa = pa[:3]
-			return i, parseSGRMouseEvent(cmd, pa)
-		}
-	case 'm' | '>'<<parser.PrefixShift:
-		// XTerm modifyOtherKeys
-		mok, _, ok := pa.Param(0, 0)
-		if !ok || mok != 4 {
-			break
-		}
-		val, _, ok := pa.Param(1, -1)
-		if !ok || val == -1 {
-			break
-		}
-		return i, ModifyOtherKeysEvent(val) //nolint:gosec
-	case 'I':
-		return i, FocusEvent{}
-	case 'O':
-		return i, BlurEvent{}
-	case 'R':
-		// Cursor position report OR modified F3
-		row, _, rok := pa.Param(0, 1)
-		col, _, cok := pa.Param(1, 1)
-		if paramsLen == 2 && rok && cok {
-			m := CursorPositionEvent{Y: row - 1, X: col - 1}
-			if row == 1 && col-1 <= int(ModMeta|ModShift|ModAlt|ModCtrl) {
-				// XXX: We cannot differentiate between cursor position report and
-				// CSI 1 ; <mod> R (which is modified F3) when the cursor is at the
-				// row 1. In this case, we report both messages.
-				//
-				// For a non ambiguous cursor position report, use
-				// [ansi.RequestExtendedCursorPosition] (DECXCPR) instead.
-				return i, MultiEvent{KeyPressEvent{Code: KeyF3, Mod: KeyMod(col - 1)}, m}
-			}
-
-			return i, m
-		}
-
-		if paramsLen != 0 {
-			break
-		}
-
-		// Unmodified key F3 (CSI R)
-		fallthrough
-	case 'a', 'b', 'c', 'd', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S', 'Z':
-		var k KeyPressEvent
-		switch cmd {
-		case 'a', 'b', 'c', 'd':
-			k = KeyPressEvent{Code: KeyUp + rune(cmd-'a'), Mod: ModShift}
-		case 'A', 'B', 'C', 'D':
-			k = KeyPressEvent{Code: KeyUp + rune(cmd-'A')}
-		case 'E':
-			k = KeyPressEvent{Code: KeyBegin}
-		case 'F':
-			k = KeyPressEvent{Code: KeyEnd}
-		case 'H':
-			k = KeyPressEvent{Code: KeyHome}
-		case 'P', 'Q', 'R', 'S':
-			k = KeyPressEvent{Code: KeyF1 + rune(cmd-'P')}
-		case 'Z':
-			k = KeyPressEvent{Code: KeyTab, Mod: ModShift}
-		}
-		id, _, _ := pa.Param(0, 1)
-		if id == 0 {
-			id = 1
-		}
-		mod, _, _ := pa.Param(1, 1)
-		if mod == 0 {
-			mod = 1
-		}
-		if paramsLen > 1 && id == 1 && mod != -1 {
-			// CSI 1 ; <modifiers> A
-			k.Mod |= KeyMod(mod - 1)
-		}
-		// Don't forget to handle Kitty keyboard protocol
-		return i, parseKittyKeyboardExt(pa, k)
-	case 'M':
-		// Handle X10 mouse
-		if i+2 >= len(b) {
-			// Incomplete sequence
-			return 0, nil
-		}
-		// PERFORMANCE: Do not use append here, as it will allocate a new slice
-		// for every mouse event. Instead, pass a sub-slice of the original
-		// buffer.
-		return i + 3, parseX10MouseEvent(b[i-1 : i+3])
-	case 'y' | '$'<<parser.IntermedShift:
-		// Report Mode (DECRPM)
-		mode, _, ok := pa.Param(0, -1)
-		if !ok || mode == -1 {
-			break
-		}
-		val, _, ok := pa.Param(1, -1)
-		if !ok || val == -1 {
-			break
-		}
-		return i, ModeReportEvent{Mode: ansi.ANSIMode(mode), Value: ansi.ModeSetting(val)}
-	case 'u':
-		// Kitty keyboard protocol & CSI u (fixterms)
-		if paramsLen == 0 {
-			return i, UnknownEvent(b[:i])
-		}
-		return i, parseKittyKeyboard(pa)
-	case '_':
-		// Win32 Input Mode
-		if paramsLen != 6 {
-			return i, UnknownEvent(b[:i])
-		}
-
-		vrc, _, _ := pa.Param(5, 0)
-		rc := uint16(vrc) //nolint:gosec
-		if rc == 0 {
-			rc = 1
-		}
-
-		vk, _, _ := pa.Param(0, 0)
-		sc, _, _ := pa.Param(1, 0)
-		uc, _, _ := pa.Param(2, 0)
-		kd, _, _ := pa.Param(3, 0)
-		cs, _, _ := pa.Param(4, 0)
-		event := p.parseWin32InputKeyEvent(
-			nil,
-			uint16(vk), //nolint:gosec // Vk wVirtualKeyCode
-			uint16(sc), //nolint:gosec // Sc wVirtualScanCode
-			rune(uc),   // Uc UnicodeChar
-			kd == 1,    // Kd bKeyDown
-			uint32(cs), //nolint:gosec // Cs dwControlKeyState
-			rc,         // Rc wRepeatCount
-		)
-
-		if event == nil {
-			return i, UnknownEvent(b[:])
-		}
-
-		return i, event
-	case '@', '^', '~':
-		if paramsLen == 0 {
-			return i, UnknownEvent(b[:i])
-		}
-
-		param, _, _ := pa.Param(0, 0)
-		switch cmd {
-		case '~':
-			switch param {
-			case 27:
-				// XTerm modifyOtherKeys 2
-				if paramsLen != 3 {
-					return i, UnknownEvent(b[:i])
-				}
-				return i, parseXTermModifyOtherKeys(pa)
-			case 200:
-				// bracketed-paste start
-				return i, PasteStartEvent{}
-			case 201:
-				// bracketed-paste end
-				return i, PasteEndEvent{}
-			}
-		}
-
-		switch param {
-		case 1, 2, 3, 4, 5, 6, 7, 8,
-			11, 12, 13, 14, 15,
-			17, 18, 19, 20, 21,
-			23, 24, 25, 26,
-			28, 29, 31, 32, 33, 34:
-			var k KeyPressEvent
-			switch param {
-			case 1:
-				if p.flags&FlagFind != 0 {
-					k = KeyPressEvent{Code: KeyFind}
-				} else {
-					k = KeyPressEvent{Code: KeyHome}
-				}
-			case 2:
-				k = KeyPressEvent{Code: KeyInsert}
-			case 3:
-				k = KeyPressEvent{Code: KeyDelete}
-			case 4:
-				if p.flags&FlagSelect != 0 {
-					k = KeyPressEvent{Code: KeySelect}
-				} else {
-					k = KeyPressEvent{Code: KeyEnd}
-				}
-			case 5:
-				k = KeyPressEvent{Code: KeyPgUp}
-			case 6:
-				k = KeyPressEvent{Code: KeyPgDown}
-			case 7:
-				k = KeyPressEvent{Code: KeyHome}
-			case 8:
-				k = KeyPressEvent{Code: KeyEnd}
-			case 11, 12, 13, 14, 15:
-				k = KeyPressEvent{Code: KeyF1 + rune(param-11)}
-			case 17, 18, 19, 20, 21:
-				k = KeyPressEvent{Code: KeyF6 + rune(param-17)}
-			case 23, 24, 25, 26:
-				k = KeyPressEvent{Code: KeyF11 + rune(param-23)}
-			case 28, 29:
-				k = KeyPressEvent{Code: KeyF15 + rune(param-28)}
-			case 31, 32, 33, 34:
-				k = KeyPressEvent{Code: KeyF17 + rune(param-31)}
-			}
-
-			// modifiers
-			mod, _, _ := pa.Param(1, -1)
-			if paramsLen > 1 && mod != -1 {
-				k.Mod |= KeyMod(mod - 1)
-			}
-
-			// Handle URxvt weird keys
-			switch cmd {
-			case '~':
-				// Don't forget to handle Kitty keyboard protocol
-				return i, parseKittyKeyboardExt(pa, k)
-			case '^':
-				k.Mod |= ModCtrl
-			case '@':
-				k.Mod |= ModCtrl | ModShift
-			}
-
-			return i, k
-		}
-
-	case 't':
-		param, _, ok := pa.Param(0, 0)
-		if !ok {
-			break
-		}
-
-		var winop WindowOpEvent
-		winop.Op = param
-		for j := 1; j < paramsLen; j++ {
-			val, _, ok := pa.Param(j, 0)
-			if ok {
-				winop.Args = append(winop.Args, val)
-			}
-		}
-
-		return i, winop
-	}
-	return i, UnknownEvent(b[:i])
-}
-
-// parseSs3 parses a SS3 sequence.
-// See https://vt100.net/docs/vt220-rm/chapter4.html#S4.4.4.2
-func (p *Parser) parseSs3(b []byte) (int, Event) {
-	if len(b) == 2 && b[0] == ansi.ESC {
-		// short cut if this is an alt+O key
-		return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
-	}
-
-	var i int
-	if b[i] == ansi.SS3 || b[i] == ansi.ESC {
-		i++
-	}
-	if i < len(b) && b[i-1] == ansi.ESC && b[i] == 'O' {
-		i++
-	}
-
-	// Scan numbers from 0-9
-	var mod int
-	for ; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ {
-		mod *= 10
-		mod += int(b[i]) - '0'
-	}
-
-	// Scan a GL character
-	// A GL character is a single byte in the range 0x21-0x7E
-	// See https://vt100.net/docs/vt220-rm/chapter2.html#S2.3.2
-	if i >= len(b) {
-		// Incomplete sequence
-		return 0, nil
-	}
-	if b[i] < 0x21 || b[i] > 0x7E {
-		return i, UnknownEvent(b[:i])
-	}
-
-	// GL character(s)
-	gl := b[i]
-	i++
-
-	var k KeyPressEvent
-	switch gl {
-	case 'a', 'b', 'c', 'd':
-		k = KeyPressEvent{Code: KeyUp + rune(gl-'a'), Mod: ModCtrl}
-	case 'A', 'B', 'C', 'D':
-		k = KeyPressEvent{Code: KeyUp + rune(gl-'A')}
-	case 'E':
-		k = KeyPressEvent{Code: KeyBegin}
-	case 'F':
-		k = KeyPressEvent{Code: KeyEnd}
-	case 'H':
-		k = KeyPressEvent{Code: KeyHome}
-	case 'P', 'Q', 'R', 'S':
-		k = KeyPressEvent{Code: KeyF1 + rune(gl-'P')}
-	case 'M':
-		k = KeyPressEvent{Code: KeyKpEnter}
-	case 'X':
-		k = KeyPressEvent{Code: KeyKpEqual}
-	case 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y':
-		k = KeyPressEvent{Code: KeyKpMultiply + rune(gl-'j')}
-	default:
-		return i, UnknownEvent(b[:i])
-	}
-
-	// Handle weird SS3 <modifier> Func
-	if mod > 0 {
-		k.Mod |= KeyMod(mod - 1)
-	}
-
-	return i, k
-}
-
-func (p *Parser) parseOsc(b []byte) (int, Event) {
-	defaultKey := func() KeyPressEvent {
-		return KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
-	}
-	if len(b) == 2 && b[0] == ansi.ESC {
-		// short cut if this is an alt+] key
-		return 2, defaultKey()
-	}
-
-	var i int
-	if b[i] == ansi.OSC || b[i] == ansi.ESC {
-		i++
-	}
-	if i < len(b) && b[i-1] == ansi.ESC && b[i] == ']' {
-		i++
-	}
-
-	// Parse OSC command
-	// An OSC sequence is terminated by a BEL, ESC, or ST character
-	var start, end int
-	cmd := -1
-	for ; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ {
-		if cmd == -1 {
-			cmd = 0
-		} else {
-			cmd *= 10
-		}
-		cmd += int(b[i]) - '0'
-	}
-
-	if i < len(b) && b[i] == ';' {
-		// mark the start of the sequence data
-		i++
-		start = i
-	}
-
-	for ; i < len(b); i++ {
-		// advance to the end of the sequence
-		if slices.Contains([]byte{ansi.BEL, ansi.ESC, ansi.ST, ansi.CAN, ansi.SUB}, b[i]) {
-			break
-		}
-	}
-
-	if i >= len(b) {
-		// Incomplete sequence
-		return 0, nil
-	}
-
-	end = i // end of the sequence data
-	i++
-
-	// Check 7-bit ST (string terminator) character
-	switch b[i-1] {
-	case ansi.CAN, ansi.SUB:
-		return i, UnknownEvent(b[:i])
-	case ansi.ESC:
-		if i >= len(b) || b[i] != '\\' {
-			if cmd == -1 || (start == 0 && end == 2) {
-				return 2, defaultKey()
-			}
-
-			// If we don't have a valid ST terminator, then this is a
-			// cancelled sequence and should be ignored.
-			return i, UnknownEvent(b[:i])
-		}
-
-		i++
-	}
-
-	if end <= start {
-		return i, UnknownEvent(b[:i])
-	}
-
-	// PERFORMANCE: Only allocate the data string if we know we have a handler
-	// for the command. This avoids allocations for unknown OSC sequences that
-	// can be sent in high frequency by trackpads.
-	switch cmd {
-	case 10, 11, 12:
-		data := string(b[start:end])
-		color := ansi.XParseColor(data)
-		switch cmd {
-		case 10:
-			return i, ForegroundColorEvent{color}
-		case 11:
-			return i, BackgroundColorEvent{color}
-		case 12:
-			return i, CursorColorEvent{color}
-		}
-	case 52:
-		data := string(b[start:end])
-		parts := strings.Split(data, ";")
-		if len(parts) == 0 {
-			return i, ClipboardEvent{}
-		}
-		if len(parts) != 2 || len(parts[0]) < 1 {
-			break
-		}
-
-		b64 := parts[1]
-		bts, err := base64.StdEncoding.DecodeString(b64)
-		if err != nil {
-			break
-		}
-
-		sel := ClipboardSelection(parts[0][0]) //nolint:unconvert
-		return i, ClipboardEvent{Selection: sel, Content: string(bts)}
-	}
-
-	return i, UnknownEvent(b[:i])
-}
-
-// parseStTerminated parses a control sequence that gets terminated by a ST character.
-func (p *Parser) parseStTerminated(
-	intro8, intro7 byte,
-	fn func([]byte) Event,
-) func([]byte) (int, Event) {
-	defaultKey := func(b []byte) (int, Event) {
-		switch intro8 {
-		case ansi.SOS:
-			return 2, KeyPressEvent{Code: 'x', Mod: ModShift | ModAlt}
-		case ansi.PM, ansi.APC:
-			return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
-		}
-		return 0, nil
-	}
-	return func(b []byte) (int, Event) {
-		if len(b) == 2 && b[0] == ansi.ESC {
-			return defaultKey(b)
-		}
-
-		var i int
-		if b[i] == intro8 || b[i] == ansi.ESC {
-			i++
-		}
-		if i < len(b) && b[i-1] == ansi.ESC && b[i] == intro7 {
-			i++
-		}
-
-		// Scan control sequence
-		// Most common control sequence is terminated by a ST character
-		// ST is a 7-bit string terminator character is (ESC \)
-		start := i
-		for ; i < len(b); i++ {
-			if slices.Contains([]byte{ansi.ESC, ansi.ST, ansi.CAN, ansi.SUB}, b[i]) {
-				break
-			}
-		}
-
-		if i >= len(b) {
-			// Incomplete sequence
-			return 0, nil
-		}
-
-		end := i // end of the sequence data
-		i++
-
-		// Check 7-bit ST (string terminator) character
-		switch b[i-1] {
-		case ansi.CAN, ansi.SUB:
-			return i, UnknownEvent(b[:i])
-		case ansi.ESC:
-			if i >= len(b) || b[i] != '\\' {
-				if start == end {
-					return defaultKey(b)
-				}
-
-				// If we don't have a valid ST terminator, then this is a
-				// cancelled sequence and should be ignored.
-				return i, UnknownEvent(b[:i])
-			}
-
-			i++
-		}
-
-		// Call the function to parse the sequence and return the result
-		if fn != nil {
-			if e := fn(b[start:end]); e != nil {
-				return i, e
-			}
-		}
-
-		return i, UnknownEvent(b[:i])
-	}
-}
-
-func (p *Parser) parseDcs(b []byte) (int, Event) {
-	if len(b) == 2 && b[0] == ansi.ESC {
-		// short cut if this is an alt+P key
-		return 2, KeyPressEvent{Code: 'p', Mod: ModShift | ModAlt}
-	}
-
-	var params [16]ansi.Param
-	var paramsLen int
-	var cmd ansi.Cmd
-
-	// DCS sequences are introduced by DCS (0x90) or ESC P (0x1b 0x50)
-	var i int
-	if b[i] == ansi.DCS || b[i] == ansi.ESC {
-		i++
-	}
-	if i < len(b) && b[i-1] == ansi.ESC && b[i] == 'P' {
-		i++
-	}
-
-	// initial DCS byte
-	if i < len(b) && b[i] >= '<' && b[i] <= '?' {
-		cmd |= ansi.Cmd(b[i]) << parser.PrefixShift
-	}
-
-	// Scan parameter bytes in the range 0x30-0x3F
-	var j int
-	for j = 0; i < len(b) && paramsLen < len(params) && b[i] >= 0x30 && b[i] <= 0x3F; i, j = i+1, j+1 {
-		if b[i] >= '0' && b[i] <= '9' {
-			if params[paramsLen] == parser.MissingParam {
-				params[paramsLen] = 0
-			}
-			params[paramsLen] *= 10
-			params[paramsLen] += ansi.Param(b[i]) - '0'
-		}
-		if b[i] == ':' {
-			params[paramsLen] |= parser.HasMoreFlag
-		}
-		if b[i] == ';' || b[i] == ':' {
-			paramsLen++
-			if paramsLen < len(params) {
-				// Don't overflow the params slice
-				params[paramsLen] = parser.MissingParam
-			}
-		}
-	}
-
-	if j > 0 && paramsLen < len(params) {
-		// has parameters
-		paramsLen++
-	}
-
-	// Scan intermediate bytes in the range 0x20-0x2F
-	var intermed byte
-	for j := 0; i < len(b) && b[i] >= 0x20 && b[i] <= 0x2F; i, j = i+1, j+1 {
-		intermed = b[i]
-	}
-
-	// set intermediate byte
-	cmd |= ansi.Cmd(intermed) << parser.IntermedShift
-
-	// Scan final byte in the range 0x40-0x7E
-	if i >= len(b) {
-		// Incomplete sequence
-		return 0, nil
-	}
-	if b[i] < 0x40 || b[i] > 0x7E {
-		return i, UnknownEvent(b[:i])
-	}
-
-	// Add the final byte
-	cmd |= ansi.Cmd(b[i])
-	i++
-
-	start := i // start of the sequence data
-	for ; i < len(b); i++ {
-		if b[i] == ansi.ST || b[i] == ansi.ESC {
-			break
-		}
-	}
-
-	if i >= len(b) {
-		// Incomplete sequence
-		return 0, nil
-	}
-
-	end := i // end of the sequence data
-	i++
-
-	// Check 7-bit ST (string terminator) character
-	if i < len(b) && b[i-1] == ansi.ESC && b[i] == '\\' {
-		i++
-	}
-
-	pa := ansi.Params(params[:paramsLen])
-	switch cmd {
-	case 'r' | '+'<<parser.IntermedShift:
-		// XTGETTCAP responses
-		param, _, _ := pa.Param(0, 0)
-		switch param {
-		case 1: // 1 means valid response, 0 means invalid response
-			tc := parseTermcap(b[start:end])
-			// XXX: some terminals like KiTTY report invalid responses with
-			// their queries i.e. sending a query for "Tc" using "\x1bP+q5463\x1b\\"
-			// returns "\x1bP0+r5463\x1b\\".
-			// The specs says that invalid responses should be in the form of
-			// DCS 0 + r ST "\x1bP0+r\x1b\\"
-			// We ignore invalid responses and only send valid ones to the program.
-			//
-			// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
-			return i, tc
-		}
-	case '|' | '>'<<parser.PrefixShift:
-		// XTVersion response
-		return i, TerminalVersionEvent(b[start:end])
-	}
-
-	return i, UnknownEvent(b[:i])
-}
-
-func (p *Parser) parseApc(b []byte) (int, Event) {
-	if len(b) == 2 && b[0] == ansi.ESC {
-		// short cut if this is an alt+_ key
-		return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
-	}
-
-	// APC sequences are introduced by APC (0x9f) or ESC _ (0x1b 0x5f)
-	return p.parseStTerminated(ansi.APC, '_', func(b []byte) Event {
-		if len(b) == 0 {
-			return nil
-		}
-
-		switch b[0] {
-		case 'G': // Kitty Graphics Protocol
-			var g KittyGraphicsEvent
-			parts := bytes.Split(b[1:], []byte{';'})
-			g.Options.UnmarshalText(parts[0]) //nolint:errcheck,gosec
-			if len(parts) > 1 {
-				g.Payload = parts[1]
-			}
-			return g
-		}
-
-		return nil
-	})(b)
-}
-
-func (p *Parser) parseUtf8(b []byte) (int, Event) {
-	if len(b) == 0 {
-		return 0, nil
-	}
-
-	c := b[0]
-	if c <= ansi.US || c == ansi.DEL || c == ansi.SP {
-		// Control codes get handled by parseControl
-		return 1, p.parseControl(c)
-	} else if c > ansi.US && c < ansi.DEL {
-		// ASCII printable characters
-		code := rune(c)
-		k := KeyPressEvent{Code: code, Text: string(code)}
-		if unicode.IsUpper(code) {
-			// Convert upper case letters to lower case + shift modifier
-			k.Code = unicode.ToLower(code)
-			k.ShiftedCode = code
-			k.Mod |= ModShift
-		}
-
-		return 1, k
-	}
-
-	code, _ := utf8.DecodeRune(b)
-	if code == utf8.RuneError {
-		return 1, UnknownEvent(b[0])
-	}
-
-	cluster, _, _, _ := uniseg.FirstGraphemeCluster(b, -1)
-	// PERFORMANCE: Use RuneCount to check for multi-rune graphemes instead of
-	// looping over the string representation.
-	if utf8.RuneCount(cluster) > 1 {
-		code = KeyExtended
-	}
-
-	return len(cluster), KeyPressEvent{Code: code, Text: string(cluster)}
-}
-
-func (p *Parser) parseControl(b byte) Event {
-	switch b {
-	case ansi.NUL:
-		if p.flags&FlagCtrlAt != 0 {
-			return KeyPressEvent{Code: '@', Mod: ModCtrl}
-		}
-		return KeyPressEvent{Code: KeySpace, Mod: ModCtrl}
-	case ansi.BS:
-		return KeyPressEvent{Code: 'h', Mod: ModCtrl}
-	case ansi.HT:
-		if p.flags&FlagCtrlI != 0 {
-			return KeyPressEvent{Code: 'i', Mod: ModCtrl}
-		}
-		return KeyPressEvent{Code: KeyTab}
-	case ansi.CR:
-		if p.flags&FlagCtrlM != 0 {
-			return KeyPressEvent{Code: 'm', Mod: ModCtrl}
-		}
-		return KeyPressEvent{Code: KeyEnter}
-	case ansi.ESC:
-		if p.flags&FlagCtrlOpenBracket != 0 {
-			return KeyPressEvent{Code: '[', Mod: ModCtrl}
-		}
-		return KeyPressEvent{Code: KeyEscape}
-	case ansi.DEL:
-		if p.flags&FlagBackspace != 0 {
-			return KeyPressEvent{Code: KeyDelete}
-		}
-		return KeyPressEvent{Code: KeyBackspace}
-	case ansi.SP:
-		return KeyPressEvent{Code: KeySpace, Text: " "}
-	default:
-		if b >= ansi.SOH && b <= ansi.SUB {
-			// Use lower case letters for control codes
-			code := rune(b + 0x60)
-			return KeyPressEvent{Code: code, Mod: ModCtrl}
-		} else if b >= ansi.FS && b <= ansi.US {
-			code := rune(b + 0x40)
-			return KeyPressEvent{Code: code, Mod: ModCtrl}
-		}
-		return UnknownEvent(b)
-	}
-}

+ 0 - 47
packages/tui/input/parse_test.go

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

+ 0 - 13
packages/tui/input/paste.go

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

+ 0 - 389
packages/tui/input/table.go

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

+ 0 - 54
packages/tui/input/termcap.go

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

+ 0 - 277
packages/tui/input/terminfo.go

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

+ 0 - 47
packages/tui/input/xterm.go

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

+ 0 - 41
packages/tui/internal/api/api.go

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

+ 0 - 963
packages/tui/internal/app/app.go

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

+ 0 - 304
packages/tui/internal/app/app_test.go

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

+ 0 - 283
packages/tui/internal/app/prompt.go

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

+ 0 - 174
packages/tui/internal/app/state.go

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

+ 0 - 178
packages/tui/internal/attachment/attachment.go

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

+ 0 - 155
packages/tui/internal/clipboard/clipboard.go

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

+ 0 - 266
packages/tui/internal/clipboard/clipboard_darwin.go

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

+ 0 - 311
packages/tui/internal/clipboard/clipboard_linux.go

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

+ 0 - 25
packages/tui/internal/clipboard/clipboard_nocgo.go

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

+ 0 - 551
packages/tui/internal/clipboard/clipboard_windows.go

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

+ 0 - 423
packages/tui/internal/commands/command.go

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

+ 0 - 75
packages/tui/internal/completions/agents.go

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

+ 0 - 144
packages/tui/internal/completions/commands.go

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

+ 0 - 126
packages/tui/internal/completions/files.go

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

+ 0 - 8
packages/tui/internal/completions/provider.go

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

+ 0 - 24
packages/tui/internal/completions/suggestion.go

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

+ 0 - 119
packages/tui/internal/completions/symbols.go

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

+ 0 - 62
packages/tui/internal/components/chat/cache.go

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

+ 0 - 906
packages/tui/internal/components/chat/editor.go

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

+ 0 - 1031
packages/tui/internal/components/chat/message.go

@@ -1,1031 +0,0 @@
-package chat
-
-import (
-	"encoding/json"
-	"fmt"
-	"maps"
-	"slices"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/charmbracelet/lipgloss/v2/compat"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/muesli/reflow/truncate"
-	"github.com/sst/opencode-sdk-go"
-	"github.com/sst/opencode/internal/app"
-	"github.com/sst/opencode/internal/commands"
-	"github.com/sst/opencode/internal/components/diff"
-	"github.com/sst/opencode/internal/styles"
-	"github.com/sst/opencode/internal/theme"
-	"github.com/sst/opencode/internal/util"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-)
-
-type blockRenderer struct {
-	textColor       compat.AdaptiveColor
-	backgroundColor compat.AdaptiveColor
-	border          bool
-	borderColor     *compat.AdaptiveColor
-	borderLeft      bool
-	borderRight     bool
-	paddingTop      int
-	paddingBottom   int
-	paddingLeft     int
-	paddingRight    int
-	marginTop       int
-	marginBottom    int
-}
-
-type renderingOption func(*blockRenderer)
-
-func WithTextColor(color compat.AdaptiveColor) renderingOption {
-	return func(c *blockRenderer) {
-		c.textColor = color
-	}
-}
-
-func WithBackgroundColor(color compat.AdaptiveColor) renderingOption {
-	return func(c *blockRenderer) {
-		c.backgroundColor = color
-	}
-}
-
-func WithNoBorder() renderingOption {
-	return func(c *blockRenderer) {
-		c.border = false
-		c.paddingLeft++
-		c.paddingRight++
-	}
-}
-
-func WithBorderColor(color compat.AdaptiveColor) renderingOption {
-	return func(c *blockRenderer) {
-		c.borderColor = &color
-	}
-}
-
-func WithBorderLeft() renderingOption {
-	return func(c *blockRenderer) {
-		c.borderLeft = true
-		c.borderRight = false
-	}
-}
-
-func WithBorderRight() renderingOption {
-	return func(c *blockRenderer) {
-		c.borderLeft = false
-		c.borderRight = true
-	}
-}
-
-func WithBorderBoth(value bool) renderingOption {
-	return func(c *blockRenderer) {
-		if value {
-			c.borderLeft = true
-			c.borderRight = true
-		}
-	}
-}
-
-func WithMarginTop(padding int) renderingOption {
-	return func(c *blockRenderer) {
-		c.marginTop = padding
-	}
-}
-
-func WithMarginBottom(padding int) renderingOption {
-	return func(c *blockRenderer) {
-		c.marginBottom = padding
-	}
-}
-
-func WithPadding(padding int) renderingOption {
-	return func(c *blockRenderer) {
-		c.paddingTop = padding
-		c.paddingBottom = padding
-		c.paddingLeft = padding
-		c.paddingRight = padding
-	}
-}
-
-func WithPaddingLeft(padding int) renderingOption {
-	return func(c *blockRenderer) {
-		c.paddingLeft = padding
-	}
-}
-
-func WithPaddingRight(padding int) renderingOption {
-	return func(c *blockRenderer) {
-		c.paddingRight = padding
-	}
-}
-
-func WithPaddingTop(padding int) renderingOption {
-	return func(c *blockRenderer) {
-		c.paddingTop = padding
-	}
-}
-
-func WithPaddingBottom(padding int) renderingOption {
-	return func(c *blockRenderer) {
-		c.paddingBottom = padding
-	}
-}
-
-func renderContentBlock(
-	app *app.App,
-	content string,
-	width int,
-	options ...renderingOption,
-) string {
-	t := theme.CurrentTheme()
-	renderer := &blockRenderer{
-		textColor:       t.TextMuted(),
-		backgroundColor: t.BackgroundPanel(),
-		border:          true,
-		borderLeft:      true,
-		borderRight:     false,
-		paddingTop:      1,
-		paddingBottom:   1,
-		paddingLeft:     2,
-		paddingRight:    2,
-	}
-	for _, option := range options {
-		option(renderer)
-	}
-
-	borderColor := t.BackgroundPanel()
-	if renderer.borderColor != nil {
-		borderColor = *renderer.borderColor
-	}
-
-	style := styles.NewStyle().
-		Foreground(renderer.textColor).
-		Background(renderer.backgroundColor).
-		PaddingTop(renderer.paddingTop).
-		PaddingBottom(renderer.paddingBottom).
-		PaddingLeft(renderer.paddingLeft).
-		PaddingRight(renderer.paddingRight).
-		AlignHorizontal(lipgloss.Left)
-
-	if renderer.border {
-		style = style.
-			BorderStyle(lipgloss.ThickBorder()).
-			BorderLeft(true).
-			BorderRight(true).
-			BorderLeftForeground(t.BackgroundPanel()).
-			BorderLeftBackground(t.Background()).
-			BorderRightForeground(t.BackgroundPanel()).
-			BorderRightBackground(t.Background())
-
-		if renderer.borderLeft {
-			style = style.BorderLeftForeground(borderColor)
-		}
-		if renderer.borderRight {
-			style = style.BorderRightForeground(borderColor)
-		}
-	} else {
-		style = style.PaddingLeft(renderer.paddingLeft).PaddingRight(renderer.paddingRight)
-	}
-
-	content = style.Render(content)
-	if renderer.marginTop > 0 {
-		for range renderer.marginTop {
-			content = "\n" + content
-		}
-	}
-	if renderer.marginBottom > 0 {
-		for range renderer.marginBottom {
-			content = content + "\n"
-		}
-	}
-
-	return content
-}
-
-func renderText(
-	app *app.App,
-	message opencode.MessageUnion,
-	text string,
-	author string,
-	showToolDetails bool,
-	width int,
-	extra string,
-	isThinking bool,
-	isQueued bool,
-	shimmer bool,
-	fileParts []opencode.FilePart,
-	agentParts []opencode.AgentPart,
-	toolCalls ...opencode.ToolPart,
-) string {
-	t := theme.CurrentTheme()
-
-	var ts time.Time
-	backgroundColor := t.BackgroundPanel()
-	var content string
-	switch casted := message.(type) {
-	case opencode.AssistantMessage:
-		backgroundColor = t.Background()
-		if isThinking {
-			backgroundColor = t.BackgroundPanel()
-		}
-		ts = time.UnixMilli(int64(casted.Time.Created))
-		if casted.Time.Completed > 0 {
-			ts = time.UnixMilli(int64(casted.Time.Completed))
-		}
-		content = util.ToMarkdown(text, width, backgroundColor)
-		if isThinking {
-			var label string
-			if shimmer {
-				label = util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent())
-			} else {
-				label = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking...")
-			}
-			label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
-			content = label + "\n\n" + content
-		} else if strings.TrimSpace(text) == "Generating..." {
-			label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text())
-			label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
-			content = label
-		}
-	case opencode.UserMessage:
-		ts = time.UnixMilli(int64(casted.Time.Created))
-		base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
-
-		var result strings.Builder
-		lastEnd := int64(0)
-
-		// Apply highlighting to filenames and base style to rest of text BEFORE wrapping
-		textLen := int64(len(text))
-
-		// Collect all parts to highlight (both file and agent parts)
-		type highlightPart struct {
-			start int64
-			end   int64
-			color compat.AdaptiveColor
-		}
-		var highlights []highlightPart
-
-		// Add file parts with secondary color
-		for _, filePart := range fileParts {
-			highlights = append(highlights, highlightPart{
-				start: filePart.Source.Text.Start,
-				end:   filePart.Source.Text.End,
-				color: t.Secondary(),
-			})
-		}
-
-		// Add agent parts with secondary color (same as file parts)
-		for _, agentPart := range agentParts {
-			highlights = append(highlights, highlightPart{
-				start: agentPart.Source.Start,
-				end:   agentPart.Source.End,
-				color: t.Secondary(),
-			})
-		}
-
-		// Sort highlights by start position
-		slices.SortFunc(highlights, func(a, b highlightPart) int {
-			if a.start < b.start {
-				return -1
-			}
-			if a.start > b.start {
-				return 1
-			}
-			return 0
-		})
-
-		// Merge overlapping highlights to prevent duplication
-		merged := make([]highlightPart, 0)
-		for _, part := range highlights {
-			if len(merged) == 0 {
-				merged = append(merged, part)
-				continue
-			}
-
-			last := &merged[len(merged)-1]
-			// If current part overlaps with the last one, merge them
-			if part.start <= last.end {
-				if part.end > last.end {
-					last.end = part.end
-				}
-			} else {
-				merged = append(merged, part)
-			}
-		}
-
-		for _, part := range merged {
-			highlight := base.Foreground(part.color)
-			start, end := part.start, part.end
-
-			if end > textLen {
-				end = textLen
-			}
-			if start > textLen {
-				start = textLen
-			}
-
-			if start > lastEnd {
-				result.WriteString(base.Render(text[lastEnd:start]))
-			}
-			if start < end {
-				result.WriteString(highlight.Render(text[start:end]))
-			}
-
-			lastEnd = end
-		}
-
-		if lastEnd < textLen {
-			result.WriteString(base.Render(text[lastEnd:]))
-		}
-
-		// wrap styled text
-		styledText := result.String()
-		styledText = strings.ReplaceAll(styledText, "-", "\u2011")
-		wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
-		wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
-		content = base.Width(width - 6).Render(wrappedText)
-		if isQueued {
-			queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1)
-			content = queuedStyle.Render("QUEUED") + "\n\n" + content
-		}
-	}
-
-	timestamp := ts.
-		Local().
-		Format("02 Jan 2006 03:04 PM")
-	if time.Now().Format("02 Jan 2006") == timestamp[:11] {
-		timestamp = timestamp[12:]
-	}
-	timestamp = styles.NewStyle().
-		Background(backgroundColor).
-		Foreground(t.TextMuted()).
-		Render(" (" + timestamp + ")")
-
-	// Check if this is an assistant message with agent information
-	var modelAndAgentSuffix string
-	if assistantMsg, ok := message.(opencode.AssistantMessage); ok && assistantMsg.Mode != "" {
-		// Find the agent index by name to get the correct color
-		var agentIndex int
-		for i, agent := range app.Agents {
-			if agent.Name == assistantMsg.Mode {
-				agentIndex = i
-				break
-			}
-		}
-
-		// Get agent color based on the original agent index (same as status bar)
-		agentColor := util.GetAgentColor(agentIndex)
-
-		// Style the agent name with the same color as status bar
-		agentName := cases.Title(language.Und).String(assistantMsg.Mode)
-		styledAgentName := styles.NewStyle().
-			Background(backgroundColor).
-			Foreground(agentColor).
-			Render(agentName + " ")
-		styledModelID := styles.NewStyle().
-			Background(backgroundColor).
-			Foreground(t.TextMuted()).
-			Render(assistantMsg.ModelID)
-		modelAndAgentSuffix = styledAgentName + styledModelID
-	}
-
-	var info string
-	if modelAndAgentSuffix != "" {
-		info = modelAndAgentSuffix + timestamp
-	} else {
-		info = author + timestamp
-	}
-	if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
-		for _, toolCall := range toolCalls {
-			title := renderToolTitle(toolCall, width-2)
-			style := styles.NewStyle()
-			if toolCall.State.Status == opencode.ToolPartStateStatusError {
-				style = style.Foreground(t.Error())
-			}
-			title = style.Render(title)
-			title = "\n∟ " + title
-			content = content + title
-		}
-	}
-
-	sections := []string{content}
-	if extra != "" {
-		sections = append(sections, "\n"+extra+"\n")
-	}
-	sections = append(sections, info)
-	content = strings.Join(sections, "\n")
-
-	switch message.(type) {
-	case opencode.UserMessage:
-		borderColor := t.Secondary()
-		if isQueued {
-			borderColor = t.Accent()
-		}
-		return renderContentBlock(
-			app,
-			content,
-			width,
-			WithTextColor(t.Text()),
-			WithBorderColor(borderColor),
-		)
-	case opencode.AssistantMessage:
-		if isThinking {
-			return renderContentBlock(
-				app,
-				content,
-				width,
-				WithTextColor(t.Text()),
-				WithBackgroundColor(t.BackgroundPanel()),
-				WithBorderColor(t.BackgroundPanel()),
-			)
-		}
-		return renderContentBlock(
-			app,
-			content,
-			width,
-			WithNoBorder(),
-			WithBackgroundColor(t.Background()),
-		)
-	}
-	return ""
-}
-
-func renderToolDetails(
-	app *app.App,
-	toolCall opencode.ToolPart,
-	permission opencode.Permission,
-	width int,
-) string {
-	measure := util.Measure("chat.renderToolDetails")
-	defer measure("tool", toolCall.Tool)
-	ignoredTools := []string{"todoread"}
-	if slices.Contains(ignoredTools, toolCall.Tool) {
-		return ""
-	}
-
-	if toolCall.State.Status == opencode.ToolPartStateStatusPending {
-		title := renderToolTitle(toolCall, width)
-		return renderContentBlock(app, title, width)
-	}
-
-	var result *string
-	if toolCall.State.Output != "" {
-		result = &toolCall.State.Output
-	}
-
-	toolInputMap := make(map[string]any)
-	if toolCall.State.Input != nil {
-		value := toolCall.State.Input
-		if m, ok := value.(map[string]any); ok {
-			toolInputMap = m
-			keys := make([]string, 0, len(toolInputMap))
-			for key := range toolInputMap {
-				keys = append(keys, key)
-			}
-			slices.Sort(keys)
-		}
-	}
-
-	body := ""
-	t := theme.CurrentTheme()
-	backgroundColor := t.BackgroundPanel()
-	borderColor := t.BackgroundPanel()
-	defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
-	baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render
-	mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render
-
-	permissionContent := ""
-	if permission.ID != "" {
-		borderColor = t.Warning()
-
-		base := styles.NewStyle().Background(backgroundColor)
-		text := base.Foreground(t.Text()).Bold(true).Render
-		muted := base.Foreground(t.TextMuted()).Render
-		if permission.Type == "doom-loop" {
-			permissionContent = permission.Title + "\n\n"
-		} else {
-			permissionContent = "Permission required to run this tool:\n\n"
-		}
-		permissionContent += text(
-			"enter ",
-		) + muted(
-			"accept   ",
-		) + text(
-			"a",
-		) + muted(
-			" accept always   ",
-		) + text(
-			"esc",
-		) + muted(
-			" reject",
-		)
-
-	}
-
-	if permission.Metadata != nil {
-		metadata, ok := toolCall.State.Metadata.(map[string]any)
-		if metadata == nil || !ok {
-			metadata = map[string]any{}
-		}
-		maps.Copy(metadata, permission.Metadata)
-		toolCall.State.Metadata = metadata
-	}
-
-	if toolCall.State.Metadata != nil {
-		metadata := toolCall.State.Metadata.(map[string]any)
-		switch toolCall.Tool {
-		case "read":
-			var preview any
-			if metadata != nil {
-				preview = metadata["preview"]
-			}
-			if preview != nil && toolInputMap["filePath"] != nil {
-				filename := toolInputMap["filePath"].(string)
-				body = preview.(string)
-				body = util.RenderFile(filename, body, width, util.WithTruncate(6))
-			}
-		case "edit":
-			if filename, ok := toolInputMap["filePath"].(string); ok {
-				var diffField any
-				if metadata != nil {
-					diffField = metadata["diff"]
-				}
-				if diffField != nil {
-					patch := diffField.(string)
-					var formattedDiff string
-					if width < 120 {
-						formattedDiff, _ = diff.FormatUnifiedDiff(
-							filename,
-							patch,
-							diff.WithWidth(width-2),
-						)
-					} else {
-						formattedDiff, _ = diff.FormatDiff(
-							filename,
-							patch,
-							diff.WithWidth(width-2),
-						)
-					}
-					body = strings.TrimSpace(formattedDiff)
-					style := styles.NewStyle().
-						Background(backgroundColor).
-						Foreground(t.TextMuted()).
-						Padding(1, 2).
-						Width(width - 4)
-
-					if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
-						diagnostics = style.Render(diagnostics)
-						body += "\n" + diagnostics
-					}
-
-					title := renderToolTitle(toolCall, width)
-					title = style.Render(title)
-					content := title + "\n" + body
-
-					if toolCall.State.Status == opencode.ToolPartStateStatusError {
-						errorStyle := styles.NewStyle().
-							Background(backgroundColor).
-							Foreground(t.Error()).
-							Padding(1, 2).
-							Width(width - 4)
-						errorContent := errorStyle.Render(toolCall.State.Error)
-						content += "\n" + errorContent
-					}
-
-					if permissionContent != "" {
-						permissionContent = styles.NewStyle().
-							Background(backgroundColor).
-							Padding(1, 2).
-							Render(permissionContent)
-						content += "\n" + permissionContent
-					}
-					content = renderContentBlock(
-						app,
-						content,
-						width,
-						WithPadding(0),
-						WithBorderColor(borderColor),
-						WithBorderBoth(permission.ID != ""),
-					)
-					return content
-				}
-			}
-		case "write":
-			if filename, ok := toolInputMap["filePath"].(string); ok {
-				if content, ok := toolInputMap["content"].(string); ok {
-					body = util.RenderFile(filename, content, width)
-					if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
-						body += "\n\n" + diagnostics
-					}
-				}
-			}
-		case "bash":
-			if command, ok := toolInputMap["command"].(string); ok {
-				body = fmt.Sprintf("```console\n$ %s\n", command)
-				output := metadata["output"]
-				if output != nil {
-					body += ansi.Strip(fmt.Sprintf("%s", output))
-				}
-				body += "```"
-				body = util.ToMarkdown(body, width, backgroundColor)
-			}
-		case "webfetch":
-			if format, ok := toolInputMap["format"].(string); ok && result != nil {
-				body = *result
-				body = util.TruncateHeight(body, 10)
-				if format == "html" || format == "markdown" {
-					body = util.ToMarkdown(body, width, backgroundColor)
-				}
-			}
-		case "todowrite":
-			todos := metadata["todos"]
-			if todos != nil {
-				for _, item := range todos.([]any) {
-					todo := item.(map[string]any)
-					content := todo["content"]
-					if content == nil {
-						continue
-					}
-					switch todo["status"] {
-					case "completed":
-						body += fmt.Sprintf("- [x] %s\n", content)
-					case "cancelled":
-						// strike through cancelled todo
-						body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
-					case "in_progress":
-						// highlight in progress todo
-						body += fmt.Sprintf("- [ ] `%s`\n", content)
-					default:
-						body += fmt.Sprintf("- [ ] %s\n", content)
-					}
-				}
-				body = util.ToMarkdown(body, width, backgroundColor)
-			}
-		case "task":
-			summary := metadata["summary"]
-			if summary != nil {
-				toolcalls := summary.([]any)
-				steps := []string{}
-				for _, item := range toolcalls {
-					data, _ := json.Marshal(item)
-					var toolCall opencode.ToolPart
-					_ = json.Unmarshal(data, &toolCall)
-					step := renderToolTitle(toolCall, width-2)
-					step = "∟ " + step
-					steps = append(steps, step)
-				}
-				body = strings.Join(steps, "\n")
-
-				body += "\n\n"
-
-				// Build navigation hint with proper spacing
-				cycleKeybind := app.Keybind(commands.SessionChildCycleCommand)
-				cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand)
-
-				var navParts []string
-				if cycleKeybind != "" {
-					navParts = append(navParts, baseStyle(cycleKeybind))
-				}
-				if cycleReverseKeybind != "" {
-					navParts = append(navParts, baseStyle(cycleReverseKeybind))
-				}
-
-				if len(navParts) > 0 {
-					body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions")
-				}
-			}
-			body = defaultStyle(body)
-		default:
-			if result == nil {
-				empty := ""
-				result = &empty
-			}
-			body = *result
-			body = util.TruncateHeight(body, 10)
-			body = defaultStyle(body)
-		}
-	}
-
-	error := ""
-	if toolCall.State.Status == opencode.ToolPartStateStatusError {
-		error = toolCall.State.Error
-	}
-
-	if error != "" {
-		errorContent := styles.NewStyle().
-			Width(width - 6).
-			Foreground(t.Error()).
-			Background(backgroundColor).
-			Render(error)
-
-		if body == "" {
-			body = errorContent
-		} else {
-			body += "\n\n" + errorContent
-		}
-	}
-
-	if body == "" && error == "" && result != nil {
-		body = *result
-		body = util.TruncateHeight(body, 10)
-		body = defaultStyle(body)
-	}
-
-	if body == "" {
-		body = defaultStyle("")
-	}
-
-	title := renderToolTitle(toolCall, width)
-	content := title + "\n\n" + body
-
-	if permissionContent != "" {
-		content += "\n\n\n" + permissionContent
-	}
-
-	return renderContentBlock(
-		app,
-		content,
-		width,
-		WithBorderColor(borderColor),
-		WithBorderBoth(permission.ID != ""),
-	)
-}
-
-func renderToolName(name string) string {
-	switch name {
-	case "bash":
-		return "Shell"
-	case "webfetch":
-		return "Fetch"
-	case "invalid":
-		return "Invalid"
-	default:
-		normalizedName := name
-		if after, ok := strings.CutPrefix(name, "opencode_"); ok {
-			normalizedName = after
-		}
-		return cases.Title(language.Und).String(normalizedName)
-	}
-}
-
-func getTodoPhase(metadata map[string]any) string {
-	todos, ok := metadata["todos"].([]any)
-	if !ok || len(todos) == 0 {
-		return "Plan"
-	}
-
-	counts := map[string]int{"pending": 0, "completed": 0}
-	for _, item := range todos {
-		if todo, ok := item.(map[string]any); ok {
-			if status, ok := todo["status"].(string); ok {
-				counts[status]++
-			}
-		}
-	}
-
-	total := len(todos)
-	switch {
-	case counts["pending"] == total:
-		return "Creating plan"
-	case counts["completed"] == total:
-		return "Completing plan"
-	default:
-		return "Updating plan"
-	}
-}
-
-func getTodoTitle(toolCall opencode.ToolPart) string {
-	if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
-		if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
-			return getTodoPhase(metadata)
-		}
-	}
-	return "Plan"
-}
-
-func renderToolTitle(
-	toolCall opencode.ToolPart,
-	width int,
-) string {
-	if toolCall.State.Status == opencode.ToolPartStateStatusPending {
-		title := renderToolAction(toolCall.Tool)
-		t := theme.CurrentTheme()
-		shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
-		return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny)
-	}
-
-	toolArgs := ""
-	toolArgsMap := make(map[string]any)
-	if toolCall.State.Input != nil {
-		value := toolCall.State.Input
-		if m, ok := value.(map[string]any); ok {
-			toolArgsMap = m
-
-			keys := make([]string, 0, len(toolArgsMap))
-			for key := range toolArgsMap {
-				keys = append(keys, key)
-			}
-			slices.Sort(keys)
-			firstKey := ""
-			if len(keys) > 0 {
-				firstKey = keys[0]
-			}
-
-			toolArgs = renderArgs(&toolArgsMap, firstKey)
-		}
-	}
-
-	title := renderToolName(toolCall.Tool)
-	switch toolCall.Tool {
-	case "read":
-		toolArgs = renderArgs(&toolArgsMap, "filePath")
-		title = fmt.Sprintf("%s %s", title, toolArgs)
-	case "edit", "write":
-		if filename, ok := toolArgsMap["filePath"].(string); ok {
-			title = fmt.Sprintf("%s %s", title, util.Relative(filename))
-		}
-	case "bash":
-		if description, ok := toolArgsMap["description"].(string); ok {
-			title = fmt.Sprintf("%s %s", title, description)
-		}
-	case "task":
-		description := toolArgsMap["description"]
-		subagent := toolArgsMap["subagent_type"]
-		if description != nil && subagent != nil {
-			title = fmt.Sprintf("%s[%s] %s", title, subagent, description)
-		} else if description != nil {
-			title = fmt.Sprintf("%s %s", title, description)
-		}
-	case "webfetch":
-		toolArgs = renderArgs(&toolArgsMap, "url")
-		title = fmt.Sprintf("%s %s", title, toolArgs)
-	case "todowrite":
-		title = getTodoTitle(toolCall)
-	case "todoread":
-		return "Plan"
-	case "invalid":
-		if actualTool, ok := toolArgsMap["tool"].(string); ok {
-			title = renderToolName(actualTool)
-		}
-	default:
-		toolName := renderToolName(toolCall.Tool)
-		title = fmt.Sprintf("%s %s", toolName, toolArgs)
-	}
-
-	title = truncate.StringWithTail(title, uint(width-6), "...")
-	if toolCall.State.Error != "" {
-		t := theme.CurrentTheme()
-		title = styles.NewStyle().Foreground(t.Error()).Render(title)
-	}
-	return title
-}
-
-func renderToolAction(name string) string {
-	switch name {
-	case "task":
-		return "Delegating..."
-	case "bash":
-		return "Writing command..."
-	case "edit":
-		return "Preparing edit..."
-	case "webfetch":
-		return "Fetching from the web..."
-	case "glob":
-		return "Finding files..."
-	case "grep":
-		return "Searching content..."
-	case "list":
-		return "Listing directory..."
-	case "read":
-		return "Reading file..."
-	case "write":
-		return "Preparing write..."
-	case "todowrite", "todoread":
-		return "Planning..."
-	case "patch":
-		return "Preparing patch..."
-	}
-	return "Working..."
-}
-
-func renderArgs(args *map[string]any, titleKey string) string {
-	if args == nil || len(*args) == 0 {
-		return ""
-	}
-
-	keys := make([]string, 0, len(*args))
-	for key := range *args {
-		keys = append(keys, key)
-	}
-	slices.Sort(keys)
-
-	title := ""
-	parts := []string{}
-	for _, key := range keys {
-		value := (*args)[key]
-		if value == nil {
-			continue
-		}
-		if key == "filePath" || key == "path" {
-			if strValue, ok := value.(string); ok {
-				value = util.Relative(strValue)
-			}
-		}
-		if key == titleKey {
-			title = fmt.Sprintf("%s", value)
-			continue
-		}
-		parts = append(parts, fmt.Sprintf("%s=%v", key, value))
-	}
-	if len(parts) == 0 {
-		return title
-	}
-	return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
-}
-
-// Diagnostic represents an LSP diagnostic
-type Diagnostic struct {
-	Range struct {
-		Start struct {
-			Line      int `json:"line"`
-			Character int `json:"character"`
-		} `json:"start"`
-	} `json:"range"`
-	Severity int    `json:"severity"`
-	Message  string `json:"message"`
-}
-
-// renderDiagnostics formats LSP diagnostics for display in the TUI
-func renderDiagnostics(
-	metadata map[string]any,
-	filePath string,
-	backgroundColor compat.AdaptiveColor,
-	width int,
-) string {
-	if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
-		if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
-			var errorDiagnostics []string
-			for _, diagInterface := range fileDiagnostics {
-				diagMap, ok := diagInterface.(map[string]any)
-				if !ok {
-					continue
-				}
-				// Parse the diagnostic
-				var diag Diagnostic
-				diagBytes, err := json.Marshal(diagMap)
-				if err != nil {
-					continue
-				}
-				if err := json.Unmarshal(diagBytes, &diag); err != nil {
-					continue
-				}
-				// Only show error diagnostics (severity === 1)
-				if diag.Severity != 1 {
-					continue
-				}
-				line := diag.Range.Start.Line + 1        // 1-based
-				column := diag.Range.Start.Character + 1 // 1-based
-				errorDiagnostics = append(
-					errorDiagnostics,
-					fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
-				)
-			}
-			if len(errorDiagnostics) == 0 {
-				return ""
-			}
-			t := theme.CurrentTheme()
-			var result strings.Builder
-			for _, diagnostic := range errorDiagnostics {
-				if result.Len() > 0 {
-					result.WriteString("\n\n")
-				}
-				diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
-				result.WriteString(
-					styles.NewStyle().
-						Background(backgroundColor).
-						Foreground(t.Error()).
-						Render(diagnostic),
-				)
-			}
-			return result.String()
-		}
-	}
-	return ""
-
-	// diagnosticsData should be a map[string][]Diagnostic
-	// strDiagnosticsData := diagnosticsData.Raw()
-	// diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
-	// fileDiagnostics, ok := diagnosticsMap[filePath]
-	// if !ok {
-	// 	return ""
-	// }
-
-	// diagnosticsList, ok := fileDiagnostics.([]any)
-	// if !ok {
-	// 	return ""
-	// }
-
-}

+ 0 - 1322
packages/tui/internal/components/chat/messages.go

@@ -1,1322 +0,0 @@
-package chat
-
-import (
-	"context"
-	"fmt"
-	"log/slog"
-	"slices"
-	"sort"
-	"strconv"
-	"strings"
-	"time"
-
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/sst/opencode-sdk-go"
-	"github.com/sst/opencode/internal/app"
-	"github.com/sst/opencode/internal/commands"
-	"github.com/sst/opencode/internal/components/dialog"
-	"github.com/sst/opencode/internal/components/diff"
-	"github.com/sst/opencode/internal/components/toast"
-	"github.com/sst/opencode/internal/layout"
-	"github.com/sst/opencode/internal/styles"
-	"github.com/sst/opencode/internal/theme"
-	"github.com/sst/opencode/internal/util"
-	"github.com/sst/opencode/internal/viewport"
-)
-
-type MessagesComponent interface {
-	tea.Model
-	tea.ViewModel
-	PageUp() (tea.Model, tea.Cmd)
-	PageDown() (tea.Model, tea.Cmd)
-	HalfPageUp() (tea.Model, tea.Cmd)
-	HalfPageDown() (tea.Model, tea.Cmd)
-	ToolDetailsVisible() bool
-	ThinkingBlocksVisible() bool
-	GotoTop() (tea.Model, tea.Cmd)
-	GotoBottom() (tea.Model, tea.Cmd)
-	CopyLastMessage() (tea.Model, tea.Cmd)
-	UndoLastMessage() (tea.Model, tea.Cmd)
-	RedoLastMessage() (tea.Model, tea.Cmd)
-	ScrollToMessage(messageID string) (tea.Model, tea.Cmd)
-}
-
-type messagesComponent struct {
-	width, height      int
-	app                *app.App
-	header             string
-	viewport           viewport.Model
-	clipboard          []string
-	cache              *PartCache
-	loading            bool
-	showToolDetails    bool
-	showThinkingBlocks bool
-	rendering          bool
-	dirty              bool
-	tail               bool
-	partCount          int
-	lineCount          int
-	selection          *selection
-	messagePositions   map[string]int // map message ID to line position
-	animating          bool
-}
-
-type selection struct {
-	startX int
-	endX   int
-	startY int
-	endY   int
-}
-
-func (s selection) coords(offset int) *selection {
-	// selecting backwards
-	if s.startY > s.endY && s.endY >= 0 {
-		return &selection{
-			startX: max(0, s.endX-1),
-			startY: s.endY - offset,
-			endX:   s.startX + 1,
-			endY:   s.startY - offset,
-		}
-	}
-
-	// selecting backwards same line
-	if s.startY == s.endY && s.startX >= s.endX {
-		return &selection{
-			startY: s.startY - offset,
-			startX: max(0, s.endX-1),
-			endY:   s.endY - offset,
-			endX:   s.startX + 1,
-		}
-	}
-
-	return &selection{
-		startX: s.startX,
-		startY: s.startY - offset,
-		endX:   s.endX,
-		endY:   s.endY - offset,
-	}
-}
-
-type ToggleToolDetailsMsg struct{}
-type ToggleThinkingBlocksMsg struct{}
-type shimmerTickMsg struct{}
-
-func (m *messagesComponent) Init() tea.Cmd {
-	return tea.Batch(m.viewport.Init())
-}
-
-func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	switch msg := msg.(type) {
-	case shimmerTickMsg:
-		if !m.app.HasAnimatingWork() {
-			m.animating = false
-			return m, nil
-		}
-		return m, tea.Sequence(
-			m.renderView(),
-			tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }),
-		)
-	case tea.MouseClickMsg:
-		slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
-		y := msg.Y + m.viewport.YOffset
-		if y > 0 {
-			m.selection = &selection{
-				startY: y,
-				startX: msg.X,
-				endY:   -1,
-				endX:   -1,
-			}
-
-			slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY))
-			return m, m.renderView()
-		}
-
-	case tea.MouseMotionMsg:
-		if m.selection != nil {
-			m.selection = &selection{
-				startX: m.selection.startX,
-				startY: m.selection.startY,
-				endX:   msg.X + 1,
-				endY:   msg.Y + m.viewport.YOffset,
-			}
-			return m, m.renderView()
-		}
-
-	case tea.MouseReleaseMsg:
-		if m.selection != nil {
-			m.selection = nil
-			if len(m.clipboard) > 0 {
-				content := strings.Join(m.clipboard, "\n")
-				m.clipboard = []string{}
-				return m, tea.Sequence(
-					m.renderView(),
-					app.SetClipboard(content),
-					toast.NewSuccessToast("Copied to clipboard"),
-				)
-			}
-			return m, m.renderView()
-		}
-	case tea.WindowSizeMsg:
-		effectiveWidth := msg.Width - 4
-		// Clear cache on resize since width affects rendering
-		if m.width != effectiveWidth {
-			m.cache.Clear()
-		}
-		m.width = effectiveWidth
-		m.height = msg.Height - 7
-		m.viewport.SetWidth(m.width)
-		m.loading = true
-		return m, m.renderView()
-	case app.SendPrompt:
-		m.viewport.GotoBottom()
-		m.tail = true
-		return m, nil
-	case app.SendCommand:
-		m.viewport.GotoBottom()
-		m.tail = true
-		return m, nil
-	case dialog.ThemeSelectedMsg:
-		m.cache.Clear()
-		m.loading = true
-		return m, m.renderView()
-	case ToggleToolDetailsMsg:
-		m.showToolDetails = !m.showToolDetails
-		m.app.State.ShowToolDetails = &m.showToolDetails
-		return m, tea.Batch(m.renderView(), m.app.SaveState())
-	case ToggleThinkingBlocksMsg:
-		m.showThinkingBlocks = !m.showThinkingBlocks
-		m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
-		return m, tea.Batch(m.renderView(), m.app.SaveState())
-	case app.SessionLoadedMsg:
-		m.tail = true
-		m.loading = true
-		return m, m.renderView()
-	case app.SessionClearedMsg:
-		m.cache.Clear()
-		m.tail = true
-		m.loading = true
-		return m, m.renderView()
-	case app.SessionUnrevertedMsg:
-		if msg.Session.ID == m.app.Session.ID {
-			m.cache.Clear()
-			m.tail = true
-			return m, m.renderView()
-		}
-	case app.SessionSelectedMsg:
-		currentParent := m.app.Session.ParentID
-		if currentParent == "" {
-			currentParent = m.app.Session.ID
-		}
-
-		targetParent := msg.ParentID
-		if targetParent == "" {
-			targetParent = msg.ID
-		}
-
-		// Clear cache only if switching between different session families
-		if currentParent != targetParent {
-			m.cache.Clear()
-		}
-
-		m.viewport.GotoBottom()
-	case app.MessageRevertedMsg:
-		if msg.Session.ID == m.app.Session.ID {
-			m.cache.Clear()
-			m.tail = true
-			return m, m.renderView()
-		}
-
-	case opencode.EventListResponseEventSessionUpdated:
-		if msg.Properties.Info.ID == m.app.Session.ID {
-			cmds = append(cmds, m.renderView())
-		}
-	case opencode.EventListResponseEventMessageUpdated:
-		if msg.Properties.Info.SessionID == m.app.Session.ID {
-			cmds = append(cmds, m.renderView())
-		}
-	case opencode.EventListResponseEventSessionError:
-		if msg.Properties.SessionID == m.app.Session.ID {
-			cmds = append(cmds, m.renderView())
-		}
-	case opencode.EventListResponseEventMessagePartUpdated:
-		if msg.Properties.Part.SessionID == m.app.Session.ID {
-			cmds = append(cmds, m.renderView())
-		}
-	case opencode.EventListResponseEventMessageRemoved:
-		if msg.Properties.SessionID == m.app.Session.ID {
-			m.cache.Clear()
-			cmds = append(cmds, m.renderView())
-		}
-	case opencode.EventListResponseEventMessagePartRemoved:
-		if msg.Properties.SessionID == m.app.Session.ID {
-			// Clear the cache when a part is removed to ensure proper re-rendering
-			m.cache.Clear()
-			cmds = append(cmds, m.renderView())
-		}
-	case opencode.EventListResponseEventPermissionUpdated:
-		m.tail = true
-		return m, m.renderView()
-	case opencode.EventListResponseEventPermissionReplied:
-		m.tail = true
-		return m, m.renderView()
-	case renderCompleteMsg:
-		m.partCount = msg.partCount
-		m.lineCount = msg.lineCount
-		m.rendering = false
-		m.clipboard = msg.clipboard
-		m.loading = false
-		m.messagePositions = msg.messagePositions
-		m.tail = m.viewport.AtBottom()
-
-		// Preserve scroll across reflow
-		// if the user was at bottom, keep following; otherwise restore the previous offset.
-		wasAtBottom := m.viewport.AtBottom()
-		prevYOffset := m.viewport.YOffset
-		m.viewport = msg.viewport
-		if wasAtBottom {
-			m.viewport.GotoBottom()
-		} else {
-			m.viewport.YOffset = prevYOffset
-		}
-
-		m.header = msg.header
-		if m.dirty {
-			cmds = append(cmds, m.renderView())
-		}
-
-		// Start shimmer ticks if any assistant/tool is in-flight
-		if !m.animating && m.app.HasAnimatingWork() {
-			m.animating = true
-			cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }))
-		}
-	}
-
-	m.tail = m.viewport.AtBottom()
-	viewport, cmd := m.viewport.Update(msg)
-	m.viewport = viewport
-	cmds = append(cmds, cmd)
-
-	return m, tea.Batch(cmds...)
-}
-
-type renderCompleteMsg struct {
-	viewport         viewport.Model
-	clipboard        []string
-	header           string
-	partCount        int
-	lineCount        int
-	messagePositions map[string]int
-}
-
-func (m *messagesComponent) renderView() tea.Cmd {
-	if m.rendering {
-		slog.Debug("pending render, skipping")
-		m.dirty = true
-		return func() tea.Msg {
-			return nil
-		}
-	}
-	m.dirty = false
-	m.rendering = true
-
-	viewport := m.viewport
-	tail := m.tail
-
-	return func() tea.Msg {
-		header := m.renderHeader()
-		measure := util.Measure("messages.renderView")
-		defer measure()
-
-		t := theme.CurrentTheme()
-		blocks := make([]string, 0)
-		partCount := 0
-		lineCount := 0
-		messagePositions := make(map[string]int) // Track message ID to line position
-
-		orphanedToolCalls := make([]opencode.ToolPart, 0)
-
-		width := m.width // always use full width
-
-		// Find the last streaming ReasoningPart to only shimmer that one
-		lastStreamingReasoningID := ""
-		if m.showThinkingBlocks {
-			for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- {
-				if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok {
-					continue
-				}
-				parts := m.app.Messages[mi].Parts
-				for pi := len(parts) - 1; pi >= 0; pi-- {
-					if rp, ok := parts[pi].(opencode.ReasoningPart); ok {
-						if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 {
-							lastStreamingReasoningID = rp.ID
-							break
-						}
-					}
-				}
-			}
-		}
-
-		reverted := false
-		revertedMessageCount := 0
-		revertedToolCount := 0
-		lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
-		for _, msg := range slices.Backward(m.app.Messages) {
-			if assistant, ok := msg.Info.(opencode.AssistantMessage); ok {
-				if assistant.Time.Completed > 0 {
-					break
-				}
-				lastAssistantMessage = assistant.ID
-				break
-			}
-		}
-		for _, message := range m.app.Messages {
-			var content string
-			var cached bool
-			error := ""
-
-			switch casted := message.Info.(type) {
-			case opencode.UserMessage:
-				// Track the position of this user message
-				messagePositions[casted.ID] = lineCount
-
-				if casted.ID == m.app.Session.Revert.MessageID {
-					reverted = true
-					revertedMessageCount = 1
-					revertedToolCount = 0
-					continue
-				}
-				if reverted {
-					revertedMessageCount++
-					continue
-				}
-
-				for partIndex, part := range message.Parts {
-					switch part := part.(type) {
-					case opencode.TextPart:
-						if part.Synthetic {
-							continue
-						}
-						if part.Text == "" {
-							continue
-						}
-						remainingParts := message.Parts[partIndex+1:]
-						fileParts := make([]opencode.FilePart, 0)
-						agentParts := make([]opencode.AgentPart, 0)
-						for _, part := range remainingParts {
-							switch part := part.(type) {
-							case opencode.FilePart:
-								if part.Source.Text.Start >= 0 && part.Source.Text.End >= part.Source.Text.Start {
-									fileParts = append(fileParts, part)
-								}
-							case opencode.AgentPart:
-								if part.Source.Start >= 0 && part.Source.End >= part.Source.Start {
-									agentParts = append(agentParts, part)
-								}
-							}
-						}
-						flexItems := []layout.FlexItem{}
-						if len(fileParts) > 0 {
-							fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
-							mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
-							for _, filePart := range fileParts {
-								mediaType := ""
-								switch filePart.Mime {
-								case "text/plain":
-									mediaType = "txt"
-								case "image/png", "image/jpeg", "image/gif", "image/webp":
-									mediaType = "img"
-									mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
-								case "application/pdf":
-									mediaType = "pdf"
-									mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
-								}
-								flexItems = append(flexItems, layout.FlexItem{
-									View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
-								})
-							}
-						}
-						bgColor := t.BackgroundPanel()
-						files := layout.Render(
-							layout.FlexOptions{
-								Background: &bgColor,
-								Width:      width - 6,
-								Direction:  layout.Column,
-							},
-							flexItems...,
-						)
-
-						author := m.app.Config.Username
-						isQueued := casted.ID > lastAssistantMessage
-						key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued)
-						content, cached = m.cache.Get(key)
-						if !cached {
-							content = renderText(
-								m.app,
-								message.Info,
-								part.Text,
-								author,
-								m.showToolDetails,
-								width,
-								files,
-								false,
-								isQueued,
-								false,
-								fileParts,
-								agentParts,
-							)
-							m.cache.Set(key, content)
-						}
-						if content != "" {
-							partCount++
-							lineCount += lipgloss.Height(content) + 1
-							blocks = append(blocks, content)
-						}
-					}
-				}
-
-			case opencode.AssistantMessage:
-				if casted.ID == m.app.Session.Revert.MessageID {
-					reverted = true
-					revertedMessageCount = 1
-					revertedToolCount = 0
-				}
-				hasTextPart := false
-				hasContent := false
-				for partIndex, p := range message.Parts {
-					switch part := p.(type) {
-					case opencode.TextPart:
-						if reverted {
-							continue
-						}
-						if strings.TrimSpace(part.Text) == "" {
-							continue
-						}
-						hasTextPart = true
-						finished := part.Time.End > 0
-						remainingParts := message.Parts[partIndex+1:]
-						toolCallParts := make([]opencode.ToolPart, 0)
-
-						// sometimes tool calls happen without an assistant message
-						// these should be included in this assistant message as well
-						if len(orphanedToolCalls) > 0 {
-							toolCallParts = append(toolCallParts, orphanedToolCalls...)
-							orphanedToolCalls = make([]opencode.ToolPart, 0)
-						}
-
-						remaining := true
-						for _, part := range remainingParts {
-							if !remaining {
-								break
-							}
-							switch part := part.(type) {
-							case opencode.TextPart:
-								// we only want tool calls associated with the current text part.
-								// if we hit another text part, we're done.
-								remaining = false
-							case opencode.ToolPart:
-								toolCallParts = append(toolCallParts, part)
-								if part.State.Status != opencode.ToolPartStateStatusCompleted && part.State.Status != opencode.ToolPartStateStatusError {
-									// i don't think there's a case where a tool call isn't in result state
-									// and the message time is 0, but just in case
-									finished = false
-								}
-							}
-						}
-
-						if finished {
-							key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts)
-							content, cached = m.cache.Get(key)
-							if !cached {
-								content = renderText(
-									m.app,
-									message.Info,
-									part.Text,
-									casted.ModelID,
-									m.showToolDetails,
-									width,
-									"",
-									false,
-									false,
-									false,
-									[]opencode.FilePart{},
-									[]opencode.AgentPart{},
-									toolCallParts...,
-								)
-								m.cache.Set(key, content)
-							}
-						} else {
-							content = renderText(
-								m.app,
-								message.Info,
-								part.Text,
-								casted.ModelID,
-								m.showToolDetails,
-								width,
-								"",
-								false,
-								false,
-								false,
-								[]opencode.FilePart{},
-								[]opencode.AgentPart{},
-								toolCallParts...,
-							)
-						}
-						if content != "" {
-							partCount++
-							lineCount += lipgloss.Height(content) + 1
-							blocks = append(blocks, content)
-							hasContent = true
-						}
-					case opencode.ToolPart:
-						if reverted {
-							revertedToolCount++
-							continue
-						}
-
-						permission := opencode.Permission{}
-						if m.app.CurrentPermission.CallID == part.CallID {
-							permission = m.app.CurrentPermission
-						}
-
-						if !m.showToolDetails && permission.ID == "" {
-							if !hasTextPart {
-								orphanedToolCalls = append(orphanedToolCalls, part)
-							}
-							continue
-						}
-
-						if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
-							key := m.cache.GenerateKey(casted.ID,
-								part.ID,
-								m.showToolDetails,
-								width,
-								permission.ID,
-							)
-							content, cached = m.cache.Get(key)
-							if !cached {
-								content = renderToolDetails(
-									m.app,
-									part,
-									permission,
-									width,
-								)
-								m.cache.Set(key, content)
-							}
-						} else {
-							// if the tool call isn't finished, don't cache
-							content = renderToolDetails(
-								m.app,
-								part,
-								permission,
-								width,
-							)
-						}
-						if content != "" {
-							partCount++
-							lineCount += lipgloss.Height(content) + 1
-							blocks = append(blocks, content)
-							hasContent = true
-						}
-					case opencode.ReasoningPart:
-						if reverted {
-							continue
-						}
-						if !m.showThinkingBlocks {
-							continue
-						}
-						if part.Text != "" {
-							text := part.Text
-							shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID
-							content = renderText(
-								m.app,
-								message.Info,
-								text,
-								casted.ModelID,
-								m.showToolDetails,
-								width,
-								"",
-								true,
-								false,
-								shimmer,
-								[]opencode.FilePart{},
-								[]opencode.AgentPart{},
-							)
-							partCount++
-							lineCount += lipgloss.Height(content) + 1
-							blocks = append(blocks, content)
-							hasContent = true
-						}
-					}
-				}
-
-				switch err := casted.Error.AsUnion().(type) {
-				case nil:
-				case opencode.AssistantMessageErrorMessageOutputLengthError:
-					error = "Message output length exceeded"
-				case opencode.AssistantMessageErrorAPIError:
-					error = err.Data.Message
-				case opencode.ProviderAuthError:
-					error = err.Data.Message
-				case opencode.MessageAbortedError:
-					error = "Request was aborted"
-				case opencode.UnknownError:
-					error = err.Data.Message
-				}
-
-				if !hasContent && error == "" && !reverted && casted.Time.Completed == 0 {
-					content = renderText(
-						m.app,
-						message.Info,
-						"Generating...",
-						casted.ModelID,
-						m.showToolDetails,
-						width,
-						"",
-						false,
-						false,
-						false,
-						[]opencode.FilePart{},
-						[]opencode.AgentPart{},
-					)
-					partCount++
-					lineCount += lipgloss.Height(content) + 1
-					blocks = append(blocks, content)
-				}
-			}
-
-			if error != "" && !reverted {
-				error = styles.NewStyle().Width(width - 6).Render(error)
-				error = renderContentBlock(
-					m.app,
-					error,
-					width,
-					WithBorderColor(t.Error()),
-				)
-				blocks = append(blocks, error)
-				lineCount += lipgloss.Height(error) + 1
-			}
-		}
-
-		if revertedMessageCount > 0 || revertedToolCount > 0 {
-			messagePlural := ""
-			toolPlural := ""
-			if revertedMessageCount != 1 {
-				messagePlural = "s"
-			}
-			if revertedToolCount != 1 {
-				toolPlural = "s"
-			}
-			revertedStyle := styles.NewStyle().
-				Background(t.BackgroundPanel()).
-				Foreground(t.TextMuted())
-
-			content := revertedStyle.Render(fmt.Sprintf(
-				"%d message%s reverted, %d tool call%s reverted",
-				revertedMessageCount,
-				messagePlural,
-				revertedToolCount,
-				toolPlural,
-			))
-			hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text())
-			hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand))
-			hint += revertedStyle.Render(" (or /redo) to restore")
-
-			content += "\n" + hint
-			if m.app.Session.Revert.Diff != "" {
-				t := theme.CurrentTheme()
-				s := styles.NewStyle().Background(t.BackgroundPanel())
-				green := s.Foreground(t.Success()).Render
-				red := s.Foreground(t.Error()).Render
-				content += "\n"
-				stats, err := diff.ParseStats(m.app.Session.Revert.Diff)
-				if err != nil {
-					slog.Error("Failed to parse diff stats", "error", err)
-				} else {
-					var files []string
-					for file := range stats {
-						files = append(files, file)
-					}
-					sort.Strings(files)
-
-					for _, file := range files {
-						fileStats := stats[file]
-						display := file
-						if fileStats.Added > 0 {
-							display += green(" +" + strconv.Itoa(int(fileStats.Added)))
-						}
-						if fileStats.Removed > 0 {
-							display += red(" -" + strconv.Itoa(int(fileStats.Removed)))
-						}
-						content += "\n" + display
-					}
-				}
-			}
-
-			content = styles.NewStyle().
-				Background(t.BackgroundPanel()).
-				Width(width - 6).
-				Render(content)
-			content = renderContentBlock(
-				m.app,
-				content,
-				width,
-				WithBorderColor(t.BackgroundPanel()),
-			)
-			blocks = append(blocks, content)
-		}
-
-		if m.app.CurrentPermission.ID != "" &&
-			m.app.CurrentPermission.SessionID != m.app.Session.ID {
-			response, err := m.app.Client.Session.Message(
-				context.Background(),
-				m.app.CurrentPermission.SessionID,
-				m.app.CurrentPermission.MessageID,
-				opencode.SessionMessageParams{},
-			)
-			if err != nil || response == nil {
-				slog.Error("Failed to get message from child session", "error", err)
-			} else {
-				for _, part := range response.Parts {
-					if part.CallID == m.app.CurrentPermission.CallID {
-						if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok {
-							content := renderToolDetails(
-								m.app,
-								toolPart,
-								m.app.CurrentPermission,
-								width,
-							)
-							if content != "" {
-								partCount++
-								lineCount += lipgloss.Height(content) + 1
-								blocks = append(blocks, content)
-							}
-						}
-					}
-				}
-			}
-		}
-
-		final := []string{}
-		clipboard := []string{}
-		var selection *selection
-		if m.selection != nil {
-			selection = m.selection.coords(lipgloss.Height(header) + 1)
-		}
-		for _, block := range blocks {
-			lines := strings.Split(block, "\n")
-			for index, line := range lines {
-				if selection == nil || index == 0 || index == len(lines)-1 {
-					final = append(final, line)
-					continue
-				}
-				y := len(final)
-				if y >= selection.startY && y <= selection.endY {
-					left := 3
-					if y == selection.startY {
-						left = selection.startX - 2
-					}
-					left = max(3, left)
-
-					width := ansi.StringWidth(line)
-					right := width - 1
-					if y == selection.endY {
-						right = min(selection.endX-2, right)
-					}
-
-					prefix := ansi.Cut(line, 0, left)
-					middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
-					suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width)
-					clipboard = append(clipboard, middle)
-					line = prefix + styles.NewStyle().
-						Background(t.Accent()).
-						Foreground(t.BackgroundPanel()).
-						Render(ansi.Strip(middle)) +
-						suffix
-				}
-				final = append(final, line)
-			}
-			y := len(final)
-			if selection != nil && y >= selection.startY && y < selection.endY {
-				clipboard = append(clipboard, "")
-			}
-			final = append(final, "")
-		}
-		content := "\n" + strings.Join(final, "\n")
-		viewport.SetHeight(m.height - lipgloss.Height(header))
-		viewport.SetContent(content)
-		if tail {
-			viewport.GotoBottom()
-		}
-
-		return renderCompleteMsg{
-			header:           header,
-			clipboard:        clipboard,
-			viewport:         viewport,
-			partCount:        partCount,
-			lineCount:        lineCount,
-			messagePositions: messagePositions,
-		}
-	}
-}
-
-func (m *messagesComponent) renderHeader() string {
-	if m.app.Session.ID == "" {
-		return ""
-	}
-
-	headerWidth := m.width
-
-	t := theme.CurrentTheme()
-	bgColor := t.Background()
-	borderColor := t.BackgroundElement()
-
-	isChildSession := m.app.Session.ParentID != ""
-	if isChildSession {
-		bgColor = t.BackgroundElement()
-		borderColor = t.Accent()
-	}
-
-	base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
-	muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
-
-	sessionInfo := ""
-	tokens := float64(0)
-	cost := float64(0)
-	contextWindow := m.app.Model.Limit.Context
-
-	for _, message := range m.app.Messages {
-		if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
-			cost += assistant.Cost
-			usage := assistant.Tokens
-			if usage.Output > 0 {
-				if assistant.Summary {
-					tokens = usage.Output
-					continue
-				}
-				tokens = (usage.Input +
-					usage.Cache.Read +
-					usage.Cache.Write +
-					usage.Output +
-					usage.Reasoning)
-			}
-		}
-	}
-
-	// Check if current model is a subscription model (cost is 0 for both input and output)
-	isSubscriptionModel := m.app.Model != nil &&
-		m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
-
-	sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
-	sessionInfo = styles.NewStyle().
-		Foreground(t.TextMuted()).
-		Background(bgColor).
-		Render(sessionInfoText)
-
-	shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
-
-	navHint := ""
-	if isChildSession {
-		navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
-	}
-
-	headerTextWidth := headerWidth
-	if isChildSession {
-		headerTextWidth -= lipgloss.Width(navHint)
-	} else if !shareEnabled {
-		headerTextWidth -= lipgloss.Width(sessionInfoText)
-	}
-	headerText := util.ToMarkdown(
-		"# "+m.app.Session.Title,
-		headerTextWidth,
-		bgColor,
-	)
-	if isChildSession {
-		headerText = layout.Render(
-			layout.FlexOptions{
-				Background: &bgColor,
-				Direction:  layout.Row,
-				Justify:    layout.JustifySpaceBetween,
-				Align:      layout.AlignStretch,
-				Width:      headerTextWidth,
-			},
-			layout.FlexItem{
-				View: headerText,
-			},
-			layout.FlexItem{
-				View: navHint,
-			},
-		)
-	}
-
-	var items []layout.FlexItem
-	if shareEnabled {
-		share := base("/share") + muted(" to create a shareable link")
-		if m.app.Session.Share.URL != "" {
-			share = muted(m.app.Session.Share.URL + "  /unshare")
-		}
-		items = []layout.FlexItem{{View: share}, {View: sessionInfo}}
-	} else {
-		items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
-	}
-
-	headerRow := layout.Render(
-		layout.FlexOptions{
-			Background: &bgColor,
-			Direction:  layout.Row,
-			Justify:    layout.JustifySpaceBetween,
-			Align:      layout.AlignStretch,
-			Width:      headerWidth - 6,
-		},
-		items...,
-	)
-
-	headerLines := []string{headerRow}
-	if shareEnabled {
-		headerLines = []string{headerText, headerRow}
-	}
-
-	header := strings.Join(headerLines, "\n")
-	header = styles.NewStyle().
-		Background(bgColor).
-		Width(headerWidth).
-		PaddingLeft(2).
-		PaddingRight(2).
-		BorderLeft(true).
-		BorderRight(true).
-		BorderBackground(t.Background()).
-		BorderForeground(borderColor).
-		BorderStyle(lipgloss.ThickBorder()).
-		Render(header)
-
-	return "\n" + header + "\n"
-}
-
-func formatTokensAndCost(
-	tokens float64,
-	contextWindow float64,
-	cost float64,
-	isSubscriptionModel bool,
-) string {
-	// Format tokens in human-readable format (e.g., 110K, 1.2M)
-	var formattedTokens string
-	switch {
-	case tokens >= 1_000_000:
-		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
-	case tokens >= 1_000:
-		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
-	default:
-		formattedTokens = fmt.Sprintf("%d", int(tokens))
-	}
-
-	// Remove .0 suffix if present
-	if strings.HasSuffix(formattedTokens, ".0K") {
-		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
-	}
-	if strings.HasSuffix(formattedTokens, ".0M") {
-		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
-	}
-
-	percentage := 0.0
-	if contextWindow > 0 {
-		percentage = (float64(tokens) / float64(contextWindow)) * 100
-	}
-
-	if isSubscriptionModel {
-		return fmt.Sprintf(
-			"%s/%d%%",
-			formattedTokens,
-			int(percentage),
-		)
-	}
-
-	formattedCost := fmt.Sprintf("$%.2f", cost)
-	return fmt.Sprintf(
-		" %s/%d%% (%s)",
-		formattedTokens,
-		int(percentage),
-		formattedCost,
-	)
-}
-
-func (m *messagesComponent) View() string {
-	t := theme.CurrentTheme()
-	bgColor := t.Background()
-
-	if m.loading {
-		return lipgloss.Place(
-			m.width,
-			m.height,
-			lipgloss.Center,
-			lipgloss.Center,
-			styles.NewStyle().Background(bgColor).Render(""),
-			styles.WhitespaceStyle(bgColor),
-		)
-	}
-
-	viewport := m.viewport.View()
-	return styles.NewStyle().
-		Background(bgColor).
-		Render(m.header + "\n" + viewport)
-}
-
-func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
-	m.viewport.ViewUp()
-	return m, nil
-}
-
-func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
-	m.viewport.ViewDown()
-	return m, nil
-}
-
-func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
-	m.viewport.HalfViewUp()
-	return m, nil
-}
-
-func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
-	m.viewport.HalfViewDown()
-	return m, nil
-}
-
-func (m *messagesComponent) ToolDetailsVisible() bool {
-	return m.showToolDetails
-}
-
-func (m *messagesComponent) ThinkingBlocksVisible() bool {
-	return m.showThinkingBlocks
-}
-
-func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) {
-	m.viewport.GotoTop()
-	return m, nil
-}
-
-func (m *messagesComponent) GotoBottom() (tea.Model, tea.Cmd) {
-	m.viewport.GotoBottom()
-	return m, nil
-}
-
-func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
-	if len(m.app.Messages) == 0 {
-		return m, nil
-	}
-	lastMessage := m.app.Messages[len(m.app.Messages)-1]
-	var lastTextPart *opencode.TextPart
-	for _, part := range lastMessage.Parts {
-		if p, ok := part.(opencode.TextPart); ok {
-			lastTextPart = &p
-		}
-	}
-	if lastTextPart == nil {
-		return m, nil
-	}
-	var cmds []tea.Cmd
-	cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
-	cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
-	return m, tea.Batch(cmds...)
-}
-
-func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) {
-	after := float64(0)
-	var revertedMessage app.Message
-	reversedMessages := []app.Message{}
-	for i := len(m.app.Messages) - 1; i >= 0; i-- {
-		reversedMessages = append(reversedMessages, m.app.Messages[i])
-		switch casted := m.app.Messages[i].Info.(type) {
-		case opencode.UserMessage:
-			if casted.ID == m.app.Session.Revert.MessageID {
-				after = casted.Time.Created
-			}
-		case opencode.AssistantMessage:
-			if casted.ID == m.app.Session.Revert.MessageID {
-				after = casted.Time.Created
-			}
-		}
-		if m.app.Session.Revert.PartID != "" {
-			for _, part := range m.app.Messages[i].Parts {
-				switch casted := part.(type) {
-				case opencode.TextPart:
-					if casted.ID == m.app.Session.Revert.PartID {
-						after = casted.Time.Start
-					}
-				case opencode.ToolPart:
-					// TODO: handle tool parts
-				}
-			}
-		}
-	}
-
-	messageID := ""
-	for _, msg := range reversedMessages {
-		switch casted := msg.Info.(type) {
-		case opencode.UserMessage:
-			if after > 0 && casted.Time.Created >= after {
-				continue
-			}
-			messageID = casted.ID
-			revertedMessage = msg
-		}
-		if messageID != "" {
-			break
-		}
-	}
-
-	if messageID == "" {
-		return m, nil
-	}
-
-	return m, func() tea.Msg {
-		response, err := m.app.Client.Session.Revert(
-			context.Background(),
-			m.app.Session.ID,
-			opencode.SessionRevertParams{
-				MessageID: opencode.F(messageID),
-			},
-		)
-		if err != nil {
-			slog.Error("Failed to undo message", "error", err)
-			return toast.NewErrorToast("Failed to undo message")()
-		}
-		if response == nil {
-			return toast.NewErrorToast("Failed to undo message")()
-		}
-		return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
-	}
-}
-
-func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
-	// Check if there's a revert state to redo from
-	if m.app.Session.Revert.MessageID == "" {
-		return m, func() tea.Msg {
-			return toast.NewErrorToast("Nothing to redo")
-		}
-	}
-
-	before := float64(0)
-	var revertedMessage app.Message
-	for _, message := range m.app.Messages {
-		switch casted := message.Info.(type) {
-		case opencode.UserMessage:
-			if casted.ID == m.app.Session.Revert.MessageID {
-				before = casted.Time.Created
-			}
-		case opencode.AssistantMessage:
-			if casted.ID == m.app.Session.Revert.MessageID {
-				before = casted.Time.Created
-			}
-		}
-		if m.app.Session.Revert.PartID != "" {
-			for _, part := range message.Parts {
-				switch casted := part.(type) {
-				case opencode.TextPart:
-					if casted.ID == m.app.Session.Revert.PartID {
-						before = casted.Time.Start
-					}
-				case opencode.ToolPart:
-					// TODO: handle tool parts
-				}
-			}
-		}
-	}
-
-	messageID := ""
-	for _, msg := range m.app.Messages {
-		switch casted := msg.Info.(type) {
-		case opencode.UserMessage:
-			if casted.Time.Created <= before {
-				continue
-			}
-			messageID = casted.ID
-			revertedMessage = msg
-		}
-		if messageID != "" {
-			break
-		}
-	}
-
-	if messageID == "" {
-		return m, func() tea.Msg {
-			// unrevert back to original state
-			response, err := m.app.Client.Session.Unrevert(
-				context.Background(),
-				m.app.Session.ID,
-				opencode.SessionUnrevertParams{},
-			)
-			if err != nil {
-				slog.Error("Failed to unrevert session", "error", err)
-				return toast.NewErrorToast("Failed to redo message")()
-			}
-			if response == nil {
-				return toast.NewErrorToast("Failed to redo message")()
-			}
-			return app.SessionUnrevertedMsg{Session: *response}
-		}
-	}
-
-	return m, func() tea.Msg {
-		// calling revert on a "later" message is like a redo
-		response, err := m.app.Client.Session.Revert(
-			context.Background(),
-			m.app.Session.ID,
-			opencode.SessionRevertParams{
-				MessageID: opencode.F(messageID),
-			},
-		)
-		if err != nil {
-			slog.Error("Failed to redo message", "error", err)
-			return toast.NewErrorToast("Failed to redo message")()
-		}
-		if response == nil {
-			return toast.NewErrorToast("Failed to redo message")()
-		}
-		return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
-	}
-}
-
-func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
-	if m.messagePositions == nil {
-		return m, nil
-	}
-
-	if position, exists := m.messagePositions[messageID]; exists {
-		m.viewport.SetYOffset(position)
-		m.tail = false // Stop auto-scrolling to bottom when manually navigating
-	}
-	return m, nil
-}
-
-func NewMessagesComponent(app *app.App) MessagesComponent {
-	vp := viewport.New()
-	vp.KeyMap = viewport.KeyMap{}
-
-	if app.ScrollSpeed > 0 {
-		vp.MouseWheelDelta = app.ScrollSpeed
-	} else {
-		vp.MouseWheelDelta = 2
-	}
-
-	// Default to showing tool details, hidden thinking blocks
-	showToolDetails := true
-	if app.State.ShowToolDetails != nil {
-		showToolDetails = *app.State.ShowToolDetails
-	}
-
-	showThinkingBlocks := false
-	if app.State.ShowThinkingBlocks != nil {
-		showThinkingBlocks = *app.State.ShowThinkingBlocks
-	}
-
-	return &messagesComponent{
-		app:                app,
-		viewport:           vp,
-		showToolDetails:    showToolDetails,
-		showThinkingBlocks: showThinkingBlocks,
-		cache:              NewPartCache(),
-		tail:               true,
-		messagePositions:   make(map[string]int),
-	}
-}

+ 0 - 247
packages/tui/internal/components/commands/commands.go

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

+ 0 - 452
packages/tui/internal/components/dialog/agents.go

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

+ 0 - 314
packages/tui/internal/components/dialog/complete.go

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

+ 0 - 80
packages/tui/internal/components/dialog/help.go

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

+ 0 - 458
packages/tui/internal/components/dialog/models.go

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

+ 0 - 255
packages/tui/internal/components/dialog/search.go

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

+ 0 - 400
packages/tui/internal/components/dialog/session.go

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

+ 0 - 132
packages/tui/internal/components/dialog/theme.go

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

+ 0 - 353
packages/tui/internal/components/dialog/timeline.go

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

+ 0 - 957
packages/tui/internal/components/diff/diff.go

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

+ 0 - 58
packages/tui/internal/components/diff/parse.go

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

+ 0 - 436
packages/tui/internal/components/list/list.go

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

+ 0 - 249
packages/tui/internal/components/list/list_test.go

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

+ 0 - 145
packages/tui/internal/components/modal/modal.go

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

+ 0 - 56
packages/tui/internal/components/qr/qr.go

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

+ 0 - 340
packages/tui/internal/components/status/status.go

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

+ 0 - 100
packages/tui/internal/components/status/status_test.go

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

+ 0 - 125
packages/tui/internal/components/textarea/memoization.go

@@ -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 changed in this diff