summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-11-02 18:43:17 -0500
committerDax Raad <[email protected]>2025-11-02 18:43:33 -0500
commitf68374ad2223ddc213bdea9519ca6a699819ee0e (patch)
tree04f0fe21b8e12cd62d7274961bb0cff64f966f40 /packages
parent5e86c9b7916f75c7ad227b80eab18c7c54fc8ffe (diff)
downloadopencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.tar.gz
opencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.zip
DELETE GO BUBBLETEA CRAP HOORAY
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme.tsx46
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/aura.json (renamed from packages/tui/internal/theme/themes/aura.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/ayu.json (renamed from packages/tui/internal/theme/themes/ayu.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json (renamed from packages/tui/internal/theme/themes/catppuccin.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/cobalt2.json (renamed from packages/tui/internal/theme/themes/cobalt2.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/dracula.json (renamed from packages/tui/internal/theme/themes/dracula.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/everforest.json (renamed from packages/tui/internal/theme/themes/everforest.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/github.json (renamed from packages/tui/internal/theme/themes/github.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/gruvbox.json (renamed from packages/tui/internal/theme/themes/gruvbox.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/kanagawa.json (renamed from packages/tui/internal/theme/themes/kanagawa.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/material.json (renamed from packages/tui/internal/theme/themes/material.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/matrix.json (renamed from packages/tui/internal/theme/themes/matrix.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/monokai.json (renamed from packages/tui/internal/theme/themes/monokai.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json (renamed from packages/tui/internal/theme/themes/nightowl.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/nord.json (renamed from packages/tui/internal/theme/themes/nord.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/one-dark.json (renamed from packages/tui/internal/theme/themes/one-dark.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/opencode.json (renamed from packages/tui/internal/theme/themes/opencode.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/palenight.json (renamed from packages/tui/internal/theme/themes/palenight.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/rosepine.json (renamed from packages/tui/internal/theme/themes/rosepine.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/solarized.json (renamed from packages/tui/internal/theme/themes/solarized.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/synthwave84.json (renamed from packages/tui/internal/theme/themes/synthwave84.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/tokyonight.json (renamed from packages/tui/internal/theme/themes/tokyonight.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/vesper.json (renamed from packages/tui/internal/theme/themes/vesper.json)0
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme/zenburn.json (renamed from packages/tui/internal/theme/themes/zenburn.json)0
-rw-r--r--packages/tui/.gitignore4
-rw-r--r--packages/tui/.goreleaser.yml77
-rw-r--r--packages/tui/cmd/opencode/main.go175
-rw-r--r--packages/tui/go.mod99
-rw-r--r--packages/tui/go.sum313
-rw-r--r--packages/tui/input/cancelreader_other.go14
-rw-r--r--packages/tui/input/cancelreader_windows.go143
-rw-r--r--packages/tui/input/clipboard.go25
-rw-r--r--packages/tui/input/color.go136
-rw-r--r--packages/tui/input/cursor.go7
-rw-r--r--packages/tui/input/da1.go18
-rw-r--r--packages/tui/input/doc.go6
-rw-r--r--packages/tui/input/driver.go192
-rw-r--r--packages/tui/input/driver_other.go17
-rw-r--r--packages/tui/input/driver_test.go25
-rw-r--r--packages/tui/input/driver_windows.go642
-rw-r--r--packages/tui/input/driver_windows_test.go271
-rw-r--r--packages/tui/input/focus.go9
-rw-r--r--packages/tui/input/focus_test.go27
-rw-r--r--packages/tui/input/go.mod18
-rw-r--r--packages/tui/input/go.sum19
-rw-r--r--packages/tui/input/input.go45
-rw-r--r--packages/tui/input/key.go574
-rw-r--r--packages/tui/input/key_test.go880
-rw-r--r--packages/tui/input/kitty.go353
-rw-r--r--packages/tui/input/mod.go37
-rw-r--r--packages/tui/input/mode.go14
-rw-r--r--packages/tui/input/mouse.go292
-rw-r--r--packages/tui/input/mouse_test.go481
-rw-r--r--packages/tui/input/parse.go1030
-rw-r--r--packages/tui/input/parse_test.go47
-rw-r--r--packages/tui/input/paste.go13
-rw-r--r--packages/tui/input/table.go389
-rw-r--r--packages/tui/input/termcap.go54
-rw-r--r--packages/tui/input/terminfo.go277
-rw-r--r--packages/tui/input/xterm.go47
-rw-r--r--packages/tui/internal/api/api.go41
-rw-r--r--packages/tui/internal/app/app.go963
-rw-r--r--packages/tui/internal/app/app_test.go304
-rw-r--r--packages/tui/internal/app/prompt.go283
-rw-r--r--packages/tui/internal/app/state.go174
-rw-r--r--packages/tui/internal/attachment/attachment.go178
-rw-r--r--packages/tui/internal/clipboard/clipboard.go155
-rw-r--r--packages/tui/internal/clipboard/clipboard_darwin.go266
-rw-r--r--packages/tui/internal/clipboard/clipboard_linux.go311
-rw-r--r--packages/tui/internal/clipboard/clipboard_nocgo.go25
-rw-r--r--packages/tui/internal/clipboard/clipboard_windows.go551
-rw-r--r--packages/tui/internal/commands/command.go423
-rw-r--r--packages/tui/internal/completions/agents.go75
-rw-r--r--packages/tui/internal/completions/commands.go144
-rw-r--r--packages/tui/internal/completions/files.go126
-rw-r--r--packages/tui/internal/completions/provider.go8
-rw-r--r--packages/tui/internal/completions/suggestion.go24
-rw-r--r--packages/tui/internal/completions/symbols.go119
-rw-r--r--packages/tui/internal/components/chat/cache.go62
-rw-r--r--packages/tui/internal/components/chat/editor.go906
-rw-r--r--packages/tui/internal/components/chat/message.go1031
-rw-r--r--packages/tui/internal/components/chat/messages.go1322
-rw-r--r--packages/tui/internal/components/commands/commands.go247
-rw-r--r--packages/tui/internal/components/dialog/agents.go452
-rw-r--r--packages/tui/internal/components/dialog/complete.go314
-rw-r--r--packages/tui/internal/components/dialog/help.go80
-rw-r--r--packages/tui/internal/components/dialog/models.go458
-rw-r--r--packages/tui/internal/components/dialog/search.go255
-rw-r--r--packages/tui/internal/components/dialog/session.go400
-rw-r--r--packages/tui/internal/components/dialog/theme.go132
-rw-r--r--packages/tui/internal/components/dialog/timeline.go353
-rw-r--r--packages/tui/internal/components/diff/diff.go957
-rw-r--r--packages/tui/internal/components/diff/parse.go58
-rw-r--r--packages/tui/internal/components/list/list.go436
-rw-r--r--packages/tui/internal/components/list/list_test.go249
-rw-r--r--packages/tui/internal/components/modal/modal.go145
-rw-r--r--packages/tui/internal/components/qr/qr.go56
-rw-r--r--packages/tui/internal/components/status/status.go340
-rw-r--r--packages/tui/internal/components/status/status_test.go100
-rw-r--r--packages/tui/internal/components/textarea/memoization.go125
-rw-r--r--packages/tui/internal/components/textarea/runeutil.go102
-rw-r--r--packages/tui/internal/components/textarea/textarea.go2377
-rw-r--r--packages/tui/internal/components/textarea/textarea_test.go75
-rw-r--r--packages/tui/internal/components/toast/toast.go266
-rw-r--r--packages/tui/internal/decoders/decoder.go118
-rw-r--r--packages/tui/internal/decoders/decoder_test.go194
-rw-r--r--packages/tui/internal/id/id.go96
-rw-r--r--packages/tui/internal/layout/flex.go325
-rw-r--r--packages/tui/internal/layout/layout.go32
-rw-r--r--packages/tui/internal/layout/overlay.go382
-rw-r--r--packages/tui/internal/styles/background.go17
-rw-r--r--packages/tui/internal/styles/markdown.go326
-rw-r--r--packages/tui/internal/styles/styles.go10
-rw-r--r--packages/tui/internal/styles/utilities.go295
-rw-r--r--packages/tui/internal/theme/loader.go408
-rw-r--r--packages/tui/internal/theme/loader_test.go141
-rw-r--r--packages/tui/internal/theme/manager.go229
-rw-r--r--packages/tui/internal/theme/system.go303
-rw-r--r--packages/tui/internal/theme/theme.go215
-rw-r--r--packages/tui/internal/theme/themes/mellow.json87
-rw-r--r--packages/tui/internal/tui/tui.go1636
-rw-r--r--packages/tui/internal/util/apilogger.go154
-rw-r--r--packages/tui/internal/util/color.go115
-rw-r--r--packages/tui/internal/util/concurrency.go40
-rw-r--r--packages/tui/internal/util/concurrency_test.go23
-rw-r--r--packages/tui/internal/util/file.go113
-rw-r--r--packages/tui/internal/util/ide.go31
-rw-r--r--packages/tui/internal/util/shimmer.go138
-rw-r--r--packages/tui/internal/util/util.go71
-rw-r--r--packages/tui/internal/viewport/highlight.go141
-rw-r--r--packages/tui/internal/viewport/keymap.go56
-rw-r--r--packages/tui/internal/viewport/viewport.go803
132 files changed, 23 insertions, 28760 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index 0078a369d..93eae6c21 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -2,29 +2,29 @@ import { SyntaxStyle, RGBA } from "@opentui/core"
import { createMemo, createSignal } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
-import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
-import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" }
-import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" }
-import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" }
-import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" }
-import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" }
-import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" }
-import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" }
-import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" }
-import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" }
-import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" }
-import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" }
-import nightowl from "../../../../../../tui/internal/theme/themes/nightowl.json" with { type: "json" }
-import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" }
-import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" }
-import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" }
-import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" }
-import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" }
-import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" }
-import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" }
-import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
-import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
-import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
+import aura from "./theme/aura.json" with { type: "json" }
+import ayu from "./theme/ayu.json" with { type: "json" }
+import catppuccin from "./theme/catppuccin.json" with { type: "json" }
+import cobalt2 from "./theme/cobalt2.json" with { type: "json" }
+import dracula from "./theme/dracula.json" with { type: "json" }
+import everforest from "./theme/everforest.json" with { type: "json" }
+import github from "./theme/github.json" with { type: "json" }
+import gruvbox from "./theme/gruvbox.json" with { type: "json" }
+import kanagawa from "./theme/kanagawa.json" with { type: "json" }
+import material from "./theme/material.json" with { type: "json" }
+import matrix from "./theme/matrix.json" with { type: "json" }
+import monokai from "./theme/monokai.json" with { type: "json" }
+import nightowl from "./theme/nightowl.json" with { type: "json" }
+import nord from "./theme/nord.json" with { type: "json" }
+import onedark from "./theme/one-dark.json" with { type: "json" }
+import opencode from "./theme/opencode.json" with { type: "json" }
+import palenight from "./theme/palenight.json" with { type: "json" }
+import rosepine from "./theme/rosepine.json" with { type: "json" }
+import solarized from "./theme/solarized.json" with { type: "json" }
+import synthwave84 from "./theme/synthwave84.json" with { type: "json" }
+import tokyonight from "./theme/tokyonight.json" with { type: "json" }
+import vesper from "./theme/vesper.json" with { type: "json" }
+import zenburn from "./theme/zenburn.json" with { type: "json" }
import { useKV } from "./kv"
type Theme = {
diff --git a/packages/tui/internal/theme/themes/aura.json b/packages/opencode/src/cli/cmd/tui/context/theme/aura.json
index e7798d520..e7798d520 100644
--- a/packages/tui/internal/theme/themes/aura.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/aura.json
diff --git a/packages/tui/internal/theme/themes/ayu.json b/packages/opencode/src/cli/cmd/tui/context/theme/ayu.json
index a42fce4c4..a42fce4c4 100644
--- a/packages/tui/internal/theme/themes/ayu.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/ayu.json
diff --git a/packages/tui/internal/theme/themes/catppuccin.json b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json
index d0fa6a11d..d0fa6a11d 100644
--- a/packages/tui/internal/theme/themes/catppuccin.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json
diff --git a/packages/tui/internal/theme/themes/cobalt2.json b/packages/opencode/src/cli/cmd/tui/context/theme/cobalt2.json
index 2967eae58..2967eae58 100644
--- a/packages/tui/internal/theme/themes/cobalt2.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/cobalt2.json
diff --git a/packages/tui/internal/theme/themes/dracula.json b/packages/opencode/src/cli/cmd/tui/context/theme/dracula.json
index c837a0b58..c837a0b58 100644
--- a/packages/tui/internal/theme/themes/dracula.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/dracula.json
diff --git a/packages/tui/internal/theme/themes/everforest.json b/packages/opencode/src/cli/cmd/tui/context/theme/everforest.json
index 62dfb31ba..62dfb31ba 100644
--- a/packages/tui/internal/theme/themes/everforest.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/everforest.json
diff --git a/packages/tui/internal/theme/themes/github.json b/packages/opencode/src/cli/cmd/tui/context/theme/github.json
index 99a80879e..99a80879e 100644
--- a/packages/tui/internal/theme/themes/github.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/github.json
diff --git a/packages/tui/internal/theme/themes/gruvbox.json b/packages/opencode/src/cli/cmd/tui/context/theme/gruvbox.json
index c3101b565..c3101b565 100644
--- a/packages/tui/internal/theme/themes/gruvbox.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/gruvbox.json
diff --git a/packages/tui/internal/theme/themes/kanagawa.json b/packages/opencode/src/cli/cmd/tui/context/theme/kanagawa.json
index 91a784014..91a784014 100644
--- a/packages/tui/internal/theme/themes/kanagawa.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/kanagawa.json
diff --git a/packages/tui/internal/theme/themes/material.json b/packages/opencode/src/cli/cmd/tui/context/theme/material.json
index c3a106808..c3a106808 100644
--- a/packages/tui/internal/theme/themes/material.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/material.json
diff --git a/packages/tui/internal/theme/themes/matrix.json b/packages/opencode/src/cli/cmd/tui/context/theme/matrix.json
index 354946284..354946284 100644
--- a/packages/tui/internal/theme/themes/matrix.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/matrix.json
diff --git a/packages/tui/internal/theme/themes/monokai.json b/packages/opencode/src/cli/cmd/tui/context/theme/monokai.json
index 09637a1e2..09637a1e2 100644
--- a/packages/tui/internal/theme/themes/monokai.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/monokai.json
diff --git a/packages/tui/internal/theme/themes/nightowl.json b/packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json
index 8eff42c5f..8eff42c5f 100644
--- a/packages/tui/internal/theme/themes/nightowl.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/nightowl.json
diff --git a/packages/tui/internal/theme/themes/nord.json b/packages/opencode/src/cli/cmd/tui/context/theme/nord.json
index 4a525382a..4a525382a 100644
--- a/packages/tui/internal/theme/themes/nord.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/nord.json
diff --git a/packages/tui/internal/theme/themes/one-dark.json b/packages/opencode/src/cli/cmd/tui/context/theme/one-dark.json
index 73b24e929..73b24e929 100644
--- a/packages/tui/internal/theme/themes/one-dark.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/one-dark.json
diff --git a/packages/tui/internal/theme/themes/opencode.json b/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json
index 8f585a450..8f585a450 100644
--- a/packages/tui/internal/theme/themes/opencode.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json
diff --git a/packages/tui/internal/theme/themes/palenight.json b/packages/opencode/src/cli/cmd/tui/context/theme/palenight.json
index 79f7c59e8..79f7c59e8 100644
--- a/packages/tui/internal/theme/themes/palenight.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/palenight.json
diff --git a/packages/tui/internal/theme/themes/rosepine.json b/packages/opencode/src/cli/cmd/tui/context/theme/rosepine.json
index 444cdbd13..444cdbd13 100644
--- a/packages/tui/internal/theme/themes/rosepine.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/rosepine.json
diff --git a/packages/tui/internal/theme/themes/solarized.json b/packages/opencode/src/cli/cmd/tui/context/theme/solarized.json
index e4de11367..e4de11367 100644
--- a/packages/tui/internal/theme/themes/solarized.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/solarized.json
diff --git a/packages/tui/internal/theme/themes/synthwave84.json b/packages/opencode/src/cli/cmd/tui/context/theme/synthwave84.json
index d25bf3b49..d25bf3b49 100644
--- a/packages/tui/internal/theme/themes/synthwave84.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/synthwave84.json
diff --git a/packages/tui/internal/theme/themes/tokyonight.json b/packages/opencode/src/cli/cmd/tui/context/theme/tokyonight.json
index 1c9503a42..1c9503a42 100644
--- a/packages/tui/internal/theme/themes/tokyonight.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/tokyonight.json
diff --git a/packages/tui/internal/theme/themes/vesper.json b/packages/opencode/src/cli/cmd/tui/context/theme/vesper.json
index 758c8f20c..758c8f20c 100644
--- a/packages/tui/internal/theme/themes/vesper.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/vesper.json
diff --git a/packages/tui/internal/theme/themes/zenburn.json b/packages/opencode/src/cli/cmd/tui/context/theme/zenburn.json
index c4475923b..c4475923b 100644
--- a/packages/tui/internal/theme/themes/zenburn.json
+++ b/packages/opencode/src/cli/cmd/tui/context/theme/zenburn.json
diff --git a/packages/tui/.gitignore b/packages/tui/.gitignore
deleted file mode 100644
index 541a71ae2..000000000
--- a/packages/tui/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-opencode-test
-cmd/opencode/opencode
-opencode
-
diff --git a/packages/tui/.goreleaser.yml b/packages/tui/.goreleaser.yml
deleted file mode 100644
index 1545199d5..000000000
--- a/packages/tui/.goreleaser.yml
+++ /dev/null
@@ -1,77 +0,0 @@
-version: 2
-project_name: opencode
-before:
- hooks:
-builds:
- - env:
- - CGO_ENABLED=0
- goos:
- - linux
- - darwin
- goarch:
- - amd64
- - arm64
- ldflags:
- - -s -w -X github.com/sst/opencode/internal/version.Version={{.Version}}
- main: ./main.go
-
-archives:
- - format: tar.gz
- name_template: >-
- opencode-
- {{- if eq .Os "darwin" }}mac-
- {{- else if eq .Os "windows" }}windows-
- {{- else if eq .Os "linux" }}linux-{{end}}
- {{- if eq .Arch "amd64" }}x86_64
- {{- else if eq .Arch "#86" }}i386
- {{- else }}{{ .Arch }}{{ end }}
- {{- if .Arm }}v{{ .Arm }}{{ end }}
- format_overrides:
- - goos: windows
- format: zip
-checksum:
- name_template: "checksums.txt"
-snapshot:
- name_template: "0.0.0-{{ .Timestamp }}"
-aurs:
- - name: opencode
- homepage: "https://github.com/sst/opencode"
- description: "terminal based agent that can build anything"
- maintainers:
- - "dax"
- - "adam"
- license: "MIT"
- private_key: "{{ .Env.AUR_KEY }}"
- git_url: "ssh://[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:"
diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go
deleted file mode 100644
index 3a7d1848a..000000000
--- a/packages/tui/cmd/opencode/main.go
+++ /dev/null
@@ -1,175 +0,0 @@
-package main
-
-import (
- "context"
- "io"
- "log/slog"
- "os"
- "os/signal"
- "strings"
- "syscall"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- flag "github.com/spf13/pflag"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode-sdk-go/option"
- "github.com/sst/opencode-sdk-go/packages/ssestream"
- "github.com/sst/opencode/internal/api"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/clipboard"
- "github.com/sst/opencode/internal/decoders"
- "github.com/sst/opencode/internal/tui"
- "github.com/sst/opencode/internal/util"
- "golang.org/x/sync/errgroup"
-)
-
-var Version = "dev"
-
-func main() {
- version := Version
- if version != "dev" && !strings.HasPrefix(Version, "v") {
- version = "v" + Version
- }
-
- var model *string = flag.String("model", "", "model to begin with")
- var prompt *string = flag.String("prompt", "", "prompt to begin with")
- var agent *string = flag.String("agent", "", "agent to begin with")
- var sessionID *string = flag.String("session", "", "session ID")
- flag.Parse()
-
- url := os.Getenv("OPENCODE_SERVER")
-
- stat, err := os.Stdin.Stat()
- if err != nil {
- slog.Error("Failed to stat stdin", "error", err)
- os.Exit(1)
- }
-
- // Check if there's data piped to stdin
- if (stat.Mode() & os.ModeCharDevice) == 0 {
- stdin, err := io.ReadAll(os.Stdin)
- if err != nil {
- slog.Error("Failed to read stdin", "error", err)
- os.Exit(1)
- }
- stdinContent := strings.TrimSpace(string(stdin))
- if stdinContent != "" {
- if prompt == nil || *prompt == "" {
- prompt = &stdinContent
- } else {
- combined := *prompt + "\n" + stdinContent
- prompt = &combined
- }
- }
- }
-
- // Register custom SSE decoder to handle large events (>32MB)
- // This is a workaround for the bufio.Scanner token size limit in the auto-generated SDK
- // See: packages/tui/internal/decoders/decoder.go
- ssestream.RegisterDecoder("text/event-stream", decoders.NewUnboundedDecoder)
-
- httpClient := opencode.NewClient(
- option.WithBaseURL(url),
- )
-
- var agents []opencode.Agent
- var path *opencode.Path
- var project *opencode.Project
-
- batch := errgroup.Group{}
-
- batch.Go(func() error {
- result, err := httpClient.Project.Current(context.Background(), opencode.ProjectCurrentParams{})
- if err != nil {
- return err
- }
- project = result
- return nil
- })
-
- batch.Go(func() error {
- result, err := httpClient.Agent.List(context.Background(), opencode.AgentListParams{})
- if err != nil {
- return err
- }
- agents = *result
- return nil
- })
-
- batch.Go(func() error {
- result, err := httpClient.Path.Get(context.Background(), opencode.PathGetParams{})
- if err != nil {
- return err
- }
- path = result
- return nil
- })
-
- err = batch.Wait()
- if err != nil {
- panic(err)
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- apiHandler := util.NewAPILogHandler(ctx, httpClient, "tui", slog.LevelDebug)
- logger := slog.New(apiHandler)
- slog.SetDefault(logger)
-
- slog.Debug("TUI launched")
-
- go func() {
- err = clipboard.Init()
- if err != nil {
- slog.Error("Failed to initialize clipboard", "error", err)
- }
- }()
-
- // Create main context for the application
- app_, err := app.New(ctx, version, project, path, agents, httpClient, model, prompt, agent, sessionID)
- if err != nil {
- panic(err)
- }
-
- tuiModel := tui.NewModel(app_).(*tui.Model)
- program := tea.NewProgram(
- tuiModel,
- tea.WithAltScreen(),
- tea.WithMouseCellMotion(),
- )
-
- // Set up signal handling for graceful shutdown
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
-
- go func() {
- stream := httpClient.Event.ListStreaming(ctx, opencode.EventListParams{})
- for stream.Next() {
- evt := stream.Current().AsUnion()
- program.Send(evt)
- }
- if err := stream.Err(); err != nil {
- slog.Error("Error streaming events", "error", err)
- program.Send(err)
- }
- }()
-
- go api.Start(ctx, program, httpClient)
-
- // Handle signals in a separate goroutine
- go func() {
- sig := <-sigChan
- slog.Info("Received signal, shutting down gracefully", "signal", sig)
- tuiModel.Cleanup()
- program.Quit()
- }()
-
- // Run the TUI
- result, err := program.Run()
- if err != nil {
- slog.Error("TUI error", "error", err)
- }
-
- tuiModel.Cleanup()
- slog.Info("TUI exited", "result", result)
-}
diff --git a/packages/tui/go.mod b/packages/tui/go.mod
deleted file mode 100644
index 6ee7c1b76..000000000
--- a/packages/tui/go.mod
+++ /dev/null
@@ -1,99 +0,0 @@
-module github.com/sst/opencode
-
-go 1.24.0
-
-require (
- github.com/BurntSushi/toml v1.5.0
- github.com/alecthomas/chroma/v2 v2.18.0
- github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
- github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
- github.com/charmbracelet/glamour v0.10.0
- github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
- github.com/charmbracelet/x/ansi v0.9.3
- github.com/fsnotify/fsnotify v1.8.0
- github.com/google/uuid v1.6.0
- github.com/lithammer/fuzzysearch v1.1.8
- github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
- github.com/muesli/reflow v0.3.0
- github.com/muesli/termenv v0.16.0
- github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
- github.com/sst/opencode-sdk-go v0.1.0-alpha.8
- golang.org/x/image v0.28.0
- rsc.io/qr v0.2.0
-)
-
-replace (
- github.com/charmbracelet/x/input => ./input
- github.com/sst/opencode-sdk-go => ../sdk/go
-)
-
-require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
-
-require (
- dario.cat/mergo v1.0.2 // indirect
- github.com/atombender/go-jsonschema v0.20.0 // indirect
- github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
- github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
- github.com/charmbracelet/x/input v0.3.7 // indirect
- github.com/charmbracelet/x/windows v0.2.1 // indirect
- github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
- github.com/getkin/kin-openapi v0.127.0 // indirect
- github.com/go-openapi/jsonpointer v0.21.0 // indirect
- github.com/go-openapi/swag v0.23.0 // indirect
- github.com/goccy/go-yaml v1.17.1 // indirect
- github.com/invopop/yaml v0.3.1 // indirect
- github.com/josharian/intern v1.0.0 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
- github.com/mitchellh/go-wordwrap v1.0.1 // indirect
- github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
- github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
- github.com/perimeterx/marshmallow v1.1.5 // indirect
- github.com/pkg/errors v0.9.1 // indirect
- github.com/sanity-io/litter v1.5.8 // indirect
- github.com/sosodev/duration v1.3.1 // indirect
- github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
- github.com/spf13/cobra v1.9.1 // indirect
- github.com/tidwall/gjson v1.14.4 // indirect
- github.com/tidwall/match v1.1.1 // indirect
- github.com/tidwall/pretty v1.2.1 // indirect
- github.com/tidwall/sjson v1.2.5 // indirect
- github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
- golang.org/x/mod v0.25.0 // indirect
- golang.org/x/tools v0.34.0 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
-)
-
-require (
- github.com/atotto/clipboard v0.1.4 // indirect
- github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
- github.com/aymerick/douceur v0.2.0 // indirect
- github.com/charmbracelet/colorprofile v0.3.1 // indirect
- github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect
- github.com/charmbracelet/x/term v0.2.1 // indirect
- github.com/dlclark/regexp2 v1.11.5 // indirect
- github.com/google/go-cmp v0.7.0 // indirect
- github.com/gorilla/css v1.0.1 // indirect
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/lucasb-eyer/go-colorful v1.2.0
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.16
- github.com/microcosm-cc/bluemonday v1.0.27 // indirect
- github.com/muesli/cancelreader v0.2.2 // indirect
- github.com/rivo/uniseg v0.4.7
- github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/spf13/pflag v1.0.6
- github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- github.com/yuin/goldmark v1.7.8 // indirect
- github.com/yuin/goldmark-emoji v1.0.5 // indirect
- golang.org/x/net v0.41.0 // indirect
- golang.org/x/sync v0.15.0
- golang.org/x/sys v0.33.0 // indirect
- golang.org/x/term v0.32.0 // indirect
- golang.org/x/text v0.26.0
- gopkg.in/yaml.v3 v3.0.1 // indirect
-)
-
-tool (
- github.com/atombender/go-jsonschema
- github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
-)
diff --git a/packages/tui/go.sum b/packages/tui/go.sum
deleted file mode 100644
index 370ea7121..000000000
--- a/packages/tui/go.sum
+++ /dev/null
@@ -1,313 +0,0 @@
-dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
-dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
-github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
-github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
-github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
-github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
-github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
-github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/chroma/v2 v2.18.0 h1:6h53Q4hW83SuF+jcsp7CVhLsMozzvQvO8HBbKQW+gn4=
-github.com/alecthomas/chroma/v2 v2.18.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk=
-github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
-github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
-github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
-github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
-github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
-github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
-github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
-github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
-github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
-github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
-github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
-github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
-github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
-github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
-github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
-github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
-github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
-github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
-github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
-github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
-github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
-github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
-github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
-github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
-github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
-github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
-github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
-github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
-github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
-github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
-github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
-github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
-github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
-github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
-github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
-github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
-github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
-github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
-github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
-github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
-github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
-github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
-github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
-github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
-github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
-github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
-github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
-github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
-github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
-github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
-github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
-github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
-github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
-github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
-github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
-github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
-github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
-github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
-github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
-github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
-github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
-github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
-github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
-github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
-github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
-github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
-github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
-github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
-github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
-github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
-github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
-github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
-github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
-github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
-github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
-github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
-github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
-github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
-github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
-github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
-github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
-github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
-github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
-github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
-github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
-github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
-github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
-github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
-github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
-golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
-golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
-golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
-golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
-golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
-golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
-golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
-golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
-golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
-golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
-golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
-rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/packages/tui/input/cancelreader_other.go b/packages/tui/input/cancelreader_other.go
deleted file mode 100644
index dbd22a2e9..000000000
--- a/packages/tui/input/cancelreader_other.go
+++ /dev/null
@@ -1,14 +0,0 @@
-//go:build !windows
-// +build !windows
-
-package input
-
-import (
- "io"
-
- "github.com/muesli/cancelreader"
-)
-
-func newCancelreader(r io.Reader, _ int) (cancelreader.CancelReader, error) {
- return cancelreader.NewReader(r) //nolint:wrapcheck
-}
diff --git a/packages/tui/input/cancelreader_windows.go b/packages/tui/input/cancelreader_windows.go
deleted file mode 100644
index 19abfce4a..000000000
--- a/packages/tui/input/cancelreader_windows.go
+++ /dev/null
@@ -1,143 +0,0 @@
-//go:build windows
-// +build windows
-
-package input
-
-import (
- "fmt"
- "io"
- "os"
- "sync"
-
- xwindows "github.com/charmbracelet/x/windows"
- "github.com/muesli/cancelreader"
- "golang.org/x/sys/windows"
-)
-
-type conInputReader struct {
- cancelMixin
- conin windows.Handle
- originalMode uint32
-}
-
-var _ cancelreader.CancelReader = &conInputReader{}
-
-func newCancelreader(r io.Reader, flags int) (cancelreader.CancelReader, error) {
- fallback := func(io.Reader) (cancelreader.CancelReader, error) {
- return cancelreader.NewReader(r)
- }
-
- var dummy uint32
- if f, ok := r.(cancelreader.File); !ok || f.Fd() != os.Stdin.Fd() ||
- // If data was piped to the standard input, it does not emit events
- // anymore. We can detect this if the console mode cannot be set anymore,
- // in this case, we fallback to the default cancelreader implementation.
- windows.GetConsoleMode(windows.Handle(f.Fd()), &dummy) != nil {
- return fallback(r)
- }
-
- conin, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
- if err != nil {
- return fallback(r)
- }
-
- // Discard any pending input events.
- if err := xwindows.FlushConsoleInputBuffer(conin); err != nil {
- return fallback(r)
- }
-
- modes := []uint32{
- windows.ENABLE_WINDOW_INPUT,
- windows.ENABLE_EXTENDED_FLAGS,
- }
-
- // Enabling mouse mode implicitly blocks console text selection. Thus, we
- // need to enable it only if the mouse mode is requested.
- // In order to toggle mouse mode, the caller must recreate the reader with
- // the appropriate flag toggled.
- if flags&FlagMouseMode != 0 {
- modes = append(modes, windows.ENABLE_MOUSE_INPUT)
- }
-
- originalMode, err := prepareConsole(conin, modes...)
- if err != nil {
- return nil, fmt.Errorf("failed to prepare console input: %w", err)
- }
-
- return &conInputReader{
- conin: conin,
- originalMode: originalMode,
- }, nil
-}
-
-// Cancel implements cancelreader.CancelReader.
-func (r *conInputReader) Cancel() bool {
- r.setCanceled()
-
- return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
-}
-
-// Close implements cancelreader.CancelReader.
-func (r *conInputReader) Close() error {
- if r.originalMode != 0 {
- err := windows.SetConsoleMode(r.conin, r.originalMode)
- if err != nil {
- return fmt.Errorf("reset console mode: %w", err)
- }
- }
-
- return nil
-}
-
-// Read implements cancelreader.CancelReader.
-func (r *conInputReader) Read(data []byte) (int, error) {
- if r.isCanceled() {
- return 0, cancelreader.ErrCanceled
- }
-
- var n uint32
- if err := windows.ReadFile(r.conin, data, &n, nil); err != nil {
- return int(n), fmt.Errorf("read console input: %w", err)
- }
-
- return int(n), nil
-}
-
-func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
- err = windows.GetConsoleMode(input, &originalMode)
- if err != nil {
- return 0, fmt.Errorf("get console mode: %w", err)
- }
-
- var newMode uint32
- for _, mode := range modes {
- newMode |= mode
- }
-
- err = windows.SetConsoleMode(input, newMode)
- if err != nil {
- return 0, fmt.Errorf("set console mode: %w", err)
- }
-
- return originalMode, nil
-}
-
-// cancelMixin represents a goroutine-safe cancelation status.
-type cancelMixin struct {
- unsafeCanceled bool
- lock sync.Mutex
-}
-
-func (c *cancelMixin) setCanceled() {
- c.lock.Lock()
- defer c.lock.Unlock()
-
- c.unsafeCanceled = true
-}
-
-func (c *cancelMixin) isCanceled() bool {
- c.lock.Lock()
- defer c.lock.Unlock()
-
- return c.unsafeCanceled
-}
diff --git a/packages/tui/input/clipboard.go b/packages/tui/input/clipboard.go
deleted file mode 100644
index 725a2d955..000000000
--- a/packages/tui/input/clipboard.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package input
-
-import "github.com/charmbracelet/x/ansi"
-
-// ClipboardSelection represents a clipboard selection. The most common
-// clipboard selections are "system" and "primary" and selections.
-type ClipboardSelection = byte
-
-// Clipboard selections.
-const (
- SystemClipboard ClipboardSelection = ansi.SystemClipboard
- PrimaryClipboard ClipboardSelection = ansi.PrimaryClipboard
-)
-
-// ClipboardEvent is a clipboard read message event. This message is emitted when
-// a terminal receives an OSC52 clipboard read message event.
-type ClipboardEvent struct {
- Content string
- Selection ClipboardSelection
-}
-
-// String returns the string representation of the clipboard message.
-func (e ClipboardEvent) String() string {
- return e.Content
-}
diff --git a/packages/tui/input/color.go b/packages/tui/input/color.go
deleted file mode 100644
index 9bcf74999..000000000
--- a/packages/tui/input/color.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package input
-
-import (
- "fmt"
- "image/color"
- "math"
-)
-
-// ForegroundColorEvent represents a foreground color event. This event is
-// emitted when the terminal requests the terminal foreground color using
-// [ansi.RequestForegroundColor].
-type ForegroundColorEvent struct{ color.Color }
-
-// String returns the hex representation of the color.
-func (e ForegroundColorEvent) String() string {
- return colorToHex(e.Color)
-}
-
-// IsDark returns whether the color is dark.
-func (e ForegroundColorEvent) IsDark() bool {
- return isDarkColor(e.Color)
-}
-
-// BackgroundColorEvent represents a background color event. This event is
-// emitted when the terminal requests the terminal background color using
-// [ansi.RequestBackgroundColor].
-type BackgroundColorEvent struct{ color.Color }
-
-// String returns the hex representation of the color.
-func (e BackgroundColorEvent) String() string {
- return colorToHex(e)
-}
-
-// IsDark returns whether the color is dark.
-func (e BackgroundColorEvent) IsDark() bool {
- return isDarkColor(e.Color)
-}
-
-// CursorColorEvent represents a cursor color change event. This event is
-// emitted when the program requests the terminal cursor color using
-// [ansi.RequestCursorColor].
-type CursorColorEvent struct{ color.Color }
-
-// String returns the hex representation of the color.
-func (e CursorColorEvent) String() string {
- return colorToHex(e)
-}
-
-// IsDark returns whether the color is dark.
-func (e CursorColorEvent) IsDark() bool {
- return isDarkColor(e)
-}
-
-type shiftable interface {
- ~uint | ~uint16 | ~uint32 | ~uint64
-}
-
-func shift[T shiftable](x T) T {
- if x > 0xff {
- x >>= 8
- }
- return x
-}
-
-func colorToHex(c color.Color) string {
- if c == nil {
- return ""
- }
- r, g, b, _ := c.RGBA()
- return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b))
-}
-
-func getMaxMin(a, b, c float64) (ma, mi float64) {
- if a > b {
- ma = a
- mi = b
- } else {
- ma = b
- mi = a
- }
- if c > ma {
- ma = c
- } else if c < mi {
- mi = c
- }
- return ma, mi
-}
-
-func round(x float64) float64 {
- return math.Round(x*1000) / 1000
-}
-
-// rgbToHSL converts an RGB triple to an HSL triple.
-func rgbToHSL(r, g, b uint8) (h, s, l float64) {
- // convert uint32 pre-multiplied value to uint8
- // The r,g,b values are divided by 255 to change the range from 0..255 to 0..1:
- Rnot := float64(r) / 255
- Gnot := float64(g) / 255
- Bnot := float64(b) / 255
- Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot)
- Δ := Cmax - Cmin
- // Lightness calculation:
- l = (Cmax + Cmin) / 2
- // Hue and Saturation Calculation:
- if Δ == 0 {
- h = 0
- s = 0
- } else {
- switch Cmax {
- case Rnot:
- h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
- case Gnot:
- h = 60 * (((Bnot - Rnot) / Δ) + 2)
- case Bnot:
- h = 60 * (((Rnot - Gnot) / Δ) + 4)
- }
- if h < 0 {
- h += 360
- }
-
- s = Δ / (1 - math.Abs((2*l)-1))
- }
-
- return h, round(s), round(l)
-}
-
-// isDarkColor returns whether the given color is dark.
-func isDarkColor(c color.Color) bool {
- if c == nil {
- return true
- }
-
- r, g, b, _ := c.RGBA()
- _, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint:gosec
- return l < 0.5
-}
diff --git a/packages/tui/input/cursor.go b/packages/tui/input/cursor.go
deleted file mode 100644
index cf4e973d2..000000000
--- a/packages/tui/input/cursor.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package input
-
-import "image"
-
-// CursorPositionEvent represents a cursor position event. Where X is the
-// zero-based column and Y is the zero-based row.
-type CursorPositionEvent image.Point
diff --git a/packages/tui/input/da1.go b/packages/tui/input/da1.go
deleted file mode 100644
index c2cd94cf7..000000000
--- a/packages/tui/input/da1.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package input
-
-import "github.com/charmbracelet/x/ansi"
-
-// PrimaryDeviceAttributesEvent is an event that represents the terminal
-// primary device attributes.
-type PrimaryDeviceAttributesEvent []int
-
-func parsePrimaryDevAttrs(params ansi.Params) Event {
- // Primary Device Attributes
- da1 := make(PrimaryDeviceAttributesEvent, len(params))
- for i, p := range params {
- if !p.HasMore() {
- da1[i] = p.Param(0)
- }
- }
- return da1
-}
diff --git a/packages/tui/input/doc.go b/packages/tui/input/doc.go
deleted file mode 100644
index 2877d496e..000000000
--- a/packages/tui/input/doc.go
+++ /dev/null
@@ -1,6 +0,0 @@
-// Package input provides a set of utilities for handling input events in a
-// terminal environment. It includes support for reading input events, parsing
-// escape sequences, and handling clipboard events.
-// The package is designed to work with various terminal types and supports
-// customization through flags and options.
-package input
diff --git a/packages/tui/input/driver.go b/packages/tui/input/driver.go
deleted file mode 100644
index 1e34677ab..000000000
--- a/packages/tui/input/driver.go
+++ /dev/null
@@ -1,192 +0,0 @@
-//nolint:unused,revive,nolintlint
-package input
-
-import (
- "bytes"
- "io"
- "unicode/utf8"
-
- "github.com/muesli/cancelreader"
-)
-
-// Logger is a simple logger interface.
-type Logger interface {
- Printf(format string, v ...any)
-}
-
-// win32InputState is a state machine for parsing key events from the Windows
-// Console API into escape sequences and utf8 runes, and keeps track of the last
-// control key state to determine modifier key changes. It also keeps track of
-// the last mouse button state and window size changes to determine which mouse
-// buttons were released and to prevent multiple size events from firing.
-type win32InputState struct {
- ansiBuf [256]byte
- ansiIdx int
- utf16Buf [2]rune
- utf16Half bool
- lastCks uint32 // the last control key state for the previous event
- lastMouseBtns uint32 // the last mouse button state for the previous event
- lastWinsizeX, lastWinsizeY int16 // the last window size for the previous event to prevent multiple size events from firing
-}
-
-// Reader represents an input event reader. It reads input events and parses
-// escape sequences from the terminal input buffer and translates them into
-// human‑readable events.
-type Reader struct {
- rd cancelreader.CancelReader
- table map[string]Key // table is a lookup table for key sequences.
- term string // $TERM
- paste []byte // bracketed paste buffer; nil when disabled
- buf [256]byte // read buffer
- partialSeq []byte // holds incomplete escape sequences
- keyState win32InputState
- parser Parser
- logger Logger
-}
-
-// NewReader returns a new input event reader.
-func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
- d := new(Reader)
- cr, err := newCancelreader(r, flags)
- if err != nil {
- return nil, err
- }
-
- d.rd = cr
- d.table = buildKeysTable(flags, termType)
- d.term = termType
- d.parser.flags = flags
- return d, nil
-}
-
-// SetLogger sets a logger for the reader.
-func (d *Reader) SetLogger(l Logger) { d.logger = l }
-
-// Read implements io.Reader.
-func (d *Reader) Read(p []byte) (int, error) { return d.rd.Read(p) }
-
-// Cancel cancels the underlying reader.
-func (d *Reader) Cancel() bool { return d.rd.Cancel() }
-
-// Close closes the underlying reader.
-func (d *Reader) Close() error { return d.rd.Close() }
-
-func (d *Reader) readEvents() ([]Event, error) {
- nb, err := d.rd.Read(d.buf[:])
- if err != nil {
- return nil, err
- }
-
- var events []Event
-
- // Combine any partial sequence from previous read with new data.
- var buf []byte
- if len(d.partialSeq) > 0 {
- buf = make([]byte, len(d.partialSeq)+nb)
- copy(buf, d.partialSeq)
- copy(buf[len(d.partialSeq):], d.buf[:nb])
- d.partialSeq = nil
- } else {
- buf = d.buf[:nb]
- }
-
- // Fast path: direct lookup for simple escape sequences.
- if bytes.HasPrefix(buf, []byte{0x1b}) {
- if k, ok := d.table[string(buf)]; ok {
- if d.logger != nil {
- d.logger.Printf("input: %q", buf)
- }
- events = append(events, KeyPressEvent(k))
- return events, nil
- }
- }
-
- var i int
- for i < len(buf) {
- consumed, ev := d.parser.parseSequence(buf[i:])
- if d.logger != nil && consumed > 0 {
- d.logger.Printf("input: %q", buf[i:i+consumed])
- }
-
- // Incomplete sequence – store remainder and exit.
- if consumed == 0 && ev == nil {
- rem := len(buf) - i
- if rem > 0 {
- d.partialSeq = make([]byte, rem)
- copy(d.partialSeq, buf[i:])
- }
- break
- }
-
- // Handle bracketed paste specially so we don’t emit a paste event for
- // every byte.
- if d.paste != nil {
- if _, ok := ev.(PasteEndEvent); !ok {
- d.paste = append(d.paste, buf[i])
- i++
- continue
- }
- }
-
- switch ev.(type) {
- case PasteStartEvent:
- d.paste = []byte{}
- case PasteEndEvent:
- var paste []rune
- for len(d.paste) > 0 {
- r, w := utf8.DecodeRune(d.paste)
- if r != utf8.RuneError {
- paste = append(paste, r)
- }
- d.paste = d.paste[w:]
- }
- d.paste = nil
- events = append(events, PasteEvent(paste))
- case nil:
- i++
- continue
- }
-
- if mevs, ok := ev.(MultiEvent); ok {
- events = append(events, []Event(mevs)...)
- } else {
- events = append(events, ev)
- }
- i += consumed
- }
-
- // Collapse bursts of wheel/motion events into a single event each.
- events = coalesceMouseEvents(events)
- return events, nil
-}
-
-// coalesceMouseEvents reduces the volume of MouseWheelEvent and MouseMotionEvent
-// objects that arrive in rapid succession by keeping only the most recent
-// event in each contiguous run.
-func coalesceMouseEvents(in []Event) []Event {
- if len(in) < 2 {
- return in
- }
-
- out := make([]Event, 0, len(in))
- for _, ev := range in {
- switch ev.(type) {
- case MouseWheelEvent:
- if len(out) > 0 {
- if _, ok := out[len(out)-1].(MouseWheelEvent); ok {
- out[len(out)-1] = ev // replace previous wheel event
- continue
- }
- }
- case MouseMotionEvent:
- if len(out) > 0 {
- if _, ok := out[len(out)-1].(MouseMotionEvent); ok {
- out[len(out)-1] = ev // replace previous motion event
- continue
- }
- }
- }
- out = append(out, ev)
- }
- return out
-}
diff --git a/packages/tui/input/driver_other.go b/packages/tui/input/driver_other.go
deleted file mode 100644
index fd3df06c6..000000000
--- a/packages/tui/input/driver_other.go
+++ /dev/null
@@ -1,17 +0,0 @@
-//go:build !windows
-// +build !windows
-
-package input
-
-// ReadEvents reads input events from the terminal.
-//
-// It reads the events available in the input buffer and returns them.
-func (d *Reader) ReadEvents() ([]Event, error) {
- return d.readEvents()
-}
-
-// parseWin32InputKeyEvent parses a Win32 input key events. This function is
-// only available on Windows.
-func (p *Parser) parseWin32InputKeyEvent(*win32InputState, uint16, uint16, rune, bool, uint32, uint16) Event {
- return nil
-}
diff --git a/packages/tui/input/driver_test.go b/packages/tui/input/driver_test.go
deleted file mode 100644
index affdf5b88..000000000
--- a/packages/tui/input/driver_test.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package input
-
-import (
- "io"
- "strings"
- "testing"
-)
-
-func BenchmarkDriver(b *testing.B) {
- input := "\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~"
- rdr := strings.NewReader(input)
- drv, err := NewReader(rdr, "dumb", 0)
- if err != nil {
- b.Fatalf("could not create driver: %v", err)
- }
-
- b.ReportAllocs()
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- rdr.Reset(input)
- if _, err := drv.ReadEvents(); err != nil && err != io.EOF {
- b.Errorf("error reading input: %v", err)
- }
- }
-}
diff --git a/packages/tui/input/driver_windows.go b/packages/tui/input/driver_windows.go
deleted file mode 100644
index b9121734f..000000000
--- a/packages/tui/input/driver_windows.go
+++ /dev/null
@@ -1,642 +0,0 @@
-//go:build windows
-// +build windows
-
-package input
-
-import (
- "errors"
- "fmt"
- "strings"
- "time"
- "unicode"
- "unicode/utf16"
- "unicode/utf8"
-
- "github.com/charmbracelet/x/ansi"
- xwindows "github.com/charmbracelet/x/windows"
- "github.com/muesli/cancelreader"
- "golang.org/x/sys/windows"
-)
-
-// ReadEvents reads input events from the terminal.
-//
-// It reads the events available in the input buffer and returns them.
-func (d *Reader) ReadEvents() ([]Event, error) {
- events, err := d.handleConInput()
- if errors.Is(err, errNotConInputReader) {
- return d.readEvents()
- }
- return events, err
-}
-
-var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader")
-
-func (d *Reader) handleConInput() ([]Event, error) {
- cc, ok := d.rd.(*conInputReader)
- if !ok {
- return nil, errNotConInputReader
- }
-
- var (
- events []xwindows.InputRecord
- err error
- )
- for {
- // Peek up to 256 events, this is to allow for sequences events reported as
- // key events.
- events, err = peekNConsoleInputs(cc.conin, 256)
- if cc.isCanceled() {
- return nil, cancelreader.ErrCanceled
- }
- if err != nil {
- return nil, fmt.Errorf("peek coninput events: %w", err)
- }
- if len(events) > 0 {
- break
- }
-
- // Sleep for a bit to avoid busy waiting.
- time.Sleep(10 * time.Millisecond)
- }
-
- events, err = readNConsoleInputs(cc.conin, uint32(len(events)))
- if cc.isCanceled() {
- return nil, cancelreader.ErrCanceled
- }
- if err != nil {
- return nil, fmt.Errorf("read coninput events: %w", err)
- }
-
- var evs []Event
- for _, event := range events {
- if e := d.parser.parseConInputEvent(event, &d.keyState); e != nil {
- if multi, ok := e.(MultiEvent); ok {
- evs = append(evs, multi...)
- } else {
- evs = append(evs, e)
- }
- }
- }
-
- return evs, nil
-}
-
-func (p *Parser) parseConInputEvent(event xwindows.InputRecord, keyState *win32InputState) Event {
- switch event.EventType {
- case xwindows.KEY_EVENT:
- kevent := event.KeyEvent()
- return p.parseWin32InputKeyEvent(keyState, kevent.VirtualKeyCode, kevent.VirtualScanCode,
- kevent.Char, kevent.KeyDown, kevent.ControlKeyState, kevent.RepeatCount)
-
- case xwindows.WINDOW_BUFFER_SIZE_EVENT:
- wevent := event.WindowBufferSizeEvent()
- if wevent.Size.X != keyState.lastWinsizeX || wevent.Size.Y != keyState.lastWinsizeY {
- keyState.lastWinsizeX, keyState.lastWinsizeY = wevent.Size.X, wevent.Size.Y
- return WindowSizeEvent{
- Width: int(wevent.Size.X),
- Height: int(wevent.Size.Y),
- }
- }
- case xwindows.MOUSE_EVENT:
- mevent := event.MouseEvent()
- Event := mouseEvent(keyState.lastMouseBtns, mevent)
- keyState.lastMouseBtns = mevent.ButtonState
- return Event
- case xwindows.FOCUS_EVENT:
- fevent := event.FocusEvent()
- if fevent.SetFocus {
- return FocusEvent{}
- }
- return BlurEvent{}
- case xwindows.MENU_EVENT:
- // ignore
- }
- return nil
-}
-
-func mouseEventButton(p, s uint32) (MouseButton, bool) {
- var isRelease bool
- button := MouseNone
- btn := p ^ s
- if btn&s == 0 {
- isRelease = true
- }
-
- if btn == 0 {
- switch {
- case s&xwindows.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
- button = MouseLeft
- case s&xwindows.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
- button = MouseMiddle
- case s&xwindows.RIGHTMOST_BUTTON_PRESSED > 0:
- button = MouseRight
- case s&xwindows.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
- button = MouseBackward
- case s&xwindows.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
- button = MouseForward
- }
- return button, isRelease
- }
-
- switch btn {
- case xwindows.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
- button = MouseLeft
- case xwindows.RIGHTMOST_BUTTON_PRESSED: // right button
- button = MouseRight
- case xwindows.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
- button = MouseMiddle
- case xwindows.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
- button = MouseBackward
- case xwindows.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
- button = MouseForward
- }
-
- return button, isRelease
-}
-
-func mouseEvent(p uint32, e xwindows.MouseEventRecord) (ev Event) {
- var mod KeyMod
- var isRelease bool
- if e.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0 {
- mod |= ModAlt
- }
- if e.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0 {
- mod |= ModCtrl
- }
- if e.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0 {
- mod |= ModShift
- }
-
- m := Mouse{
- X: int(e.MousePositon.X),
- Y: int(e.MousePositon.Y),
- Mod: mod,
- }
-
- wheelDirection := int16(highWord(e.ButtonState)) //nolint:gosec
- switch e.EventFlags {
- case 0, xwindows.DOUBLE_CLICK:
- m.Button, isRelease = mouseEventButton(p, e.ButtonState)
- case xwindows.MOUSE_WHEELED:
- if wheelDirection > 0 {
- m.Button = MouseWheelUp
- } else {
- m.Button = MouseWheelDown
- }
- case xwindows.MOUSE_HWHEELED:
- if wheelDirection > 0 {
- m.Button = MouseWheelRight
- } else {
- m.Button = MouseWheelLeft
- }
- case xwindows.MOUSE_MOVED:
- m.Button, _ = mouseEventButton(p, e.ButtonState)
- return MouseMotionEvent(m)
- }
-
- if isWheel(m.Button) {
- return MouseWheelEvent(m)
- } else if isRelease {
- return MouseReleaseEvent(m)
- }
-
- return MouseClickEvent(m)
-}
-
-func highWord(data uint32) uint16 {
- return uint16((data & 0xFFFF0000) >> 16) //nolint:gosec
-}
-
-func readNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
- if maxEvents == 0 {
- return nil, fmt.Errorf("maxEvents cannot be zero")
- }
-
- records := make([]xwindows.InputRecord, maxEvents)
- n, err := readConsoleInput(console, records)
- return records[:n], err
-}
-
-func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
- if len(inputRecords) == 0 {
- return 0, fmt.Errorf("size of input record buffer cannot be zero")
- }
-
- var read uint32
-
- err := xwindows.ReadConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
-
- return read, err //nolint:wrapcheck
-}
-
-func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
- if len(inputRecords) == 0 {
- return 0, fmt.Errorf("size of input record buffer cannot be zero")
- }
-
- var read uint32
-
- err := xwindows.PeekConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
-
- return read, err //nolint:wrapcheck
-}
-
-func peekNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
- if maxEvents == 0 {
- return nil, fmt.Errorf("maxEvents cannot be zero")
- }
-
- records := make([]xwindows.InputRecord, maxEvents)
- n, err := peekConsoleInput(console, records)
- return records[:n], err
-}
-
-// parseWin32InputKeyEvent parses a single key event from either the Windows
-// Console API or win32-input-mode events. When state is nil, it means this is
-// an event from win32-input-mode. Otherwise, it's a key event from the Windows
-// Console API and needs a state to decode ANSI escape sequences and utf16
-// runes.
-func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) (event Event) {
- defer func() {
- // Respect the repeat count.
- if repeatCount > 1 {
- var multi MultiEvent
- for i := 0; i < int(repeatCount); i++ {
- multi = append(multi, event)
- }
- event = multi
- }
- }()
- if state != nil {
- defer func() {
- state.lastCks = cks
- }()
- }
-
- var utf8Buf [utf8.UTFMax]byte
- var key Key
- if state != nil && state.utf16Half {
- state.utf16Half = false
- state.utf16Buf[1] = r
- codepoint := utf16.DecodeRune(state.utf16Buf[0], state.utf16Buf[1])
- rw := utf8.EncodeRune(utf8Buf[:], codepoint)
- r, _ = utf8.DecodeRune(utf8Buf[:rw])
- key.Code = r
- key.Text = string(r)
- key.Mod = translateControlKeyState(cks)
- key = ensureKeyCase(key, cks)
- if keyDown {
- return KeyPressEvent(key)
- }
- return KeyReleaseEvent(key)
- }
-
- var baseCode rune
- switch {
- case vkc == 0:
- // Zero means this event is either an escape code or a unicode
- // codepoint.
- if state != nil && state.ansiIdx == 0 && r != ansi.ESC {
- // This is a unicode codepoint.
- baseCode = r
- break
- }
-
- if state != nil {
- // Collect ANSI escape code.
- state.ansiBuf[state.ansiIdx] = byte(r)
- state.ansiIdx++
- if state.ansiIdx <= 2 {
- // We haven't received enough bytes to determine if this is an
- // ANSI escape code.
- return nil
- }
- if r == ansi.ESC {
- // We're expecting a closing String Terminator [ansi.ST].
- return nil
- }
-
- n, event := p.parseSequence(state.ansiBuf[:state.ansiIdx])
- if n == 0 {
- return nil
- }
- if _, ok := event.(UnknownEvent); ok {
- return nil
- }
-
- state.ansiIdx = 0
- return event
- }
- case vkc == xwindows.VK_BACK:
- baseCode = KeyBackspace
- case vkc == xwindows.VK_TAB:
- baseCode = KeyTab
- case vkc == xwindows.VK_RETURN:
- baseCode = KeyEnter
- case vkc == xwindows.VK_SHIFT:
- //nolint:nestif
- if cks&xwindows.SHIFT_PRESSED != 0 {
- if cks&xwindows.ENHANCED_KEY != 0 {
- baseCode = KeyRightShift
- } else {
- baseCode = KeyLeftShift
- }
- } else if state != nil {
- if state.lastCks&xwindows.SHIFT_PRESSED != 0 {
- if state.lastCks&xwindows.ENHANCED_KEY != 0 {
- baseCode = KeyRightShift
- } else {
- baseCode = KeyLeftShift
- }
- }
- }
- case vkc == xwindows.VK_CONTROL:
- if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
- baseCode = KeyLeftCtrl
- } else if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
- baseCode = KeyRightCtrl
- } else if state != nil {
- if state.lastCks&xwindows.LEFT_CTRL_PRESSED != 0 {
- baseCode = KeyLeftCtrl
- } else if state.lastCks&xwindows.RIGHT_CTRL_PRESSED != 0 {
- baseCode = KeyRightCtrl
- }
- }
- case vkc == xwindows.VK_MENU:
- if cks&xwindows.LEFT_ALT_PRESSED != 0 {
- baseCode = KeyLeftAlt
- } else if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
- baseCode = KeyRightAlt
- } else if state != nil {
- if state.lastCks&xwindows.LEFT_ALT_PRESSED != 0 {
- baseCode = KeyLeftAlt
- } else if state.lastCks&xwindows.RIGHT_ALT_PRESSED != 0 {
- baseCode = KeyRightAlt
- }
- }
- case vkc == xwindows.VK_PAUSE:
- baseCode = KeyPause
- case vkc == xwindows.VK_CAPITAL:
- baseCode = KeyCapsLock
- case vkc == xwindows.VK_ESCAPE:
- baseCode = KeyEscape
- case vkc == xwindows.VK_SPACE:
- baseCode = KeySpace
- case vkc == xwindows.VK_PRIOR:
- baseCode = KeyPgUp
- case vkc == xwindows.VK_NEXT:
- baseCode = KeyPgDown
- case vkc == xwindows.VK_END:
- baseCode = KeyEnd
- case vkc == xwindows.VK_HOME:
- baseCode = KeyHome
- case vkc == xwindows.VK_LEFT:
- baseCode = KeyLeft
- case vkc == xwindows.VK_UP:
- baseCode = KeyUp
- case vkc == xwindows.VK_RIGHT:
- baseCode = KeyRight
- case vkc == xwindows.VK_DOWN:
- baseCode = KeyDown
- case vkc == xwindows.VK_SELECT:
- baseCode = KeySelect
- case vkc == xwindows.VK_SNAPSHOT:
- baseCode = KeyPrintScreen
- case vkc == xwindows.VK_INSERT:
- baseCode = KeyInsert
- case vkc == xwindows.VK_DELETE:
- baseCode = KeyDelete
- case vkc >= '0' && vkc <= '9':
- baseCode = rune(vkc)
- case vkc >= 'A' && vkc <= 'Z':
- // Convert to lowercase.
- baseCode = rune(vkc) + 32
- case vkc == xwindows.VK_LWIN:
- baseCode = KeyLeftSuper
- case vkc == xwindows.VK_RWIN:
- baseCode = KeyRightSuper
- case vkc == xwindows.VK_APPS:
- baseCode = KeyMenu
- case vkc >= xwindows.VK_NUMPAD0 && vkc <= xwindows.VK_NUMPAD9:
- baseCode = rune(vkc-xwindows.VK_NUMPAD0) + KeyKp0
- case vkc == xwindows.VK_MULTIPLY:
- baseCode = KeyKpMultiply
- case vkc == xwindows.VK_ADD:
- baseCode = KeyKpPlus
- case vkc == xwindows.VK_SEPARATOR:
- baseCode = KeyKpComma
- case vkc == xwindows.VK_SUBTRACT:
- baseCode = KeyKpMinus
- case vkc == xwindows.VK_DECIMAL:
- baseCode = KeyKpDecimal
- case vkc == xwindows.VK_DIVIDE:
- baseCode = KeyKpDivide
- case vkc >= xwindows.VK_F1 && vkc <= xwindows.VK_F24:
- baseCode = rune(vkc-xwindows.VK_F1) + KeyF1
- case vkc == xwindows.VK_NUMLOCK:
- baseCode = KeyNumLock
- case vkc == xwindows.VK_SCROLL:
- baseCode = KeyScrollLock
- case vkc == xwindows.VK_LSHIFT:
- baseCode = KeyLeftShift
- case vkc == xwindows.VK_RSHIFT:
- baseCode = KeyRightShift
- case vkc == xwindows.VK_LCONTROL:
- baseCode = KeyLeftCtrl
- case vkc == xwindows.VK_RCONTROL:
- baseCode = KeyRightCtrl
- case vkc == xwindows.VK_LMENU:
- baseCode = KeyLeftAlt
- case vkc == xwindows.VK_RMENU:
- baseCode = KeyRightAlt
- case vkc == xwindows.VK_VOLUME_MUTE:
- baseCode = KeyMute
- case vkc == xwindows.VK_VOLUME_DOWN:
- baseCode = KeyLowerVol
- case vkc == xwindows.VK_VOLUME_UP:
- baseCode = KeyRaiseVol
- case vkc == xwindows.VK_MEDIA_NEXT_TRACK:
- baseCode = KeyMediaNext
- case vkc == xwindows.VK_MEDIA_PREV_TRACK:
- baseCode = KeyMediaPrev
- case vkc == xwindows.VK_MEDIA_STOP:
- baseCode = KeyMediaStop
- case vkc == xwindows.VK_MEDIA_PLAY_PAUSE:
- baseCode = KeyMediaPlayPause
- case vkc == xwindows.VK_OEM_1, vkc == xwindows.VK_OEM_PLUS, vkc == xwindows.VK_OEM_COMMA,
- vkc == xwindows.VK_OEM_MINUS, vkc == xwindows.VK_OEM_PERIOD, vkc == xwindows.VK_OEM_2,
- vkc == xwindows.VK_OEM_3, vkc == xwindows.VK_OEM_4, vkc == xwindows.VK_OEM_5,
- vkc == xwindows.VK_OEM_6, vkc == xwindows.VK_OEM_7:
- // Use the actual character provided by Windows for current keyboard layout
- // instead of hardcoded US layout mappings
- if !unicode.IsControl(r) && unicode.IsPrint(r) {
- baseCode = r
- } else {
- // Fallback to original hardcoded mappings for non-printable cases
- switch vkc {
- case xwindows.VK_OEM_1:
- baseCode = ';'
- case xwindows.VK_OEM_PLUS:
- baseCode = '+'
- case xwindows.VK_OEM_COMMA:
- baseCode = ','
- case xwindows.VK_OEM_MINUS:
- baseCode = '-'
- case xwindows.VK_OEM_PERIOD:
- baseCode = '.'
- case xwindows.VK_OEM_2:
- baseCode = '/'
- case xwindows.VK_OEM_3:
- baseCode = '`'
- case xwindows.VK_OEM_4:
- baseCode = '['
- case xwindows.VK_OEM_5:
- baseCode = '\\'
- case xwindows.VK_OEM_6:
- baseCode = ']'
- case xwindows.VK_OEM_7:
- baseCode = '\''
- }
- }
- }
-
- if utf16.IsSurrogate(r) {
- if state != nil {
- state.utf16Buf[0] = r
- state.utf16Half = true
- }
- return nil
- }
-
- // AltGr is left ctrl + right alt. On non-US keyboards, this is used to type
- // special characters and produce printable events.
- // XXX: Should this be a KeyMod?
- altGr := cks&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED) == xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED
-
- // FIXED: Remove numlock and scroll lock states when checking for printable text
- // These lock states shouldn't affect normal typing
- cksForTextCheck := cks &^ (xwindows.NUMLOCK_ON | xwindows.SCROLLLOCK_ON)
-
- var text string
- keyCode := baseCode
- if !unicode.IsControl(r) {
- rw := utf8.EncodeRune(utf8Buf[:], r)
- keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
- if unicode.IsPrint(keyCode) && (cksForTextCheck == 0 ||
- cksForTextCheck == xwindows.SHIFT_PRESSED ||
- cksForTextCheck == xwindows.CAPSLOCK_ON ||
- altGr) {
- // If the control key state is 0, shift is pressed, or caps lock
- // then the key event is a printable event i.e. [text] is not empty.
- text = string(keyCode)
- }
- }
-
- // Special case: numeric keypad divide should produce "/" text on all layouts (fix french keyboard layout)
- if baseCode == KeyKpDivide {
- text = "/"
- }
-
- key.Code = keyCode
- key.Text = text
- key.Mod = translateControlKeyState(cks)
- key.BaseCode = baseCode
- key = ensureKeyCase(key, cks)
- if keyDown {
- return KeyPressEvent(key)
- }
-
- return KeyReleaseEvent(key)
-}
-
-// ensureKeyCase ensures that the key's text is in the correct case based on the
-// control key state.
-func ensureKeyCase(key Key, cks uint32) Key {
- if len(key.Text) == 0 {
- return key
- }
-
- hasShift := cks&xwindows.SHIFT_PRESSED != 0
- hasCaps := cks&xwindows.CAPSLOCK_ON != 0
- if hasShift || hasCaps {
- if unicode.IsLower(key.Code) {
- key.ShiftedCode = unicode.ToUpper(key.Code)
- key.Text = string(key.ShiftedCode)
- }
- } else {
- if unicode.IsUpper(key.Code) {
- key.ShiftedCode = unicode.ToLower(key.Code)
- key.Text = string(key.ShiftedCode)
- }
- }
-
- return key
-}
-
-// translateControlKeyState translates the control key state from the Windows
-// Console API into a Mod bitmask.
-func translateControlKeyState(cks uint32) (m KeyMod) {
- if cks&xwindows.LEFT_CTRL_PRESSED != 0 || cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
- m |= ModCtrl
- }
- if cks&xwindows.LEFT_ALT_PRESSED != 0 || cks&xwindows.RIGHT_ALT_PRESSED != 0 {
- m |= ModAlt
- }
- if cks&xwindows.SHIFT_PRESSED != 0 {
- m |= ModShift
- }
- if cks&xwindows.CAPSLOCK_ON != 0 {
- m |= ModCapsLock
- }
- if cks&xwindows.NUMLOCK_ON != 0 {
- m |= ModNumLock
- }
- if cks&xwindows.SCROLLLOCK_ON != 0 {
- m |= ModScrollLock
- }
- return
-}
-
-//nolint:unused
-func keyEventString(vkc, sc uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) string {
- var s strings.Builder
- s.WriteString("vkc: ")
- s.WriteString(fmt.Sprintf("%d, 0x%02x", vkc, vkc))
- s.WriteString(", sc: ")
- s.WriteString(fmt.Sprintf("%d, 0x%02x", sc, sc))
- s.WriteString(", r: ")
- s.WriteString(fmt.Sprintf("%q", r))
- s.WriteString(", down: ")
- s.WriteString(fmt.Sprintf("%v", keyDown))
- s.WriteString(", cks: [")
- if cks&xwindows.LEFT_ALT_PRESSED != 0 {
- s.WriteString("left alt, ")
- }
- if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
- s.WriteString("right alt, ")
- }
- if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
- s.WriteString("left ctrl, ")
- }
- if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
- s.WriteString("right ctrl, ")
- }
- if cks&xwindows.SHIFT_PRESSED != 0 {
- s.WriteString("shift, ")
- }
- if cks&xwindows.CAPSLOCK_ON != 0 {
- s.WriteString("caps lock, ")
- }
- if cks&xwindows.NUMLOCK_ON != 0 {
- s.WriteString("num lock, ")
- }
- if cks&xwindows.SCROLLLOCK_ON != 0 {
- s.WriteString("scroll lock, ")
- }
- if cks&xwindows.ENHANCED_KEY != 0 {
- s.WriteString("enhanced key, ")
- }
- s.WriteString("], repeat count: ")
- s.WriteString(fmt.Sprintf("%d", repeatCount))
- return s.String()
-}
diff --git a/packages/tui/input/driver_windows_test.go b/packages/tui/input/driver_windows_test.go
deleted file mode 100644
index 45371fd13..000000000
--- a/packages/tui/input/driver_windows_test.go
+++ /dev/null
@@ -1,271 +0,0 @@
-package input
-
-import (
- "encoding/binary"
- "image/color"
- "reflect"
- "testing"
- "unicode/utf16"
-
- "github.com/charmbracelet/x/ansi"
- xwindows "github.com/charmbracelet/x/windows"
- "golang.org/x/sys/windows"
-)
-
-func TestWindowsInputEvents(t *testing.T) {
- cases := []struct {
- name string
- events []xwindows.InputRecord
- expected []Event
- sequence bool // indicates that the input events are ANSI sequence or utf16
- }{
- {
- name: "single key event",
- events: []xwindows.InputRecord{
- encodeKeyEvent(xwindows.KeyEventRecord{
- KeyDown: true,
- Char: 'a',
- VirtualKeyCode: 'A',
- }),
- },
- expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Text: "a"}},
- },
- {
- name: "single key event with control key",
- events: []xwindows.InputRecord{
- encodeKeyEvent(xwindows.KeyEventRecord{
- KeyDown: true,
- Char: 'a',
- VirtualKeyCode: 'A',
- ControlKeyState: xwindows.LEFT_CTRL_PRESSED,
- }),
- },
- expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Mod: ModCtrl}},
- },
- {
- name: "escape alt key event",
- events: []xwindows.InputRecord{
- encodeKeyEvent(xwindows.KeyEventRecord{
- KeyDown: true,
- Char: ansi.ESC,
- VirtualKeyCode: ansi.ESC,
- ControlKeyState: xwindows.LEFT_ALT_PRESSED,
- }),
- },
- expected: []Event{KeyPressEvent{Code: ansi.ESC, BaseCode: ansi.ESC, Mod: ModAlt}},
- },
- {
- name: "single shifted key event",
- events: []xwindows.InputRecord{
- encodeKeyEvent(xwindows.KeyEventRecord{
- KeyDown: true,
- Char: 'A',
- VirtualKeyCode: 'A',
- ControlKeyState: xwindows.SHIFT_PRESSED,
- }),
- },
- expected: []Event{KeyPressEvent{Code: 'A', BaseCode: 'a', Text: "A", Mod: ModShift}},
- },
- {
- name: "utf16 rune",
- events: encodeUtf16Rune('😊'), // smiley emoji '😊'
- expected: []Event{
- KeyPressEvent{Code: '😊', Text: "😊"},
- },
- sequence: true,
- },
- {
- name: "background color response",
- events: encodeSequence("\x1b]11;rgb:ff/ff/ff\x07"),
- expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
- sequence: true,
- },
- {
- name: "st terminated background color response",
- events: encodeSequence("\x1b]11;rgb:ffff/ffff/ffff\x1b\\"),
- expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
- sequence: true,
- },
- {
- name: "simple mouse event",
- events: []xwindows.InputRecord{
- encodeMouseEvent(xwindows.MouseEventRecord{
- MousePositon: windows.Coord{X: 10, Y: 20},
- ButtonState: xwindows.FROM_LEFT_1ST_BUTTON_PRESSED,
- EventFlags: 0,
- }),
- encodeMouseEvent(xwindows.MouseEventRecord{
- MousePositon: windows.Coord{X: 10, Y: 20},
- EventFlags: 0,
- }),
- },
- expected: []Event{
- MouseClickEvent{Button: MouseLeft, X: 10, Y: 20},
- MouseReleaseEvent{Button: MouseLeft, X: 10, Y: 20},
- },
- },
- {
- name: "focus event",
- events: []xwindows.InputRecord{
- encodeFocusEvent(xwindows.FocusEventRecord{
- SetFocus: true,
- }),
- encodeFocusEvent(xwindows.FocusEventRecord{
- SetFocus: false,
- }),
- },
- expected: []Event{
- FocusEvent{},
- BlurEvent{},
- },
- },
- {
- name: "window size event",
- events: []xwindows.InputRecord{
- encodeWindowBufferSizeEvent(xwindows.WindowBufferSizeRecord{
- Size: windows.Coord{X: 10, Y: 20},
- }),
- },
- expected: []Event{
- WindowSizeEvent{Width: 10, Height: 20},
- },
- },
- }
-
- // p is the parser to parse the input events
- var p Parser
-
- // keep track of the state of the driver to handle ANSI sequences and utf16
- var state win32InputState
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- if tc.sequence {
- var Event Event
- for _, ev := range tc.events {
- if ev.EventType != xwindows.KEY_EVENT {
- t.Fatalf("expected key event, got %v", ev.EventType)
- }
-
- key := ev.KeyEvent()
- Event = p.parseWin32InputKeyEvent(&state, key.VirtualKeyCode, key.VirtualScanCode, key.Char, key.KeyDown, key.ControlKeyState, key.RepeatCount)
- }
- if len(tc.expected) != 1 {
- t.Fatalf("expected 1 event, got %d", len(tc.expected))
- }
- if !reflect.DeepEqual(Event, tc.expected[0]) {
- t.Errorf("expected %v, got %v", tc.expected[0], Event)
- }
- } else {
- if len(tc.events) != len(tc.expected) {
- t.Fatalf("expected %d events, got %d", len(tc.expected), len(tc.events))
- }
- for j, ev := range tc.events {
- Event := p.parseConInputEvent(ev, &state)
- if !reflect.DeepEqual(Event, tc.expected[j]) {
- t.Errorf("expected %#v, got %#v", tc.expected[j], Event)
- }
- }
- }
- })
- }
-}
-
-func boolToUint32(b bool) uint32 {
- if b {
- return 1
- }
- return 0
-}
-
-func encodeMenuEvent(menu xwindows.MenuEventRecord) xwindows.InputRecord {
- var bts [16]byte
- binary.LittleEndian.PutUint32(bts[0:4], menu.CommandID)
- return xwindows.InputRecord{
- EventType: xwindows.MENU_EVENT,
- Event: bts,
- }
-}
-
-func encodeWindowBufferSizeEvent(size xwindows.WindowBufferSizeRecord) xwindows.InputRecord {
- var bts [16]byte
- binary.LittleEndian.PutUint16(bts[0:2], uint16(size.Size.X))
- binary.LittleEndian.PutUint16(bts[2:4], uint16(size.Size.Y))
- return xwindows.InputRecord{
- EventType: xwindows.WINDOW_BUFFER_SIZE_EVENT,
- Event: bts,
- }
-}
-
-func encodeFocusEvent(focus xwindows.FocusEventRecord) xwindows.InputRecord {
- var bts [16]byte
- if focus.SetFocus {
- bts[0] = 1
- }
- return xwindows.InputRecord{
- EventType: xwindows.FOCUS_EVENT,
- Event: bts,
- }
-}
-
-func encodeMouseEvent(mouse xwindows.MouseEventRecord) xwindows.InputRecord {
- var bts [16]byte
- binary.LittleEndian.PutUint16(bts[0:2], uint16(mouse.MousePositon.X))
- binary.LittleEndian.PutUint16(bts[2:4], uint16(mouse.MousePositon.Y))
- binary.LittleEndian.PutUint32(bts[4:8], mouse.ButtonState)
- binary.LittleEndian.PutUint32(bts[8:12], mouse.ControlKeyState)
- binary.LittleEndian.PutUint32(bts[12:16], mouse.EventFlags)
- return xwindows.InputRecord{
- EventType: xwindows.MOUSE_EVENT,
- Event: bts,
- }
-}
-
-func encodeKeyEvent(key xwindows.KeyEventRecord) xwindows.InputRecord {
- var bts [16]byte
- binary.LittleEndian.PutUint32(bts[0:4], boolToUint32(key.KeyDown))
- binary.LittleEndian.PutUint16(bts[4:6], key.RepeatCount)
- binary.LittleEndian.PutUint16(bts[6:8], key.VirtualKeyCode)
- binary.LittleEndian.PutUint16(bts[8:10], key.VirtualScanCode)
- binary.LittleEndian.PutUint16(bts[10:12], uint16(key.Char))
- binary.LittleEndian.PutUint32(bts[12:16], key.ControlKeyState)
- return xwindows.InputRecord{
- EventType: xwindows.KEY_EVENT,
- Event: bts,
- }
-}
-
-// encodeSequence encodes a string of ANSI escape sequences into a slice of
-// Windows input key records.
-func encodeSequence(s string) (evs []xwindows.InputRecord) {
- var state byte
- for len(s) > 0 {
- seq, _, n, newState := ansi.DecodeSequence(s, state, nil)
- for i := 0; i < n; i++ {
- evs = append(evs, encodeKeyEvent(xwindows.KeyEventRecord{
- KeyDown: true,
- Char: rune(seq[i]),
- }))
- }
- state = newState
- s = s[n:]
- }
- return
-}
-
-func encodeUtf16Rune(r rune) []xwindows.InputRecord {
- r1, r2 := utf16.EncodeRune(r)
- return encodeUtf16Pair(r1, r2)
-}
-
-func encodeUtf16Pair(r1, r2 rune) []xwindows.InputRecord {
- return []xwindows.InputRecord{
- encodeKeyEvent(xwindows.KeyEventRecord{
- KeyDown: true,
- Char: r1,
- }),
- encodeKeyEvent(xwindows.KeyEventRecord{
- KeyDown: true,
- Char: r2,
- }),
- }
-}
diff --git a/packages/tui/input/focus.go b/packages/tui/input/focus.go
deleted file mode 100644
index 796d95f64..000000000
--- a/packages/tui/input/focus.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package input
-
-// FocusEvent represents a terminal focus event.
-// This occurs when the terminal gains focus.
-type FocusEvent struct{}
-
-// BlurEvent represents a terminal blur event.
-// This occurs when the terminal loses focus.
-type BlurEvent struct{}
diff --git a/packages/tui/input/focus_test.go b/packages/tui/input/focus_test.go
deleted file mode 100644
index 2d35e4768..000000000
--- a/packages/tui/input/focus_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package input
-
-import (
- "testing"
-)
-
-func TestFocus(t *testing.T) {
- var p Parser
- _, e := p.parseSequence([]byte("\x1b[I"))
- switch e.(type) {
- case FocusEvent:
- // ok
- default:
- t.Error("invalid sequence")
- }
-}
-
-func TestBlur(t *testing.T) {
- var p Parser
- _, e := p.parseSequence([]byte("\x1b[O"))
- switch e.(type) {
- case BlurEvent:
- // ok
- default:
- t.Error("invalid sequence")
- }
-}
diff --git a/packages/tui/input/go.mod b/packages/tui/input/go.mod
deleted file mode 100644
index 36a9a92ab..000000000
--- a/packages/tui/input/go.mod
+++ /dev/null
@@ -1,18 +0,0 @@
-module github.com/charmbracelet/x/input
-
-go 1.23.0
-
-require (
- github.com/charmbracelet/x/ansi v0.9.3
- github.com/charmbracelet/x/windows v0.2.1
- github.com/muesli/cancelreader v0.2.2
- github.com/rivo/uniseg v0.4.7
- github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
- golang.org/x/sys v0.33.0
-)
-
-require (
- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
- golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
-)
diff --git a/packages/tui/input/go.sum b/packages/tui/input/go.sum
deleted file mode 100644
index 7bc7a2ebd..000000000
--- a/packages/tui/input/go.sum
+++ /dev/null
@@ -1,19 +0,0 @@
-github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
-github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
-github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
-github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
-github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
-github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
-golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
-golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
-golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
diff --git a/packages/tui/input/input.go b/packages/tui/input/input.go
deleted file mode 100644
index da5e4f0be..000000000
--- a/packages/tui/input/input.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package input
-
-import (
- "fmt"
- "strings"
-)
-
-// Event represents a terminal event.
-type Event any
-
-// UnknownEvent represents an unknown event.
-type UnknownEvent string
-
-// String returns a string representation of the unknown event.
-func (e UnknownEvent) String() string {
- return fmt.Sprintf("%q", string(e))
-}
-
-// MultiEvent represents multiple messages event.
-type MultiEvent []Event
-
-// String returns a string representation of the multiple messages event.
-func (e MultiEvent) String() string {
- var sb strings.Builder
- for _, ev := range e {
- sb.WriteString(fmt.Sprintf("%v\n", ev))
- }
- return sb.String()
-}
-
-// WindowSizeEvent is used to report the terminal size. Note that Windows does
-// not have support for reporting resizes via SIGWINCH signals and relies on
-// the Windows Console API to report window size changes.
-type WindowSizeEvent struct {
- Width int
- Height int
-}
-
-// WindowOpEvent is a window operation (XTWINOPS) report event. This is used to
-// report various window operations such as reporting the window size or cell
-// size.
-type WindowOpEvent struct {
- Op int
- Args []int
-}
diff --git a/packages/tui/input/key.go b/packages/tui/input/key.go
deleted file mode 100644
index 8d3e3ebe3..000000000
--- a/packages/tui/input/key.go
+++ /dev/null
@@ -1,574 +0,0 @@
-package input
-
-import (
- "fmt"
- "strings"
- "unicode"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-const (
- // KeyExtended is a special key code used to signify that a key event
- // contains multiple runes.
- KeyExtended = unicode.MaxRune + 1
-)
-
-// Special key symbols.
-const (
-
- // Special keys.
-
- KeyUp rune = KeyExtended + iota + 1
- KeyDown
- KeyRight
- KeyLeft
- KeyBegin
- KeyFind
- KeyInsert
- KeyDelete
- KeySelect
- KeyPgUp
- KeyPgDown
- KeyHome
- KeyEnd
-
- // Keypad keys.
-
- KeyKpEnter
- KeyKpEqual
- KeyKpMultiply
- KeyKpPlus
- KeyKpComma
- KeyKpMinus
- KeyKpDecimal
- KeyKpDivide
- KeyKp0
- KeyKp1
- KeyKp2
- KeyKp3
- KeyKp4
- KeyKp5
- KeyKp6
- KeyKp7
- KeyKp8
- KeyKp9
-
- //nolint:godox
- // The following are keys defined in the Kitty keyboard protocol.
- // TODO: Investigate the names of these keys.
-
- KeyKpSep
- KeyKpUp
- KeyKpDown
- KeyKpLeft
- KeyKpRight
- KeyKpPgUp
- KeyKpPgDown
- KeyKpHome
- KeyKpEnd
- KeyKpInsert
- KeyKpDelete
- KeyKpBegin
-
- // Function keys.
-
- KeyF1
- KeyF2
- KeyF3
- KeyF4
- KeyF5
- KeyF6
- KeyF7
- KeyF8
- KeyF9
- KeyF10
- KeyF11
- KeyF12
- KeyF13
- KeyF14
- KeyF15
- KeyF16
- KeyF17
- KeyF18
- KeyF19
- KeyF20
- KeyF21
- KeyF22
- KeyF23
- KeyF24
- KeyF25
- KeyF26
- KeyF27
- KeyF28
- KeyF29
- KeyF30
- KeyF31
- KeyF32
- KeyF33
- KeyF34
- KeyF35
- KeyF36
- KeyF37
- KeyF38
- KeyF39
- KeyF40
- KeyF41
- KeyF42
- KeyF43
- KeyF44
- KeyF45
- KeyF46
- KeyF47
- KeyF48
- KeyF49
- KeyF50
- KeyF51
- KeyF52
- KeyF53
- KeyF54
- KeyF55
- KeyF56
- KeyF57
- KeyF58
- KeyF59
- KeyF60
- KeyF61
- KeyF62
- KeyF63
-
- //nolint:godox
- // The following are keys defined in the Kitty keyboard protocol.
- // TODO: Investigate the names of these keys.
-
- KeyCapsLock
- KeyScrollLock
- KeyNumLock
- KeyPrintScreen
- KeyPause
- KeyMenu
-
- KeyMediaPlay
- KeyMediaPause
- KeyMediaPlayPause
- KeyMediaReverse
- KeyMediaStop
- KeyMediaFastForward
- KeyMediaRewind
- KeyMediaNext
- KeyMediaPrev
- KeyMediaRecord
-
- KeyLowerVol
- KeyRaiseVol
- KeyMute
-
- KeyLeftShift
- KeyLeftAlt
- KeyLeftCtrl
- KeyLeftSuper
- KeyLeftHyper
- KeyLeftMeta
- KeyRightShift
- KeyRightAlt
- KeyRightCtrl
- KeyRightSuper
- KeyRightHyper
- KeyRightMeta
- KeyIsoLevel3Shift
- KeyIsoLevel5Shift
-
- // Special names in C0.
-
- KeyBackspace = rune(ansi.DEL)
- KeyTab = rune(ansi.HT)
- KeyEnter = rune(ansi.CR)
- KeyReturn = KeyEnter
- KeyEscape = rune(ansi.ESC)
- KeyEsc = KeyEscape
-
- // Special names in G0.
-
- KeySpace = rune(ansi.SP)
-)
-
-// KeyPressEvent represents a key press event.
-type KeyPressEvent Key
-
-// String implements [fmt.Stringer] and is quite useful for matching key
-// events. For details, on what this returns see [Key.String].
-func (k KeyPressEvent) String() string {
- return Key(k).String()
-}
-
-// Keystroke returns the keystroke representation of the [Key]. While less type
-// safe than looking at the individual fields, it will usually be more
-// convenient and readable to use this method when matching against keys.
-//
-// Note that modifier keys are always printed in the following order:
-// - ctrl
-// - alt
-// - shift
-// - meta
-// - hyper
-// - super
-//
-// For example, you'll always see "ctrl+shift+alt+a" and never
-// "shift+ctrl+alt+a".
-func (k KeyPressEvent) Keystroke() string {
- return Key(k).Keystroke()
-}
-
-// Key returns the underlying key event. This is a syntactic sugar for casting
-// the key event to a [Key].
-func (k KeyPressEvent) Key() Key {
- return Key(k)
-}
-
-// KeyReleaseEvent represents a key release event.
-type KeyReleaseEvent Key
-
-// String implements [fmt.Stringer] and is quite useful for matching key
-// events. For details, on what this returns see [Key.String].
-func (k KeyReleaseEvent) String() string {
- return Key(k).String()
-}
-
-// Keystroke returns the keystroke representation of the [Key]. While less type
-// safe than looking at the individual fields, it will usually be more
-// convenient and readable to use this method when matching against keys.
-//
-// Note that modifier keys are always printed in the following order:
-// - ctrl
-// - alt
-// - shift
-// - meta
-// - hyper
-// - super
-//
-// For example, you'll always see "ctrl+shift+alt+a" and never
-// "shift+ctrl+alt+a".
-func (k KeyReleaseEvent) Keystroke() string {
- return Key(k).Keystroke()
-}
-
-// Key returns the underlying key event. This is a convenience method and
-// syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to
-// [Key].
-func (k KeyReleaseEvent) Key() Key {
- return Key(k)
-}
-
-// KeyEvent represents a key event. This can be either a key press or a key
-// release event.
-type KeyEvent interface {
- fmt.Stringer
-
- // Key returns the underlying key event.
- Key() Key
-}
-
-// Key represents a Key press or release event. It contains information about
-// the Key pressed, like the runes, the type of Key, and the modifiers pressed.
-// There are a couple general patterns you could use to check for key presses
-// or releases:
-//
-// // Switch on the string representation of the key (shorter)
-// switch ev := ev.(type) {
-// case KeyPressEvent:
-// switch ev.String() {
-// case "enter":
-// fmt.Println("you pressed enter!")
-// case "a":
-// fmt.Println("you pressed a!")
-// }
-// }
-//
-// // Switch on the key type (more foolproof)
-// switch ev := ev.(type) {
-// case KeyEvent:
-// // catch both KeyPressEvent and KeyReleaseEvent
-// switch key := ev.Key(); key.Code {
-// case KeyEnter:
-// fmt.Println("you pressed enter!")
-// default:
-// switch key.Text {
-// case "a":
-// fmt.Println("you pressed a!")
-// }
-// }
-// }
-//
-// Note that [Key.Text] will be empty for special keys like [KeyEnter],
-// [KeyTab], and for keys that don't represent printable characters like key
-// combos with modifier keys. In other words, [Key.Text] is populated only for
-// keys that represent printable characters shifted or unshifted (like 'a',
-// 'A', '1', '!', etc.).
-type Key struct {
- // Text contains the actual characters received. This usually the same as
- // [Key.Code]. When [Key.Text] is non-empty, it indicates that the key
- // pressed represents printable character(s).
- Text string
-
- // Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on.
- Mod KeyMod
-
- // Code represents the key pressed. This is usually a special key like
- // [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'.
- Code rune
-
- // ShiftedCode is the actual, shifted key pressed by the user. For example,
- // if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will
- // be 'A' and [Key.Code] will be 'a'.
- //
- // In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the
- // unshifted key on the keyboard.
- //
- // This is only available with the Kitty Keyboard Protocol or the Windows
- // Console API.
- ShiftedCode rune
-
- // BaseCode is the key pressed according to the standard PC-101 key layout.
- // On international keyboards, this is the key that would be pressed if the
- // keyboard was set to US PC-101 layout.
- //
- // For example, if the user presses 'q' on a French AZERTY keyboard,
- // [Key.BaseCode] will be 'q'.
- //
- // This is only available with the Kitty Keyboard Protocol or the Windows
- // Console API.
- BaseCode rune
-
- // IsRepeat indicates whether the key is being held down and sending events
- // repeatedly.
- //
- // This is only available with the Kitty Keyboard Protocol or the Windows
- // Console API.
- IsRepeat bool
-}
-
-// String implements [fmt.Stringer] and is quite useful for matching key
-// events. It will return the textual representation of the [Key] if there is
-// one, otherwise, it will fallback to [Key.Keystroke].
-//
-// For example, you'll always get "?" and instead of "shift+/" on a US ANSI
-// keyboard.
-func (k Key) String() string {
- if len(k.Text) > 0 && k.Text != " " {
- return k.Text
- }
- return k.Keystroke()
-}
-
-// Keystroke returns the keystroke representation of the [Key]. While less type
-// safe than looking at the individual fields, it will usually be more
-// convenient and readable to use this method when matching against keys.
-//
-// Note that modifier keys are always printed in the following order:
-// - ctrl
-// - alt
-// - shift
-// - meta
-// - hyper
-// - super
-//
-// For example, you'll always see "ctrl+shift+alt+a" and never
-// "shift+ctrl+alt+a".
-func (k Key) Keystroke() string {
- var sb strings.Builder
- if k.Mod.Contains(ModCtrl) && k.Code != KeyLeftCtrl && k.Code != KeyRightCtrl {
- sb.WriteString("ctrl+")
- }
- if k.Mod.Contains(ModAlt) && k.Code != KeyLeftAlt && k.Code != KeyRightAlt {
- sb.WriteString("alt+")
- }
- if k.Mod.Contains(ModShift) && k.Code != KeyLeftShift && k.Code != KeyRightShift {
- sb.WriteString("shift+")
- }
- if k.Mod.Contains(ModMeta) && k.Code != KeyLeftMeta && k.Code != KeyRightMeta {
- sb.WriteString("meta+")
- }
- if k.Mod.Contains(ModHyper) && k.Code != KeyLeftHyper && k.Code != KeyRightHyper {
- sb.WriteString("hyper+")
- }
- if k.Mod.Contains(ModSuper) && k.Code != KeyLeftSuper && k.Code != KeyRightSuper {
- sb.WriteString("super+")
- }
-
- if kt, ok := keyTypeString[k.Code]; ok {
- sb.WriteString(kt)
- } else {
- code := k.Code
- if k.BaseCode != 0 {
- // If a [Key.BaseCode] is present, use it to represent a key using the standard
- // PC-101 key layout.
- code = k.BaseCode
- }
-
- switch code {
- case KeySpace:
- // Space is the only invisible printable character.
- sb.WriteString("space")
- case KeyExtended:
- // Write the actual text of the key when the key contains multiple
- // runes.
- sb.WriteString(k.Text)
- default:
- sb.WriteRune(code)
- }
- }
-
- return sb.String()
-}
-
-var keyTypeString = map[rune]string{
- KeyEnter: "enter",
- KeyTab: "tab",
- KeyBackspace: "backspace",
- KeyEscape: "esc",
- KeySpace: "space",
- KeyUp: "up",
- KeyDown: "down",
- KeyLeft: "left",
- KeyRight: "right",
- KeyBegin: "begin",
- KeyFind: "find",
- KeyInsert: "insert",
- KeyDelete: "delete",
- KeySelect: "select",
- KeyPgUp: "pgup",
- KeyPgDown: "pgdown",
- KeyHome: "home",
- KeyEnd: "end",
- KeyKpEnter: "kpenter",
- KeyKpEqual: "kpequal",
- KeyKpMultiply: "kpmul",
- KeyKpPlus: "kpplus",
- KeyKpComma: "kpcomma",
- KeyKpMinus: "kpminus",
- KeyKpDecimal: "kpperiod",
- KeyKpDivide: "kpdiv",
- KeyKp0: "kp0",
- KeyKp1: "kp1",
- KeyKp2: "kp2",
- KeyKp3: "kp3",
- KeyKp4: "kp4",
- KeyKp5: "kp5",
- KeyKp6: "kp6",
- KeyKp7: "kp7",
- KeyKp8: "kp8",
- KeyKp9: "kp9",
-
- // Kitty keyboard extension
- KeyKpSep: "kpsep",
- KeyKpUp: "kpup",
- KeyKpDown: "kpdown",
- KeyKpLeft: "kpleft",
- KeyKpRight: "kpright",
- KeyKpPgUp: "kppgup",
- KeyKpPgDown: "kppgdown",
- KeyKpHome: "kphome",
- KeyKpEnd: "kpend",
- KeyKpInsert: "kpinsert",
- KeyKpDelete: "kpdelete",
- KeyKpBegin: "kpbegin",
-
- KeyF1: "f1",
- KeyF2: "f2",
- KeyF3: "f3",
- KeyF4: "f4",
- KeyF5: "f5",
- KeyF6: "f6",
- KeyF7: "f7",
- KeyF8: "f8",
- KeyF9: "f9",
- KeyF10: "f10",
- KeyF11: "f11",
- KeyF12: "f12",
- KeyF13: "f13",
- KeyF14: "f14",
- KeyF15: "f15",
- KeyF16: "f16",
- KeyF17: "f17",
- KeyF18: "f18",
- KeyF19: "f19",
- KeyF20: "f20",
- KeyF21: "f21",
- KeyF22: "f22",
- KeyF23: "f23",
- KeyF24: "f24",
- KeyF25: "f25",
- KeyF26: "f26",
- KeyF27: "f27",
- KeyF28: "f28",
- KeyF29: "f29",
- KeyF30: "f30",
- KeyF31: "f31",
- KeyF32: "f32",
- KeyF33: "f33",
- KeyF34: "f34",
- KeyF35: "f35",
- KeyF36: "f36",
- KeyF37: "f37",
- KeyF38: "f38",
- KeyF39: "f39",
- KeyF40: "f40",
- KeyF41: "f41",
- KeyF42: "f42",
- KeyF43: "f43",
- KeyF44: "f44",
- KeyF45: "f45",
- KeyF46: "f46",
- KeyF47: "f47",
- KeyF48: "f48",
- KeyF49: "f49",
- KeyF50: "f50",
- KeyF51: "f51",
- KeyF52: "f52",
- KeyF53: "f53",
- KeyF54: "f54",
- KeyF55: "f55",
- KeyF56: "f56",
- KeyF57: "f57",
- KeyF58: "f58",
- KeyF59: "f59",
- KeyF60: "f60",
- KeyF61: "f61",
- KeyF62: "f62",
- KeyF63: "f63",
-
- // Kitty keyboard extension
- KeyCapsLock: "capslock",
- KeyScrollLock: "scrolllock",
- KeyNumLock: "numlock",
- KeyPrintScreen: "printscreen",
- KeyPause: "pause",
- KeyMenu: "menu",
- KeyMediaPlay: "mediaplay",
- KeyMediaPause: "mediapause",
- KeyMediaPlayPause: "mediaplaypause",
- KeyMediaReverse: "mediareverse",
- KeyMediaStop: "mediastop",
- KeyMediaFastForward: "mediafastforward",
- KeyMediaRewind: "mediarewind",
- KeyMediaNext: "medianext",
- KeyMediaPrev: "mediaprev",
- KeyMediaRecord: "mediarecord",
- KeyLowerVol: "lowervol",
- KeyRaiseVol: "raisevol",
- KeyMute: "mute",
- KeyLeftShift: "leftshift",
- KeyLeftAlt: "leftalt",
- KeyLeftCtrl: "leftctrl",
- KeyLeftSuper: "leftsuper",
- KeyLeftHyper: "lefthyper",
- KeyLeftMeta: "leftmeta",
- KeyRightShift: "rightshift",
- KeyRightAlt: "rightalt",
- KeyRightCtrl: "rightctrl",
- KeyRightSuper: "rightsuper",
- KeyRightHyper: "righthyper",
- KeyRightMeta: "rightmeta",
- KeyIsoLevel3Shift: "isolevel3shift",
- KeyIsoLevel5Shift: "isolevel5shift",
-}
diff --git a/packages/tui/input/key_test.go b/packages/tui/input/key_test.go
deleted file mode 100644
index b09f2f859..000000000
--- a/packages/tui/input/key_test.go
+++ /dev/null
@@ -1,880 +0,0 @@
-package input
-
-import (
- "bytes"
- "context"
- "errors"
- "flag"
- "fmt"
- "image/color"
- "io"
- "math/rand"
- "reflect"
- "regexp"
- "runtime"
- "sort"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/ansi/kitty"
-)
-
-var sequences = buildKeysTable(FlagTerminfo, "dumb")
-
-func TestKeyString(t *testing.T) {
- t.Run("alt+space", func(t *testing.T) {
- k := KeyPressEvent{Code: KeySpace, Mod: ModAlt}
- if got := k.String(); got != "alt+space" {
- t.Fatalf(`expected a "alt+space", got %q`, got)
- }
- })
-
- t.Run("runes", func(t *testing.T) {
- k := KeyPressEvent{Code: 'a', Text: "a"}
- if got := k.String(); got != "a" {
- t.Fatalf(`expected an "a", got %q`, got)
- }
- })
-
- t.Run("invalid", func(t *testing.T) {
- k := KeyPressEvent{Code: 99999}
- if got := k.String(); got != "𘚟" {
- t.Fatalf(`expected a "unknown", got %q`, got)
- }
- })
-
- t.Run("space", func(t *testing.T) {
- k := KeyPressEvent{Code: KeySpace, Text: " "}
- if got := k.String(); got != "space" {
- t.Fatalf(`expected a "space", got %q`, got)
- }
- })
-
- t.Run("shift+space", func(t *testing.T) {
- k := KeyPressEvent{Code: KeySpace, Mod: ModShift}
- if got := k.String(); got != "shift+space" {
- t.Fatalf(`expected a "shift+space", got %q`, got)
- }
- })
-
- t.Run("?", func(t *testing.T) {
- k := KeyPressEvent{Code: '/', Mod: ModShift, Text: "?"}
- if got := k.String(); got != "?" {
- t.Fatalf(`expected a "?", got %q`, got)
- }
- })
-}
-
-type seqTest struct {
- seq []byte
- Events []Event
-}
-
-var f3CurPosRegexp = regexp.MustCompile(`\x1b\[1;(\d+)R`)
-
-// buildBaseSeqTests returns sequence tests that are valid for the
-// detectSequence() function.
-func buildBaseSeqTests() []seqTest {
- td := []seqTest{}
- for seq, key := range sequences {
- k := KeyPressEvent(key)
- st := seqTest{seq: []byte(seq), Events: []Event{k}}
-
- // XXX: This is a special case to handle F3 key sequence and cursor
- // position report having the same sequence. See [parseCsi] for more
- // information.
- if f3CurPosRegexp.MatchString(seq) {
- st.Events = []Event{k, CursorPositionEvent{Y: 0, X: int(key.Mod)}}
- }
- td = append(td, st)
- }
-
- // Additional special cases.
- td = append(td,
- // Unrecognized CSI sequence.
- seqTest{
- []byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
- []Event{
- UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
- },
- },
- // A lone space character.
- seqTest{
- []byte{' '},
- []Event{
- KeyPressEvent{Code: KeySpace, Text: " "},
- },
- },
- // An escape character with the alt modifier.
- seqTest{
- []byte{'\x1b', ' '},
- []Event{
- KeyPressEvent{Code: KeySpace, Mod: ModAlt},
- },
- },
- )
- return td
-}
-
-func TestParseSequence(t *testing.T) {
- td := buildBaseSeqTests()
- td = append(td,
- // Background color.
- seqTest{
- []byte("\x1b]11;rgb:1234/1234/1234\x07"),
- []Event{BackgroundColorEvent{
- Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
- }},
- },
- seqTest{
- []byte("\x1b]11;rgb:1234/1234/1234\x1b\\"),
- []Event{BackgroundColorEvent{
- Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
- }},
- },
- seqTest{
- []byte("\x1b]11;rgb:1234/1234/1234\x1b"), // Incomplete sequences are ignored.
- []Event{
- UnknownEvent("\x1b]11;rgb:1234/1234/1234\x1b"),
- },
- },
-
- // Kitty Graphics response.
- seqTest{
- []byte("\x1b_Ga=t;OK\x1b\\"),
- []Event{KittyGraphicsEvent{
- Options: kitty.Options{Action: kitty.Transmit},
- Payload: []byte("OK"),
- }},
- },
- seqTest{
- []byte("\x1b_Gi=99,I=13;OK\x1b\\"),
- []Event{KittyGraphicsEvent{
- Options: kitty.Options{ID: 99, Number: 13},
- Payload: []byte("OK"),
- }},
- },
- seqTest{
- []byte("\x1b_Gi=1337,q=1;EINVAL:your face\x1b\\"),
- []Event{KittyGraphicsEvent{
- Options: kitty.Options{ID: 1337, Quite: 1},
- Payload: []byte("EINVAL:your face"),
- }},
- },
-
- // Xterm modifyOtherKeys CSI 27 ; <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:])
- }
- }
-}
diff --git a/packages/tui/input/kitty.go b/packages/tui/input/kitty.go
deleted file mode 100644
index 4da00b502..000000000
--- a/packages/tui/input/kitty.go
+++ /dev/null
@@ -1,353 +0,0 @@
-package input
-
-import (
- "unicode"
- "unicode/utf8"
-
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/ansi/kitty"
-)
-
-// KittyGraphicsEvent represents a Kitty Graphics response event.
-//
-// See https://sw.kovidgoyal.net/kitty/graphics-protocol/
-type KittyGraphicsEvent struct {
- Options kitty.Options
- Payload []byte
-}
-
-// KittyEnhancementsEvent represents a Kitty enhancements event.
-type KittyEnhancementsEvent int
-
-// Kitty keyboard enhancement constants.
-// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
-const (
- KittyDisambiguateEscapeCodes KittyEnhancementsEvent = 1 << iota
- KittyReportEventTypes
- KittyReportAlternateKeys
- KittyReportAllKeysAsEscapeCodes
- KittyReportAssociatedText
-)
-
-// Contains reports whether m contains the given enhancements.
-func (e KittyEnhancementsEvent) Contains(enhancements KittyEnhancementsEvent) bool {
- return e&enhancements == enhancements
-}
-
-// Kitty Clipboard Control Sequences.
-var kittyKeyMap = map[int]Key{
- ansi.BS: {Code: KeyBackspace},
- ansi.HT: {Code: KeyTab},
- ansi.CR: {Code: KeyEnter},
- ansi.ESC: {Code: KeyEscape},
- ansi.DEL: {Code: KeyBackspace},
-
- 57344: {Code: KeyEscape},
- 57345: {Code: KeyEnter},
- 57346: {Code: KeyTab},
- 57347: {Code: KeyBackspace},
- 57348: {Code: KeyInsert},
- 57349: {Code: KeyDelete},
- 57350: {Code: KeyLeft},
- 57351: {Code: KeyRight},
- 57352: {Code: KeyUp},
- 57353: {Code: KeyDown},
- 57354: {Code: KeyPgUp},
- 57355: {Code: KeyPgDown},
- 57356: {Code: KeyHome},
- 57357: {Code: KeyEnd},
- 57358: {Code: KeyCapsLock},
- 57359: {Code: KeyScrollLock},
- 57360: {Code: KeyNumLock},
- 57361: {Code: KeyPrintScreen},
- 57362: {Code: KeyPause},
- 57363: {Code: KeyMenu},
- 57364: {Code: KeyF1},
- 57365: {Code: KeyF2},
- 57366: {Code: KeyF3},
- 57367: {Code: KeyF4},
- 57368: {Code: KeyF5},
- 57369: {Code: KeyF6},
- 57370: {Code: KeyF7},
- 57371: {Code: KeyF8},
- 57372: {Code: KeyF9},
- 57373: {Code: KeyF10},
- 57374: {Code: KeyF11},
- 57375: {Code: KeyF12},
- 57376: {Code: KeyF13},
- 57377: {Code: KeyF14},
- 57378: {Code: KeyF15},
- 57379: {Code: KeyF16},
- 57380: {Code: KeyF17},
- 57381: {Code: KeyF18},
- 57382: {Code: KeyF19},
- 57383: {Code: KeyF20},
- 57384: {Code: KeyF21},
- 57385: {Code: KeyF22},
- 57386: {Code: KeyF23},
- 57387: {Code: KeyF24},
- 57388: {Code: KeyF25},
- 57389: {Code: KeyF26},
- 57390: {Code: KeyF27},
- 57391: {Code: KeyF28},
- 57392: {Code: KeyF29},
- 57393: {Code: KeyF30},
- 57394: {Code: KeyF31},
- 57395: {Code: KeyF32},
- 57396: {Code: KeyF33},
- 57397: {Code: KeyF34},
- 57398: {Code: KeyF35},
- 57399: {Code: KeyKp0},
- 57400: {Code: KeyKp1},
- 57401: {Code: KeyKp2},
- 57402: {Code: KeyKp3},
- 57403: {Code: KeyKp4},
- 57404: {Code: KeyKp5},
- 57405: {Code: KeyKp6},
- 57406: {Code: KeyKp7},
- 57407: {Code: KeyKp8},
- 57408: {Code: KeyKp9},
- 57409: {Code: KeyKpDecimal},
- 57410: {Code: KeyKpDivide},
- 57411: {Code: KeyKpMultiply},
- 57412: {Code: KeyKpMinus},
- 57413: {Code: KeyKpPlus},
- 57414: {Code: KeyKpEnter},
- 57415: {Code: KeyKpEqual},
- 57416: {Code: KeyKpSep},
- 57417: {Code: KeyKpLeft},
- 57418: {Code: KeyKpRight},
- 57419: {Code: KeyKpUp},
- 57420: {Code: KeyKpDown},
- 57421: {Code: KeyKpPgUp},
- 57422: {Code: KeyKpPgDown},
- 57423: {Code: KeyKpHome},
- 57424: {Code: KeyKpEnd},
- 57425: {Code: KeyKpInsert},
- 57426: {Code: KeyKpDelete},
- 57427: {Code: KeyKpBegin},
- 57428: {Code: KeyMediaPlay},
- 57429: {Code: KeyMediaPause},
- 57430: {Code: KeyMediaPlayPause},
- 57431: {Code: KeyMediaReverse},
- 57432: {Code: KeyMediaStop},
- 57433: {Code: KeyMediaFastForward},
- 57434: {Code: KeyMediaRewind},
- 57435: {Code: KeyMediaNext},
- 57436: {Code: KeyMediaPrev},
- 57437: {Code: KeyMediaRecord},
- 57438: {Code: KeyLowerVol},
- 57439: {Code: KeyRaiseVol},
- 57440: {Code: KeyMute},
- 57441: {Code: KeyLeftShift},
- 57442: {Code: KeyLeftCtrl},
- 57443: {Code: KeyLeftAlt},
- 57444: {Code: KeyLeftSuper},
- 57445: {Code: KeyLeftHyper},
- 57446: {Code: KeyLeftMeta},
- 57447: {Code: KeyRightShift},
- 57448: {Code: KeyRightCtrl},
- 57449: {Code: KeyRightAlt},
- 57450: {Code: KeyRightSuper},
- 57451: {Code: KeyRightHyper},
- 57452: {Code: KeyRightMeta},
- 57453: {Code: KeyIsoLevel3Shift},
- 57454: {Code: KeyIsoLevel5Shift},
-}
-
-func init() {
- // These are some faulty C0 mappings some terminals such as WezTerm have
- // and doesn't follow the specs.
- kittyKeyMap[ansi.NUL] = Key{Code: KeySpace, Mod: ModCtrl}
- for i := ansi.SOH; i <= ansi.SUB; i++ {
- if _, ok := kittyKeyMap[i]; !ok {
- kittyKeyMap[i] = Key{Code: rune(i + 0x60), Mod: ModCtrl}
- }
- }
- for i := ansi.FS; i <= ansi.US; i++ {
- if _, ok := kittyKeyMap[i]; !ok {
- kittyKeyMap[i] = Key{Code: rune(i + 0x40), Mod: ModCtrl}
- }
- }
-}
-
-const (
- kittyShift = 1 << iota
- kittyAlt
- kittyCtrl
- kittySuper
- kittyHyper
- kittyMeta
- kittyCapsLock
- kittyNumLock
-)
-
-func fromKittyMod(mod int) KeyMod {
- var m KeyMod
- if mod&kittyShift != 0 {
- m |= ModShift
- }
- if mod&kittyAlt != 0 {
- m |= ModAlt
- }
- if mod&kittyCtrl != 0 {
- m |= ModCtrl
- }
- if mod&kittySuper != 0 {
- m |= ModSuper
- }
- if mod&kittyHyper != 0 {
- m |= ModHyper
- }
- if mod&kittyMeta != 0 {
- m |= ModMeta
- }
- if mod&kittyCapsLock != 0 {
- m |= ModCapsLock
- }
- if mod&kittyNumLock != 0 {
- m |= ModNumLock
- }
- return m
-}
-
-// parseKittyKeyboard parses a Kitty Keyboard Protocol sequence.
-//
-// In `CSI u`, this is parsed as:
-//
-// CSI codepoint ; modifiers u
-// codepoint: ASCII Dec value
-//
-// The Kitty Keyboard Protocol extends this with optional components that can be
-// enabled progressively. The full sequence is parsed as:
-//
-// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u
-//
-// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/
-func parseKittyKeyboard(params ansi.Params) (Event Event) {
- var isRelease bool
- var key Key
-
- // The index of parameters separated by semicolons ';'. Sub parameters are
- // separated by colons ':'.
- var paramIdx int
- var sudIdx int // The sub parameter index
- for _, p := range params {
- // Kitty Keyboard Protocol has 3 optional components.
- switch paramIdx {
- case 0:
- switch sudIdx {
- case 0:
- var foundKey bool
- code := p.Param(1) // CSI u has a default value of 1
- key, foundKey = kittyKeyMap[code]
- if !foundKey {
- r := rune(code)
- if !utf8.ValidRune(r) {
- r = utf8.RuneError
- }
-
- key.Code = r
- }
-
- case 2:
- // shifted key + base key
- if b := rune(p.Param(1)); unicode.IsPrint(b) {
- // XXX: When alternate key reporting is enabled, the protocol
- // can return 3 things, the unicode codepoint of the key,
- // the shifted codepoint of the key, and the standard
- // PC-101 key layout codepoint.
- // This is useful to create an unambiguous mapping of keys
- // when using a different language layout.
- key.BaseCode = b
- }
- fallthrough
-
- case 1:
- // shifted key
- if s := rune(p.Param(1)); unicode.IsPrint(s) {
- // XXX: We swap keys here because we want the shifted key
- // to be the Rune that is returned by the event.
- // For example, shift+a should produce "A" not "a".
- // In such a case, we set AltRune to the original key "a"
- // and Rune to "A".
- key.ShiftedCode = s
- }
- }
- case 1:
- switch sudIdx {
- case 0:
- mod := p.Param(1)
- if mod > 1 {
- key.Mod = fromKittyMod(mod - 1)
- if key.Mod > ModShift {
- // XXX: We need to clear the text if we have a modifier key
- // other than a [ModShift] key.
- key.Text = ""
- }
- }
-
- case 1:
- switch p.Param(1) {
- case 2:
- key.IsRepeat = true
- case 3:
- isRelease = true
- }
- case 2:
- }
- case 2:
- if code := p.Param(0); code != 0 {
- key.Text += string(rune(code))
- }
- }
-
- sudIdx++
- if !p.HasMore() {
- paramIdx++
- sudIdx = 0
- }
- }
-
- //nolint:nestif
- if len(key.Text) == 0 && unicode.IsPrint(key.Code) &&
- (key.Mod <= ModShift || key.Mod == ModCapsLock || key.Mod == ModShift|ModCapsLock) {
- if key.Mod == 0 {
- key.Text = string(key.Code)
- } else {
- desiredCase := unicode.ToLower
- if key.Mod.Contains(ModShift) || key.Mod.Contains(ModCapsLock) {
- desiredCase = unicode.ToUpper
- }
- if key.ShiftedCode != 0 {
- key.Text = string(key.ShiftedCode)
- } else {
- key.Text = string(desiredCase(key.Code))
- }
- }
- }
-
- if isRelease {
- return KeyReleaseEvent(key)
- }
-
- return KeyPressEvent(key)
-}
-
-// parseKittyKeyboardExt parses a Kitty Keyboard Protocol sequence extensions
-// for non CSI u sequences. This includes things like CSI A, SS3 A and others,
-// and CSI ~.
-func parseKittyKeyboardExt(params ansi.Params, k KeyPressEvent) Event {
- // Handle Kitty keyboard protocol
- if len(params) > 2 && // We have at least 3 parameters
- params[0].Param(1) == 1 && // The first parameter is 1 (defaults to 1)
- params[1].HasMore() { // The second parameter is a subparameter (separated by a ":")
- switch params[2].Param(1) { // The third parameter is the event type (defaults to 1)
- case 2:
- k.IsRepeat = true
- case 3:
- return KeyReleaseEvent(k)
- }
- }
- return k
-}
diff --git a/packages/tui/input/mod.go b/packages/tui/input/mod.go
deleted file mode 100644
index c00762769..000000000
--- a/packages/tui/input/mod.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package input
-
-// KeyMod represents modifier keys.
-type KeyMod int
-
-// Modifier keys.
-const (
- ModShift KeyMod = 1 << iota
- ModAlt
- ModCtrl
- ModMeta
-
- // These modifiers are used with the Kitty protocol.
- // XXX: Meta and Super are swapped in the Kitty protocol,
- // this is to preserve compatibility with XTerm modifiers.
-
- ModHyper
- ModSuper // Windows/Command keys
-
- // These are key lock states.
-
- ModCapsLock
- ModNumLock
- ModScrollLock // Defined in Windows API only
-)
-
-// Contains reports whether m contains the given modifiers.
-//
-// Example:
-//
-// m := ModAlt | ModCtrl
-// m.Contains(ModCtrl) // true
-// m.Contains(ModAlt | ModCtrl) // true
-// m.Contains(ModAlt | ModCtrl | ModShift) // false
-func (m KeyMod) Contains(mods KeyMod) bool {
- return m&mods == mods
-}
diff --git a/packages/tui/input/mode.go b/packages/tui/input/mode.go
deleted file mode 100644
index ea1ba571d..000000000
--- a/packages/tui/input/mode.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package input
-
-import "github.com/charmbracelet/x/ansi"
-
-// ModeReportEvent is a message that represents a mode report event (DECRPM).
-//
-// See: https://vt100.net/docs/vt510-rm/DECRPM.html
-type ModeReportEvent struct {
- // Mode is the mode number.
- Mode ansi.Mode
-
- // Value is the mode value.
- Value ansi.ModeSetting
-}
diff --git a/packages/tui/input/mouse.go b/packages/tui/input/mouse.go
deleted file mode 100644
index d97eb72ed..000000000
--- a/packages/tui/input/mouse.go
+++ /dev/null
@@ -1,292 +0,0 @@
-package input
-
-import (
- "fmt"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-// MouseButton represents the button that was pressed during a mouse message.
-type MouseButton = ansi.MouseButton
-
-// Mouse event buttons
-//
-// This is based on X11 mouse button codes.
-//
-// 1 = left button
-// 2 = middle button (pressing the scroll wheel)
-// 3 = right button
-// 4 = turn scroll wheel up
-// 5 = turn scroll wheel down
-// 6 = push scroll wheel left
-// 7 = push scroll wheel right
-// 8 = 4th button (aka browser backward button)
-// 9 = 5th button (aka browser forward button)
-// 10
-// 11
-//
-// Other buttons are not supported.
-const (
- MouseNone = ansi.MouseNone
- MouseLeft = ansi.MouseLeft
- MouseMiddle = ansi.MouseMiddle
- MouseRight = ansi.MouseRight
- MouseWheelUp = ansi.MouseWheelUp
- MouseWheelDown = ansi.MouseWheelDown
- MouseWheelLeft = ansi.MouseWheelLeft
- MouseWheelRight = ansi.MouseWheelRight
- MouseBackward = ansi.MouseBackward
- MouseForward = ansi.MouseForward
- MouseButton10 = ansi.MouseButton10
- MouseButton11 = ansi.MouseButton11
-)
-
-// MouseEvent represents a mouse message. This is a generic mouse message that
-// can represent any kind of mouse event.
-type MouseEvent interface {
- fmt.Stringer
-
- // Mouse returns the underlying mouse event.
- Mouse() Mouse
-}
-
-// Mouse represents a Mouse message. Use [MouseEvent] to represent all mouse
-// messages.
-//
-// The X and Y coordinates are zero-based, with (0,0) being the upper left
-// corner of the terminal.
-//
-// // Catch all mouse events
-// switch Event := Event.(type) {
-// case MouseEvent:
-// m := Event.Mouse()
-// fmt.Println("Mouse event:", m.X, m.Y, m)
-// }
-//
-// // Only catch mouse click events
-// switch Event := Event.(type) {
-// case MouseClickEvent:
-// fmt.Println("Mouse click event:", Event.X, Event.Y, Event)
-// }
-type Mouse struct {
- X, Y int
- Button MouseButton
- Mod KeyMod
-}
-
-// String returns a string representation of the mouse message.
-func (m Mouse) String() (s string) {
- if m.Mod.Contains(ModCtrl) {
- s += "ctrl+"
- }
- if m.Mod.Contains(ModAlt) {
- s += "alt+"
- }
- if m.Mod.Contains(ModShift) {
- s += "shift+"
- }
-
- str := m.Button.String()
- if str == "" {
- s += "unknown"
- } else if str != "none" { // motion events don't have a button
- s += str
- }
-
- return s
-}
-
-// MouseClickEvent represents a mouse button click event.
-type MouseClickEvent Mouse
-
-// String returns a string representation of the mouse click event.
-func (e MouseClickEvent) String() string {
- return Mouse(e).String()
-}
-
-// Mouse returns the underlying mouse event. This is a convenience method and
-// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
-// event to [Mouse].
-func (e MouseClickEvent) Mouse() Mouse {
- return Mouse(e)
-}
-
-// MouseReleaseEvent represents a mouse button release event.
-type MouseReleaseEvent Mouse
-
-// String returns a string representation of the mouse release event.
-func (e MouseReleaseEvent) String() string {
- return Mouse(e).String()
-}
-
-// Mouse returns the underlying mouse event. This is a convenience method and
-// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
-// event to [Mouse].
-func (e MouseReleaseEvent) Mouse() Mouse {
- return Mouse(e)
-}
-
-// MouseWheelEvent represents a mouse wheel message event.
-type MouseWheelEvent Mouse
-
-// String returns a string representation of the mouse wheel event.
-func (e MouseWheelEvent) String() string {
- return Mouse(e).String()
-}
-
-// Mouse returns the underlying mouse event. This is a convenience method and
-// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
-// event to [Mouse].
-func (e MouseWheelEvent) Mouse() Mouse {
- return Mouse(e)
-}
-
-// MouseMotionEvent represents a mouse motion event.
-type MouseMotionEvent Mouse
-
-// String returns a string representation of the mouse motion event.
-func (e MouseMotionEvent) String() string {
- m := Mouse(e)
- if m.Button != 0 {
- return m.String() + "+motion"
- }
- return m.String() + "motion"
-}
-
-// Mouse returns the underlying mouse event. This is a convenience method and
-// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
-// event to [Mouse].
-func (e MouseMotionEvent) Mouse() Mouse {
- return Mouse(e)
-}
-
-// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
-// look like:
-//
-// ESC [ < Cb ; Cx ; Cy (M or m)
-//
-// where:
-//
-// Cb is the encoded button code
-// Cx is the x-coordinate of the mouse
-// Cy is the y-coordinate of the mouse
-// M is for button press, m is for button release
-//
-// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
-func parseSGRMouseEvent(cmd ansi.Cmd, params ansi.Params) Event {
- x, _, ok := params.Param(1, 1)
- if !ok {
- x = 1
- }
- y, _, ok := params.Param(2, 1)
- if !ok {
- y = 1
- }
- release := cmd.Final() == 'm'
- b, _, _ := params.Param(0, 0)
- mod, btn, _, isMotion := parseMouseButton(b)
-
- // (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
- x--
- y--
-
- m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
-
- // Wheel buttons don't have release events
- // Motion can be reported as a release event in some terminals (Windows Terminal)
- if isWheel(m.Button) {
- return MouseWheelEvent(m)
- } else if !isMotion && release {
- return MouseReleaseEvent(m)
- } else if isMotion {
- return MouseMotionEvent(m)
- }
- return MouseClickEvent(m)
-}
-
-const x10MouseByteOffset = 32
-
-// Parse X10-encoded mouse events; the simplest kind. The last release of X10
-// was December 1986, by the way. The original X10 mouse protocol limits the Cx
-// and Cy coordinates to 223 (=255-032).
-//
-// X10 mouse events look like:
-//
-// ESC [M Cb Cx Cy
-//
-// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
-func parseX10MouseEvent(buf []byte) Event {
- v := buf[3:6]
- b := int(v[0])
- if b >= x10MouseByteOffset {
- // XXX: b < 32 should be impossible, but we're being defensive.
- b -= x10MouseByteOffset
- }
-
- mod, btn, isRelease, isMotion := parseMouseButton(b)
-
- // (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
- x := int(v[1]) - x10MouseByteOffset - 1
- y := int(v[2]) - x10MouseByteOffset - 1
-
- m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
- if isWheel(m.Button) {
- return MouseWheelEvent(m)
- } else if isMotion {
- return MouseMotionEvent(m)
- } else if isRelease {
- return MouseReleaseEvent(m)
- }
- return MouseClickEvent(m)
-}
-
-// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
-func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) {
- // mouse bit shifts
- const (
- bitShift = 0b0000_0100
- bitAlt = 0b0000_1000
- bitCtrl = 0b0001_0000
- bitMotion = 0b0010_0000
- bitWheel = 0b0100_0000
- bitAdd = 0b1000_0000 // additional buttons 8-11
-
- bitsMask = 0b0000_0011
- )
-
- // Modifiers
- if b&bitAlt != 0 {
- mod |= ModAlt
- }
- if b&bitCtrl != 0 {
- mod |= ModCtrl
- }
- if b&bitShift != 0 {
- mod |= ModShift
- }
-
- if b&bitAdd != 0 {
- btn = MouseBackward + MouseButton(b&bitsMask)
- } else if b&bitWheel != 0 {
- btn = MouseWheelUp + MouseButton(b&bitsMask)
- } else {
- btn = MouseLeft + MouseButton(b&bitsMask)
- // X10 reports a button release as 0b0000_0011 (3)
- if b&bitsMask == bitsMask {
- btn = MouseNone
- isRelease = true
- }
- }
-
- // Motion bit doesn't get reported for wheel events.
- if b&bitMotion != 0 && !isWheel(btn) {
- isMotion = true
- }
-
- return //nolint:nakedret
-}
-
-// isWheel returns true if the mouse event is a wheel event.
-func isWheel(btn MouseButton) bool {
- return btn >= MouseWheelUp && btn <= MouseWheelRight
-}
diff --git a/packages/tui/input/mouse_test.go b/packages/tui/input/mouse_test.go
deleted file mode 100644
index d55e41480..000000000
--- a/packages/tui/input/mouse_test.go
+++ /dev/null
@@ -1,481 +0,0 @@
-package input
-
-import (
- "fmt"
- "testing"
-
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/ansi/parser"
-)
-
-func TestMouseEvent_String(t *testing.T) {
- tt := []struct {
- name string
- event Event
- expected string
- }{
- {
- name: "unknown",
- event: MouseClickEvent{Button: MouseButton(0xff)},
- expected: "unknown",
- },
- {
- name: "left",
- event: MouseClickEvent{Button: MouseLeft},
- expected: "left",
- },
- {
- name: "right",
- event: MouseClickEvent{Button: MouseRight},
- expected: "right",
- },
- {
- name: "middle",
- event: MouseClickEvent{Button: MouseMiddle},
- expected: "middle",
- },
- {
- name: "release",
- event: MouseReleaseEvent{Button: MouseNone},
- expected: "",
- },
- {
- name: "wheelup",
- event: MouseWheelEvent{Button: MouseWheelUp},
- expected: "wheelup",
- },
- {
- name: "wheeldown",
- event: MouseWheelEvent{Button: MouseWheelDown},
- expected: "wheeldown",
- },
- {
- name: "wheelleft",
- event: MouseWheelEvent{Button: MouseWheelLeft},
- expected: "wheelleft",
- },
- {
- name: "wheelright",
- event: MouseWheelEvent{Button: MouseWheelRight},
- expected: "wheelright",
- },
- {
- name: "motion",
- event: MouseMotionEvent{Button: MouseNone},
- expected: "motion",
- },
- {
- name: "shift+left",
- event: MouseReleaseEvent{Button: MouseLeft, Mod: ModShift},
- expected: "shift+left",
- },
- {
- name: "shift+left", event: MouseClickEvent{Button: MouseLeft, Mod: ModShift},
- expected: "shift+left",
- },
- {
- name: "ctrl+shift+left",
- event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl | ModShift},
- expected: "ctrl+shift+left",
- },
- {
- name: "alt+left",
- event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt},
- expected: "alt+left",
- },
- {
- name: "ctrl+left",
- event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl},
- expected: "ctrl+left",
- },
- {
- name: "ctrl+alt+left",
- event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl},
- expected: "ctrl+alt+left",
- },
- {
- name: "ctrl+alt+shift+left",
- event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift},
- expected: "ctrl+alt+shift+left",
- },
- {
- name: "ignore coordinates",
- event: MouseClickEvent{X: 100, Y: 200, Button: MouseLeft},
- expected: "left",
- },
- {
- name: "broken type",
- event: MouseClickEvent{Button: MouseButton(120)},
- expected: "unknown",
- },
- }
-
- for i := range tt {
- tc := tt[i]
-
- t.Run(tc.name, func(t *testing.T) {
- actual := fmt.Sprint(tc.event)
-
- if tc.expected != actual {
- t.Fatalf("expected %q but got %q",
- tc.expected,
- actual,
- )
- }
- })
- }
-}
-
-func TestParseX10MouseDownEvent(t *testing.T) {
- encode := func(b byte, x, y int) []byte {
- return []byte{
- '\x1b',
- '[',
- 'M',
- byte(32) + b,
- byte(x + 32 + 1),
- byte(y + 32 + 1),
- }
- }
-
- tt := []struct {
- name string
- buf []byte
- expected Event
- }{
- // Position.
- {
- name: "zero position",
- buf: encode(0b0000_0000, 0, 0),
- expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
- },
- {
- name: "max position",
- buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
- expected: MouseClickEvent{X: 222, Y: 222, Button: MouseLeft},
- },
- // Simple.
- {
- name: "left",
- buf: encode(0b0000_0000, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
- },
- {
- name: "left in motion",
- buf: encode(0b0010_0000, 32, 16),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
- },
- {
- name: "middle",
- buf: encode(0b0000_0001, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
- },
- {
- name: "middle in motion",
- buf: encode(0b0010_0001, 32, 16),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
- },
- {
- name: "right",
- buf: encode(0b0000_0010, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
- },
- {
- name: "right in motion",
- buf: encode(0b0010_0010, 32, 16),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseRight},
- },
- {
- name: "motion",
- buf: encode(0b0010_0011, 32, 16),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
- },
- {
- name: "wheel up",
- buf: encode(0b0100_0000, 32, 16),
- expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
- },
- {
- name: "wheel down",
- buf: encode(0b0100_0001, 32, 16),
- expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
- },
- {
- name: "wheel left",
- buf: encode(0b0100_0010, 32, 16),
- expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
- },
- {
- name: "wheel right",
- buf: encode(0b0100_0011, 32, 16),
- expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
- },
- {
- name: "release",
- buf: encode(0b0000_0011, 32, 16),
- expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseNone},
- },
- {
- name: "backward",
- buf: encode(0b1000_0000, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
- },
- {
- name: "forward",
- buf: encode(0b1000_0001, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
- },
- {
- name: "button 10",
- buf: encode(0b1000_0010, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton10},
- },
- {
- name: "button 11",
- buf: encode(0b1000_0011, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton11},
- },
- // Combinations.
- {
- name: "alt+right",
- buf: encode(0b0000_1010, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
- },
- {
- name: "ctrl+right",
- buf: encode(0b0001_0010, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
- },
- {
- name: "left in motion",
- buf: encode(0b0010_0000, 32, 16),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
- },
- {
- name: "alt+right in motion",
- buf: encode(0b0010_1010, 32, 16),
- expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
- },
- {
- name: "ctrl+right in motion",
- buf: encode(0b0011_0010, 32, 16),
- expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
- },
- {
- name: "ctrl+alt+right",
- buf: encode(0b0001_1010, 32, 16),
- expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
- },
- {
- name: "ctrl+wheel up",
- buf: encode(0b0101_0000, 32, 16),
- expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp},
- },
- {
- name: "alt+wheel down",
- buf: encode(0b0100_1001, 32, 16),
- expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
- },
- {
- name: "ctrl+alt+wheel down",
- buf: encode(0b0101_1001, 32, 16),
- expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
- },
- // Overflow position.
- {
- name: "overflow position",
- buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
- expected: MouseMotionEvent{X: -6, Y: -33, Button: MouseLeft},
- },
- }
-
- for i := range tt {
- tc := tt[i]
-
- t.Run(tc.name, func(t *testing.T) {
- actual := parseX10MouseEvent(tc.buf)
-
- if tc.expected != actual {
- t.Fatalf("expected %#v but got %#v",
- tc.expected,
- actual,
- )
- }
- })
- }
-}
-
-func TestParseSGRMouseEvent(t *testing.T) {
- type csiSequence struct {
- params []ansi.Param
- cmd ansi.Cmd
- }
- encode := func(b, x, y int, r bool) *csiSequence {
- re := 'M'
- if r {
- re = 'm'
- }
- return &csiSequence{
- params: []ansi.Param{
- ansi.Param(b),
- ansi.Param(x + 1),
- ansi.Param(y + 1),
- },
- cmd: ansi.Cmd(re) | ('<' << parser.PrefixShift),
- }
- }
-
- tt := []struct {
- name string
- buf *csiSequence
- expected Event
- }{
- // Position.
- {
- name: "zero position",
- buf: encode(0, 0, 0, false),
- expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
- },
- {
- name: "225 position",
- buf: encode(0, 225, 225, false),
- expected: MouseClickEvent{X: 225, Y: 225, Button: MouseLeft},
- },
- // Simple.
- {
- name: "left",
- buf: encode(0, 32, 16, false),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
- },
- {
- name: "left in motion",
- buf: encode(32, 32, 16, false),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
- },
- {
- name: "left",
- buf: encode(0, 32, 16, true),
- expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseLeft},
- },
- {
- name: "middle",
- buf: encode(1, 32, 16, false),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
- },
- {
- name: "middle in motion",
- buf: encode(33, 32, 16, false),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
- },
- {
- name: "middle",
- buf: encode(1, 32, 16, true),
- expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseMiddle},
- },
- {
- name: "right",
- buf: encode(2, 32, 16, false),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
- },
- {
- name: "right",
- buf: encode(2, 32, 16, true),
- expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseRight},
- },
- {
- name: "motion",
- buf: encode(35, 32, 16, false),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
- },
- {
- name: "wheel up",
- buf: encode(64, 32, 16, false),
- expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
- },
- {
- name: "wheel down",
- buf: encode(65, 32, 16, false),
- expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
- },
- {
- name: "wheel left",
- buf: encode(66, 32, 16, false),
- expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
- },
- {
- name: "wheel right",
- buf: encode(67, 32, 16, false),
- expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
- },
- {
- name: "backward",
- buf: encode(128, 32, 16, false),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
- },
- {
- name: "backward in motion",
- buf: encode(160, 32, 16, false),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseBackward},
- },
- {
- name: "forward",
- buf: encode(129, 32, 16, false),
- expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
- },
- {
- name: "forward in motion",
- buf: encode(161, 32, 16, false),
- expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseForward},
- },
- // Combinations.
- {
- name: "alt+right",
- buf: encode(10, 32, 16, false),
- expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
- },
- {
- name: "ctrl+right",
- buf: encode(18, 32, 16, false),
- expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
- },
- {
- name: "ctrl+alt+right",
- buf: encode(26, 32, 16, false),
- expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
- },
- {
- name: "alt+wheel",
- buf: encode(73, 32, 16, false),
- expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
- },
- {
- name: "ctrl+wheel",
- buf: encode(81, 32, 16, false),
- expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown},
- },
- {
- name: "ctrl+alt+wheel",
- buf: encode(89, 32, 16, false),
- expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
- },
- {
- name: "ctrl+alt+shift+wheel",
- buf: encode(93, 32, 16, false),
- expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown},
- },
- }
-
- for i := range tt {
- tc := tt[i]
-
- t.Run(tc.name, func(t *testing.T) {
- actual := parseSGRMouseEvent(tc.buf.cmd, tc.buf.params)
- if tc.expected != actual {
- t.Fatalf("expected %#v but got %#v",
- tc.expected,
- actual,
- )
- }
- })
- }
-}
diff --git a/packages/tui/input/parse.go b/packages/tui/input/parse.go
deleted file mode 100644
index ad8e21849..000000000
--- a/packages/tui/input/parse.go
+++ /dev/null
@@ -1,1030 +0,0 @@
-package input
-
-import (
- "bytes"
- "encoding/base64"
- "slices"
- "strings"
- "unicode"
- "unicode/utf8"
-
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/ansi/parser"
- "github.com/rivo/uniseg"
-)
-
-// Flags to control the behavior of the parser.
-const (
- // When this flag is set, the driver will treat both Ctrl+Space and Ctrl+@
- // as the same key sequence.
- //
- // Historically, the ANSI specs generate NUL (0x00) on both the Ctrl+Space
- // and Ctrl+@ key sequences. This flag allows the driver to treat both as
- // the same key sequence.
- FlagCtrlAt = 1 << iota
-
- // When this flag is set, the driver will treat the Tab key and Ctrl+I as
- // the same key sequence.
- //
- // Historically, the ANSI specs generate HT (0x09) on both the Tab key and
- // Ctrl+I. This flag allows the driver to treat both as the same key
- // sequence.
- FlagCtrlI
-
- // When this flag is set, the driver will treat the Enter key and Ctrl+M as
- // the same key sequence.
- //
- // Historically, the ANSI specs generate CR (0x0D) on both the Enter key
- // and Ctrl+M. This flag allows the driver to treat both as the same key.
- FlagCtrlM
-
- // When this flag is set, the driver will treat Escape and Ctrl+[ as
- // the same key sequence.
- //
- // Historically, the ANSI specs generate ESC (0x1B) on both the Escape key
- // and Ctrl+[. This flag allows the driver to treat both as the same key
- // sequence.
- FlagCtrlOpenBracket
-
- // When this flag is set, the driver will send a BS (0x08 byte) character
- // instead of a DEL (0x7F byte) character when the Backspace key is
- // pressed.
- //
- // The VT100 terminal has both a Backspace and a Delete key. The VT220
- // terminal dropped the Backspace key and replaced it with the Delete key.
- // Both terminals send a DEL character when the Delete key is pressed.
- // Modern terminals and PCs later readded the Delete key but used a
- // different key sequence, and the Backspace key was standardized to send a
- // DEL character.
- FlagBackspace
-
- // When this flag is set, the driver will recognize the Find key instead of
- // treating it as a Home key.
- //
- // The Find key was part of the VT220 keyboard, and is no longer used in
- // modern day PCs.
- FlagFind
-
- // When this flag is set, the driver will recognize the Select key instead
- // of treating it as a End key.
- //
- // The Symbol key was part of the VT220 keyboard, and is no longer used in
- // modern day PCs.
- FlagSelect
-
- // When this flag is set, the driver will use Terminfo databases to
- // overwrite the default key sequences.
- FlagTerminfo
-
- // When this flag is set, the driver will preserve function keys (F13-F63)
- // as symbols.
- //
- // Since these keys are not part of today's standard 20th century keyboard,
- // we treat them as F1-F12 modifier keys i.e. ctrl/shift/alt + Fn combos.
- // Key definitions come from Terminfo, this flag is only useful when
- // FlagTerminfo is not set.
- FlagFKeys
-
- // When this flag is set, the driver will enable mouse mode on Windows.
- // This is only useful on Windows and has no effect on other platforms.
- FlagMouseMode
-)
-
-// Parser is a parser for input escape sequences.
-type Parser struct {
- flags int
-}
-
-// NewParser returns a new input parser. This is a low-level parser that parses
-// escape sequences into human-readable events.
-// This differs from [ansi.Parser] and [ansi.DecodeSequence] in which it
-// recognizes incorrect sequences that some terminals may send.
-//
-// For instance, the X10 mouse protocol sends a `CSI M` sequence followed by 3
-// bytes. If the parser doesn't recognize the 3 bytes, they might be echoed to
-// the terminal output causing a mess.
-//
-// Another example is how URxvt sends invalid sequences for modified keys using
-// invalid CSI final characters like '$'.
-//
-// Use flags to control the behavior of ambiguous key sequences.
-func NewParser(flags int) *Parser {
- return &Parser{flags: flags}
-}
-
-// parseSequence finds the first recognized event sequence and returns it along
-// with its length.
-//
-// It will return zero and nil no sequence is recognized or when the buffer is
-// empty. If a sequence is not supported, an UnknownEvent is returned.
-func (p *Parser) parseSequence(buf []byte) (n int, Event Event) {
- if len(buf) == 0 {
- return 0, nil
- }
-
- switch b := buf[0]; b {
- case ansi.ESC:
- if len(buf) == 1 {
- // Escape key
- return 1, KeyPressEvent{Code: KeyEscape}
- }
-
- switch bPrime := buf[1]; bPrime {
- case 'O': // Esc-prefixed SS3
- return p.parseSs3(buf)
- case 'P': // Esc-prefixed DCS
- return p.parseDcs(buf)
- case '[': // Esc-prefixed CSI
- return p.parseCsi(buf)
- case ']': // Esc-prefixed OSC
- return p.parseOsc(buf)
- case '_': // Esc-prefixed APC
- return p.parseApc(buf)
- case '^': // Esc-prefixed PM
- return p.parseStTerminated(ansi.PM, '^', nil)(buf)
- case 'X': // Esc-prefixed SOS
- return p.parseStTerminated(ansi.SOS, 'X', nil)(buf)
- default:
- n, e := p.parseSequence(buf[1:])
- if k, ok := e.(KeyPressEvent); ok {
- k.Text = ""
- k.Mod |= ModAlt
- return n + 1, k
- }
-
- // Not a key sequence, nor an alt modified key sequence. In that
- // case, just report a single escape key.
- return 1, KeyPressEvent{Code: KeyEscape}
- }
- case ansi.SS3:
- return p.parseSs3(buf)
- case ansi.DCS:
- return p.parseDcs(buf)
- case ansi.CSI:
- return p.parseCsi(buf)
- case ansi.OSC:
- return p.parseOsc(buf)
- case ansi.APC:
- return p.parseApc(buf)
- case ansi.PM:
- return p.parseStTerminated(ansi.PM, '^', nil)(buf)
- case ansi.SOS:
- return p.parseStTerminated(ansi.SOS, 'X', nil)(buf)
- default:
- if b <= ansi.US || b == ansi.DEL || b == ansi.SP {
- return 1, p.parseControl(b)
- } else if b >= ansi.PAD && b <= ansi.APC {
- // C1 control code
- // UTF-8 never starts with a C1 control code
- // Encode these as Ctrl+Alt+<code - 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)
- }
-}
diff --git a/packages/tui/input/parse_test.go b/packages/tui/input/parse_test.go
deleted file mode 100644
index dc892e0cd..000000000
--- a/packages/tui/input/parse_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package input
-
-import (
- "image/color"
- "reflect"
- "testing"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-func TestParseSequence_Events(t *testing.T) {
- input := []byte("\x1b\x1b[Ztest\x00\x1b]10;rgb:1234/1234/1234\x07\x1b[27;2;27~\x1b[?1049;2$y\x1b[4;1$y")
- want := []Event{
- KeyPressEvent{Code: KeyTab, Mod: ModShift | ModAlt},
- KeyPressEvent{Code: 't', Text: "t"},
- KeyPressEvent{Code: 'e', Text: "e"},
- KeyPressEvent{Code: 's', Text: "s"},
- KeyPressEvent{Code: 't', Text: "t"},
- KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
- ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}},
- KeyPressEvent{Code: KeyEscape, Mod: ModShift},
- ModeReportEvent{Mode: ansi.AltScreenSaveCursorMode, Value: ansi.ModeReset},
- ModeReportEvent{Mode: ansi.InsertReplaceMode, Value: ansi.ModeSet},
- }
-
- var p Parser
- for i := 0; len(input) != 0; i++ {
- if i >= len(want) {
- t.Fatalf("reached end of want events")
- }
- n, got := p.parseSequence(input)
- if !reflect.DeepEqual(got, want[i]) {
- t.Errorf("got %#v (%T), want %#v (%T)", got, got, want[i], want[i])
- }
- input = input[n:]
- }
-}
-
-func BenchmarkParseSequence(b *testing.B) {
- var p Parser
- input := []byte("\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~")
- b.ReportAllocs()
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- p.parseSequence(input)
- }
-}
diff --git a/packages/tui/input/paste.go b/packages/tui/input/paste.go
deleted file mode 100644
index 4e8fe68c9..000000000
--- a/packages/tui/input/paste.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package input
-
-// PasteEvent is an message that is emitted when a terminal receives pasted text
-// using bracketed-paste.
-type PasteEvent string
-
-// PasteStartEvent is an message that is emitted when the terminal starts the
-// bracketed-paste text.
-type PasteStartEvent struct{}
-
-// PasteEndEvent is an message that is emitted when the terminal ends the
-// bracketed-paste text.
-type PasteEndEvent struct{}
diff --git a/packages/tui/input/table.go b/packages/tui/input/table.go
deleted file mode 100644
index 7e81fde38..000000000
--- a/packages/tui/input/table.go
+++ /dev/null
@@ -1,389 +0,0 @@
-package input
-
-import (
- "maps"
- "strconv"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-// buildKeysTable builds a table of key sequences and their corresponding key
-// events based on the VT100/VT200, XTerm, and Urxvt terminal specs.
-func buildKeysTable(flags int, term string) map[string]Key {
- nul := Key{Code: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space
- if flags&FlagCtrlAt != 0 {
- nul = Key{Code: '@', Mod: ModCtrl}
- }
-
- tab := Key{Code: KeyTab} // ctrl+i or tab
- if flags&FlagCtrlI != 0 {
- tab = Key{Code: 'i', Mod: ModCtrl}
- }
-
- enter := Key{Code: KeyEnter} // ctrl+m or enter
- if flags&FlagCtrlM != 0 {
- enter = Key{Code: 'm', Mod: ModCtrl}
- }
-
- esc := Key{Code: KeyEscape} // ctrl+[ or escape
- if flags&FlagCtrlOpenBracket != 0 {
- esc = Key{Code: '[', Mod: ModCtrl} // ctrl+[ or escape
- }
-
- del := Key{Code: KeyBackspace}
- if flags&FlagBackspace != 0 {
- del.Code = KeyDelete
- }
-
- find := Key{Code: KeyHome}
- if flags&FlagFind != 0 {
- find.Code = KeyFind
- }
-
- sel := Key{Code: KeyEnd}
- if flags&FlagSelect != 0 {
- sel.Code = KeySelect
- }
-
- // The following is a table of key sequences and their corresponding key
- // events based on the VT100/VT200 terminal specs.
- //
- // See: https://vt100.net/docs/vt100-ug/chapter3.html#S3.2
- // See: https://vt100.net/docs/vt220-rm/chapter3.html
- //
- // XXX: These keys may be overwritten by other options like XTerm or
- // Terminfo.
- table := map[string]Key{
- // C0 control characters
- string(byte(ansi.NUL)): nul,
- string(byte(ansi.SOH)): {Code: 'a', Mod: ModCtrl},
- string(byte(ansi.STX)): {Code: 'b', Mod: ModCtrl},
- string(byte(ansi.ETX)): {Code: 'c', Mod: ModCtrl},
- string(byte(ansi.EOT)): {Code: 'd', Mod: ModCtrl},
- string(byte(ansi.ENQ)): {Code: 'e', Mod: ModCtrl},
- string(byte(ansi.ACK)): {Code: 'f', Mod: ModCtrl},
- string(byte(ansi.BEL)): {Code: 'g', Mod: ModCtrl},
- string(byte(ansi.BS)): {Code: 'h', Mod: ModCtrl},
- string(byte(ansi.HT)): tab,
- string(byte(ansi.LF)): {Code: 'j', Mod: ModCtrl},
- string(byte(ansi.VT)): {Code: 'k', Mod: ModCtrl},
- string(byte(ansi.FF)): {Code: 'l', Mod: ModCtrl},
- string(byte(ansi.CR)): enter,
- string(byte(ansi.SO)): {Code: 'n', Mod: ModCtrl},
- string(byte(ansi.SI)): {Code: 'o', Mod: ModCtrl},
- string(byte(ansi.DLE)): {Code: 'p', Mod: ModCtrl},
- string(byte(ansi.DC1)): {Code: 'q', Mod: ModCtrl},
- string(byte(ansi.DC2)): {Code: 'r', Mod: ModCtrl},
- string(byte(ansi.DC3)): {Code: 's', Mod: ModCtrl},
- string(byte(ansi.DC4)): {Code: 't', Mod: ModCtrl},
- string(byte(ansi.NAK)): {Code: 'u', Mod: ModCtrl},
- string(byte(ansi.SYN)): {Code: 'v', Mod: ModCtrl},
- string(byte(ansi.ETB)): {Code: 'w', Mod: ModCtrl},
- string(byte(ansi.CAN)): {Code: 'x', Mod: ModCtrl},
- string(byte(ansi.EM)): {Code: 'y', Mod: ModCtrl},
- string(byte(ansi.SUB)): {Code: 'z', Mod: ModCtrl},
- string(byte(ansi.ESC)): esc,
- string(byte(ansi.FS)): {Code: '\\', Mod: ModCtrl},
- string(byte(ansi.GS)): {Code: ']', Mod: ModCtrl},
- string(byte(ansi.RS)): {Code: '^', Mod: ModCtrl},
- string(byte(ansi.US)): {Code: '_', Mod: ModCtrl},
-
- // Special keys in G0
- string(byte(ansi.SP)): {Code: KeySpace, Text: " "},
- string(byte(ansi.DEL)): del,
-
- // Special keys
-
- "\x1b[Z": {Code: KeyTab, Mod: ModShift},
-
- "\x1b[1~": find,
- "\x1b[2~": {Code: KeyInsert},
- "\x1b[3~": {Code: KeyDelete},
- "\x1b[4~": sel,
- "\x1b[5~": {Code: KeyPgUp},
- "\x1b[6~": {Code: KeyPgDown},
- "\x1b[7~": {Code: KeyHome},
- "\x1b[8~": {Code: KeyEnd},
-
- // Normal mode
- "\x1b[A": {Code: KeyUp},
- "\x1b[B": {Code: KeyDown},
- "\x1b[C": {Code: KeyRight},
- "\x1b[D": {Code: KeyLeft},
- "\x1b[E": {Code: KeyBegin},
- "\x1b[F": {Code: KeyEnd},
- "\x1b[H": {Code: KeyHome},
- "\x1b[P": {Code: KeyF1},
- "\x1b[Q": {Code: KeyF2},
- "\x1b[R": {Code: KeyF3},
- "\x1b[S": {Code: KeyF4},
-
- // Application Cursor Key Mode (DECCKM)
- "\x1bOA": {Code: KeyUp},
- "\x1bOB": {Code: KeyDown},
- "\x1bOC": {Code: KeyRight},
- "\x1bOD": {Code: KeyLeft},
- "\x1bOE": {Code: KeyBegin},
- "\x1bOF": {Code: KeyEnd},
- "\x1bOH": {Code: KeyHome},
- "\x1bOP": {Code: KeyF1},
- "\x1bOQ": {Code: KeyF2},
- "\x1bOR": {Code: KeyF3},
- "\x1bOS": {Code: KeyF4},
-
- // Keypad Application Mode (DECKPAM)
-
- "\x1bOM": {Code: KeyKpEnter},
- "\x1bOX": {Code: KeyKpEqual},
- "\x1bOj": {Code: KeyKpMultiply},
- "\x1bOk": {Code: KeyKpPlus},
- "\x1bOl": {Code: KeyKpComma},
- "\x1bOm": {Code: KeyKpMinus},
- "\x1bOn": {Code: KeyKpDecimal},
- "\x1bOo": {Code: KeyKpDivide},
- "\x1bOp": {Code: KeyKp0},
- "\x1bOq": {Code: KeyKp1},
- "\x1bOr": {Code: KeyKp2},
- "\x1bOs": {Code: KeyKp3},
- "\x1bOt": {Code: KeyKp4},
- "\x1bOu": {Code: KeyKp5},
- "\x1bOv": {Code: KeyKp6},
- "\x1bOw": {Code: KeyKp7},
- "\x1bOx": {Code: KeyKp8},
- "\x1bOy": {Code: KeyKp9},
-
- // Function keys
-
- "\x1b[11~": {Code: KeyF1},
- "\x1b[12~": {Code: KeyF2},
- "\x1b[13~": {Code: KeyF3},
- "\x1b[14~": {Code: KeyF4},
- "\x1b[15~": {Code: KeyF5},
- "\x1b[17~": {Code: KeyF6},
- "\x1b[18~": {Code: KeyF7},
- "\x1b[19~": {Code: KeyF8},
- "\x1b[20~": {Code: KeyF9},
- "\x1b[21~": {Code: KeyF10},
- "\x1b[23~": {Code: KeyF11},
- "\x1b[24~": {Code: KeyF12},
- "\x1b[25~": {Code: KeyF13},
- "\x1b[26~": {Code: KeyF14},
- "\x1b[28~": {Code: KeyF15},
- "\x1b[29~": {Code: KeyF16},
- "\x1b[31~": {Code: KeyF17},
- "\x1b[32~": {Code: KeyF18},
- "\x1b[33~": {Code: KeyF19},
- "\x1b[34~": {Code: KeyF20},
- }
-
- // CSI ~ sequence keys
- csiTildeKeys := map[string]Key{
- "1": find, "2": {Code: KeyInsert},
- "3": {Code: KeyDelete}, "4": sel,
- "5": {Code: KeyPgUp}, "6": {Code: KeyPgDown},
- "7": {Code: KeyHome}, "8": {Code: KeyEnd},
- // There are no 9 and 10 keys
- "11": {Code: KeyF1}, "12": {Code: KeyF2},
- "13": {Code: KeyF3}, "14": {Code: KeyF4},
- "15": {Code: KeyF5}, "17": {Code: KeyF6},
- "18": {Code: KeyF7}, "19": {Code: KeyF8},
- "20": {Code: KeyF9}, "21": {Code: KeyF10},
- "23": {Code: KeyF11}, "24": {Code: KeyF12},
- "25": {Code: KeyF13}, "26": {Code: KeyF14},
- "28": {Code: KeyF15}, "29": {Code: KeyF16},
- "31": {Code: KeyF17}, "32": {Code: KeyF18},
- "33": {Code: KeyF19}, "34": {Code: KeyF20},
- }
-
- // URxvt keys
- // See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
- table["\x1b[a"] = Key{Code: KeyUp, Mod: ModShift}
- table["\x1b[b"] = Key{Code: KeyDown, Mod: ModShift}
- table["\x1b[c"] = Key{Code: KeyRight, Mod: ModShift}
- table["\x1b[d"] = Key{Code: KeyLeft, Mod: ModShift}
- table["\x1bOa"] = Key{Code: KeyUp, Mod: ModCtrl}
- table["\x1bOb"] = Key{Code: KeyDown, Mod: ModCtrl}
- table["\x1bOc"] = Key{Code: KeyRight, Mod: ModCtrl}
- table["\x1bOd"] = Key{Code: KeyLeft, Mod: ModCtrl}
- //nolint:godox
- // TODO: investigate if shift-ctrl arrow keys collide with DECCKM keys i.e.
- // "\x1bOA", "\x1bOB", "\x1bOC", "\x1bOD"
-
- // URxvt modifier CSI ~ keys
- for k, v := range csiTildeKeys {
- key := v
- // Normal (no modifier) already defined part of VT100/VT200
- // Shift modifier
- key.Mod = ModShift
- table["\x1b["+k+"$"] = key
- // Ctrl modifier
- key.Mod = ModCtrl
- table["\x1b["+k+"^"] = key
- // Shift-Ctrl modifier
- key.Mod = ModShift | ModCtrl
- table["\x1b["+k+"@"] = key
- }
-
- // URxvt F keys
- // Note: Shift + F1-F10 generates F11-F20.
- // This means Shift + F1 and Shift + F2 will generate F11 and F12, the same
- // applies to Ctrl + Shift F1 & F2.
- //
- // P.S. Don't like this? Blame URxvt, configure your terminal to use
- // different escapes like XTerm, or switch to a better terminal ¯\_(ツ)_/¯
- //
- // See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
- table["\x1b[23$"] = Key{Code: KeyF11, Mod: ModShift}
- table["\x1b[24$"] = Key{Code: KeyF12, Mod: ModShift}
- table["\x1b[25$"] = Key{Code: KeyF13, Mod: ModShift}
- table["\x1b[26$"] = Key{Code: KeyF14, Mod: ModShift}
- table["\x1b[28$"] = Key{Code: KeyF15, Mod: ModShift}
- table["\x1b[29$"] = Key{Code: KeyF16, Mod: ModShift}
- table["\x1b[31$"] = Key{Code: KeyF17, Mod: ModShift}
- table["\x1b[32$"] = Key{Code: KeyF18, Mod: ModShift}
- table["\x1b[33$"] = Key{Code: KeyF19, Mod: ModShift}
- table["\x1b[34$"] = Key{Code: KeyF20, Mod: ModShift}
- table["\x1b[11^"] = Key{Code: KeyF1, Mod: ModCtrl}
- table["\x1b[12^"] = Key{Code: KeyF2, Mod: ModCtrl}
- table["\x1b[13^"] = Key{Code: KeyF3, Mod: ModCtrl}
- table["\x1b[14^"] = Key{Code: KeyF4, Mod: ModCtrl}
- table["\x1b[15^"] = Key{Code: KeyF5, Mod: ModCtrl}
- table["\x1b[17^"] = Key{Code: KeyF6, Mod: ModCtrl}
- table["\x1b[18^"] = Key{Code: KeyF7, Mod: ModCtrl}
- table["\x1b[19^"] = Key{Code: KeyF8, Mod: ModCtrl}
- table["\x1b[20^"] = Key{Code: KeyF9, Mod: ModCtrl}
- table["\x1b[21^"] = Key{Code: KeyF10, Mod: ModCtrl}
- table["\x1b[23^"] = Key{Code: KeyF11, Mod: ModCtrl}
- table["\x1b[24^"] = Key{Code: KeyF12, Mod: ModCtrl}
- table["\x1b[25^"] = Key{Code: KeyF13, Mod: ModCtrl}
- table["\x1b[26^"] = Key{Code: KeyF14, Mod: ModCtrl}
- table["\x1b[28^"] = Key{Code: KeyF15, Mod: ModCtrl}
- table["\x1b[29^"] = Key{Code: KeyF16, Mod: ModCtrl}
- table["\x1b[31^"] = Key{Code: KeyF17, Mod: ModCtrl}
- table["\x1b[32^"] = Key{Code: KeyF18, Mod: ModCtrl}
- table["\x1b[33^"] = Key{Code: KeyF19, Mod: ModCtrl}
- table["\x1b[34^"] = Key{Code: KeyF20, Mod: ModCtrl}
- table["\x1b[23@"] = Key{Code: KeyF11, Mod: ModShift | ModCtrl}
- table["\x1b[24@"] = Key{Code: KeyF12, Mod: ModShift | ModCtrl}
- table["\x1b[25@"] = Key{Code: KeyF13, Mod: ModShift | ModCtrl}
- table["\x1b[26@"] = Key{Code: KeyF14, Mod: ModShift | ModCtrl}
- table["\x1b[28@"] = Key{Code: KeyF15, Mod: ModShift | ModCtrl}
- table["\x1b[29@"] = Key{Code: KeyF16, Mod: ModShift | ModCtrl}
- table["\x1b[31@"] = Key{Code: KeyF17, Mod: ModShift | ModCtrl}
- table["\x1b[32@"] = Key{Code: KeyF18, Mod: ModShift | ModCtrl}
- table["\x1b[33@"] = Key{Code: KeyF19, Mod: ModShift | ModCtrl}
- table["\x1b[34@"] = Key{Code: KeyF20, Mod: ModShift | ModCtrl}
-
- // Register Alt + <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
-}
diff --git a/packages/tui/input/termcap.go b/packages/tui/input/termcap.go
deleted file mode 100644
index 3502189ff..000000000
--- a/packages/tui/input/termcap.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package input
-
-import (
- "bytes"
- "encoding/hex"
- "strings"
-)
-
-// CapabilityEvent represents a Termcap/Terminfo response event. Termcap
-// responses are generated by the terminal in response to RequestTermcap
-// (XTGETTCAP) requests.
-//
-// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
-type CapabilityEvent string
-
-func parseTermcap(data []byte) CapabilityEvent {
- // XTGETTCAP
- if len(data) == 0 {
- return CapabilityEvent("")
- }
-
- var tc strings.Builder
- split := bytes.Split(data, []byte{';'})
- for _, s := range split {
- parts := bytes.SplitN(s, []byte{'='}, 2)
- if len(parts) == 0 {
- return CapabilityEvent("")
- }
-
- name, err := hex.DecodeString(string(parts[0]))
- if err != nil || len(name) == 0 {
- continue
- }
-
- var value []byte
- if len(parts) > 1 {
- value, err = hex.DecodeString(string(parts[1]))
- if err != nil {
- continue
- }
- }
-
- if tc.Len() > 0 {
- tc.WriteByte(';')
- }
- tc.WriteString(string(name))
- if len(value) > 0 {
- tc.WriteByte('=')
- tc.WriteString(string(value))
- }
- }
-
- return CapabilityEvent(tc.String())
-}
diff --git a/packages/tui/input/terminfo.go b/packages/tui/input/terminfo.go
deleted file mode 100644
index a54da2c3a..000000000
--- a/packages/tui/input/terminfo.go
+++ /dev/null
@@ -1,277 +0,0 @@
-package input
-
-import (
- "strings"
-
- "github.com/xo/terminfo"
-)
-
-func buildTerminfoKeys(flags int, term string) map[string]Key {
- table := make(map[string]Key)
- ti, _ := terminfo.Load(term)
- if ti == nil {
- return table
- }
-
- tiTable := defaultTerminfoKeys(flags)
-
- // Default keys
- for name, seq := range ti.StringCapsShort() {
- if !strings.HasPrefix(name, "k") || len(seq) == 0 {
- continue
- }
-
- if k, ok := tiTable[name]; ok {
- table[string(seq)] = k
- }
- }
-
- // Extended keys
- for name, seq := range ti.ExtStringCapsShort() {
- if !strings.HasPrefix(name, "k") || len(seq) == 0 {
- continue
- }
-
- if k, ok := tiTable[name]; ok {
- table[string(seq)] = k
- }
- }
-
- return table
-}
-
-// This returns a map of terminfo keys to key events. It's a mix of ncurses
-// terminfo default and user-defined key capabilities.
-// Upper-case caps that are defined in the default terminfo database are
-// - kNXT
-// - kPRV
-// - kHOM
-// - kEND
-// - kDC
-// - kIC
-// - kLFT
-// - kRIT
-//
-// See https://man7.org/linux/man-pages/man5/terminfo.5.html
-// See https://github.com/mirror/ncurses/blob/master/include/Caps-ncurses
-func defaultTerminfoKeys(flags int) map[string]Key {
- keys := map[string]Key{
- "kcuu1": {Code: KeyUp},
- "kUP": {Code: KeyUp, Mod: ModShift},
- "kUP3": {Code: KeyUp, Mod: ModAlt},
- "kUP4": {Code: KeyUp, Mod: ModShift | ModAlt},
- "kUP5": {Code: KeyUp, Mod: ModCtrl},
- "kUP6": {Code: KeyUp, Mod: ModShift | ModCtrl},
- "kUP7": {Code: KeyUp, Mod: ModAlt | ModCtrl},
- "kUP8": {Code: KeyUp, Mod: ModShift | ModAlt | ModCtrl},
- "kcud1": {Code: KeyDown},
- "kDN": {Code: KeyDown, Mod: ModShift},
- "kDN3": {Code: KeyDown, Mod: ModAlt},
- "kDN4": {Code: KeyDown, Mod: ModShift | ModAlt},
- "kDN5": {Code: KeyDown, Mod: ModCtrl},
- "kDN7": {Code: KeyDown, Mod: ModAlt | ModCtrl},
- "kDN6": {Code: KeyDown, Mod: ModShift | ModCtrl},
- "kDN8": {Code: KeyDown, Mod: ModShift | ModAlt | ModCtrl},
- "kcub1": {Code: KeyLeft},
- "kLFT": {Code: KeyLeft, Mod: ModShift},
- "kLFT3": {Code: KeyLeft, Mod: ModAlt},
- "kLFT4": {Code: KeyLeft, Mod: ModShift | ModAlt},
- "kLFT5": {Code: KeyLeft, Mod: ModCtrl},
- "kLFT6": {Code: KeyLeft, Mod: ModShift | ModCtrl},
- "kLFT7": {Code: KeyLeft, Mod: ModAlt | ModCtrl},
- "kLFT8": {Code: KeyLeft, Mod: ModShift | ModAlt | ModCtrl},
- "kcuf1": {Code: KeyRight},
- "kRIT": {Code: KeyRight, Mod: ModShift},
- "kRIT3": {Code: KeyRight, Mod: ModAlt},
- "kRIT4": {Code: KeyRight, Mod: ModShift | ModAlt},
- "kRIT5": {Code: KeyRight, Mod: ModCtrl},
- "kRIT6": {Code: KeyRight, Mod: ModShift | ModCtrl},
- "kRIT7": {Code: KeyRight, Mod: ModAlt | ModCtrl},
- "kRIT8": {Code: KeyRight, Mod: ModShift | ModAlt | ModCtrl},
- "kich1": {Code: KeyInsert},
- "kIC": {Code: KeyInsert, Mod: ModShift},
- "kIC3": {Code: KeyInsert, Mod: ModAlt},
- "kIC4": {Code: KeyInsert, Mod: ModShift | ModAlt},
- "kIC5": {Code: KeyInsert, Mod: ModCtrl},
- "kIC6": {Code: KeyInsert, Mod: ModShift | ModCtrl},
- "kIC7": {Code: KeyInsert, Mod: ModAlt | ModCtrl},
- "kIC8": {Code: KeyInsert, Mod: ModShift | ModAlt | ModCtrl},
- "kdch1": {Code: KeyDelete},
- "kDC": {Code: KeyDelete, Mod: ModShift},
- "kDC3": {Code: KeyDelete, Mod: ModAlt},
- "kDC4": {Code: KeyDelete, Mod: ModShift | ModAlt},
- "kDC5": {Code: KeyDelete, Mod: ModCtrl},
- "kDC6": {Code: KeyDelete, Mod: ModShift | ModCtrl},
- "kDC7": {Code: KeyDelete, Mod: ModAlt | ModCtrl},
- "kDC8": {Code: KeyDelete, Mod: ModShift | ModAlt | ModCtrl},
- "khome": {Code: KeyHome},
- "kHOM": {Code: KeyHome, Mod: ModShift},
- "kHOM3": {Code: KeyHome, Mod: ModAlt},
- "kHOM4": {Code: KeyHome, Mod: ModShift | ModAlt},
- "kHOM5": {Code: KeyHome, Mod: ModCtrl},
- "kHOM6": {Code: KeyHome, Mod: ModShift | ModCtrl},
- "kHOM7": {Code: KeyHome, Mod: ModAlt | ModCtrl},
- "kHOM8": {Code: KeyHome, Mod: ModShift | ModAlt | ModCtrl},
- "kend": {Code: KeyEnd},
- "kEND": {Code: KeyEnd, Mod: ModShift},
- "kEND3": {Code: KeyEnd, Mod: ModAlt},
- "kEND4": {Code: KeyEnd, Mod: ModShift | ModAlt},
- "kEND5": {Code: KeyEnd, Mod: ModCtrl},
- "kEND6": {Code: KeyEnd, Mod: ModShift | ModCtrl},
- "kEND7": {Code: KeyEnd, Mod: ModAlt | ModCtrl},
- "kEND8": {Code: KeyEnd, Mod: ModShift | ModAlt | ModCtrl},
- "kpp": {Code: KeyPgUp},
- "kprv": {Code: KeyPgUp},
- "kPRV": {Code: KeyPgUp, Mod: ModShift},
- "kPRV3": {Code: KeyPgUp, Mod: ModAlt},
- "kPRV4": {Code: KeyPgUp, Mod: ModShift | ModAlt},
- "kPRV5": {Code: KeyPgUp, Mod: ModCtrl},
- "kPRV6": {Code: KeyPgUp, Mod: ModShift | ModCtrl},
- "kPRV7": {Code: KeyPgUp, Mod: ModAlt | ModCtrl},
- "kPRV8": {Code: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl},
- "knp": {Code: KeyPgDown},
- "knxt": {Code: KeyPgDown},
- "kNXT": {Code: KeyPgDown, Mod: ModShift},
- "kNXT3": {Code: KeyPgDown, Mod: ModAlt},
- "kNXT4": {Code: KeyPgDown, Mod: ModShift | ModAlt},
- "kNXT5": {Code: KeyPgDown, Mod: ModCtrl},
- "kNXT6": {Code: KeyPgDown, Mod: ModShift | ModCtrl},
- "kNXT7": {Code: KeyPgDown, Mod: ModAlt | ModCtrl},
- "kNXT8": {Code: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl},
-
- "kbs": {Code: KeyBackspace},
- "kcbt": {Code: KeyTab, Mod: ModShift},
-
- // Function keys
- // This only includes the first 12 function keys. The rest are treated
- // as modifiers of the first 12.
- // Take a look at XTerm modifyFunctionKeys
- //
- // XXX: To use unambiguous function keys, use fixterms or kitty clipboard.
- //
- // See https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyFunctionKeys
- // See https://invisible-island.net/xterm/terminfo.html
-
- "kf1": {Code: KeyF1},
- "kf2": {Code: KeyF2},
- "kf3": {Code: KeyF3},
- "kf4": {Code: KeyF4},
- "kf5": {Code: KeyF5},
- "kf6": {Code: KeyF6},
- "kf7": {Code: KeyF7},
- "kf8": {Code: KeyF8},
- "kf9": {Code: KeyF9},
- "kf10": {Code: KeyF10},
- "kf11": {Code: KeyF11},
- "kf12": {Code: KeyF12},
- "kf13": {Code: KeyF1, Mod: ModShift},
- "kf14": {Code: KeyF2, Mod: ModShift},
- "kf15": {Code: KeyF3, Mod: ModShift},
- "kf16": {Code: KeyF4, Mod: ModShift},
- "kf17": {Code: KeyF5, Mod: ModShift},
- "kf18": {Code: KeyF6, Mod: ModShift},
- "kf19": {Code: KeyF7, Mod: ModShift},
- "kf20": {Code: KeyF8, Mod: ModShift},
- "kf21": {Code: KeyF9, Mod: ModShift},
- "kf22": {Code: KeyF10, Mod: ModShift},
- "kf23": {Code: KeyF11, Mod: ModShift},
- "kf24": {Code: KeyF12, Mod: ModShift},
- "kf25": {Code: KeyF1, Mod: ModCtrl},
- "kf26": {Code: KeyF2, Mod: ModCtrl},
- "kf27": {Code: KeyF3, Mod: ModCtrl},
- "kf28": {Code: KeyF4, Mod: ModCtrl},
- "kf29": {Code: KeyF5, Mod: ModCtrl},
- "kf30": {Code: KeyF6, Mod: ModCtrl},
- "kf31": {Code: KeyF7, Mod: ModCtrl},
- "kf32": {Code: KeyF8, Mod: ModCtrl},
- "kf33": {Code: KeyF9, Mod: ModCtrl},
- "kf34": {Code: KeyF10, Mod: ModCtrl},
- "kf35": {Code: KeyF11, Mod: ModCtrl},
- "kf36": {Code: KeyF12, Mod: ModCtrl},
- "kf37": {Code: KeyF1, Mod: ModShift | ModCtrl},
- "kf38": {Code: KeyF2, Mod: ModShift | ModCtrl},
- "kf39": {Code: KeyF3, Mod: ModShift | ModCtrl},
- "kf40": {Code: KeyF4, Mod: ModShift | ModCtrl},
- "kf41": {Code: KeyF5, Mod: ModShift | ModCtrl},
- "kf42": {Code: KeyF6, Mod: ModShift | ModCtrl},
- "kf43": {Code: KeyF7, Mod: ModShift | ModCtrl},
- "kf44": {Code: KeyF8, Mod: ModShift | ModCtrl},
- "kf45": {Code: KeyF9, Mod: ModShift | ModCtrl},
- "kf46": {Code: KeyF10, Mod: ModShift | ModCtrl},
- "kf47": {Code: KeyF11, Mod: ModShift | ModCtrl},
- "kf48": {Code: KeyF12, Mod: ModShift | ModCtrl},
- "kf49": {Code: KeyF1, Mod: ModAlt},
- "kf50": {Code: KeyF2, Mod: ModAlt},
- "kf51": {Code: KeyF3, Mod: ModAlt},
- "kf52": {Code: KeyF4, Mod: ModAlt},
- "kf53": {Code: KeyF5, Mod: ModAlt},
- "kf54": {Code: KeyF6, Mod: ModAlt},
- "kf55": {Code: KeyF7, Mod: ModAlt},
- "kf56": {Code: KeyF8, Mod: ModAlt},
- "kf57": {Code: KeyF9, Mod: ModAlt},
- "kf58": {Code: KeyF10, Mod: ModAlt},
- "kf59": {Code: KeyF11, Mod: ModAlt},
- "kf60": {Code: KeyF12, Mod: ModAlt},
- "kf61": {Code: KeyF1, Mod: ModShift | ModAlt},
- "kf62": {Code: KeyF2, Mod: ModShift | ModAlt},
- "kf63": {Code: KeyF3, Mod: ModShift | ModAlt},
- }
-
- // Preserve F keys from F13 to F63 instead of using them for F-keys
- // modifiers.
- if flags&FlagFKeys != 0 {
- keys["kf13"] = Key{Code: KeyF13}
- keys["kf14"] = Key{Code: KeyF14}
- keys["kf15"] = Key{Code: KeyF15}
- keys["kf16"] = Key{Code: KeyF16}
- keys["kf17"] = Key{Code: KeyF17}
- keys["kf18"] = Key{Code: KeyF18}
- keys["kf19"] = Key{Code: KeyF19}
- keys["kf20"] = Key{Code: KeyF20}
- keys["kf21"] = Key{Code: KeyF21}
- keys["kf22"] = Key{Code: KeyF22}
- keys["kf23"] = Key{Code: KeyF23}
- keys["kf24"] = Key{Code: KeyF24}
- keys["kf25"] = Key{Code: KeyF25}
- keys["kf26"] = Key{Code: KeyF26}
- keys["kf27"] = Key{Code: KeyF27}
- keys["kf28"] = Key{Code: KeyF28}
- keys["kf29"] = Key{Code: KeyF29}
- keys["kf30"] = Key{Code: KeyF30}
- keys["kf31"] = Key{Code: KeyF31}
- keys["kf32"] = Key{Code: KeyF32}
- keys["kf33"] = Key{Code: KeyF33}
- keys["kf34"] = Key{Code: KeyF34}
- keys["kf35"] = Key{Code: KeyF35}
- keys["kf36"] = Key{Code: KeyF36}
- keys["kf37"] = Key{Code: KeyF37}
- keys["kf38"] = Key{Code: KeyF38}
- keys["kf39"] = Key{Code: KeyF39}
- keys["kf40"] = Key{Code: KeyF40}
- keys["kf41"] = Key{Code: KeyF41}
- keys["kf42"] = Key{Code: KeyF42}
- keys["kf43"] = Key{Code: KeyF43}
- keys["kf44"] = Key{Code: KeyF44}
- keys["kf45"] = Key{Code: KeyF45}
- keys["kf46"] = Key{Code: KeyF46}
- keys["kf47"] = Key{Code: KeyF47}
- keys["kf48"] = Key{Code: KeyF48}
- keys["kf49"] = Key{Code: KeyF49}
- keys["kf50"] = Key{Code: KeyF50}
- keys["kf51"] = Key{Code: KeyF51}
- keys["kf52"] = Key{Code: KeyF52}
- keys["kf53"] = Key{Code: KeyF53}
- keys["kf54"] = Key{Code: KeyF54}
- keys["kf55"] = Key{Code: KeyF55}
- keys["kf56"] = Key{Code: KeyF56}
- keys["kf57"] = Key{Code: KeyF57}
- keys["kf58"] = Key{Code: KeyF58}
- keys["kf59"] = Key{Code: KeyF59}
- keys["kf60"] = Key{Code: KeyF60}
- keys["kf61"] = Key{Code: KeyF61}
- keys["kf62"] = Key{Code: KeyF62}
- keys["kf63"] = Key{Code: KeyF63}
- }
-
- return keys
-}
diff --git a/packages/tui/input/xterm.go b/packages/tui/input/xterm.go
deleted file mode 100644
index b3bbc3083..000000000
--- a/packages/tui/input/xterm.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package input
-
-import (
- "github.com/charmbracelet/x/ansi"
-)
-
-func parseXTermModifyOtherKeys(params ansi.Params) Event {
- // XTerm modify other keys starts with ESC [ 27 ; <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
diff --git a/packages/tui/internal/api/api.go b/packages/tui/internal/api/api.go
deleted file mode 100644
index b4d3adee2..000000000
--- a/packages/tui/internal/api/api.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package api
-
-import (
- "context"
- "encoding/json"
- "log"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode-sdk-go"
-)
-
-type Request struct {
- Path string `json:"path"`
- Body json.RawMessage `json:"body"`
-}
-
-func Start(ctx context.Context, program *tea.Program, client *opencode.Client) {
- for {
- select {
- case <-ctx.Done():
- return
- default:
- var req Request
- if err := client.Get(ctx, "/tui/control/next", nil, &req); err != nil {
- log.Printf("Error getting next request: %v", err)
- continue
- }
- program.Send(req)
- }
- }
-}
-
-func Reply(ctx context.Context, client *opencode.Client, response interface{}) tea.Cmd {
- return func() tea.Msg {
- err := client.Post(ctx, "/tui/control/response", response, nil)
- if err != nil {
- return err
- }
- return nil
- }
-}
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
deleted file mode 100644
index e0f1d9920..000000000
--- a/packages/tui/internal/app/app.go
+++ /dev/null
@@ -1,963 +0,0 @@
-package app
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "slices"
- "strings"
- "time"
-
- "log/slog"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/clipboard"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/id"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type Message struct {
- Info opencode.MessageUnion
- Parts []opencode.PartUnion
-}
-
-type App struct {
- Project opencode.Project
- Agents []opencode.Agent
- Providers []opencode.Provider
- Version string
- StatePath string
- Config *opencode.Config
- Client *opencode.Client
- State *State
- AgentIndex int
- Provider *opencode.Provider
- Model *opencode.Model
- Session *opencode.Session
- Messages []Message
- Permissions []opencode.Permission
- CurrentPermission opencode.Permission
- Commands commands.CommandRegistry
- InitialModel *string
- InitialPrompt *string
- InitialAgent *string
- InitialSession *string
- compactCancel context.CancelFunc
- IsLeaderSequence bool
- IsBashMode bool
- ScrollSpeed int
-}
-
-func (a *App) Agent() *opencode.Agent {
- return &a.Agents[a.AgentIndex]
-}
-
-type SessionCreatedMsg = struct {
- Session *opencode.Session
-}
-type SessionSelectedMsg = *opencode.Session
-type MessageRevertedMsg struct {
- Session opencode.Session
- Message Message
-}
-type SessionUnrevertedMsg struct {
- Session opencode.Session
-}
-type SessionLoadedMsg struct{}
-type ModelSelectedMsg struct {
- Provider opencode.Provider
- Model opencode.Model
-}
-
-type AgentSelectedMsg struct {
- AgentName string
-}
-
-type SessionClearedMsg struct{}
-type CompactSessionMsg struct{}
-type SendPrompt = Prompt
-type SendShell = struct {
- Command string
-}
-type SendCommand = struct {
- Command string
- Args string
-}
-type SetEditorContentMsg struct {
- Text string
-}
-type FileRenderedMsg struct {
- FilePath string
-}
-type PermissionRespondedToMsg struct {
- Response opencode.SessionPermissionRespondParamsResponse
-}
-
-func New(
- ctx context.Context,
- version string,
- project *opencode.Project,
- path *opencode.Path,
- agents []opencode.Agent,
- httpClient *opencode.Client,
- initialModel *string,
- initialPrompt *string,
- initialAgent *string,
- initialSession *string,
-) (*App, error) {
- util.RootPath = project.Worktree
- util.CwdPath, _ = os.Getwd()
-
- configInfo, err := httpClient.Config.Get(ctx, opencode.ConfigGetParams{})
- if err != nil {
- return nil, err
- }
-
- if configInfo.Keybinds.Leader == "" {
- configInfo.Keybinds.Leader = "ctrl+x"
- }
-
- appStatePath := filepath.Join(path.State, "tui")
- appState, err := LoadState(appStatePath)
- if err != nil {
- appState = NewState()
- SaveState(appStatePath, appState)
- }
-
- if appState.AgentModel == nil {
- appState.AgentModel = make(map[string]AgentModel)
- }
-
- if configInfo.Theme != "" {
- appState.Theme = configInfo.Theme
- }
-
- themeEnv := os.Getenv("OPENCODE_THEME")
- if themeEnv != "" {
- appState.Theme = themeEnv
- }
-
- agentIndex := slices.IndexFunc(agents, func(a opencode.Agent) bool {
- return a.Mode != "subagent"
- })
- var agent *opencode.Agent
- modeName := "build"
- if appState.Agent != "" {
- modeName = appState.Agent
- }
- if initialAgent != nil && *initialAgent != "" {
- modeName = *initialAgent
- }
- for i, m := range agents {
- if m.Name == modeName {
- agentIndex = i
- break
- }
- }
- agent = &agents[agentIndex]
-
- if agent.Model.ModelID != "" {
- appState.AgentModel[agent.Name] = AgentModel{
- ProviderID: agent.Model.ProviderID,
- ModelID: agent.Model.ModelID,
- }
- }
-
- if err := theme.LoadThemesFromDirectories(
- path.Config,
- util.RootPath,
- util.CwdPath,
- ); err != nil {
- slog.Warn("Failed to load themes from directories", "error", err)
- }
-
- if appState.Theme != "" {
- if appState.Theme == "system" && styles.Terminal != nil {
- theme.UpdateSystemTheme(
- styles.Terminal.Background,
- styles.Terminal.BackgroundIsDark,
- )
- }
- theme.SetTheme(appState.Theme)
- }
-
- slog.Debug("Loaded config", "config", configInfo)
-
- customCommands, err := httpClient.Command.List(ctx, opencode.CommandListParams{})
- if err != nil {
- return nil, err
- }
-
- app := &App{
- Project: *project,
- Agents: agents,
- Version: version,
- StatePath: appStatePath,
- Config: configInfo,
- State: appState,
- Client: httpClient,
- AgentIndex: agentIndex,
- Session: &opencode.Session{},
- Messages: []Message{},
- Commands: commands.LoadFromConfig(configInfo, *customCommands),
- InitialModel: initialModel,
- InitialPrompt: initialPrompt,
- InitialAgent: initialAgent,
- InitialSession: initialSession,
- ScrollSpeed: int(configInfo.Tui.ScrollSpeed),
- }
-
- return app, nil
-}
-
-func (a *App) Keybind(commandName commands.CommandName) string {
- command := a.Commands[commandName]
- if len(command.Keybindings) == 0 {
- return ""
- }
- kb := command.Keybindings[0]
- key := kb.Key
- if kb.RequiresLeader {
- key = a.Config.Keybinds.Leader + " " + kb.Key
- }
- return key
-}
-
-func (a *App) Key(commandName commands.CommandName) string {
- t := theme.CurrentTheme()
- base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
- muted := styles.NewStyle().
- Background(t.Background()).
- Foreground(t.TextMuted()).
- Faint(true).
- Render
- command := a.Commands[commandName]
- key := a.Keybind(commandName)
- return base(key) + muted(" "+command.Description)
-}
-
-func SetClipboard(text string) tea.Cmd {
- var cmds []tea.Cmd
- cmds = append(cmds, func() tea.Msg {
- clipboard.Write(clipboard.FmtText, []byte(text))
- return nil
- })
- // try to set the clipboard using OSC52 for terminals that support it
- cmds = append(cmds, tea.SetClipboard(text))
- return tea.Sequence(cmds...)
-}
-
-func (a *App) updateModelForNewAgent() {
- singleModelEnv := os.Getenv("OPENCODE_AGENTS_SWITCH_SINGLE_MODEL")
- isSingleModel := singleModelEnv == "1" || singleModelEnv == "true"
-
- if isSingleModel {
- return
- }
- // Set up model for the new agent
- modelID := a.Agent().Model.ModelID
- providerID := a.Agent().Model.ProviderID
- if modelID == "" {
- if model, ok := a.State.AgentModel[a.Agent().Name]; ok {
- modelID = model.ModelID
- providerID = model.ProviderID
- }
- }
-
- if modelID != "" {
- for _, provider := range a.Providers {
- if provider.ID == providerID {
- a.Provider = &provider
- for _, model := range provider.Models {
- if model.ID == modelID {
- a.Model = &model
- break
- }
- }
- break
- }
- }
- }
-}
-
-func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
- if forward {
- a.AgentIndex++
- if a.AgentIndex >= len(a.Agents) {
- a.AgentIndex = 0
- }
- } else {
- a.AgentIndex--
- if a.AgentIndex < 0 {
- a.AgentIndex = len(a.Agents) - 1
- }
- }
- if a.Agent().Mode == "subagent" {
- return a.cycleMode(forward)
- }
-
- a.updateModelForNewAgent()
-
- a.State.Agent = a.Agent().Name
- a.State.UpdateAgentUsage(a.Agent().Name)
- return a, a.SaveState()
-}
-
-func (a *App) SwitchAgent() (*App, tea.Cmd) {
- return a.cycleMode(true)
-}
-
-func (a *App) SwitchAgentReverse() (*App, tea.Cmd) {
- return a.cycleMode(false)
-}
-
-func (a *App) cycleRecentModel(forward bool) (*App, tea.Cmd) {
- recentModels := a.State.RecentlyUsedModels
- if len(recentModels) > 5 {
- recentModels = recentModels[:5]
- }
- if len(recentModels) < 2 {
- return a, toast.NewInfoToast("Need at least 2 recent models to cycle")
- }
- nextIndex := 0
- prevIndex := 0
- for i, recentModel := range recentModels {
- if a.Provider != nil && a.Model != nil && recentModel.ProviderID == a.Provider.ID &&
- recentModel.ModelID == a.Model.ID {
- nextIndex = (i + 1) % len(recentModels)
- prevIndex = (i - 1 + len(recentModels)) % len(recentModels)
- break
- }
- }
- targetIndex := nextIndex
- if !forward {
- targetIndex = prevIndex
- }
- for range recentModels {
- currentRecentModel := recentModels[targetIndex%len(recentModels)]
- provider, model := findModelByProviderAndModelID(
- a.Providers,
- currentRecentModel.ProviderID,
- currentRecentModel.ModelID,
- )
- if provider != nil && model != nil {
- a.Provider, a.Model = provider, model
- a.State.AgentModel[a.Agent().Name] = AgentModel{
- ProviderID: provider.ID,
- ModelID: model.ID,
- }
- return a, tea.Sequence(
- a.SaveState(),
- toast.NewSuccessToast(
- fmt.Sprintf("Switched to %s (%s)", model.Name, provider.Name),
- ),
- )
- }
- recentModels = append(
- recentModels[:targetIndex%len(recentModels)],
- recentModels[targetIndex%len(recentModels)+1:]...)
- if len(recentModels) < 2 {
- a.State.RecentlyUsedModels = recentModels
- return a, tea.Sequence(
- a.SaveState(),
- toast.NewInfoToast("Not enough valid recent models to cycle"),
- )
- }
- }
- a.State.RecentlyUsedModels = recentModels
- return a, toast.NewErrorToast("Recent model not found")
-}
-
-func (a *App) CycleRecentModel() (*App, tea.Cmd) {
- return a.cycleRecentModel(true)
-}
-
-func (a *App) CycleRecentModelReverse() (*App, tea.Cmd) {
- return a.cycleRecentModel(false)
-}
-
-func (a *App) SwitchToAgent(agentName string) (*App, tea.Cmd) {
- // Find the agent index by name
- for i, agent := range a.Agents {
- if agent.Name == agentName {
- a.AgentIndex = i
- break
- }
- }
-
- a.updateModelForNewAgent()
-
- a.State.Agent = a.Agent().Name
- a.State.UpdateAgentUsage(agentName)
- return a, a.SaveState()
-}
-
-// findModelByFullID finds a model by its full ID in the format "provider/model"
-func findModelByFullID(
- providers []opencode.Provider,
- fullModelID string,
-) (*opencode.Provider, *opencode.Model) {
- modelParts := strings.SplitN(fullModelID, "/", 2)
- if len(modelParts) < 2 {
- return nil, nil
- }
-
- providerID := modelParts[0]
- modelID := modelParts[1]
-
- return findModelByProviderAndModelID(providers, providerID, modelID)
-}
-
-// findModelByProviderAndModelID finds a model by provider ID and model ID
-func findModelByProviderAndModelID(
- providers []opencode.Provider,
- providerID, modelID string,
-) (*opencode.Provider, *opencode.Model) {
- for _, provider := range providers {
- if provider.ID != providerID {
- continue
- }
-
- for _, model := range provider.Models {
- if model.ID == modelID {
- return &provider, &model
- }
- }
-
- // Provider found but model not found
- return nil, nil
- }
-
- // Provider not found
- return nil, nil
-}
-
-// findProviderByID finds a provider by its ID
-func findProviderByID(providers []opencode.Provider, providerID string) *opencode.Provider {
- for _, provider := range providers {
- if provider.ID == providerID {
- return &provider
- }
- }
- return nil
-}
-
-func (a *App) InitializeProvider() tea.Cmd {
- providersResponse, err := a.Client.App.Providers(context.Background(), opencode.AppProvidersParams{})
- if err != nil {
- slog.Error("Failed to list providers", "error", err)
- // TODO: notify user
- return nil
- }
- providers := providersResponse.Providers
- if len(providers) == 0 {
- slog.Error("No providers configured")
- return nil
- }
-
- a.Providers = providers
-
- // retains backwards compatibility with old state format
- if model, ok := a.State.AgentModel[a.State.Agent]; ok {
- a.State.Provider = model.ProviderID
- a.State.Model = model.ModelID
- }
-
- var selectedProvider *opencode.Provider
- var selectedModel *opencode.Model
-
- // Priority 1: Command line --model flag (InitialModel)
- if a.InitialModel != nil && *a.InitialModel != "" {
- if provider, model := findModelByFullID(providers, *a.InitialModel); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from command line",
- "provider",
- provider.ID,
- "model",
- model.ID,
- )
- } else {
- slog.Debug("Command line model not found", "model", *a.InitialModel)
- }
- }
-
- // Priority 2: Current agent's preferred model
- if selectedProvider == nil && a.Agent().Model.ModelID != "" {
- if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from current agent",
- "provider",
- provider.ID,
- "model",
- model.ID,
- "agent",
- a.Agent().Name,
- )
- } else {
- slog.Debug("Agent model not found", "provider", a.Agent().Model.ProviderID, "model", a.Agent().Model.ModelID, "agent", a.Agent().Name)
- }
- }
-
- // Priority 3: Config file model setting
- if selectedProvider == nil && a.Config.Model != "" {
- if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID)
- } else {
- slog.Debug("Config model not found", "model", a.Config.Model)
- }
- }
-
- // Priority 4: Recent model usage (most recently used model)
- if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 {
- recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first
- if provider, model := findModelByProviderAndModelID(providers, recentUsage.ProviderID, recentUsage.ModelID); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from recent usage",
- "provider",
- provider.ID,
- "model",
- model.ID,
- )
- } else {
- slog.Debug("Recent model not found", "provider", recentUsage.ProviderID, "model", recentUsage.ModelID)
- }
- }
-
- // Priority 5: State-based model (backwards compatibility)
- if selectedProvider == nil && a.State.Provider != "" && a.State.Model != "" {
- if provider, model := findModelByProviderAndModelID(providers, a.State.Provider, a.State.Model); provider != nil &&
- model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug("Selected model from state", "provider", provider.ID, "model", model.ID)
- } else {
- slog.Debug("State model not found", "provider", a.State.Provider, "model", a.State.Model)
- }
- }
-
- // Priority 6: Internal priority fallback (Anthropic preferred, then first available)
- if selectedProvider == nil {
- // Try Anthropic first as internal priority
- if provider := findProviderByID(providers, "anthropic"); provider != nil {
- if model := getDefaultModel(providersResponse, *provider); model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from internal priority (Anthropic)",
- "provider",
- provider.ID,
- "model",
- model.ID,
- )
- }
- }
-
- // If Anthropic not available, use first available provider
- if selectedProvider == nil && len(providers) > 0 {
- provider := &providers[0]
- if model := getDefaultModel(providersResponse, *provider); model != nil {
- selectedProvider = provider
- selectedModel = model
- slog.Debug(
- "Selected model from fallback (first available)",
- "provider",
- provider.ID,
- "model",
- model.ID,
- )
- }
- }
- }
-
- // Final safety check
- if selectedProvider == nil || selectedModel == nil {
- slog.Error("Failed to select any model")
- return nil
- }
-
- var cmds []tea.Cmd
- cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
- Provider: *selectedProvider,
- Model: *selectedModel,
- }))
-
- // Load initial session if provided
- if a.InitialSession != nil && *a.InitialSession != "" {
- cmds = append(cmds, func() tea.Msg {
- // Find the session by ID
- sessions, err := a.ListSessions(context.Background())
- if err != nil {
- slog.Error("Failed to list sessions for initial session", "error", err)
- return toast.NewErrorToast("Failed to load initial session")()
- }
-
- for _, session := range sessions {
- if session.ID == *a.InitialSession {
- return SessionSelectedMsg(&session)
- }
- }
-
- slog.Warn("Initial session not found", "sessionID", *a.InitialSession)
- return toast.NewErrorToast("Session not found: " + *a.InitialSession)()
- })
- }
-
- if a.InitialPrompt != nil && *a.InitialPrompt != "" {
- cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
- }
- return tea.Sequence(cmds...)
-}
-
-func getDefaultModel(
- response *opencode.AppProvidersResponse,
- provider opencode.Provider,
-) *opencode.Model {
- if match, ok := response.Default[provider.ID]; ok {
- model := provider.Models[match]
- return &model
- } else {
- for _, model := range provider.Models {
- return &model
- }
- }
- return nil
-}
-
-func (a *App) IsBusy() bool {
- if len(a.Messages) == 0 {
- return false
- }
- if a.IsCompacting() {
- return true
- }
- lastMessage := a.Messages[len(a.Messages)-1]
- if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
- return casted.Time.Completed == 0
- }
- return false
-}
-
-func (a *App) IsCompacting() bool {
- if time.Since(time.UnixMilli(int64(a.Session.Time.Compacting))) < time.Second*30 {
- return true
- }
- return false
-}
-
-func (a *App) HasAnimatingWork() bool {
- for _, msg := range a.Messages {
- switch casted := msg.Info.(type) {
- case opencode.AssistantMessage:
- if casted.Time.Completed == 0 {
- return true
- }
- }
- for _, p := range msg.Parts {
- if tp, ok := p.(opencode.ToolPart); ok {
- if tp.State.Status == opencode.ToolPartStateStatusPending {
- return true
- }
- }
- }
- }
- return false
-}
-
-func (a *App) SaveState() tea.Cmd {
- return func() tea.Msg {
- err := SaveState(a.StatePath, a.State)
- if err != nil {
- slog.Error("Failed to save state", "error", err)
- }
- return nil
- }
-}
-
-func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
- cmds := []tea.Cmd{}
-
- session, err := a.CreateSession(ctx)
- if err != nil {
- // status.Error(err.Error())
- return nil
- }
-
- a.Session = session
- cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
-
- go func() {
- _, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
- MessageID: opencode.F(id.Ascending(id.Message)),
- ProviderID: opencode.F(a.Provider.ID),
- ModelID: opencode.F(a.Model.ID),
- })
- if err != nil {
- slog.Error("Failed to initialize project", "error", err)
- // status.Error(err.Error())
- }
- }()
-
- return tea.Batch(cmds...)
-}
-
-func (a *App) CompactSession(ctx context.Context) tea.Cmd {
- if a.compactCancel != nil {
- a.compactCancel()
- }
-
- compactCtx, cancel := context.WithCancel(ctx)
- a.compactCancel = cancel
-
- go func() {
- defer func() {
- a.compactCancel = nil
- }()
-
- _, err := a.Client.Session.Summarize(
- compactCtx,
- a.Session.ID,
- opencode.SessionSummarizeParams{
- ProviderID: opencode.F(a.Provider.ID),
- ModelID: opencode.F(a.Model.ID),
- },
- )
- if err != nil {
- if compactCtx.Err() != context.Canceled {
- slog.Error("Failed to compact session", "error", err)
- }
- }
- }()
- return nil
-}
-
-func (a *App) MarkProjectInitialized(ctx context.Context) error {
- return nil
- /*
- _, err := a.Client.App.Init(ctx)
- if err != nil {
- slog.Error("Failed to mark project as initialized", "error", err)
- return err
- }
- return nil
- */
-}
-
-func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
- session, err := a.Client.Session.New(ctx, opencode.SessionNewParams{})
- if err != nil {
- return nil, err
- }
- return session, nil
-}
-
-func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
- var cmds []tea.Cmd
- if a.Session.ID == "" {
- session, err := a.CreateSession(ctx)
- if err != nil {
- return a, toast.NewErrorToast(err.Error())
- }
- a.Session = session
- cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
- }
-
- messageID := id.Ascending(id.Message)
- message := prompt.ToMessage(messageID, a.Session.ID)
-
- a.Messages = append(a.Messages, message)
-
- cmds = append(cmds, func() tea.Msg {
- _, err := a.Client.Session.Prompt(ctx, a.Session.ID, opencode.SessionPromptParams{
- Model: opencode.F(opencode.SessionPromptParamsModel{
- ProviderID: opencode.F(a.Provider.ID),
- ModelID: opencode.F(a.Model.ID),
- }),
- Agent: opencode.F(a.Agent().Name),
- MessageID: opencode.F(messageID),
- Parts: opencode.F(message.ToSessionChatParams()),
- })
- if err != nil {
- errormsg := fmt.Sprintf("failed to send message: %v", err)
- slog.Error(errormsg)
- return toast.NewErrorToast(errormsg)()
- }
- return nil
- })
-
- // The actual response will come through SSE
- // For now, just return success
- return a, tea.Batch(cmds...)
-}
-
-func (a *App) SendCommand(ctx context.Context, command string, args string) (*App, tea.Cmd) {
- var cmds []tea.Cmd
- if a.Session.ID == "" {
- session, err := a.CreateSession(ctx)
- if err != nil {
- return a, toast.NewErrorToast(err.Error())
- }
- a.Session = session
- cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
- }
-
- cmds = append(cmds, func() tea.Msg {
- params := opencode.SessionCommandParams{
- Command: opencode.F(command),
- Arguments: opencode.F(args),
- Agent: opencode.F(a.Agents[a.AgentIndex].Name),
- }
- if a.Provider != nil && a.Model != nil {
- params.Model = opencode.F(a.Provider.ID + "/" + a.Model.ID)
- }
- _, err := a.Client.Session.Command(
- context.Background(),
- a.Session.ID,
- params,
- )
- if err != nil {
- slog.Error("Failed to execute command", "error", err)
- return toast.NewErrorToast(fmt.Sprintf("Failed to execute command: %v", err))()
- }
- return nil
- })
-
- // The actual response will come through SSE
- // For now, just return success
- return a, tea.Batch(cmds...)
-}
-
-func (a *App) SendShell(ctx context.Context, command string) (*App, tea.Cmd) {
- var cmds []tea.Cmd
- if a.Session.ID == "" {
- session, err := a.CreateSession(ctx)
- if err != nil {
- return a, toast.NewErrorToast(err.Error())
- }
- a.Session = session
- cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
- }
-
- cmds = append(cmds, func() tea.Msg {
- _, err := a.Client.Session.Shell(
- context.Background(),
- a.Session.ID,
- opencode.SessionShellParams{
- Agent: opencode.F(a.Agent().Name),
- Command: opencode.F(command),
- },
- )
- if err != nil {
- slog.Error("Failed to submit shell command", "error", err)
- return toast.NewErrorToast(fmt.Sprintf("Failed to submit shell command: %v", err))()
- }
- return nil
- })
-
- // The actual response will come through SSE
- // For now, just return success
- return a, tea.Batch(cmds...)
-}
-
-func (a *App) Cancel(ctx context.Context, sessionID string) error {
- // Cancel any running compact operation
- if a.compactCancel != nil {
- a.compactCancel()
- a.compactCancel = nil
- }
-
- _, err := a.Client.Session.Abort(ctx, sessionID, opencode.SessionAbortParams{})
- if err != nil {
- slog.Error("Failed to cancel session", "error", err)
- return err
- }
- return nil
-}
-
-func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
- response, err := a.Client.Session.List(ctx, opencode.SessionListParams{})
- if err != nil {
- return nil, err
- }
- if response == nil {
- return []opencode.Session{}, nil
- }
- sessions := *response
- return sessions, nil
-}
-
-func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
- _, err := a.Client.Session.Delete(ctx, sessionID, opencode.SessionDeleteParams{})
- if err != nil {
- slog.Error("Failed to delete session", "error", err)
- return err
- }
- return nil
-}
-
-func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) error {
- _, err := a.Client.Session.Update(ctx, sessionID, opencode.SessionUpdateParams{
- Title: opencode.F(title),
- })
- if err != nil {
- slog.Error("Failed to update session", "error", err)
- return err
- }
- return nil
-}
-
-func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
- response, err := a.Client.Session.Messages(ctx, sessionId, opencode.SessionMessagesParams{})
- if err != nil {
- return nil, err
- }
- if response == nil {
- return []Message{}, nil
- }
- messages := []Message{}
- for _, message := range *response {
- msg := Message{
- Info: message.Info.AsUnion(),
- Parts: []opencode.PartUnion{},
- }
- for _, part := range message.Parts {
- msg.Parts = append(msg.Parts, part.AsUnion())
- }
- messages = append(messages, msg)
- }
- return messages, nil
-}
-
-func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
- response, err := a.Client.App.Providers(ctx, opencode.AppProvidersParams{})
- if err != nil {
- return nil, err
- }
- if response == nil {
- return []opencode.Provider{}, nil
- }
-
- providers := *response
- return providers.Providers, nil
-}
-
-// func (a *App) loadCustomKeybinds() {
-//
-// }
diff --git a/packages/tui/internal/app/app_test.go b/packages/tui/internal/app/app_test.go
deleted file mode 100644
index e716d4376..000000000
--- a/packages/tui/internal/app/app_test.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package app
-
-import (
- "testing"
-
- "github.com/sst/opencode-sdk-go"
-)
-
-// TestFindModelByFullID tests the findModelByFullID function
-func TestFindModelByFullID(t *testing.T) {
- // Create test providers with models
- providers := []opencode.Provider{
- {
- ID: "anthropic",
- Models: map[string]opencode.Model{
- "claude-3-opus-20240229": {ID: "claude-3-opus-20240229"},
- "claude-3-sonnet-20240229": {ID: "claude-3-sonnet-20240229"},
- },
- },
- {
- ID: "openai",
- Models: map[string]opencode.Model{
- "gpt-4": {ID: "gpt-4"},
- "gpt-3.5-turbo": {ID: "gpt-3.5-turbo"},
- },
- },
- }
-
- tests := []struct {
- name string
- fullModelID string
- expectedFound bool
- expectedProviderID string
- expectedModelID string
- }{
- {
- name: "valid full model ID",
- fullModelID: "anthropic/claude-3-opus-20240229",
- expectedFound: true,
- expectedProviderID: "anthropic",
- expectedModelID: "claude-3-opus-20240229",
- },
- {
- name: "valid full model ID with slash in model name",
- fullModelID: "openai/gpt-3.5-turbo",
- expectedFound: true,
- expectedProviderID: "openai",
- expectedModelID: "gpt-3.5-turbo",
- },
- {
- name: "invalid format - missing slash",
- fullModelID: "anthropic",
- expectedFound: false,
- },
- {
- name: "invalid format - empty string",
- fullModelID: "",
- expectedFound: false,
- },
- {
- name: "provider not found",
- fullModelID: "nonexistent/model",
- expectedFound: false,
- },
- {
- name: "model not found",
- fullModelID: "anthropic/nonexistent-model",
- expectedFound: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- provider, model := findModelByFullID(providers, tt.fullModelID)
-
- if tt.expectedFound {
- if provider == nil || model == nil {
- t.Errorf("Expected to find provider/model, but got nil")
- return
- }
-
- if provider.ID != tt.expectedProviderID {
- t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
- }
-
- if model.ID != tt.expectedModelID {
- t.Errorf("Expected model ID %s, got %s", tt.expectedModelID, model.ID)
- }
- } else {
- if provider != nil || model != nil {
- t.Errorf("Expected not to find provider/model, but got provider: %v, model: %v", provider, model)
- }
- }
- })
- }
-}
-
-// TestFindModelByProviderAndModelID tests the findModelByProviderAndModelID function
-func TestFindModelByProviderAndModelID(t *testing.T) {
- // Create test providers with models
- providers := []opencode.Provider{
- {
- ID: "anthropic",
- Models: map[string]opencode.Model{
- "claude-3-opus-20240229": {ID: "claude-3-opus-20240229"},
- "claude-3-sonnet-20240229": {ID: "claude-3-sonnet-20240229"},
- },
- },
- {
- ID: "openai",
- Models: map[string]opencode.Model{
- "gpt-4": {ID: "gpt-4"},
- "gpt-3.5-turbo": {ID: "gpt-3.5-turbo"},
- },
- },
- }
-
- tests := []struct {
- name string
- providerID string
- modelID string
- expectedFound bool
- expectedProviderID string
- expectedModelID string
- }{
- {
- name: "valid provider and model",
- providerID: "anthropic",
- modelID: "claude-3-opus-20240229",
- expectedFound: true,
- expectedProviderID: "anthropic",
- expectedModelID: "claude-3-opus-20240229",
- },
- {
- name: "provider not found",
- providerID: "nonexistent",
- modelID: "claude-3-opus-20240229",
- expectedFound: false,
- },
- {
- name: "model not found",
- providerID: "anthropic",
- modelID: "nonexistent-model",
- expectedFound: false,
- },
- {
- name: "both provider and model not found",
- providerID: "nonexistent",
- modelID: "nonexistent-model",
- expectedFound: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- provider, model := findModelByProviderAndModelID(providers, tt.providerID, tt.modelID)
-
- if tt.expectedFound {
- if provider == nil || model == nil {
- t.Errorf("Expected to find provider/model, but got nil")
- return
- }
-
- if provider.ID != tt.expectedProviderID {
- t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
- }
-
- if model.ID != tt.expectedModelID {
- t.Errorf("Expected model ID %s, got %s", tt.expectedModelID, model.ID)
- }
- } else {
- if provider != nil || model != nil {
- t.Errorf("Expected not to find provider/model, but got provider: %v, model: %v", provider, model)
- }
- }
- })
- }
-}
-
-// TestFindProviderByID tests the findProviderByID function
-func TestFindProviderByID(t *testing.T) {
- // Create test providers
- providers := []opencode.Provider{
- {ID: "anthropic"},
- {ID: "openai"},
- {ID: "google"},
- }
-
- tests := []struct {
- name string
- providerID string
- expectedFound bool
- expectedProviderID string
- }{
- {
- name: "provider found",
- providerID: "anthropic",
- expectedFound: true,
- expectedProviderID: "anthropic",
- },
- {
- name: "provider not found",
- providerID: "nonexistent",
- expectedFound: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- provider := findProviderByID(providers, tt.providerID)
-
- if tt.expectedFound {
- if provider == nil {
- t.Errorf("Expected to find provider, but got nil")
- return
- }
-
- if provider.ID != tt.expectedProviderID {
- t.Errorf("Expected provider ID %s, got %s", tt.expectedProviderID, provider.ID)
- }
- } else {
- if provider != nil {
- t.Errorf("Expected not to find provider, but got %v", provider)
- }
- }
- })
- }
-}
-
-// TestModelSelectionPriority tests the priority order for model selection
-func TestModelSelectionPriority(t *testing.T) {
- providers := []opencode.Provider{
- {
- ID: "anthropic",
- Models: map[string]opencode.Model{
- "claude-opus": {ID: "claude-opus"},
- },
- },
- {
- ID: "openai",
- Models: map[string]opencode.Model{
- "gpt-4": {ID: "gpt-4"},
- },
- },
- }
-
- tests := []struct {
- name string
- agentProviderID string
- agentModelID string
- configModel string
- expectedProviderID string
- expectedModelID string
- description string
- }{
- {
- name: "agent model takes priority over config",
- agentProviderID: "openai",
- agentModelID: "gpt-4",
- configModel: "anthropic/claude-opus",
- expectedProviderID: "openai",
- expectedModelID: "gpt-4",
- description: "When agent specifies a model, it should be used even if config has a different model",
- },
- {
- name: "config model used when agent has no model",
- agentProviderID: "",
- agentModelID: "",
- configModel: "anthropic/claude-opus",
- expectedProviderID: "anthropic",
- expectedModelID: "claude-opus",
- description: "When agent has no model specified, config model should be used as fallback",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var selectedProvider *opencode.Provider
- var selectedModel *opencode.Model
-
- // Simulate priority 2: Agent model check
- if tt.agentModelID != "" {
- selectedProvider, selectedModel = findModelByProviderAndModelID(providers, tt.agentProviderID, tt.agentModelID)
- }
-
- // Simulate priority 3: Config model fallback
- if selectedProvider == nil && tt.configModel != "" {
- selectedProvider, selectedModel = findModelByFullID(providers, tt.configModel)
- }
-
- if selectedProvider == nil || selectedModel == nil {
- t.Fatalf("Expected to find model, but got nil - %s", tt.description)
- }
-
- if selectedProvider.ID != tt.expectedProviderID {
- t.Errorf("Expected provider %s, got %s - %s", tt.expectedProviderID, selectedProvider.ID, tt.description)
- }
-
- if selectedModel.ID != tt.expectedModelID {
- t.Errorf("Expected model %s, got %s - %s", tt.expectedModelID, selectedModel.ID, tt.description)
- }
- })
- }
-}
diff --git a/packages/tui/internal/app/prompt.go b/packages/tui/internal/app/prompt.go
deleted file mode 100644
index bd5086a45..000000000
--- a/packages/tui/internal/app/prompt.go
+++ /dev/null
@@ -1,283 +0,0 @@
-package app
-
-import (
- "errors"
- "time"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/attachment"
- "github.com/sst/opencode/internal/id"
-)
-
-type Prompt struct {
- Text string `toml:"text"`
- Attachments []*attachment.Attachment `toml:"attachments"`
-}
-
-func (p Prompt) ToMessage(
- messageID string,
- sessionID string,
-) Message {
- message := opencode.UserMessage{
- ID: messageID,
- SessionID: sessionID,
- Role: opencode.UserMessageRoleUser,
- Time: opencode.UserMessageTime{
- Created: float64(time.Now().UnixMilli()),
- },
- }
-
- text := p.Text
- textAttachments := []*attachment.Attachment{}
- for _, attachment := range p.Attachments {
- if attachment.Type == "text" {
- textAttachments = append(textAttachments, attachment)
- }
- }
- for i := 0; i < len(textAttachments)-1; i++ {
- for j := i + 1; j < len(textAttachments); j++ {
- if textAttachments[i].StartIndex < textAttachments[j].StartIndex {
- textAttachments[i], textAttachments[j] = textAttachments[j], textAttachments[i]
- }
- }
- }
- for _, att := range textAttachments {
- if source, ok := att.GetTextSource(); ok {
- if att.StartIndex > att.EndIndex || att.EndIndex > len(text) {
- continue
- }
- text = text[:att.StartIndex] + source.Value + text[att.EndIndex:]
- }
- }
-
- parts := []opencode.PartUnion{opencode.TextPart{
- ID: id.Ascending(id.Part),
- MessageID: messageID,
- SessionID: sessionID,
- Type: opencode.TextPartTypeText,
- Text: text,
- }}
- for _, attachment := range p.Attachments {
- if attachment.Type == "agent" {
- source, _ := attachment.GetAgentSource()
- parts = append(parts, opencode.AgentPart{
- ID: id.Ascending(id.Part),
- MessageID: messageID,
- SessionID: sessionID,
- Name: source.Name,
- Source: opencode.AgentPartSource{
- Value: attachment.Display,
- Start: int64(attachment.StartIndex),
- End: int64(attachment.EndIndex),
- },
- })
- continue
- }
-
- text := opencode.FilePartSourceText{
- Start: int64(attachment.StartIndex),
- End: int64(attachment.EndIndex),
- Value: attachment.Display,
- }
- source := &opencode.FilePartSource{}
- switch attachment.Type {
- case "text":
- continue
- case "file":
- if fileSource, ok := attachment.GetFileSource(); ok {
- source = &opencode.FilePartSource{
- Text: text,
- Path: fileSource.Path,
- Type: opencode.FilePartSourceTypeFile,
- }
- }
- case "symbol":
- if symbolSource, ok := attachment.GetSymbolSource(); ok {
- source = &opencode.FilePartSource{
- Text: text,
- Path: symbolSource.Path,
- Type: opencode.FilePartSourceTypeSymbol,
- Kind: int64(symbolSource.Kind),
- Name: symbolSource.Name,
- Range: opencode.SymbolSourceRange{
- Start: opencode.SymbolSourceRangeStart{
- Line: float64(symbolSource.Range.Start.Line),
- Character: float64(symbolSource.Range.Start.Char),
- },
- End: opencode.SymbolSourceRangeEnd{
- Line: float64(symbolSource.Range.End.Line),
- Character: float64(symbolSource.Range.End.Char),
- },
- },
- }
- }
- }
- parts = append(parts, opencode.FilePart{
- ID: id.Ascending(id.Part),
- MessageID: messageID,
- SessionID: sessionID,
- Type: opencode.FilePartTypeFile,
- Filename: attachment.Filename,
- Mime: attachment.MediaType,
- URL: attachment.URL,
- Source: *source,
- })
- }
- return Message{
- Info: message,
- Parts: parts,
- }
-}
-
-func (m Message) ToPrompt() (*Prompt, error) {
- switch m.Info.(type) {
- case opencode.UserMessage:
- text := ""
- attachments := []*attachment.Attachment{}
- for _, part := range m.Parts {
- switch p := part.(type) {
- case opencode.TextPart:
- if p.Synthetic {
- continue
- }
- text += p.Text + " "
- case opencode.AgentPart:
- attachments = append(attachments, &attachment.Attachment{
- ID: p.ID,
- Type: "agent",
- Display: p.Source.Value,
- StartIndex: int(p.Source.Start),
- EndIndex: int(p.Source.End),
- Source: &attachment.AgentSource{
- Name: p.Name,
- },
- })
- case opencode.FilePart:
- switch p.Source.Type {
- case "file":
- attachments = append(attachments, &attachment.Attachment{
- ID: p.ID,
- Type: "file",
- Display: p.Source.Text.Value,
- URL: p.URL,
- Filename: p.Filename,
- MediaType: p.Mime,
- StartIndex: int(p.Source.Text.Start),
- EndIndex: int(p.Source.Text.End),
- Source: &attachment.FileSource{
- Path: p.Source.Path,
- Mime: p.Mime,
- },
- })
- case "symbol":
- r := p.Source.Range.(opencode.SymbolSourceRange)
- attachments = append(attachments, &attachment.Attachment{
- ID: p.ID,
- Type: "symbol",
- Display: p.Source.Text.Value,
- URL: p.URL,
- Filename: p.Filename,
- MediaType: p.Mime,
- StartIndex: int(p.Source.Text.Start),
- EndIndex: int(p.Source.Text.End),
- Source: &attachment.SymbolSource{
- Path: p.Source.Path,
- Name: p.Source.Name,
- Kind: int(p.Source.Kind),
- Range: attachment.SymbolRange{
- Start: attachment.Position{
- Line: int(r.Start.Line),
- Char: int(r.Start.Character),
- },
- End: attachment.Position{
- Line: int(r.End.Line),
- Char: int(r.End.Character),
- },
- },
- },
- })
- }
- }
- }
- return &Prompt{
- Text: text,
- Attachments: attachments,
- }, nil
- }
- return nil, errors.New("unknown message type")
-}
-
-func (m Message) ToSessionChatParams() []opencode.SessionPromptParamsPartUnion {
- parts := []opencode.SessionPromptParamsPartUnion{}
- for _, part := range m.Parts {
- switch p := part.(type) {
- case opencode.TextPart:
- parts = append(parts, opencode.TextPartInputParam{
- ID: opencode.F(p.ID),
- Type: opencode.F(opencode.TextPartInputTypeText),
- Text: opencode.F(p.Text),
- Synthetic: opencode.F(p.Synthetic),
- Time: opencode.F(opencode.TextPartInputTimeParam{
- Start: opencode.F(p.Time.Start),
- End: opencode.F(p.Time.End),
- }),
- })
- case opencode.FilePart:
- var source opencode.FilePartSourceUnionParam
- switch p.Source.Type {
- case "file":
- source = opencode.FileSourceParam{
- Type: opencode.F(opencode.FileSourceTypeFile),
- Path: opencode.F(p.Source.Path),
- Text: opencode.F(opencode.FilePartSourceTextParam{
- Start: opencode.F(int64(p.Source.Text.Start)),
- End: opencode.F(int64(p.Source.Text.End)),
- Value: opencode.F(p.Source.Text.Value),
- }),
- }
- case "symbol":
- source = opencode.SymbolSourceParam{
- Type: opencode.F(opencode.SymbolSourceTypeSymbol),
- Path: opencode.F(p.Source.Path),
- Name: opencode.F(p.Source.Name),
- Kind: opencode.F(p.Source.Kind),
- Range: opencode.F(opencode.SymbolSourceRangeParam{
- Start: opencode.F(opencode.SymbolSourceRangeStartParam{
- Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Line)),
- Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Character)),
- }),
- End: opencode.F(opencode.SymbolSourceRangeEndParam{
- Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Line)),
- Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Character)),
- }),
- }),
- Text: opencode.F(opencode.FilePartSourceTextParam{
- Value: opencode.F(p.Source.Text.Value),
- Start: opencode.F(p.Source.Text.Start),
- End: opencode.F(p.Source.Text.End),
- }),
- }
- }
- parts = append(parts, opencode.FilePartInputParam{
- ID: opencode.F(p.ID),
- Type: opencode.F(opencode.FilePartInputTypeFile),
- Mime: opencode.F(p.Mime),
- URL: opencode.F(p.URL),
- Filename: opencode.F(p.Filename),
- Source: opencode.F(source),
- })
- case opencode.AgentPart:
- parts = append(parts, opencode.AgentPartInputParam{
- ID: opencode.F(p.ID),
- Type: opencode.F(opencode.AgentPartInputTypeAgent),
- Name: opencode.F(p.Name),
- Source: opencode.F(opencode.AgentPartInputSourceParam{
- Value: opencode.F(p.Source.Value),
- Start: opencode.F(p.Source.Start),
- End: opencode.F(p.Source.End),
- }),
- })
- }
- }
- return parts
-}
diff --git a/packages/tui/internal/app/state.go b/packages/tui/internal/app/state.go
deleted file mode 100644
index cc65eea5e..000000000
--- a/packages/tui/internal/app/state.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package app
-
-import (
- "bufio"
- "fmt"
- "log/slog"
- "os"
- "time"
-
- "github.com/BurntSushi/toml"
-)
-
-type ModelUsage struct {
- ProviderID string `toml:"provider_id"`
- ModelID string `toml:"model_id"`
- LastUsed time.Time `toml:"last_used"`
-}
-
-type AgentUsage struct {
- AgentName string `toml:"agent_name"`
- LastUsed time.Time `toml:"last_used"`
-}
-
-type AgentModel struct {
- ProviderID string `toml:"provider_id"`
- ModelID string `toml:"model_id"`
-}
-
-type State struct {
- Theme string `toml:"theme"`
- AgentModel map[string]AgentModel `toml:"agent_model"`
- Provider string `toml:"provider"`
- Model string `toml:"model"`
- Agent string `toml:"agent"`
- RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
- RecentlyUsedAgents []AgentUsage `toml:"recently_used_agents"`
- MessageHistory []Prompt `toml:"message_history"`
- ShowToolDetails *bool `toml:"show_tool_details"`
- ShowThinkingBlocks *bool `toml:"show_thinking_blocks"`
-}
-
-func NewState() *State {
- return &State{
- Theme: "opencode",
- Agent: "build",
- AgentModel: make(map[string]AgentModel),
- RecentlyUsedModels: make([]ModelUsage, 0),
- RecentlyUsedAgents: make([]AgentUsage, 0),
- MessageHistory: make([]Prompt, 0),
- }
-}
-
-// UpdateModelUsage updates the recently used models list with the specified model
-func (s *State) UpdateModelUsage(providerID, modelID string) {
- now := time.Now()
-
- // Check if this model is already in the list
- for i, usage := range s.RecentlyUsedModels {
- if usage.ProviderID == providerID && usage.ModelID == modelID {
- s.RecentlyUsedModels[i].LastUsed = now
- usage := s.RecentlyUsedModels[i]
- copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
- s.RecentlyUsedModels[0] = usage
- return
- }
- }
-
- newUsage := ModelUsage{
- ProviderID: providerID,
- ModelID: modelID,
- LastUsed: now,
- }
-
- // Prepend to slice and limit to last 50 entries
- s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
- if len(s.RecentlyUsedModels) > 50 {
- s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
- }
-}
-
-func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
- for i, usage := range s.RecentlyUsedModels {
- if usage.ProviderID == providerID && usage.ModelID == modelID {
- s.RecentlyUsedModels = append(s.RecentlyUsedModels[:i], s.RecentlyUsedModels[i+1:]...)
- return
- }
- }
-}
-
-// UpdateAgentUsage updates the recently used agents list with the specified agent
-func (s *State) UpdateAgentUsage(agentName string) {
- now := time.Now()
-
- // Check if this agent is already in the list
- for i, usage := range s.RecentlyUsedAgents {
- if usage.AgentName == agentName {
- s.RecentlyUsedAgents[i].LastUsed = now
- usage := s.RecentlyUsedAgents[i]
- copy(s.RecentlyUsedAgents[1:i+1], s.RecentlyUsedAgents[0:i])
- s.RecentlyUsedAgents[0] = usage
- return
- }
- }
-
- newUsage := AgentUsage{
- AgentName: agentName,
- LastUsed: now,
- }
-
- // Prepend to slice and limit to last 20 entries
- s.RecentlyUsedAgents = append([]AgentUsage{newUsage}, s.RecentlyUsedAgents...)
- if len(s.RecentlyUsedAgents) > 20 {
- s.RecentlyUsedAgents = s.RecentlyUsedAgents[:20]
- }
-}
-
-func (s *State) RemoveAgentFromRecentlyUsed(agentName string) {
- for i, usage := range s.RecentlyUsedAgents {
- if usage.AgentName == agentName {
- s.RecentlyUsedAgents = append(s.RecentlyUsedAgents[:i], s.RecentlyUsedAgents[i+1:]...)
- return
- }
- }
-}
-
-func (s *State) AddPromptToHistory(prompt Prompt) {
- s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
- if len(s.MessageHistory) > 50 {
- s.MessageHistory = s.MessageHistory[:50]
- }
-}
-
-// SaveState writes the provided Config struct to the specified TOML file.
-// It will create the file if it doesn't exist, or overwrite it if it does.
-func SaveState(filePath string, state *State) error {
- file, err := os.Create(filePath)
- if err != nil {
- return fmt.Errorf("failed to create/open config file %s: %w", filePath, err)
- }
- defer file.Close()
-
- writer := bufio.NewWriter(file)
- encoder := toml.NewEncoder(writer)
- if err := encoder.Encode(state); err != nil {
- return fmt.Errorf("failed to encode state to TOML file %s: %w", filePath, err)
- }
- if err := writer.Flush(); err != nil {
- return fmt.Errorf("failed to flush writer for state file %s: %w", filePath, err)
- }
-
- slog.Debug("State saved to file", "file", filePath)
- return nil
-}
-
-// LoadState loads the state from the specified TOML file.
-// It returns a pointer to the State struct and an error if any issues occur.
-func LoadState(filePath string) (*State, error) {
- var state State
- if _, err := toml.DecodeFile(filePath, &state); err != nil {
- if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) {
- return nil, fmt.Errorf("state file not found at %s: %w", filePath, statErr)
- }
- return nil, fmt.Errorf("failed to decode TOML from file %s: %w", filePath, err)
- }
-
- // Restore attachment sources types that were deserialized as map[string]any
- for _, prompt := range state.MessageHistory {
- for _, att := range prompt.Attachments {
- att.RestoreSourceType()
- }
- }
-
- return &state, nil
-}
diff --git a/packages/tui/internal/attachment/attachment.go b/packages/tui/internal/attachment/attachment.go
deleted file mode 100644
index 3ecd86198..000000000
--- a/packages/tui/internal/attachment/attachment.go
+++ /dev/null
@@ -1,178 +0,0 @@
-package attachment
-
-import (
- "github.com/google/uuid"
-)
-
-type TextSource struct {
- Value string `toml:"value"`
-}
-
-type FileSource struct {
- Path string `toml:"path"`
- Mime string `toml:"mime"`
- Data []byte `toml:"data,omitempty"` // Optional for image data
-}
-
-type SymbolSource struct {
- Path string `toml:"path"`
- Name string `toml:"name"`
- Kind int `toml:"kind"`
- Range SymbolRange `toml:"range"`
-}
-
-type SymbolRange struct {
- Start Position `toml:"start"`
- End Position `toml:"end"`
-}
-
-type AgentSource struct {
- Name string `toml:"name"`
-}
-
-type Position struct {
- Line int `toml:"line"`
- Char int `toml:"char"`
-}
-
-type Attachment struct {
- ID string `toml:"id"`
- Type string `toml:"type"`
- Display string `toml:"display"`
- URL string `toml:"url"`
- Filename string `toml:"filename"`
- MediaType string `toml:"media_type"`
- StartIndex int `toml:"start_index"`
- EndIndex int `toml:"end_index"`
- Source any `toml:"source,omitempty"`
-}
-
-// NewAttachment creates a new attachment with a unique ID
-func NewAttachment() *Attachment {
- return &Attachment{
- ID: uuid.NewString(),
- }
-}
-
-func (a *Attachment) GetTextSource() (*TextSource, bool) {
- if a.Type != "text" {
- return nil, false
- }
- ts, ok := a.Source.(*TextSource)
- return ts, ok
-}
-
-// GetFileSource returns the source as FileSource if the attachment is a file type
-func (a *Attachment) GetFileSource() (*FileSource, bool) {
- if a.Type != "file" {
- return nil, false
- }
- fs, ok := a.Source.(*FileSource)
- return fs, ok
-}
-
-// GetSymbolSource returns the source as SymbolSource if the attachment is a symbol type
-func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
- if a.Type != "symbol" {
- return nil, false
- }
- ss, ok := a.Source.(*SymbolSource)
- return ss, ok
-}
-
-// GetAgentSource returns the source as AgentSource if the attachment is an agent type
-func (a *Attachment) GetAgentSource() (*AgentSource, bool) {
- if a.Type != "agent" {
- return nil, false
- }
- as, ok := a.Source.(*AgentSource)
- return as, ok
-}
-
-// FromMap creates a TextSource from a map[string]any
-func (ts *TextSource) FromMap(sourceMap map[string]any) {
- if value, ok := sourceMap["value"].(string); ok {
- ts.Value = value
- }
-}
-
-// FromMap creates a FileSource from a map[string]any
-func (fs *FileSource) FromMap(sourceMap map[string]any) {
- if path, ok := sourceMap["path"].(string); ok {
- fs.Path = path
- }
- if mime, ok := sourceMap["mime"].(string); ok {
- fs.Mime = mime
- }
- if data, ok := sourceMap["data"].([]byte); ok {
- fs.Data = data
- }
-}
-
-// FromMap creates a SymbolSource from a map[string]any
-func (ss *SymbolSource) FromMap(sourceMap map[string]any) {
- if path, ok := sourceMap["path"].(string); ok {
- ss.Path = path
- }
- if name, ok := sourceMap["name"].(string); ok {
- ss.Name = name
- }
- if kind, ok := sourceMap["kind"].(int); ok {
- ss.Kind = kind
- }
- if rangeMap, ok := sourceMap["range"].(map[string]any); ok {
- ss.Range = SymbolRange{}
- if startMap, ok := rangeMap["start"].(map[string]any); ok {
- if line, ok := startMap["line"].(int); ok {
- ss.Range.Start.Line = line
- }
- if char, ok := startMap["char"].(int); ok {
- ss.Range.Start.Char = char
- }
- }
- if endMap, ok := rangeMap["end"].(map[string]any); ok {
- if line, ok := endMap["line"].(int); ok {
- ss.Range.End.Line = line
- }
- if char, ok := endMap["char"].(int); ok {
- ss.Range.End.Char = char
- }
- }
- }
-}
-
-// FromMap creates an AgentSource from a map[string]any
-func (as *AgentSource) FromMap(sourceMap map[string]any) {
- if name, ok := sourceMap["name"].(string); ok {
- as.Name = name
- }
-}
-
-// RestoreSourceType converts a map[string]any source back to the proper type
-func (a *Attachment) RestoreSourceType() {
- if a.Source == nil {
- return
- }
-
- // Check if Source is a map[string]any
- if sourceMap, ok := a.Source.(map[string]any); ok {
- switch a.Type {
- case "text":
- ts := &TextSource{}
- ts.FromMap(sourceMap)
- a.Source = ts
- case "file":
- fs := &FileSource{}
- fs.FromMap(sourceMap)
- a.Source = fs
- case "symbol":
- ss := &SymbolSource{}
- ss.FromMap(sourceMap)
- a.Source = ss
- case "agent":
- as := &AgentSource{}
- as.FromMap(sourceMap)
- a.Source = as
- }
- }
-}
diff --git a/packages/tui/internal/clipboard/clipboard.go b/packages/tui/internal/clipboard/clipboard.go
deleted file mode 100644
index 70e05bd29..000000000
--- a/packages/tui/internal/clipboard/clipboard.go
+++ /dev/null
@@ -1,155 +0,0 @@
-// Copyright 2021 The golang.design Initiative Authors.
-// All rights reserved. Use of this source code is governed
-// by a MIT license that can be found in the LICENSE file.
-//
-// Written by Changkun Ou <changkun.de>
-
-/*
-Package clipboard provides cross platform clipboard access and supports
-macOS/Linux/Windows/Android/iOS platform. Before interacting with the
-clipboard, one must call Init to assert if it is possible to use this
-package:
-
- err := clipboard.Init()
- if err != nil {
- panic(err)
- }
-
-The most common operations are `Read` and `Write`. To use them:
-
- // write/read text format data of the clipboard, and
- // the byte buffer regarding the text are UTF8 encoded.
- clipboard.Write(clipboard.FmtText, []byte("text data"))
- clipboard.Read(clipboard.FmtText)
-
- // write/read image format data of the clipboard, and
- // the byte buffer regarding the image are PNG encoded.
- clipboard.Write(clipboard.FmtImage, []byte("image data"))
- clipboard.Read(clipboard.FmtImage)
-
-Note that read/write regarding image format assumes that the bytes are
-PNG encoded since it serves the alpha blending purpose that might be
-used in other graphical software.
-
-In addition, `clipboard.Write` returns a channel that can receive an
-empty struct as a signal, which indicates the corresponding write call
-to the clipboard is outdated, meaning the clipboard has been overwritten
-by others and the previously written data is lost. For instance:
-
- changed := clipboard.Write(clipboard.FmtText, []byte("text data"))
-
- select {
- case <-changed:
- println(`"text data" is no longer available from clipboard.`)
- }
-
-You can ignore the returning channel if you don't need this type of
-notification. Furthermore, when you need more than just knowing whether
-clipboard data is changed, use the watcher API:
-
- ch := clipboard.Watch(context.TODO(), clipboard.FmtText)
- for data := range ch {
- // print out clipboard data whenever it is changed
- println(string(data))
- }
-*/
-package clipboard
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "sync"
-)
-
-var (
- // activate only for running tests.
- debug = false
- errUnavailable = errors.New("clipboard unavailable")
- errUnsupported = errors.New("unsupported format")
- errNoCgo = errors.New("clipboard: cannot use when CGO_ENABLED=0")
-)
-
-// Format represents the format of clipboard data.
-type Format int
-
-// All sorts of supported clipboard data
-const (
- // FmtText indicates plain text clipboard format
- FmtText Format = iota
- // FmtImage indicates image/png clipboard format
- FmtImage
-)
-
-var (
- // Due to the limitation on operating systems (such as darwin),
- // concurrent read can even cause panic, use a global lock to
- // guarantee one read at a time.
- lock = sync.Mutex{}
- initOnce sync.Once
- initError error
-)
-
-// Init initializes the clipboard package. It returns an error
-// if the clipboard is not available to use. This may happen if the
-// target system lacks required dependency, such as libx11-dev in X11
-// environment. For example,
-//
-// err := clipboard.Init()
-// if err != nil {
-// panic(err)
-// }
-//
-// If Init returns an error, any subsequent Read/Write/Watch call
-// may result in an unrecoverable panic.
-func Init() error {
- initOnce.Do(func() {
- initError = initialize()
- })
- return initError
-}
-
-// Read returns a chunk of bytes of the clipboard data if it presents
-// in the desired format t presents. Otherwise, it returns nil.
-func Read(t Format) []byte {
- lock.Lock()
- defer lock.Unlock()
-
- buf, err := read(t)
- if err != nil {
- if debug {
- fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err)
- }
- return nil
- }
- return buf
-}
-
-// Write writes a given buffer to the clipboard in a specified format.
-// Write returned a receive-only channel can receive an empty struct
-// as a signal, which indicates the clipboard has been overwritten from
-// this write.
-// If format t indicates an image, then the given buf assumes
-// the image data is PNG encoded.
-func Write(t Format, buf []byte) <-chan struct{} {
- lock.Lock()
- defer lock.Unlock()
-
- changed, err := write(t, buf)
- if err != nil {
- if debug {
- fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err)
- }
- return nil
- }
- return changed
-}
-
-// Watch returns a receive-only channel that received the clipboard data
-// whenever any change of clipboard data in the desired format happens.
-//
-// The returned channel will be closed if the given context is canceled.
-func Watch(ctx context.Context, t Format) <-chan []byte {
- return watch(ctx, t)
-}
diff --git a/packages/tui/internal/clipboard/clipboard_darwin.go b/packages/tui/internal/clipboard/clipboard_darwin.go
deleted file mode 100644
index ead6811f1..000000000
--- a/packages/tui/internal/clipboard/clipboard_darwin.go
+++ /dev/null
@@ -1,266 +0,0 @@
-// Copyright 2021 The golang.design Initiative Authors.
-// All rights reserved. Use of this source code is governed
-// by a MIT license that can be found in the LICENSE file.
-//
-// Written by Changkun Ou <changkun.de>
-
-//go:build darwin
-
-package clipboard
-
-import (
- "bytes"
- "context"
- "fmt"
- "os"
- "os/exec"
- "strconv"
- "strings"
- "sync"
- "time"
-)
-
-var (
- lastChangeCount int64
- changeCountMu sync.Mutex
-)
-
-func initialize() error { return nil }
-
-func read(t Format) (buf []byte, err error) {
- switch t {
- case FmtText:
- return readText()
- case FmtImage:
- return readImage()
- default:
- return nil, errUnsupported
- }
-}
-
-func readText() ([]byte, error) {
- // Check if clipboard contains string data
- checkScript := `
- try
- set clipboardTypes to (clipboard info)
- repeat with aType in clipboardTypes
- if (first item of aType) is string then
- return "hastext"
- end if
- end repeat
- return "notext"
- on error
- return "error"
- end try
- `
-
- cmd := exec.Command("osascript", "-e", checkScript)
- checkOut, err := cmd.Output()
- if err != nil {
- return nil, errUnavailable
- }
-
- checkOut = bytes.TrimSpace(checkOut)
- if !bytes.Equal(checkOut, []byte("hastext")) {
- return nil, errUnavailable
- }
-
- // Now get the actual text
- cmd = exec.Command("osascript", "-e", "get the clipboard")
- out, err := cmd.Output()
- if err != nil {
- return nil, errUnavailable
- }
- // Remove trailing newline that osascript adds
- out = bytes.TrimSuffix(out, []byte("\n"))
-
- // If clipboard was set to empty string, return nil
- if len(out) == 0 {
- return nil, nil
- }
- return out, nil
-}
-func readImage() ([]byte, error) {
- // AppleScript to read image data from clipboard as base64
- script := `
- try
- set theData to the clipboard as «class PNGf»
- return theData
- on error
- return ""
- end try
- `
-
- cmd := exec.Command("osascript", "-e", script)
- out, err := cmd.Output()
- if err != nil {
- return nil, errUnavailable
- }
-
- // Check if we got any data
- out = bytes.TrimSpace(out)
- if len(out) == 0 {
- return nil, errUnavailable
- }
-
- // The output is in hex format (e.g., «data PNGf89504E...»)
- // We need to extract and convert it
- outStr := string(out)
- if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
- return nil, errUnavailable
- }
-
- // Extract hex data
- hexData := strings.TrimPrefix(outStr, "«data PNGf")
- hexData = strings.TrimSuffix(hexData, "»")
-
- // Convert hex to bytes
- buf := make([]byte, len(hexData)/2)
- for i := 0; i < len(hexData); i += 2 {
- b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
- if err != nil {
- return nil, errUnavailable
- }
- buf[i/2] = byte(b)
- }
-
- return buf, nil
-}
-
-// write writes the given data to clipboard and
-// returns true if success or false if failed.
-func write(t Format, buf []byte) (<-chan struct{}, error) {
- var err error
- switch t {
- case FmtText:
- err = writeText(buf)
- case FmtImage:
- err = writeImage(buf)
- default:
- return nil, errUnsupported
- }
-
- if err != nil {
- return nil, err
- }
-
- // Update change count
- changeCountMu.Lock()
- lastChangeCount++
- currentCount := lastChangeCount
- changeCountMu.Unlock()
-
- // use unbuffered channel to prevent goroutine leak
- changed := make(chan struct{}, 1)
- go func() {
- for {
- time.Sleep(time.Second)
- changeCountMu.Lock()
- if lastChangeCount != currentCount {
- changeCountMu.Unlock()
- changed <- struct{}{}
- close(changed)
- return
- }
- changeCountMu.Unlock()
- }
- }()
- return changed, nil
-}
-
-func writeText(buf []byte) error {
- if len(buf) == 0 {
- // Clear clipboard
- script := `set the clipboard to ""`
- cmd := exec.Command("osascript", "-e", script)
- if err := cmd.Run(); err != nil {
- return errUnavailable
- }
- return nil
- }
-
- // Escape the text for AppleScript
- text := string(buf)
- text = strings.ReplaceAll(text, "\\", "\\\\")
- text = strings.ReplaceAll(text, "\"", "\\\"")
-
- script := fmt.Sprintf(`set the clipboard to "%s"`, text)
- cmd := exec.Command("osascript", "-e", script)
- if err := cmd.Run(); err != nil {
- return errUnavailable
- }
- return nil
-}
-func writeImage(buf []byte) error {
- if len(buf) == 0 {
- // Clear clipboard
- script := `set the clipboard to ""`
- cmd := exec.Command("osascript", "-e", script)
- if err := cmd.Run(); err != nil {
- return errUnavailable
- }
- return nil
- }
-
- // Create a temporary file to store the PNG data
- tmpFile, err := os.CreateTemp("", "clipboard*.png")
- if err != nil {
- return errUnavailable
- }
- defer os.Remove(tmpFile.Name())
-
- if _, err := tmpFile.Write(buf); err != nil {
- tmpFile.Close()
- return errUnavailable
- }
- tmpFile.Close()
-
- // Use osascript to set clipboard to the image file
- script := fmt.Sprintf(`
- set theFile to POSIX file "%s"
- set theImage to read theFile as «class PNGf»
- set the clipboard to theImage
- `, tmpFile.Name())
-
- cmd := exec.Command("osascript", "-e", script)
- if err := cmd.Run(); err != nil {
- return errUnavailable
- }
- return nil
-}
-func watch(ctx context.Context, t Format) <-chan []byte {
- recv := make(chan []byte, 1)
- ti := time.NewTicker(time.Second)
-
- // Get initial clipboard content
- var lastContent []byte
- if b := Read(t); b != nil {
- lastContent = make([]byte, len(b))
- copy(lastContent, b)
- }
-
- go func() {
- defer close(recv)
- defer ti.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ti.C:
- b := Read(t)
- if b == nil {
- continue
- }
-
- // Check if content changed
- if !bytes.Equal(lastContent, b) {
- recv <- b
- lastContent = make([]byte, len(b))
- copy(lastContent, b)
- }
- }
- }
- }()
- return recv
-}
diff --git a/packages/tui/internal/clipboard/clipboard_linux.go b/packages/tui/internal/clipboard/clipboard_linux.go
deleted file mode 100644
index 101906395..000000000
--- a/packages/tui/internal/clipboard/clipboard_linux.go
+++ /dev/null
@@ -1,311 +0,0 @@
-// Copyright 2021 The golang.design Initiative Authors.
-// All rights reserved. Use of this source code is governed
-// by a MIT license that can be found in the LICENSE file.
-//
-// Written by Changkun Ou <changkun.de>
-
-//go:build linux
-
-package clipboard
-
-import (
- "bytes"
- "context"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "strings"
- "sync"
- "time"
-)
-
-var (
- // Clipboard tools in order of preference
- clipboardTools = []struct {
- name string
- readCmd []string
- writeCmd []string
- readImg []string
- writeImg []string
- available bool
- }{
- {
- name: "xclip",
- readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
- writeCmd: []string{"xclip", "-selection", "clipboard"},
- readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
- writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
- },
- {
- name: "xsel",
- readCmd: []string{"xsel", "--clipboard", "--output"},
- writeCmd: []string{"xsel", "--clipboard", "--input"},
- readImg: []string{"xsel", "--clipboard", "--output"},
- writeImg: []string{"xsel", "--clipboard", "--input"},
- },
- {
- name: "wl-copy",
- readCmd: []string{"wl-paste", "-n"},
- writeCmd: []string{"wl-copy"},
- readImg: []string{"wl-paste", "-t", "image/png", "-n"},
- writeImg: []string{"wl-copy", "-t", "image/png"},
- },
- }
-
- selectedTool int = -1
- toolMutex sync.Mutex
- lastChangeTime time.Time
- changeTimeMu sync.Mutex
-)
-
-func initialize() error {
- toolMutex.Lock()
- defer toolMutex.Unlock()
-
- if selectedTool >= 0 {
- return nil // Already initialized
- }
-
- order := []string{"xclip", "xsel", "wl-copy"}
- if os.Getenv("WAYLAND_DISPLAY") != "" {
- order = []string{"wl-copy", "xclip", "xsel"}
- }
-
- for _, name := range order {
- for i, tool := range clipboardTools {
- if tool.name == name {
- cmd := exec.Command("which", tool.name)
- if err := cmd.Run(); err == nil {
- clipboardTools[i].available = true
- if selectedTool < 0 {
- selectedTool = i
- slog.Debug("Clipboard tool found", "tool", tool.name)
- }
- }
- break
- }
- }
- }
-
- if selectedTool < 0 {
- slog.Warn(
- "No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
- )
- return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
-
-For X11 systems:
- apt install -y xclip
- # or
- apt install -y xsel
-
-For Wayland systems:
- apt install -y wl-clipboard
-
-If running in a headless environment, you may also need:
- apt install -y xvfb
- # and run:
- Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
- export DISPLAY=:99.0`, errUnavailable)
- }
-
- return nil
-}
-
-func read(t Format) (buf []byte, err error) {
- // Ensure clipboard is initialized before attempting to read
- if err := initialize(); err != nil {
- slog.Debug("Clipboard read failed: not initialized", "error", err)
- return nil, err
- }
-
- toolMutex.Lock()
- tool := clipboardTools[selectedTool]
- toolMutex.Unlock()
-
- switch t {
- case FmtText:
- return readText(tool)
- case FmtImage:
- return readImage(tool)
- default:
- return nil, errUnsupported
- }
-}
-
-func readText(tool struct {
- name string
- readCmd []string
- writeCmd []string
- readImg []string
- writeImg []string
- available bool
-}) ([]byte, error) {
- // First check if clipboard contains text
- cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
- out, err := cmd.Output()
- if err != nil {
- // Check if it's because clipboard contains non-text data
- if tool.name == "xclip" {
- // xclip returns error when clipboard doesn't contain requested type
- checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
- targets, _ := checkCmd.Output()
- if bytes.Contains(targets, []byte("image/png")) &&
- !bytes.Contains(targets, []byte("UTF8_STRING")) {
- return nil, errUnavailable
- }
- }
- return nil, errUnavailable
- }
-
- return out, nil
-}
-
-func readImage(tool struct {
- name string
- readCmd []string
- writeCmd []string
- readImg []string
- writeImg []string
- available bool
-}) ([]byte, error) {
- if tool.name == "xsel" {
- // xsel doesn't support image types well, return error
- return nil, errUnavailable
- }
-
- cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
- out, err := cmd.Output()
- if err != nil {
- return nil, errUnavailable
- }
-
- // Verify it's PNG data
- if len(out) < 8 ||
- !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
- return nil, errUnavailable
- }
-
- return out, nil
-}
-
-func write(t Format, buf []byte) (<-chan struct{}, error) {
- // Ensure clipboard is initialized before attempting to write
- if err := initialize(); err != nil {
- return nil, err
- }
-
- toolMutex.Lock()
- tool := clipboardTools[selectedTool]
- toolMutex.Unlock()
-
- var cmd *exec.Cmd
- switch t {
- case FmtText:
- if len(buf) == 0 {
- // Write empty string
- cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
- cmd.Stdin = bytes.NewReader([]byte{})
- } else {
- cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
- cmd.Stdin = bytes.NewReader(buf)
- }
- case FmtImage:
- if tool.name == "xsel" {
- // xsel doesn't support image types well
- return nil, errUnavailable
- }
- if len(buf) == 0 {
- // Clear clipboard
- cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
- cmd.Stdin = bytes.NewReader([]byte{})
- } else {
- cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
- cmd.Stdin = bytes.NewReader(buf)
- }
- default:
- return nil, errUnsupported
- }
-
- if err := cmd.Run(); err != nil {
- return nil, errUnavailable
- }
-
- // Update change time
- changeTimeMu.Lock()
- lastChangeTime = time.Now()
- currentTime := lastChangeTime
- changeTimeMu.Unlock()
-
- // Create change notification channel
- changed := make(chan struct{}, 1)
- go func() {
- for {
- time.Sleep(time.Second)
- changeTimeMu.Lock()
- if !lastChangeTime.Equal(currentTime) {
- changeTimeMu.Unlock()
- changed <- struct{}{}
- close(changed)
- return
- }
- changeTimeMu.Unlock()
- }
- }()
-
- return changed, nil
-}
-
-func watch(ctx context.Context, t Format) <-chan []byte {
- recv := make(chan []byte, 1)
-
- // Ensure clipboard is initialized before starting watch
- if err := initialize(); err != nil {
- close(recv)
- return recv
- }
-
- ti := time.NewTicker(time.Second)
-
- // Get initial clipboard content
- var lastContent []byte
- if b := Read(t); b != nil {
- lastContent = make([]byte, len(b))
- copy(lastContent, b)
- }
-
- go func() {
- defer close(recv)
- defer ti.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return
- case <-ti.C:
- b := Read(t)
- if b == nil {
- continue
- }
-
- // Check if content changed
- if !bytes.Equal(lastContent, b) {
- recv <- b
- lastContent = make([]byte, len(b))
- copy(lastContent, b)
- }
- }
- }
- }()
- return recv
-}
-
-// Helper function to check clipboard content type for xclip
-func getClipboardTargets() []string {
- cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
- out, err := cmd.Output()
- if err != nil {
- return nil
- }
- return strings.Split(string(out), "\n")
-}
diff --git a/packages/tui/internal/clipboard/clipboard_nocgo.go b/packages/tui/internal/clipboard/clipboard_nocgo.go
deleted file mode 100644
index 7b3e05f6c..000000000
--- a/packages/tui/internal/clipboard/clipboard_nocgo.go
+++ /dev/null
@@ -1,25 +0,0 @@
-//go:build !windows && !darwin && !linux && !cgo
-
-package clipboard
-
-import "context"
-
-func initialize() error {
- return errNoCgo
-}
-
-func read(t Format) (buf []byte, err error) {
- panic("clipboard: cannot use when CGO_ENABLED=0")
-}
-
-func readc(t string) ([]byte, error) {
- panic("clipboard: cannot use when CGO_ENABLED=0")
-}
-
-func write(t Format, buf []byte) (<-chan struct{}, error) {
- panic("clipboard: cannot use when CGO_ENABLED=0")
-}
-
-func watch(ctx context.Context, t Format) <-chan []byte {
- panic("clipboard: cannot use when CGO_ENABLED=0")
-}
diff --git a/packages/tui/internal/clipboard/clipboard_windows.go b/packages/tui/internal/clipboard/clipboard_windows.go
deleted file mode 100644
index 09fc14169..000000000
--- a/packages/tui/internal/clipboard/clipboard_windows.go
+++ /dev/null
@@ -1,551 +0,0 @@
-// Copyright 2021 The golang.design Initiative Authors.
-// All rights reserved. Use of this source code is governed
-// by a MIT license that can be found in the LICENSE file.
-//
-// Written by Changkun Ou <changkun.de>
-
-//go:build windows
-
-package clipboard
-
-// Interacting with Clipboard on Windows:
-// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard
-
-import (
- "bytes"
- "context"
- "encoding/binary"
- "errors"
- "fmt"
- "image"
- "image/color"
- "image/png"
- "reflect"
- "runtime"
- "syscall"
- "time"
- "unicode/utf16"
- "unsafe"
-
- "golang.org/x/image/bmp"
-)
-
-func initialize() error { return nil }
-
-// readText reads the clipboard and returns the text data if presents.
-// The caller is responsible for opening/closing the clipboard before
-// calling this function.
-func readText() (buf []byte, err error) {
- hMem, _, err := getClipboardData.Call(cFmtUnicodeText)
- if hMem == 0 {
- return nil, err
- }
- p, _, err := gLock.Call(hMem)
- if p == 0 {
- return nil, err
- }
- defer gUnlock.Call(hMem)
-
- // Find NUL terminator
- n := 0
- for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
- ptr = unsafe.Pointer(uintptr(ptr) +
- unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p)))))
- }
-
- var s []uint16
- h := (*reflect.SliceHeader)(unsafe.Pointer(&s))
- h.Data = p
- h.Len = n
- h.Cap = n
- return []byte(string(utf16.Decode(s))), nil
-}
-
-// writeText writes given data to the clipboard. It is the caller's
-// responsibility for opening/closing the clipboard before calling
-// this function.
-func writeText(buf []byte) error {
- r, _, err := emptyClipboard.Call()
- if r == 0 {
- return fmt.Errorf("failed to clear clipboard: %w", err)
- }
-
- // empty text, we are done here.
- if len(buf) == 0 {
- return nil
- }
-
- s, err := syscall.UTF16FromString(string(buf))
- if err != nil {
- return fmt.Errorf("failed to convert given string: %w", err)
- }
-
- hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
- if hMem == 0 {
- return fmt.Errorf("failed to alloc global memory: %w", err)
- }
-
- p, _, err := gLock.Call(hMem)
- if p == 0 {
- return fmt.Errorf("failed to lock global memory: %w", err)
- }
- defer gUnlock.Call(hMem)
-
- // no return value
- memMove.Call(p, uintptr(unsafe.Pointer(&s[0])),
- uintptr(len(s)*int(unsafe.Sizeof(s[0]))))
-
- v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem)
- if v == 0 {
- gFree.Call(hMem)
- return fmt.Errorf("failed to set text to clipboard: %w", err)
- }
-
- return nil
-}
-
-// readImage reads the clipboard and returns PNG encoded image data
-// if presents. The caller is responsible for opening/closing the
-// clipboard before calling this function.
-func readImage() ([]byte, error) {
- hMem, _, err := getClipboardData.Call(cFmtDIBV5)
- if hMem == 0 {
- // second chance to try FmtDIB
- return readImageDib()
- }
- p, _, err := gLock.Call(hMem)
- if p == 0 {
- return nil, err
- }
- defer gUnlock.Call(hMem)
-
- // inspect header information
- info := (*bitmapV5Header)(unsafe.Pointer(p))
-
- // maybe deal with other formats?
- if info.BitCount != 32 {
- return nil, errUnsupported
- }
-
- var data []byte
- sh := (*reflect.SliceHeader)(unsafe.Pointer(&data))
- sh.Data = uintptr(p)
- sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
- sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height))
- img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height)))
- offset := int(info.Size)
- stride := int(info.Width)
- for y := 0; y < int(info.Height); y++ {
- for x := 0; x < int(info.Width); x++ {
- idx := offset + 4*(y*stride+x)
- xhat := (x + int(info.Width)) % int(info.Width)
- yhat := int(info.Height) - 1 - y
- r := data[idx+2]
- g := data[idx+1]
- b := data[idx+0]
- a := data[idx+3]
- img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a})
- }
- }
- // always use PNG encoding.
- var buf bytes.Buffer
- png.Encode(&buf, img)
- return buf.Bytes(), nil
-}
-
-func readImageDib() ([]byte, error) {
- const (
- fileHeaderLen = 14
- infoHeaderLen = 40
- cFmtDIB = 8
- )
-
- hClipDat, _, err := getClipboardData.Call(cFmtDIB)
- if err != nil {
- return nil, errors.New("not dib format data: " + err.Error())
- }
- pMemBlk, _, err := gLock.Call(hClipDat)
- if pMemBlk == 0 {
- return nil, errors.New("failed to call global lock: " + err.Error())
- }
- defer gUnlock.Call(hClipDat)
-
- bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk))
- dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen
-
- if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 {
- iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3)
- dataSize += iSizeImage
- }
- buf := new(bytes.Buffer)
- binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8))
- binary.Write(buf, binary.LittleEndian, uint32(dataSize))
- binary.Write(buf, binary.LittleEndian, uint32(0))
- const sizeof_colorbar = 0
- binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar))
- j := 0
- for i := fileHeaderLen; i < int(dataSize); i++ {
- binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j))))
- j++
- }
- return bmpToPng(buf)
-}
-
-func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) {
- var f bytes.Buffer
- original_image, err := bmp.Decode(bmpBuf)
- if err != nil {
- return nil, err
- }
- err = png.Encode(&f, original_image)
- if err != nil {
- return nil, err
- }
- return f.Bytes(), nil
-}
-
-func writeImage(buf []byte) error {
- r, _, err := emptyClipboard.Call()
- if r == 0 {
- return fmt.Errorf("failed to clear clipboard: %w", err)
- }
-
- // empty text, we are done here.
- if len(buf) == 0 {
- return nil
- }
-
- img, err := png.Decode(bytes.NewReader(buf))
- if err != nil {
- return fmt.Errorf("input bytes is not PNG encoded: %w", err)
- }
-
- offset := unsafe.Sizeof(bitmapV5Header{})
- width := img.Bounds().Dx()
- height := img.Bounds().Dy()
- imageSize := 4 * width * height
-
- data := make([]byte, int(offset)+imageSize)
- for y := 0; y < height; y++ {
- for x := 0; x < width; x++ {
- idx := int(offset) + 4*(y*width+x)
- r, g, b, a := img.At(x, height-1-y).RGBA()
- data[idx+2] = uint8(r)
- data[idx+1] = uint8(g)
- data[idx+0] = uint8(b)
- data[idx+3] = uint8(a)
- }
- }
-
- info := bitmapV5Header{}
- info.Size = uint32(offset)
- info.Width = int32(width)
- info.Height = int32(height)
- info.Planes = 1
- info.Compression = 0 // BI_RGB
- info.SizeImage = uint32(4 * info.Width * info.Height)
- info.RedMask = 0xff0000 // default mask
- info.GreenMask = 0xff00
- info.BlueMask = 0xff
- info.AlphaMask = 0xff000000
- info.BitCount = 32 // we only deal with 32 bpp at the moment.
- // Use calibrated RGB values as Go's image/png assumes linear color space.
- // Other options:
- // - LCS_CALIBRATED_RGB = 0x00000000
- // - LCS_sRGB = 0x73524742
- // - LCS_WINDOWS_COLOR_SPACE = 0x57696E20
- // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f
- info.CSType = 0x73524742
- // Use GL_IMAGES for GamutMappingIntent
- // Other options:
- // - LCS_GM_ABS_COLORIMETRIC = 0x00000008
- // - LCS_GM_BUSINESS = 0x00000001
- // - LCS_GM_GRAPHICS = 0x00000002
- // - LCS_GM_IMAGES = 0x00000004
- // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38
- info.Intent = 4 // LCS_GM_IMAGES
-
- infob := make([]byte, int(unsafe.Sizeof(info)))
- for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) {
- infob[i] = v
- }
- copy(data[:], infob[:])
-
- hMem, _, err := gAlloc.Call(gmemMoveable,
- uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
- if hMem == 0 {
- return fmt.Errorf("failed to alloc global memory: %w", err)
- }
-
- p, _, err := gLock.Call(hMem)
- if p == 0 {
- return fmt.Errorf("failed to lock global memory: %w", err)
- }
- defer gUnlock.Call(hMem)
-
- memMove.Call(p, uintptr(unsafe.Pointer(&data[0])),
- uintptr(len(data)*int(unsafe.Sizeof(data[0]))))
-
- v, _, err := setClipboardData.Call(cFmtDIBV5, hMem)
- if v == 0 {
- gFree.Call(hMem)
- return fmt.Errorf("failed to set text to clipboard: %w", err)
- }
-
- return nil
-}
-
-func read(t Format) (buf []byte, err error) {
- // On Windows, OpenClipboard and CloseClipboard must be executed on
- // the same thread. Thus, lock the OS thread for further execution.
- runtime.LockOSThread()
- defer runtime.UnlockOSThread()
-
- var format uintptr
- switch t {
- case FmtImage:
- format = cFmtDIBV5
- case FmtText:
- fallthrough
- default:
- format = cFmtUnicodeText
- }
-
- // check if clipboard is available for the requested format
- r, _, err := isClipboardFormatAvailable.Call(format)
- if r == 0 {
- return nil, errUnavailable
- }
-
- // try again until open clipboard succeeds
- for {
- r, _, _ = openClipboard.Call()
- if r == 0 {
- continue
- }
- break
- }
- defer closeClipboard.Call()
-
- switch format {
- case cFmtDIBV5:
- return readImage()
- case cFmtUnicodeText:
- fallthrough
- default:
- return readText()
- }
-}
-
-// write writes the given data to clipboard and
-// returns true if success or false if failed.
-func write(t Format, buf []byte) (<-chan struct{}, error) {
- errch := make(chan error)
- changed := make(chan struct{}, 1)
- go func() {
- // make sure GetClipboardSequenceNumber happens with
- // OpenClipboard on the same thread.
- runtime.LockOSThread()
- defer runtime.UnlockOSThread()
- for {
- r, _, _ := openClipboard.Call(0)
- if r == 0 {
- continue
- }
- break
- }
-
- // var param uintptr
- switch t {
- case FmtImage:
- err := writeImage(buf)
- if err != nil {
- errch <- err
- closeClipboard.Call()
- return
- }
- case FmtText:
- fallthrough
- default:
- // param = cFmtUnicodeText
- err := writeText(buf)
- if err != nil {
- errch <- err
- closeClipboard.Call()
- return
- }
- }
- // Close the clipboard otherwise other applications cannot
- // paste the data.
- closeClipboard.Call()
-
- cnt, _, _ := getClipboardSequenceNumber.Call()
- errch <- nil
- for {
- time.Sleep(time.Second)
- cur, _, _ := getClipboardSequenceNumber.Call()
- if cur != cnt {
- changed <- struct{}{}
- close(changed)
- return
- }
- }
- }()
- err := <-errch
- if err != nil {
- return nil, err
- }
- return changed, nil
-}
-
-func watch(ctx context.Context, t Format) <-chan []byte {
- recv := make(chan []byte, 1)
- ready := make(chan struct{})
- go func() {
- // not sure if we are too slow or the user too fast :)
- ti := time.NewTicker(time.Second)
- cnt, _, _ := getClipboardSequenceNumber.Call()
- ready <- struct{}{}
- for {
- select {
- case <-ctx.Done():
- close(recv)
- return
- case <-ti.C:
- cur, _, _ := getClipboardSequenceNumber.Call()
- if cnt != cur {
- b := Read(t)
- if b == nil {
- continue
- }
- recv <- b
- cnt = cur
- }
- }
- }
- }()
- <-ready
- return recv
-}
-
-const (
- cFmtBitmap = 2 // Win+PrintScreen
- cFmtUnicodeText = 13
- cFmtDIBV5 = 17
- // Screenshot taken from special shortcut is in different format (why??), see:
- // https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/
- cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats
- gmemMoveable = 0x0002
-)
-
-// BITMAPV5Header structure, see:
-// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
-type bitmapV5Header struct {
- Size uint32
- Width int32
- Height int32
- Planes uint16
- BitCount uint16
- Compression uint32
- SizeImage uint32
- XPelsPerMeter int32
- YPelsPerMeter int32
- ClrUsed uint32
- ClrImportant uint32
- RedMask uint32
- GreenMask uint32
- BlueMask uint32
- AlphaMask uint32
- CSType uint32
- Endpoints struct {
- CiexyzRed, CiexyzGreen, CiexyzBlue struct {
- CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30
- }
- }
- GammaRed uint32
- GammaGreen uint32
- GammaBlue uint32
- Intent uint32
- ProfileData uint32
- ProfileSize uint32
- Reserved uint32
-}
-
-type bitmapHeader struct {
- Size uint32
- Width uint32
- Height uint32
- PLanes uint16
- BitCount uint16
- Compression uint32
- SizeImage uint32
- XPelsPerMeter uint32
- YPelsPerMeter uint32
- ClrUsed uint32
- ClrImportant uint32
-}
-
-// Calling a Windows DLL, see:
-// https://github.com/golang/go/wiki/WindowsDLLs
-var (
- user32 = syscall.MustLoadDLL("user32")
- // Opens the clipboard for examination and prevents other
- // applications from modifying the clipboard content.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard
- openClipboard = user32.MustFindProc("OpenClipboard")
- // Closes the clipboard.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard
- closeClipboard = user32.MustFindProc("CloseClipboard")
- // Empties the clipboard and frees handles to data in the clipboard.
- // The function then assigns ownership of the clipboard to the
- // window that currently has the clipboard open.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard
- emptyClipboard = user32.MustFindProc("EmptyClipboard")
- // Retrieves data from the clipboard in a specified format.
- // The clipboard must have been opened previously.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata
- getClipboardData = user32.MustFindProc("GetClipboardData")
- // Places data on the clipboard in a specified clipboard format.
- // The window must be the current clipboard owner, and the
- // application must have called the OpenClipboard function. (When
- // responding to the WM_RENDERFORMAT message, the clipboard owner
- // must not call OpenClipboard before calling SetClipboardData.)
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata
- setClipboardData = user32.MustFindProc("SetClipboardData")
- // Determines whether the clipboard contains data in the specified format.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
- isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable")
- // Clipboard data formats are stored in an ordered list. To perform
- // an enumeration of clipboard data formats, you make a series of
- // calls to the EnumClipboardFormats function. For each call, the
- // format parameter specifies an available clipboard format, and the
- // function returns the next available clipboard format.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable
- enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats")
- // Retrieves the clipboard sequence number for the current window station.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber
- getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber")
- // Registers a new clipboard format. This format can then be used as
- // a valid clipboard format.
- // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata
- registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA")
-
- kernel32 = syscall.NewLazyDLL("kernel32")
-
- // Locks a global memory object and returns a pointer to the first
- // byte of the object's memory block.
- // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock
- gLock = kernel32.NewProc("GlobalLock")
- // Decrements the lock count associated with a memory object that was
- // allocated with GMEM_MOVEABLE. This function has no effect on memory
- // objects allocated with GMEM_FIXED.
- // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock
- gUnlock = kernel32.NewProc("GlobalUnlock")
- // Allocates the specified number of bytes from the heap.
- // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc
- gAlloc = kernel32.NewProc("GlobalAlloc")
- // Frees the specified global memory object and invalidates its handle.
- // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree
- gFree = kernel32.NewProc("GlobalFree")
- memMove = kernel32.NewProc("RtlMoveMemory")
-)
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
deleted file mode 100644
index d552b78ec..000000000
--- a/packages/tui/internal/commands/command.go
+++ /dev/null
@@ -1,423 +0,0 @@
-package commands
-
-import (
- "encoding/json"
- "log/slog"
- "slices"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode-sdk-go"
-)
-
-type ExecuteCommandMsg Command
-type ExecuteCommandsMsg []Command
-type CommandExecutedMsg Command
-
-type Keybinding struct {
- RequiresLeader bool
- Key string
-}
-
-func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
- key := k.Key
- key = strings.TrimSpace(key)
- return key == msg.String() && (k.RequiresLeader == leader)
-}
-
-type CommandName string
-type Command struct {
- Name CommandName
- Description string
- Keybindings []Keybinding
- Trigger []string
- Custom bool
-}
-
-func (c Command) Keys() []string {
- var keys []string
- for _, k := range c.Keybindings {
- keys = append(keys, k.Key)
- }
- return keys
-}
-
-func (c Command) HasTrigger() bool {
- return len(c.Trigger) > 0
-}
-
-func (c Command) PrimaryTrigger() string {
- if len(c.Trigger) > 0 {
- return c.Trigger[0]
- }
- return ""
-}
-
-func (c Command) MatchesTrigger(trigger string) bool {
- return slices.Contains(c.Trigger, trigger)
-}
-
-type CommandRegistry map[CommandName]Command
-
-func (r CommandRegistry) Sorted() []Command {
- var commands []Command
- for _, command := range r {
- commands = append(commands, command)
- }
- slices.SortFunc(commands, func(a, b Command) int {
- // Priority order: session_new, session_share, model_list, agent_list, app_help first, app_exit last
- priorityOrder := map[CommandName]int{
- SessionNewCommand: 0,
- AppHelpCommand: 1,
- SessionShareCommand: 2,
- ModelListCommand: 3,
- AgentListCommand: 4,
- }
-
- aPriority, aHasPriority := priorityOrder[a.Name]
- bPriority, bHasPriority := priorityOrder[b.Name]
-
- if aHasPriority && bHasPriority {
- return aPriority - bPriority
- }
- if aHasPriority {
- return -1
- }
- if bHasPriority {
- return 1
- }
- if a.Name == AppExitCommand {
- return 1
- }
- if b.Name == AppExitCommand {
- return -1
- }
- if a.Custom && !b.Custom {
- return 1
- }
- if !a.Custom && b.Custom {
- return -1
- }
-
- return strings.Compare(string(a.Name), string(b.Name))
- })
- return commands
-}
-
-func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
- var matched []Command
- for _, command := range r.Sorted() {
- if command.Matches(msg, leader) {
- matched = append(matched, command)
- }
- }
- return matched
-}
-
-const (
- SessionChildCycleCommand CommandName = "session_child_cycle"
- SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
- ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
- AgentCycleCommand CommandName = "agent_cycle"
- AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
- AppHelpCommand CommandName = "app_help"
- SwitchAgentCommand CommandName = "switch_agent"
- SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
- EditorOpenCommand CommandName = "editor_open"
- SessionNewCommand CommandName = "session_new"
- SessionListCommand CommandName = "session_list"
- SessionTimelineCommand CommandName = "session_timeline"
- SessionShareCommand CommandName = "session_share"
- SessionUnshareCommand CommandName = "session_unshare"
- SessionInterruptCommand CommandName = "session_interrupt"
- SessionCompactCommand CommandName = "session_compact"
- SessionExportCommand CommandName = "session_export"
- ToolDetailsCommand CommandName = "tool_details"
- ThinkingBlocksCommand CommandName = "thinking_blocks"
- ModelListCommand CommandName = "model_list"
- AgentListCommand CommandName = "agent_list"
- ModelCycleRecentCommand CommandName = "model_cycle_recent"
- ThemeListCommand CommandName = "theme_list"
- FileListCommand CommandName = "file_list"
- FileCloseCommand CommandName = "file_close"
- FileSearchCommand CommandName = "file_search"
- FileDiffToggleCommand CommandName = "file_diff_toggle"
- ProjectInitCommand CommandName = "project_init"
- InputClearCommand CommandName = "input_clear"
- InputPasteCommand CommandName = "input_paste"
- InputSubmitCommand CommandName = "input_submit"
- InputNewlineCommand CommandName = "input_newline"
- MessagesPageUpCommand CommandName = "messages_page_up"
- MessagesPageDownCommand CommandName = "messages_page_down"
- MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
- MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
- MessagesPreviousCommand CommandName = "messages_previous"
- MessagesNextCommand CommandName = "messages_next"
- MessagesFirstCommand CommandName = "messages_first"
- MessagesLastCommand CommandName = "messages_last"
- MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
- MessagesCopyCommand CommandName = "messages_copy"
- MessagesUndoCommand CommandName = "messages_undo"
- MessagesRedoCommand CommandName = "messages_redo"
- AppExitCommand CommandName = "app_exit"
-)
-
-func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
- for _, binding := range k.Keybindings {
- if binding.Matches(msg, leader) {
- return true
- }
- }
- return false
-}
-
-func parseBindings(bindings ...string) []Keybinding {
- var parsedBindings []Keybinding
- for _, binding := range bindings {
- if binding == "none" {
- continue
- }
- for p := range strings.SplitSeq(binding, ",") {
- requireLeader := strings.HasPrefix(p, "<leader>")
- keybinding := strings.ReplaceAll(p, "<leader>", "")
- keybinding = strings.TrimSpace(keybinding)
- parsedBindings = append(parsedBindings, Keybinding{
- RequiresLeader: requireLeader,
- Key: keybinding,
- })
- }
- }
- return parsedBindings
-}
-
-func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) CommandRegistry {
- defaults := []Command{
- {
- Name: AppHelpCommand,
- Description: "show help",
- Keybindings: parseBindings("<leader>h"),
- Trigger: []string{"help"},
- },
- {
- Name: EditorOpenCommand,
- Description: "open editor",
- Keybindings: parseBindings("<leader>e"),
- Trigger: []string{"editor"},
- },
- {
- Name: SessionExportCommand,
- Description: "export conversation",
- Keybindings: parseBindings("<leader>x"),
- Trigger: []string{"export"},
- },
- {
- Name: SessionNewCommand,
- Description: "new session",
- Keybindings: parseBindings("<leader>n"),
- Trigger: []string{"new", "clear"},
- },
- {
- Name: SessionListCommand,
- Description: "list sessions",
- Keybindings: parseBindings("<leader>l"),
- Trigger: []string{"sessions", "resume", "continue"},
- },
- {
- Name: SessionTimelineCommand,
- Description: "show session timeline",
- Keybindings: parseBindings("<leader>g"),
- Trigger: []string{"timeline", "history", "goto"},
- },
- {
- Name: SessionShareCommand,
- Description: "share session",
- Keybindings: parseBindings("<leader>s"),
- Trigger: []string{"share"},
- },
- {
- Name: SessionUnshareCommand,
- Description: "unshare session",
- Trigger: []string{"unshare"},
- },
- {
- Name: SessionInterruptCommand,
- Description: "interrupt session",
- Keybindings: parseBindings("esc"),
- },
- {
- Name: SessionCompactCommand,
- Description: "compact the session",
- Keybindings: parseBindings("<leader>c"),
- Trigger: []string{"compact", "summarize"},
- },
- {
- Name: SessionChildCycleCommand,
- Description: "cycle to next child session",
- Keybindings: parseBindings("ctrl+right"),
- },
- {
- Name: SessionChildCycleReverseCommand,
- Description: "cycle to previous child session",
- Keybindings: parseBindings("ctrl+left"),
- },
- {
- Name: ToolDetailsCommand,
- Description: "toggle tool details",
- Keybindings: parseBindings("<leader>d"),
- Trigger: []string{"details"},
- },
- {
- Name: ThinkingBlocksCommand,
- Description: "toggle thinking blocks",
- Keybindings: parseBindings("<leader>b"),
- Trigger: []string{"thinking"},
- },
- {
- Name: ModelListCommand,
- Description: "list models",
- Keybindings: parseBindings("<leader>m"),
- Trigger: []string{"models"},
- },
- {
- Name: ModelCycleRecentCommand,
- Description: "next recent model",
- Keybindings: parseBindings("f2"),
- },
- {
- Name: ModelCycleRecentReverseCommand,
- Description: "previous recent model",
- Keybindings: parseBindings("shift+f2"),
- },
- {
- Name: AgentListCommand,
- Description: "list agents",
- Keybindings: parseBindings("<leader>a"),
- Trigger: []string{"agents"},
- },
- {
- Name: AgentCycleCommand,
- Description: "next agent",
- Keybindings: parseBindings("tab"),
- },
- {
- Name: AgentCycleReverseCommand,
- Description: "previous agent",
- Keybindings: parseBindings("shift+tab"),
- },
- {
- Name: ThemeListCommand,
- Description: "list themes",
- Keybindings: parseBindings("<leader>t"),
- Trigger: []string{"themes"},
- },
- {
- Name: ProjectInitCommand,
- Description: "create/update AGENTS.md",
- Keybindings: parseBindings("<leader>i"),
- Trigger: []string{"init"},
- },
- {
- Name: InputClearCommand,
- Description: "clear input",
- Keybindings: parseBindings("ctrl+c"),
- },
- {
- Name: InputPasteCommand,
- Description: "paste content",
- Keybindings: parseBindings("ctrl+v", "super+v"),
- },
- {
- Name: InputSubmitCommand,
- Description: "submit message",
- Keybindings: parseBindings("enter"),
- },
- {
- Name: InputNewlineCommand,
- Description: "insert newline",
- Keybindings: parseBindings("shift+enter", "ctrl+j"),
- },
- {
- Name: MessagesPageUpCommand,
- Description: "page up",
- Keybindings: parseBindings("pgup"),
- },
- {
- Name: MessagesPageDownCommand,
- Description: "page down",
- Keybindings: parseBindings("pgdown"),
- },
- {
- Name: MessagesHalfPageUpCommand,
- Description: "half page up",
- Keybindings: parseBindings("ctrl+alt+u"),
- },
- {
- Name: MessagesHalfPageDownCommand,
- Description: "half page down",
- Keybindings: parseBindings("ctrl+alt+d"),
- },
-
- {
- Name: MessagesFirstCommand,
- Description: "first message",
- Keybindings: parseBindings("ctrl+g"),
- },
- {
- Name: MessagesLastCommand,
- Description: "last message",
- Keybindings: parseBindings("ctrl+alt+g"),
- },
-
- {
- Name: MessagesCopyCommand,
- Description: "copy message",
- Keybindings: parseBindings("<leader>y"),
- },
- {
- Name: MessagesUndoCommand,
- Description: "undo last message",
- Keybindings: parseBindings("<leader>u"),
- Trigger: []string{"undo"},
- },
- {
- Name: MessagesRedoCommand,
- Description: "redo message",
- Keybindings: parseBindings("<leader>r"),
- Trigger: []string{"redo"},
- },
- {
- Name: AppExitCommand,
- Description: "exit the app",
- Keybindings: parseBindings("ctrl+c", "<leader>q"),
- Trigger: []string{"exit", "quit", "q"},
- },
- }
- registry := make(CommandRegistry)
- keybinds := map[string]string{}
- marshalled, _ := json.Marshal(config.Keybinds)
- json.Unmarshal(marshalled, &keybinds)
- for _, command := range defaults {
- // Remove share/unshare commands if sharing is disabled
- if config.Share == opencode.ConfigShareDisabled &&
- (command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
- slog.Info("Removing share/unshare commands")
- continue
- }
- if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
- command.Keybindings = parseBindings(keybind)
- }
- registry[command.Name] = command
- }
- for _, command := range customCommands {
- registry[CommandName(command.Name)] = Command{
- Name: CommandName(command.Name),
- Description: command.Description,
- Trigger: []string{command.Name},
- Keybindings: []Keybinding{},
- Custom: true,
- }
- }
-
- slog.Info("Loaded commands", "commands", registry)
- return registry
-}
diff --git a/packages/tui/internal/completions/agents.go b/packages/tui/internal/completions/agents.go
deleted file mode 100644
index d25c76d89..000000000
--- a/packages/tui/internal/completions/agents.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package completions
-
-import (
- "context"
- "log/slog"
- "strings"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type agentsContextGroup struct {
- app *app.App
-}
-
-func (cg *agentsContextGroup) GetId() string {
- return "agents"
-}
-
-func (cg *agentsContextGroup) GetEmptyMessage() string {
- return "no matching agents"
-}
-
-func (cg *agentsContextGroup) GetChildEntries(
- query string,
-) ([]CompletionSuggestion, error) {
- items := make([]CompletionSuggestion, 0)
-
- query = strings.TrimSpace(query)
-
- agents, err := cg.app.Client.Agent.List(
- context.Background(),
- opencode.AgentListParams{},
- )
- if err != nil {
- slog.Error("Failed to get agent list", "error", err)
- return items, err
- }
- if agents == nil {
- return items, nil
- }
-
- for _, agent := range *agents {
- if query != "" && !strings.Contains(strings.ToLower(agent.Name), strings.ToLower(query)) {
- continue
- }
- if agent.Mode == opencode.AgentModePrimary {
- continue
- }
-
- displayFunc := func(s styles.Style) string {
- t := theme.CurrentTheme()
- muted := s.Foreground(t.TextMuted()).Render
- return s.Render(agent.Name) + muted(" (agent)")
- }
-
- item := CompletionSuggestion{
- Display: displayFunc,
- Value: agent.Name,
- ProviderID: cg.GetId(),
- RawData: agent,
- }
- items = append(items, item)
- }
-
- return items, nil
-}
-
-func NewAgentsContextGroup(app *app.App) CompletionProvider {
- return &agentsContextGroup{
- app: app,
- }
-}
diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go
deleted file mode 100644
index 72e261f82..000000000
--- a/packages/tui/internal/completions/commands.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package completions
-
-import (
- "sort"
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type CommandCompletionProvider struct {
- app *app.App
-}
-
-func NewCommandCompletionProvider(app *app.App) CompletionProvider {
- return &CommandCompletionProvider{app: app}
-}
-
-func (c *CommandCompletionProvider) GetId() string {
- return "commands"
-}
-
-func (c *CommandCompletionProvider) GetEmptyMessage() string {
- return "no matching commands"
-}
-
-func (c *CommandCompletionProvider) getCommandCompletionItem(
- cmd commands.Command,
- space int,
-) CompletionSuggestion {
- displayFunc := func(s styles.Style) string {
- t := theme.CurrentTheme()
- spacer := strings.Repeat(" ", space)
- display := " /" + cmd.PrimaryTrigger() + s.
- Foreground(t.TextMuted()).
- Render(spacer+cmd.Description)
- return display
- }
-
- value := string(cmd.Name)
- return CompletionSuggestion{
- Display: displayFunc,
- Value: value,
- ProviderID: c.GetId(),
- RawData: cmd,
- }
-}
-
-func (c *CommandCompletionProvider) GetChildEntries(
- query string,
-) ([]CompletionSuggestion, error) {
- commands := c.app.Commands
-
- space := 1
- for _, cmd := range c.app.Commands {
- if cmd.HasTrigger() && lipgloss.Width(cmd.PrimaryTrigger()) > space {
- space = lipgloss.Width(cmd.PrimaryTrigger())
- }
- }
- space += 2
-
- sorted := commands.Sorted()
- if query == "" {
- // If no query, return all commands
- items := []CompletionSuggestion{}
- for _, cmd := range sorted {
- if !cmd.HasTrigger() {
- continue
- }
- space := space - lipgloss.Width(cmd.PrimaryTrigger())
- items = append(items, c.getCommandCompletionItem(cmd, space))
- }
- return items, nil
- }
-
- var commandNames []string
- commandMap := make(map[string]CompletionSuggestion)
-
- for _, cmd := range sorted {
- if !cmd.HasTrigger() {
- continue
- }
- space := space - lipgloss.Width(cmd.PrimaryTrigger())
- for _, trigger := range cmd.Trigger {
- commandNames = append(commandNames, trigger)
- commandMap[trigger] = c.getCommandCompletionItem(cmd, space)
- }
- }
-
- matches := fuzzy.RankFindFold(query, commandNames)
-
- // Custom sort to prioritize exact matches
- sort.Slice(matches, func(i, j int) bool {
- // Check for exact match (case-insensitive)
- iExact := strings.EqualFold(matches[i].Target, query)
- jExact := strings.EqualFold(matches[j].Target, query)
-
- // Exact matches come first
- if iExact && !jExact {
- return true
- }
- if !iExact && jExact {
- return false
- }
-
- // Check for prefix match (case-insensitive)
- iPrefix := strings.HasPrefix(strings.ToLower(matches[i].Target), strings.ToLower(query))
- jPrefix := strings.HasPrefix(strings.ToLower(matches[j].Target), strings.ToLower(query))
-
- // Prefix matches come before fuzzy matches
- if iPrefix && !jPrefix {
- return true
- }
- if !iPrefix && jPrefix {
- return false
- }
-
- // Otherwise, sort by fuzzy match score (lower distance is better)
- if matches[i].Distance != matches[j].Distance {
- return matches[i].Distance < matches[j].Distance
- }
-
- // If distances are equal, sort by original index (stable sort)
- return matches[i].OriginalIndex < matches[j].OriginalIndex
- })
-
- // Convert matches to completion items, deduplicating by command name
- items := []CompletionSuggestion{}
- seen := make(map[string]bool)
- for _, match := range matches {
- if item, ok := commandMap[match.Target]; ok {
- // Use the command's value (name) as the deduplication key
- if !seen[item.Value] {
- seen[item.Value] = true
- items = append(items, item)
- }
- }
- }
- return items, nil
-}
diff --git a/packages/tui/internal/completions/files.go b/packages/tui/internal/completions/files.go
deleted file mode 100644
index d00873656..000000000
--- a/packages/tui/internal/completions/files.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package completions
-
-import (
- "context"
- "log/slog"
- "sort"
- "strconv"
- "strings"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type filesContextGroup struct {
- app *app.App
- gitFiles []CompletionSuggestion
-}
-
-func (cg *filesContextGroup) GetId() string {
- return "files"
-}
-
-func (cg *filesContextGroup) GetEmptyMessage() string {
- return "no matching files"
-}
-
-func (cg *filesContextGroup) getGitFiles() []CompletionSuggestion {
- items := make([]CompletionSuggestion, 0)
-
- status, _ := cg.app.Client.File.Status(context.Background(), opencode.FileStatusParams{})
- if status != nil {
- files := *status
- sort.Slice(files, func(i, j int) bool {
- return files[i].Added+files[i].Removed > files[j].Added+files[j].Removed
- })
-
- for _, file := range files {
- displayFunc := func(s styles.Style) string {
- t := theme.CurrentTheme()
- green := s.Foreground(t.Success()).Render
- red := s.Foreground(t.Error()).Render
- display := file.Path
- if file.Added > 0 {
- display += green(" +" + strconv.Itoa(int(file.Added)))
- }
- if file.Removed > 0 {
- display += red(" -" + strconv.Itoa(int(file.Removed)))
- }
- return display
- }
- item := CompletionSuggestion{
- Display: displayFunc,
- Value: file.Path,
- ProviderID: cg.GetId(),
- RawData: file,
- }
- items = append(items, item)
- }
- }
-
- return items
-}
-
-func (cg *filesContextGroup) GetChildEntries(
- query string,
-) ([]CompletionSuggestion, error) {
- items := make([]CompletionSuggestion, 0)
-
- query = strings.TrimSpace(query)
- if query == "" {
- items = append(items, cg.gitFiles...)
- }
-
- files, err := cg.app.Client.Find.Files(
- context.Background(),
- opencode.FindFilesParams{Query: opencode.F(query)},
- )
- if err != nil {
- slog.Error("Failed to get completion items", "error", err)
- return items, err
- }
- if files == nil {
- return items, nil
- }
-
- for _, file := range *files {
- exists := false
- for _, existing := range cg.gitFiles {
- if existing.Value == file {
- if query != "" {
- items = append(items, existing)
- }
- exists = true
- }
- }
- if !exists {
- displayFunc := func(s styles.Style) string {
- // t := theme.CurrentTheme()
- // return s.Foreground(t.Text()).Render(file)
- return s.Render(file)
- }
-
- item := CompletionSuggestion{
- Display: displayFunc,
- Value: file,
- ProviderID: cg.GetId(),
- RawData: file,
- }
- items = append(items, item)
- }
- }
-
- return items, nil
-}
-
-func NewFileContextGroup(app *app.App) CompletionProvider {
- cg := &filesContextGroup{
- app: app,
- }
- go func() {
- cg.gitFiles = cg.getGitFiles()
- }()
- return cg
-}
diff --git a/packages/tui/internal/completions/provider.go b/packages/tui/internal/completions/provider.go
deleted file mode 100644
index dc11522c3..000000000
--- a/packages/tui/internal/completions/provider.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package completions
-
-// CompletionProvider defines the interface for completion data providers
-type CompletionProvider interface {
- GetId() string
- GetChildEntries(query string) ([]CompletionSuggestion, error)
- GetEmptyMessage() string
-}
diff --git a/packages/tui/internal/completions/suggestion.go b/packages/tui/internal/completions/suggestion.go
deleted file mode 100644
index fac6b6813..000000000
--- a/packages/tui/internal/completions/suggestion.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package completions
-
-import "github.com/sst/opencode/internal/styles"
-
-// CompletionSuggestion represents a data-only completion suggestion
-// with no styling or rendering logic
-type CompletionSuggestion struct {
- // The text to be displayed in the list. May contain minimal inline
- // ANSI styling if intrinsic to the data (e.g., git diff colors).
- Display func(styles.Style) string
-
- // The value to be used when the item is selected (e.g., inserted into the editor).
- Value string
-
- // An optional, longer description to be displayed.
- Description string
-
- // The ID of the provider that generated this suggestion.
- ProviderID string
-
- // The raw, underlying data object (e.g., opencode.Symbol, commands.Command).
- // This allows the selection handler to perform rich actions.
- RawData any
-}
diff --git a/packages/tui/internal/completions/symbols.go b/packages/tui/internal/completions/symbols.go
deleted file mode 100644
index 725e2e69b..000000000
--- a/packages/tui/internal/completions/symbols.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package completions
-
-import (
- "context"
- "fmt"
- "log/slog"
- "strings"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type symbolsContextGroup struct {
- app *app.App
-}
-
-func (cg *symbolsContextGroup) GetId() string {
- return "symbols"
-}
-
-func (cg *symbolsContextGroup) GetEmptyMessage() string {
- return "no matching symbols"
-}
-
-type SymbolKind int
-
-const (
- SymbolKindFile SymbolKind = 1
- SymbolKindModule SymbolKind = 2
- SymbolKindNamespace SymbolKind = 3
- SymbolKindPackage SymbolKind = 4
- SymbolKindClass SymbolKind = 5
- SymbolKindMethod SymbolKind = 6
- SymbolKindProperty SymbolKind = 7
- SymbolKindField SymbolKind = 8
- SymbolKindConstructor SymbolKind = 9
- SymbolKindEnum SymbolKind = 10
- SymbolKindInterface SymbolKind = 11
- SymbolKindFunction SymbolKind = 12
- SymbolKindVariable SymbolKind = 13
- SymbolKindConstant SymbolKind = 14
- SymbolKindString SymbolKind = 15
- SymbolKindNumber SymbolKind = 16
- SymbolKindBoolean SymbolKind = 17
- SymbolKindArray SymbolKind = 18
- SymbolKindObject SymbolKind = 19
- SymbolKindKey SymbolKind = 20
- SymbolKindNull SymbolKind = 21
- SymbolKindEnumMember SymbolKind = 22
- SymbolKindStruct SymbolKind = 23
- SymbolKindEvent SymbolKind = 24
- SymbolKindOperator SymbolKind = 25
- SymbolKindTypeParameter SymbolKind = 26
-)
-
-func (cg *symbolsContextGroup) GetChildEntries(
- query string,
-) ([]CompletionSuggestion, error) {
- items := make([]CompletionSuggestion, 0)
-
- query = strings.TrimSpace(query)
- if query == "" {
- return items, nil
- }
-
- symbols, err := cg.app.Client.Find.Symbols(
- context.Background(),
- opencode.FindSymbolsParams{Query: opencode.F(query)},
- )
- if err != nil {
- slog.Error("Failed to get symbol completion items", "error", err)
- return items, err
- }
- if symbols == nil {
- return items, nil
- }
-
- for _, sym := range *symbols {
- parts := strings.Split(sym.Name, ".")
- lastPart := parts[len(parts)-1]
- start := int(sym.Location.Range.Start.Line)
- end := int(sym.Location.Range.End.Line)
-
- displayFunc := func(s styles.Style) string {
- t := theme.CurrentTheme()
- base := s.Foreground(t.Text()).Render
- muted := s.Foreground(t.TextMuted()).Render
- display := base(lastPart)
-
- uriParts := strings.Split(sym.Location.Uri, "/")
- lastTwoParts := uriParts[len(uriParts)-2:]
- joined := strings.Join(lastTwoParts, "/")
- display += muted(fmt.Sprintf(" %s", joined))
-
- display += muted(fmt.Sprintf(":L%d-%d", start, end))
- return display
- }
-
- value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
-
- item := CompletionSuggestion{
- Display: displayFunc,
- Value: value,
- ProviderID: cg.GetId(),
- RawData: sym,
- }
- items = append(items, item)
- }
-
- return items, nil
-}
-
-func NewSymbolsContextGroup(app *app.App) CompletionProvider {
- return &symbolsContextGroup{
- app: app,
- }
-}
diff --git a/packages/tui/internal/components/chat/cache.go b/packages/tui/internal/components/chat/cache.go
deleted file mode 100644
index 454f1a5a9..000000000
--- a/packages/tui/internal/components/chat/cache.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package chat
-
-import (
- "encoding/hex"
- "fmt"
- "hash/fnv"
- "sync"
-)
-
-// PartCache caches rendered messages to avoid re-rendering
-type PartCache struct {
- mu sync.RWMutex
- cache map[string]string
-}
-
-// NewPartCache creates a new message cache
-func NewPartCache() *PartCache {
- return &PartCache{
- cache: make(map[string]string),
- }
-}
-
-// generateKey creates a unique key for a message based on its content and rendering parameters
-func (c *PartCache) GenerateKey(params ...any) string {
- h := fnv.New64a()
- for _, param := range params {
- h.Write(fmt.Appendf(nil, ":%v", param))
- }
- return hex.EncodeToString(h.Sum(nil))
-}
-
-// Get retrieves a cached rendered message
-func (c *PartCache) Get(key string) (string, bool) {
- c.mu.RLock()
- defer c.mu.RUnlock()
-
- content, exists := c.cache[key]
- return content, exists
-}
-
-// Set stores a rendered message in the cache
-func (c *PartCache) Set(key string, content string) {
- c.mu.Lock()
- defer c.mu.Unlock()
- c.cache[key] = content
-}
-
-// Clear removes all entries from the cache
-func (c *PartCache) Clear() {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- c.cache = make(map[string]string)
-}
-
-// Size returns the number of cached entries
-func (c *PartCache) Size() int {
- c.mu.RLock()
- defer c.mu.RUnlock()
-
- return len(c.cache)
-}
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
deleted file mode 100644
index d3c813840..000000000
--- a/packages/tui/internal/components/chat/editor.go
+++ /dev/null
@@ -1,906 +0,0 @@
-package chat
-
-import (
- "encoding/base64"
- "fmt"
- "log/slog"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "unicode/utf8"
-
- "github.com/charmbracelet/bubbles/v2/spinner"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/google/uuid"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/attachment"
- "github.com/sst/opencode/internal/clipboard"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/components/dialog"
- "github.com/sst/opencode/internal/components/textarea"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type EditorComponent interface {
- tea.Model
- tea.ViewModel
- Content() string
- Cursor() *tea.Cursor
- Lines() int
- Value() string
- Length() int
- Focused() bool
- Focus() (tea.Model, tea.Cmd)
- Blur()
- Submit() (tea.Model, tea.Cmd)
- SubmitBash() (tea.Model, tea.Cmd)
- Clear() (tea.Model, tea.Cmd)
- Paste() (tea.Model, tea.Cmd)
- Newline() (tea.Model, tea.Cmd)
- SetValue(value string)
- SetValueWithAttachments(value string)
- SetInterruptKeyInDebounce(inDebounce bool)
- SetExitKeyInDebounce(inDebounce bool)
- RestoreFromHistory(index int)
- GetAttachments() []*attachment.Attachment
-}
-
-type editorComponent struct {
- app *app.App
- width int
- textarea textarea.Model
- spinner spinner.Model
- interruptKeyInDebounce bool
- exitKeyInDebounce bool
- historyIndex int // -1 means current (not in history)
- currentText string // Store current text when navigating history
- pasteCounter int
- reverted bool
-}
-
-func (m *editorComponent) Init() tea.Cmd {
- return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
-}
-
-func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- var cmd tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width - 4
- return m, nil
- case spinner.TickMsg:
- m.spinner, cmd = m.spinner.Update(msg)
- return m, cmd
- case tea.KeyPressMsg:
- // Handle up/down arrows and ctrl+p/ctrl+n for history navigation
- switch msg.String() {
- case "up", "ctrl+p":
- // Only navigate history if cursor is at the first line and column (for arrow keys)
- // or allow ctrl+p from anywhere
- if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 {
- if m.historyIndex == -1 {
- // Save current text before entering history
- m.currentText = m.textarea.Value()
- m.textarea.MoveToBegin()
- }
- // Move up in history (older messages)
- if m.historyIndex < len(m.app.State.MessageHistory)-1 {
- m.historyIndex++
- m.RestoreFromHistory(m.historyIndex)
- m.textarea.MoveToBegin()
- }
- return m, nil
- }
- case "down", "ctrl+n":
- // Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys)
- // or allow ctrl+n from anywhere if we're in history navigation
- if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 {
- // Move down in history (newer messages)
- m.historyIndex--
- if m.historyIndex == -1 {
- // Restore current text
- m.textarea.Reset()
- m.textarea.SetValue(m.currentText)
- m.currentText = ""
- } else {
- m.RestoreFromHistory(m.historyIndex)
- m.textarea.MoveToEnd()
- }
- return m, nil
- } else if m.historyIndex > -1 && msg.String() == "down" {
- m.textarea.MoveToEnd()
- return m, nil
- }
- }
- // Reset history navigation on any other input
- if m.historyIndex != -1 {
- m.historyIndex = -1
- m.currentText = ""
- }
- // Maximize editor responsiveness for printable characters
- if msg.Text != "" {
- m.reverted = false
- m.textarea, cmd = m.textarea.Update(msg)
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
- }
- case app.MessageRevertedMsg:
- if msg.Session.ID == m.app.Session.ID {
- switch msg.Message.Info.(type) {
- case opencode.UserMessage:
- prompt, err := msg.Message.ToPrompt()
- if err != nil {
- return m, toast.NewErrorToast("Failed to revert message")
- }
- m.RestoreFromPrompt(*prompt)
- m.textarea.MoveToEnd()
- m.reverted = true
- return m, nil
- }
- }
- case app.SessionUnrevertedMsg:
- if msg.Session.ID == m.app.Session.ID {
- if m.reverted {
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- return m, cmd
- }
- return m, nil
- }
- case tea.PasteMsg:
- text := string(msg)
-
- if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
- statPath := filePath
- if !filepath.IsAbs(filePath) {
- statPath = filepath.Join(util.CwdPath, filePath)
- }
- if _, err := os.Stat(statPath); err == nil {
- attachment := m.createAttachmentFromPath(filePath)
- if attachment != nil {
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
- }
- }
- }
-
- text = strings.ReplaceAll(text, "\\", "")
- text, err := strconv.Unquote(`"` + text + `"`)
- if err != nil {
- slog.Error("Failed to unquote text", "error", err)
- text := string(msg)
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
- }
- return m, nil
- }
- if _, err := os.Stat(text); err != nil {
- slog.Error("Failed to paste file", "error", err)
- text := string(msg)
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
- }
- return m, nil
- }
-
- filePath := text
-
- attachment := m.createAttachmentFromFile(filePath)
- if attachment == nil {
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
- }
- return m, nil
- }
-
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- case tea.ClipboardMsg:
- text := string(msg)
- // Check if the pasted text is long and should be summarized
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(text))
- }
- case dialog.ThemeSelectedMsg:
- m.textarea = updateTextareaStyles(m.textarea)
- m.spinner = createSpinner()
- return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick)
- case dialog.CompletionSelectedMsg:
- switch msg.Item.ProviderID {
- case "commands":
- command := msg.Item.RawData.(commands.Command)
- if command.Custom {
- m.SetValue("/" + command.PrimaryTrigger() + " ")
- return m, nil
- }
-
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- cmds = append(cmds, cmd)
-
- commandName := strings.TrimPrefix(msg.Item.Value, "/")
- cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
- return m, tea.Batch(cmds...)
- case "files":
- atIndex := m.textarea.LastRuneIndex('@')
- if atIndex == -1 {
- // Should not happen, but as a fallback, just insert.
- m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
- }
-
- // The range to replace is from the '@' up to the current cursor position.
- // Replace the search term (e.g., "@search") with an empty string first.
- cursorCol := m.textarea.CursorColumn()
- m.textarea.ReplaceRange(atIndex, cursorCol, "")
-
- // Now, insert the attachment at the position where the '@' was.
- // The cursor is now at `atIndex` after the replacement.
- filePath := msg.Item.Value
- attachment := m.createAttachmentFromPath(filePath)
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
- case "symbols":
- atIndex := m.textarea.LastRuneIndex('@')
- if atIndex == -1 {
- // Should not happen, but as a fallback, just insert.
- m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
- }
-
- cursorCol := m.textarea.CursorColumn()
- m.textarea.ReplaceRange(atIndex, cursorCol, "")
-
- symbol := msg.Item.RawData.(opencode.Symbol)
- parts := strings.Split(symbol.Name, ".")
- lastPart := parts[len(parts)-1]
- attachment := &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "symbol",
- Display: "@" + lastPart,
- URL: msg.Item.Value,
- Filename: lastPart,
- MediaType: "text/plain",
- Source: &attachment.SymbolSource{
- Path: symbol.Location.Uri,
- Name: symbol.Name,
- Kind: int(symbol.Kind),
- Range: attachment.SymbolRange{
- Start: attachment.Position{
- Line: int(symbol.Location.Range.Start.Line),
- Char: int(symbol.Location.Range.Start.Character),
- },
- End: attachment.Position{
- Line: int(symbol.Location.Range.End.Line),
- Char: int(symbol.Location.Range.End.Character),
- },
- },
- },
- }
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
- case "agents":
- atIndex := m.textarea.LastRuneIndex('@')
- if atIndex == -1 {
- // Should not happen, but as a fallback, just insert.
- m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
- }
-
- cursorCol := m.textarea.CursorColumn()
- m.textarea.ReplaceRange(atIndex, cursorCol, "")
-
- name := msg.Item.Value
- attachment := &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "agent",
- Display: "@" + name,
- Source: &attachment.AgentSource{
- Name: name,
- },
- }
-
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
-
- default:
- slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
- return m, nil
- }
- }
-
- m.spinner, cmd = m.spinner.Update(msg)
- cmds = append(cmds, cmd)
-
- m.textarea, cmd = m.textarea.Update(msg)
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
-}
-
-func (m *editorComponent) Content() string {
- width := m.width
- if m.app.Session.ID == "" {
- width = min(width, 80)
- }
-
- t := theme.CurrentTheme()
- base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
- muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
-
- promptStyle := styles.NewStyle().Foreground(t.Primary()).
- Padding(0, 0, 0, 1).
- Bold(true)
- prompt := promptStyle.Render(">")
- borderForeground := t.Border()
- if m.app.IsLeaderSequence {
- borderForeground = t.Accent()
- }
- if m.app.IsBashMode {
- borderForeground = t.Secondary()
- prompt = promptStyle.Render("!")
- }
-
- m.textarea.SetWidth(width - 6)
- textarea := lipgloss.JoinHorizontal(
- lipgloss.Top,
- prompt,
- m.textarea.View(),
- )
- textarea = styles.NewStyle().
- Background(t.BackgroundElement()).
- Width(width).
- PaddingTop(1).
- PaddingBottom(1).
- BorderStyle(lipgloss.ThickBorder()).
- BorderForeground(borderForeground).
- BorderBackground(t.Background()).
- BorderLeft(true).
- BorderRight(true).
- Render(textarea)
-
- hint := base(m.getSubmitKeyText()) + muted(" send ")
- if m.exitKeyInDebounce {
- keyText := m.getExitKeyText()
- hint = base(keyText+" again") + muted(" to exit")
- } else if m.app.IsBusy() {
- keyText := m.getInterruptKeyText()
- status := "working"
- if m.app.IsCompacting() {
- status = "compacting"
- }
- if m.app.CurrentPermission.ID != "" {
- status = "waiting for permission"
- }
- if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" {
- hint = muted(
- status,
- ) + m.spinner.View() + muted(
- " ",
- ) + base(
- keyText+" again",
- ) + muted(
- " interrupt",
- )
- } else {
- hint = muted(status) + m.spinner.View()
- if m.app.CurrentPermission.ID == "" {
- hint += muted(" ") + base(keyText) + muted(" interrupt")
- }
- }
- }
-
- model := ""
- if m.app.Model != nil {
- model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
- }
-
- space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
- spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
-
- info := hint + spacer + model
- info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
-
- content := strings.Join([]string{"", textarea, info}, "\n")
- return content
-}
-
-func (m *editorComponent) Cursor() *tea.Cursor {
- return m.textarea.Cursor()
-}
-
-func (m *editorComponent) View() string {
- width := m.width
- if m.app.Session.ID == "" {
- width = min(width, 80)
- }
-
- if m.Lines() > 1 {
- return lipgloss.Place(
- width,
- 5,
- lipgloss.Center,
- lipgloss.Center,
- "",
- styles.WhitespaceStyle(theme.CurrentTheme().Background()),
- )
- }
- return m.Content()
-}
-
-func (m *editorComponent) Focused() bool {
- return m.textarea.Focused()
-}
-
-func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
- return m, m.textarea.Focus()
-}
-
-func (m *editorComponent) Blur() {
- m.textarea.Blur()
-}
-
-func (m *editorComponent) Lines() int {
- return m.textarea.LineCount()
-}
-
-func (m *editorComponent) Value() string {
- return m.textarea.Value()
-}
-
-func (m *editorComponent) Length() int {
- return m.textarea.Length()
-}
-
-func (m *editorComponent) GetAttachments() []*attachment.Attachment {
- return m.textarea.GetAttachments()
-}
-
-func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
- value := strings.TrimSpace(m.Value())
- if value == "" {
- return m, nil
- }
-
- switch value {
- case "exit", "quit", "q", ":q":
- return m, tea.Quit
- }
-
- if len(value) > 0 && value[len(value)-1] == '\\' {
- // If the last character is a backslash, remove it and add a newline
- backslashCol := m.textarea.CurrentRowLength() - 1
- m.textarea.ReplaceRange(backslashCol, backslashCol+1, "")
- m.textarea.InsertString("\n")
- return m, nil
- }
-
- var cmds []tea.Cmd
- if strings.HasPrefix(value, "/") {
- // Expand attachments in the value to get actual content
- expandedValue := value
- attachments := m.textarea.GetAttachments()
- for _, att := range attachments {
- if att.Type == "text" && att.Source != nil {
- if textSource, ok := att.Source.(*attachment.TextSource); ok {
- expandedValue = strings.Replace(expandedValue, att.Display, textSource.Value, 1)
- }
- }
- }
-
- expandedValue = expandedValue[1:] // Remove the "/"
- commandName := strings.Split(expandedValue, " ")[0]
- command := m.app.Commands[commands.CommandName(commandName)]
- if command.Custom {
- args := ""
- if strings.HasPrefix(expandedValue, command.PrimaryTrigger()+" ") {
- args = strings.TrimPrefix(expandedValue, command.PrimaryTrigger()+" ")
- }
- cmds = append(
- cmds,
- util.CmdHandler(app.SendCommand{Command: string(command.Name), Args: args}),
- )
-
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
- }
- }
-
- attachments := m.textarea.GetAttachments()
-
- prompt := app.Prompt{Text: value, Attachments: attachments}
- m.app.State.AddPromptToHistory(prompt)
- cmds = append(cmds, m.app.SaveState())
-
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- cmds = append(cmds, cmd)
-
- cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
- return m, tea.Batch(cmds...)
-}
-
-func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) {
- command := m.textarea.Value()
- var cmds []tea.Cmd
- updated, cmd := m.Clear()
- m = updated.(*editorComponent)
- cmds = append(cmds, cmd)
- cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command}))
- return m, tea.Batch(cmds...)
-}
-
-func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
- m.textarea.Reset()
- m.historyIndex = -1
- m.currentText = ""
- m.pasteCounter = 0
- return m, nil
-}
-
-func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
- imageBytes := clipboard.Read(clipboard.FmtImage)
- if imageBytes != nil {
- attachmentCount := len(m.textarea.GetAttachments())
- attachmentIndex := attachmentCount + 1
- base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
- attachment := &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "file",
- MediaType: "image/png",
- Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
- Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
- URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
- Source: &attachment.FileSource{
- Path: fmt.Sprintf("image-%d.png", attachmentIndex),
- Mime: "image/png",
- Data: imageBytes,
- },
- }
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
- }
-
- textBytes := clipboard.Read(clipboard.FmtText)
- if textBytes != nil {
- text := string(textBytes)
- // Check if the pasted text is long and should be summarized
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(text))
- }
- return m, nil
- }
-
- // fallback to reading the clipboard using OSC52
- return m, tea.ReadClipboard
-}
-
-func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
- m.textarea.Newline()
- return m, nil
-}
-
-func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
- m.interruptKeyInDebounce = inDebounce
-}
-
-func (m *editorComponent) SetValue(value string) {
- m.textarea.SetValue(value)
-}
-
-func (m *editorComponent) SetValueWithAttachments(value string) {
- m.textarea.Reset()
-
- i := 0
- for i < len(value) {
- r, size := utf8.DecodeRuneInString(value[i:])
- // Check if filepath and add attachment
- if r == '@' {
- start := i + size
- end := start
- for end < len(value) {
- nextR, nextSize := utf8.DecodeRuneInString(value[end:])
- if nextR == ' ' || nextR == '\t' || nextR == '\n' || nextR == '\r' {
- break
- }
- end += nextSize
- }
- if end > start {
- filePath := value[start:end]
- if _, err := os.Stat(filepath.Join(util.CwdPath, filePath)); err == nil {
- attachment := m.createAttachmentFromFile(filePath)
- if attachment != nil {
- m.textarea.InsertAttachment(attachment)
- i = end
- continue
- }
- }
- }
- }
-
- // Not a valid file path, insert the character normally
- m.textarea.InsertRune(r)
- i += size
- }
-}
-
-func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
- m.exitKeyInDebounce = inDebounce
-}
-
-func (m *editorComponent) getInterruptKeyText() string {
- return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
-}
-
-func (m *editorComponent) getSubmitKeyText() string {
- return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
-}
-
-func (m *editorComponent) getExitKeyText() string {
- return m.app.Commands[commands.AppExitCommand].Keys()[0]
-}
-
-// shouldSummarizePastedText determines if pasted text should be summarized
-func (m *editorComponent) shouldSummarizePastedText(text string) bool {
- if m.app.IsBashMode {
- return false
- }
-
- if m.app.Config != nil && m.app.Config.Experimental.DisablePasteSummary {
- return false
- }
-
- lines := strings.Split(text, "\n")
- lineCount := len(lines)
- charCount := len(text)
-
- // Consider text long if it has more than 3 lines or more than 150 characters
- return lineCount > 3 || charCount > 150
-}
-
-// handleLongPaste handles long pasted text by creating a summary attachment
-func (m *editorComponent) handleLongPaste(text string) {
- lines := strings.Split(text, "\n")
- lineCount := len(lines)
-
- // Increment paste counter
- m.pasteCounter++
-
- // Create attachment with full text as base64 encoded data
- fileBytes := []byte(text)
- base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
- url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
-
- fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
- displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
-
- attachment := &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "text",
- MediaType: "text/plain",
- Display: displayText,
- URL: url,
- Filename: fileName,
- Source: &attachment.TextSource{
- Value: text,
- },
- }
-
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
-}
-
-func updateTextareaStyles(ta textarea.Model) textarea.Model {
- t := theme.CurrentTheme()
- bgColor := t.BackgroundElement()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
- ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
- ta.Styles.Blurred.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
- ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
- ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
- ta.Styles.Focused.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
- ta.Styles.Attachment = styles.NewStyle().
- Foreground(t.Secondary()).
- Background(bgColor).
- Lipgloss()
- ta.Styles.SelectedAttachment = styles.NewStyle().
- Foreground(t.Text()).
- Background(t.Secondary()).
- Lipgloss()
- ta.Styles.Cursor.Color = t.Primary()
- return ta
-}
-
-func createSpinner() spinner.Model {
- t := theme.CurrentTheme()
- return spinner.New(
- spinner.WithSpinner(spinner.Ellipsis),
- spinner.WithStyle(
- styles.NewStyle().
- Background(t.Background()).
- Foreground(t.TextMuted()).
- Width(3).
- Lipgloss(),
- ),
- )
-}
-
-func NewEditorComponent(app *app.App) EditorComponent {
- s := createSpinner()
-
- ta := textarea.New()
- ta.Prompt = " "
- ta.ShowLineNumbers = false
- ta.CharLimit = -1
- ta.VirtualCursor = false
- ta = updateTextareaStyles(ta)
-
- m := &editorComponent{
- app: app,
- textarea: ta,
- spinner: s,
- interruptKeyInDebounce: false,
- historyIndex: -1,
- pasteCounter: 0,
- }
-
- return m
-}
-
-func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) {
- m.textarea.Reset()
- m.textarea.SetValue(prompt.Text)
-
- // Sort attachments by start index in reverse order (process from end to beginning)
- // This prevents index shifting issues
- attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments))
- copy(attachmentsCopy, prompt.Attachments)
-
- for i := 0; i < len(attachmentsCopy)-1; i++ {
- for j := i + 1; j < len(attachmentsCopy); j++ {
- if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
- attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
- }
- }
- }
-
- for _, att := range attachmentsCopy {
- m.textarea.SetCursorColumn(att.StartIndex)
- m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
- m.textarea.InsertAttachment(att)
- }
-}
-
-// RestoreFromHistory restores a message from history at the given index
-func (m *editorComponent) RestoreFromHistory(index int) {
- if index < 0 || index >= len(m.app.State.MessageHistory) {
- return
- }
- entry := m.app.State.MessageHistory[index]
- m.RestoreFromPrompt(entry)
-}
-
-func getMediaTypeFromExtension(ext string) string {
- switch strings.ToLower(ext) {
- case ".jpg":
- return "image/jpeg"
- case ".png", ".jpeg", ".gif", ".webp":
- return "image/" + ext[1:]
- case ".pdf":
- return "application/pdf"
- default:
- return "text/plain"
- }
-}
-
-func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
- ext := strings.ToLower(filepath.Ext(filePath))
- mediaType := getMediaTypeFromExtension(ext)
- absolutePath := filePath
- if !filepath.IsAbs(filePath) {
- absolutePath = filepath.Join(util.CwdPath, filePath)
- }
-
- // For text files, create a simple file reference
- if mediaType == "text/plain" {
- return &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "file",
- Display: "@" + filePath,
- URL: fmt.Sprintf("file://%s", absolutePath),
- Filename: filePath,
- MediaType: mediaType,
- Source: &attachment.FileSource{
- Path: absolutePath,
- Mime: mediaType,
- },
- }
- }
-
- // For binary files (images, PDFs), read and encode
- fileBytes, err := os.ReadFile(filePath)
- if err != nil {
- slog.Error("Failed to read file", "error", err)
- return nil
- }
-
- base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
- url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
- attachmentCount := len(m.textarea.GetAttachments())
- attachmentIndex := attachmentCount + 1
- label := "File"
- if strings.HasPrefix(mediaType, "image/") {
- label = "Image"
- }
- return &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "file",
- MediaType: mediaType,
- Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
- URL: url,
- Filename: filePath,
- Source: &attachment.FileSource{
- Path: absolutePath,
- Mime: mediaType,
- Data: fileBytes,
- },
- }
-}
-
-func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
- extension := filepath.Ext(filePath)
- mediaType := getMediaTypeFromExtension(extension)
- absolutePath := filePath
- if !filepath.IsAbs(filePath) {
- absolutePath = filepath.Join(util.CwdPath, filePath)
- }
- return &attachment.Attachment{
- ID: uuid.NewString(),
- Type: "file",
- Display: "@" + filePath,
- URL: fmt.Sprintf("file://%s", absolutePath),
- Filename: filePath,
- MediaType: mediaType,
- Source: &attachment.FileSource{
- Path: absolutePath,
- Mime: mediaType,
- },
- }
-}
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
deleted file mode 100644
index 801545a88..000000000
--- a/packages/tui/internal/components/chat/message.go
+++ /dev/null
@@ -1,1031 +0,0 @@
-package chat
-
-import (
- "encoding/json"
- "fmt"
- "maps"
- "slices"
- "strings"
- "time"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/charmbracelet/x/ansi"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/components/diff"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
- "golang.org/x/text/cases"
- "golang.org/x/text/language"
-)
-
-type blockRenderer struct {
- textColor compat.AdaptiveColor
- backgroundColor compat.AdaptiveColor
- border bool
- borderColor *compat.AdaptiveColor
- borderLeft bool
- borderRight bool
- paddingTop int
- paddingBottom int
- paddingLeft int
- paddingRight int
- marginTop int
- marginBottom int
-}
-
-type renderingOption func(*blockRenderer)
-
-func WithTextColor(color compat.AdaptiveColor) renderingOption {
- return func(c *blockRenderer) {
- c.textColor = color
- }
-}
-
-func WithBackgroundColor(color compat.AdaptiveColor) renderingOption {
- return func(c *blockRenderer) {
- c.backgroundColor = color
- }
-}
-
-func WithNoBorder() renderingOption {
- return func(c *blockRenderer) {
- c.border = false
- c.paddingLeft++
- c.paddingRight++
- }
-}
-
-func WithBorderColor(color compat.AdaptiveColor) renderingOption {
- return func(c *blockRenderer) {
- c.borderColor = &color
- }
-}
-
-func WithBorderLeft() renderingOption {
- return func(c *blockRenderer) {
- c.borderLeft = true
- c.borderRight = false
- }
-}
-
-func WithBorderRight() renderingOption {
- return func(c *blockRenderer) {
- c.borderLeft = false
- c.borderRight = true
- }
-}
-
-func WithBorderBoth(value bool) renderingOption {
- return func(c *blockRenderer) {
- if value {
- c.borderLeft = true
- c.borderRight = true
- }
- }
-}
-
-func WithMarginTop(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.marginTop = padding
- }
-}
-
-func WithMarginBottom(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.marginBottom = padding
- }
-}
-
-func WithPadding(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingTop = padding
- c.paddingBottom = padding
- c.paddingLeft = padding
- c.paddingRight = padding
- }
-}
-
-func WithPaddingLeft(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingLeft = padding
- }
-}
-
-func WithPaddingRight(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingRight = padding
- }
-}
-
-func WithPaddingTop(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingTop = padding
- }
-}
-
-func WithPaddingBottom(padding int) renderingOption {
- return func(c *blockRenderer) {
- c.paddingBottom = padding
- }
-}
-
-func renderContentBlock(
- app *app.App,
- content string,
- width int,
- options ...renderingOption,
-) string {
- t := theme.CurrentTheme()
- renderer := &blockRenderer{
- textColor: t.TextMuted(),
- backgroundColor: t.BackgroundPanel(),
- border: true,
- borderLeft: true,
- borderRight: false,
- paddingTop: 1,
- paddingBottom: 1,
- paddingLeft: 2,
- paddingRight: 2,
- }
- for _, option := range options {
- option(renderer)
- }
-
- borderColor := t.BackgroundPanel()
- if renderer.borderColor != nil {
- borderColor = *renderer.borderColor
- }
-
- style := styles.NewStyle().
- Foreground(renderer.textColor).
- Background(renderer.backgroundColor).
- PaddingTop(renderer.paddingTop).
- PaddingBottom(renderer.paddingBottom).
- PaddingLeft(renderer.paddingLeft).
- PaddingRight(renderer.paddingRight).
- AlignHorizontal(lipgloss.Left)
-
- if renderer.border {
- style = style.
- BorderStyle(lipgloss.ThickBorder()).
- BorderLeft(true).
- BorderRight(true).
- BorderLeftForeground(t.BackgroundPanel()).
- BorderLeftBackground(t.Background()).
- BorderRightForeground(t.BackgroundPanel()).
- BorderRightBackground(t.Background())
-
- if renderer.borderLeft {
- style = style.BorderLeftForeground(borderColor)
- }
- if renderer.borderRight {
- style = style.BorderRightForeground(borderColor)
- }
- } else {
- style = style.PaddingLeft(renderer.paddingLeft).PaddingRight(renderer.paddingRight)
- }
-
- content = style.Render(content)
- if renderer.marginTop > 0 {
- for range renderer.marginTop {
- content = "\n" + content
- }
- }
- if renderer.marginBottom > 0 {
- for range renderer.marginBottom {
- content = content + "\n"
- }
- }
-
- return content
-}
-
-func renderText(
- app *app.App,
- message opencode.MessageUnion,
- text string,
- author string,
- showToolDetails bool,
- width int,
- extra string,
- isThinking bool,
- isQueued bool,
- shimmer bool,
- fileParts []opencode.FilePart,
- agentParts []opencode.AgentPart,
- toolCalls ...opencode.ToolPart,
-) string {
- t := theme.CurrentTheme()
-
- var ts time.Time
- backgroundColor := t.BackgroundPanel()
- var content string
- switch casted := message.(type) {
- case opencode.AssistantMessage:
- backgroundColor = t.Background()
- if isThinking {
- backgroundColor = t.BackgroundPanel()
- }
- ts = time.UnixMilli(int64(casted.Time.Created))
- if casted.Time.Completed > 0 {
- ts = time.UnixMilli(int64(casted.Time.Completed))
- }
- content = util.ToMarkdown(text, width, backgroundColor)
- if isThinking {
- var label string
- if shimmer {
- label = util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent())
- } else {
- label = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking...")
- }
- label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
- content = label + "\n\n" + content
- } else if strings.TrimSpace(text) == "Generating..." {
- label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text())
- label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
- content = label
- }
- case opencode.UserMessage:
- ts = time.UnixMilli(int64(casted.Time.Created))
- base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
-
- var result strings.Builder
- lastEnd := int64(0)
-
- // Apply highlighting to filenames and base style to rest of text BEFORE wrapping
- textLen := int64(len(text))
-
- // Collect all parts to highlight (both file and agent parts)
- type highlightPart struct {
- start int64
- end int64
- color compat.AdaptiveColor
- }
- var highlights []highlightPart
-
- // Add file parts with secondary color
- for _, filePart := range fileParts {
- highlights = append(highlights, highlightPart{
- start: filePart.Source.Text.Start,
- end: filePart.Source.Text.End,
- color: t.Secondary(),
- })
- }
-
- // Add agent parts with secondary color (same as file parts)
- for _, agentPart := range agentParts {
- highlights = append(highlights, highlightPart{
- start: agentPart.Source.Start,
- end: agentPart.Source.End,
- color: t.Secondary(),
- })
- }
-
- // Sort highlights by start position
- slices.SortFunc(highlights, func(a, b highlightPart) int {
- if a.start < b.start {
- return -1
- }
- if a.start > b.start {
- return 1
- }
- return 0
- })
-
- // Merge overlapping highlights to prevent duplication
- merged := make([]highlightPart, 0)
- for _, part := range highlights {
- if len(merged) == 0 {
- merged = append(merged, part)
- continue
- }
-
- last := &merged[len(merged)-1]
- // If current part overlaps with the last one, merge them
- if part.start <= last.end {
- if part.end > last.end {
- last.end = part.end
- }
- } else {
- merged = append(merged, part)
- }
- }
-
- for _, part := range merged {
- highlight := base.Foreground(part.color)
- start, end := part.start, part.end
-
- if end > textLen {
- end = textLen
- }
- if start > textLen {
- start = textLen
- }
-
- if start > lastEnd {
- result.WriteString(base.Render(text[lastEnd:start]))
- }
- if start < end {
- result.WriteString(highlight.Render(text[start:end]))
- }
-
- lastEnd = end
- }
-
- if lastEnd < textLen {
- result.WriteString(base.Render(text[lastEnd:]))
- }
-
- // wrap styled text
- styledText := result.String()
- styledText = strings.ReplaceAll(styledText, "-", "\u2011")
- wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
- wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
- content = base.Width(width - 6).Render(wrappedText)
- if isQueued {
- queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1)
- content = queuedStyle.Render("QUEUED") + "\n\n" + content
- }
- }
-
- timestamp := ts.
- Local().
- Format("02 Jan 2006 03:04 PM")
- if time.Now().Format("02 Jan 2006") == timestamp[:11] {
- timestamp = timestamp[12:]
- }
- timestamp = styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.TextMuted()).
- Render(" (" + timestamp + ")")
-
- // Check if this is an assistant message with agent information
- var modelAndAgentSuffix string
- if assistantMsg, ok := message.(opencode.AssistantMessage); ok && assistantMsg.Mode != "" {
- // Find the agent index by name to get the correct color
- var agentIndex int
- for i, agent := range app.Agents {
- if agent.Name == assistantMsg.Mode {
- agentIndex = i
- break
- }
- }
-
- // Get agent color based on the original agent index (same as status bar)
- agentColor := util.GetAgentColor(agentIndex)
-
- // Style the agent name with the same color as status bar
- agentName := cases.Title(language.Und).String(assistantMsg.Mode)
- styledAgentName := styles.NewStyle().
- Background(backgroundColor).
- Foreground(agentColor).
- Render(agentName + " ")
- styledModelID := styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.TextMuted()).
- Render(assistantMsg.ModelID)
- modelAndAgentSuffix = styledAgentName + styledModelID
- }
-
- var info string
- if modelAndAgentSuffix != "" {
- info = modelAndAgentSuffix + timestamp
- } else {
- info = author + timestamp
- }
- if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
- for _, toolCall := range toolCalls {
- title := renderToolTitle(toolCall, width-2)
- style := styles.NewStyle()
- if toolCall.State.Status == opencode.ToolPartStateStatusError {
- style = style.Foreground(t.Error())
- }
- title = style.Render(title)
- title = "\n∟ " + title
- content = content + title
- }
- }
-
- sections := []string{content}
- if extra != "" {
- sections = append(sections, "\n"+extra+"\n")
- }
- sections = append(sections, info)
- content = strings.Join(sections, "\n")
-
- switch message.(type) {
- case opencode.UserMessage:
- borderColor := t.Secondary()
- if isQueued {
- borderColor = t.Accent()
- }
- return renderContentBlock(
- app,
- content,
- width,
- WithTextColor(t.Text()),
- WithBorderColor(borderColor),
- )
- case opencode.AssistantMessage:
- if isThinking {
- return renderContentBlock(
- app,
- content,
- width,
- WithTextColor(t.Text()),
- WithBackgroundColor(t.BackgroundPanel()),
- WithBorderColor(t.BackgroundPanel()),
- )
- }
- return renderContentBlock(
- app,
- content,
- width,
- WithNoBorder(),
- WithBackgroundColor(t.Background()),
- )
- }
- return ""
-}
-
-func renderToolDetails(
- app *app.App,
- toolCall opencode.ToolPart,
- permission opencode.Permission,
- width int,
-) string {
- measure := util.Measure("chat.renderToolDetails")
- defer measure("tool", toolCall.Tool)
- ignoredTools := []string{"todoread"}
- if slices.Contains(ignoredTools, toolCall.Tool) {
- return ""
- }
-
- if toolCall.State.Status == opencode.ToolPartStateStatusPending {
- title := renderToolTitle(toolCall, width)
- return renderContentBlock(app, title, width)
- }
-
- var result *string
- if toolCall.State.Output != "" {
- result = &toolCall.State.Output
- }
-
- toolInputMap := make(map[string]any)
- if toolCall.State.Input != nil {
- value := toolCall.State.Input
- if m, ok := value.(map[string]any); ok {
- toolInputMap = m
- keys := make([]string, 0, len(toolInputMap))
- for key := range toolInputMap {
- keys = append(keys, key)
- }
- slices.Sort(keys)
- }
- }
-
- body := ""
- t := theme.CurrentTheme()
- backgroundColor := t.BackgroundPanel()
- borderColor := t.BackgroundPanel()
- defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
- baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render
- mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render
-
- permissionContent := ""
- if permission.ID != "" {
- borderColor = t.Warning()
-
- base := styles.NewStyle().Background(backgroundColor)
- text := base.Foreground(t.Text()).Bold(true).Render
- muted := base.Foreground(t.TextMuted()).Render
- if permission.Type == "doom-loop" {
- permissionContent = permission.Title + "\n\n"
- } else {
- permissionContent = "Permission required to run this tool:\n\n"
- }
- permissionContent += text(
- "enter ",
- ) + muted(
- "accept ",
- ) + text(
- "a",
- ) + muted(
- " accept always ",
- ) + text(
- "esc",
- ) + muted(
- " reject",
- )
-
- }
-
- if permission.Metadata != nil {
- metadata, ok := toolCall.State.Metadata.(map[string]any)
- if metadata == nil || !ok {
- metadata = map[string]any{}
- }
- maps.Copy(metadata, permission.Metadata)
- toolCall.State.Metadata = metadata
- }
-
- if toolCall.State.Metadata != nil {
- metadata := toolCall.State.Metadata.(map[string]any)
- switch toolCall.Tool {
- case "read":
- var preview any
- if metadata != nil {
- preview = metadata["preview"]
- }
- if preview != nil && toolInputMap["filePath"] != nil {
- filename := toolInputMap["filePath"].(string)
- body = preview.(string)
- body = util.RenderFile(filename, body, width, util.WithTruncate(6))
- }
- case "edit":
- if filename, ok := toolInputMap["filePath"].(string); ok {
- var diffField any
- if metadata != nil {
- diffField = metadata["diff"]
- }
- if diffField != nil {
- patch := diffField.(string)
- var formattedDiff string
- if width < 120 {
- formattedDiff, _ = diff.FormatUnifiedDiff(
- filename,
- patch,
- diff.WithWidth(width-2),
- )
- } else {
- formattedDiff, _ = diff.FormatDiff(
- filename,
- patch,
- diff.WithWidth(width-2),
- )
- }
- body = strings.TrimSpace(formattedDiff)
- style := styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.TextMuted()).
- Padding(1, 2).
- Width(width - 4)
-
- if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
- diagnostics = style.Render(diagnostics)
- body += "\n" + diagnostics
- }
-
- title := renderToolTitle(toolCall, width)
- title = style.Render(title)
- content := title + "\n" + body
-
- if toolCall.State.Status == opencode.ToolPartStateStatusError {
- errorStyle := styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.Error()).
- Padding(1, 2).
- Width(width - 4)
- errorContent := errorStyle.Render(toolCall.State.Error)
- content += "\n" + errorContent
- }
-
- if permissionContent != "" {
- permissionContent = styles.NewStyle().
- Background(backgroundColor).
- Padding(1, 2).
- Render(permissionContent)
- content += "\n" + permissionContent
- }
- content = renderContentBlock(
- app,
- content,
- width,
- WithPadding(0),
- WithBorderColor(borderColor),
- WithBorderBoth(permission.ID != ""),
- )
- return content
- }
- }
- case "write":
- if filename, ok := toolInputMap["filePath"].(string); ok {
- if content, ok := toolInputMap["content"].(string); ok {
- body = util.RenderFile(filename, content, width)
- if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
- body += "\n\n" + diagnostics
- }
- }
- }
- case "bash":
- if command, ok := toolInputMap["command"].(string); ok {
- body = fmt.Sprintf("```console\n$ %s\n", command)
- output := metadata["output"]
- if output != nil {
- body += ansi.Strip(fmt.Sprintf("%s", output))
- }
- body += "```"
- body = util.ToMarkdown(body, width, backgroundColor)
- }
- case "webfetch":
- if format, ok := toolInputMap["format"].(string); ok && result != nil {
- body = *result
- body = util.TruncateHeight(body, 10)
- if format == "html" || format == "markdown" {
- body = util.ToMarkdown(body, width, backgroundColor)
- }
- }
- case "todowrite":
- todos := metadata["todos"]
- if todos != nil {
- for _, item := range todos.([]any) {
- todo := item.(map[string]any)
- content := todo["content"]
- if content == nil {
- continue
- }
- switch todo["status"] {
- case "completed":
- body += fmt.Sprintf("- [x] %s\n", content)
- case "cancelled":
- // strike through cancelled todo
- body += fmt.Sprintf("- [ ] ~~%s~~\n", content)
- case "in_progress":
- // highlight in progress todo
- body += fmt.Sprintf("- [ ] `%s`\n", content)
- default:
- body += fmt.Sprintf("- [ ] %s\n", content)
- }
- }
- body = util.ToMarkdown(body, width, backgroundColor)
- }
- case "task":
- summary := metadata["summary"]
- if summary != nil {
- toolcalls := summary.([]any)
- steps := []string{}
- for _, item := range toolcalls {
- data, _ := json.Marshal(item)
- var toolCall opencode.ToolPart
- _ = json.Unmarshal(data, &toolCall)
- step := renderToolTitle(toolCall, width-2)
- step = "∟ " + step
- steps = append(steps, step)
- }
- body = strings.Join(steps, "\n")
-
- body += "\n\n"
-
- // Build navigation hint with proper spacing
- cycleKeybind := app.Keybind(commands.SessionChildCycleCommand)
- cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand)
-
- var navParts []string
- if cycleKeybind != "" {
- navParts = append(navParts, baseStyle(cycleKeybind))
- }
- if cycleReverseKeybind != "" {
- navParts = append(navParts, baseStyle(cycleReverseKeybind))
- }
-
- if len(navParts) > 0 {
- body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions")
- }
- }
- body = defaultStyle(body)
- default:
- if result == nil {
- empty := ""
- result = &empty
- }
- body = *result
- body = util.TruncateHeight(body, 10)
- body = defaultStyle(body)
- }
- }
-
- error := ""
- if toolCall.State.Status == opencode.ToolPartStateStatusError {
- error = toolCall.State.Error
- }
-
- if error != "" {
- errorContent := styles.NewStyle().
- Width(width - 6).
- Foreground(t.Error()).
- Background(backgroundColor).
- Render(error)
-
- if body == "" {
- body = errorContent
- } else {
- body += "\n\n" + errorContent
- }
- }
-
- if body == "" && error == "" && result != nil {
- body = *result
- body = util.TruncateHeight(body, 10)
- body = defaultStyle(body)
- }
-
- if body == "" {
- body = defaultStyle("")
- }
-
- title := renderToolTitle(toolCall, width)
- content := title + "\n\n" + body
-
- if permissionContent != "" {
- content += "\n\n\n" + permissionContent
- }
-
- return renderContentBlock(
- app,
- content,
- width,
- WithBorderColor(borderColor),
- WithBorderBoth(permission.ID != ""),
- )
-}
-
-func renderToolName(name string) string {
- switch name {
- case "bash":
- return "Shell"
- case "webfetch":
- return "Fetch"
- case "invalid":
- return "Invalid"
- default:
- normalizedName := name
- if after, ok := strings.CutPrefix(name, "opencode_"); ok {
- normalizedName = after
- }
- return cases.Title(language.Und).String(normalizedName)
- }
-}
-
-func getTodoPhase(metadata map[string]any) string {
- todos, ok := metadata["todos"].([]any)
- if !ok || len(todos) == 0 {
- return "Plan"
- }
-
- counts := map[string]int{"pending": 0, "completed": 0}
- for _, item := range todos {
- if todo, ok := item.(map[string]any); ok {
- if status, ok := todo["status"].(string); ok {
- counts[status]++
- }
- }
- }
-
- total := len(todos)
- switch {
- case counts["pending"] == total:
- return "Creating plan"
- case counts["completed"] == total:
- return "Completing plan"
- default:
- return "Updating plan"
- }
-}
-
-func getTodoTitle(toolCall opencode.ToolPart) string {
- if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
- if metadata, ok := toolCall.State.Metadata.(map[string]any); ok {
- return getTodoPhase(metadata)
- }
- }
- return "Plan"
-}
-
-func renderToolTitle(
- toolCall opencode.ToolPart,
- width int,
-) string {
- if toolCall.State.Status == opencode.ToolPartStateStatusPending {
- title := renderToolAction(toolCall.Tool)
- t := theme.CurrentTheme()
- shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
- return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny)
- }
-
- toolArgs := ""
- toolArgsMap := make(map[string]any)
- if toolCall.State.Input != nil {
- value := toolCall.State.Input
- if m, ok := value.(map[string]any); ok {
- toolArgsMap = m
-
- keys := make([]string, 0, len(toolArgsMap))
- for key := range toolArgsMap {
- keys = append(keys, key)
- }
- slices.Sort(keys)
- firstKey := ""
- if len(keys) > 0 {
- firstKey = keys[0]
- }
-
- toolArgs = renderArgs(&toolArgsMap, firstKey)
- }
- }
-
- title := renderToolName(toolCall.Tool)
- switch toolCall.Tool {
- case "read":
- toolArgs = renderArgs(&toolArgsMap, "filePath")
- title = fmt.Sprintf("%s %s", title, toolArgs)
- case "edit", "write":
- if filename, ok := toolArgsMap["filePath"].(string); ok {
- title = fmt.Sprintf("%s %s", title, util.Relative(filename))
- }
- case "bash":
- if description, ok := toolArgsMap["description"].(string); ok {
- title = fmt.Sprintf("%s %s", title, description)
- }
- case "task":
- description := toolArgsMap["description"]
- subagent := toolArgsMap["subagent_type"]
- if description != nil && subagent != nil {
- title = fmt.Sprintf("%s[%s] %s", title, subagent, description)
- } else if description != nil {
- title = fmt.Sprintf("%s %s", title, description)
- }
- case "webfetch":
- toolArgs = renderArgs(&toolArgsMap, "url")
- title = fmt.Sprintf("%s %s", title, toolArgs)
- case "todowrite":
- title = getTodoTitle(toolCall)
- case "todoread":
- return "Plan"
- case "invalid":
- if actualTool, ok := toolArgsMap["tool"].(string); ok {
- title = renderToolName(actualTool)
- }
- default:
- toolName := renderToolName(toolCall.Tool)
- title = fmt.Sprintf("%s %s", toolName, toolArgs)
- }
-
- title = truncate.StringWithTail(title, uint(width-6), "...")
- if toolCall.State.Error != "" {
- t := theme.CurrentTheme()
- title = styles.NewStyle().Foreground(t.Error()).Render(title)
- }
- return title
-}
-
-func renderToolAction(name string) string {
- switch name {
- case "task":
- return "Delegating..."
- case "bash":
- return "Writing command..."
- case "edit":
- return "Preparing edit..."
- case "webfetch":
- return "Fetching from the web..."
- case "glob":
- return "Finding files..."
- case "grep":
- return "Searching content..."
- case "list":
- return "Listing directory..."
- case "read":
- return "Reading file..."
- case "write":
- return "Preparing write..."
- case "todowrite", "todoread":
- return "Planning..."
- case "patch":
- return "Preparing patch..."
- }
- return "Working..."
-}
-
-func renderArgs(args *map[string]any, titleKey string) string {
- if args == nil || len(*args) == 0 {
- return ""
- }
-
- keys := make([]string, 0, len(*args))
- for key := range *args {
- keys = append(keys, key)
- }
- slices.Sort(keys)
-
- title := ""
- parts := []string{}
- for _, key := range keys {
- value := (*args)[key]
- if value == nil {
- continue
- }
- if key == "filePath" || key == "path" {
- if strValue, ok := value.(string); ok {
- value = util.Relative(strValue)
- }
- }
- if key == titleKey {
- title = fmt.Sprintf("%s", value)
- continue
- }
- parts = append(parts, fmt.Sprintf("%s=%v", key, value))
- }
- if len(parts) == 0 {
- return title
- }
- return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", "))
-}
-
-// Diagnostic represents an LSP diagnostic
-type Diagnostic struct {
- Range struct {
- Start struct {
- Line int `json:"line"`
- Character int `json:"character"`
- } `json:"start"`
- } `json:"range"`
- Severity int `json:"severity"`
- Message string `json:"message"`
-}
-
-// renderDiagnostics formats LSP diagnostics for display in the TUI
-func renderDiagnostics(
- metadata map[string]any,
- filePath string,
- backgroundColor compat.AdaptiveColor,
- width int,
-) string {
- if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
- if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
- var errorDiagnostics []string
- for _, diagInterface := range fileDiagnostics {
- diagMap, ok := diagInterface.(map[string]any)
- if !ok {
- continue
- }
- // Parse the diagnostic
- var diag Diagnostic
- diagBytes, err := json.Marshal(diagMap)
- if err != nil {
- continue
- }
- if err := json.Unmarshal(diagBytes, &diag); err != nil {
- continue
- }
- // Only show error diagnostics (severity === 1)
- if diag.Severity != 1 {
- continue
- }
- line := diag.Range.Start.Line + 1 // 1-based
- column := diag.Range.Start.Character + 1 // 1-based
- errorDiagnostics = append(
- errorDiagnostics,
- fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
- )
- }
- if len(errorDiagnostics) == 0 {
- return ""
- }
- t := theme.CurrentTheme()
- var result strings.Builder
- for _, diagnostic := range errorDiagnostics {
- if result.Len() > 0 {
- result.WriteString("\n\n")
- }
- diagnostic = ansi.WordwrapWc(diagnostic, width, " -")
- result.WriteString(
- styles.NewStyle().
- Background(backgroundColor).
- Foreground(t.Error()).
- Render(diagnostic),
- )
- }
- return result.String()
- }
- }
- return ""
-
- // diagnosticsData should be a map[string][]Diagnostic
- // strDiagnosticsData := diagnosticsData.Raw()
- // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any)
- // fileDiagnostics, ok := diagnosticsMap[filePath]
- // if !ok {
- // return ""
- // }
-
- // diagnosticsList, ok := fileDiagnostics.([]any)
- // if !ok {
- // return ""
- // }
-
-}
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
deleted file mode 100644
index 3d52b84e5..000000000
--- a/packages/tui/internal/components/chat/messages.go
+++ /dev/null
@@ -1,1322 +0,0 @@
-package chat
-
-import (
- "context"
- "fmt"
- "log/slog"
- "slices"
- "sort"
- "strconv"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/components/dialog"
- "github.com/sst/opencode/internal/components/diff"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
- "github.com/sst/opencode/internal/viewport"
-)
-
-type MessagesComponent interface {
- tea.Model
- tea.ViewModel
- PageUp() (tea.Model, tea.Cmd)
- PageDown() (tea.Model, tea.Cmd)
- HalfPageUp() (tea.Model, tea.Cmd)
- HalfPageDown() (tea.Model, tea.Cmd)
- ToolDetailsVisible() bool
- ThinkingBlocksVisible() bool
- GotoTop() (tea.Model, tea.Cmd)
- GotoBottom() (tea.Model, tea.Cmd)
- CopyLastMessage() (tea.Model, tea.Cmd)
- UndoLastMessage() (tea.Model, tea.Cmd)
- RedoLastMessage() (tea.Model, tea.Cmd)
- ScrollToMessage(messageID string) (tea.Model, tea.Cmd)
-}
-
-type messagesComponent struct {
- width, height int
- app *app.App
- header string
- viewport viewport.Model
- clipboard []string
- cache *PartCache
- loading bool
- showToolDetails bool
- showThinkingBlocks bool
- rendering bool
- dirty bool
- tail bool
- partCount int
- lineCount int
- selection *selection
- messagePositions map[string]int // map message ID to line position
- animating bool
-}
-
-type selection struct {
- startX int
- endX int
- startY int
- endY int
-}
-
-func (s selection) coords(offset int) *selection {
- // selecting backwards
- if s.startY > s.endY && s.endY >= 0 {
- return &selection{
- startX: max(0, s.endX-1),
- startY: s.endY - offset,
- endX: s.startX + 1,
- endY: s.startY - offset,
- }
- }
-
- // selecting backwards same line
- if s.startY == s.endY && s.startX >= s.endX {
- return &selection{
- startY: s.startY - offset,
- startX: max(0, s.endX-1),
- endY: s.endY - offset,
- endX: s.startX + 1,
- }
- }
-
- return &selection{
- startX: s.startX,
- startY: s.startY - offset,
- endX: s.endX,
- endY: s.endY - offset,
- }
-}
-
-type ToggleToolDetailsMsg struct{}
-type ToggleThinkingBlocksMsg struct{}
-type shimmerTickMsg struct{}
-
-func (m *messagesComponent) Init() tea.Cmd {
- return tea.Batch(m.viewport.Init())
-}
-
-func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case shimmerTickMsg:
- if !m.app.HasAnimatingWork() {
- m.animating = false
- return m, nil
- }
- return m, tea.Sequence(
- m.renderView(),
- tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }),
- )
- case tea.MouseClickMsg:
- slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
- y := msg.Y + m.viewport.YOffset
- if y > 0 {
- m.selection = &selection{
- startY: y,
- startX: msg.X,
- endY: -1,
- endX: -1,
- }
-
- slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY))
- return m, m.renderView()
- }
-
- case tea.MouseMotionMsg:
- if m.selection != nil {
- m.selection = &selection{
- startX: m.selection.startX,
- startY: m.selection.startY,
- endX: msg.X + 1,
- endY: msg.Y + m.viewport.YOffset,
- }
- return m, m.renderView()
- }
-
- case tea.MouseReleaseMsg:
- if m.selection != nil {
- m.selection = nil
- if len(m.clipboard) > 0 {
- content := strings.Join(m.clipboard, "\n")
- m.clipboard = []string{}
- return m, tea.Sequence(
- m.renderView(),
- app.SetClipboard(content),
- toast.NewSuccessToast("Copied to clipboard"),
- )
- }
- return m, m.renderView()
- }
- case tea.WindowSizeMsg:
- effectiveWidth := msg.Width - 4
- // Clear cache on resize since width affects rendering
- if m.width != effectiveWidth {
- m.cache.Clear()
- }
- m.width = effectiveWidth
- m.height = msg.Height - 7
- m.viewport.SetWidth(m.width)
- m.loading = true
- return m, m.renderView()
- case app.SendPrompt:
- m.viewport.GotoBottom()
- m.tail = true
- return m, nil
- case app.SendCommand:
- m.viewport.GotoBottom()
- m.tail = true
- return m, nil
- case dialog.ThemeSelectedMsg:
- m.cache.Clear()
- m.loading = true
- return m, m.renderView()
- case ToggleToolDetailsMsg:
- m.showToolDetails = !m.showToolDetails
- m.app.State.ShowToolDetails = &m.showToolDetails
- return m, tea.Batch(m.renderView(), m.app.SaveState())
- case ToggleThinkingBlocksMsg:
- m.showThinkingBlocks = !m.showThinkingBlocks
- m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
- return m, tea.Batch(m.renderView(), m.app.SaveState())
- case app.SessionLoadedMsg:
- m.tail = true
- m.loading = true
- return m, m.renderView()
- case app.SessionClearedMsg:
- m.cache.Clear()
- m.tail = true
- m.loading = true
- return m, m.renderView()
- case app.SessionUnrevertedMsg:
- if msg.Session.ID == m.app.Session.ID {
- m.cache.Clear()
- m.tail = true
- return m, m.renderView()
- }
- case app.SessionSelectedMsg:
- currentParent := m.app.Session.ParentID
- if currentParent == "" {
- currentParent = m.app.Session.ID
- }
-
- targetParent := msg.ParentID
- if targetParent == "" {
- targetParent = msg.ID
- }
-
- // Clear cache only if switching between different session families
- if currentParent != targetParent {
- m.cache.Clear()
- }
-
- m.viewport.GotoBottom()
- case app.MessageRevertedMsg:
- if msg.Session.ID == m.app.Session.ID {
- m.cache.Clear()
- m.tail = true
- return m, m.renderView()
- }
-
- case opencode.EventListResponseEventSessionUpdated:
- if msg.Properties.Info.ID == m.app.Session.ID {
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventMessageUpdated:
- if msg.Properties.Info.SessionID == m.app.Session.ID {
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventSessionError:
- if msg.Properties.SessionID == m.app.Session.ID {
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventMessagePartUpdated:
- if msg.Properties.Part.SessionID == m.app.Session.ID {
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventMessageRemoved:
- if msg.Properties.SessionID == m.app.Session.ID {
- m.cache.Clear()
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventMessagePartRemoved:
- if msg.Properties.SessionID == m.app.Session.ID {
- // Clear the cache when a part is removed to ensure proper re-rendering
- m.cache.Clear()
- cmds = append(cmds, m.renderView())
- }
- case opencode.EventListResponseEventPermissionUpdated:
- m.tail = true
- return m, m.renderView()
- case opencode.EventListResponseEventPermissionReplied:
- m.tail = true
- return m, m.renderView()
- case renderCompleteMsg:
- m.partCount = msg.partCount
- m.lineCount = msg.lineCount
- m.rendering = false
- m.clipboard = msg.clipboard
- m.loading = false
- m.messagePositions = msg.messagePositions
- m.tail = m.viewport.AtBottom()
-
- // Preserve scroll across reflow
- // if the user was at bottom, keep following; otherwise restore the previous offset.
- wasAtBottom := m.viewport.AtBottom()
- prevYOffset := m.viewport.YOffset
- m.viewport = msg.viewport
- if wasAtBottom {
- m.viewport.GotoBottom()
- } else {
- m.viewport.YOffset = prevYOffset
- }
-
- m.header = msg.header
- if m.dirty {
- cmds = append(cmds, m.renderView())
- }
-
- // Start shimmer ticks if any assistant/tool is in-flight
- if !m.animating && m.app.HasAnimatingWork() {
- m.animating = true
- cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }))
- }
- }
-
- m.tail = m.viewport.AtBottom()
- viewport, cmd := m.viewport.Update(msg)
- m.viewport = viewport
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
-}
-
-type renderCompleteMsg struct {
- viewport viewport.Model
- clipboard []string
- header string
- partCount int
- lineCount int
- messagePositions map[string]int
-}
-
-func (m *messagesComponent) renderView() tea.Cmd {
- if m.rendering {
- slog.Debug("pending render, skipping")
- m.dirty = true
- return func() tea.Msg {
- return nil
- }
- }
- m.dirty = false
- m.rendering = true
-
- viewport := m.viewport
- tail := m.tail
-
- return func() tea.Msg {
- header := m.renderHeader()
- measure := util.Measure("messages.renderView")
- defer measure()
-
- t := theme.CurrentTheme()
- blocks := make([]string, 0)
- partCount := 0
- lineCount := 0
- messagePositions := make(map[string]int) // Track message ID to line position
-
- orphanedToolCalls := make([]opencode.ToolPart, 0)
-
- width := m.width // always use full width
-
- // Find the last streaming ReasoningPart to only shimmer that one
- lastStreamingReasoningID := ""
- if m.showThinkingBlocks {
- for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- {
- if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok {
- continue
- }
- parts := m.app.Messages[mi].Parts
- for pi := len(parts) - 1; pi >= 0; pi-- {
- if rp, ok := parts[pi].(opencode.ReasoningPart); ok {
- if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 {
- lastStreamingReasoningID = rp.ID
- break
- }
- }
- }
- }
- }
-
- reverted := false
- revertedMessageCount := 0
- revertedToolCount := 0
- lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
- for _, msg := range slices.Backward(m.app.Messages) {
- if assistant, ok := msg.Info.(opencode.AssistantMessage); ok {
- if assistant.Time.Completed > 0 {
- break
- }
- lastAssistantMessage = assistant.ID
- break
- }
- }
- for _, message := range m.app.Messages {
- var content string
- var cached bool
- error := ""
-
- switch casted := message.Info.(type) {
- case opencode.UserMessage:
- // Track the position of this user message
- messagePositions[casted.ID] = lineCount
-
- if casted.ID == m.app.Session.Revert.MessageID {
- reverted = true
- revertedMessageCount = 1
- revertedToolCount = 0
- continue
- }
- if reverted {
- revertedMessageCount++
- continue
- }
-
- for partIndex, part := range message.Parts {
- switch part := part.(type) {
- case opencode.TextPart:
- if part.Synthetic {
- continue
- }
- if part.Text == "" {
- continue
- }
- remainingParts := message.Parts[partIndex+1:]
- fileParts := make([]opencode.FilePart, 0)
- agentParts := make([]opencode.AgentPart, 0)
- for _, part := range remainingParts {
- switch part := part.(type) {
- case opencode.FilePart:
- if part.Source.Text.Start >= 0 && part.Source.Text.End >= part.Source.Text.Start {
- fileParts = append(fileParts, part)
- }
- case opencode.AgentPart:
- if part.Source.Start >= 0 && part.Source.End >= part.Source.Start {
- agentParts = append(agentParts, part)
- }
- }
- }
- flexItems := []layout.FlexItem{}
- if len(fileParts) > 0 {
- fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
- mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
- for _, filePart := range fileParts {
- mediaType := ""
- switch filePart.Mime {
- case "text/plain":
- mediaType = "txt"
- case "image/png", "image/jpeg", "image/gif", "image/webp":
- mediaType = "img"
- mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
- case "application/pdf":
- mediaType = "pdf"
- mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
- }
- flexItems = append(flexItems, layout.FlexItem{
- View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
- })
- }
- }
- bgColor := t.BackgroundPanel()
- files := layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Width: width - 6,
- Direction: layout.Column,
- },
- flexItems...,
- )
-
- author := m.app.Config.Username
- isQueued := casted.ID > lastAssistantMessage
- key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued)
- content, cached = m.cache.Get(key)
- if !cached {
- content = renderText(
- m.app,
- message.Info,
- part.Text,
- author,
- m.showToolDetails,
- width,
- files,
- false,
- isQueued,
- false,
- fileParts,
- agentParts,
- )
- m.cache.Set(key, content)
- }
- if content != "" {
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- }
- }
- }
-
- case opencode.AssistantMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- reverted = true
- revertedMessageCount = 1
- revertedToolCount = 0
- }
- hasTextPart := false
- hasContent := false
- for partIndex, p := range message.Parts {
- switch part := p.(type) {
- case opencode.TextPart:
- if reverted {
- continue
- }
- if strings.TrimSpace(part.Text) == "" {
- continue
- }
- hasTextPart = true
- finished := part.Time.End > 0
- remainingParts := message.Parts[partIndex+1:]
- toolCallParts := make([]opencode.ToolPart, 0)
-
- // sometimes tool calls happen without an assistant message
- // these should be included in this assistant message as well
- if len(orphanedToolCalls) > 0 {
- toolCallParts = append(toolCallParts, orphanedToolCalls...)
- orphanedToolCalls = make([]opencode.ToolPart, 0)
- }
-
- remaining := true
- for _, part := range remainingParts {
- if !remaining {
- break
- }
- switch part := part.(type) {
- case opencode.TextPart:
- // we only want tool calls associated with the current text part.
- // if we hit another text part, we're done.
- remaining = false
- case opencode.ToolPart:
- toolCallParts = append(toolCallParts, part)
- if part.State.Status != opencode.ToolPartStateStatusCompleted && part.State.Status != opencode.ToolPartStateStatusError {
- // i don't think there's a case where a tool call isn't in result state
- // and the message time is 0, but just in case
- finished = false
- }
- }
- }
-
- if finished {
- key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts)
- content, cached = m.cache.Get(key)
- if !cached {
- content = renderText(
- m.app,
- message.Info,
- part.Text,
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- false,
- false,
- false,
- []opencode.FilePart{},
- []opencode.AgentPart{},
- toolCallParts...,
- )
- m.cache.Set(key, content)
- }
- } else {
- content = renderText(
- m.app,
- message.Info,
- part.Text,
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- false,
- false,
- false,
- []opencode.FilePart{},
- []opencode.AgentPart{},
- toolCallParts...,
- )
- }
- if content != "" {
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- hasContent = true
- }
- case opencode.ToolPart:
- if reverted {
- revertedToolCount++
- continue
- }
-
- permission := opencode.Permission{}
- if m.app.CurrentPermission.CallID == part.CallID {
- permission = m.app.CurrentPermission
- }
-
- if !m.showToolDetails && permission.ID == "" {
- if !hasTextPart {
- orphanedToolCalls = append(orphanedToolCalls, part)
- }
- continue
- }
-
- if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
- key := m.cache.GenerateKey(casted.ID,
- part.ID,
- m.showToolDetails,
- width,
- permission.ID,
- )
- content, cached = m.cache.Get(key)
- if !cached {
- content = renderToolDetails(
- m.app,
- part,
- permission,
- width,
- )
- m.cache.Set(key, content)
- }
- } else {
- // if the tool call isn't finished, don't cache
- content = renderToolDetails(
- m.app,
- part,
- permission,
- width,
- )
- }
- if content != "" {
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- hasContent = true
- }
- case opencode.ReasoningPart:
- if reverted {
- continue
- }
- if !m.showThinkingBlocks {
- continue
- }
- if part.Text != "" {
- text := part.Text
- shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID
- content = renderText(
- m.app,
- message.Info,
- text,
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- true,
- false,
- shimmer,
- []opencode.FilePart{},
- []opencode.AgentPart{},
- )
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- hasContent = true
- }
- }
- }
-
- switch err := casted.Error.AsUnion().(type) {
- case nil:
- case opencode.AssistantMessageErrorMessageOutputLengthError:
- error = "Message output length exceeded"
- case opencode.AssistantMessageErrorAPIError:
- error = err.Data.Message
- case opencode.ProviderAuthError:
- error = err.Data.Message
- case opencode.MessageAbortedError:
- error = "Request was aborted"
- case opencode.UnknownError:
- error = err.Data.Message
- }
-
- if !hasContent && error == "" && !reverted && casted.Time.Completed == 0 {
- content = renderText(
- m.app,
- message.Info,
- "Generating...",
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- false,
- false,
- false,
- []opencode.FilePart{},
- []opencode.AgentPart{},
- )
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- }
- }
-
- if error != "" && !reverted {
- error = styles.NewStyle().Width(width - 6).Render(error)
- error = renderContentBlock(
- m.app,
- error,
- width,
- WithBorderColor(t.Error()),
- )
- blocks = append(blocks, error)
- lineCount += lipgloss.Height(error) + 1
- }
- }
-
- if revertedMessageCount > 0 || revertedToolCount > 0 {
- messagePlural := ""
- toolPlural := ""
- if revertedMessageCount != 1 {
- messagePlural = "s"
- }
- if revertedToolCount != 1 {
- toolPlural = "s"
- }
- revertedStyle := styles.NewStyle().
- Background(t.BackgroundPanel()).
- Foreground(t.TextMuted())
-
- content := revertedStyle.Render(fmt.Sprintf(
- "%d message%s reverted, %d tool call%s reverted",
- revertedMessageCount,
- messagePlural,
- revertedToolCount,
- toolPlural,
- ))
- hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text())
- hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand))
- hint += revertedStyle.Render(" (or /redo) to restore")
-
- content += "\n" + hint
- if m.app.Session.Revert.Diff != "" {
- t := theme.CurrentTheme()
- s := styles.NewStyle().Background(t.BackgroundPanel())
- green := s.Foreground(t.Success()).Render
- red := s.Foreground(t.Error()).Render
- content += "\n"
- stats, err := diff.ParseStats(m.app.Session.Revert.Diff)
- if err != nil {
- slog.Error("Failed to parse diff stats", "error", err)
- } else {
- var files []string
- for file := range stats {
- files = append(files, file)
- }
- sort.Strings(files)
-
- for _, file := range files {
- fileStats := stats[file]
- display := file
- if fileStats.Added > 0 {
- display += green(" +" + strconv.Itoa(int(fileStats.Added)))
- }
- if fileStats.Removed > 0 {
- display += red(" -" + strconv.Itoa(int(fileStats.Removed)))
- }
- content += "\n" + display
- }
- }
- }
-
- content = styles.NewStyle().
- Background(t.BackgroundPanel()).
- Width(width - 6).
- Render(content)
- content = renderContentBlock(
- m.app,
- content,
- width,
- WithBorderColor(t.BackgroundPanel()),
- )
- blocks = append(blocks, content)
- }
-
- if m.app.CurrentPermission.ID != "" &&
- m.app.CurrentPermission.SessionID != m.app.Session.ID {
- response, err := m.app.Client.Session.Message(
- context.Background(),
- m.app.CurrentPermission.SessionID,
- m.app.CurrentPermission.MessageID,
- opencode.SessionMessageParams{},
- )
- if err != nil || response == nil {
- slog.Error("Failed to get message from child session", "error", err)
- } else {
- for _, part := range response.Parts {
- if part.CallID == m.app.CurrentPermission.CallID {
- if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok {
- content := renderToolDetails(
- m.app,
- toolPart,
- m.app.CurrentPermission,
- width,
- )
- if content != "" {
- partCount++
- lineCount += lipgloss.Height(content) + 1
- blocks = append(blocks, content)
- }
- }
- }
- }
- }
- }
-
- final := []string{}
- clipboard := []string{}
- var selection *selection
- if m.selection != nil {
- selection = m.selection.coords(lipgloss.Height(header) + 1)
- }
- for _, block := range blocks {
- lines := strings.Split(block, "\n")
- for index, line := range lines {
- if selection == nil || index == 0 || index == len(lines)-1 {
- final = append(final, line)
- continue
- }
- y := len(final)
- if y >= selection.startY && y <= selection.endY {
- left := 3
- if y == selection.startY {
- left = selection.startX - 2
- }
- left = max(3, left)
-
- width := ansi.StringWidth(line)
- right := width - 1
- if y == selection.endY {
- right = min(selection.endX-2, right)
- }
-
- prefix := ansi.Cut(line, 0, left)
- middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
- suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width)
- clipboard = append(clipboard, middle)
- line = prefix + styles.NewStyle().
- Background(t.Accent()).
- Foreground(t.BackgroundPanel()).
- Render(ansi.Strip(middle)) +
- suffix
- }
- final = append(final, line)
- }
- y := len(final)
- if selection != nil && y >= selection.startY && y < selection.endY {
- clipboard = append(clipboard, "")
- }
- final = append(final, "")
- }
- content := "\n" + strings.Join(final, "\n")
- viewport.SetHeight(m.height - lipgloss.Height(header))
- viewport.SetContent(content)
- if tail {
- viewport.GotoBottom()
- }
-
- return renderCompleteMsg{
- header: header,
- clipboard: clipboard,
- viewport: viewport,
- partCount: partCount,
- lineCount: lineCount,
- messagePositions: messagePositions,
- }
- }
-}
-
-func (m *messagesComponent) renderHeader() string {
- if m.app.Session.ID == "" {
- return ""
- }
-
- headerWidth := m.width
-
- t := theme.CurrentTheme()
- bgColor := t.Background()
- borderColor := t.BackgroundElement()
-
- isChildSession := m.app.Session.ParentID != ""
- if isChildSession {
- bgColor = t.BackgroundElement()
- borderColor = t.Accent()
- }
-
- base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
- muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
-
- sessionInfo := ""
- tokens := float64(0)
- cost := float64(0)
- contextWindow := m.app.Model.Limit.Context
-
- for _, message := range m.app.Messages {
- if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
- cost += assistant.Cost
- usage := assistant.Tokens
- if usage.Output > 0 {
- if assistant.Summary {
- tokens = usage.Output
- continue
- }
- tokens = (usage.Input +
- usage.Cache.Read +
- usage.Cache.Write +
- usage.Output +
- usage.Reasoning)
- }
- }
- }
-
- // Check if current model is a subscription model (cost is 0 for both input and output)
- isSubscriptionModel := m.app.Model != nil &&
- m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
-
- sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
- sessionInfo = styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(bgColor).
- Render(sessionInfoText)
-
- shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
-
- navHint := ""
- if isChildSession {
- navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
- }
-
- headerTextWidth := headerWidth
- if isChildSession {
- headerTextWidth -= lipgloss.Width(navHint)
- } else if !shareEnabled {
- headerTextWidth -= lipgloss.Width(sessionInfoText)
- }
- headerText := util.ToMarkdown(
- "# "+m.app.Session.Title,
- headerTextWidth,
- bgColor,
- )
- if isChildSession {
- headerText = layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: headerTextWidth,
- },
- layout.FlexItem{
- View: headerText,
- },
- layout.FlexItem{
- View: navHint,
- },
- )
- }
-
- var items []layout.FlexItem
- if shareEnabled {
- share := base("/share") + muted(" to create a shareable link")
- if m.app.Session.Share.URL != "" {
- share = muted(m.app.Session.Share.URL + " /unshare")
- }
- items = []layout.FlexItem{{View: share}, {View: sessionInfo}}
- } else {
- items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
- }
-
- headerRow := layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: headerWidth - 6,
- },
- items...,
- )
-
- headerLines := []string{headerRow}
- if shareEnabled {
- headerLines = []string{headerText, headerRow}
- }
-
- header := strings.Join(headerLines, "\n")
- header = styles.NewStyle().
- Background(bgColor).
- Width(headerWidth).
- PaddingLeft(2).
- PaddingRight(2).
- BorderLeft(true).
- BorderRight(true).
- BorderBackground(t.Background()).
- BorderForeground(borderColor).
- BorderStyle(lipgloss.ThickBorder()).
- Render(header)
-
- return "\n" + header + "\n"
-}
-
-func formatTokensAndCost(
- tokens float64,
- contextWindow float64,
- cost float64,
- isSubscriptionModel bool,
-) string {
- // Format tokens in human-readable format (e.g., 110K, 1.2M)
- var formattedTokens string
- switch {
- case tokens >= 1_000_000:
- formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
- case tokens >= 1_000:
- formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
- default:
- formattedTokens = fmt.Sprintf("%d", int(tokens))
- }
-
- // Remove .0 suffix if present
- if strings.HasSuffix(formattedTokens, ".0K") {
- formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
- }
- if strings.HasSuffix(formattedTokens, ".0M") {
- formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
- }
-
- percentage := 0.0
- if contextWindow > 0 {
- percentage = (float64(tokens) / float64(contextWindow)) * 100
- }
-
- if isSubscriptionModel {
- return fmt.Sprintf(
- "%s/%d%%",
- formattedTokens,
- int(percentage),
- )
- }
-
- formattedCost := fmt.Sprintf("$%.2f", cost)
- return fmt.Sprintf(
- " %s/%d%% (%s)",
- formattedTokens,
- int(percentage),
- formattedCost,
- )
-}
-
-func (m *messagesComponent) View() string {
- t := theme.CurrentTheme()
- bgColor := t.Background()
-
- if m.loading {
- return lipgloss.Place(
- m.width,
- m.height,
- lipgloss.Center,
- lipgloss.Center,
- styles.NewStyle().Background(bgColor).Render(""),
- styles.WhitespaceStyle(bgColor),
- )
- }
-
- viewport := m.viewport.View()
- return styles.NewStyle().
- Background(bgColor).
- Render(m.header + "\n" + viewport)
-}
-
-func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
- m.viewport.ViewUp()
- return m, nil
-}
-
-func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
- m.viewport.ViewDown()
- return m, nil
-}
-
-func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
- m.viewport.HalfViewUp()
- return m, nil
-}
-
-func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
- m.viewport.HalfViewDown()
- return m, nil
-}
-
-func (m *messagesComponent) ToolDetailsVisible() bool {
- return m.showToolDetails
-}
-
-func (m *messagesComponent) ThinkingBlocksVisible() bool {
- return m.showThinkingBlocks
-}
-
-func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) {
- m.viewport.GotoTop()
- return m, nil
-}
-
-func (m *messagesComponent) GotoBottom() (tea.Model, tea.Cmd) {
- m.viewport.GotoBottom()
- return m, nil
-}
-
-func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
- if len(m.app.Messages) == 0 {
- return m, nil
- }
- lastMessage := m.app.Messages[len(m.app.Messages)-1]
- var lastTextPart *opencode.TextPart
- for _, part := range lastMessage.Parts {
- if p, ok := part.(opencode.TextPart); ok {
- lastTextPart = &p
- }
- }
- if lastTextPart == nil {
- return m, nil
- }
- var cmds []tea.Cmd
- cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
- cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
- return m, tea.Batch(cmds...)
-}
-
-func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) {
- after := float64(0)
- var revertedMessage app.Message
- reversedMessages := []app.Message{}
- for i := len(m.app.Messages) - 1; i >= 0; i-- {
- reversedMessages = append(reversedMessages, m.app.Messages[i])
- switch casted := m.app.Messages[i].Info.(type) {
- case opencode.UserMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- after = casted.Time.Created
- }
- case opencode.AssistantMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- after = casted.Time.Created
- }
- }
- if m.app.Session.Revert.PartID != "" {
- for _, part := range m.app.Messages[i].Parts {
- switch casted := part.(type) {
- case opencode.TextPart:
- if casted.ID == m.app.Session.Revert.PartID {
- after = casted.Time.Start
- }
- case opencode.ToolPart:
- // TODO: handle tool parts
- }
- }
- }
- }
-
- messageID := ""
- for _, msg := range reversedMessages {
- switch casted := msg.Info.(type) {
- case opencode.UserMessage:
- if after > 0 && casted.Time.Created >= after {
- continue
- }
- messageID = casted.ID
- revertedMessage = msg
- }
- if messageID != "" {
- break
- }
- }
-
- if messageID == "" {
- return m, nil
- }
-
- return m, func() tea.Msg {
- response, err := m.app.Client.Session.Revert(
- context.Background(),
- m.app.Session.ID,
- opencode.SessionRevertParams{
- MessageID: opencode.F(messageID),
- },
- )
- if err != nil {
- slog.Error("Failed to undo message", "error", err)
- return toast.NewErrorToast("Failed to undo message")()
- }
- if response == nil {
- return toast.NewErrorToast("Failed to undo message")()
- }
- return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
- }
-}
-
-func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
- // Check if there's a revert state to redo from
- if m.app.Session.Revert.MessageID == "" {
- return m, func() tea.Msg {
- return toast.NewErrorToast("Nothing to redo")
- }
- }
-
- before := float64(0)
- var revertedMessage app.Message
- for _, message := range m.app.Messages {
- switch casted := message.Info.(type) {
- case opencode.UserMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- before = casted.Time.Created
- }
- case opencode.AssistantMessage:
- if casted.ID == m.app.Session.Revert.MessageID {
- before = casted.Time.Created
- }
- }
- if m.app.Session.Revert.PartID != "" {
- for _, part := range message.Parts {
- switch casted := part.(type) {
- case opencode.TextPart:
- if casted.ID == m.app.Session.Revert.PartID {
- before = casted.Time.Start
- }
- case opencode.ToolPart:
- // TODO: handle tool parts
- }
- }
- }
- }
-
- messageID := ""
- for _, msg := range m.app.Messages {
- switch casted := msg.Info.(type) {
- case opencode.UserMessage:
- if casted.Time.Created <= before {
- continue
- }
- messageID = casted.ID
- revertedMessage = msg
- }
- if messageID != "" {
- break
- }
- }
-
- if messageID == "" {
- return m, func() tea.Msg {
- // unrevert back to original state
- response, err := m.app.Client.Session.Unrevert(
- context.Background(),
- m.app.Session.ID,
- opencode.SessionUnrevertParams{},
- )
- if err != nil {
- slog.Error("Failed to unrevert session", "error", err)
- return toast.NewErrorToast("Failed to redo message")()
- }
- if response == nil {
- return toast.NewErrorToast("Failed to redo message")()
- }
- return app.SessionUnrevertedMsg{Session: *response}
- }
- }
-
- return m, func() tea.Msg {
- // calling revert on a "later" message is like a redo
- response, err := m.app.Client.Session.Revert(
- context.Background(),
- m.app.Session.ID,
- opencode.SessionRevertParams{
- MessageID: opencode.F(messageID),
- },
- )
- if err != nil {
- slog.Error("Failed to redo message", "error", err)
- return toast.NewErrorToast("Failed to redo message")()
- }
- if response == nil {
- return toast.NewErrorToast("Failed to redo message")()
- }
- return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
- }
-}
-
-func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
- if m.messagePositions == nil {
- return m, nil
- }
-
- if position, exists := m.messagePositions[messageID]; exists {
- m.viewport.SetYOffset(position)
- m.tail = false // Stop auto-scrolling to bottom when manually navigating
- }
- return m, nil
-}
-
-func NewMessagesComponent(app *app.App) MessagesComponent {
- vp := viewport.New()
- vp.KeyMap = viewport.KeyMap{}
-
- if app.ScrollSpeed > 0 {
- vp.MouseWheelDelta = app.ScrollSpeed
- } else {
- vp.MouseWheelDelta = 2
- }
-
- // Default to showing tool details, hidden thinking blocks
- showToolDetails := true
- if app.State.ShowToolDetails != nil {
- showToolDetails = *app.State.ShowToolDetails
- }
-
- showThinkingBlocks := false
- if app.State.ShowThinkingBlocks != nil {
- showThinkingBlocks = *app.State.ShowThinkingBlocks
- }
-
- return &messagesComponent{
- app: app,
- viewport: vp,
- showToolDetails: showToolDetails,
- showThinkingBlocks: showThinkingBlocks,
- cache: NewPartCache(),
- tail: true,
- messagePositions: make(map[string]int),
- }
-}
diff --git a/packages/tui/internal/components/commands/commands.go b/packages/tui/internal/components/commands/commands.go
deleted file mode 100644
index fd578a41b..000000000
--- a/packages/tui/internal/components/commands/commands.go
+++ /dev/null
@@ -1,247 +0,0 @@
-package commands
-
-import (
- "fmt"
- "runtime"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type CommandsComponent interface {
- tea.ViewModel
- SetSize(width, height int) tea.Cmd
- SetBackgroundColor(color compat.AdaptiveColor)
-}
-
-type commandsComponent struct {
- app *app.App
- width, height int
- showKeybinds bool
- showAll bool
- showVscode bool
- background *compat.AdaptiveColor
- limit *int
-}
-
-func (c *commandsComponent) SetSize(width, height int) tea.Cmd {
- c.width = width
- c.height = height
- return nil
-}
-
-func (c *commandsComponent) SetBackgroundColor(color compat.AdaptiveColor) {
- c.background = &color
-}
-
-func (c *commandsComponent) View() string {
- t := theme.CurrentTheme()
-
- triggerStyle := styles.NewStyle().Foreground(t.Primary()).Bold(true)
- descriptionStyle := styles.NewStyle().Foreground(t.Text())
- keybindStyle := styles.NewStyle().Foreground(t.TextMuted())
-
- if c.background != nil {
- triggerStyle = triggerStyle.Background(*c.background)
- descriptionStyle = descriptionStyle.Background(*c.background)
- keybindStyle = keybindStyle.Background(*c.background)
- }
-
- var commandsToShow []commands.Command
- var triggeredCommands []commands.Command
- var untriggeredCommands []commands.Command
-
- for _, cmd := range c.app.Commands.Sorted() {
- if c.showAll || cmd.HasTrigger() {
- if cmd.HasTrigger() {
- triggeredCommands = append(triggeredCommands, cmd)
- } else if c.showAll {
- untriggeredCommands = append(untriggeredCommands, cmd)
- }
- }
- }
-
- // Combine triggered commands first, then untriggered
- commandsToShow = append(commandsToShow, triggeredCommands...)
- commandsToShow = append(commandsToShow, untriggeredCommands...)
-
- if c.limit != nil && len(commandsToShow) > *c.limit {
- commandsToShow = commandsToShow[:*c.limit]
- }
-
- if c.showVscode {
- ctrlKey := "ctrl"
- if runtime.GOOS == "darwin" {
- ctrlKey = "cmd"
- }
- commandsToShow = append(commandsToShow,
- // empty line
- // commands.Command{
- // Name: "",
- // Description: "",
- // },
- commands.Command{
- Name: commands.CommandName(util.Ide()),
- Description: "open opencode",
- Keybindings: []commands.Keybinding{
- {Key: ctrlKey + "+esc", RequiresLeader: false},
- },
- },
- commands.Command{
- Name: commands.CommandName(util.Ide()),
- Description: "reference file",
- Keybindings: []commands.Keybinding{
- {Key: ctrlKey + "+opt+k", RequiresLeader: false},
- },
- },
- )
- }
-
- if len(commandsToShow) == 0 {
- muted := styles.NewStyle().Foreground(theme.CurrentTheme().TextMuted())
- if c.showAll {
- return muted.Render("No commands available")
- }
- return muted.Render("No commands with triggers available")
- }
-
- // Calculate column widths
- maxTriggerWidth := 0
- maxDescriptionWidth := 0
- maxKeybindWidth := 0
-
- // Prepare command data
- type commandRow struct {
- trigger string
- description string
- keybinds string
- }
-
- rows := make([]commandRow, 0, len(commandsToShow))
-
- for _, cmd := range commandsToShow {
- trigger := ""
- if cmd.HasTrigger() {
- trigger = "/" + cmd.PrimaryTrigger()
- } else {
- trigger = string(cmd.Name)
- }
- description := cmd.Description
-
- // Format keybindings
- var keybindStrs []string
- if c.showKeybinds {
- for _, kb := range cmd.Keybindings {
- if kb.RequiresLeader {
- keybindStrs = append(keybindStrs, c.app.Config.Keybinds.Leader+" "+kb.Key)
- } else {
- keybindStrs = append(keybindStrs, kb.Key)
- }
- }
- }
- keybinds := strings.Join(keybindStrs, ", ")
-
- rows = append(rows, commandRow{
- trigger: trigger,
- description: description,
- keybinds: keybinds,
- })
-
- // Update max widths
- if len(trigger) > maxTriggerWidth {
- maxTriggerWidth = len(trigger)
- }
- if len(description) > maxDescriptionWidth {
- maxDescriptionWidth = len(description)
- }
- if len(keybinds) > maxKeybindWidth {
- maxKeybindWidth = len(keybinds)
- }
- }
-
- // Add padding between columns
- columnPadding := 3
-
- // Build the output
- var output strings.Builder
-
- maxWidth := 0
- for _, row := range rows {
- // Pad each column to align properly
- trigger := fmt.Sprintf("%-*s", maxTriggerWidth, row.trigger)
- description := fmt.Sprintf("%-*s", maxDescriptionWidth, row.description)
-
- // Apply styles and combine
- line := triggerStyle.Render(trigger) +
- triggerStyle.Render(strings.Repeat(" ", columnPadding)) +
- descriptionStyle.Render(description)
-
- if c.showKeybinds && row.keybinds != "" {
- line += keybindStyle.Render(strings.Repeat(" ", columnPadding)) +
- keybindStyle.Render(row.keybinds)
- }
-
- output.WriteString(line + "\n")
- maxWidth = max(maxWidth, lipgloss.Width(line))
- }
-
- // Remove trailing newline
- result := strings.TrimSuffix(output.String(), "\n")
- if c.background != nil {
- result = styles.NewStyle().Background(*c.background).Width(maxWidth).Render(result)
- }
-
- return result
-}
-
-type Option func(*commandsComponent)
-
-func WithKeybinds(show bool) Option {
- return func(c *commandsComponent) {
- c.showKeybinds = show
- }
-}
-
-func WithBackground(background compat.AdaptiveColor) Option {
- return func(c *commandsComponent) {
- c.background = &background
- }
-}
-
-func WithLimit(limit int) Option {
- return func(c *commandsComponent) {
- c.limit = &limit
- }
-}
-
-func WithShowAll(showAll bool) Option {
- return func(c *commandsComponent) {
- c.showAll = showAll
- }
-}
-
-func WithVscode(showVscode bool) Option {
- return func(c *commandsComponent) {
- c.showVscode = showVscode
- }
-}
-
-func New(app *app.App, opts ...Option) CommandsComponent {
- c := &commandsComponent{
- app: app,
- background: nil,
- showKeybinds: true,
- showAll: false,
- }
- for _, opt := range opts {
- opt(c)
- }
- return c
-}
diff --git a/packages/tui/internal/components/dialog/agents.go b/packages/tui/internal/components/dialog/agents.go
deleted file mode 100644
index c2cbd6450..000000000
--- a/packages/tui/internal/components/dialog/agents.go
+++ /dev/null
@@ -1,452 +0,0 @@
-package dialog
-
-import (
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-const (
- numVisibleAgents = 10
- minAgentDialogWidth = 40
- maxAgentDialogWidth = 60
- maxDescriptionLength = 60
- maxRecentAgents = 5
-)
-
-// AgentDialog interface for the agent selection dialog
-type AgentDialog interface {
- layout.Modal
-}
-
-type agentDialog struct {
- app *app.App
- allAgents []agentSelectItem
- width int
- height int
- modal *modal.Modal
- searchDialog *SearchDialog
- dialogWidth int
-}
-
-// agentSelectItem combines the visual improvements with code patterns
-type agentSelectItem struct {
- name string
- displayName string
- description string
- mode string // "primary", "subagent", "all"
- isCurrent bool
- agentIndex int
- agent opencode.Agent // Keep original agent for compatibility
-}
-
-func (a agentSelectItem) Render(
- selected bool,
- width int,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
- itemStyle := baseStyle.
- Background(t.BackgroundPanel()).
- Foreground(t.Text())
-
- if selected {
- // Use agent color for highlighting when selected (visual improvement)
- agentColor := util.GetAgentColor(a.agentIndex)
- itemStyle = itemStyle.Foreground(agentColor)
- }
-
- descStyle := baseStyle.
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel())
-
- // Calculate available width (accounting for padding and margins)
- availableWidth := width - 2 // Account for left padding
-
- agentName := a.displayName
-
- // Determine if agent is built-in or custom using the agent's builtIn field
- var displayText string
- if a.agent.BuiltIn {
- displayText = "(built-in)"
- } else {
- if a.description != "" {
- displayText = a.description
- } else {
- displayText = "(user)"
- }
- }
-
- separator := " - "
-
- // Calculate how much space we have for the description (visual improvement)
- nameAndSeparatorLength := len(agentName) + len(separator)
- descriptionMaxLength := availableWidth - nameAndSeparatorLength
-
- // Cap description length to the maximum allowed
- if descriptionMaxLength > maxDescriptionLength {
- descriptionMaxLength = maxDescriptionLength
- }
-
- // Truncate description if it's too long (visual improvement)
- if len(displayText) > descriptionMaxLength && descriptionMaxLength > 3 {
- displayText = displayText[:descriptionMaxLength-3] + "..."
- }
-
- namePart := itemStyle.Render(agentName)
- descPart := descStyle.Render(separator + displayText)
- combinedText := namePart + descPart
-
- return baseStyle.
- Background(t.BackgroundPanel()).
- PaddingLeft(1).
- Width(width).
- Render(combinedText)
-}
-
-func (a agentSelectItem) Selectable() bool {
- return true
-}
-
-type agentKeyMap struct {
- Enter key.Binding
- Escape key.Binding
-}
-
-var agentKeys = agentKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select agent"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-func (a *agentDialog) Init() tea.Cmd {
- a.setupAllAgents()
- return a.searchDialog.Init()
-}
-
-func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- a.width = msg.Width
- a.height = msg.Height
- a.searchDialog.SetWidth(a.dialogWidth)
- a.searchDialog.SetHeight(msg.Height)
-
- case SearchSelectionMsg:
- // Handle selection from search dialog
- if item, ok := msg.Item.(agentSelectItem); ok {
- if !item.isCurrent {
- // Switch to selected agent (using their better pattern)
- return a, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.AgentSelectedMsg{AgentName: item.name}),
- )
- }
- }
- return a, util.CmdHandler(modal.CloseModalMsg{})
- case SearchCancelledMsg:
- return a, util.CmdHandler(modal.CloseModalMsg{})
-
- case SearchRemoveItemMsg:
- if item, ok := msg.Item.(agentSelectItem); ok {
- if a.isAgentInRecentSection(item, msg.Index) {
- a.app.State.RemoveAgentFromRecentlyUsed(item.name)
- items := a.buildDisplayList(a.searchDialog.GetQuery())
- a.searchDialog.SetItems(items)
- return a, a.app.SaveState()
- }
- }
- return a, nil
-
- case SearchQueryChangedMsg:
- // Update the list based on search query
- items := a.buildDisplayList(msg.Query)
- a.searchDialog.SetItems(items)
- return a, nil
- }
-
- updatedDialog, cmd := a.searchDialog.Update(msg)
- a.searchDialog = updatedDialog.(*SearchDialog)
- return a, cmd
-}
-
-func (a *agentDialog) SetSize(width, height int) {
- a.width = width
- a.height = height
-}
-
-func (a *agentDialog) View() string {
- return a.searchDialog.View()
-}
-
-func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) int {
- maxWidth := minAgentDialogWidth
-
- for _, agent := range agents {
- // Calculate the width needed for this item: "AgentName - Description" (visual improvement)
- itemWidth := len(agent.displayName)
-
- if agent.agent.BuiltIn {
- itemWidth += len("(built-in)") + 3 // " - "
- } else {
- if agent.description != "" {
- descLength := len(agent.description)
- if descLength > maxDescriptionLength {
- descLength = maxDescriptionLength
- }
- itemWidth += descLength + 3 // " - "
- } else {
- itemWidth += len("(user)") + 3 // " - "
- }
- }
-
- if itemWidth > maxWidth {
- maxWidth = itemWidth
- }
- }
-
- maxWidth = min(maxWidth, maxAgentDialogWidth)
- return maxWidth
-}
-
-func (a *agentDialog) setupAllAgents() {
- currentAgentName := a.app.Agent().Name
-
- // Build agent items from app.Agents (no API call needed) - their pattern
- a.allAgents = make([]agentSelectItem, 0, len(a.app.Agents))
- for i, agent := range a.app.Agents {
- if agent.Mode == "subagent" {
- continue // Skip subagents entirely
- }
- isCurrent := agent.Name == currentAgentName
-
- // Create display name (capitalize first letter)
- displayName := strings.Title(agent.Name)
-
- a.allAgents = append(a.allAgents, agentSelectItem{
- name: agent.Name,
- displayName: displayName,
- description: agent.Description, // Keep for search but don't use in display
- mode: string(agent.Mode),
- isCurrent: isCurrent,
- agentIndex: i,
- agent: agent, // Keep original for compatibility
- })
- }
-
- a.sortAgents()
-
- // Calculate optimal width based on all agents (visual improvement)
- a.dialogWidth = a.calculateOptimalWidth(a.allAgents)
-
- // Ensure minimum width to prevent textinput issues
- a.dialogWidth = max(a.dialogWidth, minAgentDialogWidth)
-
- a.searchDialog = NewSearchDialog("Search agents...", numVisibleAgents)
- a.searchDialog.SetWidth(a.dialogWidth)
-
- // Build initial display list (empty query shows grouped view)
- items := a.buildDisplayList("")
- a.searchDialog.SetItems(items)
-}
-
-func (a *agentDialog) sortAgents() {
- sort.Slice(a.allAgents, func(i, j int) bool {
- agentA := a.allAgents[i]
- agentB := a.allAgents[j]
-
- // Current agent goes first (your preference)
- if agentA.name == a.app.Agent().Name {
- return true
- }
- if agentB.name == a.app.Agent().Name {
- return false
- }
-
- // Alphabetical order for all other agents
- return agentA.name < agentB.name
- })
-}
-
-// buildDisplayList creates the list items based on search query
-func (a *agentDialog) buildDisplayList(query string) []list.Item {
- if query != "" {
- // Search mode: use fuzzy matching
- return a.buildSearchResults(query)
- } else {
- // Grouped mode: show Recent agents section and alphabetical list (their pattern)
- return a.buildGroupedResults()
- }
-}
-
-// buildSearchResults creates a flat list of search results using fuzzy matching
-func (a *agentDialog) buildSearchResults(query string) []list.Item {
- agentNames := []string{}
- agentMap := make(map[string]agentSelectItem)
-
- for _, agent := range a.allAgents {
- // Only include non-subagents in search
- if agent.mode == "subagent" {
- continue
- }
- searchStr := agent.name
- agentNames = append(agentNames, searchStr)
- agentMap[searchStr] = agent
- }
-
- matches := fuzzy.RankFindFold(query, agentNames)
- sort.Sort(matches)
-
- items := []list.Item{}
- seenAgents := make(map[string]bool)
-
- for _, match := range matches {
- agent := agentMap[match.Target]
- // Create a unique key to avoid duplicates
- key := agent.name
- if seenAgents[key] {
- continue
- }
- seenAgents[key] = true
- items = append(items, agent)
- }
-
- return items
-}
-
-// buildGroupedResults creates a grouped list with Recent agents section and categorized agents
-func (a *agentDialog) buildGroupedResults() []list.Item {
- var items []list.Item
-
- // Add Recent section (their pattern)
- recentAgents := a.getRecentAgents(maxRecentAgents)
- if len(recentAgents) > 0 {
- items = append(items, list.HeaderItem("Recent"))
- for _, agent := range recentAgents {
- items = append(items, agent)
- }
- }
-
- // Create map of recent agent names for filtering
- recentAgentNames := make(map[string]bool)
- for _, recent := range recentAgents {
- recentAgentNames[recent.name] = true
- }
-
- // Only show non-subagents (primary/user) in the main section
- mainAgents := make([]agentSelectItem, 0)
- for _, agent := range a.allAgents {
- if !recentAgentNames[agent.name] {
- mainAgents = append(mainAgents, agent)
- }
- }
-
- // Sort main agents alphabetically
- sort.Slice(mainAgents, func(i, j int) bool {
- return mainAgents[i].name < mainAgents[j].name
- })
-
- // Add main agents section
- if len(mainAgents) > 0 {
- items = append(items, list.HeaderItem("Agents"))
- for _, agent := range mainAgents {
- items = append(items, agent)
- }
- }
-
- return items
-}
-
-func (a *agentDialog) Render(background string) string {
- return a.modal.Render(a.View(), background)
-}
-
-func (a *agentDialog) Close() tea.Cmd {
- return nil
-}
-
-// getRecentAgents returns the most recently used agents (their pattern)
-func (a *agentDialog) getRecentAgents(limit int) []agentSelectItem {
- var recentAgents []agentSelectItem
-
- // Get recent agents from app state
- for _, usage := range a.app.State.RecentlyUsedAgents {
- if len(recentAgents) >= limit {
- break
- }
-
- // Find the corresponding agent
- for _, agent := range a.allAgents {
- if agent.name == usage.AgentName {
- recentAgents = append(recentAgents, agent)
- break
- }
- }
- }
-
- // If no recent agents, use the current agent
- if len(recentAgents) == 0 {
- currentAgentName := a.app.Agent().Name
- for _, agent := range a.allAgents {
- if agent.name == currentAgentName {
- recentAgents = append(recentAgents, agent)
- break
- }
- }
- }
-
- return recentAgents
-}
-
-func (a *agentDialog) isAgentInRecentSection(agent agentSelectItem, index int) bool {
- // Only check if we're in grouped mode (no search query)
- if a.searchDialog.GetQuery() != "" {
- return false
- }
-
- recentAgents := a.getRecentAgents(maxRecentAgents)
- if len(recentAgents) == 0 {
- return false
- }
-
- // Index 0 is the "Recent" header, so recent agents are at indices 1 to len(recentAgents)
- if index >= 1 && index <= len(recentAgents) {
- if index-1 < len(recentAgents) {
- recentAgent := recentAgents[index-1]
- return recentAgent.name == agent.name
- }
- }
-
- return false
-}
-
-func NewAgentDialog(app *app.App) AgentDialog {
- dialog := &agentDialog{
- app: app,
- }
-
- dialog.setupAllAgents()
-
- dialog.modal = modal.New(
- modal.WithTitle("Select Agent"),
- modal.WithMaxWidth(dialog.dialogWidth+4),
- )
-
- return dialog
-}
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
deleted file mode 100644
index 4e890b081..000000000
--- a/packages/tui/internal/components/dialog/complete.go
+++ /dev/null
@@ -1,314 +0,0 @@
-package dialog
-
-import (
- "log/slog"
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textarea"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode/internal/completions"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type CompletionSelectedMsg struct {
- Item completions.CompletionSuggestion
- SearchString string
-}
-
-type CompletionDialogCompleteItemMsg struct {
- Value string
-}
-
-type CompletionDialogCloseMsg struct{}
-
-type CompletionDialog interface {
- tea.Model
- tea.ViewModel
- SetWidth(width int)
- IsEmpty() bool
-}
-
-type completionDialogComponent struct {
- query string
- providers []completions.CompletionProvider
- width int
- height int
- pseudoSearchTextArea textarea.Model
- list list.List[completions.CompletionSuggestion]
- trigger string
-}
-
-type completionDialogKeyMap struct {
- Complete key.Binding
- Cancel key.Binding
-}
-
-var completionDialogKeys = completionDialogKeyMap{
- Complete: key.NewBinding(
- key.WithKeys("tab", "enter", "right"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("space", " ", "esc", "backspace", "ctrl+h", "ctrl+c"),
- ),
-}
-
-func (c *completionDialogComponent) Init() tea.Cmd {
- return nil
-}
-
-func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
- return func() tea.Msg {
- // Collect results from all providers and preserve provider order
- type providerItems struct {
- idx int
- items []completions.CompletionSuggestion
- }
-
- itemsByProvider := make([]providerItems, 0, len(c.providers))
- providersWithResults := 0
-
- for idx, provider := range c.providers {
- items, err := provider.GetChildEntries(query)
- if err != nil {
- slog.Error(
- "Failed to get completion items",
- "provider",
- provider.GetId(),
- "error",
- err,
- )
- continue
- }
- if len(items) > 0 {
- providersWithResults++
- itemsByProvider = append(itemsByProvider, providerItems{idx: idx, items: items})
- }
- }
-
- // If there's a query, fuzzy-rank within each provider, then concatenate by provider order
- if query != "" && providersWithResults > 1 {
- t := theme.CurrentTheme()
- baseStyle := styles.NewStyle().Background(t.BackgroundElement())
-
- // Ensure stable provider order just in case
- sort.SliceStable(
- itemsByProvider,
- func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx },
- )
-
- final := make([]completions.CompletionSuggestion, 0)
- for _, entry := range itemsByProvider {
- // Build display values for fuzzy matching within this provider
- displayValues := make([]string, len(entry.items))
- for i, item := range entry.items {
- displayValues[i] = item.Display(baseStyle)
- }
-
- matches := fuzzy.RankFindFold(query, displayValues)
- sort.Sort(matches)
-
- // Reorder items for this provider based on fuzzy ranking
- ranked := make([]completions.CompletionSuggestion, 0, len(matches))
- for _, m := range matches {
- ranked = append(ranked, entry.items[m.OriginalIndex])
- }
- final = append(final, ranked...)
- }
-
- return final
- }
-
- // No query or no results: just concatenate in provider order
- all := make([]completions.CompletionSuggestion, 0)
- for _, entry := range itemsByProvider {
- all = append(all, entry.items...)
- }
- return all
- }
-}
-func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case []completions.CompletionSuggestion:
- c.list.SetItems(msg)
- case tea.KeyMsg:
- if c.pseudoSearchTextArea.Focused() {
- if !key.Matches(msg, completionDialogKeys.Complete) {
- var cmd tea.Cmd
- c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
- cmds = append(cmds, cmd)
-
- fullValue := c.pseudoSearchTextArea.Value()
- query := strings.TrimPrefix(fullValue, c.trigger)
-
- if query != c.query {
- c.query = query
- cmds = append(cmds, c.getAllCompletions(query))
- }
-
- u, cmd := c.list.Update(msg)
- c.list = u.(list.List[completions.CompletionSuggestion])
- cmds = append(cmds, cmd)
- }
-
- switch {
- case key.Matches(msg, completionDialogKeys.Complete):
- item, i := c.list.GetSelectedItem()
- if i == -1 {
- return c, nil
- }
- return c, c.complete(item)
- case key.Matches(msg, completionDialogKeys.Cancel):
- value := c.pseudoSearchTextArea.Value()
- width := lipgloss.Width(value)
- triggerWidth := lipgloss.Width(c.trigger)
-
- if msg.String() == "space" || msg.String() == " " {
- item, i := c.list.GetSelectedItem()
- if i > -1 {
- return c, c.complete(item)
- }
- // If no exact match, close the dialog
- return c, c.close()
- }
-
- // Only close on backspace when there are no characters left, unless we're back to just the trigger
- if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
- return c, c.close()
- }
- }
-
- return c, tea.Batch(cmds...)
- } else {
- cmds = append(cmds, c.getAllCompletions(""))
- cmds = append(cmds, c.pseudoSearchTextArea.Focus())
- return c, tea.Batch(cmds...)
- }
- }
-
- return c, tea.Batch(cmds...)
-}
-
-func (c *completionDialogComponent) View() string {
- t := theme.CurrentTheme()
- c.list.SetMaxWidth(c.width)
-
- return styles.NewStyle().
- Padding(0, 1).
- Foreground(t.Text()).
- Background(t.BackgroundElement()).
- BorderStyle(lipgloss.ThickBorder()).
- BorderLeft(true).
- BorderRight(true).
- BorderForeground(t.Border()).
- BorderBackground(t.Background()).
- Width(c.width).
- Render(c.list.View())
-}
-
-func (c *completionDialogComponent) SetWidth(width int) {
- c.width = width
-}
-
-func (c *completionDialogComponent) IsEmpty() bool {
- return c.list.IsEmpty()
-}
-
-func (c *completionDialogComponent) complete(item completions.CompletionSuggestion) tea.Cmd {
- value := c.pseudoSearchTextArea.Value()
- return tea.Batch(
- util.CmdHandler(CompletionSelectedMsg{
- SearchString: value,
- Item: item,
- }),
- c.close(),
- )
-}
-
-func (c *completionDialogComponent) close() tea.Cmd {
- c.pseudoSearchTextArea.Reset()
- c.pseudoSearchTextArea.Blur()
- return util.CmdHandler(CompletionDialogCloseMsg{})
-}
-
-func NewCompletionDialogComponent(
- trigger string,
- providers ...completions.CompletionProvider,
-) CompletionDialog {
- ti := textarea.New()
- ti.SetValue(trigger)
-
- // Use a generic empty message if we have multiple providers
- emptyMessage := "no matching items"
- if len(providers) == 1 {
- emptyMessage = providers[0].GetEmptyMessage()
- }
-
- // Define render function for completion suggestions
- renderFunc := func(item completions.CompletionSuggestion, selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
- style := baseStyle
-
- if selected {
- style = style.Background(t.BackgroundElement()).Foreground(t.Primary())
- } else {
- style = style.Background(t.BackgroundElement()).Foreground(t.Text())
- }
-
- // The item.Display string already has any inline colors from the provider
- truncatedStr := truncate.String(item.Display(style), uint(width-4))
- return style.Width(width - 4).Render(truncatedStr)
- }
-
- // Define selectable function - all completion suggestions are selectable
- selectableFunc := func(item completions.CompletionSuggestion) bool {
- return true
- }
-
- li := list.NewListComponent(
- list.WithItems([]completions.CompletionSuggestion{}),
- list.WithMaxVisibleHeight[completions.CompletionSuggestion](7),
- list.WithFallbackMessage[completions.CompletionSuggestion](emptyMessage),
- list.WithAlphaNumericKeys[completions.CompletionSuggestion](false),
- list.WithRenderFunc(renderFunc),
- list.WithSelectableFunc(selectableFunc),
- )
-
- c := &completionDialogComponent{
- query: "",
- providers: providers,
- pseudoSearchTextArea: ti,
- list: li,
- trigger: trigger,
- }
-
- // Load initial items from all providers
- go func() {
- allItems := make([]completions.CompletionSuggestion, 0)
- for _, provider := range providers {
- items, err := provider.GetChildEntries("")
- if err != nil {
- slog.Error(
- "Failed to get completion items",
- "provider",
- provider.GetId(),
- "error",
- err,
- )
- continue
- }
- allItems = append(allItems, items...)
- }
- li.SetItems(allItems)
- }()
-
- return c
-}
diff --git a/packages/tui/internal/components/dialog/help.go b/packages/tui/internal/components/dialog/help.go
deleted file mode 100644
index 15931724b..000000000
--- a/packages/tui/internal/components/dialog/help.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package dialog
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode/internal/app"
- commandsComponent "github.com/sst/opencode/internal/components/commands"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/viewport"
-)
-
-type helpDialog struct {
- width int
- height int
- modal *modal.Modal
- app *app.App
- commandsComponent commandsComponent.CommandsComponent
- viewport viewport.Model
-}
-
-func (h *helpDialog) Init() tea.Cmd {
- return h.viewport.Init()
-}
-
-func (h *helpDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- h.width = msg.Width
- h.height = msg.Height
- // Set viewport size with some padding for the modal, but cap at reasonable width
- maxWidth := min(80, msg.Width-8)
- h.viewport = viewport.New(viewport.WithWidth(maxWidth-4), viewport.WithHeight(msg.Height-6))
- h.commandsComponent.SetSize(maxWidth-4, msg.Height-6)
- }
-
- // Update viewport content
- h.viewport.SetContent(h.commandsComponent.View())
-
- // Update viewport
- var vpCmd tea.Cmd
- h.viewport, vpCmd = h.viewport.Update(msg)
- cmds = append(cmds, vpCmd)
-
- return h, tea.Batch(cmds...)
-}
-
-func (h *helpDialog) View() string {
- t := theme.CurrentTheme()
- h.commandsComponent.SetBackgroundColor(t.BackgroundPanel())
- return h.viewport.View()
-}
-
-func (h *helpDialog) Render(background string) string {
- return h.modal.Render(h.View(), background)
-}
-
-func (h *helpDialog) Close() tea.Cmd {
- return nil
-}
-
-type HelpDialog interface {
- layout.Modal
-}
-
-func NewHelpDialog(app *app.App) HelpDialog {
- vp := viewport.New(viewport.WithHeight(12))
- return &helpDialog{
- app: app,
- commandsComponent: commandsComponent.New(app,
- commandsComponent.WithBackground(theme.CurrentTheme().BackgroundPanel()),
- commandsComponent.WithShowAll(true),
- commandsComponent.WithKeybinds(true),
- ),
- modal: modal.New(modal.WithTitle("Help"), modal.WithMaxWidth(80)),
- viewport: vp,
- }
-}
diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go
deleted file mode 100644
index e30a1068e..000000000
--- a/packages/tui/internal/components/dialog/models.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package dialog
-
-import (
- "context"
- "fmt"
- "sort"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/lithammer/fuzzysearch/fuzzy"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-const (
- numVisibleModels = 10
- minDialogWidth = 40
- maxDialogWidth = 80
- maxRecentModels = 5
-)
-
-// ModelDialog interface for the model selection dialog
-type ModelDialog interface {
- layout.Modal
-}
-
-type modelDialog struct {
- app *app.App
- allModels []ModelWithProvider
- width int
- height int
- modal *modal.Modal
- searchDialog *SearchDialog
- dialogWidth int
-}
-
-type ModelWithProvider struct {
- Model opencode.Model
- Provider opencode.Provider
-}
-
-// modelItem is a custom list item for model selections
-type modelItem struct {
- model ModelWithProvider
-}
-
-func (m modelItem) Render(
- selected bool,
- width int,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
-
- itemStyle := baseStyle.
- Background(t.BackgroundPanel()).
- Foreground(t.Text())
-
- if selected {
- itemStyle = itemStyle.Foreground(t.Primary())
- }
-
- providerStyle := baseStyle.
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel())
-
- modelPart := itemStyle.Render(m.model.Model.Name)
- providerPart := providerStyle.Render(fmt.Sprintf(" %s", m.model.Provider.Name))
-
- combinedText := modelPart + providerPart
- return baseStyle.
- Background(t.BackgroundPanel()).
- PaddingLeft(1).
- Render(combinedText)
-}
-
-func (m modelItem) Selectable() bool {
- return true
-}
-
-type modelKeyMap struct {
- Enter key.Binding
- Escape key.Binding
-}
-
-var modelKeys = modelKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select model"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-func (m *modelDialog) Init() tea.Cmd {
- m.setupAllModels()
- return m.searchDialog.Init()
-}
-
-func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case SearchSelectionMsg:
- // Handle selection from search dialog
- if item, ok := msg.Item.(modelItem); ok {
- return m, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(
- app.ModelSelectedMsg{
- Provider: item.model.Provider,
- Model: item.model.Model,
- }),
- )
- }
- return m, util.CmdHandler(modal.CloseModalMsg{})
- case SearchCancelledMsg:
- return m, util.CmdHandler(modal.CloseModalMsg{})
-
- case SearchRemoveItemMsg:
- if item, ok := msg.Item.(modelItem); ok {
- if m.isModelInRecentSection(item.model, msg.Index) {
- m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
- items := m.buildDisplayList(m.searchDialog.GetQuery())
- m.searchDialog.SetItems(items)
- return m, m.app.SaveState()
- }
- }
- return m, nil
-
- case SearchQueryChangedMsg:
- // Update the list based on search query
- items := m.buildDisplayList(msg.Query)
- m.searchDialog.SetItems(items)
- return m, nil
-
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- m.searchDialog.SetWidth(m.dialogWidth)
- m.searchDialog.SetHeight(msg.Height)
- }
-
- updatedDialog, cmd := m.searchDialog.Update(msg)
- m.searchDialog = updatedDialog.(*SearchDialog)
- return m, cmd
-}
-
-func (m *modelDialog) View() string {
- return m.searchDialog.View()
-}
-
-func (m *modelDialog) calculateOptimalWidth(models []ModelWithProvider) int {
- maxWidth := minDialogWidth
-
- for _, model := range models {
- // Calculate the width needed for this item: "ModelName (ProviderName)"
- // Add 4 for the parentheses, space, and some padding
- itemWidth := len(model.Model.Name) + len(model.Provider.Name) + 4
- if itemWidth > maxWidth {
- maxWidth = itemWidth
- }
- }
-
- if maxWidth > maxDialogWidth {
- maxWidth = maxDialogWidth
- }
-
- return maxWidth
-}
-
-func (m *modelDialog) setupAllModels() {
- providers, _ := m.app.ListProviders(context.Background())
-
- m.allModels = make([]ModelWithProvider, 0)
- for _, provider := range providers {
- for _, model := range provider.Models {
- m.allModels = append(m.allModels, ModelWithProvider{
- Model: model,
- Provider: provider,
- })
- }
- }
-
- m.sortModels()
-
- // Calculate optimal width based on all models
- m.dialogWidth = m.calculateOptimalWidth(m.allModels)
-
- // Initialize search dialog
- m.searchDialog = NewSearchDialog("Search models...", numVisibleModels)
- m.searchDialog.SetWidth(m.dialogWidth)
-
- // Build initial display list (empty query shows grouped view)
- items := m.buildDisplayList("")
- m.searchDialog.SetItems(items)
-}
-
-func (m *modelDialog) sortModels() {
- sort.Slice(m.allModels, func(i, j int) bool {
- modelA := m.allModels[i]
- modelB := m.allModels[j]
-
- usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
- usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
-
- // If both have usage times, sort by most recent first
- if !usageA.IsZero() && !usageB.IsZero() {
- return usageA.After(usageB)
- }
-
- // If only one has usage time, it goes first
- if !usageA.IsZero() && usageB.IsZero() {
- return true
- }
- if usageA.IsZero() && !usageB.IsZero() {
- return false
- }
-
- // If neither has usage time, sort by release date desc if available
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
- dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
- dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
- if !dateA.IsZero() && !dateB.IsZero() {
- return dateA.After(dateB)
- }
- }
-
- // If only one has release date, it goes first
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
- return true
- }
- if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
- return false
- }
-
- // If neither has usage time nor release date, fall back to alphabetical sorting
- return modelA.Model.Name < modelB.Model.Name
- })
-}
-
-func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
- if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
- return parsed
- }
-
- return time.Time{}
-}
-
-func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
- for _, usage := range m.app.State.RecentlyUsedModels {
- if usage.ProviderID == providerID && usage.ModelID == modelID {
- return usage.LastUsed
- }
- }
- return time.Time{}
-}
-
-// buildDisplayList creates the list items based on search query
-func (m *modelDialog) buildDisplayList(query string) []list.Item {
- if query != "" {
- // Search mode: use fuzzy matching
- return m.buildSearchResults(query)
- } else {
- // Grouped mode: show Recent section and provider groups
- return m.buildGroupedResults()
- }
-}
-
-// buildSearchResults creates a flat list of search results using fuzzy matching
-func (m *modelDialog) buildSearchResults(query string) []list.Item {
- type modelMatch struct {
- model ModelWithProvider
- score int
- }
-
- modelNames := []string{}
- modelMap := make(map[string]ModelWithProvider)
-
- // Create search strings and perform fuzzy matching
- for _, model := range m.allModels {
- searchStr := fmt.Sprintf("%s %s", model.Model.Name, model.Provider.Name)
- modelNames = append(modelNames, searchStr)
- modelMap[searchStr] = model
-
- searchStr = fmt.Sprintf("%s %s", model.Provider.Name, model.Model.Name)
- modelNames = append(modelNames, searchStr)
- modelMap[searchStr] = model
- }
-
- matches := fuzzy.RankFindFold(query, modelNames)
- sort.Sort(matches)
-
- items := []list.Item{}
- seenModels := make(map[string]bool)
-
- for _, match := range matches {
- model := modelMap[match.Target]
- // Create a unique key to avoid duplicates
- // Include name to handle custom models with same ID but different names
- key := fmt.Sprintf("%s:%s:%s", model.Provider.ID, model.Model.ID, model.Model.Name)
- if seenModels[key] {
- continue
- }
- seenModels[key] = true
- items = append(items, modelItem{model: model})
- }
-
- return items
-}
-
-// buildGroupedResults creates a grouped list with Recent section and provider groups
-func (m *modelDialog) buildGroupedResults() []list.Item {
- var items []list.Item
-
- // Add Recent section
- recentModels := m.getRecentModels(maxRecentModels)
- if len(recentModels) > 0 {
- items = append(items, list.HeaderItem("Recent"))
- for _, model := range recentModels {
- items = append(items, modelItem{model: model})
- }
- }
-
- // Group models by provider
- providerGroups := make(map[string][]ModelWithProvider)
- for _, model := range m.allModels {
- providerName := model.Provider.Name
- providerGroups[providerName] = append(providerGroups[providerName], model)
- }
-
- // Get sorted provider names for consistent order
- var providerNames []string
- for name := range providerGroups {
- providerNames = append(providerNames, name)
- }
- sort.Strings(providerNames)
-
- // Add provider groups
- for _, providerName := range providerNames {
- models := providerGroups[providerName]
-
- // Sort models within provider group
- sort.Slice(models, func(i, j int) bool {
- modelA := models[i]
- modelB := models[j]
-
- usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
- usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
-
- // Sort by usage time first, then by release date, then alphabetically
- if !usageA.IsZero() && !usageB.IsZero() {
- return usageA.After(usageB)
- }
- if !usageA.IsZero() && usageB.IsZero() {
- return true
- }
- if usageA.IsZero() && !usageB.IsZero() {
- return false
- }
-
- // Sort by release date if available
- if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
- dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
- dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
- if !dateA.IsZero() && !dateB.IsZero() {
- return dateA.After(dateB)
- }
- }
-
- return modelA.Model.Name < modelB.Model.Name
- })
-
- // Add provider header
- items = append(items, list.HeaderItem(providerName))
-
- // Add models in this provider group
- for _, model := range models {
- items = append(items, modelItem{model: model})
- }
- }
-
- return items
-}
-
-// getRecentModels returns the most recently used models
-func (m *modelDialog) getRecentModels(limit int) []ModelWithProvider {
- var recentModels []ModelWithProvider
-
- // Get recent models from app state
- for _, usage := range m.app.State.RecentlyUsedModels {
- if len(recentModels) >= limit {
- break
- }
-
- // Find the corresponding model
- for _, model := range m.allModels {
- if model.Provider.ID == usage.ProviderID && model.Model.ID == usage.ModelID {
- recentModels = append(recentModels, model)
- break
- }
- }
- }
-
- return recentModels
-}
-
-func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int) bool {
- // Only check if we're in grouped mode (no search query)
- if m.searchDialog.GetQuery() != "" {
- return false
- }
-
- recentModels := m.getRecentModels(maxRecentModels)
- if len(recentModels) == 0 {
- return false
- }
-
- // Index 0 is the "Recent" header, so recent models are at indices 1 to len(recentModels)
- if index >= 1 && index <= len(recentModels) {
- if index-1 < len(recentModels) {
- recentModel := recentModels[index-1]
- return recentModel.Provider.ID == model.Provider.ID &&
- recentModel.Model.ID == model.Model.ID
- }
- }
-
- return false
-}
-
-func (m *modelDialog) Render(background string) string {
- return m.modal.Render(m.View(), background)
-}
-
-func (s *modelDialog) Close() tea.Cmd {
- return nil
-}
-
-func NewModelDialog(app *app.App) ModelDialog {
- dialog := &modelDialog{
- app: app,
- }
-
- dialog.setupAllModels()
-
- dialog.modal = modal.New(
- modal.WithTitle("Select Model"),
- modal.WithMaxWidth(dialog.dialogWidth+4),
- )
-
- return dialog
-}
diff --git a/packages/tui/internal/components/dialog/search.go b/packages/tui/internal/components/dialog/search.go
deleted file mode 100644
index b8fefd8b9..000000000
--- a/packages/tui/internal/components/dialog/search.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textinput"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// SearchQueryChangedMsg is emitted when the search query changes
-type SearchQueryChangedMsg struct {
- Query string
-}
-
-// SearchSelectionMsg is emitted when an item is selected
-type SearchSelectionMsg struct {
- Item any
- Index int
-}
-
-// SearchCancelledMsg is emitted when the search is cancelled
-type SearchCancelledMsg struct{}
-
-// SearchRemoveItemMsg is emitted when Ctrl+X is pressed to remove an item
-type SearchRemoveItemMsg struct {
- Item any
- Index int
-}
-
-// SearchDialog is a reusable component that combines a text input with a list
-type SearchDialog struct {
- textInput textinput.Model
- list list.List[list.Item]
- width int
- height int
- focused bool
-}
-
-type searchKeyMap struct {
- Up key.Binding
- Down key.Binding
- Enter key.Binding
- Escape key.Binding
- Remove key.Binding
-}
-
-var searchKeys = searchKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("↑", "previous item"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("↓", "next item"),
- ),
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
- Remove: key.NewBinding(
- key.WithKeys("ctrl+x"),
- key.WithHelp("ctrl+x", "remove from recent"),
- ),
-}
-
-// NewSearchDialog creates a new SearchDialog
-func NewSearchDialog(placeholder string, maxVisibleHeight int) *SearchDialog {
- t := theme.CurrentTheme()
- bgColor := t.BackgroundElement()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- ti := textinput.New()
- ti.Placeholder = placeholder
- ti.Styles.Blurred.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- ti.Styles.Blurred.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- ti.Styles.Focused.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- ti.Styles.Focused.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- ti.Styles.Focused.Prompt = styles.NewStyle().
- Background(bgColor).
- Lipgloss()
- ti.Styles.Cursor.Color = t.Primary()
- ti.VirtualCursor = true
-
- ti.Prompt = " "
- ti.CharLimit = -1
- ti.Focus()
-
- emptyList := list.NewListComponent(
- list.WithItems([]list.Item{}),
- list.WithMaxVisibleHeight[list.Item](maxVisibleHeight),
- list.WithFallbackMessage[list.Item](" No items"),
- list.WithAlphaNumericKeys[list.Item](false),
- list.WithRenderFunc(
- func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, baseStyle)
- },
- ),
- list.WithSelectableFunc(func(item list.Item) bool {
- return item.Selectable()
- }),
- )
-
- return &SearchDialog{
- textInput: ti,
- list: emptyList,
- focused: true,
- }
-}
-
-func (s *SearchDialog) Init() tea.Cmd {
- return textinput.Blink
-}
-
-func (s *SearchDialog) updateTextInput(msg tea.Msg) []tea.Cmd {
- var cmds []tea.Cmd
- oldValue := s.textInput.Value()
- var cmd tea.Cmd
- s.textInput, cmd = s.textInput.Update(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- if newValue := s.textInput.Value(); newValue != oldValue {
- cmds = append(cmds, func() tea.Msg {
- return SearchQueryChangedMsg{Query: newValue}
- })
- }
- return cmds
-}
-
-func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.PasteMsg, tea.ClipboardMsg:
- cmds = append(cmds, s.updateTextInput(msg)...)
- case tea.KeyMsg:
- switch msg.String() {
- case "ctrl+c":
- value := s.textInput.Value()
- if value == "" {
- return s, nil
- }
- s.textInput.Reset()
- cmds = append(cmds, func() tea.Msg {
- return SearchQueryChangedMsg{Query: ""}
- })
- }
-
- switch {
- case key.Matches(msg, searchKeys.Escape):
- return s, func() tea.Msg { return SearchCancelledMsg{} }
-
- case key.Matches(msg, searchKeys.Enter):
- if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
- return s, func() tea.Msg {
- return SearchSelectionMsg{Item: selectedItem, Index: idx}
- }
- }
-
- case key.Matches(msg, searchKeys.Remove):
- if selectedItem, idx := s.list.GetSelectedItem(); idx != -1 {
- return s, func() tea.Msg {
- return SearchRemoveItemMsg{Item: selectedItem, Index: idx}
- }
- }
-
- case key.Matches(msg, searchKeys.Up):
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[list.Item])
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- case key.Matches(msg, searchKeys.Down):
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[list.Item])
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- default:
- cmds = append(cmds, s.updateTextInput(msg)...)
- }
- }
-
- return s, tea.Batch(cmds...)
-}
-
-func (s *SearchDialog) View() string {
- s.list.SetMaxWidth(s.width)
- listView := s.list.View()
- listView = lipgloss.PlaceVertical(s.list.GetMaxVisibleHeight(), lipgloss.Top, listView)
- textinput := s.textInput.View()
- return textinput + "\n\n" + listView
-}
-
-// SetWidth sets the width of the search dialog
-func (s *SearchDialog) SetWidth(width int) {
- s.width = width
- s.textInput.SetWidth(width - 2) // Account for padding and borders
-}
-
-// SetHeight sets the height of the search dialog
-func (s *SearchDialog) SetHeight(height int) {
- s.height = height
-}
-
-// SetItems updates the list items
-func (s *SearchDialog) SetItems(items []list.Item) {
- s.list.SetItems(items)
-}
-
-// GetQuery returns the current search query
-func (s *SearchDialog) GetQuery() string {
- return s.textInput.Value()
-}
-
-// SetQuery sets the search query
-func (s *SearchDialog) SetQuery(query string) {
- s.textInput.SetValue(query)
-}
-
-// Focus focuses the search dialog
-func (s *SearchDialog) Focus() {
- s.focused = true
- s.textInput.Focus()
-}
-
-// Blur removes focus from the search dialog
-func (s *SearchDialog) Blur() {
- s.focused = false
- s.textInput.Blur()
-}
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
deleted file mode 100644
index a1700c896..000000000
--- a/packages/tui/internal/components/dialog/session.go
+++ /dev/null
@@ -1,400 +0,0 @@
-package dialog
-
-import (
- "context"
- "strings"
-
- "slices"
-
- "github.com/charmbracelet/bubbles/v2/textinput"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// SessionDialog interface for the session switching dialog
-type SessionDialog interface {
- layout.Modal
-}
-
-// sessionItem is a custom list item for sessions that can show delete confirmation
-type sessionItem struct {
- title string
- isDeleteConfirming bool
- isCurrentSession bool
-}
-
-func (s sessionItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
-) string {
- t := theme.CurrentTheme()
-
- var text string
- if s.isDeleteConfirming {
- text = "Press again to confirm delete"
- } else {
- if s.isCurrentSession {
- text = "● " + s.title
- } else {
- text = s.title
- }
- }
-
- truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
-
- var itemStyle styles.Style
- if selected {
- if s.isDeleteConfirming {
- // Red background for delete confirmation
- itemStyle = baseStyle.
- Background(t.Error()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else if s.isCurrentSession {
- // Different style for current session when selected
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1).
- Bold(true)
- } else {
- // Normal selection
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- }
- } else {
- if s.isDeleteConfirming {
- // Red text for delete confirmation when not selected
- itemStyle = baseStyle.
- Foreground(t.Error()).
- PaddingLeft(1)
- } else if s.isCurrentSession {
- // Highlight current session when not selected
- itemStyle = baseStyle.
- Foreground(t.Primary()).
- PaddingLeft(1).
- Bold(true)
- } else {
- itemStyle = baseStyle.
- PaddingLeft(1)
- }
- }
-
- return itemStyle.Render(truncatedStr)
-}
-
-func (s sessionItem) Selectable() bool {
- return true
-}
-
-type sessionDialog struct {
- width int
- height int
- modal *modal.Modal
- sessions []opencode.Session
- list list.List[sessionItem]
- app *app.App
- deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
- renameMode bool
- renameInput textinput.Model
- renameIndex int // index of session being renamed
-}
-
-func (s *sessionDialog) Init() tea.Cmd {
- return nil
-}
-
-func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- s.width = msg.Width
- s.height = msg.Height
- s.list.SetMaxWidth(layout.Current.Container.Width - 12)
- case tea.KeyPressMsg:
- if s.renameMode {
- switch msg.String() {
- case "enter":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex {
- newTitle := s.renameInput.Value()
- if strings.TrimSpace(newTitle) != "" {
- sessionToUpdate := s.sessions[idx]
- return s, tea.Sequence(
- func() tea.Msg {
- ctx := context.Background()
- err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle)
- if err != nil {
- return toast.NewErrorToast("Failed to rename session: " + err.Error())()
- }
- s.sessions[idx].Title = newTitle
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
- return toast.NewSuccessToast("Session renamed successfully")()
- },
- )
- }
- }
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
- return s, nil
- default:
- var cmd tea.Cmd
- s.renameInput, cmd = s.renameInput.Update(msg)
- return s, cmd
- }
- } else {
- switch msg.String() {
- case "enter":
- if s.deleteConfirmation >= 0 {
- s.deleteConfirmation = -1
- s.updateListItems()
- return s, nil
- }
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- selectedSession := s.sessions[idx]
- return s, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
- )
- }
- case "n":
- return s, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(app.SessionClearedMsg{}),
- )
- case "r":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- s.renameMode = true
- s.renameIndex = idx
- s.setupRenameInput(s.sessions[idx].Title)
- s.modal.SetTitle("Rename Session")
- s.updateListItems()
- return s, textinput.Blink
- }
- case "x", "delete", "backspace":
- if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
- if s.deleteConfirmation == idx {
- // Second press - actually delete the session
- sessionToDelete := s.sessions[idx]
- return s, tea.Sequence(
- func() tea.Msg {
- s.sessions = slices.Delete(s.sessions, idx, idx+1)
- s.deleteConfirmation = -1
- s.updateListItems()
- return nil
- },
- s.deleteSession(sessionToDelete.ID),
- )
- } else {
- // First press - enter delete confirmation mode
- s.deleteConfirmation = idx
- s.updateListItems()
- return s, nil
- }
- }
- case "esc":
- if s.deleteConfirmation >= 0 {
- s.deleteConfirmation = -1
- s.updateListItems()
- return s, nil
- }
- }
- }
- }
-
- if !s.renameMode {
- var cmd tea.Cmd
- listModel, cmd := s.list.Update(msg)
- s.list = listModel.(list.List[sessionItem])
- return s, cmd
- }
- return s, nil
-}
-
-func (s *sessionDialog) Render(background string) string {
- if s.renameMode {
- // Show rename input instead of list
- t := theme.CurrentTheme()
- renameView := s.renameInput.View()
-
- mutedStyle := styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel()).
- Render
- helpText := mutedStyle("Enter to confirm, Esc to cancel")
- helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
-
- content := strings.Join([]string{renameView, helpText}, "\n")
- return s.modal.Render(content, background)
- }
-
- listView := s.list.View()
-
- t := theme.CurrentTheme()
- keyStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundPanel()).
- Bold(true).
- Render
- mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
-
- leftHelp := keyStyle("n") + mutedStyle(" new ") + keyStyle("r") + mutedStyle(" rename")
- rightHelp := keyStyle("x/del") + mutedStyle(" delete")
-
- bgColor := t.BackgroundPanel()
- helpText := layout.Render(layout.FlexOptions{
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Width: layout.Current.Container.Width - 14,
- Background: &bgColor,
- }, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
-
- helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
-
- content := strings.Join([]string{listView, helpText}, "\n")
-
- return s.modal.Render(content, background)
-}
-
-func (s *sessionDialog) setupRenameInput(currentTitle string) {
- t := theme.CurrentTheme()
- bgColor := t.BackgroundPanel()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- s.renameInput = textinput.New()
- s.renameInput.SetValue(currentTitle)
- s.renameInput.Focus()
- s.renameInput.CharLimit = 100
- s.renameInput.SetWidth(layout.Current.Container.Width - 20)
-
- s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Blurred.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Placeholder = styles.NewStyle().
- Foreground(textMutedColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Text = styles.NewStyle().
- Foreground(textColor).
- Background(bgColor).
- Lipgloss()
- s.renameInput.Styles.Focused.Prompt = styles.NewStyle().
- Background(bgColor).
- Lipgloss()
-}
-
-func (s *sessionDialog) updateListItems() {
- _, currentIdx := s.list.GetSelectedItem()
-
- var items []sessionItem
- for i, sess := range s.sessions {
- item := sessionItem{
- title: sess.Title,
- isDeleteConfirming: s.deleteConfirmation == i,
- isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
- }
- items = append(items, item)
- }
- s.list.SetItems(items)
- s.list.SetSelectedIndex(currentIdx)
-}
-
-func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
- return func() tea.Msg {
- ctx := context.Background()
- if err := s.app.DeleteSession(ctx, sessionID); err != nil {
- return toast.NewErrorToast("Failed to delete session: " + err.Error())()
- }
- return nil
- }
-}
-
-// ReopenSessionModalMsg is emitted when the session modal should be reopened
-type ReopenSessionModalMsg struct{}
-
-func (s *sessionDialog) Close() tea.Cmd {
- if s.renameMode {
- // If in rename mode, exit rename mode and return a command to reopen the modal
- s.renameMode = false
- s.modal.SetTitle("Switch Session")
- s.updateListItems()
-
- // Return a command that will reopen the session modal
- return func() tea.Msg {
- return ReopenSessionModalMsg{}
- }
- }
- // Normal close behavior
- return nil
-}
-
-// NewSessionDialog creates a new session switching dialog
-func NewSessionDialog(app *app.App) SessionDialog {
- sessions, _ := app.ListSessions(context.Background())
-
- var filteredSessions []opencode.Session
- var items []sessionItem
- for _, sess := range sessions {
- if sess.ParentID != "" {
- continue
- }
- filteredSessions = append(filteredSessions, sess)
- items = append(items, sessionItem{
- title: sess.Title,
- isDeleteConfirming: false,
- isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
- })
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[sessionItem](10),
- list.WithFallbackMessage[sessionItem]("No sessions available"),
- list.WithAlphaNumericKeys[sessionItem](true),
- list.WithRenderFunc(
- func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- list.WithSelectableFunc(func(item sessionItem) bool {
- return true
- }),
- )
- listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
-
- return &sessionDialog{
- sessions: filteredSessions,
- list: listComponent,
- app: app,
- deleteConfirmation: -1,
- renameMode: false,
- renameIndex: -1,
- modal: modal.New(
- modal.WithTitle("Switch Session"),
- modal.WithMaxWidth(layout.Current.Container.Width-8),
- ),
- }
-}
diff --git a/packages/tui/internal/components/dialog/theme.go b/packages/tui/internal/components/dialog/theme.go
deleted file mode 100644
index c71cddc8e..000000000
--- a/packages/tui/internal/components/dialog/theme.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package dialog
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
- list "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// ThemeSelectedMsg is sent when the theme is changed
-type ThemeSelectedMsg struct {
- ThemeName string
-}
-
-// ThemeDialog interface for the theme switching dialog
-type ThemeDialog interface {
- layout.Modal
-}
-
-type themeDialog struct {
- width int
- height int
-
- modal *modal.Modal
- list list.List[list.Item]
- originalTheme string
- themeApplied bool
-}
-
-func (t *themeDialog) Init() tea.Cmd {
- return nil
-}
-
-func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- t.width = msg.Width
- t.height = msg.Height
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- if item, idx := t.list.GetSelectedItem(); idx >= 0 {
- if stringItem, ok := item.(list.StringItem); ok {
- selectedTheme := string(stringItem)
- if err := theme.SetTheme(selectedTheme); err != nil {
- // status.Error(err.Error())
- return t, nil
- }
- t.themeApplied = true
- return t, tea.Sequence(
- util.CmdHandler(modal.CloseModalMsg{}),
- util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
- )
- }
- }
-
- }
- }
-
- _, prevIdx := t.list.GetSelectedItem()
-
- var cmd tea.Cmd
- listModel, cmd := t.list.Update(msg)
- t.list = listModel.(list.List[list.Item])
-
- if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
- if stringItem, ok := item.(list.StringItem); ok {
- theme.SetTheme(string(stringItem))
- return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
- }
- }
- return t, cmd
-}
-
-func (t *themeDialog) Render(background string) string {
- return t.modal.Render(t.list.View(), background)
-}
-
-func (t *themeDialog) Close() tea.Cmd {
- if !t.themeApplied {
- theme.SetTheme(t.originalTheme)
- return util.CmdHandler(ThemeSelectedMsg{ThemeName: t.originalTheme})
- }
- return nil
-}
-
-// NewThemeDialog creates a new theme switching dialog
-func NewThemeDialog() ThemeDialog {
- themes := theme.AvailableThemes()
- currentTheme := theme.CurrentThemeName()
-
- var selectedIdx int
- for i, name := range themes {
- if name == currentTheme {
- selectedIdx = i
- }
- }
-
- // Convert themes to list items
- items := make([]list.Item, len(themes))
- for i, theme := range themes {
- items[i] = list.StringItem(theme)
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[list.Item](10),
- list.WithFallbackMessage[list.Item]("No themes available"),
- list.WithAlphaNumericKeys[list.Item](true),
- list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, baseStyle)
- }),
- list.WithSelectableFunc(func(item list.Item) bool {
- return item.Selectable()
- }),
- )
-
- // Set the initial selection to the current theme
- listComponent.SetSelectedIndex(selectedIdx)
-
- // Set the max width for the list to match the modal width
- listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
- return &themeDialog{
- list: listComponent,
- modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
- originalTheme: currentTheme,
- themeApplied: false,
- }
-}
diff --git a/packages/tui/internal/components/dialog/timeline.go b/packages/tui/internal/components/dialog/timeline.go
deleted file mode 100644
index f2eeb7fb4..000000000
--- a/packages/tui/internal/components/dialog/timeline.go
+++ /dev/null
@@ -1,353 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/components/list"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// TimelineDialog interface for the session timeline dialog
-type TimelineDialog interface {
- layout.Modal
-}
-
-// ScrollToMessageMsg is sent when a message should be scrolled to
-type ScrollToMessageMsg struct {
- MessageID string
-}
-
-// RestoreToMessageMsg is sent when conversation should be restored to a specific message
-type RestoreToMessageMsg struct {
- MessageID string
- Index int
-}
-
-// timelineItem represents a user message in the timeline list
-type timelineItem struct {
- messageID string
- content string
- timestamp time.Time
- index int // Index in the full message list
- toolCount int // Number of tools used in this message
-}
-
-func (n timelineItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
- isCurrent bool,
-) string {
- t := theme.CurrentTheme()
- infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
- textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
-
- // Add dot after timestamp if this is the current message - only apply color when not selected
- var dot string
- var dotVisualLen int
- if isCurrent {
- if selected {
- dot = "● "
- } else {
- dot = lipgloss.NewStyle().Foreground(t.Success()).Render("● ")
- }
- dotVisualLen = 2 // "● " is 2 characters wide
- }
-
- // Format timestamp - only apply color when not selected
- var timeStr string
- var timeVisualLen int
- if selected {
- timeStr = n.timestamp.Format("15:04") + " " + dot
- timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
- } else {
- timeStr = infoStyle(n.timestamp.Format("15:04")+" ") + dot
- timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
- }
-
- // Tool count display (fixed width for alignment) - only apply color when not selected
- toolInfo := ""
- toolInfoVisualLen := 0
- if n.toolCount > 0 {
- toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
- if selected {
- toolInfo = toolInfoText
- } else {
- toolInfo = infoStyle(toolInfoText)
- }
- toolInfoVisualLen = lipgloss.Width(toolInfo)
- }
-
- // Calculate available space for content
- // Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer
- reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
- contentWidth := max(width-reservedSpace, 8)
-
- truncatedContent := truncate.StringWithTail(
- strings.Split(n.content, "\n")[0],
- uint(contentWidth),
- "...",
- )
-
- // Apply normal text color to content for non-selected items
- var styledContent string
- if selected {
- styledContent = truncatedContent
- } else {
- styledContent = textStyle(truncatedContent)
- }
-
- // Create the line with proper spacing - content left-aligned, tools right-aligned
- var text string
- text = timeStr + styledContent
- if toolInfo != "" {
- bgColor := t.BackgroundPanel()
- if selected {
- bgColor = t.Primary()
- }
- text = layout.Render(
- layout.FlexOptions{
- Background: &bgColor,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: width - 2,
- },
- layout.FlexItem{
- View: text,
- },
- layout.FlexItem{
- View: toolInfo,
- },
- )
- }
-
- var itemStyle styles.Style
- if selected {
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else {
- itemStyle = baseStyle.PaddingLeft(1)
- }
-
- return itemStyle.Render(text)
-}
-
-func (n timelineItem) Selectable() bool {
- return true
-}
-
-type timelineDialog struct {
- width int
- height int
- modal *modal.Modal
- list list.List[timelineItem]
- app *app.App
-}
-
-func (n *timelineDialog) Init() tea.Cmd {
- return nil
-}
-
-func (n *timelineDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- n.width = msg.Width
- n.height = msg.Height
- n.list.SetMaxWidth(layout.Current.Container.Width - 12)
- case tea.KeyPressMsg:
- switch msg.String() {
- case "up", "down":
- // Handle navigation and immediately scroll to selected message
- var cmd tea.Cmd
- listModel, cmd := n.list.Update(msg)
- n.list = listModel.(list.List[timelineItem])
-
- // Get the newly selected item and scroll to it immediately
- if item, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, tea.Sequence(
- cmd,
- util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
- )
- }
- return n, cmd
- case "r":
- // Restore conversation to selected message
- if item, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, tea.Sequence(
- util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
- util.CmdHandler(modal.CloseModalMsg{}),
- )
- }
- case "enter":
- // Keep Enter functionality for closing the modal
- if _, idx := n.list.GetSelectedItem(); idx >= 0 {
- return n, util.CmdHandler(modal.CloseModalMsg{})
- }
- }
- }
-
- var cmd tea.Cmd
- listModel, cmd := n.list.Update(msg)
- n.list = listModel.(list.List[timelineItem])
- return n, cmd
-}
-
-func (n *timelineDialog) Render(background string) string {
- listView := n.list.View()
-
- t := theme.CurrentTheme()
- keyStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundPanel()).
- Bold(true).
- Render
- mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
-
- helpText := keyStyle(
- "↑/↓",
- ) + mutedStyle(
- " jump ",
- ) + keyStyle(
- "r",
- ) + mutedStyle(
- " restore",
- )
-
- bgColor := t.BackgroundPanel()
- helpView := styles.NewStyle().
- Background(bgColor).
- Width(layout.Current.Container.Width - 14).
- PaddingLeft(1).
- PaddingTop(1).
- Render(helpText)
-
- content := strings.Join([]string{listView, helpView}, "\n")
-
- return n.modal.Render(content, background)
-}
-
-func (n *timelineDialog) Close() tea.Cmd {
- return nil
-}
-
-// extractMessagePreview extracts a preview from message parts
-func extractMessagePreview(parts []opencode.PartUnion) string {
- for _, part := range parts {
- switch casted := part.(type) {
- case opencode.TextPart:
- text := strings.TrimSpace(casted.Text)
- if text != "" {
- return text
- }
- }
- }
- return "No text content"
-}
-
-// countToolsInResponse counts tools in the assistant's response to a user message
-func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
- count := 0
- // Look at subsequent messages to find the assistant's response
- for i := userMessageIndex + 1; i < len(messages); i++ {
- message := messages[i]
- // If we hit another user message, stop looking
- if _, isUser := message.Info.(opencode.UserMessage); isUser {
- break
- }
- // Count tools in this assistant message
- for _, part := range message.Parts {
- switch part.(type) {
- case opencode.ToolPart:
- count++
- }
- }
- }
- return count
-}
-
-// NewTimelineDialog creates a new session timeline dialog
-func NewTimelineDialog(app *app.App) TimelineDialog { // renamed from NewNavigationDialog
- var items []timelineItem
-
- // Filter to only user messages and extract relevant info
- for i, message := range app.Messages {
- if userMsg, ok := message.Info.(opencode.UserMessage); ok {
- preview := extractMessagePreview(message.Parts)
- toolCount := countToolsInResponse(app.Messages, i)
-
- items = append(items, timelineItem{
- messageID: userMsg.ID,
- content: preview,
- timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
- index: i,
- toolCount: toolCount,
- })
- }
- }
-
- listComponent := list.NewListComponent(
- list.WithItems(items),
- list.WithMaxVisibleHeight[timelineItem](12),
- list.WithFallbackMessage[timelineItem]("No user messages in this session"),
- list.WithAlphaNumericKeys[timelineItem](true),
- list.WithRenderFunc(
- func(item timelineItem, selected bool, width int, baseStyle styles.Style) string {
- // Determine if this item is the current message for the session
- isCurrent := false
- if app.Session.Revert.MessageID != "" {
- // When reverted, Session.Revert.MessageID contains the NEXT user message ID
- // So we need to find the previous user message to highlight the correct one
- for i, navItem := range items {
- if navItem.messageID == app.Session.Revert.MessageID && i > 0 {
- // Found the next message, so the previous one is current
- isCurrent = item.messageID == items[i-1].messageID
- break
- }
- }
- } else if len(app.Messages) > 0 {
- // If not reverted, highlight the last user message
- lastUserMsgID := ""
- for i := len(app.Messages) - 1; i >= 0; i-- {
- if userMsg, ok := app.Messages[i].Info.(opencode.UserMessage); ok {
- lastUserMsgID = userMsg.ID
- break
- }
- }
- isCurrent = item.messageID == lastUserMsgID
- }
- // Only show the dot if undo/redo/restore is available
- showDot := app.Session.Revert.MessageID != ""
- return item.Render(selected, width, false, baseStyle, isCurrent && showDot)
- },
- ),
- list.WithSelectableFunc(func(item timelineItem) bool {
- return true
- }),
- )
- listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
-
- return &timelineDialog{
- list: listComponent,
- app: app,
- modal: modal.New(
- modal.WithTitle("Session Timeline"),
- modal.WithMaxWidth(layout.Current.Container.Width-8),
- ),
- }
-}
diff --git a/packages/tui/internal/components/diff/diff.go b/packages/tui/internal/components/diff/diff.go
deleted file mode 100644
index da2e007c2..000000000
--- a/packages/tui/internal/components/diff/diff.go
+++ /dev/null
@@ -1,957 +0,0 @@
-package diff
-
-import (
- "bufio"
- "bytes"
- "fmt"
- "image/color"
- "io"
- "regexp"
- "strconv"
- "strings"
- "sync"
- "unicode/utf8"
-
- "github.com/alecthomas/chroma/v2"
- "github.com/alecthomas/chroma/v2/formatters"
- "github.com/alecthomas/chroma/v2/lexers"
- "github.com/alecthomas/chroma/v2/styles"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/charmbracelet/x/ansi"
- "github.com/sergi/go-diff/diffmatchpatch"
- stylesi "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// -------------------------------------------------------------------------
-// Core Types
-// -------------------------------------------------------------------------
-
-// LineType represents the kind of line in a diff.
-type LineType int
-
-const (
- LineContext LineType = iota // Line exists in both files
- LineAdded // Line added in the new file
- LineRemoved // Line removed from the old file
-)
-
-var (
- ansiRegex = regexp.MustCompile(`\x1b(?:[@-Z\\-_]|\[[0-9?]*(?:;[0-9?]*)*[@-~])`)
-)
-
-// Segment represents a portion of a line for intra-line highlighting
-type Segment struct {
- Start int
- End int
- Type LineType
- Text string
-}
-
-// DiffLine represents a single line in a diff
-type DiffLine struct {
- OldLineNo int // Line number in old file (0 for added lines)
- NewLineNo int // Line number in new file (0 for removed lines)
- Kind LineType // Type of line (added, removed, context)
- Content string // Content of the line
- Segments []Segment // Segments for intraline highlighting
-}
-
-// Hunk represents a section of changes in a diff
-type Hunk struct {
- Header string
- Lines []DiffLine
-}
-
-// DiffResult contains the parsed result of a diff
-type DiffResult struct {
- OldFile string
- NewFile string
- Hunks []Hunk
-}
-
-// linePair represents a pair of lines for side-by-side display
-type linePair struct {
- left *DiffLine
- right *DiffLine
-}
-
-// UnifiedConfig configures the rendering of unified diffs
-type UnifiedConfig struct {
- Width int
-}
-
-// UnifiedOption modifies a UnifiedConfig
-type UnifiedOption func(*UnifiedConfig)
-
-// NewUnifiedConfig creates a UnifiedConfig with default values
-func NewUnifiedConfig(opts ...UnifiedOption) UnifiedConfig {
- config := UnifiedConfig{
- Width: 80,
- }
- for _, opt := range opts {
- opt(&config)
- }
- return config
-}
-
-// NewSideBySideConfig creates a SideBySideConfig with default values
-func NewSideBySideConfig(opts ...UnifiedOption) UnifiedConfig {
- config := UnifiedConfig{
- Width: 160,
- }
- for _, opt := range opts {
- opt(&config)
- }
- return config
-}
-
-// WithWidth sets the width for unified view
-func WithWidth(width int) UnifiedOption {
- return func(u *UnifiedConfig) {
- if width > 0 {
- u.Width = width
- }
- }
-}
-
-// -------------------------------------------------------------------------
-// Diff Parsing
-// -------------------------------------------------------------------------
-
-// ParseUnifiedDiff parses a unified diff format string into structured data
-func ParseUnifiedDiff(diff string) (DiffResult, error) {
- var result DiffResult
- var currentHunk *Hunk
- result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
-
- scanner := bufio.NewScanner(strings.NewReader(diff))
- var oldLine, newLine int
- inFileHeader := true
-
- for scanner.Scan() {
- line := scanner.Text()
-
- if inFileHeader {
- if strings.HasPrefix(line, "--- a/") {
- result.OldFile = line[6:]
- continue
- }
- if strings.HasPrefix(line, "+++ b/") {
- result.NewFile = line[6:]
- inFileHeader = false
- continue
- }
- }
-
- if strings.HasPrefix(line, "@@") {
- if currentHunk != nil {
- result.Hunks = append(result.Hunks, *currentHunk)
- }
- currentHunk = &Hunk{
- Header: line,
- Lines: make([]DiffLine, 0, 10), // Pre-allocate
- }
-
- // Manual parsing of hunk header is faster than regex
- parts := strings.Split(line, " ")
- if len(parts) > 2 {
- oldRange := strings.Split(parts[1][1:], ",")
- newRange := strings.Split(parts[2][1:], ",")
- oldLine, _ = strconv.Atoi(oldRange[0])
- newLine, _ = strconv.Atoi(newRange[0])
- }
- continue
- }
-
- if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
- continue
- }
-
- var dl DiffLine
- dl.Content = line
- if len(line) > 0 {
- switch line[0] {
- case '+':
- dl.Kind = LineAdded
- dl.NewLineNo = newLine
- dl.Content = line[1:]
- newLine++
- case '-':
- dl.Kind = LineRemoved
- dl.OldLineNo = oldLine
- dl.Content = line[1:]
- oldLine++
- default: // context line
- dl.Kind = LineContext
- dl.OldLineNo = oldLine
- dl.NewLineNo = newLine
- oldLine++
- newLine++
- }
- } else { // empty context line
- dl.Kind = LineContext
- dl.OldLineNo = oldLine
- dl.NewLineNo = newLine
- oldLine++
- newLine++
- }
- currentHunk.Lines = append(currentHunk.Lines, dl)
- }
-
- if currentHunk != nil {
- result.Hunks = append(result.Hunks, *currentHunk)
- }
-
- return result, scanner.Err()
-}
-
-// HighlightIntralineChanges updates lines in a hunk to show character-level differences
-func HighlightIntralineChanges(h *Hunk) {
- var updated []DiffLine
- dmp := diffmatchpatch.New()
-
- for i := 0; i < len(h.Lines); i++ {
- // Look for removed line followed by added line
- if i+1 < len(h.Lines) &&
- h.Lines[i].Kind == LineRemoved &&
- h.Lines[i+1].Kind == LineAdded {
-
- oldLine := h.Lines[i]
- newLine := h.Lines[i+1]
-
- // Find character-level differences
- patches := dmp.DiffMain(oldLine.Content, newLine.Content, false)
- patches = dmp.DiffCleanupSemantic(patches)
- patches = dmp.DiffCleanupMerge(patches)
- patches = dmp.DiffCleanupEfficiency(patches)
-
- segments := make([]Segment, 0)
-
- removeStart := 0
- addStart := 0
- for _, patch := range patches {
- switch patch.Type {
- case diffmatchpatch.DiffDelete:
- segments = append(segments, Segment{
- Start: removeStart,
- End: removeStart + len(patch.Text),
- Type: LineRemoved,
- Text: patch.Text,
- })
- removeStart += len(patch.Text)
- case diffmatchpatch.DiffInsert:
- segments = append(segments, Segment{
- Start: addStart,
- End: addStart + len(patch.Text),
- Type: LineAdded,
- Text: patch.Text,
- })
- addStart += len(patch.Text)
- default:
- // Context text, no highlighting needed
- removeStart += len(patch.Text)
- addStart += len(patch.Text)
- }
- }
- oldLine.Segments = segments
- newLine.Segments = segments
-
- updated = append(updated, oldLine, newLine)
- i++ // Skip the next line as we've already processed it
- } else {
- updated = append(updated, h.Lines[i])
- }
- }
-
- h.Lines = updated
-}
-
-// pairLines converts a flat list of diff lines to pairs for side-by-side display
-func pairLines(lines []DiffLine) []linePair {
- var pairs []linePair
- i := 0
-
- for i < len(lines) {
- switch lines[i].Kind {
- case LineRemoved:
- // Check if the next line is an addition, if so pair them
- if i+1 < len(lines) && lines[i+1].Kind == LineAdded {
- pairs = append(pairs, linePair{left: &lines[i], right: &lines[i+1]})
- i += 2
- } else {
- pairs = append(pairs, linePair{left: &lines[i], right: nil})
- i++
- }
- case LineAdded:
- pairs = append(pairs, linePair{left: nil, right: &lines[i]})
- i++
- case LineContext:
- pairs = append(pairs, linePair{left: &lines[i], right: &lines[i]})
- i++
- }
- }
-
- return pairs
-}
-
-// -------------------------------------------------------------------------
-// Syntax Highlighting
-// -------------------------------------------------------------------------
-
-// SyntaxHighlight applies syntax highlighting to text based on file extension
-func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
- t := theme.CurrentTheme()
-
- // Determine the language lexer to use
- l := lexers.Match(fileName)
- if l == nil {
- l = lexers.Analyse(source)
- }
- if l == nil {
- l = lexers.Fallback
- }
- l = chroma.Coalesce(l)
-
- // Get the formatter
- f := formatters.Get(formatter)
- if f == nil {
- f = formatters.Fallback
- }
-
- // Dynamic theme based on current theme values
- syntaxThemeXml := fmt.Sprintf(`
- <style name="opencode-theme">
- <!-- Base colors -->
- <entry type="Background" style="bg:%s"/>
- <entry type="Text" style="%s"/>
- <entry type="Other" style="%s"/>
- <entry type="Error" style="%s"/>
- <!-- Keywords -->
- <entry type="Keyword" style="%s"/>
- <entry type="KeywordConstant" style="%s"/>
- <entry type="KeywordDeclaration" style="%s"/>
- <entry type="KeywordNamespace" style="%s"/>
- <entry type="KeywordPseudo" style="%s"/>
- <entry type="KeywordReserved" style="%s"/>
- <entry type="KeywordType" style="%s"/>
- <!-- Names -->
- <entry type="Name" style="%s"/>
- <entry type="NameAttribute" style="%s"/>
- <entry type="NameBuiltin" style="%s"/>
- <entry type="NameBuiltinPseudo" style="%s"/>
- <entry type="NameClass" style="%s"/>
- <entry type="NameConstant" style="%s"/>
- <entry type="NameDecorator" style="%s"/>
- <entry type="NameEntity" style="%s"/>
- <entry type="NameException" style="%s"/>
- <entry type="NameFunction" style="%s"/>
- <entry type="NameLabel" style="%s"/>
- <entry type="NameNamespace" style="%s"/>
- <entry type="NameOther" style="%s"/>
- <entry type="NameTag" style="%s"/>
- <entry type="NameVariable" style="%s"/>
- <entry type="NameVariableClass" style="%s"/>
- <entry type="NameVariableGlobal" style="%s"/>
- <entry type="NameVariableInstance" style="%s"/>
- <!-- Literals -->
- <entry type="Literal" style="%s"/>
- <entry type="LiteralDate" style="%s"/>
- <entry type="LiteralString" style="%s"/>
- <entry type="LiteralStringBacktick" style="%s"/>
- <entry type="LiteralStringChar" style="%s"/>
- <entry type="LiteralStringDoc" style="%s"/>
- <entry type="LiteralStringDouble" style="%s"/>
- <entry type="LiteralStringEscape" style="%s"/>
- <entry type="LiteralStringHeredoc" style="%s"/>
- <entry type="LiteralStringInterpol" style="%s"/>
- <entry type="LiteralStringOther" style="%s"/>
- <entry type="LiteralStringRegex" style="%s"/>
- <entry type="LiteralStringSingle" style="%s"/>
- <entry type="LiteralStringSymbol" style="%s"/>
- <!-- Numbers -->
- <entry type="LiteralNumber" style="%s"/>
- <entry type="LiteralNumberBin" style="%s"/>
- <entry type="LiteralNumberFloat" style="%s"/>
- <entry type="LiteralNumberHex" style="%s"/>
- <entry type="LiteralNumberInteger" style="%s"/>
- <entry type="LiteralNumberIntegerLong" style="%s"/>
- <entry type="LiteralNumberOct" style="%s"/>
- <!-- Operators -->
- <entry type="Operator" style="%s"/>
- <entry type="OperatorWord" style="%s"/>
- <entry type="Punctuation" style="%s"/>
- <!-- Comments -->
- <entry type="Comment" style="%s"/>
- <entry type="CommentHashbang" style="%s"/>
- <entry type="CommentMultiline" style="%s"/>
- <entry type="CommentSingle" style="%s"/>
- <entry type="CommentSpecial" style="%s"/>
- <entry type="CommentPreproc" style="%s"/>
- <!-- Generic styles -->
- <entry type="Generic" style="%s"/>
- <entry type="GenericDeleted" style="%s"/>
- <entry type="GenericEmph" style="italic %s"/>
- <entry type="GenericError" style="%s"/>
- <entry type="GenericHeading" style="bold %s"/>
- <entry type="GenericInserted" style="%s"/>
- <entry type="GenericOutput" style="%s"/>
- <entry type="GenericPrompt" style="%s"/>
- <entry type="GenericStrong" style="bold %s"/>
- <entry type="GenericSubheading" style="bold %s"/>
- <entry type="GenericTraceback" style="%s"/>
- <entry type="GenericUnderline" style="underline"/>
- <entry type="TextWhitespace" style="%s"/>
-</style>
-`,
- getChromaColor(t.BackgroundPanel()), // Background
- getChromaColor(t.Text()), // Text
- getChromaColor(t.Text()), // Other
- getChromaColor(t.Error()), // Error
-
- getChromaColor(t.SyntaxKeyword()), // Keyword
- getChromaColor(t.SyntaxKeyword()), // KeywordConstant
- getChromaColor(t.SyntaxKeyword()), // KeywordDeclaration
- getChromaColor(t.SyntaxKeyword()), // KeywordNamespace
- getChromaColor(t.SyntaxKeyword()), // KeywordPseudo
- getChromaColor(t.SyntaxKeyword()), // KeywordReserved
- getChromaColor(t.SyntaxType()), // KeywordType
-
- getChromaColor(t.Text()), // Name
- getChromaColor(t.SyntaxVariable()), // NameAttribute
- getChromaColor(t.SyntaxType()), // NameBuiltin
- getChromaColor(t.SyntaxVariable()), // NameBuiltinPseudo
- getChromaColor(t.SyntaxType()), // NameClass
- getChromaColor(t.SyntaxVariable()), // NameConstant
- getChromaColor(t.SyntaxFunction()), // NameDecorator
- getChromaColor(t.SyntaxVariable()), // NameEntity
- getChromaColor(t.SyntaxType()), // NameException
- getChromaColor(t.SyntaxFunction()), // NameFunction
- getChromaColor(t.Text()), // NameLabel
- getChromaColor(t.SyntaxType()), // NameNamespace
- getChromaColor(t.SyntaxVariable()), // NameOther
- getChromaColor(t.SyntaxKeyword()), // NameTag
- getChromaColor(t.SyntaxVariable()), // NameVariable
- getChromaColor(t.SyntaxVariable()), // NameVariableClass
- getChromaColor(t.SyntaxVariable()), // NameVariableGlobal
- getChromaColor(t.SyntaxVariable()), // NameVariableInstance
-
- getChromaColor(t.SyntaxString()), // Literal
- getChromaColor(t.SyntaxString()), // LiteralDate
- getChromaColor(t.SyntaxString()), // LiteralString
- getChromaColor(t.SyntaxString()), // LiteralStringBacktick
- getChromaColor(t.SyntaxString()), // LiteralStringChar
- getChromaColor(t.SyntaxString()), // LiteralStringDoc
- getChromaColor(t.SyntaxString()), // LiteralStringDouble
- getChromaColor(t.SyntaxString()), // LiteralStringEscape
- getChromaColor(t.SyntaxString()), // LiteralStringHeredoc
- getChromaColor(t.SyntaxString()), // LiteralStringInterpol
- getChromaColor(t.SyntaxString()), // LiteralStringOther
- getChromaColor(t.SyntaxString()), // LiteralStringRegex
- getChromaColor(t.SyntaxString()), // LiteralStringSingle
- getChromaColor(t.SyntaxString()), // LiteralStringSymbol
-
- getChromaColor(t.SyntaxNumber()), // LiteralNumber
- getChromaColor(t.SyntaxNumber()), // LiteralNumberBin
- getChromaColor(t.SyntaxNumber()), // LiteralNumberFloat
- getChromaColor(t.SyntaxNumber()), // LiteralNumberHex
- getChromaColor(t.SyntaxNumber()), // LiteralNumberInteger
- getChromaColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
- getChromaColor(t.SyntaxNumber()), // LiteralNumberOct
-
- getChromaColor(t.SyntaxOperator()), // Operator
- getChromaColor(t.SyntaxKeyword()), // OperatorWord
- getChromaColor(t.SyntaxPunctuation()), // Punctuation
-
- getChromaColor(t.SyntaxComment()), // Comment
- getChromaColor(t.SyntaxComment()), // CommentHashbang
- getChromaColor(t.SyntaxComment()), // CommentMultiline
- getChromaColor(t.SyntaxComment()), // CommentSingle
- getChromaColor(t.SyntaxComment()), // CommentSpecial
- getChromaColor(t.SyntaxKeyword()), // CommentPreproc
-
- getChromaColor(t.Text()), // Generic
- getChromaColor(t.Error()), // GenericDeleted
- getChromaColor(t.Text()), // GenericEmph
- getChromaColor(t.Error()), // GenericError
- getChromaColor(t.Text()), // GenericHeading
- getChromaColor(t.Success()), // GenericInserted
- getChromaColor(t.TextMuted()), // GenericOutput
- getChromaColor(t.Text()), // GenericPrompt
- getChromaColor(t.Text()), // GenericStrong
- getChromaColor(t.Text()), // GenericSubheading
- getChromaColor(t.Error()), // GenericTraceback
- getChromaColor(t.Text()), // TextWhitespace
- )
-
- r := strings.NewReader(syntaxThemeXml)
- style := chroma.MustNewXMLStyle(r)
-
- // Modify the style to use the provided background
- s, err := style.Builder().Transform(
- func(t chroma.StyleEntry) chroma.StyleEntry {
- if _, ok := bg.(lipgloss.NoColor); ok {
- return t
- }
- r, g, b, _ := bg.RGBA()
- t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
- return t
- },
- ).Build()
- if err != nil {
- s = styles.Fallback
- }
-
- // Tokenize and format
- it, err := l.Tokenise(nil, source)
- if err != nil {
- return err
- }
-
- return f.Format(w, s, it)
-}
-
-// getColor returns the appropriate hex color string based on terminal background
-func getColor(adaptiveColor compat.AdaptiveColor) *string {
- return stylesi.AdaptiveColorToString(adaptiveColor)
-}
-
-func getChromaColor(adaptiveColor compat.AdaptiveColor) string {
- color := stylesi.AdaptiveColorToString(adaptiveColor)
- if color == nil {
- return ""
- }
- return *color
-}
-
-// highlightLine applies syntax highlighting to a single line
-func highlightLine(fileName string, line string, bg color.Color) string {
- var buf bytes.Buffer
- err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
- if err != nil {
- return line
- }
- return buf.String()
-}
-
-// createStyles generates the lipgloss styles needed for rendering diffs
-func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle stylesi.Style) {
- removedLineStyle = stylesi.NewStyle().Background(t.DiffRemovedBg())
- addedLineStyle = stylesi.NewStyle().Background(t.DiffAddedBg())
- contextLineStyle = stylesi.NewStyle().Background(t.DiffContextBg())
- lineNumberStyle = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffLineNumber())
- return
-}
-
-// -------------------------------------------------------------------------
-// Rendering Functions
-// -------------------------------------------------------------------------
-
-// applyHighlighting applies intra-line highlighting to a piece of text
-func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg compat.AdaptiveColor) string {
- // Find all ANSI sequences in the content
- ansiMatches := ansiRegex.FindAllStringIndex(content, -1)
-
- // Build a mapping of visible character positions to their actual indices
- visibleIdx := 0
- ansiSequences := make(map[int]string)
- lastAnsiSeq := "\x1b[0m" // Default reset sequence
-
- for i := 0; i < len(content); {
- isAnsi := false
- for _, match := range ansiMatches {
- if match[0] == i {
- ansiSequences[visibleIdx] = content[match[0]:match[1]]
- lastAnsiSeq = content[match[0]:match[1]]
- i = match[1]
- isAnsi = true
- break
- }
- }
- if isAnsi {
- continue
- }
-
- // For non-ANSI positions, store the last ANSI sequence
- if _, exists := ansiSequences[visibleIdx]; !exists {
- ansiSequences[visibleIdx] = lastAnsiSeq
- }
- visibleIdx++
-
- // Properly advance by UTF-8 rune, not byte
- _, size := utf8.DecodeRuneInString(content[i:])
- i += size
- }
-
- // Apply highlighting
- var sb strings.Builder
- inSelection := false
- currentPos := 0
-
- // Get the appropriate color based on terminal background
- bg := getColor(highlightBg)
- fg := getColor(theme.CurrentTheme().BackgroundPanel())
- var bgColor color.Color
- var fgColor color.Color
-
- if bg != nil {
- bgColor = lipgloss.Color(*bg)
- }
- if fg != nil {
- fgColor = lipgloss.Color(*fg)
- }
- for i := 0; i < len(content); {
- // Check if we're at an ANSI sequence
- isAnsi := false
- for _, match := range ansiMatches {
- if match[0] == i {
- sb.WriteString(content[match[0]:match[1]]) // Preserve ANSI sequence
- i = match[1]
- isAnsi = true
- break
- }
- }
- if isAnsi {
- continue
- }
-
- // Check for segment boundaries
- for _, seg := range segments {
- if seg.Type == segmentType {
- if currentPos == seg.Start {
- inSelection = true
- }
- if currentPos == seg.End {
- inSelection = false
- }
- }
- }
-
- // Get current character (properly handle UTF-8)
- r, size := utf8.DecodeRuneInString(content[i:])
- char := string(r)
-
- if inSelection {
- // Get the current styling
- currentStyle := ansiSequences[currentPos]
-
- // Apply foreground and background highlight
- if fgColor != nil {
- sb.WriteString("\x1b[38;2;")
- r, g, b, _ := fgColor.RGBA()
- sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
- } else {
- sb.WriteString("\x1b[49m")
- }
- if bgColor != nil {
- sb.WriteString("\x1b[48;2;")
- r, g, b, _ := bgColor.RGBA()
- sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
- } else {
- sb.WriteString("\x1b[39m")
- }
- sb.WriteString(char)
-
- // Full reset of all attributes to ensure clean state
- sb.WriteString("\x1b[0m")
-
- // Reapply the original ANSI sequence
- sb.WriteString(currentStyle)
- } else {
- // Not in selection, just copy the character
- sb.WriteString(char)
- }
-
- currentPos++
- i += size
- }
-
- return sb.String()
-}
-
-// renderLinePrefix renders the line number and marker prefix for a diff line
-func renderLinePrefix(dl DiffLine, lineNum string, marker string, lineNumberStyle stylesi.Style, t theme.Theme) string {
- // Style the marker based on line type
- var styledMarker string
- switch dl.Kind {
- case LineRemoved:
- styledMarker = stylesi.NewStyle().Foreground(t.DiffRemoved()).Background(t.DiffRemovedBg()).Render(marker)
- case LineAdded:
- styledMarker = stylesi.NewStyle().Foreground(t.DiffAdded()).Background(t.DiffAddedBg()).Render(marker)
- case LineContext:
- styledMarker = stylesi.NewStyle().Foreground(t.TextMuted()).Background(t.DiffContextBg()).Render(marker)
- default:
- styledMarker = marker
- }
-
- return lineNumberStyle.Render(lineNum + " " + styledMarker)
-}
-
-// renderLineContent renders the content of a diff line with syntax and intra-line highlighting
-func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, highlightColor compat.AdaptiveColor, width int) string {
- // Apply syntax highlighting
- content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
-
- // Apply intra-line highlighting if needed
- if len(dl.Segments) > 0 && (dl.Kind == LineRemoved || dl.Kind == LineAdded) {
- content = applyHighlighting(content, dl.Segments, dl.Kind, highlightColor)
- }
-
- // Add a padding space for added/removed lines
- if dl.Kind == LineRemoved || dl.Kind == LineAdded {
- content = bgStyle.Render(" ") + content
- }
-
- // Create the final line and truncate if needed
- return bgStyle.MaxHeight(1).Width(width).Render(
- ansi.Truncate(
- content,
- width,
- "...",
- ),
- )
-}
-
-// renderUnifiedLine renders a single line in unified diff format
-func renderUnifiedLine(fileName string, dl DiffLine, width int, t theme.Theme) string {
- removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
-
- // Determine line style and marker based on line type
- var marker string
- var bgStyle stylesi.Style
- var lineNum string
- var highlightColor compat.AdaptiveColor
-
- switch dl.Kind {
- case LineRemoved:
- marker = "-"
- bgStyle = removedLineStyle
- lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
- highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
- if dl.OldLineNo > 0 {
- lineNum = fmt.Sprintf("%6d ", dl.OldLineNo)
- } else {
- lineNum = " "
- }
- case LineAdded:
- marker = "+"
- bgStyle = addedLineStyle
- lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
- highlightColor = t.DiffHighlightAdded() // TODO: handle "none"
- if dl.NewLineNo > 0 {
- lineNum = fmt.Sprintf(" %7d", dl.NewLineNo)
- } else {
- lineNum = " "
- }
- case LineContext:
- marker = " "
- bgStyle = contextLineStyle
- if dl.OldLineNo > 0 && dl.NewLineNo > 0 {
- lineNum = fmt.Sprintf("%6d %6d", dl.OldLineNo, dl.NewLineNo)
- } else {
- lineNum = " "
- }
- }
-
- // Create the line prefix
- prefix := renderLinePrefix(dl, lineNum, marker, lineNumberStyle, t)
-
- // Render the content
- prefixWidth := ansi.StringWidth(prefix)
- contentWidth := width - prefixWidth
- content := renderLineContent(fileName, dl, bgStyle, highlightColor, contentWidth)
-
- return prefix + content
-}
-
-// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
-func renderDiffColumnLine(
- fileName string,
- dl *DiffLine,
- colWidth int,
- isLeftColumn bool,
- t theme.Theme,
-) string {
- if dl == nil {
- contextLineStyle := stylesi.NewStyle().Background(t.DiffContextBg())
- return contextLineStyle.Width(colWidth).Render("")
- }
-
- removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
-
- // Determine line style based on line type and column
- var marker string
- var bgStyle stylesi.Style
- var lineNum string
- var highlightColor compat.AdaptiveColor
-
- if isLeftColumn {
- // Left column logic
- switch dl.Kind {
- case LineRemoved:
- marker = "-"
- bgStyle = removedLineStyle
- lineNumberStyle = lineNumberStyle.Background(t.DiffRemovedLineNumberBg()).Foreground(t.DiffRemoved())
- highlightColor = t.DiffHighlightRemoved() // TODO: handle "none"
- case LineAdded:
- marker = "?"
- bgStyle = contextLineStyle
- case LineContext:
- marker = " "
- bgStyle = contextLineStyle
- }
-
- // Format line number for left column
- if dl.OldLineNo > 0 {
- lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
- }
- } else {
- // Right column logic
- switch dl.Kind {
- case LineAdded:
- marker = "+"
- bgStyle = addedLineStyle
- lineNumberStyle = lineNumberStyle.Background(t.DiffAddedLineNumberBg()).Foreground(t.DiffAdded())
- highlightColor = t.DiffHighlightAdded()
- case LineRemoved:
- marker = "?"
- bgStyle = contextLineStyle
- case LineContext:
- marker = " "
- bgStyle = contextLineStyle
- }
-
- // Format line number for right column
- if dl.NewLineNo > 0 {
- lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
- }
- }
-
- // Create the line prefix
- prefix := renderLinePrefix(*dl, lineNum, marker, lineNumberStyle, t)
-
- // Determine if we should render content
- shouldRenderContent := (dl.Kind == LineRemoved && isLeftColumn) ||
- (dl.Kind == LineAdded && !isLeftColumn) ||
- dl.Kind == LineContext
-
- if !shouldRenderContent {
- return bgStyle.Width(colWidth).Render("")
- }
-
- // Render the content
- prefixWidth := ansi.StringWidth(prefix)
- contentWidth := colWidth - prefixWidth
- content := renderLineContent(fileName, *dl, bgStyle, highlightColor, contentWidth)
-
- return prefix + content
-}
-
-// renderLeftColumn formats the left side of a side-by-side diff
-func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
- return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
-}
-
-// renderRightColumn formats the right side of a side-by-side diff
-func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
- return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
-}
-
-// -------------------------------------------------------------------------
-// Public API
-// -------------------------------------------------------------------------
-
-// RenderUnifiedHunk formats a hunk for unified display
-func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
- // Apply options to create the configuration
- config := NewUnifiedConfig(opts...)
-
- // Make a copy of the hunk so we don't modify the original
- hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
- copy(hunkCopy.Lines, h.Lines)
-
- // Highlight changes within lines
- HighlightIntralineChanges(&hunkCopy)
-
- var sb strings.Builder
- sb.Grow(len(hunkCopy.Lines) * config.Width)
-
- util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
- return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
- })
-
- return sb.String()
-}
-
-// RenderSideBySideHunk formats a hunk for side-by-side display
-func RenderSideBySideHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
- // Apply options to create the configuration
- config := NewSideBySideConfig(opts...)
-
- // Make a copy of the hunk so we don't modify the original
- hunkCopy := Hunk{Lines: make([]DiffLine, len(h.Lines))}
- copy(hunkCopy.Lines, h.Lines)
-
- // Highlight changes within lines
- HighlightIntralineChanges(&hunkCopy)
-
- // Pair lines for side-by-side display
- pairs := pairLines(hunkCopy.Lines)
-
- // Calculate column width
- colWidth := config.Width / 2
-
- leftWidth := colWidth
- rightWidth := config.Width - colWidth
- var sb strings.Builder
-
- util.WriteStringsPar(&sb, pairs, func(p linePair) string {
- wg := &sync.WaitGroup{}
- var leftStr, rightStr string
- wg.Add(2)
- go func() {
- defer wg.Done()
- leftStr = renderLeftColumn(fileName, p.left, leftWidth)
- }()
- go func() {
- defer wg.Done()
- rightStr = renderRightColumn(fileName, p.right, rightWidth)
- }()
- wg.Wait()
- return leftStr + rightStr + "\n"
- })
-
- return sb.String()
-}
-
-// FormatUnifiedDiff creates a unified formatted view of a diff
-func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
- diffResult, err := ParseUnifiedDiff(diffText)
- if err != nil {
- return "", err
- }
-
- var sb strings.Builder
- util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
- return RenderUnifiedHunk(filename, h, opts...)
- })
-
- return sb.String(), nil
-}
-
-// FormatDiff creates a side-by-side formatted view of a diff
-func FormatDiff(filename string, diffText string, opts ...UnifiedOption) (string, error) {
- diffResult, err := ParseUnifiedDiff(diffText)
- if err != nil {
- return "", err
- }
-
- var sb strings.Builder
- util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
- return RenderSideBySideHunk(filename, h, opts...)
- })
-
- return sb.String(), nil
-}
diff --git a/packages/tui/internal/components/diff/parse.go b/packages/tui/internal/components/diff/parse.go
deleted file mode 100644
index 261ba5970..000000000
--- a/packages/tui/internal/components/diff/parse.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package diff
-
-import (
- "bufio"
- "fmt"
- "strings"
-)
-
-type DiffStats struct {
- Added int
- Removed int
- Modified int
-}
-
-func ParseStats(diff string) (map[string]DiffStats, error) {
- stats := make(map[string]DiffStats)
- var currentFile string
- scanner := bufio.NewScanner(strings.NewReader(diff))
-
- for scanner.Scan() {
- line := scanner.Text()
- if strings.HasPrefix(line, "---") {
- continue
- } else if strings.HasPrefix(line, "+++") {
- parts := strings.SplitN(line, " ", 2)
- if len(parts) == 2 {
- currentFile = strings.TrimPrefix(parts[1], "b/")
- }
- continue
- }
- if strings.HasPrefix(line, "@@") {
- continue
- }
- if currentFile == "" {
- continue
- }
-
- fileStats := stats[currentFile]
- switch {
- case strings.HasPrefix(line, "+"):
- fileStats.Added++
- case strings.HasPrefix(line, "-"):
- fileStats.Removed++
- }
- stats[currentFile] = fileStats
- }
-
- if err := scanner.Err(); err != nil {
- return nil, fmt.Errorf("error reading diff string: %w", err)
- }
-
- for file, fileStats := range stats {
- fileStats.Modified = fileStats.Added + fileStats.Removed
- stats[file] = fileStats
- }
-
- return stats, nil
-}
diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go
deleted file mode 100644
index a9823d0ab..000000000
--- a/packages/tui/internal/components/list/list.go
+++ /dev/null
@@ -1,436 +0,0 @@
-package list
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/muesli/reflow/truncate"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// Item interface that all list items must implement
-type Item interface {
- Render(selected bool, width int, baseStyle styles.Style) string
- Selectable() bool
-}
-
-// RenderFunc defines how to render an item in the list
-type RenderFunc[T any] func(item T, selected bool, width int, baseStyle styles.Style) string
-
-// SelectableFunc defines whether an item is selectable
-type SelectableFunc[T any] func(item T) bool
-
-// Options holds configuration for the list component
-type Options[T any] struct {
- items []T
- maxVisibleHeight int
- fallbackMsg string
- useAlphaNumericKeys bool
- renderItem RenderFunc[T]
- isSelectable SelectableFunc[T]
- baseStyle styles.Style
-}
-
-// Option is a function that configures the list component
-type Option[T any] func(*Options[T])
-
-// WithItems sets the initial items for the list
-func WithItems[T any](items []T) Option[T] {
- return func(o *Options[T]) {
- o.items = items
- }
-}
-
-// WithMaxVisibleHeight sets the maximum visible height in lines
-func WithMaxVisibleHeight[T any](height int) Option[T] {
- return func(o *Options[T]) {
- o.maxVisibleHeight = height
- }
-}
-
-// WithFallbackMessage sets the message to show when the list is empty
-func WithFallbackMessage[T any](msg string) Option[T] {
- return func(o *Options[T]) {
- o.fallbackMsg = msg
- }
-}
-
-// WithAlphaNumericKeys enables j/k navigation keys
-func WithAlphaNumericKeys[T any](enabled bool) Option[T] {
- return func(o *Options[T]) {
- o.useAlphaNumericKeys = enabled
- }
-}
-
-// WithRenderFunc sets the function to render items
-func WithRenderFunc[T any](fn RenderFunc[T]) Option[T] {
- return func(o *Options[T]) {
- o.renderItem = fn
- }
-}
-
-// WithSelectableFunc sets the function to determine if items are selectable
-func WithSelectableFunc[T any](fn SelectableFunc[T]) Option[T] {
- return func(o *Options[T]) {
- o.isSelectable = fn
- }
-}
-
-// WithStyle sets the base style that gets passed to render functions
-func WithStyle[T any](style styles.Style) Option[T] {
- return func(o *Options[T]) {
- o.baseStyle = style
- }
-}
-
-type List[T any] interface {
- tea.Model
- tea.ViewModel
- SetMaxWidth(maxWidth int)
- GetSelectedItem() (item T, idx int)
- SetItems(items []T)
- GetItems() []T
- SetSelectedIndex(idx int)
- SetEmptyMessage(msg string)
- IsEmpty() bool
- GetMaxVisibleHeight() int
-}
-
-type listComponent[T any] struct {
- fallbackMsg string
- items []T
- selectedIdx int
- maxWidth int
- maxVisibleHeight int
- useAlphaNumericKeys bool
- width int
- height int
- renderItem RenderFunc[T]
- isSelectable SelectableFunc[T]
- baseStyle styles.Style
-}
-
-type listKeyMap struct {
- Up key.Binding
- Down key.Binding
- UpAlpha key.Binding
- DownAlpha key.Binding
-}
-
-var simpleListKeys = listKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("↑", "previous list item"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("↓", "next list item"),
- ),
- UpAlpha: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous list item"),
- ),
- DownAlpha: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next list item"),
- ),
-}
-
-func (c *listComponent[T]) Init() tea.Cmd {
- return nil
-}
-
-func (c *listComponent[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
- c.moveUp()
- return c, nil
- case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
- c.moveDown()
- return c, nil
- }
- }
-
- return c, nil
-}
-
-// moveUp moves the selection up, skipping non-selectable items
-func (c *listComponent[T]) moveUp() {
- if len(c.items) == 0 {
- return
- }
-
- // Find the previous selectable item
- for i := c.selectedIdx - 1; i >= 0; i-- {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-
- // If no selectable item found above, wrap to the bottom
- for i := len(c.items) - 1; i > c.selectedIdx; i-- {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-}
-
-// moveDown moves the selection down, skipping non-selectable items
-func (c *listComponent[T]) moveDown() {
- if len(c.items) == 0 {
- return
- }
-
- originalIdx := c.selectedIdx
- // First try moving down from current position
- for i := c.selectedIdx + 1; i < len(c.items); i++ {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-
- // If no selectable item found below, wrap to the top
- for i := 0; i < originalIdx; i++ {
- if c.isSelectable(c.items[i]) {
- c.selectedIdx = i
- return
- }
- }
-}
-
-func (c *listComponent[T]) GetSelectedItem() (T, int) {
- if len(c.items) > 0 && c.isSelectable(c.items[c.selectedIdx]) {
- return c.items[c.selectedIdx], c.selectedIdx
- }
-
- var zero T
- return zero, -1
-}
-
-func (c *listComponent[T]) SetItems(items []T) {
- c.items = items
- c.selectedIdx = 0
-
- // Ensure initial selection is on a selectable item
- if len(items) > 0 && !c.isSelectable(items[0]) {
- c.moveDown()
- }
-}
-
-func (c *listComponent[T]) GetItems() []T {
- return c.items
-}
-
-func (c *listComponent[T]) SetEmptyMessage(msg string) {
- c.fallbackMsg = msg
-}
-
-func (c *listComponent[T]) IsEmpty() bool {
- return len(c.items) == 0
-}
-
-func (c *listComponent[T]) SetMaxWidth(width int) {
- c.maxWidth = width
-}
-
-func (c *listComponent[T]) SetSelectedIndex(idx int) {
- if idx >= 0 && idx < len(c.items) {
- c.selectedIdx = idx
- }
-}
-
-func (c *listComponent[T]) GetMaxVisibleHeight() int {
- return c.maxVisibleHeight
-}
-
-func (c *listComponent[T]) View() string {
- items := c.items
- maxWidth := c.maxWidth
- if maxWidth == 0 {
- maxWidth = 80 // Default width if not set
- }
-
- if len(items) <= 0 {
- return c.fallbackMsg
- }
-
- // Calculate viewport based on actual heights
- startIdx, endIdx := c.calculateViewport()
-
- listItems := make([]string, 0, endIdx-startIdx)
-
- for i := startIdx; i < endIdx; i++ {
- item := items[i]
-
- // Special handling for HeaderItem to remove top margin on first item
- if i == startIdx {
- // Check if this is a HeaderItem
- if _, ok := any(item).(Item); ok {
- if headerItem, isHeader := any(item).(HeaderItem); isHeader {
- // Render header without top margin when it's first
- t := theme.CurrentTheme()
- truncatedStr := truncate.StringWithTail(string(headerItem), uint(maxWidth-1), "...")
- headerStyle := c.baseStyle.
- Foreground(t.Accent()).
- Bold(true).
- MarginBottom(0).
- PaddingLeft(1)
- listItems = append(listItems, headerStyle.Render(truncatedStr))
- continue
- }
- }
- }
-
- title := c.renderItem(item, i == c.selectedIdx, maxWidth, c.baseStyle)
- listItems = append(listItems, title)
- }
-
- return strings.Join(listItems, "\n")
-}
-
-// calculateViewport determines which items to show based on available space
-func (c *listComponent[T]) calculateViewport() (startIdx, endIdx int) {
- items := c.items
- if len(items) == 0 {
- return 0, 0
- }
-
- // Calculate heights of all items
- itemHeights := make([]int, len(items))
- for i, item := range items {
- rendered := c.renderItem(item, false, c.maxWidth, c.baseStyle)
- itemHeights[i] = lipgloss.Height(rendered)
- }
-
- // Find the range of items that fit within maxVisibleHeight
- // Start by trying to center the selected item
- start := 0
- end := len(items)
-
- // Calculate height from start to selected
- heightToSelected := 0
- for i := 0; i <= c.selectedIdx && i < len(items); i++ {
- heightToSelected += itemHeights[i]
- }
-
- // If selected item is beyond visible height, scroll to show it
- if heightToSelected > c.maxVisibleHeight {
- // Start from selected and work backwards to find start
- currentHeight := itemHeights[c.selectedIdx]
- start = c.selectedIdx
-
- for i := c.selectedIdx - 1; i >= 0 && currentHeight+itemHeights[i] <= c.maxVisibleHeight; i-- {
- currentHeight += itemHeights[i]
- start = i
- }
- }
-
- // Calculate end based on start
- currentHeight := 0
- for i := start; i < len(items); i++ {
- if currentHeight+itemHeights[i] > c.maxVisibleHeight {
- end = i
- break
- }
- currentHeight += itemHeights[i]
- }
-
- return start, end
-}
-
-func abs(x int) int {
- if x < 0 {
- return -x
- }
- return x
-}
-
-func max(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
-
-func NewListComponent[T any](opts ...Option[T]) List[T] {
- options := &Options[T]{
- baseStyle: styles.NewStyle(), // Default empty style
- }
-
- for _, opt := range opts {
- opt(options)
- }
-
- return &listComponent[T]{
- fallbackMsg: options.fallbackMsg,
- items: options.items,
- maxVisibleHeight: options.maxVisibleHeight,
- useAlphaNumericKeys: options.useAlphaNumericKeys,
- selectedIdx: 0,
- renderItem: options.renderItem,
- isSelectable: options.isSelectable,
- baseStyle: options.baseStyle,
- }
-}
-
-// StringItem is a simple implementation of Item for string values
-type StringItem string
-
-func (s StringItem) Render(selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
-
- truncatedStr := truncate.StringWithTail(string(s), uint(width-1), "...")
-
- var itemStyle styles.Style
- if selected {
- itemStyle = baseStyle.
- Background(t.Primary()).
- Foreground(t.BackgroundElement()).
- Width(width).
- PaddingLeft(1)
- } else {
- itemStyle = baseStyle.
- Foreground(t.TextMuted()).
- PaddingLeft(1)
- }
-
- return itemStyle.Render(truncatedStr)
-}
-
-func (s StringItem) Selectable() bool {
- return true
-}
-
-// HeaderItem is a non-selectable header item for grouping
-type HeaderItem string
-
-func (h HeaderItem) Render(selected bool, width int, baseStyle styles.Style) string {
- t := theme.CurrentTheme()
-
- truncatedStr := truncate.StringWithTail(string(h), uint(width-1), "...")
-
- headerStyle := baseStyle.
- Foreground(t.Accent()).
- Bold(true).
- MarginTop(1).
- MarginBottom(0).
- PaddingLeft(1)
-
- return headerStyle.Render(truncatedStr)
-}
-
-func (h HeaderItem) Selectable() bool {
- return false
-}
-
-// Ensure StringItem and HeaderItem implement Item
-var _ Item = StringItem("")
-var _ Item = HeaderItem("")
diff --git a/packages/tui/internal/components/list/list_test.go b/packages/tui/internal/components/list/list_test.go
deleted file mode 100644
index 25cca8cf4..000000000
--- a/packages/tui/internal/components/list/list_test.go
+++ /dev/null
@@ -1,249 +0,0 @@
-package list
-
-import (
- "testing"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/sst/opencode/internal/styles"
-)
-
-// testItem is a simple test implementation of ListItem
-type testItem struct {
- value string
-}
-
-func (t testItem) Render(
- selected bool,
- width int,
- isFirstInViewport bool,
- baseStyle styles.Style,
-) string {
- return t.value
-}
-
-func (t testItem) Selectable() bool {
- return true
-}
-
-// createTestList creates a list with test items for testing
-func createTestList() *listComponent[testItem] {
- items := []testItem{
- {value: "item1"},
- {value: "item2"},
- {value: "item3"},
- }
- list := NewListComponent(
- WithItems(items),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](false),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- return list.(*listComponent[testItem])
-}
-
-func TestArrowKeyNavigation(t *testing.T) {
- list := createTestList()
-
- // Test down arrow navigation
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ := list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after down arrow, got %d", idx)
- }
-
- // Test up arrow navigation
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ = list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after up arrow, got %d", idx)
- }
-}
-
-func TestJKKeyNavigation(t *testing.T) {
- items := []testItem{
- {value: "item1"},
- {value: "item2"},
- {value: "item3"},
- }
- // Create list with alpha keys enabled
- list := NewListComponent(
- WithItems(items),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](true),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- // Test j key (down)
- jKey := tea.KeyPressMsg{Code: 'j', Text: "j"}
- updatedModel, _ := list.Update(jKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after 'j' key, got %d", idx)
- }
-
- // Test k key (up)
- kKey := tea.KeyPressMsg{Code: 'k', Text: "k"}
- updatedModel, _ = list.Update(kKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after 'k' key, got %d", idx)
- }
-}
-
-func TestCtrlNavigation(t *testing.T) {
- list := createTestList()
-
- // Test Ctrl-N (down)
- ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
- updatedModel, _ := list.Update(ctrlN)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected selected index 1 after Ctrl-N, got %d", idx)
- }
-
- // Test Ctrl-P (up)
- ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
- updatedModel, _ = list.Update(ctrlP)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected selected index 0 after Ctrl-P, got %d", idx)
- }
-}
-
-func TestNavigationBoundaries(t *testing.T) {
- list := createTestList()
-
- // Test up arrow at first item (should wrap to last item)
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ := list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx := list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to wrap to index 2 when pressing up at first item, got %d", idx)
- }
-
- // Move to first item
- list.SetSelectedIndex(0)
-
- // Move to last item
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to be at index 2, got %d", idx)
- }
-
- // Test down arrow at last item (should wrap to first item)
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to wrap to index 0 when pressing down at last item, got %d", idx)
- }
-}
-
-func TestEmptyList(t *testing.T) {
- emptyList := NewListComponent(
- WithItems([]testItem{}),
- WithMaxVisibleHeight[testItem](5),
- WithFallbackMessage[testItem]("empty"),
- WithAlphaNumericKeys[testItem](false),
- WithRenderFunc(
- func(item testItem, selected bool, width int, baseStyle styles.Style) string {
- return item.Render(selected, width, false, baseStyle)
- },
- ),
- WithSelectableFunc(func(item testItem) bool {
- return item.Selectable()
- }),
- )
-
- // Test navigation on empty list (should not crash)
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- ctrlN := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}
- ctrlP := tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}
-
- updatedModel, _ := emptyList.Update(downKey)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(upKey)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(ctrlN)
- emptyList = updatedModel.(*listComponent[testItem])
- updatedModel, _ = emptyList.Update(ctrlP)
- emptyList = updatedModel.(*listComponent[testItem])
-
- // Verify empty list behavior
- _, idx := emptyList.GetSelectedItem()
- if idx != -1 {
- t.Errorf("Expected index -1 for empty list, got %d", idx)
- }
-
- if !emptyList.IsEmpty() {
- t.Error("Expected IsEmpty() to return true for empty list")
- }
-}
-
-func TestWrapAroundNavigation(t *testing.T) {
- list := createTestList()
-
- // Start at first item (index 0)
- _, idx := list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to start at index 0, got %d", idx)
- }
-
- // Press up arrow - should wrap to last item (index 2)
- upKey := tea.KeyPressMsg{Code: tea.KeyUp}
- updatedModel, _ := list.Update(upKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 2 {
- t.Errorf("Expected to wrap to index 2 when pressing up from first item, got %d", idx)
- }
-
- // Press down arrow - should wrap to first item (index 0)
- downKey := tea.KeyPressMsg{Code: tea.KeyDown}
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 0 {
- t.Errorf("Expected to wrap to index 0 when pressing down from last item, got %d", idx)
- }
-
- // Navigate to middle and verify normal navigation still works
- updatedModel, _ = list.Update(downKey)
- list = updatedModel.(*listComponent[testItem])
- _, idx = list.GetSelectedItem()
- if idx != 1 {
- t.Errorf("Expected to move to index 1, got %d", idx)
- }
-}
diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go
deleted file mode 100644
index 09989d8ec..000000000
--- a/packages/tui/internal/components/modal/modal.go
+++ /dev/null
@@ -1,145 +0,0 @@
-package modal
-
-import (
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// CloseModalMsg is a message to signal that the active modal should be closed.
-type CloseModalMsg struct{}
-
-// Modal is a reusable modal component that handles frame rendering and overlay placement
-type Modal struct {
- width int
- height int
- title string
- maxWidth int
- maxHeight int
- fitContent bool
-}
-
-// ModalOption is a function that configures a Modal
-type ModalOption func(*Modal)
-
-// WithTitle sets the modal title
-func WithTitle(title string) ModalOption {
- return func(m *Modal) {
- m.title = title
- }
-}
-
-// WithMaxWidth sets the maximum width
-func WithMaxWidth(width int) ModalOption {
- return func(m *Modal) {
- m.maxWidth = width
- m.fitContent = false
- }
-}
-
-// WithMaxHeight sets the maximum height
-func WithMaxHeight(height int) ModalOption {
- return func(m *Modal) {
- m.maxHeight = height
- }
-}
-
-func WithFitContent(fit bool) ModalOption {
- return func(m *Modal) {
- m.fitContent = fit
- }
-}
-
-// New creates a new Modal with the given options
-func New(opts ...ModalOption) *Modal {
- m := &Modal{
- maxWidth: 0,
- maxHeight: 0,
- fitContent: true,
- }
-
- for _, opt := range opts {
- opt(m)
- }
-
- return m
-}
-
-func (m *Modal) SetTitle(title string) {
- m.title = title
-}
-
-// Render renders the modal centered on the screen
-func (m *Modal) Render(contentView string, background string) string {
- t := theme.CurrentTheme()
-
- outerWidth := layout.Current.Container.Width - 8
- if m.maxWidth > 0 && outerWidth > m.maxWidth {
- outerWidth = m.maxWidth
- }
-
- if m.fitContent {
- titleWidth := lipgloss.Width(m.title)
- contentWidth := lipgloss.Width(contentView)
- largestWidth := max(titleWidth+2, contentWidth)
- outerWidth = largestWidth + 6
- }
-
- innerWidth := outerWidth - 4
-
- baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel())
-
- var finalContent string
- if m.title != "" {
- titleStyle := baseStyle.
- Foreground(t.Text()).
- Bold(true).
- Padding(0, 1)
-
- escStyle := baseStyle.Foreground(t.TextMuted())
- escText := escStyle.Render("esc")
-
- // Calculate position for esc text
- titleWidth := lipgloss.Width(m.title)
- escWidth := lipgloss.Width(escText)
- spacesNeeded := max(0, innerWidth-titleWidth-escWidth-2)
- spacer := strings.Repeat(" ", spacesNeeded)
- titleLine := m.title + spacer + escText
- titleLine = titleStyle.Render(titleLine)
-
- finalContent = strings.Join([]string{titleLine, "", contentView}, "\n")
- } else {
- finalContent = contentView
- }
-
- modalStyle := baseStyle.
- PaddingTop(1).
- PaddingBottom(1).
- PaddingLeft(2).
- PaddingRight(2)
-
- modalView := modalStyle.
- Width(outerWidth).
- Render(finalContent)
-
- // Calculate position for centering
- bgHeight := lipgloss.Height(background)
- bgWidth := lipgloss.Width(background)
- modalHeight := lipgloss.Height(modalView)
- modalWidth := lipgloss.Width(modalView)
-
- row := (bgHeight - modalHeight) / 2
- col := (bgWidth - modalWidth) / 2
-
- return layout.PlaceOverlay(
- col-1, // TODO: whyyyyy
- row,
- modalView,
- background,
- layout.WithOverlayBorder(),
- layout.WithOverlayBorderColor(t.BorderActive()),
- )
-}
diff --git a/packages/tui/internal/components/qr/qr.go b/packages/tui/internal/components/qr/qr.go
deleted file mode 100644
index 233bcf524..000000000
--- a/packages/tui/internal/components/qr/qr.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package qr
-
-import (
- "strings"
-
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "rsc.io/qr"
-)
-
-var tops_bottoms = []rune{' ', '▀', '▄', '█'}
-
-// Generate a text string to a QR code, which you can write to a terminal or file.
-func Generate(text string) (string, int, error) {
- code, err := qr.Encode(text, qr.Level(0))
- if err != nil {
- return "", 0, err
- }
-
- t := theme.CurrentTheme()
- if t == nil {
- return "", 0, err
- }
-
- // Create lipgloss style for QR code with theme colors
- qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
-
- var result strings.Builder
-
- // content
- for y := 0; y < code.Size-1; y += 2 {
- var line strings.Builder
- for x := 0; x < code.Size; x += 1 {
- var num int8
- if code.Black(x, y) {
- num += 1
- }
- if code.Black(x, y+1) {
- num += 2
- }
- line.WriteRune(tops_bottoms[num])
- }
- result.WriteString(qrStyle.Render(line.String()) + "\n")
- }
-
- // add lower border when required (only required when QR size is odd)
- if code.Size%2 == 1 {
- var borderLine strings.Builder
- for range code.Size {
- borderLine.WriteRune('▀')
- }
- result.WriteString(qrStyle.Render(borderLine.String()) + "\n")
- }
-
- return result.String(), code.Size, nil
-}
diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go
deleted file mode 100644
index aba80900b..000000000
--- a/packages/tui/internal/components/status/status.go
+++ /dev/null
@@ -1,340 +0,0 @@
-package status
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/fsnotify/fsnotify"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-type GitBranchUpdatedMsg struct {
- Branch string
-}
-
-type StatusComponent interface {
- tea.Model
- tea.ViewModel
- Cleanup()
-}
-
-type statusComponent struct {
- app *app.App
- width int
- cwd string
- branch string
- watcher *fsnotify.Watcher
- done chan struct{}
- lastUpdate time.Time
-}
-
-func (m *statusComponent) Init() tea.Cmd {
- return m.startGitWatcher()
-}
-
-func (m *statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width
- return m, nil
- case GitBranchUpdatedMsg:
- if m.branch != msg.Branch {
- m.branch = msg.Branch
- }
- // Continue watching for changes (persistent watcher)
- return m, m.watchForGitChanges()
- }
- return m, nil
-}
-
-func (m *statusComponent) logo() string {
- t := theme.CurrentTheme()
- base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
- emphasis := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundElement()).
- Bold(true).
- Render
-
- open := base("open")
- code := emphasis("code")
- version := base(" " + m.app.Version)
-
- content := open + code
- if m.width > 40 {
- content += version
- }
- return styles.NewStyle().
- Background(t.BackgroundElement()).
- Padding(0, 1).
- Render(content)
-}
-
-func (m *statusComponent) collapsePath(path string, maxWidth int) string {
- if lipgloss.Width(path) <= maxWidth {
- return path
- }
-
- const ellipsis = ".."
- ellipsisLen := len(ellipsis)
-
- if maxWidth <= ellipsisLen {
- if maxWidth > 0 {
- return "..."[:maxWidth]
- }
- return ""
- }
-
- separator := string(filepath.Separator)
- parts := strings.Split(path, separator)
-
- if len(parts) == 1 {
- return path[:maxWidth-ellipsisLen] + ellipsis
- }
-
- truncatedPath := parts[len(parts)-1]
- for i := len(parts) - 2; i >= 0; i-- {
- part := parts[i]
- if len(truncatedPath)+len(separator)+len(part)+ellipsisLen > maxWidth {
- return ellipsis + separator + truncatedPath
- }
- truncatedPath = part + separator + truncatedPath
- }
- return truncatedPath
-}
-
-func (m *statusComponent) View() string {
- t := theme.CurrentTheme()
- logo := m.logo()
- logoWidth := lipgloss.Width(logo)
-
- var modeBackground compat.AdaptiveColor
- var modeForeground compat.AdaptiveColor
-
- agentColor := util.GetAgentColor(m.app.AgentIndex)
-
- if m.app.AgentIndex == 0 {
- modeBackground = t.BackgroundElement()
- modeForeground = agentColor
- } else {
- modeBackground = agentColor
- modeForeground = t.BackgroundPanel()
- }
-
- command := m.app.Commands[commands.AgentCycleCommand]
- kb := command.Keybindings[0]
- key := kb.Key
- if kb.RequiresLeader {
- key = m.app.Config.Keybinds.Leader + " " + kb.Key
- }
-
- agentStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
- agentNameStyle := agentStyle.Bold(true).Render
- agentDescStyle := agentStyle.Render
- agent := agentNameStyle(strings.ToUpper(m.app.Agent().Name)) + agentDescStyle(" AGENT")
- agent = agentStyle.
- Padding(0, 1).
- BorderLeft(true).
- BorderStyle(lipgloss.ThickBorder()).
- BorderForeground(modeBackground).
- BorderBackground(t.BackgroundPanel()).
- Render(agent)
-
- faintStyle := styles.NewStyle().
- Faint(true).
- Background(t.BackgroundPanel()).
- Foreground(t.TextMuted())
- agent = faintStyle.Render(key+" ") + agent
- modeWidth := lipgloss.Width(agent)
-
- availableWidth := m.width - logoWidth - modeWidth
- branchSuffix := ""
- if m.branch != "" {
- branchSuffix = ":" + m.branch
- }
-
- maxCwdWidth := availableWidth - lipgloss.Width(branchSuffix)
- cwdDisplay := m.collapsePath(m.cwd, maxCwdWidth)
-
- if m.branch != "" && availableWidth > lipgloss.Width(cwdDisplay)+lipgloss.Width(branchSuffix) {
- cwdDisplay += faintStyle.Render(branchSuffix)
- }
-
- cwd := styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.BackgroundPanel()).
- Padding(0, 1).
- Render(cwdDisplay)
-
- background := t.BackgroundPanel()
- status := layout.Render(
- layout.FlexOptions{
- Background: &background,
- Direction: layout.Row,
- Justify: layout.JustifySpaceBetween,
- Align: layout.AlignStretch,
- Width: m.width,
- },
- layout.FlexItem{
- View: logo + cwd,
- },
- layout.FlexItem{
- View: agent,
- },
- )
-
- blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
- return blank + "\n" + status
-}
-
-func (m *statusComponent) startGitWatcher() tea.Cmd {
- cmd := util.CmdHandler(
- GitBranchUpdatedMsg{Branch: getCurrentGitBranch(util.CwdPath)},
- )
- if err := m.initWatcher(); err != nil {
- return cmd
- }
- return tea.Batch(cmd, m.watchForGitChanges())
-}
-
-func (m *statusComponent) initWatcher() error {
- gitDir := filepath.Join(util.CwdPath, ".git")
- headFile := filepath.Join(gitDir, "HEAD")
- if info, err := os.Stat(gitDir); err != nil || !info.IsDir() {
- return err
- }
-
- watcher, err := fsnotify.NewWatcher()
- if err != nil {
- return err
- }
-
- if err := watcher.Add(headFile); err != nil {
- watcher.Close()
- return err
- }
-
- // Also watch the ref file if HEAD points to a ref
- refFile := getGitRefFile(util.CwdPath)
- if refFile != headFile && refFile != "" {
- if _, err := os.Stat(refFile); err == nil {
- watcher.Add(refFile) // Ignore error, HEAD watching is sufficient
- }
- }
-
- m.watcher = watcher
- m.done = make(chan struct{})
- return nil
-}
-
-func (m *statusComponent) watchForGitChanges() tea.Cmd {
- if m.watcher == nil {
- return nil
- }
-
- return tea.Cmd(func() tea.Msg {
- for {
- select {
- case event, ok := <-m.watcher.Events:
- branch := getCurrentGitBranch(util.CwdPath)
- if !ok {
- return GitBranchUpdatedMsg{Branch: branch}
- }
- if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
- // Debounce updates to prevent excessive refreshes
- now := time.Now()
- if now.Sub(m.lastUpdate) < 100*time.Millisecond {
- continue
- }
- m.lastUpdate = now
- if strings.HasSuffix(event.Name, "HEAD") {
- m.updateWatchedFiles()
- }
- return GitBranchUpdatedMsg{Branch: branch}
- }
- case <-m.watcher.Errors:
- // Continue watching even on errors
- case <-m.done:
- return GitBranchUpdatedMsg{Branch: ""}
- }
- }
- })
-}
-
-func (m *statusComponent) updateWatchedFiles() {
- if m.watcher == nil {
- return
- }
- refFile := getGitRefFile(util.CwdPath)
- headFile := filepath.Join(util.CwdPath, ".git", "HEAD")
- if refFile != headFile && refFile != "" {
- if _, err := os.Stat(refFile); err == nil {
- // Try to add the new ref file (ignore error if already watching)
- m.watcher.Add(refFile)
- }
- }
-}
-
-func getCurrentGitBranch(cwd string) string {
- cmd := exec.Command("git", "branch", "--show-current")
- cmd.Dir = cwd
- output, err := cmd.Output()
- if err != nil {
- return ""
- }
- return strings.TrimSpace(string(output))
-}
-
-func getGitRefFile(cwd string) string {
- headFile := filepath.Join(cwd, ".git", "HEAD")
- content, err := os.ReadFile(headFile)
- if err != nil {
- return ""
- }
-
- headContent := strings.TrimSpace(string(content))
- if after, ok := strings.CutPrefix(headContent, "ref: "); ok {
- // HEAD points to a ref file
- refPath := after
- return filepath.Join(cwd, ".git", refPath)
- }
-
- // HEAD contains a direct commit hash
- return headFile
-}
-
-func (m *statusComponent) Cleanup() {
- if m.done != nil {
- close(m.done)
- }
- if m.watcher != nil {
- m.watcher.Close()
- }
-}
-
-func NewStatusCmp(app *app.App) StatusComponent {
- statusComponent := &statusComponent{
- app: app,
- lastUpdate: time.Now(),
- }
-
- homePath, err := os.UserHomeDir()
- cwdPath := util.CwdPath
- if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
- cwdPath = "~" + cwdPath[len(homePath):]
- }
- statusComponent.cwd = cwdPath
-
- return statusComponent
-}
diff --git a/packages/tui/internal/components/status/status_test.go b/packages/tui/internal/components/status/status_test.go
deleted file mode 100644
index 1e1caf8ac..000000000
--- a/packages/tui/internal/components/status/status_test.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package status
-
-import (
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestGetCurrentGitBranch(t *testing.T) {
- // Test in current directory (should be a git repo)
- branch := getCurrentGitBranch(".")
- if branch == "" {
- t.Skip("Not in a git repository, skipping test")
- }
- t.Logf("Current branch: %s", branch)
-}
-
-func TestGetGitRefFile(t *testing.T) {
- // Create a temporary git directory structure for testing
- tmpDir := t.TempDir()
- gitDir := filepath.Join(tmpDir, ".git")
- err := os.MkdirAll(gitDir, 0755)
- if err != nil {
- t.Fatal(err)
- }
-
- // Test case 1: HEAD points to a ref
- headFile := filepath.Join(gitDir, "HEAD")
- err = os.WriteFile(headFile, []byte("ref: refs/heads/main\n"), 0644)
- if err != nil {
- t.Fatal(err)
- }
-
- refFile := getGitRefFile(tmpDir)
- expected := filepath.Join(gitDir, "refs", "heads", "main")
- if refFile != expected {
- t.Errorf("Expected %s, got %s", expected, refFile)
- }
-
- // Test case 2: HEAD contains a direct commit hash
- err = os.WriteFile(headFile, []byte("abc123def456\n"), 0644)
- if err != nil {
- t.Fatal(err)
- }
-
- refFile = getGitRefFile(tmpDir)
- if refFile != headFile {
- t.Errorf("Expected %s, got %s", headFile, refFile)
- }
-}
-
-func TestFileWatcherIntegration(t *testing.T) {
- // This test requires being in a git repository
- if getCurrentGitBranch(".") == "" {
- t.Skip("Not in a git repository, skipping integration test")
- }
-
- // Test that the file watcher setup doesn't crash
- tmpDir := t.TempDir()
- gitDir := filepath.Join(tmpDir, ".git")
- err := os.MkdirAll(gitDir, 0755)
- if err != nil {
- t.Fatal(err)
- }
-
- headFile := filepath.Join(gitDir, "HEAD")
- err = os.WriteFile(headFile, []byte("ref: refs/heads/main\n"), 0644)
- if err != nil {
- t.Fatal(err)
- }
-
- // Create the refs directory and file
- refsDir := filepath.Join(gitDir, "refs", "heads")
- err = os.MkdirAll(refsDir, 0755)
- if err != nil {
- t.Fatal(err)
- }
-
- mainRef := filepath.Join(refsDir, "main")
- err = os.WriteFile(mainRef, []byte("abc123def456\n"), 0644)
- if err != nil {
- t.Fatal(err)
- }
-
- // Test that we can create a watcher without crashing
- // This is a basic smoke test
- done := make(chan bool, 1)
- go func() {
- time.Sleep(100 * time.Millisecond)
- done <- true
- }()
-
- select {
- case <-done:
- // Test passed - no crash
- case <-time.After(1 * time.Second):
- t.Error("Test timed out")
- }
-}
diff --git a/packages/tui/internal/components/textarea/memoization.go b/packages/tui/internal/components/textarea/memoization.go
deleted file mode 100644
index 2c9aec4f7..000000000
--- a/packages/tui/internal/components/textarea/memoization.go
+++ /dev/null
@@ -1,125 +0,0 @@
-// Package memoization implement a simple memoization cache. It's designed to
-// improve performance in textarea.
-package textarea
-
-import (
- "container/list"
- "crypto/sha256"
- "fmt"
- "sync"
-)
-
-// Hasher is an interface that requires a Hash method. The Hash method is
-// expected to return a string representation of the hash of the object.
-type Hasher interface {
- Hash() string
-}
-
-// entry is a struct that holds a key-value pair. It is used as an element
-// in the evictionList of the MemoCache.
-type entry[T any] struct {
- key string
- value T
-}
-
-// MemoCache is a struct that represents a cache with a set capacity. It
-// uses an LRU (Least Recently Used) eviction policy. It is safe for
-// concurrent use.
-type MemoCache[H Hasher, T any] struct {
- capacity int
- mutex sync.Mutex
- cache map[string]*list.Element // The cache holding the results
- evictionList *list.List // A list to keep track of the order for LRU
- hashableItems map[string]T // This map keeps track of the original hashable items (optional)
-}
-
-// NewMemoCache is a function that creates a new MemoCache with a given
-// capacity. It returns a pointer to the created MemoCache.
-func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
- return &MemoCache[H, T]{
- capacity: capacity,
- cache: make(map[string]*list.Element),
- evictionList: list.New(),
- hashableItems: make(map[string]T),
- }
-}
-
-// Capacity is a method that returns the capacity of the MemoCache.
-func (m *MemoCache[H, T]) Capacity() int {
- return m.capacity
-}
-
-// Size is a method that returns the current size of the MemoCache. It is
-// the number of items currently stored in the cache.
-func (m *MemoCache[H, T]) Size() int {
- m.mutex.Lock()
- defer m.mutex.Unlock()
- return m.evictionList.Len()
-}
-
-// Get is a method that returns the value associated with the given
-// hashable item in the MemoCache. If there is no corresponding value, the
-// method returns nil.
-func (m *MemoCache[H, T]) Get(h H) (T, bool) {
- m.mutex.Lock()
- defer m.mutex.Unlock()
-
- hashedKey := h.Hash()
- if element, found := m.cache[hashedKey]; found {
- m.evictionList.MoveToFront(element)
- return element.Value.(*entry[T]).value, true
- }
- var result T
- return result, false
-}
-
-// Set is a method that sets the value for the given hashable item in the
-// MemoCache. If the cache is at capacity, it evicts the least recently
-// used item before adding the new item.
-func (m *MemoCache[H, T]) Set(h H, value T) {
- m.mutex.Lock()
- defer m.mutex.Unlock()
-
- hashedKey := h.Hash()
- if element, found := m.cache[hashedKey]; found {
- m.evictionList.MoveToFront(element)
- element.Value.(*entry[T]).value = value
- return
- }
-
- // Check if the cache is at capacity
- if m.evictionList.Len() >= m.capacity {
- // Evict the least recently used item from the cache
- toEvict := m.evictionList.Back()
- if toEvict != nil {
- evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
- delete(m.cache, evictedEntry.key)
- delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
- }
- }
-
- // Add the value to the cache and the evictionList
- newEntry := &entry[T]{
- key: hashedKey,
- value: value,
- }
- element := m.evictionList.PushFront(newEntry)
- m.cache[hashedKey] = element
- m.hashableItems[hashedKey] = value // if you're keeping track of original items
-}
-
-// HString is a type that implements the Hasher interface for strings.
-type HString string
-
-// Hash is a method that returns the hash of the string.
-func (h HString) Hash() string {
- return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
-}
-
-// HInt is a type that implements the Hasher interface for integers.
-type HInt int
-
-// Hash is a method that returns the hash of the integer.
-func (h HInt) Hash() string {
- return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
-}
diff --git a/packages/tui/internal/components/textarea/runeutil.go b/packages/tui/internal/components/textarea/runeutil.go
deleted file mode 100644
index c4fc87f80..000000000
--- a/packages/tui/internal/components/textarea/runeutil.go
+++ /dev/null
@@ -1,102 +0,0 @@
-// Package runeutil provides utility functions for tidying up incoming runes
-// from Key messages.
-package textarea
-
-import (
- "unicode"
- "unicode/utf8"
-)
-
-// Sanitizer is a helper for bubble widgets that want to process
-// Runes from input key messages.
-type Sanitizer interface {
- // Sanitize removes control characters from runes in a KeyRunes
- // message, and optionally replaces newline/carriage return/tabs by a
- // specified character.
- //
- // The rune array is modified in-place if possible. In that case, the
- // returned slice is the original slice shortened after the control
- // characters have been removed/translated.
- Sanitize(runes []rune) []rune
-}
-
-// NewSanitizer constructs a rune sanitizer.
-func NewSanitizer(opts ...Option) Sanitizer {
- s := sanitizer{
- replaceNewLine: []rune("\n"),
- replaceTab: []rune(" "),
- }
- for _, o := range opts {
- s = o(s)
- }
- return &s
-}
-
-// Option is the type of option that can be passed to Sanitize().
-type Option func(sanitizer) sanitizer
-
-// ReplaceTabs replaces tabs by the specified string.
-func ReplaceTabs(tabRepl string) Option {
- return func(s sanitizer) sanitizer {
- s.replaceTab = []rune(tabRepl)
- return s
- }
-}
-
-// ReplaceNewlines replaces newline characters by the specified string.
-func ReplaceNewlines(nlRepl string) Option {
- return func(s sanitizer) sanitizer {
- s.replaceNewLine = []rune(nlRepl)
- return s
- }
-}
-
-func (s *sanitizer) Sanitize(runes []rune) []rune {
- // dstrunes are where we are storing the result.
- dstrunes := runes[:0:len(runes)]
- // copied indicates whether dstrunes is an alias of runes
- // or a copy. We need a copy when dst moves past src.
- // We use this as an optimization to avoid allocating
- // a new rune slice in the common case where the output
- // is smaller or equal to the input.
- copied := false
-
- for src := 0; src < len(runes); src++ {
- r := runes[src]
- switch {
- case r == utf8.RuneError:
- // skip
-
- case r == '\r' || r == '\n':
- if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
- dst := len(dstrunes)
- dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
- copy(dstrunes, runes[:dst])
- copied = true
- }
- dstrunes = append(dstrunes, s.replaceNewLine...)
-
- case r == '\t':
- if len(dstrunes)+len(s.replaceTab) > src && !copied {
- dst := len(dstrunes)
- dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
- copy(dstrunes, runes[:dst])
- copied = true
- }
- dstrunes = append(dstrunes, s.replaceTab...)
-
- case unicode.IsControl(r):
- // Other control characters: skip.
-
- default:
- // Keep the character.
- dstrunes = append(dstrunes, runes[src])
- }
- }
- return dstrunes
-}
-
-type sanitizer struct {
- replaceNewLine []rune
- replaceTab []rune
-}
diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go
deleted file mode 100644
index 6e6695917..000000000
--- a/packages/tui/internal/components/textarea/textarea.go
+++ /dev/null
@@ -1,2377 +0,0 @@
-package textarea
-
-import (
- "crypto/sha256"
- "fmt"
- "image/color"
- "strconv"
- "strings"
- "time"
- "unicode"
-
- "slices"
-
- "github.com/charmbracelet/bubbles/v2/cursor"
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- rw "github.com/mattn/go-runewidth"
- "github.com/rivo/uniseg"
- "github.com/sst/opencode/internal/attachment"
-)
-
-const (
- minHeight = 1
- defaultHeight = 1
- defaultWidth = 40
- defaultCharLimit = 0 // no limit
- defaultMaxHeight = 99
- defaultMaxWidth = 500
-
- // XXX: in v2, make max lines dynamic and default max lines configurable.
- maxLines = 10000
-)
-
-// Helper functions for converting between runes and any slices
-
-// runesToInterfaces converts a slice of runes to a slice of interfaces
-func runesToInterfaces(runes []rune) []any {
- result := make([]any, len(runes))
- for i, r := range runes {
- result[i] = r
- }
- return result
-}
-
-// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes)
-func interfacesToRunes(items []any) []rune {
- var result []rune
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- result = append(result, val)
- case *attachment.Attachment:
- result = append(result, []rune(val.Display)...)
- }
- }
- return result
-}
-
-// copyInterfaceSlice creates a copy of an any slice
-func copyInterfaceSlice(src []any) []any {
- dst := make([]any, len(src))
- copy(dst, src)
- return dst
-}
-
-// interfacesToString converts a slice of interfaces to a string for display
-func interfacesToString(items []any) string {
- var s strings.Builder
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- s.WriteRune(val)
- case *attachment.Attachment:
- s.WriteString(val.Display)
- }
- }
- return s.String()
-}
-
-// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment.
-// This allows for proper highlighting even when the cursor is technically at the position
-// after the attachment object in the underlying slice.
-func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) {
- if m.row >= len(m.value) {
- return nil, -1, -1
- }
-
- row := m.value[m.row]
- col := m.col
-
- if col < 0 || col > len(row) {
- return nil, -1, -1
- }
-
- // Check if the cursor is at the same index as an attachment.
- if col < len(row) {
- if att, ok := row[col].(*attachment.Attachment); ok {
- return att, col, col
- }
- }
-
- // Check if the cursor is immediately after an attachment. This is a common
- // state, for example, after just inserting one.
- if col > 0 && col <= len(row) {
- if att, ok := row[col-1].(*attachment.Attachment); ok {
- return att, col - 1, col - 1
- }
- }
-
- return nil, -1, -1
-}
-
-// renderLineWithAttachments renders a line with proper attachment highlighting
-func (m Model) renderLineWithAttachments(
- items []any,
- style lipgloss.Style,
-) string {
- var s strings.Builder
- currentAttachment, _, _ := m.isAttachmentAtCursor()
-
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- s.WriteString(style.Render(string(val)))
- case *attachment.Attachment:
- // Check if this is the attachment the cursor is currently on
- if currentAttachment != nil && currentAttachment.ID == val.ID {
- // Cursor is on this attachment, highlight it
- s.WriteString(m.Styles.SelectedAttachment.Render(val.Display))
- } else {
- s.WriteString(m.Styles.Attachment.Render(val.Display))
- }
- }
- }
- return s.String()
-}
-
-// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune
-func getRuneAt(items []any, index int) rune {
- if index < 0 || index >= len(items) {
- return 0
- }
- if r, ok := items[index].(rune); ok {
- return r
- }
- return 0
-}
-
-// isSpaceAt checks if the item at index is a space rune
-func isSpaceAt(items []any, index int) bool {
- r := getRuneAt(items, index)
- return r != 0 && unicode.IsSpace(r)
-}
-
-// setRuneAt safely sets a rune at a specific position if it's a rune
-func setRuneAt(items []any, index int, r rune) {
- if index >= 0 && index < len(items) {
- if _, ok := items[index].(rune); ok {
- items[index] = r
- }
- }
-}
-
-// Internal messages for clipboard operations.
-type (
- pasteMsg string
- pasteErrMsg struct{ error }
-)
-
-// KeyMap is the key bindings for different actions within the textarea.
-type KeyMap struct {
- CharacterBackward key.Binding
- CharacterForward key.Binding
- DeleteAfterCursor key.Binding
- DeleteBeforeCursor key.Binding
- DeleteCharacterBackward key.Binding
- DeleteCharacterForward key.Binding
- DeleteWordBackward key.Binding
- DeleteWordForward key.Binding
- InsertNewline key.Binding
- LineEnd key.Binding
- LineNext key.Binding
- LinePrevious key.Binding
- LineStart key.Binding
- Paste key.Binding
- WordBackward key.Binding
- WordForward key.Binding
- InputBegin key.Binding
- InputEnd key.Binding
-
- UppercaseWordForward key.Binding
- LowercaseWordForward key.Binding
- CapitalizeWordForward key.Binding
-
- TransposeCharacterBackward key.Binding
-}
-
-// DefaultKeyMap returns the default set of key bindings for navigating and acting
-// upon the textarea.
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- CharacterForward: key.NewBinding(
- key.WithKeys("right", "ctrl+f"),
- key.WithHelp("right", "character forward"),
- ),
- CharacterBackward: key.NewBinding(
- key.WithKeys("left", "ctrl+b"),
- key.WithHelp("left", "character backward"),
- ),
- WordForward: key.NewBinding(
- key.WithKeys("alt+right", "ctrl+right", "alt+f"),
- key.WithHelp("alt+right", "word forward"),
- ),
- WordBackward: key.NewBinding(
- key.WithKeys("alt+left", "ctrl+left", "alt+b"),
- key.WithHelp("alt+left", "word backward"),
- ),
- LineNext: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("down", "next line"),
- ),
- LinePrevious: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("up", "previous line"),
- ),
- DeleteWordBackward: key.NewBinding(
- key.WithKeys("alt+backspace", "ctrl+w"),
- key.WithHelp("alt+backspace", "delete word backward"),
- ),
- DeleteWordForward: key.NewBinding(
- key.WithKeys("alt+delete", "alt+d"),
- key.WithHelp("alt+delete", "delete word forward"),
- ),
- DeleteAfterCursor: key.NewBinding(
- key.WithKeys("ctrl+k"),
- key.WithHelp("ctrl+k", "delete after cursor"),
- ),
- DeleteBeforeCursor: key.NewBinding(
- key.WithKeys("ctrl+u"),
- key.WithHelp("ctrl+u", "delete before cursor"),
- ),
- InsertNewline: key.NewBinding(
- key.WithKeys("enter", "ctrl+m"),
- key.WithHelp("enter", "insert newline"),
- ),
- DeleteCharacterBackward: key.NewBinding(
- key.WithKeys("backspace", "ctrl+h"),
- key.WithHelp("backspace", "delete character backward"),
- ),
- DeleteCharacterForward: key.NewBinding(
- key.WithKeys("delete", "ctrl+d"),
- key.WithHelp("delete", "delete character forward"),
- ),
- LineStart: key.NewBinding(
- key.WithKeys("home", "ctrl+a"),
- key.WithHelp("home", "line start"),
- ),
- LineEnd: key.NewBinding(
- key.WithKeys("end", "ctrl+e"),
- key.WithHelp("end", "line end"),
- ),
- Paste: key.NewBinding(
- key.WithKeys("ctrl+v"),
- key.WithHelp("ctrl+v", "paste"),
- ),
- InputBegin: key.NewBinding(
- key.WithKeys("alt+<", "ctrl+home"),
- key.WithHelp("alt+<", "input begin"),
- ),
- InputEnd: key.NewBinding(
- key.WithKeys("alt+>", "ctrl+end"),
- key.WithHelp("alt+>", "input end"),
- ),
-
- CapitalizeWordForward: key.NewBinding(
- key.WithKeys("alt+c"),
- key.WithHelp("alt+c", "capitalize word forward"),
- ),
- LowercaseWordForward: key.NewBinding(
- key.WithKeys("alt+l"),
- key.WithHelp("alt+l", "lowercase word forward"),
- ),
- UppercaseWordForward: key.NewBinding(
- key.WithKeys("alt+u"),
- key.WithHelp("alt+u", "uppercase word forward"),
- ),
-
- TransposeCharacterBackward: key.NewBinding(
- key.WithKeys("ctrl+t"),
- key.WithHelp("ctrl+t", "transpose character backward"),
- ),
- }
-}
-
-// LineInfo is a helper for keeping track of line information regarding
-// soft-wrapped lines.
-type LineInfo struct {
- // Width is the number of columns in the line.
- Width int
-
- // CharWidth is the number of characters in the line to account for
- // double-width runes.
- CharWidth int
-
- // Height is the number of rows in the line.
- Height int
-
- // StartColumn is the index of the first column of the line.
- StartColumn int
-
- // ColumnOffset is the number of columns that the cursor is offset from the
- // start of the line.
- ColumnOffset int
-
- // RowOffset is the number of rows that the cursor is offset from the start
- // of the line.
- RowOffset int
-
- // CharOffset is the number of characters that the cursor is offset
- // from the start of the line. This will generally be equivalent to
- // ColumnOffset, but will be different there are double-width runes before
- // the cursor.
- CharOffset int
-}
-
-// CursorStyle is the style for real and virtual cursors.
-type CursorStyle struct {
- // Style styles the cursor block.
- //
- // For real cursors, the foreground color set here will be used as the
- // cursor color.
- Color color.Color
-
- // Shape is the cursor shape. The following shapes are available:
- //
- // - tea.CursorBlock
- // - tea.CursorUnderline
- // - tea.CursorBar
- //
- // This is only used for real cursors.
- Shape tea.CursorShape
-
- // CursorBlink determines whether or not the cursor should blink.
- Blink bool
-
- // BlinkSpeed is the speed at which the virtual cursor blinks. This has no
- // effect on real cursors as well as no effect if the cursor is set not to
- // [CursorBlink].
- //
- // By default, the blink speed is set to about 500ms.
- BlinkSpeed time.Duration
-}
-
-// Styles are the styles for the textarea, separated into focused and blurred
-// states. The appropriate styles will be chosen based on the focus state of
-// the textarea.
-type Styles struct {
- Focused StyleState
- Blurred StyleState
- Cursor CursorStyle
- Attachment lipgloss.Style
- SelectedAttachment lipgloss.Style
-}
-
-// StyleState that will be applied to the text area.
-//
-// StyleState can be applied to focused and unfocused states to change the styles
-// depending on the focus state.
-//
-// For an introduction to styling with Lip Gloss see:
-// https://github.com/charmbracelet/lipgloss
-type StyleState struct {
- Base lipgloss.Style
- Text lipgloss.Style
- LineNumber lipgloss.Style
- CursorLineNumber lipgloss.Style
- CursorLine lipgloss.Style
- EndOfBuffer lipgloss.Style
- Placeholder lipgloss.Style
- Prompt lipgloss.Style
-}
-
-func (s StyleState) computedCursorLine() lipgloss.Style {
- return s.CursorLine.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedCursorLineNumber() lipgloss.Style {
- return s.CursorLineNumber.
- Inherit(s.CursorLine).
- Inherit(s.Base).
- Inline(true)
-}
-
-func (s StyleState) computedEndOfBuffer() lipgloss.Style {
- return s.EndOfBuffer.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedLineNumber() lipgloss.Style {
- return s.LineNumber.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedPlaceholder() lipgloss.Style {
- return s.Placeholder.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedPrompt() lipgloss.Style {
- return s.Prompt.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedText() lipgloss.Style {
- return s.Text.Inherit(s.Base).Inline(true)
-}
-
-// line is the input to the text wrapping function. This is stored in a struct
-// so that it can be hashed and memoized.
-type line struct {
- content []any // Contains runes and *Attachment
- width int
-}
-
-// Hash returns a hash of the line.
-func (w line) Hash() string {
- var s strings.Builder
- for _, item := range w.content {
- switch v := item.(type) {
- case rune:
- s.WriteRune(v)
- case *attachment.Attachment:
- s.WriteString(v.ID)
- }
- }
- v := fmt.Sprintf("%s:%d", s.String(), w.width)
- return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))
-}
-
-// Model is the Bubble Tea model for this text area element.
-type Model struct {
- Err error
-
- // General settings.
- cache *MemoCache[line, [][]any]
-
- // Prompt is printed at the beginning of each line.
- //
- // When changing the value of Prompt after the model has been
- // initialized, ensure that SetWidth() gets called afterwards.
- //
- // See also [SetPromptFunc] for a dynamic prompt.
- Prompt string
-
- // Placeholder is the text displayed when the user
- // hasn't entered anything yet.
- Placeholder string
-
- // ShowLineNumbers, if enabled, causes line numbers to be printed
- // after the prompt.
- ShowLineNumbers bool
-
- // EndOfBufferCharacter is displayed at the end of the input.
- EndOfBufferCharacter rune
-
- // KeyMap encodes the keybindings recognized by the widget.
- KeyMap KeyMap
-
- // Styling. FocusedStyle and BlurredStyle are used to style the textarea in
- // focused and blurred states.
- Styles Styles
-
- // virtualCursor manages the virtual cursor.
- virtualCursor cursor.Model
-
- // VirtualCursor determines whether or not to use the virtual cursor. If
- // set to false, use [Model.Cursor] to return a real cursor for rendering.
- VirtualCursor bool
-
- // CharLimit is the maximum number of characters this input element will
- // accept. If 0 or less, there's no limit.
- CharLimit int
-
- // MaxHeight is the maximum height of the text area in rows. If 0 or less,
- // there's no limit.
- MaxHeight int
-
- // MaxWidth is the maximum width of the text area in columns. If 0 or less,
- // there's no limit.
- MaxWidth int
-
- // If promptFunc is set, it replaces Prompt as a generator for
- // prompt strings at the beginning of each line.
- promptFunc func(line int) string
-
- // promptWidth is the width of the prompt.
- promptWidth int
-
- // width is the maximum number of characters that can be displayed at once.
- // If 0 or less this setting is ignored.
- width int
-
- // height is the maximum number of lines that can be displayed at once. It
- // essentially treats the text field like a vertically scrolling viewport
- // if there are more lines than the permitted height.
- height int
-
- // Underlying text value. Contains either rune or *Attachment types.
- value [][]any
-
- // focus indicates whether user input focus should be on this input
- // component. When false, ignore keyboard input and hide the cursor.
- focus bool
-
- // Cursor column (slice index).
- col int
-
- // Cursor row.
- row int
-
- // Last character offset, used to maintain state when the cursor is moved
- // vertically such that we can maintain the same navigating position.
- lastCharOffset int
-
- // rune sanitizer for input.
- rsan Sanitizer
-}
-
-// New creates a new model with default settings.
-func New() Model {
- cur := cursor.New()
-
- styles := DefaultDarkStyles()
-
- m := Model{
- CharLimit: defaultCharLimit,
- MaxHeight: defaultMaxHeight,
- MaxWidth: defaultMaxWidth,
- Prompt: lipgloss.ThickBorder().Left + " ",
- Styles: styles,
- cache: NewMemoCache[line, [][]any](maxLines),
- EndOfBufferCharacter: ' ',
- ShowLineNumbers: true,
- VirtualCursor: true,
- virtualCursor: cur,
- KeyMap: DefaultKeyMap(),
-
- value: make([][]any, minHeight, maxLines),
- focus: false,
- col: 0,
- row: 0,
- }
-
- m.SetWidth(defaultWidth)
- m.SetHeight(defaultHeight)
-
- return m
-}
-
-// DefaultStyles returns the default styles for focused and blurred states for
-// the textarea.
-func DefaultStyles(isDark bool) Styles {
- lightDark := lipgloss.LightDark(isDark)
-
- var s Styles
- s.Focused = StyleState{
- Base: lipgloss.NewStyle(),
- CursorLine: lipgloss.NewStyle().
- Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))),
- CursorLineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))),
- EndOfBuffer: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
- LineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
- Text: lipgloss.NewStyle(),
- }
- s.Blurred = StyleState{
- Base: lipgloss.NewStyle(),
- CursorLine: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
- CursorLineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- EndOfBuffer: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
- LineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
- Text: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
- }
- s.Attachment = lipgloss.NewStyle().
- Background(lipgloss.Color("11")).
- Foreground(lipgloss.Color("0"))
- s.SelectedAttachment = lipgloss.NewStyle().
- Background(lipgloss.Color("11")).
- Foreground(lipgloss.Color("0"))
- s.Cursor = CursorStyle{
- Color: lipgloss.Color("7"),
- Shape: tea.CursorBlock,
- Blink: true,
- }
- return s
-}
-
-// DefaultLightStyles returns the default styles for a light background.
-func DefaultLightStyles() Styles {
- return DefaultStyles(false)
-}
-
-// DefaultDarkStyles returns the default styles for a dark background.
-func DefaultDarkStyles() Styles {
- return DefaultStyles(true)
-}
-
-// updateVirtualCursorStyle sets styling on the virtual cursor based on the
-// textarea's style settings.
-func (m *Model) updateVirtualCursorStyle() {
- if !m.VirtualCursor {
- m.virtualCursor.SetMode(cursor.CursorHide)
- return
- }
-
- m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.Styles.Cursor.Color)
-
- // By default, the blink speed of the cursor is set to a default
- // internally.
- if m.Styles.Cursor.Blink {
- if m.Styles.Cursor.BlinkSpeed > 0 {
- m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed
- }
- m.virtualCursor.SetMode(cursor.CursorBlink)
- return
- }
- m.virtualCursor.SetMode(cursor.CursorStatic)
-}
-
-// SetValue sets the value of the text input.
-func (m *Model) SetValue(s string) {
- m.Reset()
- m.InsertString(s)
-}
-
-// InsertString inserts a string at the cursor position.
-func (m *Model) InsertString(s string) {
- m.InsertRunesFromUserInput([]rune(s))
-}
-
-// InsertRune inserts a rune at the cursor position.
-func (m *Model) InsertRune(r rune) {
- m.InsertRunesFromUserInput([]rune{r})
-}
-
-// InsertAttachment inserts an attachment at the cursor position.
-func (m *Model) InsertAttachment(att *attachment.Attachment) {
- if m.CharLimit > 0 {
- availSpace := m.CharLimit - m.Length()
- // If the char limit's been reached, cancel.
- if availSpace <= 0 {
- return
- }
- }
-
- // Insert the attachment at the current cursor position
- m.value[m.row] = append(
- m.value[m.row][:m.col],
- append([]any{att}, m.value[m.row][m.col:]...)...)
- m.col++
- m.SetCursorColumn(m.col)
-}
-
-// removeAttachmentAtCursor replaces the attachment at or immediately before the
-// cursor with its textual display and positions the cursor at the end of the
-// inserted text. Returns true if an attachment was removed.
-func (m *Model) removeAttachmentAtCursor() bool {
- att, startIdx, _ := m.isAttachmentAtCursor()
- if att == nil {
- return false
- }
- // Replace the attachment element with the display runes
- before := m.value[m.row][:startIdx]
- after := m.value[m.row][startIdx+1:]
- replacement := runesToInterfaces([]rune(att.Display))
- newRow := make([]any, 0, len(before)+len(replacement)+len(after))
- newRow = append(newRow, before...)
- newRow = append(newRow, replacement...)
- newRow = append(newRow, after...)
- m.value[m.row] = newRow
- m.col = startIdx + len(replacement)
- m.SetCursorColumn(m.col)
- return true
-}
-
-// ReplaceRange replaces text from startCol to endCol on the current row with the given string.
-// This preserves attachments outside the replaced range.
-func (m *Model) ReplaceRange(startCol, endCol int, replacement string) {
- if m.row >= len(m.value) || startCol < 0 || endCol < startCol {
- return
- }
-
- // Ensure bounds are within the current row
- rowLen := len(m.value[m.row])
- startCol = max(0, min(startCol, rowLen))
- endCol = max(startCol, min(endCol, rowLen))
-
- // Create new row content: before + replacement + after
- before := m.value[m.row][:startCol]
- after := m.value[m.row][endCol:]
- replacementRunes := runesToInterfaces([]rune(replacement))
-
- // Combine the parts
- newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after))
- newRow = append(newRow, before...)
- newRow = append(newRow, replacementRunes...)
- newRow = append(newRow, after...)
-
- m.value[m.row] = newRow
-
- // Position cursor at end of replacement
- m.col = startCol + len(replacementRunes)
- m.SetCursorColumn(m.col)
-}
-
-// CurrentRowLength returns the length of the current row.
-func (m *Model) CurrentRowLength() int {
- if m.row >= len(m.value) {
- return 0
- }
- return len(m.value[m.row])
-}
-
-// GetAttachments returns all attachments in the textarea with accurate position indices.
-func (m Model) GetAttachments() []*attachment.Attachment {
- var attachments []*attachment.Attachment
- position := 0 // Track absolute position in the text
-
- for rowIdx, row := range m.value {
- colPosition := 0 // Track position within the current row
-
- for _, item := range row {
- switch v := item.(type) {
- case *attachment.Attachment:
- // Clone the attachment to avoid modifying the original
- att := *v
- att.StartIndex = position + colPosition
- att.EndIndex = position + colPosition + len(v.Display)
- attachments = append(attachments, &att)
- colPosition += len(v.Display)
- case rune:
- colPosition++
- }
- }
-
- // Add newline character position (except for last row)
- if rowIdx < len(m.value)-1 {
- position += colPosition + 1 // +1 for newline
- } else {
- position += colPosition
- }
- }
-
- return attachments
-}
-
-// InsertRunesFromUserInput inserts runes at the current cursor position.
-func (m *Model) InsertRunesFromUserInput(runes []rune) {
- // Clean up any special characters in the input provided by the
- // clipboard. This avoids bugs due to e.g. tab characters and
- // whatnot.
- runes = m.san().Sanitize(runes)
-
- if m.CharLimit > 0 {
- availSpace := m.CharLimit - m.Length()
- // If the char limit's been reached, cancel.
- if availSpace <= 0 {
- return
- }
- // If there's not enough space to paste the whole thing cut the pasted
- // runes down so they'll fit.
- if availSpace < len(runes) {
- runes = runes[:availSpace]
- }
- }
-
- // Split the input into lines.
- var lines [][]rune
- lstart := 0
- for i := range runes {
- if runes[i] == '\n' {
- // Queue a line to become a new row in the text area below.
- // Beware to clamp the max capacity of the slice, to ensure no
- // data from different rows get overwritten when later edits
- // will modify this line.
- lines = append(lines, runes[lstart:i:i])
- lstart = i + 1
- }
- }
- if lstart <= len(runes) {
- // The last line did not end with a newline character.
- // Take it now.
- lines = append(lines, runes[lstart:])
- }
-
- // Obey the maximum line limit.
- if maxLines > 0 && len(m.value)+len(lines)-1 > maxLines {
- allowedHeight := max(0, maxLines-len(m.value)+1)
- lines = lines[:allowedHeight]
- }
-
- if len(lines) == 0 {
- // Nothing left to insert.
- return
- }
-
- // Save the remainder of the original line at the current
- // cursor position.
- tail := copyInterfaceSlice(m.value[m.row][m.col:])
-
- // Paste the first line at the current cursor position.
- m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...)
- m.col += len(lines[0])
-
- if numExtraLines := len(lines) - 1; numExtraLines > 0 {
- // Add the new lines.
- // We try to reuse the slice if there's already space.
- var newGrid [][]any
- if cap(m.value) >= len(m.value)+numExtraLines {
- // Can reuse the extra space.
- newGrid = m.value[:len(m.value)+numExtraLines]
- } else {
- // No space left; need a new slice.
- newGrid = make([][]any, len(m.value)+numExtraLines)
- copy(newGrid, m.value[:m.row+1])
- }
- // Add all the rows that were after the cursor in the original
- // grid at the end of the new grid.
- copy(newGrid[m.row+1+numExtraLines:], m.value[m.row+1:])
- m.value = newGrid
- // Insert all the new lines in the middle.
- for _, l := range lines[1:] {
- m.row++
- m.value[m.row] = runesToInterfaces(l)
- m.col = len(l)
- }
- }
-
- // Finally add the tail at the end of the last line inserted.
- m.value[m.row] = append(m.value[m.row], tail...)
-
- m.SetCursorColumn(m.col)
-}
-
-// Value returns the value of the text input.
-func (m Model) Value() string {
- if m.value == nil {
- return ""
- }
-
- var v strings.Builder
- for _, l := range m.value {
- for _, item := range l {
- switch val := item.(type) {
- case rune:
- v.WriteRune(val)
- case *attachment.Attachment:
- v.WriteString(val.Display)
- }
- }
- v.WriteByte('\n')
- }
-
- return strings.TrimSuffix(v.String(), "\n")
-}
-
-// Length returns the number of characters currently in the text input.
-func (m *Model) Length() int {
- var l int
- for _, row := range m.value {
- for _, item := range row {
- switch val := item.(type) {
- case rune:
- l += rw.RuneWidth(val)
- case *attachment.Attachment:
- l += uniseg.StringWidth(val.Display)
- }
- }
- }
- // We add len(m.value) to include the newline characters.
- return l + len(m.value) - 1
-}
-
-// LineCount returns the number of lines that are currently in the text input.
-func (m *Model) LineCount() int {
- return m.ContentHeight()
-}
-
-// Line returns the line position.
-func (m Model) Line() int {
- return m.row
-}
-
-// CursorColumn returns the cursor's column position (slice index).
-func (m Model) CursorColumn() int {
- return m.col
-}
-
-// LastRuneIndex returns the index of the last occurrence of a rune on the current line,
-// searching backwards from the current cursor position.
-// Returns -1 if the rune is not found before the cursor.
-func (m Model) LastRuneIndex(r rune) int {
- if m.row >= len(m.value) {
- return -1
- }
- // Iterate backwards from just before the cursor position
- for i := m.col - 1; i >= 0; i-- {
- if i < len(m.value[m.row]) {
- if item, ok := m.value[m.row][i].(rune); ok && item == r {
- return i
- }
- }
- }
- return -1
-}
-
-func (m *Model) Newline() {
- if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight {
- return
- }
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- m.splitLine(m.row, m.col)
-}
-
-// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index.
-// This is used to maintain the cursor's horizontal position when moving vertically.
-func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
- if row < 0 || row >= len(m.value) {
- return 0
- }
-
- offset := 0
- // Find the slice index that corresponds to the visual offset.
- for i, item := range m.value[row] {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
-
- // If the target offset falls within the current item, this is our index.
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- return i + 1
- }
- return i
- }
- offset += itemWidth
- }
-
- return len(m.value[row])
-}
-
-// CursorDown moves the cursor down by one line.
-func (m *Model) CursorDown() {
- li := m.LineInfo()
- charOffset := max(m.lastCharOffset, li.CharOffset)
- m.lastCharOffset = charOffset
-
- if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
- // Move to the next model line
- m.row++
-
- // We want to land on the first wrapped line of the new model line.
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[0]
-
- // Find position within the first wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundNextLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundNextLine:
- m.col = colInLine // startCol is 0 for the first wrapped line
- } else if li.RowOffset+1 < li.Height {
- // Move to the next wrapped line within the same model line
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[li.RowOffset+1]
-
- startCol := 0
- for i := 0; i < li.RowOffset+1; i++ {
- startCol += len(grid[i])
- }
-
- // Find position within the target wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundSameLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundSameLine:
- m.col = startCol + colInLine
- }
- m.SetCursorColumn(m.col)
-}
-
-// CursorUp moves the cursor up by one line.
-func (m *Model) CursorUp() {
- li := m.LineInfo()
- charOffset := max(m.lastCharOffset, li.CharOffset)
- m.lastCharOffset = charOffset
-
- if li.RowOffset <= 0 && m.row > 0 {
- // Move to the previous model line. We want to land on the last wrapped
- // line of the previous model line.
- m.row--
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[len(grid)-1]
-
- // Find start of last wrapped line.
- startCol := len(m.value[m.row]) - len(targetLineContent)
-
- // Find position within the last wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundPrevLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundPrevLine:
- m.col = startCol + colInLine
- } else if li.RowOffset > 0 {
- // Move to the previous wrapped line within the same model line.
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[li.RowOffset-1]
-
- startCol := 0
- for i := 0; i < li.RowOffset-1; i++ {
- startCol += len(grid[i])
- }
-
- // Find position within the target wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundSameLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundSameLine:
- m.col = startCol + colInLine
- }
- m.SetCursorColumn(m.col)
-}
-
-// SetCursorColumn moves the cursor to the given position. If the position is
-// out of bounds the cursor will be moved to the start or end accordingly.
-func (m *Model) SetCursorColumn(col int) {
- m.col = clamp(col, 0, len(m.value[m.row]))
- // Any time that we move the cursor horizontally we need to reset the last
- // offset so that the horizontal position when navigating is adjusted.
- m.lastCharOffset = 0
-}
-
-// CursorStart moves the cursor to the start of the input field.
-func (m *Model) CursorStart() {
- m.SetCursorColumn(0)
-}
-
-// CursorEnd moves the cursor to the end of the input field.
-func (m *Model) CursorEnd() {
- m.SetCursorColumn(len(m.value[m.row]))
-}
-
-func (m *Model) IsCursorAtEnd() bool {
- return m.CursorColumn() == len(m.value[m.row])
-}
-
-// Focused returns the focus state on the model.
-func (m Model) Focused() bool {
- return m.focus
-}
-
-// activeStyle returns the appropriate set of styles to use depending on
-// whether the textarea is focused or blurred.
-func (m Model) activeStyle() *StyleState {
- if m.focus {
- return &m.Styles.Focused
- }
- return &m.Styles.Blurred
-}
-
-// Focus sets the focus state on the model. When the model is in focus it can
-// receive keyboard input and the cursor will be hidden.
-func (m *Model) Focus() tea.Cmd {
- m.focus = true
- return m.virtualCursor.Focus()
-}
-
-// Blur removes the focus state on the model. When the model is blurred it can
-// not receive keyboard input and the cursor will be hidden.
-func (m *Model) Blur() {
- m.focus = false
- m.virtualCursor.Blur()
-}
-
-// Reset sets the input to its default state with no input.
-func (m *Model) Reset() {
- m.value = make([][]any, minHeight, maxLines)
- m.col = 0
- m.row = 0
- m.SetCursorColumn(0)
-}
-
-// san initializes or retrieves the rune sanitizer.
-func (m *Model) san() Sanitizer {
- if m.rsan == nil {
- // Textinput has all its input on a single line so collapse
- // newlines/tabs to single spaces.
- m.rsan = NewSanitizer()
- }
- return m.rsan
-}
-
-// deleteBeforeCursor deletes all text before the cursor. Returns whether or
-// not the cursor blink should be reset.
-func (m *Model) deleteBeforeCursor() {
- m.value[m.row] = m.value[m.row][m.col:]
- m.SetCursorColumn(0)
-}
-
-// deleteAfterCursor deletes all text after the cursor. Returns whether or not
-// the cursor blink should be reset. If input is masked delete everything after
-// the cursor so as not to reveal word breaks in the masked input.
-func (m *Model) deleteAfterCursor() {
- m.value[m.row] = m.value[m.row][:m.col]
- m.SetCursorColumn(len(m.value[m.row]))
-}
-
-// transposeLeft exchanges the runes at the cursor and immediately
-// before. No-op if the cursor is at the beginning of the line. If
-// the cursor is not at the end of the line yet, moves the cursor to
-// the right.
-func (m *Model) transposeLeft() {
- if m.col == 0 || len(m.value[m.row]) < 2 {
- return
- }
- if m.col >= len(m.value[m.row]) {
- m.SetCursorColumn(m.col - 1)
- }
- m.value[m.row][m.col-1], m.value[m.row][m.col] = m.value[m.row][m.col], m.value[m.row][m.col-1]
- if m.col < len(m.value[m.row]) {
- m.SetCursorColumn(m.col + 1)
- }
-}
-
-// deleteWordLeft deletes the word left to the cursor. Returns whether or not
-// the cursor blink should be reset.
-func (m *Model) deleteWordLeft() {
- if m.col == 0 || len(m.value[m.row]) == 0 {
- return
- }
-
- // Linter note: it's critical that we acquire the initial cursor position
- // here prior to altering it via SetCursor() below. As such, moving this
- // call into the corresponding if clause does not apply here.
- oldCol := m.col //nolint:ifshort
-
- m.SetCursorColumn(m.col - 1)
- for isSpaceAt(m.value[m.row], m.col) {
- if m.col <= 0 {
- break
- }
- // ignore series of whitespace before cursor
- m.SetCursorColumn(m.col - 1)
- }
-
- for m.col > 0 {
- if !isSpaceAt(m.value[m.row], m.col) {
- m.SetCursorColumn(m.col - 1)
- } else {
- if m.col > 0 {
- // keep the previous space
- m.SetCursorColumn(m.col + 1)
- }
- break
- }
- }
-
- if oldCol > len(m.value[m.row]) {
- m.value[m.row] = m.value[m.row][:m.col]
- } else {
- m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][oldCol:]...)
- }
-}
-
-// deleteWordRight deletes the word right to the cursor.
-func (m *Model) deleteWordRight() {
- if m.col >= len(m.value[m.row]) || len(m.value[m.row]) == 0 {
- return
- }
-
- oldCol := m.col
-
- for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) {
- // ignore series of whitespace after cursor
- m.SetCursorColumn(m.col + 1)
- }
-
- for m.col < len(m.value[m.row]) {
- if !isSpaceAt(m.value[m.row], m.col) {
- m.SetCursorColumn(m.col + 1)
- } else {
- break
- }
- }
-
- if m.col > len(m.value[m.row]) {
- m.value[m.row] = m.value[m.row][:oldCol]
- } else {
- m.value[m.row] = append(m.value[m.row][:oldCol], m.value[m.row][m.col:]...)
- }
-
- m.SetCursorColumn(oldCol)
-}
-
-// characterRight moves the cursor one character to the right.
-func (m *Model) characterRight() {
- if m.col < len(m.value[m.row]) {
- m.SetCursorColumn(m.col + 1)
- } else {
- if m.row < len(m.value)-1 {
- m.row++
- m.CursorStart()
- }
- }
-}
-
-// characterLeft moves the cursor one character to the left.
-// If insideLine is set, the cursor is moved to the last
-// character in the previous line, instead of one past that.
-func (m *Model) characterLeft(insideLine bool) {
- if m.col == 0 && m.row != 0 {
- m.row--
- m.CursorEnd()
- if !insideLine {
- return
- }
- }
- if m.col > 0 {
- m.SetCursorColumn(m.col - 1)
- }
-}
-
-// wordLeft moves the cursor one word to the left. Returns whether or not the
-// cursor blink should be reset. If input is masked, move input to the start
-// so as not to reveal word breaks in the masked input.
-func (m *Model) wordLeft() {
- for {
- m.characterLeft(true /* insideLine */)
- if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) {
- break
- }
- }
-
- for m.col > 0 {
- if isSpaceAt(m.value[m.row], m.col-1) {
- break
- }
- m.SetCursorColumn(m.col - 1)
- }
-}
-
-// wordRight moves the cursor one word to the right. Returns whether or not the
-// cursor blink should be reset. If the input is masked, move input to the end
-// so as not to reveal word breaks in the masked input.
-func (m *Model) wordRight() {
- m.doWordRight(func(int, int) { /* nothing */ })
-}
-
-func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
- // Skip spaces forward.
- for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) {
- if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) {
- // End of text.
- break
- }
- m.characterRight()
- }
-
- charIdx := 0
- for m.col < len(m.value[m.row]) {
- if isSpaceAt(m.value[m.row], m.col) {
- break
- }
- fn(charIdx, m.col)
- m.SetCursorColumn(m.col + 1)
- charIdx++
- }
-}
-
-// uppercaseRight changes the word to the right to uppercase.
-func (m *Model) uppercaseRight() {
- m.doWordRight(func(_ int, i int) {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToUpper(r)
- }
- })
-}
-
-// lowercaseRight changes the word to the right to lowercase.
-func (m *Model) lowercaseRight() {
- m.doWordRight(func(_ int, i int) {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToLower(r)
- }
- })
-}
-
-// capitalizeRight changes the word to the right to title case.
-func (m *Model) capitalizeRight() {
- m.doWordRight(func(charIdx int, i int) {
- if charIdx == 0 {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToTitle(r)
- }
- }
- })
-}
-
-// LineInfo returns the number of characters from the start of the
-// (soft-wrapped) line and the (soft-wrapped) line width.
-func (m Model) LineInfo() LineInfo {
- grid := m.memoizedWrap(m.value[m.row], m.width)
-
- // Find out which line we are currently on. This can be determined by the
- // m.col and counting the number of runes that we need to skip.
- var counter int
- for i, line := range grid {
- start := counter
- end := counter + len(line)
-
- if m.col >= start && m.col <= end {
- // This is the wrapped line the cursor is on.
-
- // Special case: if the cursor is at the end of a wrapped line,
- // and there's another wrapped line after it, the cursor should
- // be considered at the beginning of the next line.
- if m.col == end && i < len(grid)-1 {
- nextLine := grid[i+1]
- return LineInfo{
- CharOffset: 0,
- ColumnOffset: 0,
- Height: len(grid),
- RowOffset: i + 1,
- StartColumn: end,
- Width: len(nextLine),
- CharWidth: uniseg.StringWidth(interfacesToString(nextLine)),
- }
- }
-
- return LineInfo{
- CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])),
- ColumnOffset: m.col - start,
- Height: len(grid),
- RowOffset: i,
- StartColumn: start,
- Width: len(line),
- CharWidth: uniseg.StringWidth(interfacesToString(line)),
- }
- }
- counter = end
- }
- return LineInfo{}
-}
-
-// Width returns the width of the textarea.
-func (m Model) Width() int {
- return m.width
-}
-
-// MoveToBegin moves the cursor to the beginning of the input.
-func (m *Model) MoveToBegin() {
- m.row = 0
- m.SetCursorColumn(0)
-}
-
-// MoveToEnd moves the cursor to the end of the input.
-func (m *Model) MoveToEnd() {
- m.row = len(m.value) - 1
- m.SetCursorColumn(len(m.value[m.row]))
-}
-
-// SetWidth sets the width of the textarea to fit exactly within the given width.
-// This means that the textarea will account for the width of the prompt and
-// whether or not line numbers are being shown.
-//
-// Ensure that SetWidth is called after setting the Prompt and ShowLineNumbers,
-// It is important that the width of the textarea be exactly the given width
-// and no more.
-func (m *Model) SetWidth(w int) {
- // Update prompt width only if there is no prompt function as
- // [SetPromptFunc] updates the prompt width when it is called.
- if m.promptFunc == nil {
- // XXX: Do we even need this or can we calculate the prompt width
- // at render time?
- m.promptWidth = uniseg.StringWidth(m.Prompt)
- }
-
- // Add base style borders and padding to reserved outer width.
- reservedOuter := m.activeStyle().Base.GetHorizontalFrameSize()
-
- // Add prompt width to reserved inner width.
- reservedInner := m.promptWidth
-
- // Add line number width to reserved inner width.
- if m.ShowLineNumbers {
- // XXX: this was originally documented as needing "1 cell" but was,
- // in practice, effectively hardcoded to 2 cells. We can, and should,
- // reduce this to one gap and update the tests accordingly.
- const gap = 2
-
- // Number of digits plus 1 cell for the margin.
- reservedInner += numDigits(m.MaxHeight) + gap
- }
-
- // Input width must be at least one more than the reserved inner and outer
- // width. This gives us a minimum input width of 1.
- minWidth := reservedInner + reservedOuter + 1
- inputWidth := max(w, minWidth)
-
- // Input width must be no more than maximum width.
- if m.MaxWidth > 0 {
- inputWidth = min(inputWidth, m.MaxWidth)
- }
-
- // Since the width of the viewport and input area is dependent on the width of
- // borders, prompt and line numbers, we need to calculate it by subtracting
- // the reserved width from them.
-
- m.width = inputWidth - reservedOuter - reservedInner
-}
-
-// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead.
-//
-// If the function returns a prompt that is shorter than the specified
-// promptWidth, it will be padded to the left. If it returns a prompt that is
-// longer, display artifacts may occur; the caller is responsible for computing
-// an adequate promptWidth.
-func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIndex int) string) {
- m.promptFunc = fn
- m.promptWidth = promptWidth
-}
-
-// Height returns the current height of the textarea.
-func (m Model) Height() int {
- return m.height
-}
-
-// ContentHeight returns the actual height needed to display all content
-// including wrapped lines.
-func (m Model) ContentHeight() int {
- totalLines := 0
- for _, line := range m.value {
- wrappedLines := m.memoizedWrap(line, m.width)
- totalLines += len(wrappedLines)
- }
- // Ensure at least one line is shown
- if totalLines == 0 {
- totalLines = 1
- }
- return totalLines
-}
-
-// SetHeight sets the height of the textarea.
-func (m *Model) SetHeight(h int) {
- // Calculate the actual content height
- contentHeight := m.ContentHeight()
-
- // Use the content height as the actual height
- if m.MaxHeight > 0 {
- m.height = clamp(contentHeight, minHeight, m.MaxHeight)
- } else {
- m.height = max(contentHeight, minHeight)
- }
-}
-
-// Update is the Bubble Tea update loop.
-func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
- if !m.focus {
- m.virtualCursor.Blur()
- return m, nil
- }
-
- // Used to determine if the cursor should blink.
- oldRow, oldCol := m.cursorLineNumber(), m.col
-
- var cmds []tea.Cmd
-
- if m.row >= len(m.value) {
- m.value = append(m.value, make([]any, 0))
- }
- if m.value[m.row] == nil {
- m.value[m.row] = make([]any, 0)
- }
-
- if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() {
- m.cache = NewMemoCache[line, [][]any](m.MaxHeight)
- }
-
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col >= len(m.value[m.row]) {
- m.mergeLineBelow(m.row)
- break
- }
- m.deleteAfterCursor()
- case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- m.deleteBeforeCursor()
- case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
- // If the cursor is at or just after an attachment, convert it to text instead of deleting
- if att, _, _ := m.isAttachmentAtCursor(); att != nil {
- if m.removeAttachmentAtCursor() {
- break
- }
- }
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- if len(m.value[m.row]) > 0 && m.col > 0 {
- m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col)
- m.SetCursorColumn(m.col - 1)
- }
- case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
- // If the cursor is on an attachment, convert it to text instead of deleting
- if att, _, _ := m.isAttachmentAtCursor(); att != nil {
- if m.removeAttachmentAtCursor() {
- break
- }
- }
- if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
- m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1)
- }
- if m.col >= len(m.value[m.row]) {
- m.mergeLineBelow(m.row)
- break
- }
- case key.Matches(msg, m.KeyMap.DeleteWordBackward):
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- m.deleteWordLeft()
- case key.Matches(msg, m.KeyMap.DeleteWordForward):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col >= len(m.value[m.row]) {
- m.mergeLineBelow(m.row)
- break
- }
- m.deleteWordRight()
- case key.Matches(msg, m.KeyMap.InsertNewline):
- m.Newline()
- case key.Matches(msg, m.KeyMap.LineEnd):
- m.CursorEnd()
- case key.Matches(msg, m.KeyMap.LineStart):
- m.CursorStart()
- case key.Matches(msg, m.KeyMap.CharacterForward):
- m.characterRight()
- case key.Matches(msg, m.KeyMap.LineNext):
- m.CursorDown()
- case key.Matches(msg, m.KeyMap.WordForward):
- m.wordRight()
- case key.Matches(msg, m.KeyMap.CharacterBackward):
- m.characterLeft(false /* insideLine */)
- case key.Matches(msg, m.KeyMap.LinePrevious):
- m.CursorUp()
- case key.Matches(msg, m.KeyMap.WordBackward):
- m.wordLeft()
- case key.Matches(msg, m.KeyMap.InputBegin):
- m.MoveToBegin()
- case key.Matches(msg, m.KeyMap.InputEnd):
- m.MoveToEnd()
- case key.Matches(msg, m.KeyMap.LowercaseWordForward):
- m.lowercaseRight()
- case key.Matches(msg, m.KeyMap.UppercaseWordForward):
- m.uppercaseRight()
- case key.Matches(msg, m.KeyMap.CapitalizeWordForward):
- m.capitalizeRight()
- case key.Matches(msg, m.KeyMap.TransposeCharacterBackward):
- m.transposeLeft()
-
- default:
- m.InsertRunesFromUserInput([]rune(msg.Text))
- }
-
- case pasteMsg:
- m.InsertRunesFromUserInput([]rune(msg))
-
- case pasteErrMsg:
- m.Err = msg
- }
-
- var cmd tea.Cmd
- newRow, newCol := m.cursorLineNumber(), m.col
- m.virtualCursor, cmd = m.virtualCursor.Update(msg)
- if (newRow != oldRow || newCol != oldCol) && m.virtualCursor.Mode() == cursor.CursorBlink {
- m.virtualCursor.Blink = false
- cmd = m.virtualCursor.BlinkCmd()
- }
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
-}
-
-// View renders the text area in its current state.
-func (m Model) View() string {
- m.updateVirtualCursorStyle()
- if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" {
- return m.placeholderView()
- }
- m.virtualCursor.TextStyle = m.activeStyle().computedCursorLine()
-
- var (
- s strings.Builder
- style lipgloss.Style
- newLines int
- widestLineNumber int
- lineInfo = m.LineInfo()
- styles = m.activeStyle()
- )
-
- displayLine := 0
- for l, line := range m.value {
- wrappedLines := m.memoizedWrap(line, m.width)
-
- if m.row == l {
- style = styles.computedCursorLine()
- } else {
- style = styles.computedText()
- }
-
- for wl, wrappedLine := range wrappedLines {
- prompt := m.promptView(displayLine)
- prompt = styles.computedPrompt().Render(prompt)
- s.WriteString(style.Render(prompt))
- displayLine++
-
- var ln string
- if m.ShowLineNumbers {
- if wl == 0 { // normal line
- isCursorLine := m.row == l
- s.WriteString(m.lineNumberView(l+1, isCursorLine))
- } else { // soft wrapped line
- isCursorLine := m.row == l
- s.WriteString(m.lineNumberView(-1, isCursorLine))
- }
- }
-
- // Note the widest line number for padding purposes later.
- lnw := uniseg.StringWidth(ln)
- if lnw > widestLineNumber {
- widestLineNumber = lnw
- }
-
- wrappedLineStr := interfacesToString(wrappedLine)
- strwidth := uniseg.StringWidth(wrappedLineStr)
- padding := m.width - strwidth
- // If the trailing space causes the line to be wider than the
- // width, we should not draw it to the screen since it will result
- // in an extra space at the end of the line which can look off when
- // the cursor line is showing.
- if strwidth > m.width {
- // The character causing the line to be wider than the width is
- // guaranteed to be a space since any other character would
- // have been wrapped.
- wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ")
- padding = m.width - uniseg.StringWidth(wrappedLineStr)
- }
-
- if m.row == l && lineInfo.RowOffset == wl {
- // Render the part of the line before the cursor
- s.WriteString(
- m.renderLineWithAttachments(
- wrappedLine[:lineInfo.ColumnOffset],
- style,
- ),
- )
-
- if m.col >= len(line) && lineInfo.CharOffset >= m.width {
- m.virtualCursor.SetChar(" ")
- s.WriteString(m.virtualCursor.View())
- } else if lineInfo.ColumnOffset < len(wrappedLine) {
- // Render the item under the cursor
- item := wrappedLine[lineInfo.ColumnOffset]
- if att, ok := item.(*attachment.Attachment); ok {
- // Item at cursor is an attachment. Render it with the selection style.
- // This becomes the "cursor" visually.
- s.WriteString(m.Styles.SelectedAttachment.Render(att.Display))
- } else {
- // Item at cursor is a rune. Render it with the virtual cursor.
- m.virtualCursor.SetChar(string(item.(rune)))
- s.WriteString(style.Render(m.virtualCursor.View()))
- }
-
- // Render the part of the line after the cursor
- s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style))
- } else {
- // Cursor is at the end of the line
- m.virtualCursor.SetChar(" ")
- s.WriteString(style.Render(m.virtualCursor.View()))
- }
- } else {
- s.WriteString(m.renderLineWithAttachments(wrappedLine, style))
- }
-
- s.WriteString(style.Render(strings.Repeat(" ", max(0, padding))))
- s.WriteRune('\n')
- newLines++
- }
- }
-
- // Remove the trailing newline from the last line
- result := s.String()
- if len(result) > 0 && result[len(result)-1] == '\n' {
- result = result[:len(result)-1]
- }
-
- return styles.Base.Render(result)
-}
-
-// promptView renders a single line of the prompt.
-func (m Model) promptView(displayLine int) (prompt string) {
- prompt = m.Prompt
- if m.promptFunc == nil {
- return prompt
- }
- prompt = m.promptFunc(displayLine)
- width := lipgloss.Width(prompt)
- if width < m.promptWidth {
- prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt)
- }
-
- return m.activeStyle().computedPrompt().Render(prompt)
-}
-
-// lineNumberView renders the line number.
-//
-// If the argument is less than 0, a space styled as a line number is returned
-// instead. Such cases are used for soft-wrapped lines.
-//
-// The second argument indicates whether this line number is for a 'cursorline'
-// line number.
-func (m Model) lineNumberView(n int, isCursorLine bool) (str string) {
- if !m.ShowLineNumbers {
- return ""
- }
-
- if n <= 0 {
- str = " "
- } else {
- str = strconv.Itoa(n)
- }
-
- // XXX: is textStyle really necessary here?
- textStyle := m.activeStyle().computedText()
- lineNumberStyle := m.activeStyle().computedLineNumber()
- if isCursorLine {
- textStyle = m.activeStyle().computedCursorLine()
- lineNumberStyle = m.activeStyle().computedCursorLineNumber()
- }
-
- // Format line number dynamically based on the maximum number of lines.
- digits := len(strconv.Itoa(m.MaxHeight))
- str = fmt.Sprintf(" %*v ", digits, str)
-
- return textStyle.Render(lineNumberStyle.Render(str))
-}
-
-// placeholderView returns the prompt and placeholder, if any.
-func (m Model) placeholderView() string {
- var (
- s strings.Builder
- p = m.Placeholder
- styles = m.activeStyle()
- )
- // word wrap lines
- pwordwrap := ansi.Wordwrap(p, m.width, "")
- // hard wrap lines (handles lines that could not be word wrapped)
- pwrap := ansi.Hardwrap(pwordwrap, m.width, true)
- // split string by new lines
- plines := strings.Split(strings.TrimSpace(pwrap), "\n")
-
- // Only render the actual placeholder lines, not padded to m.height
- maxLines := max(len(plines), 1) // At least show one line for cursor
- for i := range maxLines {
- isLineNumber := len(plines) > i
-
- lineStyle := styles.computedPlaceholder()
- if len(plines) > i {
- lineStyle = styles.computedCursorLine()
- }
-
- // render prompt
- prompt := m.promptView(i)
- prompt = styles.computedPrompt().Render(prompt)
- s.WriteString(lineStyle.Render(prompt))
-
- // when show line numbers enabled:
- // - render line number for only the cursor line
- // - indent other placeholder lines
- // this is consistent with vim with line numbers enabled
- if m.ShowLineNumbers {
- var ln int
-
- switch {
- case i == 0:
- ln = i + 1
- fallthrough
- case len(plines) > i:
- s.WriteString(m.lineNumberView(ln, isLineNumber))
- default:
- }
- }
-
- switch {
- // first line
- case i == 0:
- // first character of first line as cursor with character
- m.virtualCursor.TextStyle = styles.computedPlaceholder()
- m.virtualCursor.SetChar(string(plines[0][0]))
- s.WriteString(lineStyle.Render(m.virtualCursor.View()))
-
- // the rest of the first line
- placeholderTail := plines[0][1:]
- gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0])))
- renderedPlaceholder := styles.computedPlaceholder().Render(placeholderTail + gap)
- s.WriteString(lineStyle.Render(renderedPlaceholder))
- // remaining lines
- case len(plines) > i:
- // current line placeholder text
- if len(plines) > i {
- placeholderLine := plines[i]
- gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i])))
- s.WriteString(lineStyle.Render(placeholderLine + gap))
- }
- default:
- // end of line buffer character
- eob := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter))
- s.WriteString(eob)
- }
-
- // terminate with new line (except for last line)
- if i < maxLines-1 {
- s.WriteRune('\n')
- }
- }
-
- return styles.Base.Render(s.String())
-}
-
-// Blink returns the blink command for the virtual cursor.
-func Blink() tea.Msg {
- return cursor.Blink()
-}
-
-// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea
-// program. This requires that [Model.VirtualCursor] is set to false.
-//
-// Note that you will almost certainly also need to adjust the offset cursor
-// position per the textarea's per the textarea's position in the terminal.
-//
-// Example:
-//
-// // In your top-level View function:
-// f := tea.NewFrame(m.textarea.View())
-// f.Cursor = m.textarea.Cursor()
-// f.Cursor.Position.X += offsetX
-// f.Cursor.Position.Y += offsetY
-func (m Model) Cursor() *tea.Cursor {
- if m.VirtualCursor {
- return nil
- }
-
- lineInfo := m.LineInfo()
- w := lipgloss.Width
- baseStyle := m.activeStyle().Base
-
- xOffset := lineInfo.CharOffset +
- w(m.promptView(0)) +
- w(m.lineNumberView(0, false)) +
- baseStyle.GetMarginLeft() +
- baseStyle.GetPaddingLeft() +
- baseStyle.GetBorderLeftSize()
-
- yOffset := m.cursorLineNumber() -
- baseStyle.GetMarginTop() +
- baseStyle.GetPaddingTop() +
- baseStyle.GetBorderTopSize()
-
- c := tea.NewCursor(xOffset, yOffset)
- c.Blink = m.Styles.Cursor.Blink
- c.Color = m.Styles.Cursor.Color
- c.Shape = m.Styles.Cursor.Shape
- return c
-}
-
-func (m Model) memoizedWrap(content []any, width int) [][]any {
- input := line{content: content, width: width}
- if v, ok := m.cache.Get(input); ok {
- return v
- }
- v := wrapInterfaces(content, width)
- m.cache.Set(input, v)
- return v
-}
-
-// cursorLineNumber returns the line number that the cursor is on.
-// This accounts for soft wrapped lines.
-func (m Model) cursorLineNumber() int {
- line := 0
- for i := range m.row {
- // Calculate the number of lines that the current line will be split
- // into.
- line += len(m.memoizedWrap(m.value[i], m.width))
- }
- line += m.LineInfo().RowOffset
- return line
-}
-
-// mergeLineBelow merges the current line the cursor is on with the line below.
-func (m *Model) mergeLineBelow(row int) {
- if row >= len(m.value)-1 {
- return
- }
-
- // To perform a merge, we will need to combine the two lines and then
- m.value[row] = append(m.value[row], m.value[row+1]...)
-
- // Shift all lines up by one
- for i := row + 1; i < len(m.value)-1; i++ {
- m.value[i] = m.value[i+1]
- }
-
- // And, remove the last line
- if len(m.value) > 0 {
- m.value = m.value[:len(m.value)-1]
- }
-}
-
-// mergeLineAbove merges the current line the cursor is on with the line above.
-func (m *Model) mergeLineAbove(row int) {
- if row <= 0 {
- return
- }
-
- m.col = len(m.value[row-1])
- m.row = m.row - 1
-
- // To perform a merge, we will need to combine the two lines and then
- m.value[row-1] = append(m.value[row-1], m.value[row]...)
-
- // Shift all lines up by one
- for i := row; i < len(m.value)-1; i++ {
- m.value[i] = m.value[i+1]
- }
-
- // And, remove the last line
- if len(m.value) > 0 {
- m.value = m.value[:len(m.value)-1]
- }
-}
-
-func (m *Model) splitLine(row, col int) {
- // To perform a split, take the current line and keep the content before
- // the cursor, take the content after the cursor and make it the content of
- // the line underneath, and shift the remaining lines down by one
- head, tailSrc := m.value[row][:col], m.value[row][col:]
- tail := copyInterfaceSlice(tailSrc)
-
- m.value = append(m.value[:row+1], m.value[row:]...)
-
- m.value[row] = head
- m.value[row+1] = tail
-
- m.col = 0
- m.row++
-}
-
-func itemWidth(item any) int {
- switch v := item.(type) {
- case rune:
- return rw.RuneWidth(v)
- case *attachment.Attachment:
- return uniseg.StringWidth(v.Display)
- }
- return 0
-}
-
-// forceWrapAttachment splits an attachment's display text across multiple lines
-func forceWrapAttachment(att *attachment.Attachment, width int) [][]any {
- if width <= 0 {
- return [][]any{{att}}
- }
-
- display := att.Display
- displayRunes := []rune(display)
-
- if len(displayRunes) <= width {
- return [][]any{{att}}
- }
-
- var lines [][]any
- start := 0
-
- for start < len(displayRunes) {
- // Calculate how many runes fit in this line
- end := start + width
- if end > len(displayRunes) {
- end = len(displayRunes)
- }
-
- // Create a wrapped attachment for this segment
- wrappedAtt := &attachment.Attachment{
- ID: att.ID,
- Type: att.Type,
- Display: string(displayRunes[start:end]),
- URL: att.URL,
- Filename: att.Filename,
- MediaType: att.MediaType,
- Source: att.Source,
- }
-
- lines = append(lines, []any{wrappedAtt})
- start = end
- }
-
- return lines
-}
-
-// forceWrapWord splits a word that's too long to fit within the given width
-func forceWrapWord(word []any, width int) [][]any {
- if width <= 0 || len(word) == 0 {
- return [][]any{word}
- }
-
- var lines [][]any
- currentLine := []any{}
- currentWidth := 0
-
- for _, item := range word {
- if att, ok := item.(*attachment.Attachment); ok {
- // Handle attachment that might be too wide
- attWidth := uniseg.StringWidth(att.Display)
-
- // If the attachment display is too wide, split it
- if attWidth > width {
- // Finish current line if it has content
- if len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- // Split the attachment display across multiple lines
- wrappedAttachment := forceWrapAttachment(att, width)
- lines = append(lines, wrappedAttachment...)
- continue
- }
-
- // If adding this attachment would exceed the width, start a new line
- if currentWidth+attWidth > width && len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- currentLine = append(currentLine, item)
- currentWidth += attWidth
- } else if r, ok := item.(rune); ok {
- itemWidth := rw.RuneWidth(r)
-
- // If adding this rune would exceed the width, start a new line
- if currentWidth+itemWidth > width && len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- currentLine = append(currentLine, item)
- currentWidth += itemWidth
- }
- }
-
- // Add the last line if it has content
- if len(currentLine) > 0 {
- lines = append(lines, currentLine)
- }
-
- return lines
-}
-
-func wrapInterfaces(content []any, width int) [][]any {
- if width <= 0 {
- return [][]any{content}
- }
-
- var (
- lines = [][]any{{}}
- word = []any{}
- wordW int
- lineW int
- spaceW int
- inSpaces bool
- )
-
- for _, item := range content {
- itemW := 0
- isSpace := false
-
- if r, ok := item.(rune); ok {
- if unicode.IsSpace(r) {
- isSpace = true
- }
- itemW = rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- itemW = uniseg.StringWidth(att.Display)
- }
-
- if isSpace {
- if !inSpaces {
- // End of a word
- if lineW > 0 && lineW+wordW > width {
- // If the word itself is too long to fit on a line, force-wrap it
- if wordW > width {
- wrappedLines := forceWrapWord(word, width)
- lines = append(lines, wrappedLines...)
- // Calculate width of the last wrapped line
- lastLine := wrappedLines[len(wrappedLines)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- } else {
- lines = append(lines, word)
- lineW = wordW
- }
- } else {
- // Check if the word needs to be force-wrapped even when it fits on the current line
- if wordW > width {
- currentLine := lines[len(lines)-1]
- wrappedWord := forceWrapWord(word, width-lineW)
- if len(wrappedWord) > 0 {
- lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
- for i := 1; i < len(wrappedWord); i++ {
- lines = append(lines, wrappedWord[i])
- }
- // Calculate width of the last wrapped line
- lastLine := wrappedWord[len(wrappedWord)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- }
- } else {
- lines[len(lines)-1] = append(lines[len(lines)-1], word...)
- lineW += wordW
- }
- }
- word = nil
- wordW = 0
- }
- inSpaces = true
- spaceW += itemW
- } else { // It's not a space, it's a character for a word.
- if inSpaces {
- // We just finished a block of spaces. Handle them now.
- lineW += spaceW
- for i := 0; i < spaceW; i++ {
- lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
- }
- if lineW > width {
- // The spaces made the line overflow. Start a new line for the upcoming word.
- lines = append(lines, []any{})
- lineW = 0
- }
- spaceW = 0
- }
- inSpaces = false
- word = append(word, item)
- wordW += itemW
- }
- }
-
- // Handle any remaining word/spaces at the end of the content.
- if wordW > 0 {
- if lineW > 0 && lineW+wordW > width {
- // If the word itself is too long to fit on a line, force-wrap it
- if wordW > width {
- wrappedLines := forceWrapWord(word, width)
- lines = append(lines, wrappedLines...)
- // Calculate width of the last wrapped line
- lastLine := wrappedLines[len(wrappedLines)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- } else {
- lines = append(lines, word)
- lineW = wordW
- }
- } else {
- // Check if the word needs to be force-wrapped even when it fits on the current line
- if wordW > width {
- currentLine := lines[len(lines)-1]
- wrappedWord := forceWrapWord(word, width-lineW)
- if len(wrappedWord) > 0 {
- lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
- for i := 1; i < len(wrappedWord); i++ {
- lines = append(lines, wrappedWord[i])
- }
- // Calculate width of the last wrapped line
- lastLine := wrappedWord[len(wrappedWord)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- }
- } else {
- lines[len(lines)-1] = append(lines[len(lines)-1], word...)
- lineW += wordW
- }
- }
- }
- if spaceW > 0 {
- // There are trailing spaces. Add them.
- for i := 0; i < spaceW; i++ {
- lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
- lineW += 1
- }
- if lineW > width {
- lines = append(lines, []any{})
- }
- }
-
- return lines
-}
-
-func repeatSpaces(n int) []rune {
- return []rune(strings.Repeat(string(' '), n))
-}
-
-// numDigits returns the number of digits in an integer.
-func numDigits(n int) int {
- if n == 0 {
- return 1
- }
- count := 0
- num := abs(n)
- for num > 0 {
- count++
- num /= 10
- }
- return count
-}
-
-func clamp(v, low, high int) int {
- if high < low {
- low, high = high, low
- }
- return min(high, max(low, v))
-}
-
-func abs(n int) int {
- if n < 0 {
- return -n
- }
- return n
-}
diff --git a/packages/tui/internal/components/textarea/textarea_test.go b/packages/tui/internal/components/textarea/textarea_test.go
deleted file mode 100644
index fb3c5b8ba..000000000
--- a/packages/tui/internal/components/textarea/textarea_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package textarea
-
-import (
- "testing"
-
- "github.com/sst/opencode/internal/attachment"
-)
-
-func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorAfterAttachment(t *testing.T) {
- m := New()
- m.InsertString("a ")
- att := &attachment.Attachment{ID: "1", Display: "@file.txt"}
- m.InsertAttachment(att)
- m.InsertString(" b")
-
- // Position cursor immediately after the attachment (index 3: 'a',' ',att,' ', 'b')
- m.SetCursorColumn(3)
-
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur")
- }
- got := m.Value()
- want := "a @file.txt b"
- if got != want {
- t.Fatalf("expected %q, got %q", want, got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorOnAttachment(t *testing.T) {
- m := New()
- m.InsertString("x ")
- att := &attachment.Attachment{ID: "2", Display: "@img.png"}
- m.InsertAttachment(att)
- m.InsertString(" y")
-
- // Position cursor on the attachment token (index 2: 'x',' ',att,' ', 'y')
- m.SetCursorColumn(2)
-
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur")
- }
- got := m.Value()
- want := "x @img.png y"
- if got != want {
- t.Fatalf("expected %q, got %q", want, got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_StartOfLine(t *testing.T) {
- m := New()
- att := &attachment.Attachment{ID: "3", Display: "@a.txt"}
- m.InsertAttachment(att)
- m.InsertString(" tail")
-
- // Position cursor immediately after the attachment at start of line (index 1)
- m.SetCursorColumn(1)
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur at start of line")
- }
- if got := m.Value(); got != "@a.txt tail" {
- t.Fatalf("unexpected value: %q", got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_NoAttachment_NoChange(t *testing.T) {
- m := New()
- m.InsertString("hello world")
- col := m.CursorColumn()
- if ok := m.removeAttachmentAtCursor(); ok {
- t.Fatalf("did not expect removal to occur")
- }
- if m.Value() != "hello world" || m.CursorColumn() != col {
- t.Fatalf("value or cursor unexpectedly changed")
- }
-}
diff --git a/packages/tui/internal/components/toast/toast.go b/packages/tui/internal/components/toast/toast.go
deleted file mode 100644
index 2de6bf619..000000000
--- a/packages/tui/internal/components/toast/toast.go
+++ /dev/null
@@ -1,266 +0,0 @@
-package toast
-
-import (
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-// ShowToastMsg is a message to display a toast notification
-type ShowToastMsg struct {
- Message string
- Title *string
- Color compat.AdaptiveColor
- Duration time.Duration
-}
-
-// DismissToastMsg is a message to dismiss a specific toast
-type DismissToastMsg struct {
- ID string
-}
-
-// Toast represents a single toast notification
-type Toast struct {
- ID string
- Message string
- Title *string
- Color compat.AdaptiveColor
- CreatedAt time.Time
- Duration time.Duration
-}
-
-// ToastManager manages multiple toast notifications
-type ToastManager struct {
- toasts []Toast
-}
-
-// NewToastManager creates a new toast manager
-func NewToastManager() *ToastManager {
- return &ToastManager{
- toasts: []Toast{},
- }
-}
-
-// Init initializes the toast manager
-func (tm *ToastManager) Init() tea.Cmd {
- return nil
-}
-
-// Update handles messages for the toast manager
-func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
- switch msg := msg.(type) {
- case ShowToastMsg:
- toast := Toast{
- ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()),
- Title: msg.Title,
- Message: msg.Message,
- Color: msg.Color,
- CreatedAt: time.Now(),
- Duration: msg.Duration,
- }
-
- tm.toasts = append(tm.toasts, toast)
-
- // Return command to dismiss after duration
- return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg {
- return DismissToastMsg{ID: toast.ID}
- })
-
- case DismissToastMsg:
- var newToasts []Toast
- for _, t := range tm.toasts {
- if t.ID != msg.ID {
- newToasts = append(newToasts, t)
- }
- }
- tm.toasts = newToasts
- }
-
- return tm, nil
-}
-
-// renderSingleToast renders a single toast notification
-func (tm *ToastManager) renderSingleToast(toast Toast) string {
- t := theme.CurrentTheme()
-
- baseStyle := styles.NewStyle().
- Foreground(t.Text()).
- Background(t.BackgroundElement()).
- Padding(1, 2)
-
- maxWidth := max(40, layout.Current.Viewport.Width/3)
- contentMaxWidth := max(maxWidth-6, 20)
-
- // Build content with wrapping
- var content strings.Builder
- if toast.Title != nil {
- titleStyle := styles.NewStyle().Foreground(toast.Color).
- Bold(true)
- content.WriteString(titleStyle.Render(*toast.Title))
- content.WriteString("\n")
- }
-
- // Wrap message text
- messageStyle := styles.NewStyle()
- contentWidth := lipgloss.Width(toast.Message)
- if contentWidth > contentMaxWidth {
- messageStyle = messageStyle.Width(contentMaxWidth)
- }
- content.WriteString(messageStyle.Render(toast.Message))
-
- // Render toast with max width
- return baseStyle.MaxWidth(maxWidth).Render(content.String())
-}
-
-// View renders all active toasts
-func (tm *ToastManager) View() string {
- if len(tm.toasts) == 0 {
- return ""
- }
-
- var toastViews []string
- for _, toast := range tm.toasts {
- toastView := tm.renderSingleToast(toast)
- toastViews = append(toastViews, toastView+"\n")
- }
-
- return strings.Join(toastViews, "\n")
-}
-
-// RenderOverlay renders the toasts as an overlay on the given background
-func (tm *ToastManager) RenderOverlay(background string) string {
- if len(tm.toasts) == 0 {
- return background
- }
-
- bgWidth := lipgloss.Width(background)
- bgHeight := lipgloss.Height(background)
- result := background
-
- // Start from top with 2 character padding
- currentY := 2
-
- // Render each toast individually
- for _, toast := range tm.toasts {
- // Render individual toast
- toastView := tm.renderSingleToast(toast)
- toastWidth := lipgloss.Width(toastView)
- toastHeight := lipgloss.Height(toastView)
-
- // Position at top-right with 2 character padding from right edge
- x := max(bgWidth-toastWidth-4, 0)
-
- // Check if toast fits vertically
- if currentY+toastHeight > bgHeight-2 {
- // No more room for toasts
- break
- }
-
- // Place this toast
- result = layout.PlaceOverlay(
- x,
- currentY,
- toastView,
- result,
- layout.WithOverlayBorder(),
- layout.WithOverlayBorderColor(toast.Color),
- )
-
- // Move down for next toast (add 1 for spacing between toasts)
- currentY += toastHeight + 1
- }
-
- return result
-}
-
-type ToastOptions struct {
- Title string
- Duration time.Duration
-}
-
-type toastOptions struct {
- title *string
- duration *time.Duration
- color *compat.AdaptiveColor
-}
-
-type ToastOption func(*toastOptions)
-
-func WithTitle(title string) ToastOption {
- return func(t *toastOptions) {
- t.title = &title
- }
-}
-func WithDuration(duration time.Duration) ToastOption {
- return func(t *toastOptions) {
- t.duration = &duration
- }
-}
-
-func WithColor(color compat.AdaptiveColor) ToastOption {
- return func(t *toastOptions) {
- t.color = &color
- }
-}
-
-func NewToast(message string, options ...ToastOption) tea.Cmd {
- t := theme.CurrentTheme()
- duration := 5 * time.Second
- color := t.Primary()
-
- opts := toastOptions{
- duration: &duration,
- color: &color,
- }
- for _, option := range options {
- option(&opts)
- }
-
- return func() tea.Msg {
- return ShowToastMsg{
- Message: message,
- Title: opts.title,
- Duration: *opts.duration,
- Color: *opts.color,
- }
- }
-}
-
-func NewInfoToast(message string, options ...ToastOption) tea.Cmd {
- options = append(options, WithColor(theme.CurrentTheme().Info()))
- return NewToast(
- message,
- options...,
- )
-}
-
-func NewSuccessToast(message string, options ...ToastOption) tea.Cmd {
- options = append(options, WithColor(theme.CurrentTheme().Success()))
- return NewToast(
- message,
- options...,
- )
-}
-
-func NewWarningToast(message string, options ...ToastOption) tea.Cmd {
- options = append(options, WithColor(theme.CurrentTheme().Warning()))
- return NewToast(
- message,
- options...,
- )
-}
-
-func NewErrorToast(message string, options ...ToastOption) tea.Cmd {
- options = append(options, WithColor(theme.CurrentTheme().Error()))
- return NewToast(
- message,
- options...,
- )
-}
diff --git a/packages/tui/internal/decoders/decoder.go b/packages/tui/internal/decoders/decoder.go
deleted file mode 100644
index efb699202..000000000
--- a/packages/tui/internal/decoders/decoder.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package decoders
-
-import (
- "bufio"
- "bytes"
- "io"
-
- "github.com/sst/opencode-sdk-go/packages/ssestream"
-)
-
-// UnboundedDecoder is an SSE decoder that uses bufio.Reader instead of bufio.Scanner
-// to avoid the 32MB token size limit. This is a workaround for large SSE events until
-// the upstream Stainless SDK is fixed.
-//
-// This decoder handles SSE events of unlimited size by reading line-by-line with
-// bufio.Reader.ReadBytes('\n'), which dynamically grows the buffer as needed.
-type UnboundedDecoder struct {
- reader *bufio.Reader
- closer io.ReadCloser
- evt ssestream.Event
- err error
-}
-
-// NewUnboundedDecoder creates a new unbounded SSE decoder with a 1MB initial buffer size
-func NewUnboundedDecoder(rc io.ReadCloser) ssestream.Decoder {
- reader := bufio.NewReaderSize(rc, 1024*1024) // 1MB initial buffer
- return &UnboundedDecoder{
- reader: reader,
- closer: rc,
- }
-}
-
-// Next reads and decodes the next SSE event from the stream
-func (d *UnboundedDecoder) Next() bool {
- if d.err != nil {
- return false
- }
-
- event := ""
- data := bytes.NewBuffer(nil)
-
- for {
- line, err := d.reader.ReadBytes('\n')
- if err != nil {
- if err == io.EOF && len(line) == 0 {
- return false
- }
- if err != io.EOF {
- d.err = err
- return false
- }
- }
-
- // Remove trailing newline characters
- line = bytes.TrimRight(line, "\r\n")
-
- // Empty line indicates end of event
- if len(line) == 0 {
- if data.Len() > 0 || event != "" {
- d.evt = ssestream.Event{
- Type: event,
- Data: data.Bytes(),
- }
- return true
- }
- continue
- }
-
- // Skip comments (lines starting with ':')
- if line[0] == ':' {
- continue
- }
-
- // Parse field
- name, value, found := bytes.Cut(line, []byte(":"))
- if !found {
- // Field with no value
- continue
- }
-
- // Remove leading space from value
- if len(value) > 0 && value[0] == ' ' {
- value = value[1:]
- }
-
- switch string(name) {
- case "":
- // An empty line in the form ": something" is a comment and should be ignored
- continue
- case "event":
- event = string(value)
- case "data":
- _, d.err = data.Write(value)
- if d.err != nil {
- return false
- }
- _, d.err = data.WriteRune('\n')
- if d.err != nil {
- return false
- }
- }
- }
-}
-
-// Event returns the current event
-func (d *UnboundedDecoder) Event() ssestream.Event {
- return d.evt
-}
-
-// Close closes the underlying reader
-func (d *UnboundedDecoder) Close() error {
- return d.closer.Close()
-}
-
-// Err returns any error that occurred during decoding
-func (d *UnboundedDecoder) Err() error {
- return d.err
-}
diff --git a/packages/tui/internal/decoders/decoder_test.go b/packages/tui/internal/decoders/decoder_test.go
deleted file mode 100644
index e5ad1d55a..000000000
--- a/packages/tui/internal/decoders/decoder_test.go
+++ /dev/null
@@ -1,194 +0,0 @@
-package decoders
-
-import (
- "bytes"
- "io"
- "strings"
- "testing"
-)
-
-func TestUnboundedDecoder_SmallEvent(t *testing.T) {
- data := "event: test\ndata: hello world\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "test" {
- t.Errorf("Expected event type 'test', got '%s'", evt.Type)
- }
- if string(evt.Data) != "hello world\n" {
- t.Errorf("Expected data 'hello world\\n', got '%s'", string(evt.Data))
- }
-
- if decoder.Next() {
- t.Error("Expected Next() to return false at end of stream")
- }
-
- if err := decoder.Err(); err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
-}
-
-func TestUnboundedDecoder_LargeEvent(t *testing.T) {
- // Create a large event (50MB)
- size := 50 * 1024 * 1024
- largeData := strings.Repeat("x", size)
-
- var buf bytes.Buffer
- buf.WriteString("event: large\n")
- buf.WriteString("data: ")
- buf.WriteString(largeData)
- buf.WriteString("\n\n")
-
- rc := io.NopCloser(&buf)
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "large" {
- t.Errorf("Expected event type 'large', got '%s'", evt.Type)
- }
-
- expectedData := largeData + "\n"
- if string(evt.Data) != expectedData {
- t.Errorf("Data size mismatch: expected %d, got %d", len(expectedData), len(evt.Data))
- }
-
- if decoder.Next() {
- t.Error("Expected Next() to return false at end of stream")
- }
-
- if err := decoder.Err(); err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
-}
-
-func TestUnboundedDecoder_MultipleEvents(t *testing.T) {
- data := "event: first\ndata: data1\n\nevent: second\ndata: data2\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- // First event
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true for first event")
- }
- evt := decoder.Event()
- if evt.Type != "first" {
- t.Errorf("Expected event type 'first', got '%s'", evt.Type)
- }
- if string(evt.Data) != "data1\n" {
- t.Errorf("Expected data 'data1\\n', got '%s'", string(evt.Data))
- }
-
- // Second event
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true for second event")
- }
- evt = decoder.Event()
- if evt.Type != "second" {
- t.Errorf("Expected event type 'second', got '%s'", evt.Type)
- }
- if string(evt.Data) != "data2\n" {
- t.Errorf("Expected data 'data2\\n', got '%s'", string(evt.Data))
- }
-
- // No more events
- if decoder.Next() {
- t.Error("Expected Next() to return false at end of stream")
- }
-
- if err := decoder.Err(); err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
-}
-
-func TestUnboundedDecoder_MultilineData(t *testing.T) {
- data := "event: multiline\ndata: line1\ndata: line2\ndata: line3\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "multiline" {
- t.Errorf("Expected event type 'multiline', got '%s'", evt.Type)
- }
-
- expectedData := "line1\nline2\nline3\n"
- if string(evt.Data) != expectedData {
- t.Errorf("Expected data '%s', got '%s'", expectedData, string(evt.Data))
- }
-}
-
-func TestUnboundedDecoder_Comments(t *testing.T) {
- data := ": this is a comment\nevent: test\n: another comment\ndata: hello\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "test" {
- t.Errorf("Expected event type 'test', got '%s'", evt.Type)
- }
- if string(evt.Data) != "hello\n" {
- t.Errorf("Expected data 'hello\\n', got '%s'", string(evt.Data))
- }
-}
-
-func TestUnboundedDecoder_NoEventType(t *testing.T) {
- data := "data: hello\n\n"
- rc := io.NopCloser(strings.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- t.Fatal("Expected Next() to return true")
- }
-
- evt := decoder.Event()
- if evt.Type != "" {
- t.Errorf("Expected empty event type, got '%s'", evt.Type)
- }
- if string(evt.Data) != "hello\n" {
- t.Errorf("Expected data 'hello\\n', got '%s'", string(evt.Data))
- }
-}
-
-func BenchmarkUnboundedDecoder_LargeEvent(b *testing.B) {
- // Create a 10MB event for benchmarking
- size := 10 * 1024 * 1024
- largeData := strings.Repeat("x", size)
-
- var buf bytes.Buffer
- buf.WriteString("event: bench\n")
- buf.WriteString("data: ")
- buf.WriteString(largeData)
- buf.WriteString("\n\n")
-
- data := buf.Bytes()
-
- b.ResetTimer()
- b.SetBytes(int64(len(data)))
-
- for i := 0; i < b.N; i++ {
- rc := io.NopCloser(bytes.NewReader(data))
- decoder := NewUnboundedDecoder(rc)
-
- if !decoder.Next() {
- b.Fatal("Expected Next() to return true")
- }
-
- _ = decoder.Event()
- }
-}
diff --git a/packages/tui/internal/id/id.go b/packages/tui/internal/id/id.go
deleted file mode 100644
index 0490b8f20..000000000
--- a/packages/tui/internal/id/id.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package id
-
-import (
- "crypto/rand"
- "encoding/hex"
- "fmt"
- "strings"
- "sync"
- "time"
-)
-
-const (
- PrefixSession = "ses"
- PrefixMessage = "msg"
- PrefixUser = "usr"
- PrefixPart = "prt"
-)
-
-const length = 26
-
-var (
- lastTimestamp int64
- counter int64
- mu sync.Mutex
-)
-
-type Prefix string
-
-const (
- Session Prefix = PrefixSession
- Message Prefix = PrefixMessage
- User Prefix = PrefixUser
- Part Prefix = PrefixPart
-)
-
-func ValidatePrefix(id string, prefix Prefix) bool {
- return strings.HasPrefix(id, string(prefix))
-}
-
-func Ascending(prefix Prefix, given ...string) string {
- return generateID(prefix, false, given...)
-}
-
-func Descending(prefix Prefix, given ...string) string {
- return generateID(prefix, true, given...)
-}
-
-func generateID(prefix Prefix, descending bool, given ...string) string {
- if len(given) > 0 && given[0] != "" {
- if !strings.HasPrefix(given[0], string(prefix)) {
- panic(fmt.Sprintf("ID %s does not start with %s", given[0], string(prefix)))
- }
- return given[0]
- }
-
- return generateNewID(prefix, descending)
-}
-
-func randomBase62(length int) string {
- const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
- result := make([]byte, length)
- bytes := make([]byte, length)
- rand.Read(bytes)
-
- for i := 0; i < length; i++ {
- result[i] = chars[bytes[i]%62]
- }
-
- return string(result)
-}
-
-func generateNewID(prefix Prefix, descending bool) string {
- mu.Lock()
- defer mu.Unlock()
-
- currentTimestamp := time.Now().UnixMilli()
-
- if currentTimestamp != lastTimestamp {
- lastTimestamp = currentTimestamp
- counter = 0
- }
- counter++
-
- now := uint64(currentTimestamp)*0x1000 + uint64(counter)
-
- if descending {
- now = ^now
- }
-
- timeBytes := make([]byte, 6)
- for i := 0; i < 6; i++ {
- timeBytes[i] = byte((now >> (40 - 8*i)) & 0xff)
- }
-
- return string(prefix) + "_" + hex.EncodeToString(timeBytes) + randomBase62(length-12)
-} \ No newline at end of file
diff --git a/packages/tui/internal/layout/flex.go b/packages/tui/internal/layout/flex.go
deleted file mode 100644
index 5b10a9523..000000000
--- a/packages/tui/internal/layout/flex.go
+++ /dev/null
@@ -1,325 +0,0 @@
-package layout
-
-import (
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-type Direction int
-
-const (
- Row Direction = iota
- Column
-)
-
-type Justify int
-
-const (
- JustifyStart Justify = iota
- JustifyEnd
- JustifyCenter
- JustifySpaceBetween
- JustifySpaceAround
-)
-
-type Align int
-
-const (
- AlignStart Align = iota
- AlignEnd
- AlignCenter
- AlignStretch // Only applicable in the cross-axis
-)
-
-type FlexOptions struct {
- Background *compat.AdaptiveColor
- Direction Direction
- Justify Justify
- Align Align
- Width int
- Height int
- Gap int
-}
-
-type FlexItem struct {
- View string
- FixedSize int // Fixed size in the main axis (width for Row, height for Column)
- Grow bool // If true, the item will grow to fill available space
-}
-
-// Render lays out a series of view strings based on flexbox-like rules.
-func Render(opts FlexOptions, items ...FlexItem) string {
- if len(items) == 0 {
- return ""
- }
-
- t := theme.CurrentTheme()
- if opts.Background == nil {
- background := t.Background()
- opts.Background = &background
- }
-
- // Calculate dimensions for each item
- mainAxisSize := opts.Width
- crossAxisSize := opts.Height
- if opts.Direction == Column {
- mainAxisSize = opts.Height
- crossAxisSize = opts.Width
- }
-
- // Calculate total fixed size and count grow items
- totalFixedSize := 0
- growCount := 0
- for _, item := range items {
- if item.FixedSize > 0 {
- totalFixedSize += item.FixedSize
- } else if item.Grow {
- growCount++
- }
- }
-
- // Account for gaps between items
- totalGapSize := 0
- if len(items) > 1 && opts.Gap > 0 {
- totalGapSize = opts.Gap * (len(items) - 1)
- }
-
- // Calculate available space for grow items
- availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
-
- // Calculate size for each grow item
- growItemSize := 0
- if growCount > 0 && availableSpace > 0 {
- growItemSize = availableSpace / growCount
- }
-
- // Prepare sized views
- sizedViews := make([]string, len(items))
- actualSizes := make([]int, len(items))
-
- for i, item := range items {
- view := item.View
-
- // Determine the size for this item
- itemSize := 0
- if item.FixedSize > 0 {
- itemSize = item.FixedSize
- } else if item.Grow && growItemSize > 0 {
- itemSize = growItemSize
- } else {
- // No fixed size and not growing - use natural size
- if opts.Direction == Row {
- itemSize = lipgloss.Width(view)
- } else {
- itemSize = lipgloss.Height(view)
- }
- }
-
- // Apply size constraints
- if opts.Direction == Row {
- // For row direction, constrain width and handle height alignment
- if itemSize > 0 {
- view = styles.NewStyle().
- Background(*opts.Background).
- Width(itemSize).
- Height(crossAxisSize).
- Render(view)
- }
-
- // Apply cross-axis alignment
- switch opts.Align {
- case AlignCenter:
- view = lipgloss.PlaceVertical(
- crossAxisSize,
- lipgloss.Center,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignEnd:
- view = lipgloss.PlaceVertical(
- crossAxisSize,
- lipgloss.Bottom,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignStart:
- view = lipgloss.PlaceVertical(
- crossAxisSize,
- lipgloss.Top,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignStretch:
- // Already stretched by Height setting above
- }
- } else {
- // For column direction, constrain height and handle width alignment
- if itemSize > 0 {
- style := styles.NewStyle().
- Background(*opts.Background).
- Height(itemSize)
- // Only set width for stretch alignment
- if opts.Align == AlignStretch {
- style = style.Width(crossAxisSize)
- }
- view = style.Render(view)
- }
-
- // Apply cross-axis alignment
- switch opts.Align {
- case AlignCenter:
- view = lipgloss.PlaceHorizontal(
- crossAxisSize,
- lipgloss.Center,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignEnd:
- view = lipgloss.PlaceHorizontal(
- crossAxisSize,
- lipgloss.Right,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignStart:
- view = lipgloss.PlaceHorizontal(
- crossAxisSize,
- lipgloss.Left,
- view,
- styles.WhitespaceStyle(*opts.Background),
- )
- case AlignStretch:
- // Already stretched by Width setting above
- }
- }
-
- sizedViews[i] = view
- if opts.Direction == Row {
- actualSizes[i] = lipgloss.Width(view)
- } else {
- actualSizes[i] = lipgloss.Height(view)
- }
- }
-
- // Calculate total actual size including gaps
- totalActualSize := 0
- for _, size := range actualSizes {
- totalActualSize += size
- }
- if len(items) > 1 && opts.Gap > 0 {
- totalActualSize += opts.Gap * (len(items) - 1)
- }
-
- // Apply justification
- remainingSpace := max(mainAxisSize-totalActualSize, 0)
-
- // Calculate spacing based on justification
- var spaceBefore, spaceBetween, spaceAfter int
- switch opts.Justify {
- case JustifyStart:
- spaceAfter = remainingSpace
- case JustifyEnd:
- spaceBefore = remainingSpace
- case JustifyCenter:
- spaceBefore = remainingSpace / 2
- spaceAfter = remainingSpace - spaceBefore
- case JustifySpaceBetween:
- if len(items) > 1 {
- spaceBetween = remainingSpace / (len(items) - 1)
- } else {
- spaceAfter = remainingSpace
- }
- case JustifySpaceAround:
- if len(items) > 0 {
- spaceAround := remainingSpace / (len(items) * 2)
- spaceBefore = spaceAround
- spaceAfter = spaceAround
- spaceBetween = spaceAround * 2
- }
- }
-
- // Build the final layout
- var parts []string
-
- spaceStyle := styles.NewStyle().Background(*opts.Background)
- // Add space before if needed
- if spaceBefore > 0 {
- if opts.Direction == Row {
- space := strings.Repeat(" ", spaceBefore)
- parts = append(parts, spaceStyle.Render(space))
- } else {
- // For vertical layout, add empty lines as separate parts
- for range spaceBefore {
- parts = append(parts, "")
- }
- }
- }
-
- // Add items with spacing
- for i, view := range sizedViews {
- parts = append(parts, view)
-
- // Add space between items (not after the last one)
- if i < len(sizedViews)-1 {
- // Add gap first, then any additional spacing from justification
- totalSpacing := opts.Gap + spaceBetween
- if totalSpacing > 0 {
- if opts.Direction == Row {
- space := strings.Repeat(" ", totalSpacing)
- parts = append(parts, spaceStyle.Render(space))
- } else {
- // For vertical layout, add empty lines as separate parts
- for range totalSpacing {
- parts = append(parts, "")
- }
- }
- }
- }
- }
-
- // Add space after if needed
- if spaceAfter > 0 {
- if opts.Direction == Row {
- space := strings.Repeat(" ", spaceAfter)
- parts = append(parts, spaceStyle.Render(space))
- } else {
- // For vertical layout, add empty lines as separate parts
- for range spaceAfter {
- parts = append(parts, "")
- }
- }
- }
-
- // Join the parts
- if opts.Direction == Row {
- return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
- } else {
- return lipgloss.JoinVertical(lipgloss.Left, parts...)
- }
-}
-
-// Helper function to create a simple vertical layout
-func Vertical(width, height int, items ...FlexItem) string {
- return Render(FlexOptions{
- Direction: Column,
- Width: width,
- Height: height,
- Justify: JustifyStart,
- Align: AlignStretch,
- }, items...)
-}
-
-// Helper function to create a simple horizontal layout
-func Horizontal(width, height int, items ...FlexItem) string {
- return Render(FlexOptions{
- Direction: Row,
- Width: width,
- Height: height,
- Justify: JustifyStart,
- Align: AlignStretch,
- }, items...)
-}
diff --git a/packages/tui/internal/layout/layout.go b/packages/tui/internal/layout/layout.go
deleted file mode 100644
index dce27ac68..000000000
--- a/packages/tui/internal/layout/layout.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package layout
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
-)
-
-var Current *LayoutInfo
-
-func init() {
- Current = &LayoutInfo{
- Viewport: Dimensions{Width: 80, Height: 25},
- Container: Dimensions{Width: 80, Height: 25},
- }
-}
-
-type LayoutSize string
-
-type Dimensions struct {
- Width int
- Height int
-}
-
-type LayoutInfo struct {
- Viewport Dimensions
- Container Dimensions
-}
-
-type Modal interface {
- tea.Model
- Render(background string) string
- Close() tea.Cmd
-}
diff --git a/packages/tui/internal/layout/overlay.go b/packages/tui/internal/layout/overlay.go
deleted file mode 100644
index 08016e31c..000000000
--- a/packages/tui/internal/layout/overlay.go
+++ /dev/null
@@ -1,382 +0,0 @@
-package layout
-
-import (
- "fmt"
- "regexp"
- "strings"
- "unicode/utf8"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- chAnsi "github.com/charmbracelet/x/ansi"
- "github.com/muesli/ansi"
- "github.com/muesli/reflow/truncate"
- "github.com/muesli/termenv"
- "github.com/sst/opencode/internal/util"
-)
-
-var (
- // ANSI escape sequence regex
- ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
-)
-
-// Split a string into lines, additionally returning the size of the widest line.
-func getLines(s string) (lines []string, widest int) {
- lines = strings.Split(s, "\n")
- for _, l := range lines {
- w := ansi.PrintableRuneWidth(l)
- if widest < w {
- widest = w
- }
- }
- return lines, widest
-}
-
-// overlayOptions holds configuration for overlay rendering
-type overlayOptions struct {
- whitespace *whitespace
- border bool
- borderColor *compat.AdaptiveColor
-}
-
-// OverlayOption sets options for overlay rendering
-type OverlayOption func(*overlayOptions)
-
-// PlaceOverlay places fg on top of bg.
-func PlaceOverlay(
- x, y int,
- fg, bg string,
- opts ...OverlayOption,
-) string {
- fgLines, fgWidth := getLines(fg)
- bgLines, bgWidth := getLines(bg)
- bgHeight := len(bgLines)
- fgHeight := len(fgLines)
-
- // Parse options
- options := &overlayOptions{
- whitespace: &whitespace{},
- }
- for _, opt := range opts {
- opt(options)
- }
-
- // Adjust for borders if enabled
- if options.border {
- // Add space for left and right borders
- adjustedFgWidth := fgWidth + 2
- // Adjust placement to account for borders
- x = util.Clamp(x, 0, bgWidth-adjustedFgWidth)
- y = util.Clamp(y, 0, bgHeight-fgHeight)
-
- // Pad all foreground lines to the same width for consistent borders
- for i := range fgLines {
- lineWidth := ansi.PrintableRuneWidth(fgLines[i])
- if lineWidth < fgWidth {
- fgLines[i] += strings.Repeat(" ", fgWidth-lineWidth)
- }
- }
- } else {
- if fgWidth >= bgWidth && fgHeight >= bgHeight {
- // FIXME: return fg or bg?
- return fg
- }
- // TODO: allow placement outside of the bg box?
- x = util.Clamp(x, 0, bgWidth-fgWidth)
- y = util.Clamp(y, 0, bgHeight-fgHeight)
- }
-
- var b strings.Builder
- for i, bgLine := range bgLines {
- if i > 0 {
- b.WriteByte('\n')
- }
- if i < y || i >= y+fgHeight {
- b.WriteString(bgLine)
- continue
- }
-
- pos := 0
-
- // Handle left side of the line up to the overlay
- if x > 0 {
- left := truncate.String(bgLine, uint(x))
- pos = ansi.PrintableRuneWidth(left)
- b.WriteString(left)
- if pos < x {
- b.WriteString(options.whitespace.render(x - pos))
- pos = x
- }
- }
-
- // Render the overlay content with optional borders
- if options.border {
- // Get the foreground line
- fgLine := fgLines[i-y]
- fgLineWidth := ansi.PrintableRuneWidth(fgLine)
-
- // Extract the styles at the border positions
- // We need to get the style just before the border position to preserve background
- leftStyle := ansiStyle{}
- if pos > 0 {
- leftStyle = getStyleAtPosition(bgLine, pos-1)
- } else {
- leftStyle = getStyleAtPosition(bgLine, pos)
- }
- rightStyle := getStyleAtPosition(bgLine, pos+fgLineWidth)
-
- // Left border - combine background from original with border foreground
- leftSeq := combineStyles(leftStyle, options.borderColor)
- if leftSeq != "" {
- b.WriteString(leftSeq)
- }
- b.WriteString("┃")
- if leftSeq != "" {
- b.WriteString("\x1b[0m") // Reset all styles only if we applied any
- }
- pos++
-
- // Content
- b.WriteString(fgLine)
- pos += fgLineWidth
-
- // Right border - combine background from original with border foreground
- rightSeq := combineStyles(rightStyle, options.borderColor)
- if rightSeq != "" {
- b.WriteString(rightSeq)
- }
- b.WriteString("┃")
- if rightSeq != "" {
- b.WriteString("\x1b[0m") // Reset all styles only if we applied any
- }
- pos++
- } else {
- // No border, just render the content
- fgLine := fgLines[i-y]
- b.WriteString(fgLine)
- pos += ansi.PrintableRuneWidth(fgLine)
- }
-
- // Handle right side of the line after the overlay
- right := cutLeft(bgLine, pos)
- bgWidth := ansi.PrintableRuneWidth(bgLine)
- rightWidth := ansi.PrintableRuneWidth(right)
- if rightWidth <= bgWidth-pos {
- b.WriteString(options.whitespace.render(bgWidth - rightWidth - pos))
- }
-
- b.WriteString(right)
- }
-
- return b.String()
-}
-
-// cutLeft cuts printable characters from the left.
-// This function is heavily based on muesli's ansi and truncate packages.
-func cutLeft(s string, cutWidth int) string {
- return chAnsi.Cut(s, cutWidth, lipgloss.Width(s))
-}
-
-// ansiStyle represents parsed ANSI style attributes
-type ansiStyle struct {
- fgColor string
- bgColor string
- attrs []string
-}
-
-// parseANSISequence parses an ANSI escape sequence into its components
-func parseANSISequence(seq string) ansiStyle {
- style := ansiStyle{}
-
- // Extract the parameters from the sequence (e.g., \x1b[38;5;123;48;5;456m -> "38;5;123;48;5;456")
- if !strings.HasPrefix(seq, "\x1b[") || !strings.HasSuffix(seq, "m") {
- return style
- }
-
- params := seq[2 : len(seq)-1]
- if params == "" {
- return style
- }
-
- parts := strings.Split(params, ";")
- i := 0
- for i < len(parts) {
- switch parts[i] {
- case "0": // Reset
- // Mark this as a reset by adding it to attrs
- style.attrs = append(style.attrs, "0")
- // Don't clear the style here, let the caller handle it
- case "1", "2", "3", "4", "5", "6", "7", "8", "9": // Various attributes
- style.attrs = append(style.attrs, parts[i])
- case "38": // Foreground color
- if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
- // 256 color mode
- style.fgColor = strings.Join(parts[i:i+3], ";")
- i += 2
- } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
- // RGB color mode
- style.fgColor = strings.Join(parts[i:i+5], ";")
- i += 4
- }
- case "48": // Background color
- if i+1 < len(parts) && parts[i+1] == "5" && i+2 < len(parts) {
- // 256 color mode
- style.bgColor = strings.Join(parts[i:i+3], ";")
- i += 2
- } else if i+1 < len(parts) && parts[i+1] == "2" && i+4 < len(parts) {
- // RGB color mode
- style.bgColor = strings.Join(parts[i:i+5], ";")
- i += 4
- }
- case "30", "31", "32", "33", "34", "35", "36", "37": // Standard foreground colors
- style.fgColor = parts[i]
- case "40", "41", "42", "43", "44", "45", "46", "47": // Standard background colors
- style.bgColor = parts[i]
- case "90", "91", "92", "93", "94", "95", "96", "97": // Bright foreground colors
- style.fgColor = parts[i]
- case "100", "101", "102", "103", "104", "105", "106", "107": // Bright background colors
- style.bgColor = parts[i]
- }
- i++
- }
-
- return style
-}
-
-// combineStyles creates an ANSI sequence that combines background from one style with foreground from another
-func combineStyles(bgStyle ansiStyle, fgColor *compat.AdaptiveColor) string {
- if fgColor == nil && bgStyle.bgColor == "" && len(bgStyle.attrs) == 0 {
- return ""
- }
-
- var parts []string
-
- // Add attributes
- parts = append(parts, bgStyle.attrs...)
-
- // Add background color from the original style
- if bgStyle.bgColor != "" {
- parts = append(parts, bgStyle.bgColor)
- }
-
- // Add foreground color if specified
- if fgColor != nil {
- // Use the adaptive color which automatically selects based on terminal background
- // The RGBA method already handles light/dark selection
- r, g, b, _ := fgColor.RGBA()
- // RGBA returns 16-bit values, we need 8-bit
- parts = append(parts, fmt.Sprintf("38;2;%d;%d;%d", r>>8, g>>8, b>>8))
- }
-
- if len(parts) == 0 {
- return ""
- }
-
- return fmt.Sprintf("\x1b[%sm", strings.Join(parts, ";"))
-}
-
-// getStyleAtPosition extracts the active ANSI style at a given visual position
-func getStyleAtPosition(s string, targetPos int) ansiStyle {
- visualPos := 0
- currentStyle := ansiStyle{}
-
- i := 0
- for i < len(s) && visualPos <= targetPos {
- // Check if we're at an ANSI escape sequence
- if match := ansiRegex.FindStringIndex(s[i:]); match != nil && match[0] == 0 {
- // Found an ANSI sequence at current position
- seq := s[i : i+match[1]]
- parsedStyle := parseANSISequence(seq)
-
- // Check if this is a reset sequence
- if len(parsedStyle.attrs) > 0 && parsedStyle.attrs[0] == "0" {
- // Reset all styles
- currentStyle = ansiStyle{}
- } else {
- // Update current style (merge with existing)
- if parsedStyle.fgColor != "" {
- currentStyle.fgColor = parsedStyle.fgColor
- }
- if parsedStyle.bgColor != "" {
- currentStyle.bgColor = parsedStyle.bgColor
- }
- if len(parsedStyle.attrs) > 0 {
- currentStyle.attrs = parsedStyle.attrs
- }
- }
-
- i += match[1]
- } else if i < len(s) {
- // Regular character
- if visualPos == targetPos {
- return currentStyle
- }
- _, size := utf8.DecodeRuneInString(s[i:])
- i += size
- visualPos++
- }
- }
-
- return currentStyle
-}
-
-type whitespace struct {
- style termenv.Style
- chars string
-}
-
-// Render whitespaces.
-func (w whitespace) render(width int) string {
- if w.chars == "" {
- w.chars = " "
- }
-
- r := []rune(w.chars)
- j := 0
- b := strings.Builder{}
-
- // Cycle through runes and print them into the whitespace.
- for i := 0; i < width; {
- b.WriteRune(r[j])
- j++
- if j >= len(r) {
- j = 0
- }
- i += ansi.PrintableRuneWidth(string(r[j]))
- }
-
- // Fill any extra gaps white spaces. This might be necessary if any runes
- // are more than one cell wide, which could leave a one-rune gap.
- short := width - ansi.PrintableRuneWidth(b.String())
- if short > 0 {
- b.WriteString(strings.Repeat(" ", short))
- }
-
- return w.style.Styled(b.String())
-}
-
-// WhitespaceOption sets a styling rule for rendering whitespace.
-type WhitespaceOption func(*whitespace)
-
-// WithWhitespace sets whitespace options for the overlay
-func WithWhitespace(opts ...WhitespaceOption) OverlayOption {
- return func(o *overlayOptions) {
- for _, opt := range opts {
- opt(o.whitespace)
- }
- }
-}
-
-// WithOverlayBorder enables border rendering for the overlay
-func WithOverlayBorder() OverlayOption {
- return func(o *overlayOptions) {
- o.border = true
- }
-}
-
-// WithOverlayBorderColor sets the border color for the overlay
-func WithOverlayBorderColor(color compat.AdaptiveColor) OverlayOption {
- return func(o *overlayOptions) {
- o.borderColor = &color
- }
-}
diff --git a/packages/tui/internal/styles/background.go b/packages/tui/internal/styles/background.go
deleted file mode 100644
index 99b05b456..000000000
--- a/packages/tui/internal/styles/background.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package styles
-
-import "image/color"
-
-type TerminalInfo struct {
- Background color.Color
- BackgroundIsDark bool
-}
-
-var Terminal *TerminalInfo
-
-func init() {
- Terminal = &TerminalInfo{
- Background: color.Black,
- BackgroundIsDark: true,
- }
-}
diff --git a/packages/tui/internal/styles/markdown.go b/packages/tui/internal/styles/markdown.go
deleted file mode 100644
index d73c14101..000000000
--- a/packages/tui/internal/styles/markdown.go
+++ /dev/null
@@ -1,326 +0,0 @@
-package styles
-
-import (
- "github.com/charmbracelet/glamour"
- "github.com/charmbracelet/glamour/ansi"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/lucasb-eyer/go-colorful"
- "github.com/sst/opencode/internal/theme"
-)
-
-const defaultMargin = 1
-
-// Helper functions for style pointers
-func boolPtr(b bool) *bool { return &b }
-func stringPtr(s string) *string { return &s }
-func uintPtr(u uint) *uint { return &u }
-
-// returns a glamour TermRenderer configured with the current theme
-func GetMarkdownRenderer(width int, backgroundColor compat.AdaptiveColor) *glamour.TermRenderer {
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(generateMarkdownStyleConfig(backgroundColor)),
- glamour.WithWordWrap(width),
- glamour.WithChromaFormatter("terminal16m"),
- )
- return r
-}
-
-// creates an ansi.StyleConfig for markdown rendering
-// using adaptive colors from the provided theme.
-func generateMarkdownStyleConfig(backgroundColor compat.AdaptiveColor) ansi.StyleConfig {
- t := theme.CurrentTheme()
- background := AdaptiveColorToString(backgroundColor)
-
- return ansi.StyleConfig{
- Document: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockPrefix: "",
- BlockSuffix: "",
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- },
- BlockQuote: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownBlockQuote()),
- Italic: boolPtr(true),
- Prefix: "┃ ",
- },
- Indent: uintPtr(1),
- IndentToken: stringPtr(" "),
- },
- List: ansi.StyleList{
- LevelIndent: defaultMargin,
- StyleBlock: ansi.StyleBlock{
- IndentToken: stringPtr(" "),
- StylePrimitive: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- },
- },
- Heading: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockSuffix: "\n",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H1: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "# ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H2: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "## ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H3: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "### ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H4: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "#### ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H5: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "##### ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- H6: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "###### ",
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- Bold: boolPtr(true),
- },
- },
- Strikethrough: ansi.StylePrimitive{
- CrossedOut: boolPtr(true),
- Color: AdaptiveColorToString(t.TextMuted()),
- },
- Emph: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownEmph()),
- Italic: boolPtr(true),
- },
- Strong: ansi.StylePrimitive{
- Bold: boolPtr(true),
- Color: AdaptiveColorToString(t.MarkdownStrong()),
- },
- HorizontalRule: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownHorizontalRule()),
- Format: "\n─────────────────────────────────────────\n",
- },
- Item: ansi.StylePrimitive{
- BlockPrefix: "• ",
- Color: AdaptiveColorToString(t.MarkdownListItem()),
- },
- Enumeration: ansi.StylePrimitive{
- BlockPrefix: ". ",
- Color: AdaptiveColorToString(t.MarkdownListEnumeration()),
- },
- Task: ansi.StyleTask{
- Ticked: "[✓] ",
- Unticked: "[ ] ",
- },
- Link: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownLink()),
- Underline: boolPtr(true),
- },
- LinkText: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownLinkText()),
- Bold: boolPtr(true),
- },
- Image: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownImage()),
- Underline: boolPtr(true),
- Format: "🖼 {{.text}}",
- },
- ImageText: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownImageText()),
- Format: "{{.text}}",
- },
- Code: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownCode()),
- Prefix: "",
- Suffix: "",
- },
- },
- CodeBlock: ansi.StyleCodeBlock{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BackgroundColor: background,
- Prefix: " ",
- Color: AdaptiveColorToString(t.MarkdownCodeBlock()),
- },
- },
- Chroma: &ansi.Chroma{
- Background: ansi.StylePrimitive{
- BackgroundColor: background,
- },
- Text: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- Error: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.Error()),
- },
- Comment: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxComment()),
- },
- CommentPreproc: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- Keyword: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- KeywordReserved: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- KeywordNamespace: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- KeywordType: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxType()),
- },
- Operator: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxOperator()),
- },
- Punctuation: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxPunctuation()),
- },
- Name: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxVariable()),
- },
- NameBuiltin: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxVariable()),
- },
- NameTag: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- NameAttribute: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxFunction()),
- },
- NameClass: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxType()),
- },
- NameConstant: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxVariable()),
- },
- NameDecorator: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxFunction()),
- },
- NameFunction: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxFunction()),
- },
- LiteralNumber: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxNumber()),
- },
- LiteralString: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxString()),
- },
- LiteralStringEscape: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.SyntaxKeyword()),
- },
- GenericDeleted: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.DiffRemoved()),
- },
- GenericEmph: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownEmph()),
- Italic: boolPtr(true),
- },
- GenericInserted: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.DiffAdded()),
- },
- GenericStrong: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownStrong()),
- Bold: boolPtr(true),
- },
- GenericSubheading: ansi.StylePrimitive{
- BackgroundColor: background,
- Color: AdaptiveColorToString(t.MarkdownHeading()),
- },
- },
- },
- Table: ansi.StyleTable{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockSuffix: "\n",
- // TODO: find better way to fix markdown table renders
- BackgroundColor: stringPtr(""),
- },
- },
- CenterSeparator: stringPtr("┼"),
- ColumnSeparator: stringPtr("│"),
- RowSeparator: stringPtr("─"),
- },
- DefinitionDescription: ansi.StylePrimitive{
- BlockPrefix: "\n ❯ ",
- Color: AdaptiveColorToString(t.MarkdownLinkText()),
- },
- Text: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- Paragraph: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: AdaptiveColorToString(t.MarkdownText()),
- },
- },
- }
-}
-
-// AdaptiveColorToString converts a compat.AdaptiveColor to the appropriate
-// hex color string based on the current terminal background
-func AdaptiveColorToString(color compat.AdaptiveColor) *string {
- if Terminal.BackgroundIsDark {
- if _, ok := color.Dark.(lipgloss.NoColor); ok {
- return nil
- }
- c1, _ := colorful.MakeColor(color.Dark)
- return stringPtr(c1.Hex())
- }
- if _, ok := color.Light.(lipgloss.NoColor); ok {
- return nil
- }
- c1, _ := colorful.MakeColor(color.Light)
- return stringPtr(c1.Hex())
-}
diff --git a/packages/tui/internal/styles/styles.go b/packages/tui/internal/styles/styles.go
deleted file mode 100644
index b8905f8e4..000000000
--- a/packages/tui/internal/styles/styles.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package styles
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-func WhitespaceStyle(bg compat.AdaptiveColor) lipgloss.WhitespaceOption {
- return lipgloss.WithWhitespaceStyle(NewStyle().Background(bg).Lipgloss())
-}
diff --git a/packages/tui/internal/styles/utilities.go b/packages/tui/internal/styles/utilities.go
deleted file mode 100644
index 29d10f5c5..000000000
--- a/packages/tui/internal/styles/utilities.go
+++ /dev/null
@@ -1,295 +0,0 @@
-package styles
-
-import (
- "image/color"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-// IsNoColor checks if a color is the special NoColor type
-func IsNoColor(c color.Color) bool {
- _, ok := c.(lipgloss.NoColor)
- return ok
-}
-
-// Style wraps lipgloss.Style to provide a fluent API for handling "none" colors
-type Style struct {
- lipgloss.Style
-}
-
-// NewStyle creates a new Style with proper handling of "none" colors
-func NewStyle() Style {
- return Style{lipgloss.NewStyle()}
-}
-
-func (s Style) Lipgloss() lipgloss.Style {
- return s.Style
-}
-
-// Foreground sets the foreground color, handling "none" appropriately
-func (s Style) Foreground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetForeground()}
- }
- return Style{s.Style.Foreground(c)}
-}
-
-// Background sets the background color, handling "none" appropriately
-func (s Style) Background(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBackground()}
- }
- return Style{s.Style.Background(c)}
-}
-
-// BorderForeground sets the border foreground color, handling "none" appropriately
-func (s Style) BorderForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderForeground()}
- }
- return Style{s.Style.BorderForeground(c)}
-}
-
-// BorderBackground sets the border background color, handling "none" appropriately
-func (s Style) BorderBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderBackground()}
- }
- return Style{s.Style.BorderBackground(c)}
-}
-
-// BorderTopForeground sets the border top foreground color, handling "none" appropriately
-func (s Style) BorderTopForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderTopForeground()}
- }
- return Style{s.Style.BorderTopForeground(c)}
-}
-
-// BorderTopBackground sets the border top background color, handling "none" appropriately
-func (s Style) BorderTopBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderTopBackground()}
- }
- return Style{s.Style.BorderTopBackground(c)}
-}
-
-// BorderBottomForeground sets the border bottom foreground color, handling "none" appropriately
-func (s Style) BorderBottomForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderBottomForeground()}
- }
- return Style{s.Style.BorderBottomForeground(c)}
-}
-
-// BorderBottomBackground sets the border bottom background color, handling "none" appropriately
-func (s Style) BorderBottomBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderBottomBackground()}
- }
- return Style{s.Style.BorderBottomBackground(c)}
-}
-
-// BorderLeftForeground sets the border left foreground color, handling "none" appropriately
-func (s Style) BorderLeftForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderLeftForeground()}
- }
- return Style{s.Style.BorderLeftForeground(c)}
-}
-
-// BorderLeftBackground sets the border left background color, handling "none" appropriately
-func (s Style) BorderLeftBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderLeftBackground()}
- }
- return Style{s.Style.BorderLeftBackground(c)}
-}
-
-// BorderRightForeground sets the border right foreground color, handling "none" appropriately
-func (s Style) BorderRightForeground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderRightForeground()}
- }
- return Style{s.Style.BorderRightForeground(c)}
-}
-
-// BorderRightBackground sets the border right background color, handling "none" appropriately
-func (s Style) BorderRightBackground(c compat.AdaptiveColor) Style {
- if IsNoColor(c.Dark) && IsNoColor(c.Light) {
- return Style{s.Style.UnsetBorderRightBackground()}
- }
- return Style{s.Style.BorderRightBackground(c)}
-}
-
-// Render applies the style to a string
-func (s Style) Render(str string) string {
- return s.Style.Render(str)
-}
-
-// Common lipgloss.Style method delegations for seamless usage
-
-func (s Style) Bold(v bool) Style {
- return Style{s.Style.Bold(v)}
-}
-
-func (s Style) Italic(v bool) Style {
- return Style{s.Style.Italic(v)}
-}
-
-func (s Style) Underline(v bool) Style {
- return Style{s.Style.Underline(v)}
-}
-
-func (s Style) Strikethrough(v bool) Style {
- return Style{s.Style.Strikethrough(v)}
-}
-
-func (s Style) Blink(v bool) Style {
- return Style{s.Style.Blink(v)}
-}
-
-func (s Style) Faint(v bool) Style {
- return Style{s.Style.Faint(v)}
-}
-
-func (s Style) Reverse(v bool) Style {
- return Style{s.Style.Reverse(v)}
-}
-
-func (s Style) Width(i int) Style {
- return Style{s.Style.Width(i)}
-}
-
-func (s Style) Height(i int) Style {
- return Style{s.Style.Height(i)}
-}
-
-func (s Style) Padding(i ...int) Style {
- return Style{s.Style.Padding(i...)}
-}
-
-func (s Style) PaddingTop(i int) Style {
- return Style{s.Style.PaddingTop(i)}
-}
-
-func (s Style) PaddingBottom(i int) Style {
- return Style{s.Style.PaddingBottom(i)}
-}
-
-func (s Style) PaddingLeft(i int) Style {
- return Style{s.Style.PaddingLeft(i)}
-}
-
-func (s Style) PaddingRight(i int) Style {
- return Style{s.Style.PaddingRight(i)}
-}
-
-func (s Style) Margin(i ...int) Style {
- return Style{s.Style.Margin(i...)}
-}
-
-func (s Style) MarginTop(i int) Style {
- return Style{s.Style.MarginTop(i)}
-}
-
-func (s Style) MarginBottom(i int) Style {
- return Style{s.Style.MarginBottom(i)}
-}
-
-func (s Style) MarginLeft(i int) Style {
- return Style{s.Style.MarginLeft(i)}
-}
-
-func (s Style) MarginRight(i int) Style {
- return Style{s.Style.MarginRight(i)}
-}
-
-func (s Style) Border(b lipgloss.Border, sides ...bool) Style {
- return Style{s.Style.Border(b, sides...)}
-}
-
-func (s Style) BorderStyle(b lipgloss.Border) Style {
- return Style{s.Style.BorderStyle(b)}
-}
-
-func (s Style) BorderTop(v bool) Style {
- return Style{s.Style.BorderTop(v)}
-}
-
-func (s Style) BorderBottom(v bool) Style {
- return Style{s.Style.BorderBottom(v)}
-}
-
-func (s Style) BorderLeft(v bool) Style {
- return Style{s.Style.BorderLeft(v)}
-}
-
-func (s Style) BorderRight(v bool) Style {
- return Style{s.Style.BorderRight(v)}
-}
-
-func (s Style) Align(p ...lipgloss.Position) Style {
- return Style{s.Style.Align(p...)}
-}
-
-func (s Style) AlignHorizontal(p lipgloss.Position) Style {
- return Style{s.Style.AlignHorizontal(p)}
-}
-
-func (s Style) AlignVertical(p lipgloss.Position) Style {
- return Style{s.Style.AlignVertical(p)}
-}
-
-func (s Style) Inline(v bool) Style {
- return Style{s.Style.Inline(v)}
-}
-
-func (s Style) MaxWidth(n int) Style {
- return Style{s.Style.MaxWidth(n)}
-}
-
-func (s Style) MaxHeight(n int) Style {
- return Style{s.Style.MaxHeight(n)}
-}
-
-func (s Style) TabWidth(n int) Style {
- return Style{s.Style.TabWidth(n)}
-}
-
-func (s Style) UnsetBold() Style {
- return Style{s.Style.UnsetBold()}
-}
-
-func (s Style) UnsetItalic() Style {
- return Style{s.Style.UnsetItalic()}
-}
-
-func (s Style) UnsetUnderline() Style {
- return Style{s.Style.UnsetUnderline()}
-}
-
-func (s Style) UnsetStrikethrough() Style {
- return Style{s.Style.UnsetStrikethrough()}
-}
-
-func (s Style) UnsetBlink() Style {
- return Style{s.Style.UnsetBlink()}
-}
-
-func (s Style) UnsetFaint() Style {
- return Style{s.Style.UnsetFaint()}
-}
-
-func (s Style) UnsetReverse() Style {
- return Style{s.Style.UnsetReverse()}
-}
-
-func (s Style) Copy() Style {
- return Style{s.Style}
-}
-
-func (s Style) Inherit(i Style) Style {
- return Style{s.Style.Inherit(i.Style)}
-}
diff --git a/packages/tui/internal/theme/loader.go b/packages/tui/internal/theme/loader.go
deleted file mode 100644
index b3d2f0982..000000000
--- a/packages/tui/internal/theme/loader.go
+++ /dev/null
@@ -1,408 +0,0 @@
-package theme
-
-import (
- "embed"
- "encoding/json"
- "fmt"
- "image/color"
- "os"
- "path"
- "path/filepath"
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-//go:embed themes/*.json
-var themesFS embed.FS
-
-type JSONTheme struct {
- Defs map[string]any `json:"defs,omitempty"`
- Theme map[string]any `json:"theme"`
-}
-
-type LoadedTheme struct {
- BaseTheme
- name string
-}
-
-func (t *LoadedTheme) Name() string {
- return t.name
-}
-
-type colorRef struct {
- value any
- resolved bool
-}
-
-func LoadThemesFromJSON() error {
- entries, err := themesFS.ReadDir("themes")
- if err != nil {
- return fmt.Errorf("failed to read themes directory: %w", err)
- }
-
- for _, entry := range entries {
- if !strings.HasSuffix(entry.Name(), ".json") {
- continue
- }
- themeName := strings.TrimSuffix(entry.Name(), ".json")
- data, err := themesFS.ReadFile(path.Join("themes", entry.Name()))
- if err != nil {
- return fmt.Errorf("failed to read theme file %s: %w", entry.Name(), err)
- }
- theme, err := parseJSONTheme(themeName, data)
- if err != nil {
- return fmt.Errorf("failed to parse theme %s: %w", themeName, err)
- }
- RegisterTheme(themeName, theme)
- }
-
- return nil
-}
-
-// LoadThemesFromDirectories loads themes from user directories in the correct override order.
-// The hierarchy is (from lowest to highest priority):
-// 1. Built-in themes (embedded)
-// 2. USER_CONFIG/opencode/themes/*.json
-// 3. PROJECT_ROOT/.opencode/themes/*.json
-// 4. CWD/.opencode/themes/*.json
-func LoadThemesFromDirectories(userConfig, projectRoot, cwd string) error {
- if err := LoadThemesFromJSON(); err != nil {
- return fmt.Errorf("failed to load built-in themes: %w", err)
- }
-
- dirs := []string{
- filepath.Join(userConfig, "themes"),
- filepath.Join(projectRoot, ".opencode", "themes"),
- }
- if cwd != projectRoot {
- dirs = append(dirs, filepath.Join(cwd, ".opencode", "themes"))
- }
-
- for _, dir := range dirs {
- if err := loadThemesFromDirectory(dir); err != nil {
- fmt.Printf("Warning: Failed to load themes from %s: %v\n", dir, err)
- }
- }
-
- return nil
-}
-
-func loadThemesFromDirectory(dir string) error {
- if _, err := os.Stat(dir); os.IsNotExist(err) {
- return nil // Directory doesn't exist, which is fine
- }
-
- entries, err := os.ReadDir(dir)
- if err != nil {
- return fmt.Errorf("failed to read directory: %w", err)
- }
-
- for _, entry := range entries {
- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
- continue
- }
-
- themeName := strings.TrimSuffix(entry.Name(), ".json")
- filePath := filepath.Join(dir, entry.Name())
-
- data, err := os.ReadFile(filePath)
- if err != nil {
- fmt.Printf("Warning: Failed to read theme file %s: %v\n", filePath, err)
- continue
- }
-
- theme, err := parseJSONTheme(themeName, data)
- if err != nil {
- fmt.Printf("Warning: Failed to parse theme %s: %v\n", filePath, err)
- continue
- }
-
- RegisterTheme(themeName, theme)
- }
-
- return nil
-}
-
-func parseJSONTheme(name string, data []byte) (Theme, error) {
- var jsonTheme JSONTheme
- if err := json.Unmarshal(data, &jsonTheme); err != nil {
- return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
- }
- theme := &LoadedTheme{
- name: name,
- }
- colorMap := make(map[string]*colorRef)
- for key, value := range jsonTheme.Defs {
- colorMap[key] = &colorRef{value: value, resolved: false}
- }
- for key, value := range jsonTheme.Theme {
- colorMap[key] = &colorRef{value: value, resolved: false}
- }
- resolver := &colorResolver{
- colors: colorMap,
- visited: make(map[string]bool),
- }
- for key, value := range jsonTheme.Theme {
- resolved, err := resolver.resolveColor(key, value)
- if err != nil {
- return nil, fmt.Errorf("failed to resolve color %s: %w", key, err)
- }
- adaptiveColor, err := parseResolvedColor(resolved)
- if err != nil {
- return nil, fmt.Errorf("failed to parse color %s: %w", key, err)
- }
- if err := setThemeColor(theme, key, adaptiveColor); err != nil {
- return nil, fmt.Errorf("failed to set color %s: %w", key, err)
- }
- }
-
- return theme, nil
-}
-
-type colorResolver struct {
- colors map[string]*colorRef
- visited map[string]bool
-}
-
-func (r *colorResolver) resolveColor(key string, value any) (any, error) {
- if r.visited[key] {
- return nil, fmt.Errorf("circular reference detected for color %s", key)
- }
- r.visited[key] = true
- defer func() { r.visited[key] = false }()
-
- switch v := value.(type) {
- case string:
- if strings.HasPrefix(v, "#") || v == "none" {
- return v, nil
- }
- return r.resolveReference(v)
- case float64:
- return v, nil
- case map[string]any:
- resolved := make(map[string]any)
-
- if dark, ok := v["dark"]; ok {
- resolvedDark, err := r.resolveColorValue(dark)
- if err != nil {
- return nil, fmt.Errorf("failed to resolve dark variant: %w", err)
- }
- resolved["dark"] = resolvedDark
- }
-
- if light, ok := v["light"]; ok {
- resolvedLight, err := r.resolveColorValue(light)
- if err != nil {
- return nil, fmt.Errorf("failed to resolve light variant: %w", err)
- }
- resolved["light"] = resolvedLight
- }
-
- return resolved, nil
- default:
- return nil, fmt.Errorf("invalid color value type: %T", value)
- }
-}
-
-func (r *colorResolver) resolveColorValue(value any) (any, error) {
- switch v := value.(type) {
- case string:
- if strings.HasPrefix(v, "#") || v == "none" {
- return v, nil
- }
- return r.resolveReference(v)
- case float64:
- return v, nil
- default:
- return nil, fmt.Errorf("invalid color value type: %T", value)
- }
-}
-
-func (r *colorResolver) resolveReference(ref string) (any, error) {
- colorRef, exists := r.colors[ref]
- if !exists {
- return nil, fmt.Errorf("color reference '%s' not found", ref)
- }
-
- if colorRef.resolved {
- return colorRef.value, nil
- }
-
- resolved, err := r.resolveColor(ref, colorRef.value)
- if err != nil {
- return nil, err
- }
-
- colorRef.value = resolved
- colorRef.resolved = true
-
- return resolved, nil
-}
-
-func parseResolvedColor(value any) (compat.AdaptiveColor, error) {
- switch v := value.(type) {
- case string:
- if v == "none" {
- return compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }, nil
- }
- return compat.AdaptiveColor{
- Dark: lipgloss.Color(v),
- Light: lipgloss.Color(v),
- }, nil
- case float64:
- colorStr := fmt.Sprintf("%d", int(v))
- return compat.AdaptiveColor{
- Dark: lipgloss.Color(colorStr),
- Light: lipgloss.Color(colorStr),
- }, nil
- case map[string]any:
- dark, darkOk := v["dark"]
- light, lightOk := v["light"]
-
- if !darkOk || !lightOk {
- return compat.AdaptiveColor{}, fmt.Errorf("color object must have both 'dark' and 'light' keys")
- }
- darkColor, err := parseColorValue(dark)
- if err != nil {
- return compat.AdaptiveColor{}, fmt.Errorf("failed to parse dark color: %w", err)
- }
- lightColor, err := parseColorValue(light)
- if err != nil {
- return compat.AdaptiveColor{}, fmt.Errorf("failed to parse light color: %w", err)
- }
- return compat.AdaptiveColor{
- Dark: darkColor,
- Light: lightColor,
- }, nil
- default:
- return compat.AdaptiveColor{}, fmt.Errorf("invalid resolved color type: %T", value)
- }
-}
-
-func parseColorValue(value any) (color.Color, error) {
- switch v := value.(type) {
- case string:
- if v == "none" {
- return lipgloss.NoColor{}, nil
- }
- return lipgloss.Color(v), nil
- case float64:
- return lipgloss.Color(fmt.Sprintf("%d", int(v))), nil
- default:
- return nil, fmt.Errorf("invalid color value type: %T", value)
- }
-}
-
-func setThemeColor(theme *LoadedTheme, key string, color compat.AdaptiveColor) error {
- switch key {
- case "primary":
- theme.PrimaryColor = color
- case "secondary":
- theme.SecondaryColor = color
- case "accent":
- theme.AccentColor = color
- case "error":
- theme.ErrorColor = color
- case "warning":
- theme.WarningColor = color
- case "success":
- theme.SuccessColor = color
- case "info":
- theme.InfoColor = color
- case "text":
- theme.TextColor = color
- case "textMuted":
- theme.TextMutedColor = color
- case "background":
- theme.BackgroundColor = color
- case "backgroundPanel":
- theme.BackgroundPanelColor = color
- case "backgroundElement":
- theme.BackgroundElementColor = color
- case "border":
- theme.BorderColor = color
- case "borderActive":
- theme.BorderActiveColor = color
- case "borderSubtle":
- theme.BorderSubtleColor = color
- case "diffAdded":
- theme.DiffAddedColor = color
- case "diffRemoved":
- theme.DiffRemovedColor = color
- case "diffContext":
- theme.DiffContextColor = color
- case "diffHunkHeader":
- theme.DiffHunkHeaderColor = color
- case "diffHighlightAdded":
- theme.DiffHighlightAddedColor = color
- case "diffHighlightRemoved":
- theme.DiffHighlightRemovedColor = color
- case "diffAddedBg":
- theme.DiffAddedBgColor = color
- case "diffRemovedBg":
- theme.DiffRemovedBgColor = color
- case "diffContextBg":
- theme.DiffContextBgColor = color
- case "diffLineNumber":
- theme.DiffLineNumberColor = color
- case "diffAddedLineNumberBg":
- theme.DiffAddedLineNumberBgColor = color
- case "diffRemovedLineNumberBg":
- theme.DiffRemovedLineNumberBgColor = color
- case "markdownText":
- theme.MarkdownTextColor = color
- case "markdownHeading":
- theme.MarkdownHeadingColor = color
- case "markdownLink":
- theme.MarkdownLinkColor = color
- case "markdownLinkText":
- theme.MarkdownLinkTextColor = color
- case "markdownCode":
- theme.MarkdownCodeColor = color
- case "markdownBlockQuote":
- theme.MarkdownBlockQuoteColor = color
- case "markdownEmph":
- theme.MarkdownEmphColor = color
- case "markdownStrong":
- theme.MarkdownStrongColor = color
- case "markdownHorizontalRule":
- theme.MarkdownHorizontalRuleColor = color
- case "markdownListItem":
- theme.MarkdownListItemColor = color
- case "markdownListEnumeration":
- theme.MarkdownListEnumerationColor = color
- case "markdownImage":
- theme.MarkdownImageColor = color
- case "markdownImageText":
- theme.MarkdownImageTextColor = color
- case "markdownCodeBlock":
- theme.MarkdownCodeBlockColor = color
- case "syntaxComment":
- theme.SyntaxCommentColor = color
- case "syntaxKeyword":
- theme.SyntaxKeywordColor = color
- case "syntaxFunction":
- theme.SyntaxFunctionColor = color
- case "syntaxVariable":
- theme.SyntaxVariableColor = color
- case "syntaxString":
- theme.SyntaxStringColor = color
- case "syntaxNumber":
- theme.SyntaxNumberColor = color
- case "syntaxType":
- theme.SyntaxTypeColor = color
- case "syntaxOperator":
- theme.SyntaxOperatorColor = color
- case "syntaxPunctuation":
- theme.SyntaxPunctuationColor = color
- default:
- // Ignore unknown keys for forward compatibility
- return nil
- }
- return nil
-}
diff --git a/packages/tui/internal/theme/loader_test.go b/packages/tui/internal/theme/loader_test.go
deleted file mode 100644
index 37546789b..000000000
--- a/packages/tui/internal/theme/loader_test.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package theme
-
-import (
- "os"
- "path/filepath"
- "slices"
- "testing"
-)
-
-func TestLoadThemesFromJSON(t *testing.T) {
- // Test loading themes
- err := LoadThemesFromJSON()
- if err != nil {
- t.Fatalf("Failed to load themes: %v", err)
- }
-
- // Check that themes were loaded
- themes := AvailableThemes()
- if len(themes) == 0 {
- t.Fatal("No themes were loaded")
- }
-
- // Check for expected themes
- expectedThemes := []string{"tokyonight", "opencode", "everforest", "ayu"}
- for _, expected := range expectedThemes {
- found := slices.Contains(themes, expected)
- if !found {
- t.Errorf("Expected theme %s not found", expected)
- }
- }
-
- // Test getting a specific theme
- tokyonight := GetTheme("tokyonight")
- if tokyonight == nil {
- t.Fatal("Failed to get tokyonight theme")
- }
-
- // Test theme colors
- primary := tokyonight.Primary()
- if primary.Dark == nil || primary.Light == nil {
- t.Error("Primary color not properly set")
- }
-}
-
-func TestColorReferenceResolution(t *testing.T) {
- // Load themes first
- err := LoadThemesFromJSON()
- if err != nil {
- t.Fatalf("Failed to load themes: %v", err)
- }
-
- // Test a theme that uses references (e.g., solarized uses color definitions)
- solarized := GetTheme("solarized")
- if solarized == nil {
- t.Fatal("Failed to get solarized theme")
- }
-
- // Check that color references were resolved
- primary := solarized.Primary()
- if primary.Dark == nil || primary.Light == nil {
- t.Error("Primary color reference not resolved")
- }
-
- // Check that all colors are properly resolved
- text := solarized.Text()
- if text.Dark == nil || text.Light == nil {
- t.Error("Text color reference not resolved")
- }
-}
-
-func TestLoadThemesFromDirectories(t *testing.T) {
- // Create temporary directories for testing
- tempDir := t.TempDir()
-
- userConfig := filepath.Join(tempDir, "config")
- projectRoot := filepath.Join(tempDir, "project")
- cwd := filepath.Join(tempDir, "cwd")
-
- // Create theme directories
- os.MkdirAll(filepath.Join(userConfig, "opencode", "themes"), 0755)
- os.MkdirAll(filepath.Join(projectRoot, ".opencode", "themes"), 0755)
- os.MkdirAll(filepath.Join(cwd, ".opencode", "themes"), 0755)
-
- // Create test themes with same name to test override behavior
- testTheme1 := `{
- "theme": {
- "primary": "#111111",
- "secondary": "#222222",
- "accent": "#333333",
- "text": "#ffffff",
- "textMuted": "#cccccc",
- "background": "#000000"
- }
- }`
-
- testTheme2 := `{
- "theme": {
- "primary": "#444444",
- "secondary": "#555555",
- "accent": "#666666",
- "text": "#ffffff",
- "textMuted": "#cccccc",
- "background": "#000000"
- }
- }`
-
- testTheme3 := `{
- "theme": {
- "primary": "#777777",
- "secondary": "#888888",
- "accent": "#999999",
- "text": "#ffffff",
- "textMuted": "#cccccc",
- "background": "#000000"
- }
- }`
-
- // Write themes to different directories
- os.WriteFile(filepath.Join(userConfig, "opencode", "themes", "override-test.json"), []byte(testTheme1), 0644)
- os.WriteFile(filepath.Join(projectRoot, ".opencode", "themes", "override-test.json"), []byte(testTheme2), 0644)
- os.WriteFile(filepath.Join(cwd, ".opencode", "themes", "override-test.json"), []byte(testTheme3), 0644)
-
- // Load themes
- err := LoadThemesFromDirectories(userConfig, projectRoot, cwd)
- if err != nil {
- t.Fatalf("Failed to load themes from directories: %v", err)
- }
-
- // Check that the theme from CWD (highest priority) won
- overrideTheme := GetTheme("override-test")
- if overrideTheme == nil {
- t.Fatal("Failed to get override-test theme")
- }
-
- // The primary color should be from testTheme3 (#777777)
- primary := overrideTheme.Primary()
- // We can't directly check the color value, but we can verify it was loaded
- if primary.Dark == nil || primary.Light == nil {
- t.Error("Override theme not properly loaded")
- }
-}
diff --git a/packages/tui/internal/theme/manager.go b/packages/tui/internal/theme/manager.go
deleted file mode 100644
index 420b96dea..000000000
--- a/packages/tui/internal/theme/manager.go
+++ /dev/null
@@ -1,229 +0,0 @@
-package theme
-
-import (
- "fmt"
- "image/color"
- "slices"
- "strconv"
- "strings"
- "sync"
-
- "github.com/alecthomas/chroma/v2/styles"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/charmbracelet/x/ansi"
-)
-
-// Manager handles theme registration, selection, and retrieval.
-// It maintains a registry of available themes and tracks the currently active theme.
-type Manager struct {
- themes map[string]Theme
- currentName string
- currentUsesAnsiCache bool // Cache whether current theme uses ANSI colors
- mu sync.RWMutex
-}
-
-// Global instance of the theme manager
-var globalManager = &Manager{
- themes: make(map[string]Theme),
- currentName: "",
-}
-
-// RegisterTheme adds a new theme to the registry.
-// If this is the first theme registered, it becomes the default.
-func RegisterTheme(name string, theme Theme) {
- globalManager.mu.Lock()
- defer globalManager.mu.Unlock()
-
- globalManager.themes[name] = theme
-
- // If this is the first theme, make it the default
- if globalManager.currentName == "" {
- globalManager.currentName = name
- globalManager.currentUsesAnsiCache = themeUsesAnsiColors(theme)
- }
-}
-
-// SetTheme changes the active theme to the one with the specified name.
-// Returns an error if the theme doesn't exist.
-func SetTheme(name string) error {
- globalManager.mu.Lock()
- defer globalManager.mu.Unlock()
- delete(styles.Registry, "charm")
-
- theme, exists := globalManager.themes[name]
- if !exists {
- return fmt.Errorf("theme '%s' not found", name)
- }
-
- globalManager.currentName = name
- globalManager.currentUsesAnsiCache = themeUsesAnsiColors(theme)
-
- return nil
-}
-
-// CurrentTheme returns the currently active theme.
-// If no theme is set, it returns nil.
-func CurrentTheme() Theme {
- globalManager.mu.RLock()
- defer globalManager.mu.RUnlock()
-
- if globalManager.currentName == "" {
- return nil
- }
-
- return globalManager.themes[globalManager.currentName]
-}
-
-// CurrentThemeName returns the name of the currently active theme.
-func CurrentThemeName() string {
- globalManager.mu.RLock()
- defer globalManager.mu.RUnlock()
-
- return globalManager.currentName
-}
-
-// AvailableThemes returns a list of all registered theme names.
-func AvailableThemes() []string {
- globalManager.mu.RLock()
- defer globalManager.mu.RUnlock()
-
- names := make([]string, 0, len(globalManager.themes))
- for name := range globalManager.themes {
- names = append(names, name)
- }
- slices.SortFunc(names, func(a, b string) int {
- if a == "opencode" {
- return -1
- } else if b == "opencode" {
- return 1
- }
- if a == "system" {
- return -1
- } else if b == "system" {
- return 1
- }
- return strings.Compare(a, b)
- })
- return names
-}
-
-// GetTheme returns a specific theme by name.
-// Returns nil if the theme doesn't exist.
-func GetTheme(name string) Theme {
- globalManager.mu.RLock()
- defer globalManager.mu.RUnlock()
-
- return globalManager.themes[name]
-}
-
-// UpdateSystemTheme updates the system theme with terminal background info
-func UpdateSystemTheme(terminalBg color.Color, isDark bool) {
- globalManager.mu.Lock()
- defer globalManager.mu.Unlock()
-
- dynamicTheme := NewSystemTheme(terminalBg, isDark)
- globalManager.themes["system"] = dynamicTheme
- if globalManager.currentName == "system" {
- globalManager.currentUsesAnsiCache = themeUsesAnsiColors(dynamicTheme)
- }
-}
-
-// CurrentThemeUsesAnsiColors returns true if the current theme uses ANSI 0-16 colors
-func CurrentThemeUsesAnsiColors() bool {
- // globalManager.mu.RLock()
- // defer globalManager.mu.RUnlock()
-
- return globalManager.currentUsesAnsiCache
-}
-
-// isAnsiColor checks if a color represents an ANSI 0-16 color
-func isAnsiColor(c color.Color) bool {
- if _, ok := c.(lipgloss.NoColor); ok {
- return false
- }
- if _, ok := c.(ansi.BasicColor); ok {
- return true
- }
-
- // For other color types, check if they represent ANSI colors
- // by examining their string representation
- if stringer, ok := c.(fmt.Stringer); ok {
- str := stringer.String()
- // Check if it's a numeric ANSI color (0-15)
- if num, err := strconv.Atoi(str); err == nil && num >= 0 && num <= 15 {
- return true
- }
- }
-
- return false
-}
-
-// adaptiveColorUsesAnsi checks if an AdaptiveColor uses ANSI colors
-func adaptiveColorUsesAnsi(ac compat.AdaptiveColor) bool {
- if isAnsiColor(ac.Dark) {
- return true
- }
- if isAnsiColor(ac.Light) {
- return true
- }
- return false
-}
-
-// themeUsesAnsiColors checks if a theme uses any ANSI 0-16 colors
-func themeUsesAnsiColors(theme Theme) bool {
- if theme == nil {
- return false
- }
-
- return adaptiveColorUsesAnsi(theme.Primary()) ||
- adaptiveColorUsesAnsi(theme.Secondary()) ||
- adaptiveColorUsesAnsi(theme.Accent()) ||
- adaptiveColorUsesAnsi(theme.Error()) ||
- adaptiveColorUsesAnsi(theme.Warning()) ||
- adaptiveColorUsesAnsi(theme.Success()) ||
- adaptiveColorUsesAnsi(theme.Info()) ||
- adaptiveColorUsesAnsi(theme.Text()) ||
- adaptiveColorUsesAnsi(theme.TextMuted()) ||
- adaptiveColorUsesAnsi(theme.Background()) ||
- adaptiveColorUsesAnsi(theme.BackgroundPanel()) ||
- adaptiveColorUsesAnsi(theme.BackgroundElement()) ||
- adaptiveColorUsesAnsi(theme.Border()) ||
- adaptiveColorUsesAnsi(theme.BorderActive()) ||
- adaptiveColorUsesAnsi(theme.BorderSubtle()) ||
- adaptiveColorUsesAnsi(theme.DiffAdded()) ||
- adaptiveColorUsesAnsi(theme.DiffRemoved()) ||
- adaptiveColorUsesAnsi(theme.DiffContext()) ||
- adaptiveColorUsesAnsi(theme.DiffHunkHeader()) ||
- adaptiveColorUsesAnsi(theme.DiffHighlightAdded()) ||
- adaptiveColorUsesAnsi(theme.DiffHighlightRemoved()) ||
- adaptiveColorUsesAnsi(theme.DiffAddedBg()) ||
- adaptiveColorUsesAnsi(theme.DiffRemovedBg()) ||
- adaptiveColorUsesAnsi(theme.DiffContextBg()) ||
- adaptiveColorUsesAnsi(theme.DiffLineNumber()) ||
- adaptiveColorUsesAnsi(theme.DiffAddedLineNumberBg()) ||
- adaptiveColorUsesAnsi(theme.DiffRemovedLineNumberBg()) ||
- adaptiveColorUsesAnsi(theme.MarkdownText()) ||
- adaptiveColorUsesAnsi(theme.MarkdownHeading()) ||
- adaptiveColorUsesAnsi(theme.MarkdownLink()) ||
- adaptiveColorUsesAnsi(theme.MarkdownLinkText()) ||
- adaptiveColorUsesAnsi(theme.MarkdownCode()) ||
- adaptiveColorUsesAnsi(theme.MarkdownBlockQuote()) ||
- adaptiveColorUsesAnsi(theme.MarkdownEmph()) ||
- adaptiveColorUsesAnsi(theme.MarkdownStrong()) ||
- adaptiveColorUsesAnsi(theme.MarkdownHorizontalRule()) ||
- adaptiveColorUsesAnsi(theme.MarkdownListItem()) ||
- adaptiveColorUsesAnsi(theme.MarkdownListEnumeration()) ||
- adaptiveColorUsesAnsi(theme.MarkdownImage()) ||
- adaptiveColorUsesAnsi(theme.MarkdownImageText()) ||
- adaptiveColorUsesAnsi(theme.MarkdownCodeBlock()) ||
- adaptiveColorUsesAnsi(theme.SyntaxComment()) ||
- adaptiveColorUsesAnsi(theme.SyntaxKeyword()) ||
- adaptiveColorUsesAnsi(theme.SyntaxFunction()) ||
- adaptiveColorUsesAnsi(theme.SyntaxVariable()) ||
- adaptiveColorUsesAnsi(theme.SyntaxString()) ||
- adaptiveColorUsesAnsi(theme.SyntaxNumber()) ||
- adaptiveColorUsesAnsi(theme.SyntaxType()) ||
- adaptiveColorUsesAnsi(theme.SyntaxOperator()) ||
- adaptiveColorUsesAnsi(theme.SyntaxPunctuation())
-}
diff --git a/packages/tui/internal/theme/system.go b/packages/tui/internal/theme/system.go
deleted file mode 100644
index 8dd48cfec..000000000
--- a/packages/tui/internal/theme/system.go
+++ /dev/null
@@ -1,303 +0,0 @@
-package theme
-
-import (
- "fmt"
- "image/color"
- "math"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-// SystemTheme is a dynamic theme that derives its gray scale colors
-// from the terminal's background color at runtime
-type SystemTheme struct {
- BaseTheme
- terminalBg color.Color
- terminalBgIsDark bool
-}
-
-// NewSystemTheme creates a new instance of the dynamic system theme
-func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme {
- theme := &SystemTheme{
- terminalBg: terminalBg,
- terminalBgIsDark: isDark,
- }
- theme.initializeColors()
- return theme
-}
-
-func (t *SystemTheme) Name() string {
- return "system"
-}
-
-// initializeColors sets up all theme colors
-func (t *SystemTheme) initializeColors() {
- // Generate gray scale based on terminal background
- grays := t.generateGrayScale()
-
- // Set ANSI colors for primary colors
- t.PrimaryColor = compat.AdaptiveColor{
- Dark: lipgloss.Cyan,
- Light: lipgloss.Cyan,
- }
- t.SecondaryColor = compat.AdaptiveColor{
- Dark: lipgloss.Magenta,
- Light: lipgloss.Magenta,
- }
- t.AccentColor = compat.AdaptiveColor{
- Dark: lipgloss.Cyan,
- Light: lipgloss.Cyan,
- }
-
- // Status colors using ANSI
- t.ErrorColor = compat.AdaptiveColor{
- Dark: lipgloss.Red,
- Light: lipgloss.Red,
- }
- t.WarningColor = compat.AdaptiveColor{
- Dark: lipgloss.Yellow,
- Light: lipgloss.Yellow,
- }
- t.SuccessColor = compat.AdaptiveColor{
- Dark: lipgloss.Green,
- Light: lipgloss.Green,
- }
- t.InfoColor = compat.AdaptiveColor{
- Dark: lipgloss.Cyan,
- Light: lipgloss.Cyan,
- }
-
- // Text colors
- t.TextColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- // Derive muted text color from terminal foreground
- t.TextMutedColor = t.generateMutedTextColor()
-
- // Background colors
- t.BackgroundColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.BackgroundPanelColor = grays[2]
- t.BackgroundElementColor = grays[3]
-
- // Border colors
- t.BorderSubtleColor = grays[6]
- t.BorderColor = grays[7]
- t.BorderActiveColor = grays[8]
-
- // Diff colors using ANSI colors
- t.DiffAddedColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("2"), // green
- Light: lipgloss.Color("2"),
- }
- t.DiffRemovedColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("1"), // red
- Light: lipgloss.Color("1"),
- }
- t.DiffContextColor = grays[7] // Use gray for context
- t.DiffHunkHeaderColor = grays[7]
- t.DiffHighlightAddedColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("2"), // green
- Light: lipgloss.Color("2"),
- }
- t.DiffHighlightRemovedColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("1"), // red
- Light: lipgloss.Color("1"),
- }
- // Use subtle gray backgrounds for diff
- t.DiffAddedBgColor = grays[2]
- t.DiffRemovedBgColor = grays[2]
- t.DiffContextBgColor = grays[1]
- t.DiffLineNumberColor = grays[6]
- t.DiffAddedLineNumberBgColor = grays[3]
- t.DiffRemovedLineNumberBgColor = grays[3]
-
- // Markdown colors using ANSI
- t.MarkdownTextColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.MarkdownHeadingColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.MarkdownLinkColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("4"), // blue
- Light: lipgloss.Color("4"),
- }
- t.MarkdownLinkTextColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.MarkdownCodeColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("2"), // green
- Light: lipgloss.Color("2"),
- }
- t.MarkdownBlockQuoteColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("3"), // yellow
- Light: lipgloss.Color("3"),
- }
- t.MarkdownEmphColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("3"), // yellow
- Light: lipgloss.Color("3"),
- }
- t.MarkdownStrongColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.MarkdownHorizontalRuleColor = t.BorderColor
- t.MarkdownListItemColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("4"), // blue
- Light: lipgloss.Color("4"),
- }
- t.MarkdownListEnumerationColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.MarkdownImageColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("4"), // blue
- Light: lipgloss.Color("4"),
- }
- t.MarkdownImageTextColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.MarkdownCodeBlockColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
-
- // Syntax colors
- t.SyntaxCommentColor = t.TextMutedColor // Use same as muted text
- t.SyntaxKeywordColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("5"), // magenta
- Light: lipgloss.Color("5"),
- }
- t.SyntaxFunctionColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("4"), // blue
- Light: lipgloss.Color("4"),
- }
- t.SyntaxVariableColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
- t.SyntaxStringColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("2"), // green
- Light: lipgloss.Color("2"),
- }
- t.SyntaxNumberColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("3"), // yellow
- Light: lipgloss.Color("3"),
- }
- t.SyntaxTypeColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.SyntaxOperatorColor = compat.AdaptiveColor{
- Dark: lipgloss.Color("6"), // cyan
- Light: lipgloss.Color("6"),
- }
- t.SyntaxPunctuationColor = compat.AdaptiveColor{
- Dark: lipgloss.NoColor{},
- Light: lipgloss.NoColor{},
- }
-}
-
-// generateGrayScale creates a gray scale based on the terminal background
-func (t *SystemTheme) generateGrayScale() map[int]compat.AdaptiveColor {
- grays := make(map[int]compat.AdaptiveColor)
-
- r, g, b, _ := t.terminalBg.RGBA()
- bgR := float64(r >> 8)
- bgG := float64(g >> 8)
- bgB := float64(b >> 8)
-
- luminance := 0.299*bgR + 0.587*bgG + 0.114*bgB
-
- for i := 1; i <= 12; i++ {
- var stepColor string
- factor := float64(i) / 12.0
-
- if t.terminalBgIsDark {
- if luminance < 10 {
- grayValue := int(factor * 0.4 * 255)
- stepColor = fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
- } else {
- newLum := luminance + (255-luminance)*factor*0.4
-
- ratio := newLum / luminance
- newR := math.Min(bgR*ratio, 255)
- newG := math.Min(bgG*ratio, 255)
- newB := math.Min(bgB*ratio, 255)
-
- stepColor = fmt.Sprintf("#%02x%02x%02x", int(newR), int(newG), int(newB))
- }
- } else {
- if luminance > 245 {
- grayValue := int(255 - factor*0.4*255)
- stepColor = fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
- } else {
- newLum := luminance * (1 - factor*0.4)
-
- ratio := newLum / luminance
- newR := math.Max(bgR*ratio, 0)
- newG := math.Max(bgG*ratio, 0)
- newB := math.Max(bgB*ratio, 0)
-
- stepColor = fmt.Sprintf("#%02x%02x%02x", int(newR), int(newG), int(newB))
- }
- }
-
- grays[i] = compat.AdaptiveColor{
- Dark: lipgloss.Color(stepColor),
- Light: lipgloss.Color(stepColor),
- }
- }
-
- return grays
-}
-
-// generateMutedTextColor creates a muted gray color based on the terminal background
-func (t *SystemTheme) generateMutedTextColor() compat.AdaptiveColor {
- bgR, bgG, bgB, _ := t.terminalBg.RGBA()
-
- bgRf := float64(bgR >> 8)
- bgGf := float64(bgG >> 8)
- bgBf := float64(bgB >> 8)
-
- bgLum := 0.299*bgRf + 0.587*bgGf + 0.114*bgBf
-
- var grayValue int
- if t.terminalBgIsDark {
- if bgLum < 10 {
- // Very dark/black background
- // grays[3] would be around #2e (46), so we need much lighter
- grayValue = 180 // #b4b4b4
- } else {
- // Scale up for lighter dark backgrounds
- // Ensure we're always significantly brighter than BackgroundElement
- grayValue = min(int(160+(bgLum*0.3)), 200)
- }
- } else {
- if bgLum > 245 {
- // Very light/white background
- // grays[3] would be around #f5 (245), so we need much darker
- grayValue = 75 // #4b4b4b
- } else {
- // Scale down for darker light backgrounds
- // Ensure we're always significantly darker than BackgroundElement
- grayValue = max(int(100-((255-bgLum)*0.2)), 60)
- }
- }
-
- mutedColor := fmt.Sprintf("#%02x%02x%02x", grayValue, grayValue, grayValue)
-
- return compat.AdaptiveColor{
- Dark: lipgloss.Color(mutedColor),
- Light: lipgloss.Color(mutedColor),
- }
-}
diff --git a/packages/tui/internal/theme/theme.go b/packages/tui/internal/theme/theme.go
deleted file mode 100644
index d5d27a1e1..000000000
--- a/packages/tui/internal/theme/theme.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package theme
-
-import (
- "github.com/charmbracelet/lipgloss/v2/compat"
-)
-
-// Theme defines the interface for all UI themes in the application.
-// All colors must be defined as compat.AdaptiveColor to support
-// both light and dark terminal backgrounds.
-type Theme interface {
- Name() string
-
- // Background colors
- Background() compat.AdaptiveColor // Radix 1
- BackgroundPanel() compat.AdaptiveColor // Radix 2
- BackgroundElement() compat.AdaptiveColor // Radix 3
-
- // Border colors
- BorderSubtle() compat.AdaptiveColor // Radix 6
- Border() compat.AdaptiveColor // Radix 7
- BorderActive() compat.AdaptiveColor // Radix 8
-
- // Brand colors
- Primary() compat.AdaptiveColor // Radix 9
- Secondary() compat.AdaptiveColor
- Accent() compat.AdaptiveColor
-
- // Text colors
- TextMuted() compat.AdaptiveColor // Radix 11
- Text() compat.AdaptiveColor // Radix 12
-
- // Status colors
- Error() compat.AdaptiveColor
- Warning() compat.AdaptiveColor
- Success() compat.AdaptiveColor
- Info() compat.AdaptiveColor
-
- // Diff view colors
- DiffAdded() compat.AdaptiveColor
- DiffRemoved() compat.AdaptiveColor
- DiffContext() compat.AdaptiveColor
- DiffHunkHeader() compat.AdaptiveColor
- DiffHighlightAdded() compat.AdaptiveColor
- DiffHighlightRemoved() compat.AdaptiveColor
- DiffAddedBg() compat.AdaptiveColor
- DiffRemovedBg() compat.AdaptiveColor
- DiffContextBg() compat.AdaptiveColor
- DiffLineNumber() compat.AdaptiveColor
- DiffAddedLineNumberBg() compat.AdaptiveColor
- DiffRemovedLineNumberBg() compat.AdaptiveColor
-
- // Markdown colors
- MarkdownText() compat.AdaptiveColor
- MarkdownHeading() compat.AdaptiveColor
- MarkdownLink() compat.AdaptiveColor
- MarkdownLinkText() compat.AdaptiveColor
- MarkdownCode() compat.AdaptiveColor
- MarkdownBlockQuote() compat.AdaptiveColor
- MarkdownEmph() compat.AdaptiveColor
- MarkdownStrong() compat.AdaptiveColor
- MarkdownHorizontalRule() compat.AdaptiveColor
- MarkdownListItem() compat.AdaptiveColor
- MarkdownListEnumeration() compat.AdaptiveColor
- MarkdownImage() compat.AdaptiveColor
- MarkdownImageText() compat.AdaptiveColor
- MarkdownCodeBlock() compat.AdaptiveColor
-
- // Syntax highlighting colors
- SyntaxComment() compat.AdaptiveColor
- SyntaxKeyword() compat.AdaptiveColor
- SyntaxFunction() compat.AdaptiveColor
- SyntaxVariable() compat.AdaptiveColor
- SyntaxString() compat.AdaptiveColor
- SyntaxNumber() compat.AdaptiveColor
- SyntaxType() compat.AdaptiveColor
- SyntaxOperator() compat.AdaptiveColor
- SyntaxPunctuation() compat.AdaptiveColor
-}
-
-// BaseTheme provides a default implementation of the Theme interface
-// that can be embedded in concrete theme implementations.
-type BaseTheme struct {
- // Background colors
- BackgroundColor compat.AdaptiveColor
- BackgroundPanelColor compat.AdaptiveColor
- BackgroundElementColor compat.AdaptiveColor
-
- // Border colors
- BorderSubtleColor compat.AdaptiveColor
- BorderColor compat.AdaptiveColor
- BorderActiveColor compat.AdaptiveColor
-
- // Brand colors
- PrimaryColor compat.AdaptiveColor
- SecondaryColor compat.AdaptiveColor
- AccentColor compat.AdaptiveColor
-
- // Text colors
- TextMutedColor compat.AdaptiveColor
- TextColor compat.AdaptiveColor
-
- // Status colors
- ErrorColor compat.AdaptiveColor
- WarningColor compat.AdaptiveColor
- SuccessColor compat.AdaptiveColor
- InfoColor compat.AdaptiveColor
-
- // Diff view colors
- DiffAddedColor compat.AdaptiveColor
- DiffRemovedColor compat.AdaptiveColor
- DiffContextColor compat.AdaptiveColor
- DiffHunkHeaderColor compat.AdaptiveColor
- DiffHighlightAddedColor compat.AdaptiveColor
- DiffHighlightRemovedColor compat.AdaptiveColor
- DiffAddedBgColor compat.AdaptiveColor
- DiffRemovedBgColor compat.AdaptiveColor
- DiffContextBgColor compat.AdaptiveColor
- DiffLineNumberColor compat.AdaptiveColor
- DiffAddedLineNumberBgColor compat.AdaptiveColor
- DiffRemovedLineNumberBgColor compat.AdaptiveColor
-
- // Markdown colors
- MarkdownTextColor compat.AdaptiveColor
- MarkdownHeadingColor compat.AdaptiveColor
- MarkdownLinkColor compat.AdaptiveColor
- MarkdownLinkTextColor compat.AdaptiveColor
- MarkdownCodeColor compat.AdaptiveColor
- MarkdownBlockQuoteColor compat.AdaptiveColor
- MarkdownEmphColor compat.AdaptiveColor
- MarkdownStrongColor compat.AdaptiveColor
- MarkdownHorizontalRuleColor compat.AdaptiveColor
- MarkdownListItemColor compat.AdaptiveColor
- MarkdownListEnumerationColor compat.AdaptiveColor
- MarkdownImageColor compat.AdaptiveColor
- MarkdownImageTextColor compat.AdaptiveColor
- MarkdownCodeBlockColor compat.AdaptiveColor
-
- // Syntax highlighting colors
- SyntaxCommentColor compat.AdaptiveColor
- SyntaxKeywordColor compat.AdaptiveColor
- SyntaxFunctionColor compat.AdaptiveColor
- SyntaxVariableColor compat.AdaptiveColor
- SyntaxStringColor compat.AdaptiveColor
- SyntaxNumberColor compat.AdaptiveColor
- SyntaxTypeColor compat.AdaptiveColor
- SyntaxOperatorColor compat.AdaptiveColor
- SyntaxPunctuationColor compat.AdaptiveColor
-}
-
-// Implement the Theme interface for BaseTheme
-func (t *BaseTheme) Primary() compat.AdaptiveColor { return t.PrimaryColor }
-func (t *BaseTheme) Secondary() compat.AdaptiveColor { return t.SecondaryColor }
-func (t *BaseTheme) Accent() compat.AdaptiveColor { return t.AccentColor }
-
-func (t *BaseTheme) Error() compat.AdaptiveColor { return t.ErrorColor }
-func (t *BaseTheme) Warning() compat.AdaptiveColor { return t.WarningColor }
-func (t *BaseTheme) Success() compat.AdaptiveColor { return t.SuccessColor }
-func (t *BaseTheme) Info() compat.AdaptiveColor { return t.InfoColor }
-
-func (t *BaseTheme) Text() compat.AdaptiveColor { return t.TextColor }
-func (t *BaseTheme) TextMuted() compat.AdaptiveColor { return t.TextMutedColor }
-
-func (t *BaseTheme) Background() compat.AdaptiveColor { return t.BackgroundColor }
-func (t *BaseTheme) BackgroundPanel() compat.AdaptiveColor { return t.BackgroundPanelColor }
-func (t *BaseTheme) BackgroundElement() compat.AdaptiveColor { return t.BackgroundElementColor }
-
-func (t *BaseTheme) Border() compat.AdaptiveColor { return t.BorderColor }
-func (t *BaseTheme) BorderActive() compat.AdaptiveColor { return t.BorderActiveColor }
-func (t *BaseTheme) BorderSubtle() compat.AdaptiveColor { return t.BorderSubtleColor }
-
-func (t *BaseTheme) DiffAdded() compat.AdaptiveColor { return t.DiffAddedColor }
-func (t *BaseTheme) DiffRemoved() compat.AdaptiveColor { return t.DiffRemovedColor }
-func (t *BaseTheme) DiffContext() compat.AdaptiveColor { return t.DiffContextColor }
-func (t *BaseTheme) DiffHunkHeader() compat.AdaptiveColor { return t.DiffHunkHeaderColor }
-func (t *BaseTheme) DiffHighlightAdded() compat.AdaptiveColor { return t.DiffHighlightAddedColor }
-func (t *BaseTheme) DiffHighlightRemoved() compat.AdaptiveColor { return t.DiffHighlightRemovedColor }
-func (t *BaseTheme) DiffAddedBg() compat.AdaptiveColor { return t.DiffAddedBgColor }
-func (t *BaseTheme) DiffRemovedBg() compat.AdaptiveColor { return t.DiffRemovedBgColor }
-func (t *BaseTheme) DiffContextBg() compat.AdaptiveColor { return t.DiffContextBgColor }
-func (t *BaseTheme) DiffLineNumber() compat.AdaptiveColor { return t.DiffLineNumberColor }
-func (t *BaseTheme) DiffAddedLineNumberBg() compat.AdaptiveColor {
- return t.DiffAddedLineNumberBgColor
-}
-func (t *BaseTheme) DiffRemovedLineNumberBg() compat.AdaptiveColor {
- return t.DiffRemovedLineNumberBgColor
-}
-
-func (t *BaseTheme) MarkdownText() compat.AdaptiveColor { return t.MarkdownTextColor }
-func (t *BaseTheme) MarkdownHeading() compat.AdaptiveColor { return t.MarkdownHeadingColor }
-func (t *BaseTheme) MarkdownLink() compat.AdaptiveColor { return t.MarkdownLinkColor }
-func (t *BaseTheme) MarkdownLinkText() compat.AdaptiveColor { return t.MarkdownLinkTextColor }
-func (t *BaseTheme) MarkdownCode() compat.AdaptiveColor { return t.MarkdownCodeColor }
-func (t *BaseTheme) MarkdownBlockQuote() compat.AdaptiveColor { return t.MarkdownBlockQuoteColor }
-func (t *BaseTheme) MarkdownEmph() compat.AdaptiveColor { return t.MarkdownEmphColor }
-func (t *BaseTheme) MarkdownStrong() compat.AdaptiveColor { return t.MarkdownStrongColor }
-func (t *BaseTheme) MarkdownHorizontalRule() compat.AdaptiveColor {
- return t.MarkdownHorizontalRuleColor
-}
-func (t *BaseTheme) MarkdownListItem() compat.AdaptiveColor { return t.MarkdownListItemColor }
-func (t *BaseTheme) MarkdownListEnumeration() compat.AdaptiveColor {
- return t.MarkdownListEnumerationColor
-}
-func (t *BaseTheme) MarkdownImage() compat.AdaptiveColor { return t.MarkdownImageColor }
-func (t *BaseTheme) MarkdownImageText() compat.AdaptiveColor { return t.MarkdownImageTextColor }
-func (t *BaseTheme) MarkdownCodeBlock() compat.AdaptiveColor { return t.MarkdownCodeBlockColor }
-
-func (t *BaseTheme) SyntaxComment() compat.AdaptiveColor { return t.SyntaxCommentColor }
-func (t *BaseTheme) SyntaxKeyword() compat.AdaptiveColor { return t.SyntaxKeywordColor }
-func (t *BaseTheme) SyntaxFunction() compat.AdaptiveColor { return t.SyntaxFunctionColor }
-func (t *BaseTheme) SyntaxVariable() compat.AdaptiveColor { return t.SyntaxVariableColor }
-func (t *BaseTheme) SyntaxString() compat.AdaptiveColor { return t.SyntaxStringColor }
-func (t *BaseTheme) SyntaxNumber() compat.AdaptiveColor { return t.SyntaxNumberColor }
-func (t *BaseTheme) SyntaxType() compat.AdaptiveColor { return t.SyntaxTypeColor }
-func (t *BaseTheme) SyntaxOperator() compat.AdaptiveColor { return t.SyntaxOperatorColor }
-func (t *BaseTheme) SyntaxPunctuation() compat.AdaptiveColor { return t.SyntaxPunctuationColor }
diff --git a/packages/tui/internal/theme/themes/mellow.json b/packages/tui/internal/theme/themes/mellow.json
deleted file mode 100644
index f2a00a47f..000000000
--- a/packages/tui/internal/theme/themes/mellow.json
+++ /dev/null
@@ -1,87 +0,0 @@
-{
- "$schema": "https://opencode.ai/theme.json",
- "defs": {
- "dark_bg": "#161617",
- "dark_fg": "#c9c7cd",
- "dark_bg_dark": "#131314",
-
- "dark_black": "#27272a",
- "dark_red": "#f5a191",
- "dark_green": "#90b99f",
- "dark_yellow": "#e6b99d",
- "dark_blue": "#aca1cf",
- "dark_magenta": "#e29eca",
- "dark_cyan": "#ea83a5",
- "dark_white": "#c1c0d4",
-
- "dark_bright_black": "#353539",
- "dark_bright_red": "#ffae9f",
- "dark_bright_green": "#9dc6ac",
- "dark_bright_yellow": "#f0c5a9",
- "dark_bright_blue": "#b9aeda",
- "dark_bright_magenta": "#ecaad6",
- "dark_bright_cyan": "#f591b2",
- "dark_bright_white": "#cac9dd",
-
- "dark_gray00": "#18181a",
- "dark_gray01": "#1b1b1d",
- "dark_gray02": "#2a2a2d",
- "dark_gray03": "#3e3e43",
- "dark_gray04": "#57575f",
- "dark_gray05": "#757581",
- "dark_gray06": "#9998a8",
- "dark_gray07": "#c1c0d4"
- },
- "theme": {
- "primary": "dark_cyan",
- "secondary": "dark_cyan",
- "accent": "dark_blue",
- "error": "dark_cyan",
- "warning": "dark_yellow",
- "success": "dark_green",
- "info": "dark_blue",
- "text": "dark_fg",
- "textMuted": "dark_white",
- "background": "dark_bg",
- "backgroundPanel": "dark_gray01",
- "backgroundElement": "dark_gray02",
- "border": "dark_gray02",
- "borderActive": "dark_gray01",
- "borderSubtle": "dark_gray00",
- "diffAdded": "dark_black",
- "diffRemoved": "dark_black",
- "diffContext": "dark_fg",
- "diffHunkHeader": "dark_magenta",
- "diffHighlightAdded": "dark_bright_green",
- "diffHighlightRemoved": "dark_bright_red",
- "diffAddedBg": "dark_green",
- "diffRemovedBg": "dark_red",
- "diffContextBg": "dark_gray00",
- "diffLineNumber": "diffContextBg",
- "diffAddedLineNumberBg": "dark_green",
- "diffRemovedLineNumberBg": "dark_red",
- "markdownText": "dark_fg",
- "markdownHeading": "dark_gray06",
- "markdownLink": "dark_blue",
- "markdownLinkText": "dark_cyan",
- "markdownCode": "dark_bright_green",
- "markdownBlockQuote": "dark_gray00",
- "markdownEmph": "dark_bright_yellow",
- "markdownStrong": "dark_bright_red",
- "markdownHorizontalRule": "markdownText",
- "markdownListItem": "dark_blue",
- "markdownListEnumeration": "dark_bright_blue",
- "markdownImage": "markdownLink",
- "markdownImageText": "markdownLinkText",
- "markdownCodeBlock": "dark_fg",
- "syntaxComment": "dark_gray05",
- "syntaxKeyword": "dark_blue",
- "syntaxFunction": "dark_gray07",
- "syntaxVariable": "dark_fg",
- "syntaxString": "dark_green",
- "syntaxNumber": "dark_magenta",
- "syntaxType": "dark_magenta",
- "syntaxOperator": "dark_yellow",
- "syntaxPunctuation": "dark_gray06"
- }
-}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
deleted file mode 100644
index 3a0bc3730..000000000
--- a/packages/tui/internal/tui/tui.go
+++ /dev/null
@@ -1,1636 +0,0 @@
-package tui
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "slices"
- "strings"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
-
- "github.com/sst/opencode-sdk-go"
- "github.com/sst/opencode/internal/api"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/commands"
- "github.com/sst/opencode/internal/completions"
- "github.com/sst/opencode/internal/components/chat"
- cmdcomp "github.com/sst/opencode/internal/components/commands"
- "github.com/sst/opencode/internal/components/dialog"
- "github.com/sst/opencode/internal/components/modal"
- "github.com/sst/opencode/internal/components/status"
- "github.com/sst/opencode/internal/components/toast"
- "github.com/sst/opencode/internal/layout"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/util"
-)
-
-// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
-type InterruptDebounceTimeoutMsg struct{}
-
-// ExitDebounceTimeoutMsg is sent when the exit key debounce timeout expires
-type ExitDebounceTimeoutMsg struct{}
-
-// InterruptKeyState tracks the state of interrupt key presses for debouncing
-type InterruptKeyState int
-
-// ExitKeyState tracks the state of exit key presses for debouncing
-type ExitKeyState int
-
-const (
- InterruptKeyIdle InterruptKeyState = iota
- InterruptKeyFirstPress
-)
-
-const (
- ExitKeyIdle ExitKeyState = iota
- ExitKeyFirstPress
-)
-
-const interruptDebounceTimeout = 1 * time.Second
-const exitDebounceTimeout = 1 * time.Second
-
-type Model struct {
- tea.Model
- tea.CursorModel
- width, height int
- app *app.App
- modal layout.Modal
- status status.StatusComponent
- editor chat.EditorComponent
- messages chat.MessagesComponent
- completions dialog.CompletionDialog
- commandProvider completions.CompletionProvider
- fileProvider completions.CompletionProvider
- symbolsProvider completions.CompletionProvider
- agentsProvider completions.CompletionProvider
- showCompletionDialog bool
- leaderBinding *key.Binding
- toastManager *toast.ToastManager
- interruptKeyState InterruptKeyState
- exitKeyState ExitKeyState
- messagesRight bool
-}
-
-func (a Model) Init() tea.Cmd {
- var cmds []tea.Cmd
- // https://github.com/charmbracelet/bubbletea/issues/1440
- // https://github.com/sst/opencode/issues/127
- if !util.IsWsl() {
- cmds = append(cmds, tea.RequestBackgroundColor)
- }
- cmds = append(cmds, a.app.InitializeProvider())
- cmds = append(cmds, a.editor.Init())
- cmds = append(cmds, a.messages.Init())
- cmds = append(cmds, a.status.Init())
- cmds = append(cmds, a.completions.Init())
- cmds = append(cmds, a.toastManager.Init())
-
- return tea.Batch(cmds...)
-}
-
-func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- keyString := msg.String()
-
- if a.app.CurrentPermission.ID != "" {
- if keyString == "enter" || keyString == "esc" || keyString == "a" {
- sessionID := a.app.CurrentPermission.SessionID
- permissionID := a.app.CurrentPermission.ID
- a.editor.Focus()
- a.app.Permissions = a.app.Permissions[1:]
- if len(a.app.Permissions) > 0 {
- a.app.CurrentPermission = a.app.Permissions[0]
- } else {
- a.app.CurrentPermission = opencode.Permission{}
- }
- response := opencode.SessionPermissionRespondParamsResponseOnce
- switch keyString {
- case "enter":
- response = opencode.SessionPermissionRespondParamsResponseOnce
- case "a":
- response = opencode.SessionPermissionRespondParamsResponseAlways
- case "esc":
- response = opencode.SessionPermissionRespondParamsResponseReject
- }
-
- return a, func() tea.Msg {
- resp, err := a.app.Client.Session.Permissions.Respond(
- context.Background(),
- sessionID,
- permissionID,
- opencode.SessionPermissionRespondParams{Response: opencode.F(response)},
- )
- if err != nil {
- slog.Error("Failed to respond to permission request", "error", err)
- return toast.NewErrorToast("Failed to respond to permission request")()
- }
- slog.Debug("Responded to permission request", "response", resp)
- return nil
- }
- }
- }
-
- if a.app.IsBashMode {
- if keyString == "backspace" && a.editor.Length() == 0 {
- a.app.IsBashMode = false
- return a, nil
- }
-
- if keyString == "enter" || keyString == "esc" || keyString == "ctrl+c" {
- a.app.IsBashMode = false
- if keyString == "enter" {
- updated, cmd := a.editor.SubmitBash()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- }
- return a, tea.Batch(cmds...)
- }
- }
-
- // 1. Handle active modal
- if a.modal != nil {
- switch keyString {
- // Escape closes current modal, but give modal a chance to handle it first
- case "esc":
- // give the modal a chance to handle the esc
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- if cmd != nil {
- return a, cmd
- }
- cmd = a.modal.Close()
- a.modal = nil
- return a, cmd
- case "ctrl+c":
- // give the modal a chance to handle the ctrl+c
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- if cmd != nil {
- return a, cmd
- }
- cmd = a.modal.Close()
- a.modal = nil
- return a, cmd
- }
-
- // Pass all other key presses to the modal
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- return a, cmd
- }
-
- // 2. Check for commands that require leader
- if a.app.IsLeaderSequence {
- matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
- a.app.IsLeaderSequence = false
- if len(matches) > 0 {
- return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
- }
- }
-
- // 3. Handle completions trigger
- if keyString == "/" &&
- !a.showCompletionDialog &&
- a.editor.Value() == "" &&
- !a.app.IsBashMode {
- a.showCompletionDialog = true
-
- updated, cmd := a.editor.Update(msg)
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- // Set command provider for command completion
- a.completions = dialog.NewCompletionDialogComponent("/", a.commandProvider)
- updated, cmd = a.completions.Update(msg)
- a.completions = updated.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
-
- return a, tea.Sequence(cmds...)
- }
-
- // Handle file completions trigger
- if keyString == "@" &&
- !a.showCompletionDialog &&
- !a.app.IsBashMode {
- a.showCompletionDialog = true
-
- updated, cmd := a.editor.Update(msg)
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- // Set file, symbols, and agents providers for @ completion
- a.completions = dialog.NewCompletionDialogComponent("@", a.agentsProvider, a.fileProvider, a.symbolsProvider)
- updated, cmd = a.completions.Update(msg)
- a.completions = updated.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
-
- return a, tea.Sequence(cmds...)
- }
-
- if keyString == "!" && a.editor.Value() == "" {
- a.app.IsBashMode = true
- return a, nil
- }
-
- if a.showCompletionDialog {
- switch keyString {
- case "tab", "enter", "esc", "ctrl+c", "up", "down", "ctrl+p", "ctrl+n":
- updated, cmd := a.completions.Update(msg)
- a.completions = updated.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
- }
-
- updated, cmd := a.editor.Update(msg)
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- updated, cmd = a.completions.Update(msg)
- a.completions = updated.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
-
- return a, tea.Batch(cmds...)
- }
-
- // 4. Maximize editor responsiveness for printable characters
- if msg.Text != "" {
- updated, cmd := a.editor.Update(msg)
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
- }
-
- // 5. Check for leader key activation
- if a.leaderBinding != nil &&
- !a.app.IsLeaderSequence &&
- key.Matches(msg, *a.leaderBinding) {
- a.app.IsLeaderSequence = true
- return a, nil
- }
-
- // 6 Handle input clear command
- inputClearCommand := a.app.Commands[commands.InputClearCommand]
- if inputClearCommand.Matches(msg, a.app.IsLeaderSequence) && a.editor.Length() > 0 {
- return a, util.CmdHandler(commands.ExecuteCommandMsg(inputClearCommand))
- }
-
- // 7. Handle interrupt key debounce for session interrupt
- interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
- if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() {
- switch a.interruptKeyState {
- case InterruptKeyIdle:
- // First interrupt key press - start debounce timer
- a.interruptKeyState = InterruptKeyFirstPress
- a.editor.SetInterruptKeyInDebounce(true)
- return a, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg {
- return InterruptDebounceTimeoutMsg{}
- })
- case InterruptKeyFirstPress:
- // Second interrupt key press within timeout - actually interrupt
- a.interruptKeyState = InterruptKeyIdle
- a.editor.SetInterruptKeyInDebounce(false)
- return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand))
- }
- }
-
- // 8. Handle exit key debounce for app exit when using non-leader command
- exitCommand := a.app.Commands[commands.AppExitCommand]
- if exitCommand.Matches(msg, a.app.IsLeaderSequence) {
- switch a.exitKeyState {
- case ExitKeyIdle:
- // First exit key press - start debounce timer
- a.exitKeyState = ExitKeyFirstPress
- a.editor.SetExitKeyInDebounce(true)
- return a, tea.Tick(exitDebounceTimeout, func(t time.Time) tea.Msg {
- return ExitDebounceTimeoutMsg{}
- })
- case ExitKeyFirstPress:
- // Second exit key press within timeout - actually exit
- a.exitKeyState = ExitKeyIdle
- a.editor.SetExitKeyInDebounce(false)
- return a, util.CmdHandler(commands.ExecuteCommandMsg(exitCommand))
- }
- }
-
- // 9. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce)
- matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
- if len(matches) > 0 {
- // Skip interrupt key if we're in debounce mode and app is busy
- if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
- return a, nil
- }
- return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
- }
-
- // Fallback: suspend if ctrl+z is pressed and no user keybind matched
- if keyString == "ctrl+z" {
- return a, tea.Suspend
- }
-
- // 10. Fallback to editor. This is for other characters like backspace, tab, etc.
- updatedEditor, cmd := a.editor.Update(msg)
- a.editor = updatedEditor.(chat.EditorComponent)
- return a, cmd
- case tea.MouseWheelMsg:
- if a.modal != nil {
- u, cmd := a.modal.Update(msg)
- a.modal = u.(layout.Modal)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
- }
-
- updated, cmd := a.messages.Update(msg)
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
- case tea.BackgroundColorMsg:
- styles.Terminal = &styles.TerminalInfo{
- Background: msg.Color,
- BackgroundIsDark: msg.IsDark(),
- }
- slog.Debug("Background color", "color", msg.String(), "isDark", msg.IsDark())
- return a, func() tea.Msg {
- theme.UpdateSystemTheme(
- styles.Terminal.Background,
- styles.Terminal.BackgroundIsDark,
- )
- return dialog.ThemeSelectedMsg{
- ThemeName: theme.CurrentThemeName(),
- }
- }
- case modal.CloseModalMsg:
- a.editor.Focus()
- var cmd tea.Cmd
- if a.modal != nil {
- cmd = a.modal.Close()
- }
- a.modal = nil
- return a, cmd
- case dialog.ReopenSessionModalMsg:
- // Reopen the session modal (used when exiting rename mode)
- sessionDialog := dialog.NewSessionDialog(a.app)
- a.modal = sessionDialog
- return a, nil
- case commands.ExecuteCommandMsg:
- updated, cmd := a.executeCommand(commands.Command(msg))
- return updated, cmd
- case commands.ExecuteCommandsMsg:
- for _, command := range msg {
- updated, cmd := a.executeCommand(command)
- if cmd != nil {
- return updated, cmd
- }
- }
- case error:
- return a, toast.NewErrorToast(msg.Error())
- case app.SendPrompt:
- a.showCompletionDialog = false
- // If we're in a child session, switch back to parent before sending prompt
- if a.app.Session.ParentID != "" {
- parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return a, toast.NewErrorToast("Failed to get parent session")
- }
- a.app.Session = parentSession
- a.app, cmd = a.app.SendPrompt(context.Background(), msg)
- cmds = append(cmds, tea.Sequence(
- util.CmdHandler(app.SessionSelectedMsg(parentSession)),
- cmd,
- ))
- } else {
- a.app, cmd = a.app.SendPrompt(context.Background(), msg)
- cmds = append(cmds, cmd)
- }
- case app.SendCommand:
- // If we're in a child session, switch back to parent before sending prompt
- if a.app.Session.ParentID != "" {
- parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return a, toast.NewErrorToast("Failed to get parent session")
- }
- a.app.Session = parentSession
- a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args)
- cmds = append(cmds, tea.Sequence(
- util.CmdHandler(app.SessionSelectedMsg(parentSession)),
- cmd,
- ))
- } else {
- a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args)
- cmds = append(cmds, cmd)
- }
- case app.SendShell:
- // If we're in a child session, switch back to parent before sending prompt
- if a.app.Session.ParentID != "" {
- parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return a, toast.NewErrorToast("Failed to get parent session")
- }
- a.app.Session = parentSession
- a.app, cmd = a.app.SendShell(context.Background(), msg.Command)
- cmds = append(cmds, tea.Sequence(
- util.CmdHandler(app.SessionSelectedMsg(parentSession)),
- cmd,
- ))
- } else {
- a.app, cmd = a.app.SendShell(context.Background(), msg.Command)
- cmds = append(cmds, cmd)
- }
- case app.SetEditorContentMsg:
- // Set the editor content without sending
- a.editor.SetValueWithAttachments(msg.Text)
- updated, cmd := a.editor.Focus()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case app.SessionClearedMsg:
- a.app.Session = &opencode.Session{}
- a.app.Messages = []app.Message{}
- case dialog.CompletionDialogCloseMsg:
- a.showCompletionDialog = false
- case opencode.EventListResponseEventInstallationUpdated:
- return a, toast.NewSuccessToast(
- "opencode updated to "+msg.Properties.Version+", restart to apply.",
- toast.WithTitle("New version installed"),
- )
- /*
- case opencode.EventListResponseEventIdeInstalled:
- return a, toast.NewSuccessToast(
- "Installed the opencode extension in "+msg.Properties.Ide,
- toast.WithTitle(msg.Properties.Ide+" extension installed"),
- )
- */
- case opencode.EventListResponseEventSessionDeleted:
- if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
- a.app.Session = &opencode.Session{}
- a.app.Messages = []app.Message{}
- }
- return a, toast.NewSuccessToast("Session deleted successfully")
- case opencode.EventListResponseEventSessionUpdated:
- if msg.Properties.Info.ID == a.app.Session.ID {
- a.app.Session = &msg.Properties.Info
- }
- case opencode.EventListResponseEventMessagePartUpdated:
- slog.Debug("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID)
- if msg.Properties.Part.SessionID == a.app.Session.ID {
- messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
- switch casted := m.Info.(type) {
- case opencode.UserMessage:
- return casted.ID == msg.Properties.Part.MessageID
- case opencode.AssistantMessage:
- return casted.ID == msg.Properties.Part.MessageID
- }
- return false
- })
- if messageIndex > -1 {
- message := a.app.Messages[messageIndex]
- partIndex := slices.IndexFunc(message.Parts, func(p opencode.PartUnion) bool {
- switch casted := p.(type) {
- case opencode.TextPart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.ReasoningPart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.FilePart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.ToolPart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.StepStartPart:
- return casted.ID == msg.Properties.Part.ID
- case opencode.StepFinishPart:
- return casted.ID == msg.Properties.Part.ID
- }
- return false
- })
- if partIndex > -1 {
- message.Parts[partIndex] = msg.Properties.Part.AsUnion()
- }
- if partIndex == -1 {
- message.Parts = append(message.Parts, msg.Properties.Part.AsUnion())
- }
- a.app.Messages[messageIndex] = message
- }
- }
- case opencode.EventListResponseEventMessagePartRemoved:
- slog.Debug("message part removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID, "part", msg.Properties.PartID)
- if msg.Properties.SessionID == a.app.Session.ID {
- messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
- switch casted := m.Info.(type) {
- case opencode.UserMessage:
- return casted.ID == msg.Properties.MessageID
- case opencode.AssistantMessage:
- return casted.ID == msg.Properties.MessageID
- }
- return false
- })
- if messageIndex > -1 {
- message := a.app.Messages[messageIndex]
- partIndex := slices.IndexFunc(message.Parts, func(p opencode.PartUnion) bool {
- switch casted := p.(type) {
- case opencode.TextPart:
- return casted.ID == msg.Properties.PartID
- case opencode.ReasoningPart:
- return casted.ID == msg.Properties.PartID
- case opencode.FilePart:
- return casted.ID == msg.Properties.PartID
- case opencode.ToolPart:
- return casted.ID == msg.Properties.PartID
- case opencode.StepStartPart:
- return casted.ID == msg.Properties.PartID
- case opencode.StepFinishPart:
- return casted.ID == msg.Properties.PartID
- }
- return false
- })
- if partIndex > -1 {
- // Remove the part at partIndex
- message.Parts = append(message.Parts[:partIndex], message.Parts[partIndex+1:]...)
- a.app.Messages[messageIndex] = message
- }
- }
- }
- case opencode.EventListResponseEventMessageRemoved:
- slog.Debug("message removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID)
- if msg.Properties.SessionID == a.app.Session.ID {
- messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
- switch casted := m.Info.(type) {
- case opencode.UserMessage:
- return casted.ID == msg.Properties.MessageID
- case opencode.AssistantMessage:
- return casted.ID == msg.Properties.MessageID
- }
- return false
- })
- if messageIndex > -1 {
- a.app.Messages = append(a.app.Messages[:messageIndex], a.app.Messages[messageIndex+1:]...)
- }
- }
- case opencode.EventListResponseEventMessageUpdated:
- if msg.Properties.Info.SessionID == a.app.Session.ID {
- matchIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
- switch casted := m.Info.(type) {
- case opencode.UserMessage:
- return casted.ID == msg.Properties.Info.ID
- case opencode.AssistantMessage:
- return casted.ID == msg.Properties.Info.ID
- }
- return false
- })
-
- if matchIndex > -1 {
- match := a.app.Messages[matchIndex]
- a.app.Messages[matchIndex] = app.Message{
- Info: msg.Properties.Info.AsUnion(),
- Parts: match.Parts,
- }
- }
-
- if matchIndex == -1 {
- // Extract the new message ID
- var newMessageID string
- switch casted := msg.Properties.Info.AsUnion().(type) {
- case opencode.UserMessage:
- newMessageID = casted.ID
- case opencode.AssistantMessage:
- newMessageID = casted.ID
- }
-
- // Find the correct insertion index by scanning backwards
- // Most messages are added to the end, so start from the end
- insertIndex := len(a.app.Messages)
- for i := len(a.app.Messages) - 1; i >= 0; i-- {
- var existingID string
- switch casted := a.app.Messages[i].Info.(type) {
- case opencode.UserMessage:
- existingID = casted.ID
- case opencode.AssistantMessage:
- existingID = casted.ID
- }
- if existingID < newMessageID {
- insertIndex = i + 1
- break
- }
- }
-
- // Create the new message
- newMessage := app.Message{
- Info: msg.Properties.Info.AsUnion(),
- Parts: []opencode.PartUnion{},
- }
-
- // Insert at the correct position
- a.app.Messages = append(a.app.Messages[:insertIndex], append([]app.Message{newMessage}, a.app.Messages[insertIndex:]...)...)
- }
- }
- case opencode.EventListResponseEventPermissionUpdated:
- slog.Debug("permission updated", "session", msg.Properties.SessionID, "permission", msg.Properties.ID)
- a.app.Permissions = append(a.app.Permissions, msg.Properties)
- a.app.CurrentPermission = a.app.Permissions[0]
- a.editor.Blur()
- case opencode.EventListResponseEventPermissionReplied:
- index := slices.IndexFunc(a.app.Permissions, func(p opencode.Permission) bool {
- return p.ID == msg.Properties.PermissionID
- })
- if index > -1 {
- a.app.Permissions = append(a.app.Permissions[:index], a.app.Permissions[index+1:]...)
- }
- if a.app.CurrentPermission.ID == msg.Properties.PermissionID {
- if len(a.app.Permissions) > 0 {
- a.app.CurrentPermission = a.app.Permissions[0]
- } else {
- a.app.CurrentPermission = opencode.Permission{}
- }
- }
- case opencode.EventListResponseEventSessionError:
- switch err := msg.Properties.Error.AsUnion().(type) {
- case nil:
- // No error details provided
- case opencode.ProviderAuthError:
- slog.Error("Failed to authenticate with provider", "error", err.Data.Message)
- return a, toast.NewErrorToast("Provider error: " + err.Data.Message)
- case opencode.UnknownError:
- slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
- return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
- case opencode.EventListResponseEventSessionErrorPropertiesErrorAPIError:
- slog.Error("API error", "message", err.Data.Message, "statusCode", err.Data.StatusCode)
- return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
- case opencode.MessageAbortedError:
- // Message was aborted - this is expected when user cancels, so just log it
- slog.Debug("Message aborted", "message", err.Data.Message)
- case opencode.EventListResponseEventSessionErrorPropertiesErrorMessageOutputLengthError:
- slog.Error("Message output length error")
- return a, toast.NewErrorToast("Message output length exceeded limit")
- default:
- // Handle any unhandled error types
- slog.Error("Unhandled session error type", "type", fmt.Sprintf("%T", err))
- return a, toast.NewErrorToast("An unexpected error occurred")
- }
- case opencode.EventListResponseEventSessionCompacted:
- if msg.Properties.SessionID == a.app.Session.ID {
- return a, toast.NewSuccessToast("Session compacted successfully")
- }
- case tea.WindowSizeMsg:
- msg.Height -= 2 // Make space for the status bar
- a.width, a.height = msg.Width, msg.Height
- container := min(a.width, 86)
- layout.Current = &layout.LayoutInfo{
- Viewport: layout.Dimensions{
- Width: a.width,
- Height: a.height,
- },
- Container: layout.Dimensions{
- Width: container,
- },
- }
- case app.SessionSelectedMsg:
- updated, cmd := a.messages.Update(msg)
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
-
- messages, err := a.app.ListMessages(context.Background(), msg.ID)
- if err != nil {
- slog.Error("Failed to list messages", "error", err.Error())
- return a, toast.NewErrorToast("Failed to open session")
- }
- a.app.Session = msg
- a.app.Messages = messages
- cmds = append(cmds, util.CmdHandler(app.SessionLoadedMsg{}))
- return a, tea.Batch(cmds...)
- case app.SessionCreatedMsg:
- a.app.Session = msg.Session
- case dialog.ScrollToMessageMsg:
- updated, cmd := a.messages.ScrollToMessage(msg.MessageID)
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case dialog.RestoreToMessageMsg:
- cmd := func() tea.Msg {
- // Find next user message after target
- var nextMessageID string
- for i := msg.Index + 1; i < len(a.app.Messages); i++ {
- if userMsg, ok := a.app.Messages[i].Info.(opencode.UserMessage); ok {
- nextMessageID = userMsg.ID
- break
- }
- }
-
- var response *opencode.Session
- var err error
-
- if nextMessageID == "" {
- // Last message - use unrevert to restore full conversation
- response, err = a.app.Client.Session.Unrevert(context.Background(), a.app.Session.ID, opencode.SessionUnrevertParams{})
- } else {
- // Revert to next message to make target the last visible
- response, err = a.app.Client.Session.Revert(context.Background(), a.app.Session.ID,
- opencode.SessionRevertParams{MessageID: opencode.F(nextMessageID)})
- }
-
- if err != nil || response == nil {
- return toast.NewErrorToast("Failed to restore to message")
- }
- return app.MessageRevertedMsg{Session: *response, Message: app.Message{}}
- }
- cmds = append(cmds, cmd)
- case app.MessageRevertedMsg:
- if msg.Session.ID == a.app.Session.ID {
- a.app.Session = &msg.Session
- }
- case app.ModelSelectedMsg:
- a.app.Provider = &msg.Provider
- a.app.Model = &msg.Model
- a.app.State.AgentModel[a.app.Agent().Name] = app.AgentModel{
- ProviderID: msg.Provider.ID,
- ModelID: msg.Model.ID,
- }
- a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
- cmds = append(cmds, a.app.SaveState())
- case app.AgentSelectedMsg:
- updated, cmd := a.app.SwitchToAgent(msg.AgentName)
- a.app = updated
- cmds = append(cmds, cmd)
- case dialog.ThemeSelectedMsg:
- a.app.State.Theme = msg.ThemeName
- cmds = append(cmds, a.app.SaveState())
- case toast.ShowToastMsg:
- tm, cmd := a.toastManager.Update(msg)
- a.toastManager = tm
- cmds = append(cmds, cmd)
- case toast.DismissToastMsg:
- tm, cmd := a.toastManager.Update(msg)
- a.toastManager = tm
- cmds = append(cmds, cmd)
- case InterruptDebounceTimeoutMsg:
- // Reset interrupt key state after timeout
- a.interruptKeyState = InterruptKeyIdle
- a.editor.SetInterruptKeyInDebounce(false)
- case ExitDebounceTimeoutMsg:
- // Reset exit key state after timeout
- a.exitKeyState = ExitKeyIdle
- a.editor.SetExitKeyInDebounce(false)
- case tea.PasteMsg, tea.ClipboardMsg:
- // Paste events: prioritize modal if active, otherwise editor
- if a.modal != nil {
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- return a, cmd
- } else {
- updatedEditor, cmd := a.editor.Update(msg)
- a.editor = updatedEditor.(chat.EditorComponent)
- return a, cmd
- }
-
- // API
- case api.Request:
- slog.Info("api", "path", msg.Path)
- var response any = true
- switch msg.Path {
- case "/tui/open-help":
- helpDialog := dialog.NewHelpDialog(a.app)
- a.modal = helpDialog
- case "/tui/open-sessions":
- sessionDialog := dialog.NewSessionDialog(a.app)
- a.modal = sessionDialog
- case "/tui/open-timeline":
- navigationDialog := dialog.NewTimelineDialog(a.app)
- a.modal = navigationDialog
- case "/tui/open-themes":
- themeDialog := dialog.NewThemeDialog()
- a.modal = themeDialog
- case "/tui/open-models":
- modelDialog := dialog.NewModelDialog(a.app)
- a.modal = modelDialog
- case "/tui/append-prompt":
- var body struct {
- Text string `json:"text"`
- }
- json.Unmarshal((msg.Body), &body)
- existing := a.editor.Value()
- text := body.Text
- if existing != "" && !strings.HasSuffix(existing, " ") {
- text = " " + text
- }
- a.editor.SetValueWithAttachments(existing + text + " ")
- case "/tui/submit-prompt":
- updated, cmd := a.editor.Submit()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case "/tui/clear-prompt":
- updated, cmd := a.editor.Clear()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case "/tui/execute-command":
- var body struct {
- Command string `json:"command"`
- }
- json.Unmarshal((msg.Body), &body)
- command := commands.Command{}
- for _, cmd := range a.app.Commands {
- if string(cmd.Name) == body.Command {
- command = cmd
- break
- }
- }
- if command.Name == "" {
- slog.Error("Invalid command passed to /tui/execute-command", "command", body.Command)
- return a, nil
- }
- updated, cmd := a.executeCommand(commands.Command(command))
- a = updated.(Model)
- cmds = append(cmds, cmd)
- case "/tui/show-toast":
- var body struct {
- Title string `json:"title,omitempty"`
- Message string `json:"message"`
- Variant string `json:"variant"`
- }
- json.Unmarshal((msg.Body), &body)
-
- var toastCmd tea.Cmd
- switch body.Variant {
- case "info":
- if body.Title != "" {
- toastCmd = toast.NewInfoToast(body.Message, toast.WithTitle(body.Title))
- } else {
- toastCmd = toast.NewInfoToast(body.Message)
- }
- case "success":
- if body.Title != "" {
- toastCmd = toast.NewSuccessToast(body.Message, toast.WithTitle(body.Title))
- } else {
- toastCmd = toast.NewSuccessToast(body.Message)
- }
- case "warning":
- if body.Title != "" {
- toastCmd = toast.NewErrorToast(body.Message, toast.WithTitle(body.Title))
- } else {
- toastCmd = toast.NewErrorToast(body.Message)
- }
- case "error":
- if body.Title != "" {
- toastCmd = toast.NewErrorToast(body.Message, toast.WithTitle(body.Title))
- } else {
- toastCmd = toast.NewErrorToast(body.Message)
- }
- default:
- slog.Error("Invalid toast variant", "variant", body.Variant)
- return a, nil
- }
- cmds = append(cmds, toastCmd)
-
- default:
- break
- }
- cmds = append(cmds, api.Reply(context.Background(), a.app.Client, response))
- }
-
- s, cmd := a.status.Update(msg)
- cmds = append(cmds, cmd)
- a.status = s.(status.StatusComponent)
-
- updatedEditor, cmd := a.editor.Update(msg)
- a.editor = updatedEditor.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- updatedMessages, cmd := a.messages.Update(msg)
- a.messages = updatedMessages.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
-
- if a.modal != nil {
- updatedModal, cmd := a.modal.Update(msg)
- a.modal = updatedModal.(layout.Modal)
- cmds = append(cmds, cmd)
- }
-
- if a.showCompletionDialog {
- u, cmd := a.completions.Update(msg)
- a.completions = u.(dialog.CompletionDialog)
- cmds = append(cmds, cmd)
- }
-
- return a, tea.Batch(cmds...)
-}
-
-func (a Model) View() (string, *tea.Cursor) {
- t := theme.CurrentTheme()
-
- var mainLayout string
-
- var editorX int
- var editorY int
- if a.app.Session.ID == "" {
- mainLayout, editorX, editorY = a.home()
- } else {
- mainLayout, editorX, editorY = a.chat()
- }
- mainLayout = styles.NewStyle().
- Background(t.Background()).
- Padding(0, 2).
- Render(mainLayout)
- mainLayout = lipgloss.PlaceHorizontal(
- a.width,
- lipgloss.Center,
- mainLayout,
- styles.WhitespaceStyle(t.Background()),
- )
-
- mainStyle := styles.NewStyle().Background(t.Background())
- mainLayout = mainStyle.Render(mainLayout)
-
- if a.modal != nil {
- mainLayout = a.modal.Render(mainLayout)
- }
- mainLayout = a.toastManager.RenderOverlay(mainLayout)
-
- if theme.CurrentThemeUsesAnsiColors() {
- mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
- }
-
- cursor := a.editor.Cursor()
- cursor.Position.X += editorX
- cursor.Position.Y += editorY
-
- return mainLayout + "\n" + a.status.View(), cursor
-}
-
-func (a Model) Cleanup() {
- a.status.Cleanup()
-}
-
-func (a Model) home() (string, int, int) {
- t := theme.CurrentTheme()
- effectiveWidth := a.width - 4
- baseStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
- base := baseStyle.Render
- muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
-
- open := `
-
-█▀▀█ █▀▀█ █▀▀█ █▀▀▄
-█░░█ █░░█ █▀▀▀ █░░█
-▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ `
-
- code := `
- ▄
-█▀▀▀ █▀▀█ █▀▀█ █▀▀█
-█░░░ █░░█ █░░█ █▀▀▀
-▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`
-
- logo := lipgloss.JoinHorizontal(
- lipgloss.Top,
- muted(open),
- base(code),
- )
- // cwd := app.Info.Path.Cwd
- // config := app.Info.Path.Config
-
- versionStyle := styles.NewStyle().
- Foreground(t.TextMuted()).
- Background(t.Background()).
- Width(lipgloss.Width(logo)).
- Align(lipgloss.Right)
- version := versionStyle.Render(a.app.Version)
-
- logoAndVersion := strings.Join([]string{logo, version}, "\n")
- logoAndVersion = lipgloss.PlaceHorizontal(
- effectiveWidth,
- lipgloss.Center,
- logoAndVersion,
- styles.WhitespaceStyle(t.Background()),
- )
-
- // Use limit of 4 for vscode, 6 for others
- limit := 5
- if util.IsVSCode() {
- limit = 3
- }
-
- showVscode := util.IsVSCode()
- commandsView := cmdcomp.New(
- a.app,
- cmdcomp.WithBackground(t.Background()),
- cmdcomp.WithLimit(limit),
- cmdcomp.WithVscode(showVscode),
- )
- cmds := lipgloss.PlaceHorizontal(
- effectiveWidth,
- lipgloss.Center,
- commandsView.View(),
- styles.WhitespaceStyle(t.Background()),
- )
-
- lines := []string{}
- lines = append(lines, "")
- lines = append(lines, logoAndVersion)
- lines = append(lines, "")
- lines = append(lines, cmds)
- lines = append(lines, "")
- lines = append(lines, "")
-
- mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
-
- editorView := a.editor.View()
- editorWidth := lipgloss.Width(editorView)
- editorView = lipgloss.PlaceHorizontal(
- effectiveWidth,
- lipgloss.Center,
- editorView,
- styles.WhitespaceStyle(t.Background()),
- )
- lines = append(lines, editorView)
-
- editorLines := a.editor.Lines()
-
- mainLayout := lipgloss.Place(
- effectiveWidth,
- a.height,
- lipgloss.Center,
- lipgloss.Center,
- baseStyle.Render(strings.Join(lines, "\n")),
- styles.WhitespaceStyle(t.Background()),
- )
-
- editorX := max(0, (effectiveWidth-editorWidth)/2)
- editorY := (a.height / 2) + (mainHeight / 2) - 3
- editorYDelta := 3
-
- if editorLines > 1 {
- editorYDelta = 2
- content := a.editor.Content()
- editorHeight := lipgloss.Height(content)
-
- if editorY+editorHeight > a.height {
- difference := (editorY + editorHeight) - a.height
- editorY -= difference
- }
- mainLayout = layout.PlaceOverlay(
- editorX,
- editorY,
- content,
- mainLayout,
- )
- }
-
- if a.showCompletionDialog {
- a.completions.SetWidth(editorWidth)
- overlay := a.completions.View()
- overlayHeight := lipgloss.Height(overlay)
-
- mainLayout = layout.PlaceOverlay(
- editorX,
- editorY-overlayHeight+2,
- overlay,
- mainLayout,
- )
- }
-
- return mainLayout, editorX + 5, editorY + editorYDelta
-}
-
-func (a Model) chat() (string, int, int) {
- effectiveWidth := a.width - 4
- t := theme.CurrentTheme()
- editorView := a.editor.View()
- lines := a.editor.Lines()
- messagesView := a.messages.View()
-
- editorWidth := lipgloss.Width(editorView)
- editorHeight := max(lines, 5)
- editorView = lipgloss.PlaceHorizontal(
- effectiveWidth,
- lipgloss.Center,
- editorView,
- styles.WhitespaceStyle(t.Background()),
- )
-
- mainLayout := messagesView + "\n" + editorView
- editorX := max(0, (effectiveWidth-editorWidth)/2)
- editorY := a.height - editorHeight
-
- if lines > 1 {
- content := a.editor.Content()
- editorHeight := lipgloss.Height(content)
- if editorY+editorHeight > a.height {
- difference := (editorY + editorHeight) - a.height
- editorY -= difference
- }
- mainLayout = layout.PlaceOverlay(
- editorX,
- editorY,
- content,
- mainLayout,
- )
- }
-
- if a.showCompletionDialog {
- a.completions.SetWidth(editorWidth)
- overlay := a.completions.View()
- overlayHeight := lipgloss.Height(overlay)
- editorY := a.height - editorHeight + 1
-
- mainLayout = layout.PlaceOverlay(
- editorX,
- editorY-overlayHeight,
- overlay,
- mainLayout,
- )
- }
-
- return mainLayout, editorX + 5, editorY + 2
-}
-
-func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
- cmds := []tea.Cmd{
- util.CmdHandler(commands.CommandExecutedMsg(command)),
- }
- switch command.Name {
- case commands.AppHelpCommand:
- helpDialog := dialog.NewHelpDialog(a.app)
- a.modal = helpDialog
- case commands.AgentCycleCommand:
- updated, cmd := a.app.SwitchAgent()
- a.app = updated
- cmds = append(cmds, cmd)
- case commands.AgentCycleReverseCommand:
- updated, cmd := a.app.SwitchAgentReverse()
- a.app = updated
- cmds = append(cmds, cmd)
- case commands.EditorOpenCommand:
- if a.app.IsBusy() {
- // status.Warn("Agent is working, please wait...")
- return a, nil
- }
- editor := util.GetEditor()
- if editor == "" {
- return a, toast.NewErrorToast("No editor found. Set EDITOR environment variable (e.g., export EDITOR=vim)")
- }
-
- value := a.editor.Value()
-
- // Expand text attachments before opening editor
- for _, att := range a.editor.GetAttachments() {
- if textSource, ok := att.GetTextSource(); ok {
- value = strings.Replace(value, att.Display, textSource.Value, 1)
- }
- }
-
- updated, cmd := a.editor.Clear()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
-
- tmpfile, err := os.CreateTemp("", "msg_*.md")
- tmpfile.WriteString(value)
- if err != nil {
- slog.Error("Failed to create temp file", "error", err)
- return a, toast.NewErrorToast("Something went wrong, couldn't open editor")
- }
- tmpfile.Close()
- parts := strings.Fields(editor)
- c := exec.Command(parts[0], append(parts[1:], tmpfile.Name())...) //nolint:gosec
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- cmd = tea.ExecProcess(c, func(err error) tea.Msg {
- if err != nil {
- slog.Error("Failed to open editor", "error", err)
- return nil
- }
- content, err := os.ReadFile(tmpfile.Name())
- if err != nil {
- slog.Error("Failed to read file", "error", err)
- return nil
- }
- if len(content) == 0 {
- slog.Warn("Message is empty")
- return nil
- }
- os.Remove(tmpfile.Name())
- return app.SetEditorContentMsg{
- Text: string(content),
- }
- })
- cmds = append(cmds, cmd)
- case commands.SessionNewCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
-
- case commands.SessionListCommand:
- sessionDialog := dialog.NewSessionDialog(a.app)
- a.modal = sessionDialog
- case commands.SessionTimelineCommand:
- if a.app.Session.ID == "" {
- return a, toast.NewErrorToast("No active session")
- }
- navigationDialog := dialog.NewTimelineDialog(a.app)
- a.modal = navigationDialog
- case commands.SessionShareCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- response, err := a.app.Client.Session.Share(
- context.Background(),
- a.app.Session.ID,
- opencode.SessionShareParams{},
- )
- if err != nil {
- slog.Error("Failed to share session", "error", err)
- return a, toast.NewErrorToast("Failed to share session")
- }
- shareUrl := response.Share.URL
- cmds = append(cmds, app.SetClipboard(shareUrl))
- cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
- case commands.SessionUnshareCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- _, err := a.app.Client.Session.Unshare(
- context.Background(),
- a.app.Session.ID,
- opencode.SessionUnshareParams{},
- )
- if err != nil {
- slog.Error("Failed to unshare session", "error", err)
- return a, toast.NewErrorToast("Failed to unshare session")
- }
- a.app.Session.Share.URL = ""
- cmds = append(cmds, toast.NewSuccessToast("Session unshared successfully"))
- case commands.SessionInterruptCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- a.app.Cancel(context.Background(), a.app.Session.ID)
- return a, nil
- case commands.SessionCompactCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- // TODO: block until compaction is complete
- a.app.CompactSession(context.Background())
- case commands.SessionChildCycleCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- cmds = append(cmds, func() tea.Msg {
- parentSessionID := a.app.Session.ID
- var parentSession *opencode.Session
- if a.app.Session.ParentID != "" {
- parentSessionID = a.app.Session.ParentID
- session, err := a.app.Client.Session.Get(
- context.Background(),
- parentSessionID,
- opencode.SessionGetParams{},
- )
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return toast.NewErrorToast("Failed to get parent session")
- }
- parentSession = session
- } else {
- parentSession = a.app.Session
- }
-
- children, err := a.app.Client.Session.Children(
- context.Background(),
- parentSessionID,
- opencode.SessionChildrenParams{},
- )
- if err != nil {
- slog.Error("Failed to get session children", "error", err)
- return toast.NewErrorToast("Failed to get session children")
- }
-
- // Reverse sort the children (newest first)
- slices.Reverse(*children)
-
- // Create combined array: [parent, child1, child2, ...]
- sessions := []*opencode.Session{parentSession}
- for i := range *children {
- sessions = append(sessions, &(*children)[i])
- }
-
- if len(sessions) == 1 {
- return toast.NewInfoToast("No child sessions available")
- }
-
- // Find current session index in combined array
- currentIndex := -1
- for i, session := range sessions {
- if session.ID == a.app.Session.ID {
- currentIndex = i
- break
- }
- }
-
- // If session not found, default to parent (shouldn't happen)
- if currentIndex == -1 {
- currentIndex = 0
- }
-
- // Cycle to next session (parent or child)
- nextIndex := (currentIndex + 1) % len(sessions)
- nextSession := sessions[nextIndex]
-
- return app.SessionSelectedMsg(nextSession)
- })
- case commands.SessionChildCycleReverseCommand:
- if a.app.Session.ID == "" {
- return a, nil
- }
- cmds = append(cmds, func() tea.Msg {
- parentSessionID := a.app.Session.ID
- var parentSession *opencode.Session
- if a.app.Session.ParentID != "" {
- parentSessionID = a.app.Session.ParentID
- session, err := a.app.Client.Session.Get(
- context.Background(),
- parentSessionID,
- opencode.SessionGetParams{},
- )
- if err != nil {
- slog.Error("Failed to get parent session", "error", err)
- return toast.NewErrorToast("Failed to get parent session")
- }
- parentSession = session
- } else {
- parentSession = a.app.Session
- }
-
- children, err := a.app.Client.Session.Children(
- context.Background(),
- parentSessionID,
- opencode.SessionChildrenParams{},
- )
- if err != nil {
- slog.Error("Failed to get session children", "error", err)
- return toast.NewErrorToast("Failed to get session children")
- }
-
- // Reverse sort the children (newest first)
- slices.Reverse(*children)
-
- // Create combined array: [parent, child1, child2, ...]
- sessions := []*opencode.Session{parentSession}
- for i := range *children {
- sessions = append(sessions, &(*children)[i])
- }
-
- if len(sessions) == 1 {
- return toast.NewInfoToast("No child sessions available")
- }
-
- // Find current session index in combined array
- currentIndex := -1
- for i, session := range sessions {
- if session.ID == a.app.Session.ID {
- currentIndex = i
- break
- }
- }
-
- // If session not found, default to parent (shouldn't happen)
- if currentIndex == -1 {
- currentIndex = 0
- }
-
- // Cycle to previous session (parent or child)
- nextIndex := (currentIndex - 1 + len(sessions)) % len(sessions)
- nextSession := sessions[nextIndex]
-
- return app.SessionSelectedMsg(nextSession)
- })
- case commands.SessionExportCommand:
- if a.app.Session.ID == "" {
- return a, toast.NewErrorToast("No active session to export.")
- }
-
- // Use current conversation history
- messages := a.app.Messages
- if len(messages) == 0 {
- return a, toast.NewInfoToast("No messages to export.")
- }
-
- // Format to Markdown
- markdownContent := formatConversationToMarkdown(messages)
-
- editor := util.GetEditor()
- if editor == "" {
- return a, toast.NewErrorToast("No editor found. Set EDITOR environment variable (e.g., export EDITOR=vim)")
- }
-
- // Create and write to temp file
- tmpfile, err := os.CreateTemp("", "conversation-*.md")
- if err != nil {
- slog.Error("Failed to create temp file", "error", err)
- return a, toast.NewErrorToast("Failed to create temporary file.")
- }
-
- _, err = tmpfile.WriteString(markdownContent)
- if err != nil {
- slog.Error("Failed to write to temp file", "error", err)
- tmpfile.Close()
- os.Remove(tmpfile.Name())
- return a, toast.NewErrorToast("Failed to write conversation to file.")
- }
- tmpfile.Close()
-
- // Open in editor
- parts := strings.Fields(editor)
- c := exec.Command(parts[0], append(parts[1:], tmpfile.Name())...) //nolint:gosec
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- cmd = tea.ExecProcess(c, func(err error) tea.Msg {
- if err != nil {
- slog.Error("Failed to open editor for conversation", "error", err)
- }
- // Clean up the file after editor closes
- os.Remove(tmpfile.Name())
- return nil
- })
- cmds = append(cmds, cmd)
- case commands.ToolDetailsCommand:
- message := "Tool details are now visible"
- if a.messages.ToolDetailsVisible() {
- message = "Tool details are now hidden"
- }
- cmds = append(cmds, util.CmdHandler(chat.ToggleToolDetailsMsg{}))
- cmds = append(cmds, toast.NewInfoToast(message))
- case commands.ThinkingBlocksCommand:
- message := "Thinking blocks are now visible"
- if a.messages.ThinkingBlocksVisible() {
- message = "Thinking blocks are now hidden"
- }
- cmds = append(cmds, util.CmdHandler(chat.ToggleThinkingBlocksMsg{}))
- cmds = append(cmds, toast.NewInfoToast(message))
- case commands.ModelListCommand:
- modelDialog := dialog.NewModelDialog(a.app)
- a.modal = modelDialog
-
- case commands.AgentListCommand:
- agentDialog := dialog.NewAgentDialog(a.app)
- a.modal = agentDialog
- case commands.ModelCycleRecentCommand:
- slog.Debug("ModelCycleRecentCommand triggered")
- updated, cmd := a.app.CycleRecentModel()
- a.app = updated
- cmds = append(cmds, cmd)
- case commands.ModelCycleRecentReverseCommand:
- updated, cmd := a.app.CycleRecentModelReverse()
- a.app = updated
- cmds = append(cmds, cmd)
- case commands.ThemeListCommand:
- themeDialog := dialog.NewThemeDialog()
- a.modal = themeDialog
- case commands.ProjectInitCommand:
- cmds = append(cmds, a.app.InitializeProject(context.Background()))
- case commands.InputClearCommand:
- if a.editor.Value() == "" {
- return a, nil
- }
- updated, cmd := a.editor.Clear()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case commands.InputPasteCommand:
- updated, cmd := a.editor.Paste()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case commands.InputSubmitCommand:
- updated, cmd := a.editor.Submit()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case commands.InputNewlineCommand:
- updated, cmd := a.editor.Newline()
- a.editor = updated.(chat.EditorComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesFirstCommand:
- updated, cmd := a.messages.GotoTop()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesLastCommand:
- updated, cmd := a.messages.GotoBottom()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesPageUpCommand:
- updated, cmd := a.messages.PageUp()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesPageDownCommand:
- updated, cmd := a.messages.PageDown()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesHalfPageUpCommand:
- updated, cmd := a.messages.HalfPageUp()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesHalfPageDownCommand:
- updated, cmd := a.messages.HalfPageDown()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesCopyCommand:
- updated, cmd := a.messages.CopyLastMessage()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesUndoCommand:
- updated, cmd := a.messages.UndoLastMessage()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.MessagesRedoCommand:
- updated, cmd := a.messages.RedoLastMessage()
- a.messages = updated.(chat.MessagesComponent)
- cmds = append(cmds, cmd)
- case commands.AppExitCommand:
- return a, tea.Quit
- }
- return a, tea.Batch(cmds...)
-}
-
-func NewModel(app *app.App) tea.Model {
- commandProvider := completions.NewCommandCompletionProvider(app)
- fileProvider := completions.NewFileContextGroup(app)
- symbolsProvider := completions.NewSymbolsContextGroup(app)
- agentsProvider := completions.NewAgentsContextGroup(app)
-
- messages := chat.NewMessagesComponent(app)
- editor := chat.NewEditorComponent(app)
- completions := dialog.NewCompletionDialogComponent("/", commandProvider)
-
- var leaderBinding *key.Binding
- if app.Config.Keybinds.Leader != "" {
- binding := key.NewBinding(key.WithKeys(app.Config.Keybinds.Leader))
- leaderBinding = &binding
- }
-
- model := &Model{
- status: status.NewStatusCmp(app),
- app: app,
- editor: editor,
- messages: messages,
- completions: completions,
- commandProvider: commandProvider,
- fileProvider: fileProvider,
- symbolsProvider: symbolsProvider,
- agentsProvider: agentsProvider,
- leaderBinding: leaderBinding,
- showCompletionDialog: false,
- toastManager: toast.NewToastManager(),
- interruptKeyState: InterruptKeyIdle,
- exitKeyState: ExitKeyIdle,
- }
-
- return model
-}
-
-func formatConversationToMarkdown(messages []app.Message) string {
- var builder strings.Builder
-
- builder.WriteString("# Conversation History\n\n")
-
- for _, msg := range messages {
- builder.WriteString("---\n\n")
-
- var role string
- var timestamp time.Time
-
- switch info := msg.Info.(type) {
- case opencode.UserMessage:
- role = "User"
- timestamp = time.UnixMilli(int64(info.Time.Created))
- case opencode.AssistantMessage:
- role = "Assistant"
- timestamp = time.UnixMilli(int64(info.Time.Created))
- default:
- continue
- }
-
- builder.WriteString(
- fmt.Sprintf("**%s** (*%s*)\n\n", role, timestamp.Format("2006-01-02 15:04:05")),
- )
-
- for _, part := range msg.Parts {
- switch p := part.(type) {
- case opencode.TextPart:
- builder.WriteString(p.Text + "\n\n")
- case opencode.FilePart:
- builder.WriteString(fmt.Sprintf("[File: %s]\n\n", p.Filename))
- case opencode.ToolPart:
- builder.WriteString(fmt.Sprintf("[Tool: %s]\n\n", p.Tool))
- }
- }
- }
-
- return builder.String()
-}
diff --git a/packages/tui/internal/util/apilogger.go b/packages/tui/internal/util/apilogger.go
deleted file mode 100644
index 8e872e63a..000000000
--- a/packages/tui/internal/util/apilogger.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package util
-
-import (
- "context"
- "fmt"
- "log/slog"
- "reflect"
- "sync"
-
- opencode "github.com/sst/opencode-sdk-go"
-)
-
-func sanitizeValue(val any) any {
- if val == nil {
- return nil
- }
-
- if err, ok := val.(error); ok {
- return err.Error()
- }
-
- v := reflect.ValueOf(val)
- if v.Kind() == reflect.Interface && !v.IsNil() {
- return fmt.Sprintf("%T", val)
- }
-
- return val
-}
-
-type APILogHandler struct {
- client *opencode.Client
- service string
- level slog.Level
- attrs []slog.Attr
- groups []string
- mu sync.Mutex
- queue chan opencode.AppLogParams
-}
-
-func NewAPILogHandler(ctx context.Context, client *opencode.Client, service string, level slog.Level) *APILogHandler {
- result := &APILogHandler{
- client: client,
- service: service,
- level: level,
- attrs: make([]slog.Attr, 0),
- groups: make([]string, 0),
- queue: make(chan opencode.AppLogParams, 100_000),
- }
- go func() {
- for {
- select {
- case <-ctx.Done():
- return
- case params := <-result.queue:
- _, err := client.App.Log(context.Background(), params)
- if err != nil {
- slog.Error("Failed to log to API", "error", err)
- }
- }
- }
- }()
- return result
-}
-
-func (h *APILogHandler) Enabled(_ context.Context, level slog.Level) bool {
- return level >= h.level
-}
-
-func (h *APILogHandler) Handle(ctx context.Context, r slog.Record) error {
- var apiLevel opencode.AppLogParamsLevel
- switch r.Level {
- case slog.LevelDebug:
- apiLevel = opencode.AppLogParamsLevelDebug
- case slog.LevelInfo:
- apiLevel = opencode.AppLogParamsLevelInfo
- case slog.LevelWarn:
- apiLevel = opencode.AppLogParamsLevelWarn
- case slog.LevelError:
- apiLevel = opencode.AppLogParamsLevelError
- default:
- apiLevel = opencode.AppLogParamsLevelInfo
- }
-
- extra := make(map[string]any)
-
- h.mu.Lock()
- for _, attr := range h.attrs {
- val := attr.Value.Any()
- extra[attr.Key] = sanitizeValue(val)
- }
- h.mu.Unlock()
-
- r.Attrs(func(attr slog.Attr) bool {
- val := attr.Value.Any()
- extra[attr.Key] = sanitizeValue(val)
- return true
- })
-
- params := opencode.AppLogParams{
- Service: opencode.F(h.service),
- Level: opencode.F(apiLevel),
- Message: opencode.F(r.Message),
- }
-
- if len(extra) > 0 {
- params.Extra = opencode.F(extra)
- }
-
- h.queue <- params
-
- return nil
-}
-
-// WithAttrs returns a new Handler whose attributes consist of
-// both the receiver's attributes and the arguments.
-func (h *APILogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
- h.mu.Lock()
- defer h.mu.Unlock()
-
- newHandler := &APILogHandler{
- client: h.client,
- service: h.service,
- level: h.level,
- attrs: make([]slog.Attr, len(h.attrs)+len(attrs)),
- groups: make([]string, len(h.groups)),
- }
-
- copy(newHandler.attrs, h.attrs)
- copy(newHandler.attrs[len(h.attrs):], attrs)
- copy(newHandler.groups, h.groups)
-
- return newHandler
-}
-
-// WithGroup returns a new Handler with the given group appended to
-// the receiver's existing groups.
-func (h *APILogHandler) WithGroup(name string) slog.Handler {
- h.mu.Lock()
- defer h.mu.Unlock()
-
- newHandler := &APILogHandler{
- client: h.client,
- service: h.service,
- level: h.level,
- attrs: make([]slog.Attr, len(h.attrs)),
- groups: make([]string, len(h.groups)+1),
- }
-
- copy(newHandler.attrs, h.attrs)
- copy(newHandler.groups, h.groups)
- newHandler.groups[len(h.groups)] = name
-
- return newHandler
-}
diff --git a/packages/tui/internal/util/color.go b/packages/tui/internal/util/color.go
deleted file mode 100644
index b387ca655..000000000
--- a/packages/tui/internal/util/color.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package util
-
-import (
- "regexp"
- "strings"
-
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/theme"
-)
-
-var csiRE *regexp.Regexp
-
-func init() {
- csiRE = regexp.MustCompile(`\x1b\[([0-9;]+)m`)
-}
-
-var targetFGMap = map[string]string{
- "0;0;0": "\x1b[30m", // Black
- "128;0;0": "\x1b[31m", // Red
- "0;128;0": "\x1b[32m", // Green
- "128;128;0": "\x1b[33m", // Yellow
- "0;0;128": "\x1b[34m", // Blue
- "128;0;128": "\x1b[35m", // Magenta
- "0;128;128": "\x1b[36m", // Cyan
- "192;192;192": "\x1b[37m", // White (light grey)
- "128;128;128": "\x1b[90m", // Bright Black (dark grey)
- "255;0;0": "\x1b[91m", // Bright Red
- "0;255;0": "\x1b[92m", // Bright Green
- "255;255;0": "\x1b[93m", // Bright Yellow
- "0;0;255": "\x1b[94m", // Bright Blue
- "255;0;255": "\x1b[95m", // Bright Magenta
- "0;255;255": "\x1b[96m", // Bright Cyan
- "255;255;255": "\x1b[97m", // Bright White
-}
-
-var targetBGMap = map[string]string{
- "0;0;0": "\x1b[40m",
- "128;0;0": "\x1b[41m",
- "0;128;0": "\x1b[42m",
- "128;128;0": "\x1b[43m",
- "0;0;128": "\x1b[44m",
- "128;0;128": "\x1b[45m",
- "0;128;128": "\x1b[46m",
- "192;192;192": "\x1b[47m",
- "128;128;128": "\x1b[100m",
- "255;0;0": "\x1b[101m",
- "0;255;0": "\x1b[102m",
- "255;255;0": "\x1b[103m",
- "0;0;255": "\x1b[104m",
- "255;0;255": "\x1b[105m",
- "0;255;255": "\x1b[106m",
- "255;255;255": "\x1b[107m",
-}
-
-func ConvertRGBToAnsi16Colors(s string) string {
- return csiRE.ReplaceAllStringFunc(s, func(seq string) string {
- params := strings.Split(csiRE.FindStringSubmatch(seq)[1], ";")
- out := make([]string, 0, len(params))
-
- for i := 0; i < len(params); {
- // Detect “38 | 48 ; 2 ; r ; g ; b ( ; alpha? )”
- if (params[i] == "38" || params[i] == "48") &&
- i+4 < len(params) &&
- params[i+1] == "2" {
-
- key := strings.Join(params[i+2:i+5], ";")
- var repl string
- if params[i] == "38" {
- repl = targetFGMap[key]
- } else {
- repl = targetBGMap[key]
- }
-
- if repl != "" { // exact RGB hit
- out = append(out, repl[2:len(repl)-1])
- i += 5 // skip 38/48;2;r;g;b
-
- // if i == len(params)-1 && looksLikeByte(params[i]) {
- // i++ // swallow the alpha byte
- // }
- continue
- }
- }
- // Normal token — keep verbatim.
- out = append(out, params[i])
- i++
- }
-
- return "\x1b[" + strings.Join(out, ";") + "m"
- })
-}
-
-// func looksLikeByte(tok string) bool {
-// v, err := strconv.Atoi(tok)
-// return err == nil && v >= 0 && v <= 255
-// }
-
-// GetAgentColor returns the color for a given agent index, matching the status bar colors
-func GetAgentColor(agentIndex int) compat.AdaptiveColor {
- t := theme.CurrentTheme()
- agentColors := []compat.AdaptiveColor{
- t.TextMuted(),
- t.Secondary(),
- t.Accent(),
- t.Success(),
- t.Warning(),
- t.Primary(),
- t.Error(),
- }
-
- if agentIndex >= 0 && agentIndex < len(agentColors) {
- return agentColors[agentIndex]
- }
- return t.Secondary() // default fallback
-}
diff --git a/packages/tui/internal/util/concurrency.go b/packages/tui/internal/util/concurrency.go
deleted file mode 100644
index d24c7f974..000000000
--- a/packages/tui/internal/util/concurrency.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package util
-
-import (
- "strings"
-)
-
-func mapParallel[in, out any](items []in, fn func(in) out) chan out {
- mapChans := make([]chan out, 0, len(items))
-
- for _, v := range items {
- ch := make(chan out)
- mapChans = append(mapChans, ch)
- go func() {
- defer close(ch)
- ch <- fn(v)
- }()
- }
-
- resultChan := make(chan out)
-
- go func() {
- defer close(resultChan)
- for _, ch := range mapChans {
- v := <-ch
- resultChan <- v
- }
- }()
-
- return resultChan
-}
-
-// WriteStringsPar allows to iterate over a list and compute strings in parallel,
-// yet write them in order.
-func WriteStringsPar[a any](sb *strings.Builder, items []a, fn func(a) string) {
- ch := mapParallel(items, fn)
-
- for v := range ch {
- sb.WriteString(v)
- }
-}
diff --git a/packages/tui/internal/util/concurrency_test.go b/packages/tui/internal/util/concurrency_test.go
deleted file mode 100644
index 6512882f5..000000000
--- a/packages/tui/internal/util/concurrency_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package util_test
-
-import (
- "strconv"
- "strings"
- "testing"
- "time"
-
- "github.com/sst/opencode/internal/util"
-)
-
-func TestWriteStringsPar(t *testing.T) {
- items := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
- sb := strings.Builder{}
- util.WriteStringsPar(&sb, items, func(i int) string {
- // sleep for the inverse duration so that later items finish first
- time.Sleep(time.Duration(10-i) * time.Millisecond)
- return strconv.Itoa(i)
- })
- if sb.String() != "0123456789" {
- t.Fatalf("expected 0123456789, got %s", sb.String())
- }
-}
diff --git a/packages/tui/internal/util/file.go b/packages/tui/internal/util/file.go
deleted file mode 100644
index 050b96343..000000000
--- a/packages/tui/internal/util/file.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package util
-
-import (
- "fmt"
- "path/filepath"
- "regexp"
- "strings"
- "unicode"
-
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/charmbracelet/x/ansi"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
-)
-
-var RootPath string
-var CwdPath string
-
-type fileRenderer struct {
- filename string
- content string
- height int
-}
-
-type fileRenderingOption func(*fileRenderer)
-
-func WithTruncate(height int) fileRenderingOption {
- return func(c *fileRenderer) {
- c.height = height
- }
-}
-
-func RenderFile(
- filename string,
- content string,
- width int,
- options ...fileRenderingOption) string {
- t := theme.CurrentTheme()
- renderer := &fileRenderer{
- filename: filename,
- content: content,
- }
- for _, option := range options {
- option(renderer)
- }
-
- lines := []string{}
- for line := range strings.SplitSeq(content, "\n") {
- line = strings.TrimRightFunc(line, unicode.IsSpace)
- line = strings.ReplaceAll(line, "\t", " ")
- lines = append(lines, line)
- }
- content = strings.Join(lines, "\n")
-
- if renderer.height > 0 {
- content = TruncateHeight(content, renderer.height)
- }
- content = fmt.Sprintf("```%s\n%s\n```", Extension(renderer.filename), content)
- content = ToMarkdown(content, width, t.BackgroundPanel())
- return content
-}
-
-func TruncateHeight(content string, height int) string {
- lines := strings.Split(content, "\n")
- if len(lines) > height {
- return strings.Join(lines[:height], "\n")
- }
- return content
-}
-
-func Relative(path string) string {
- path = strings.TrimPrefix(path, CwdPath+"/")
- return strings.TrimPrefix(path, RootPath+"/")
-}
-
-func Extension(path string) string {
- ext := filepath.Ext(path)
- if ext == "" {
- ext = ""
- } else {
- ext = strings.ToLower(ext[1:])
- }
- return ext
-}
-
-func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
- r := styles.GetMarkdownRenderer(width-6, backgroundColor)
- content = strings.ReplaceAll(content, RootPath+"/", "")
- hyphenRegex := regexp.MustCompile(`-([^ \-|]|$)`)
- content = hyphenRegex.ReplaceAllString(content, "\u2011$1")
- rendered, _ := r.Render(content)
- lines := strings.Split(rendered, "\n")
-
- if len(lines) > 0 {
- firstLine := lines[0]
- cleaned := ansi.Strip(firstLine)
- nospace := strings.ReplaceAll(cleaned, " ", "")
- if nospace == "" {
- lines = lines[1:]
- }
- if len(lines) > 0 {
- lastLine := lines[len(lines)-1]
- cleaned = ansi.Strip(lastLine)
- nospace = strings.ReplaceAll(cleaned, " ", "")
- if nospace == "" {
- lines = lines[:len(lines)-1]
- }
- }
- }
- content = strings.Join(lines, "\n")
- content = strings.ReplaceAll(content, "\u2011", "-")
- return strings.TrimSuffix(content, "\n")
-}
diff --git a/packages/tui/internal/util/ide.go b/packages/tui/internal/util/ide.go
deleted file mode 100644
index 7b3832f9b..000000000
--- a/packages/tui/internal/util/ide.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package util
-
-import (
- "os"
- "strings"
-)
-
-var SUPPORTED_IDES = []struct {
- Search string
- ShortName string
-}{
- {"Windsurf", "Windsurf"},
- {"Visual Studio Code", "vscode"},
- {"Cursor", "Cursor"},
- {"VSCodium", "VSCodium"},
-}
-
-func IsVSCode() bool {
- return os.Getenv("OPENCODE_CALLER") == "vscode"
-}
-
-func Ide() string {
- for _, ide := range SUPPORTED_IDES {
- if strings.Contains(os.Getenv("GIT_ASKPASS"), ide.Search) {
- return ide.ShortName
- }
- }
-
- return "unknown"
-}
-
diff --git a/packages/tui/internal/util/shimmer.go b/packages/tui/internal/util/shimmer.go
deleted file mode 100644
index b6ba0db64..000000000
--- a/packages/tui/internal/util/shimmer.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package util
-
-import (
- "math"
- "os"
- "strings"
- "time"
-
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/lipgloss/v2/compat"
- "github.com/sst/opencode/internal/styles"
-)
-
-var (
- shimmerStart = time.Now()
- trueColorSupport = hasTrueColor()
-)
-
-// Shimmer renders text with a moving foreground highlight.
-// bg is the background color, dim is the base text color, bright is the highlight color.
-func Shimmer(s string, bg compat.AdaptiveColor, _ compat.AdaptiveColor, _ compat.AdaptiveColor) string {
- if s == "" {
- return ""
- }
-
- runes := []rune(s)
- n := len(runes)
- if n == 0 {
- return s
- }
-
- pad := 10
- period := float64(n + pad*2)
- sweep := 2.5
- elapsed := time.Since(shimmerStart).Seconds()
- pos := (math.Mod(elapsed, sweep) / sweep) * period
-
- half := 2.0
-
- type seg struct {
- useHex bool
- hex string
- bold bool
- faint bool
- text string
- }
- segs := make([]seg, 0, n/4)
-
- useHex := trueColorSupport
- for i, r := range runes {
- ip := float64(i + pad)
- dist := math.Abs(ip - pos)
-
- bold := false
- faint := true
- hex := ""
-
- if dist <= half {
- // Simple 3-level brightness based on distance
- if dist <= half/3 {
- // Center: brightest
- bold = true
- faint = false
- if useHex {
- hex = "#ffffff"
- }
- } else {
- // Edge: medium bright
- bold = false
- faint = false
- if useHex {
- hex = "#cccccc"
- }
- }
- }
-
- if len(segs) == 0 ||
- segs[len(segs)-1].useHex != useHex ||
- segs[len(segs)-1].hex != hex ||
- segs[len(segs)-1].bold != bold ||
- segs[len(segs)-1].faint != faint {
- segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)})
- } else {
- segs[len(segs)-1].text += string(r)
- }
- }
-
- baseStyle := styles.NewStyle().Background(bg)
- var b strings.Builder
- b.Grow(len(s) * 2)
- for _, g := range segs {
- st := baseStyle
- if g.useHex && g.hex != "" {
- c := compat.AdaptiveColor{Dark: lipgloss.Color(g.hex), Light: lipgloss.Color(g.hex)}
- st = st.Foreground(c)
- }
- if g.bold {
- st = st.Bold(true)
- }
- if g.faint {
- st = st.Faint(true)
- }
- b.WriteString(st.Render(g.text))
- }
- return b.String()
-}
-
-func hasTrueColor() bool {
- c := strings.ToLower(os.Getenv("COLORTERM"))
- return strings.Contains(c, "truecolor") || strings.Contains(c, "24bit")
-}
-
-func rgbHex(r, g, b int) string {
- if r < 0 {
- r = 0
- }
- if r > 255 {
- r = 255
- }
- if g < 0 {
- g = 0
- }
- if g > 255 {
- g = 255
- }
- if b < 0 {
- b = 0
- }
- if b > 255 {
- b = 255
- }
- return "#" + hex2(r) + hex2(g) + hex2(b)
-}
-
-func hex2(v int) string {
- const digits = "0123456789abcdef"
- return string([]byte{digits[(v>>4)&0xF], digits[v&0xF]})
-}
diff --git a/packages/tui/internal/util/util.go b/packages/tui/internal/util/util.go
deleted file mode 100644
index b49d2e292..000000000
--- a/packages/tui/internal/util/util.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package util
-
-import (
- "log/slog"
- "os"
- "os/exec"
- "runtime"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
-)
-
-func CmdHandler(msg tea.Msg) tea.Cmd {
- return func() tea.Msg {
- return msg
- }
-}
-
-func Clamp(v, low, high int) int {
- // Swap if needed to ensure low <= high
- if high < low {
- low, high = high, low
- }
- return min(high, max(low, v))
-}
-
-func IsWsl() bool {
- // Check for WSL environment variables
- if os.Getenv("WSL_DISTRO_NAME") != "" {
- return true
- }
-
- // Check /proc/version for WSL signature
- if data, err := os.ReadFile("/proc/version"); err == nil {
- version := strings.ToLower(string(data))
- return strings.Contains(version, "microsoft") || strings.Contains(version, "wsl")
- }
-
- return false
-}
-
-func Measure(tag string) func(...any) {
- startTime := time.Now()
- return func(args ...any) {
- args = append(args, []any{"timeTakenMs", time.Since(startTime).Milliseconds()}...)
- slog.Debug(tag, args...)
- }
-}
-
-func GetEditor() string {
- if editor := os.Getenv("VISUAL"); editor != "" {
- return editor
- }
- if editor := os.Getenv("EDITOR"); editor != "" {
- return editor
- }
-
- commonEditors := []string{"vim", "nvim", "zed", "code", "cursor", "vi", "nano"}
- if runtime.GOOS == "windows" {
- commonEditors = []string{"vim", "nvim", "zed", "code.cmd", "cursor.cmd", "notepad.exe", "vi", "nano"}
- }
-
- for _, editor := range commonEditors {
- if _, err := exec.LookPath(editor); err == nil {
- return editor
- }
- }
-
- return ""
-}
diff --git a/packages/tui/internal/viewport/highlight.go b/packages/tui/internal/viewport/highlight.go
deleted file mode 100644
index ec0ffda56..000000000
--- a/packages/tui/internal/viewport/highlight.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package viewport
-
-import (
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- "github.com/rivo/uniseg"
-)
-
-// parseMatches converts the given matches into highlight ranges.
-//
-// Assumptions:
-// - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return
-// - matches were made against the given content
-// - matches are in order
-// - matches do not overlap
-// - content is line terminated with \n only
-//
-// We'll then convert the ranges into [highlightInfo]s, which hold the starting
-// line and the grapheme positions.
-func parseMatches(
- content string,
- matches [][]int,
-) []highlightInfo {
- if len(matches) == 0 {
- return nil
- }
-
- line := 0
- graphemePos := 0
- previousLinesOffset := 0
- bytePos := 0
-
- highlights := make([]highlightInfo, 0, len(matches))
- gr := uniseg.NewGraphemes(ansi.Strip(content))
-
- for _, match := range matches {
- byteStart, byteEnd := match[0], match[1]
-
- // hilight for this match:
- hi := highlightInfo{
- lines: map[int][2]int{},
- }
-
- // find the beginning of this byte range, setup current line and
- // grapheme position.
- for byteStart > bytePos {
- if !gr.Next() {
- break
- }
- if content[bytePos] == '\n' {
- previousLinesOffset = graphemePos + 1
- line++
- }
- graphemePos += max(1, gr.Width())
- bytePos += len(gr.Str())
- }
-
- hi.lineStart = line
- hi.lineEnd = line
-
- graphemeStart := graphemePos
-
- // loop until we find the end
- for byteEnd > bytePos {
- if !gr.Next() {
- break
- }
-
- // if it ends with a new line, add the range, increase line, and continue
- if content[bytePos] == '\n' {
- colstart := max(0, graphemeStart-previousLinesOffset)
- colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself
-
- if colend > colstart {
- hi.lines[line] = [2]int{colstart, colend}
- hi.lineEnd = line
- }
-
- previousLinesOffset = graphemePos + 1
- line++
- }
-
- graphemePos += max(1, gr.Width())
- bytePos += len(gr.Str())
- }
-
- // we found it!, add highlight and continue
- if bytePos == byteEnd {
- colstart := max(0, graphemeStart-previousLinesOffset)
- colend := max(graphemePos-previousLinesOffset, colstart)
-
- if colend > colstart {
- hi.lines[line] = [2]int{colstart, colend}
- hi.lineEnd = line
- }
- }
-
- highlights = append(highlights, hi)
- }
-
- return highlights
-}
-
-type highlightInfo struct {
- // in which line this highlight starts and ends
- lineStart, lineEnd int
-
- // the grapheme highlight ranges for each of these lines
- lines map[int][2]int
-}
-
-// coords returns the line x column of this highlight.
-func (hi highlightInfo) coords() (int, int, int) {
- for i := hi.lineStart; i <= hi.lineEnd; i++ {
- hl, ok := hi.lines[i]
- if !ok {
- continue
- }
- return i, hl[0], hl[1]
- }
- return hi.lineStart, 0, 0
-}
-
-func makeHighlightRanges(
- highlights []highlightInfo,
- line int,
- style lipgloss.Style,
-) []lipgloss.Range {
- result := []lipgloss.Range{}
- for _, hi := range highlights {
- lihi, ok := hi.lines[line]
- if !ok {
- continue
- }
- if lihi == [2]int{} {
- continue
- }
- result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style))
- }
- return result
-}
diff --git a/packages/tui/internal/viewport/keymap.go b/packages/tui/internal/viewport/keymap.go
deleted file mode 100644
index d9c503a9f..000000000
--- a/packages/tui/internal/viewport/keymap.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package viewport
-
-import "github.com/charmbracelet/bubbles/v2/key"
-
-// KeyMap defines the keybindings for the viewport. Note that you don't
-// necessary need to use keybindings at all; the viewport can be controlled
-// programmatically with methods like Model.LineDown(1). See the GoDocs for
-// details.
-type KeyMap struct {
- PageDown key.Binding
- PageUp key.Binding
- HalfPageUp key.Binding
- HalfPageDown key.Binding
- Down key.Binding
- Up key.Binding
- Left key.Binding
- Right key.Binding
-}
-
-// DefaultKeyMap returns a set of pager-like default keybindings.
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- PageDown: key.NewBinding(
- key.WithKeys("pgdown", "space", "f"),
- key.WithHelp("f/pgdn", "page down"),
- ),
- PageUp: key.NewBinding(
- key.WithKeys("pgup", "b"),
- key.WithHelp("b/pgup", "page up"),
- ),
- HalfPageUp: key.NewBinding(
- key.WithKeys("u", "ctrl+u"),
- key.WithHelp("u", "½ page up"),
- ),
- HalfPageDown: key.NewBinding(
- key.WithKeys("d", "ctrl+d"),
- key.WithHelp("d", "½ page down"),
- ),
- Up: key.NewBinding(
- key.WithKeys("up", "k"),
- key.WithHelp("↑/k", "up"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "j"),
- key.WithHelp("↓/j", "down"),
- ),
- Left: key.NewBinding(
- key.WithKeys("left", "h"),
- key.WithHelp("←/h", "move left"),
- ),
- Right: key.NewBinding(
- key.WithKeys("right", "l"),
- key.WithHelp("→/l", "move right"),
- ),
- }
-}
diff --git a/packages/tui/internal/viewport/viewport.go b/packages/tui/internal/viewport/viewport.go
deleted file mode 100644
index 10c875fab..000000000
--- a/packages/tui/internal/viewport/viewport.go
+++ /dev/null
@@ -1,803 +0,0 @@
-package viewport
-
-import (
- "math"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-)
-
-const (
- defaultHorizontalStep = 6
-)
-
-// Option is a configuration option that works in conjunction with [New]. For
-// example:
-//
-// timer := New(WithWidth(10, WithHeight(5)))
-type Option func(*Model)
-
-// WithWidth is an initialization option that sets the width of the
-// viewport. Pass as an argument to [New].
-func WithWidth(w int) Option {
- return func(m *Model) {
- m.width = w
- }
-}
-
-// WithHeight is an initialization option that sets the height of the
-// viewport. Pass as an argument to [New].
-func WithHeight(h int) Option {
- return func(m *Model) {
- m.height = h
- }
-}
-
-// New returns a new model with the given width and height as well as default
-// key mappings.
-func New(opts ...Option) (m Model) {
- for _, opt := range opts {
- opt(&m)
- }
- m.setInitialValues()
- m.memo = &Memo{}
- return m
-}
-
-type Memo struct {
- dirty bool
- cache string
-}
-
-func (m *Memo) View(render func() string) string {
- if m.dirty {
- // slog.Debug("memo dirty")
- m.cache = render()
- m.dirty = false
- return m.cache
- }
- // slog.Debug("memo cache")
- return m.cache
-}
-
-func (m *Memo) Invalidate() {
- m.dirty = true
-}
-
-// Model is the Bubble Tea model for this viewport element.
-type Model struct {
- memo *Memo
- width int
- height int
- KeyMap KeyMap
-
- // Whether or not to wrap text. If false, it'll allow horizontal scrolling
- // instead.
- SoftWrap bool
-
- // Whether or not to fill to the height of the viewport with empty lines.
- FillHeight bool
-
- // Whether or not to respond to the mouse. The mouse must be enabled in
- // Bubble Tea for this to work. For details, see the Bubble Tea docs.
- MouseWheelEnabled bool
-
- // The number of lines the mouse wheel will scroll. By default, this is 3.
- MouseWheelDelta int
-
- // YOffset is the vertical scroll position.
- YOffset int
-
- // xOffset is the horizontal scroll position.
- xOffset int
-
- // horizontalStep is the number of columns we move left or right during a
- // default horizontal scroll.
- horizontalStep int
-
- // YPosition is the position of the viewport in relation to the terminal
- // window. It's used in high performance rendering only.
- YPosition int
-
- // Style applies a lipgloss style to the viewport. Realistically, it's most
- // useful for setting borders, margins and padding.
- Style lipgloss.Style
-
- // LeftGutterFunc allows to define a [GutterFunc] that adds a column into
- // the left of the viewport, which is kept when horizontal scrolling.
- // This can be used for things like line numbers, selection indicators,
- // show statuses, etc.
- LeftGutterFunc GutterFunc
-
- initialized bool
- lines []string
- longestLineWidth int
-
- // HighlightStyle highlights the ranges set with [SetHighligths].
- HighlightStyle lipgloss.Style
-
- // SelectedHighlightStyle highlights the highlight range focused during
- // navigation.
- // Use [SetHighligths] to set the highlight ranges, and [HightlightNext]
- // and [HihglightPrevious] to navigate.
- SelectedHighlightStyle lipgloss.Style
-
- // StyleLineFunc allows to return a [lipgloss.Style] for each line.
- // The argument is the line index.
- StyleLineFunc func(int) lipgloss.Style
-
- highlights []highlightInfo
- hiIdx int
-}
-
-// GutterFunc can be implemented and set into [Model.LeftGutterFunc].
-//
-// Example implementation showing line numbers:
-//
-// func(info GutterContext) string {
-// if info.Soft {
-// return " │ "
-// }
-// if info.Index >= info.TotalLines {
-// return " ~ │ "
-// }
-// return fmt.Sprintf("%4d │ ", info.Index+1)
-// }
-type GutterFunc func(GutterContext) string
-
-// NoGutter is the default gutter used.
-var NoGutter = func(GutterContext) string { return "" }
-
-// GutterContext provides context to a [GutterFunc].
-type GutterContext struct {
- Index int
- TotalLines int
- Soft bool
-}
-
-func (m *Model) setInitialValues() {
- m.KeyMap = DefaultKeyMap()
- m.MouseWheelEnabled = true
- m.MouseWheelDelta = 3
- m.initialized = true
- m.horizontalStep = defaultHorizontalStep
- m.LeftGutterFunc = NoGutter
-}
-
-// Init exists to satisfy the tea.Model interface for composability purposes.
-func (m Model) Init() tea.Cmd {
- return nil
-}
-
-// Height returns the height of the viewport.
-func (m Model) Height() int {
- return m.height
-}
-
-// SetHeight sets the height of the viewport.
-func (m *Model) SetHeight(h int) {
- m.height = h
- m.memo.Invalidate()
-}
-
-// Width returns the width of the viewport.
-func (m Model) Width() int {
- return m.width
-}
-
-// SetWidth sets the width of the viewport.
-func (m *Model) SetWidth(w int) {
- m.width = w
- m.memo.Invalidate()
-}
-
-// AtTop returns whether or not the viewport is at the very top position.
-func (m Model) AtTop() bool {
- return m.YOffset <= 0
-}
-
-// AtBottom returns whether or not the viewport is at or past the very bottom
-// position.
-func (m Model) AtBottom() bool {
- return m.YOffset >= m.maxYOffset()
-}
-
-// PastBottom returns whether or not the viewport is scrolled beyond the last
-// line. This can happen when adjusting the viewport height.
-func (m Model) PastBottom() bool {
- return m.YOffset > m.maxYOffset()
-}
-
-// ScrollPercent returns the amount scrolled as a float between 0 and 1.
-func (m Model) ScrollPercent() float64 {
- count := m.lineCount()
- if m.Height() >= count {
- return 1.0
- }
- y := float64(m.YOffset)
- h := float64(m.Height())
- t := float64(count)
- v := y / (t - h)
- return math.Max(0.0, math.Min(1.0, v))
-}
-
-// HorizontalScrollPercent returns the amount horizontally scrolled as a float
-// between 0 and 1.
-func (m Model) HorizontalScrollPercent() float64 {
- if m.xOffset >= m.longestLineWidth-m.Width() {
- return 1.0
- }
- y := float64(m.xOffset)
- h := float64(m.Width())
- t := float64(m.longestLineWidth)
- v := y / (t - h)
- return math.Max(0.0, math.Min(1.0, v))
-}
-
-// SetContent set the pager's text content.
-// Line endings will be normalized to '\n'.
-func (m *Model) SetContent(s string) {
- s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings
- m.SetContentLines(strings.Split(s, "\n"))
- m.memo.Invalidate()
-}
-
-// SetContentLines allows to set the lines to be shown instead of the content.
-// If a given line has a \n in it, it'll be considered a [Model.SoftWrap].
-// See also [Model.SetContent].
-func (m *Model) SetContentLines(lines []string) {
- // if there's no content, set content to actual nil instead of one empty
- // line.
- m.lines = lines
- if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 {
- m.lines = nil
- }
- m.longestLineWidth = maxLineWidth(m.lines)
- m.ClearHighlights()
-
- if m.YOffset > m.maxYOffset() {
- m.GotoBottom()
- }
- m.memo.Invalidate()
-}
-
-// GetContent returns the entire content as a single string.
-// Line endings are normalized to '\n'.
-func (m Model) GetContent() string {
- return strings.Join(m.lines, "\n")
-}
-
-// calculateLine taking soft wrapping into account, returns the total viewable
-// lines and the real-line index for the given yoffset.
-func (m Model) calculateLine(yoffset int) (total, idx int) {
- if !m.SoftWrap {
- for i, line := range m.lines {
- adjust := max(1, lipgloss.Height(line))
- if yoffset >= total && yoffset < total+adjust {
- idx = i
- }
- total += adjust
- }
- if yoffset >= total {
- idx = len(m.lines)
- }
- return total, idx
- }
-
- maxWidth := m.maxWidth()
- var gutterSize int
- if m.LeftGutterFunc != nil {
- gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
- }
- for i, line := range m.lines {
- adjust := max(1, lipgloss.Width(line)/(maxWidth-gutterSize))
- if yoffset >= total && yoffset < total+adjust {
- idx = i
- }
- total += adjust
- }
- if yoffset >= total {
- idx = len(m.lines)
- }
- return total, idx
-}
-
-// lineToIndex taking soft wrappign into account, return the real line index
-// for the given line.
-func (m Model) lineToIndex(y int) int {
- _, idx := m.calculateLine(y)
- return idx
-}
-
-// lineCount taking soft wrapping into account, return the total viewable line
-// count (real lines + soft wrapped line).
-func (m Model) lineCount() int {
- total, _ := m.calculateLine(0)
- return total
-}
-
-// maxYOffset returns the maximum possible value of the y-offset based on the
-// viewport's content and set height.
-func (m Model) maxYOffset() int {
- return max(0, m.lineCount()-m.Height()+m.Style.GetVerticalFrameSize())
-}
-
-// maxXOffset returns the maximum possible value of the x-offset based on the
-// viewport's content and set width.
-func (m Model) maxXOffset() int {
- return max(0, m.longestLineWidth-m.Width())
-}
-
-func (m Model) maxWidth() int {
- var gutterSize int
- if m.LeftGutterFunc != nil {
- gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
- }
- return m.Width() -
- m.Style.GetHorizontalFrameSize() -
- gutterSize
-}
-
-func (m Model) maxHeight() int {
- return m.Height() - m.Style.GetVerticalFrameSize()
-}
-
-// visibleLines returns the lines that should currently be visible in the
-// viewport.
-func (m Model) visibleLines() (lines []string) {
- maxHeight := m.maxHeight()
- maxWidth := m.maxWidth()
-
- if m.lineCount() > 0 {
- pos := m.lineToIndex(m.YOffset)
- top := max(0, pos)
- bottom := clamp(pos+maxHeight, top, len(m.lines))
- lines = make([]string, bottom-top)
- copy(lines, m.lines[top:bottom])
- lines = m.styleLines(lines, top)
- lines = m.highlightLines(lines, top)
- }
-
- for m.FillHeight && len(lines) < maxHeight {
- lines = append(lines, "")
- }
-
- // if longest line fit within width, no need to do anything else.
- if (m.xOffset == 0 && m.longestLineWidth <= maxWidth) || maxWidth == 0 {
- return m.setupGutter(lines)
- }
-
- if m.SoftWrap {
- return m.softWrap(lines, maxWidth)
- }
-
- for i, line := range lines {
- sublines := strings.Split(line, "\n") // will only have more than 1 if caller used [Model.SetContentLines].
- for j := range sublines {
- sublines[j] = ansi.Cut(sublines[j], m.xOffset, m.xOffset+maxWidth)
- }
- lines[i] = strings.Join(sublines, "\n")
- }
- return m.setupGutter(lines)
-}
-
-// styleLines styles the lines using [Model.StyleLineFunc].
-func (m Model) styleLines(lines []string, offset int) []string {
- if m.StyleLineFunc == nil {
- return lines
- }
- for i := range lines {
- lines[i] = m.StyleLineFunc(i + offset).Render(lines[i])
- }
- return lines
-}
-
-// highlightLines highlights the lines with [Model.HighlightStyle] and
-// [Model.SelectedHighlightStyle].
-func (m Model) highlightLines(lines []string, offset int) []string {
- if len(m.highlights) == 0 {
- return lines
- }
- for i := range lines {
- ranges := makeHighlightRanges(
- m.highlights,
- i+offset,
- m.HighlightStyle,
- )
- lines[i] = lipgloss.StyleRanges(lines[i], ranges...)
- if m.hiIdx < 0 {
- continue
- }
- sel := m.highlights[m.hiIdx]
- if hi, ok := sel.lines[i+offset]; ok {
- lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange(
- hi[0],
- hi[1],
- m.SelectedHighlightStyle,
- ))
- }
- }
- return lines
-}
-
-func (m Model) softWrap(lines []string, maxWidth int) []string {
- var wrappedLines []string
- total := m.TotalLineCount()
- for i, line := range lines {
- idx := 0
- for ansi.StringWidth(line) >= idx {
- truncatedLine := ansi.Cut(line, idx, maxWidth+idx)
- if m.LeftGutterFunc != nil {
- truncatedLine = m.LeftGutterFunc(GutterContext{
- Index: i + m.YOffset,
- TotalLines: total,
- Soft: idx > 0,
- }) + truncatedLine
- }
- wrappedLines = append(wrappedLines, truncatedLine)
- idx += maxWidth
- }
- }
- return wrappedLines
-}
-
-// setupGutter sets up the left gutter using [Moddel.LeftGutterFunc].
-func (m Model) setupGutter(lines []string) []string {
- if m.LeftGutterFunc == nil {
- return lines
- }
-
- offset := max(0, m.lineToIndex(m.YOffset))
- total := m.TotalLineCount()
- result := make([]string, len(lines))
- for i := range lines {
- var line []string
- for j, realLine := range strings.Split(lines[i], "\n") {
- line = append(line, m.LeftGutterFunc(GutterContext{
- Index: i + offset,
- TotalLines: total,
- Soft: j > 0,
- })+realLine)
- }
- result[i] = strings.Join(line, "\n")
- }
- m.memo.Invalidate()
- return result
-}
-
-// SetYOffset sets the Y offset.
-func (m *Model) SetYOffset(n int) {
- m.YOffset = clamp(n, 0, m.maxYOffset())
- m.memo.Invalidate()
-}
-
-// SetXOffset sets the X offset.
-// No-op when soft wrap is enabled.
-func (m *Model) SetXOffset(n int) {
- if m.SoftWrap {
- return
- }
- m.xOffset = clamp(n, 0, m.maxXOffset())
- m.memo.Invalidate()
-}
-
-// EnsureVisible ensures that the given line and column are in the viewport.
-func (m *Model) EnsureVisible(line, colstart, colend int) {
- maxWidth := m.maxWidth()
- if colend <= maxWidth {
- m.SetXOffset(0)
- } else {
- m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural
- }
-
- if line < m.YOffset || line >= m.YOffset+m.maxHeight() {
- m.SetYOffset(line)
- }
-
- m.visibleLines()
-}
-
-// ViewDown moves the view down by the number of lines in the viewport.
-// Basically, "page down".
-func (m *Model) ViewDown() {
- if m.AtBottom() {
- return
- }
-
- m.LineDown(m.Height())
- m.memo.Invalidate()
-}
-
-// ViewUp moves the view up by one height of the viewport. Basically, "page up".
-func (m *Model) ViewUp() {
- if m.AtTop() {
- return
- }
-
- m.LineUp(m.Height())
- m.memo.Invalidate()
-}
-
-// HalfViewDown moves the view down by half the height of the viewport.
-func (m *Model) HalfViewDown() {
- if m.AtBottom() {
- return
- }
-
- m.LineDown(m.Height() / 2) //nolint:mnd
- m.memo.Invalidate()
-}
-
-// HalfViewUp moves the view up by half the height of the viewport.
-func (m *Model) HalfViewUp() {
- if m.AtTop() {
- return
- }
-
- m.LineUp(m.Height() / 2) //nolint:mnd
- m.memo.Invalidate()
-}
-
-// LineDown moves the view down by the given number of lines.
-func (m *Model) LineDown(n int) {
- if m.AtBottom() || n == 0 || len(m.lines) == 0 {
- return
- }
-
- // Make sure the number of lines by which we're going to scroll isn't
- // greater than the number of lines we actually have left before we reach
- // the bottom.
- m.SetYOffset(m.YOffset + n)
- m.hiIdx = m.findNearedtMatch()
- m.memo.Invalidate()
-}
-
-// LineUp moves the view down by the given number of lines. Returns the new
-// lines to show.
-func (m *Model) LineUp(n int) {
- if m.AtTop() || n == 0 || len(m.lines) == 0 {
- return
- }
-
- // Make sure the number of lines by which we're going to scroll isn't
- // greater than the number of lines we are from the top.
- m.SetYOffset(m.YOffset - n)
- m.hiIdx = m.findNearedtMatch()
- m.memo.Invalidate()
-}
-
-// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
-func (m Model) TotalLineCount() int {
- return m.lineCount()
-}
-
-// VisibleLineCount returns the number of the visible lines within the viewport.
-func (m Model) VisibleLineCount() int {
- return len(m.visibleLines())
-}
-
-// GotoTop sets the viewport to the top position.
-func (m *Model) GotoTop() (lines []string) {
- if m.AtTop() {
- return nil
- }
-
- m.SetYOffset(0)
- m.hiIdx = m.findNearedtMatch()
- m.memo.Invalidate()
- return m.visibleLines()
-}
-
-// GotoBottom sets the viewport to the bottom position.
-func (m *Model) GotoBottom() (lines []string) {
- m.SetYOffset(m.maxYOffset())
- m.hiIdx = m.findNearedtMatch()
- m.memo.Invalidate()
- return m.visibleLines()
-}
-
-// SetHorizontalStep sets the amount of cells that the viewport moves in the
-// default viewport keymapping. If set to 0 or less, horizontal scrolling is
-// disabled.
-func (m *Model) SetHorizontalStep(n int) {
- if n < 0 {
- n = 0
- }
-
- m.horizontalStep = n
- m.memo.Invalidate()
-}
-
-// MoveLeft moves the viewport to the left by the given number of columns.
-func (m *Model) MoveLeft(cols int) {
- m.xOffset -= cols
- if m.xOffset < 0 {
- m.xOffset = 0
- m.memo.Invalidate()
- }
-}
-
-// MoveRight moves viewport to the right by the given number of columns.
-func (m *Model) MoveRight(cols int) {
- // prevents over scrolling to the right
- w := m.maxWidth()
- if m.xOffset > m.longestLineWidth-w {
- return
- }
- m.xOffset += cols
-}
-
-// Resets lines indent to zero.
-func (m *Model) ResetIndent() {
- m.xOffset = 0
- m.memo.Invalidate()
-}
-
-// SetHighlights sets ranges of characters to highlight.
-// For instance, `[]int{[]int{2, 10}, []int{20, 30}}` will highlight characters
-// 2 to 10 and 20 to 30.
-// Note that highlights are not expected to transpose each other, and are also
-// expected to be in order.
-// Use [Model.SetHighlights] to set the highlight ranges, and
-// [Model.HighlightNext] and [Model.HighlightPrevious] to navigate.
-// Use [Model.ClearHighlights] to remove all highlights.
-func (m *Model) SetHighlights(matches [][]int) {
- if len(matches) == 0 || len(m.lines) == 0 {
- return
- }
- m.highlights = parseMatches(m.GetContent(), matches)
- m.hiIdx = m.findNearedtMatch()
- m.showHighlight()
- m.memo.Invalidate()
-}
-
-// ClearHighlights clears previously set highlights.
-func (m *Model) ClearHighlights() {
- m.highlights = nil
- m.hiIdx = -1
- m.memo.Invalidate()
-}
-
-func (m *Model) showHighlight() {
- if m.hiIdx == -1 {
- return
- }
- line, colstart, colend := m.highlights[m.hiIdx].coords()
- m.EnsureVisible(line, colstart, colend)
- m.memo.Invalidate()
-}
-
-// HighlightNext highlights the next match.
-func (m *Model) HighlightNext() {
- if m.highlights == nil {
- return
- }
-
- m.hiIdx = (m.hiIdx + 1) % len(m.highlights)
- m.showHighlight()
- m.memo.Invalidate()
-}
-
-// HighlightPrevious highlights the previous match.
-func (m *Model) HighlightPrevious() {
- if m.highlights == nil {
- return
- }
-
- m.hiIdx = (m.hiIdx - 1 + len(m.highlights)) % len(m.highlights)
- m.showHighlight()
- m.memo.Invalidate()
-}
-
-func (m Model) findNearedtMatch() int {
- for i, match := range m.highlights {
- if match.lineStart >= m.YOffset {
- return i
- }
- }
- return -1
-}
-
-// Update handles standard message-based viewport updates.
-func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
- m = m.updateAsModel(msg)
- return m, nil
-}
-
-// Author's note: this method has been broken out to make it easier to
-// potentially transition Update to satisfy tea.Model.
-func (m Model) updateAsModel(msg tea.Msg) Model {
- if !m.initialized {
- m.setInitialValues()
- }
-
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, m.KeyMap.PageDown):
- m.ViewDown()
-
- case key.Matches(msg, m.KeyMap.PageUp):
- m.ViewUp()
-
- case key.Matches(msg, m.KeyMap.HalfPageDown):
- m.HalfViewDown()
-
- case key.Matches(msg, m.KeyMap.HalfPageUp):
- m.HalfViewUp()
-
- case key.Matches(msg, m.KeyMap.Down):
- m.LineDown(1)
-
- case key.Matches(msg, m.KeyMap.Up):
- m.LineUp(1)
-
- case key.Matches(msg, m.KeyMap.Left):
- m.MoveLeft(m.horizontalStep)
-
- case key.Matches(msg, m.KeyMap.Right):
- m.MoveRight(m.horizontalStep)
- }
-
- case tea.MouseWheelMsg:
- if !m.MouseWheelEnabled {
- break
- }
-
- switch msg.Button {
- case tea.MouseWheelDown:
- m.LineDown(m.MouseWheelDelta)
-
- case tea.MouseWheelUp:
- m.LineUp(m.MouseWheelDelta)
- }
- }
-
- return m
-}
-
-// View renders the viewport into a string.
-func (m *Model) render() {
-}
-
-func (m Model) View() string {
- return m.memo.View(func() string {
- w, h := m.Width(), m.Height()
- if sw := m.Style.GetWidth(); sw != 0 {
- w = min(w, sw)
- }
- if sh := m.Style.GetHeight(); sh != 0 {
- h = min(h, sh)
- }
- contentWidth := w - m.Style.GetHorizontalFrameSize()
- contentHeight := h - m.Style.GetVerticalFrameSize()
- visible := m.visibleLines()
- contents := lipgloss.NewStyle().
- Width(contentWidth). // pad to width.
- Height(contentHeight). // pad to height.
- MaxHeight(contentHeight). // truncate height if taller.
- MaxWidth(contentWidth). // truncate width if wider.
- Render(strings.Join(visible, "\n"))
- return m.Style.
- UnsetWidth().UnsetHeight(). // Style size already applied in contents.
- Render(contents)
- })
-}
-
-func clamp(v, low, high int) int {
- if high < low {
- low, high = high, low
- }
- return min(high, max(low, v))
-}
-
-func maxLineWidth(lines []string) int {
- result := 0
- for _, line := range lines {
- result = max(result, lipgloss.Width(line))
- }
- return result
-}