Compare commits

...
Author SHA1 Message Date
moloch-- 82d979436b Add support for additional wasm-donute options in windows execute-shellcode 2026-03-01 22:35:37 -08:00
Joe d2632e75bd Merge pull request #2199 from BishopFox/feature/go-tests2
Refactor unit tests to not write to main db
2026-03-01 09:47:23 -07:00
moloch-- a55831fb44 Go v1.25.7 2026-02-28 22:39:46 -08:00
moloch-- 39d4edec43 Tweak go-tests.sh 2026-02-28 22:08:59 -08:00
moloch-- bbdb08e7a4 Refactor go-tests.sh 2026-02-28 14:12:37 -08:00
moloch-- 39b488bd25 Refactor unit tests to not write to main db 2026-02-28 13:11:14 -08:00
Joe e7b8a24534 Merge pull request #2198 from BishopFox/feature/handler-recover
Feature/handler recover
2026-02-28 11:39:00 -07:00
moloch-- 17b9d611f8 Add panic/recover to mtls, dns, and wg handlers 2026-02-28 09:56:48 -08:00
moloch-- ed9075d065 Add better nil checks to various pb handlers 2026-02-28 09:52:40 -08:00
Joe 1f7221933d Merge pull request #2194 from BishopFox/crush/v2
Upgrade crush libs to v2
2026-02-25 09:22:24 -07:00
moloch-- 6333e0691a Upgrade crush libs to v2 2026-02-24 17:40:13 -08:00
moloch-- 3bbaf80510 v1.7.3 2026-02-23 20:49:28 -08:00
moloch-- 25726d8105 Revert wasm-donut to v0.0.3 2026-02-23 20:48:53 -08:00
moloch-- 67e9a7f37f Update wasm-donut to v0.0.6 2026-02-23 19:33:26 -08:00
moloch-- a5b6935da6 Tweak AGENTS.md 2026-02-23 07:10:28 -08:00
moloch-- 2e36f45bdf v1.7.2 2026-02-22 17:33:08 -08:00
Joe 3e06a9d5ee Merge pull request #2191 from BishopFox/feature/file-tab-completer
Feature/file tab completer
2026-02-22 18:32:27 -07:00
moloch-- 952b2df49d Update beignet 2026-02-22 16:42:32 -08:00
moloch-- 9769f1bb57 Fix mod vendor 2026-02-22 16:41:16 -08:00
moloch-- f5f2c80ad3 Update malasada to v0.0.3 2026-02-22 16:38:41 -08:00
moloch-- bb2edf63cf Add file tab completer to more commands 2026-02-22 16:37:30 -08:00
moloch-- cd711afe8a Add tab completer to generate --save 2026-02-22 16:26:32 -08:00
Joe f9bbbb34bb Merge pull request #2190 from BishopFox/skoveit-feature/PE-Metadata-Spoofing
Skoveit feature/pe metadata spoofing
2026-02-21 23:29:33 -07:00
Joe c3582fc203 Merge pull request #2189 from BishopFox/jjensn-fix/yamux-disable-logging
Jjensn fix/yamux disable logging
2026-02-19 22:29:46 -07:00
moloch-- 07c95b203f Fix unit tests 2026-02-19 18:05:57 -08:00
moloch-- 958e44deef Fix 'websites rm' add tab completer issue #2185 2026-02-19 17:54:55 -08:00
Adam 836395a2d6 Fix execute infinite spin
Signed-off-by: Adam <[email protected]>
2026-02-19 17:54:53 -08:00
Adam 1a7a1c9b2d Change env-inheritance flag default to false
Signed-off-by: Adam <[email protected]>
2026-02-19 17:54:27 -08:00
Joe 4ba285796f Merge pull request #2188 from BishopFox/fix/websites-rm
Fix 'websites rm' add tab completer issue #2185
2026-02-19 18:51:41 -07:00
moloch-- a05dfad100 Fix 'websites rm' add tab completer issue #2185 2026-02-19 17:30:33 -08:00
Joe 0eb29ad9b8 Merge pull request #2187 from Aptimex/execute-fix
Fix: Execute default env inheritance issues
2026-02-19 18:06:43 -07:00
Adam ad8b329f85 Fix execute infinite spin
Signed-off-by: Adam <[email protected]>
2026-02-19 16:45:39 -07:00
Adam c6206257b6 Change env-inheritance flag default to false
Signed-off-by: Adam <[email protected]>
2026-02-19 16:20:59 -07:00
jjensn 4d4eab998f Declare vars before use 2026-02-18 13:34:24 -05:00
jjensn c081f17380 typo: Add yamux client constructor 2026-02-17 13:17:44 -05:00
jjensn fafbac3fa6 Yamux logging for debug implants 2026-02-17 12:59:34 -05:00
309 changed files with 36901 additions and 3536 deletions

No files matched your search

-23
View File
@@ -1,41 +1,18 @@
# Repository Guidelines
## Project Structure & Module Organization
- `client/` holds the CLI client and interactive commands.
- `server/` contains the server runtime, C2 logic, configs, and web assets.
- `implant/` is the implant runtime and transport implementations.
- `protobuf/` stores protobuf definitions and generated Go types.
- `util/` is shared helpers (crypto, encoders, minisign, etc.).
- `docs/sliver-docs/` is the Next.js documentation site and prebuild scripts.
- `vendor/` is the vendored Go dependency tree used by builds.
- Build outputs land in the repo root as `sliver-client*` and `sliver-server*`.
## Build, Test, and Development Commands
- `make` builds server + client using vendored modules and runs `go-assets.sh`.
- `make client` builds a CGO-free client; `make debug` keeps symbols.
- `make pb` regenerates protobuf stubs from `protobuf/`.
- `./go-tests.sh` runs the curated Go test suite with build tags.
- Docs site: `cd docs/sliver-docs && npm install && npm run dev` for local dev; `npm run build` for a static build.
- Go toolchain: the Makefile enforces Go >= 1.24.
## Coding Style & Naming Conventions
- Run `gofmt` on all Go code; keep package names lowercase and idiomatic.
- Avoid `CGO` and empty interfaces; prefer explicit types and interfaces.
- Do not import `server` packages from `client`.
- If `math/rand` is required, import it as `insecureRand` and avoid security use.
## Testing Guidelines
- Go tests follow standard `_test.go` naming within each package.
- Prefer `docker build --target test --build-arg GO_TESTS_FLAGS=--skip-generate .`
- Add or update tests when behavior changes; some server packages run longer tests.
## Commit & Pull Request Guidelines
- Commits must be signed for PRs to `master`; work in a feature branch.
- Commit subjects are short and imperative (e.g., "Fix ...", "Add ...", "Update ...", "Bump ...").
- Changes to `vendor/` should be in a distinct commit.
- PRs should link the tracking issue, note tests run, and confirm `gofmt` plus unit tests as applicable.
## Security Notes
- Default to secure configuration; fail closed rather than open.
- Avoid insecure algorithms (MD5/SHA1/AES-ECB); prefer AES-GCM and modern curves.
- Treat user input as untrusted: canonicalize paths and use restrictive file permissions.
+1 -1
View File
@@ -26,7 +26,7 @@ K := $(foreach exec,$(EXECUTABLES),\
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
MIN_SUPPORTED_GO_MINOR_VERSION = 24
MIN_SUPPORTED_GO_MINOR_VERSION = 25
GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MIN_SUPPORTED_GO_MAJOR_VERSION).$(MIN_SUPPORTED_GO_MINOR_VERSION)
SLIVER_PUBLIC_KEY ?= RWTZPg959v3b7tLG7VzKHRB1/QT+d3c71Uzetfa44qAoX5rH7mGoQTTR
+3
View File
@@ -1,6 +1,7 @@
package alias
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -32,6 +33,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
aliasCmd.AddCommand(aliasLoadCmd)
flags.NewCompletions(aliasLoadCmd).PositionalCompletion(carapace.ActionDirectories().Tag("alias directory").Usage("path to the alias directory"))
completers.RegisterLocalFilePathPositionalCompletion(aliasLoadCmd, 0)
aliasInstallCmd := &cobra.Command{
Use: consts.InstallStr + " [ALIAS]",
@@ -44,6 +46,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
aliasCmd.AddCommand(aliasInstallCmd)
flags.NewCompletions(aliasInstallCmd).PositionalCompletion(carapace.ActionFiles().Tag("alias file"))
completers.RegisterLocalFilePathPositionalCompletion(aliasInstallCmd, 0)
aliasRemove := &cobra.Command{
Use: consts.RmStr + " [ALIAS]",
+4
View File
@@ -1,6 +1,7 @@
package c2profiles
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/generate"
"github.com/bishopfox/sliver/client/command/help"
@@ -27,6 +28,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("file", "f", "", "Path to C2 configuration file to import")
f.BoolP("overwrite", "o", false, "Overwrite profile if it exists")
})
completers.RegisterLocalFilePathFlagCompletion(importC2ProfileCmd, "file")
exportC2ProfileCmd := &cobra.Command{
Use: consts.ExportC2ProfileStr,
@@ -40,6 +42,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("file", "f", "", "Path to file to export C2 configuration to")
f.StringP("name", "n", consts.DefaultC2Profile, "HTTP C2 Profile name")
})
completers.RegisterLocalFilePathFlagCompletion(exportC2ProfileCmd, "file")
flags.BindFlagCompletions(exportC2ProfileCmd, func(comp *carapace.ActionMap) {
(*comp)["name"] = generate.HTTPC2Completer(con)
})
@@ -76,6 +79,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "HTTP C2 Profile name to save C2Profile as")
f.StringP("template", "t", consts.DefaultC2Profile, "HTTP C2 Profile to use as a template for the new profile")
})
completers.RegisterLocalFilePathFlagCompletion(generateC2ProfileCmd, "file")
flags.BindFlagCompletions(generateC2ProfileCmd, func(comp *carapace.ActionMap) {
(*comp)["template"] = generate.HTTPC2Completer(con)
+58
View File
@@ -22,6 +22,7 @@ import (
"net"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
)
// ClientInterfacesCompleter completes interface addresses on the client host.
@@ -84,3 +85,60 @@ func LocalProxyCompleter() carapace.Action {
}).Invoke(c).Prefix(prefix).ToA()
})
}
// LocalFilePathCompleter completes local file and directory paths.
func LocalFilePathCompleter() carapace.Action {
return carapace.ActionFiles().Tag("local file path")
}
// RegisterLocalFilePathFlagCompletion registers local file-path completion for a flag.
func RegisterLocalFilePathFlagCompletion(cmd *cobra.Command, name string) {
if cmd == nil {
return
}
if _, ok := cmd.GetFlagCompletionFunc(name); ok {
return
}
if cmd.Flags().Lookup(name) == nil && cmd.PersistentFlags().Lookup(name) == nil {
return
}
_ = cmd.RegisterFlagCompletionFunc(name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault
})
}
// RegisterLocalFilePathFlagCompletions registers local file-path completion for a list of flags.
func RegisterLocalFilePathFlagCompletions(cmd *cobra.Command, names ...string) {
for _, name := range names {
RegisterLocalFilePathFlagCompletion(cmd, name)
}
}
// RegisterLocalFilePathPositionalCompletion registers local file-path completion for a positional argument index.
func RegisterLocalFilePathPositionalCompletion(cmd *cobra.Command, index int) {
if cmd == nil || index < 0 {
return
}
if cmd.ValidArgsFunction != nil {
return
}
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == index {
return nil, cobra.ShellCompDirectiveDefault
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
// RegisterLocalFilePathPositionalAnyCompletion registers local file-path completion for all positional arguments.
func RegisterLocalFilePathPositionalAnyCompletion(cmd *cobra.Command) {
if cmd == nil {
return
}
if cmd.ValidArgsFunction != nil {
return
}
cmd.ValidArgsFunction = func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault
}
}
+4
View File
@@ -1,6 +1,7 @@
package crack
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -61,6 +62,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "wordlist name (blank = filename)")
})
carapace.Gen(wordlistsAddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local wordlist file"))
completers.RegisterLocalFilePathPositionalCompletion(wordlistsAddCmd, 0)
wordlistsCmd.AddCommand(wordlistsAddCmd)
wordlistsRmCmd := &cobra.Command{
@@ -95,6 +97,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "rules name (blank = filename)")
})
carapace.Gen(rulesAddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local rules file"))
completers.RegisterLocalFilePathPositionalCompletion(rulesAddCmd, 0)
rulesCmd.AddCommand(rulesAddCmd)
rulesRmCmd := &cobra.Command{
@@ -130,6 +133,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "hcstat2 name (blank = filename)")
})
carapace.Gen(hcstat2AddCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to local hcstat2 file"))
completers.RegisterLocalFilePathPositionalCompletion(hcstat2AddCmd, 0)
hcstat2Cmd.AddCommand(hcstat2AddCmd)
hcstat2RmCmd := &cobra.Command{
+2
View File
@@ -1,6 +1,7 @@
package creds
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -65,6 +66,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["hash-type"] = CredsHashTypeCompleter(con)
})
carapace.Gen(credsAddFileCmd).PositionalCompletion(carapace.ActionFiles().Tag("credential file"))
completers.RegisterLocalFilePathPositionalCompletion(credsAddFileCmd, 0)
credsAddCmd.AddCommand(credsAddFileCmd)
credsRmCmd := &cobra.Command{
+5
View File
@@ -1,6 +1,7 @@
package cursed
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -75,6 +76,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.BindFlagCompletions(cursedChromeCmd, func(comp *carapace.ActionMap) {
(*comp)["payload"] = carapace.ActionFiles("js").Tag("javascript files")
})
completers.RegisterLocalFilePathFlagCompletion(cursedChromeCmd, "payload")
cursedChromeCmd.Flags().ParseErrorsWhitelist.UnknownFlags = true
carapace.Gen(cursedChromeCmd).PositionalAnyCompletion(carapace.ActionValues().Usage("additional Chrome CLI arguments"))
@@ -99,6 +101,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.BindFlagCompletions(cursedEdgeCmd, func(comp *carapace.ActionMap) {
(*comp)["payload"] = carapace.ActionFiles("js").Tag("javascript files")
})
completers.RegisterLocalFilePathFlagCompletion(cursedEdgeCmd, "payload")
cursedEdgeCmd.Flags().ParseErrorsWhitelist.UnknownFlags = true
carapace.Gen(cursedEdgeCmd).PositionalAnyCompletion(carapace.ActionValues().Usage("additional Edge CLI arguments"))
@@ -130,6 +133,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.Bind("", false, CursedCookiesCmd, func(f *pflag.FlagSet) {
f.StringP("save", "s", "", "save to file")
})
completers.RegisterLocalFilePathFlagCompletion(CursedCookiesCmd, "save")
cursedScreenshotCmd := &cobra.Command{
Use: consts.ScreenshotStr,
@@ -144,6 +148,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.Int64P("quality", "q", 100, "screenshot quality (1 - 100)")
f.StringP("save", "s", "", "save to file")
})
completers.RegisterLocalFilePathFlagCompletion(cursedScreenshotCmd, "save")
return []*cobra.Command{cursedCmd}
}
+2
View File
@@ -1,6 +1,7 @@
package dllhijack
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -37,6 +38,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["file"] = carapace.ActionFiles()
(*comp)["profile"] = generate.ProfileNameCompleter(con)
})
completers.RegisterLocalFilePathFlagCompletions(dllhijackCmd, "reference-file", "file")
carapace.Gen(dllhijackCmd).PositionalCompletion(carapace.ActionValues().Usage("Path to upload the DLL to on the remote system"))
return []*cobra.Command{dllhijackCmd}
+12 -2
View File
@@ -9,6 +9,7 @@ import (
"time"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/bishopfox/sliver/client/command/filesystem"
@@ -17,8 +18,9 @@ import (
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/util/encoders"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/colorprofile"
"github.com/spf13/cobra"
"golang.org/x/term"
"google.golang.org/protobuf/proto"
)
@@ -151,7 +153,15 @@ func EditCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
func runEditor(content, filename string, lexer chroma.Lexer, lexerName string, showLineNumbers bool) (editorResult, error) {
model := newEditorModel(content, filename, lexer, lexerName, showLineNumbers)
program := tea.NewProgram(model, tea.WithAltScreen())
width, height := 80, 24
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
width, height = w, h
}
program := tea.NewProgram(
model,
tea.WithWindowSize(width, height),
tea.WithColorProfile(colorprofile.TrueColor),
)
finalModel, err := program.Run()
if err != nil {
return editorResult{}, err
+51 -31
View File
@@ -6,11 +6,11 @@ import (
"strings"
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/styles"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"golang.org/x/term"
)
@@ -69,9 +69,12 @@ type editorModel struct {
}
var (
cursorStyle = lipgloss.NewStyle().Reverse(true)
statusStyle = lipgloss.NewStyle().Reverse(true)
lineStyle = lipgloss.NewStyle()
lineStyle = lipgloss.NewStyle()
)
const (
ansiReverseOn = "\x1b[7m"
ansiReverseOff = "\x1b[0m"
)
func newEditorModel(content, filename string, lexer chroma.Lexer, syntaxName string, showLineNumbers bool) *editorModel {
@@ -108,28 +111,29 @@ func (m *editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.ensureCursorVisible()
return m, nil
case tea.KeyMsg:
case tea.KeyPressMsg:
if m.clearMessage {
m.message = ""
m.clearMessage = false
}
if msg.Type == tea.KeyCtrlC {
key := msg.Key()
if key.Mod.Contains(tea.ModCtrl) && key.Code == 'c' {
m.action = actionQuit
return m, tea.Quit
}
switch m.mode {
case modeCommand:
return m.handleCommand(msg)
return m.handleCommand(key)
case modeInsert:
return m.handleInsert(msg)
return m.handleInsert(key)
default:
return m.handleNormal(msg)
return m.handleNormal(key)
}
}
return m, nil
}
func (m *editorModel) View() string {
func (m *editorModel) View() tea.View {
if m.width == 0 || m.height == 0 {
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
m.width = w
@@ -152,19 +156,33 @@ func (m *editorModel) View() string {
}
if m.height < 2 {
return b.String()
return editorView(b.String())
}
b.WriteByte('\n')
b.WriteString(statusStyle.Width(m.width).Render(m.statusLine()))
b.WriteString(renderStatusBar(m.width, m.statusLine()))
if m.height < 3 {
return b.String()
return editorView(b.String())
}
b.WriteByte('\n')
b.WriteString(lineStyle.Width(m.width).Render(m.commandLine()))
return b.String()
return editorView(b.String())
}
func renderStatusBar(width int, text string) string {
return ansiReverseOn + lineStyle.Width(width).Render(text) + ansiReverseOff
}
func renderCursorCell(cell string) string {
return ansiReverseOn + cell + ansiReverseOff
}
func editorView(content string) tea.View {
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m *editorModel) result() editorResult {
@@ -176,8 +194,8 @@ func (m *editorModel) result() editorResult {
}
}
func (m *editorModel) handleCommand(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
func (m *editorModel) handleCommand(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.mode = modeNormal
m.command = ""
@@ -196,14 +214,16 @@ func (m *editorModel) handleCommand(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case tea.KeyBackspace:
m.command = popRune(m.command)
case tea.KeyRunes:
m.command += string(msg.Runes)
default:
if key.Text != "" {
m.command += key.Text
}
}
return m, nil
}
func (m *editorModel) handleInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
func (m *editorModel) handleInsert(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.mode = modeNormal
case tea.KeyEnter:
@@ -222,8 +242,8 @@ func (m *editorModel) handleInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveDown()
case tea.KeyTab:
m.insertRune('\t')
case tea.KeyRunes:
for _, r := range msg.Runes {
default:
for _, r := range key.Text {
m.insertRune(r)
}
}
@@ -231,8 +251,8 @@ func (m *editorModel) handleInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *editorModel) handleNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
func (m *editorModel) handleNormal(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.pending = 0
case tea.KeyLeft:
@@ -243,8 +263,8 @@ func (m *editorModel) handleNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveUp()
case tea.KeyDown:
m.moveDown()
case tea.KeyRunes:
for _, r := range msg.Runes {
default:
for _, r := range key.Text {
if m.handleNormalRune(r) {
break
}
@@ -409,9 +429,9 @@ func (m *editorModel) renderLine(index int) string {
if cursor >= start && cursor <= start+contentWidth {
rel := cursor - start
if rel < len(visible) {
lineText = string(visible[:rel]) + cursorStyle.Render(string(visible[rel])) + string(visible[rel+1:])
lineText = string(visible[:rel]) + renderCursorCell(string(visible[rel])) + string(visible[rel+1:])
} else {
lineText = string(visible) + cursorStyle.Render(" ")
lineText = string(visible) + renderCursorCell(" ")
}
}
}
@@ -917,11 +937,11 @@ func (m *editorModel) applyCursor(rendered, highlighted string, line []rune, sta
}
if cursor == len(line) {
left := ansi.Cut(highlighted, start, cursor)
return left + cursorStyle.Render(" ")
return left + renderCursorCell(" ")
}
left := ansi.Cut(highlighted, start, cursor)
right := ansi.Cut(highlighted, cursor+1, end)
return left + cursorStyle.Render(string(line[cursor])) + right
return left + renderCursorCell(string(line[cursor])) + right
}
func (m *editorModel) executeSearch(pattern string) {
+15
View File
@@ -1,6 +1,7 @@
package exec
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/generate"
"github.com/bishopfox/sliver/client/command/help"
@@ -93,6 +94,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
carapace.Gen(executeAssemblyCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to assembly file (required)"))
carapace.Gen(executeAssemblyCmd).PositionalAnyCompletion(carapace.ActionValues().Usage("arguments to pass to the assembly entrypoint (optional)"))
completers.RegisterLocalFilePathPositionalCompletion(executeAssemblyCmd, 0)
executeShellcodeCmd := &cobra.Command{
Use: consts.ExecuteShellcodeStr,
@@ -112,6 +114,14 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.BoolP("shikata-ga-nai", "S", false, "encode shellcode using shikata ga nai prior to execution")
f.StringP("architecture", "A", "amd64", "architecture of the shellcode: 386, amd64 (used with --shikata-ga-nai flag)")
f.Uint32P("iterations", "I", 1, "number of encoding iterations (used with --shikata-ga-nai flag)")
f.Uint32("shellcode-entropy", 1, "Shellcode entropy (Donut: 1=none, 2=random names, 3=random+encrypt) (windows PE input only)")
f.Bool("shellcode-compress", false, "Enable shellcode compression (aPLib) (windows PE input only)")
f.Uint32("shellcode-exitopt", 1, "Shellcode exit option (Donut: 1=exit thread, 2=exit process, 3=block) (windows PE input only)")
f.Uint32("shellcode-bypass", 3, "Shellcode bypass mode (Donut: 1=none, 2=abort, 3=continue) (windows PE input only)")
f.Uint32("shellcode-headers", 1, "Shellcode headers handling (Donut: 1=overwrite, 2=keep) (windows PE input only)")
f.Bool("shellcode-thread", false, "Run unmanaged EXE entrypoint as a new thread (Donut) (windows PE input only)")
f.Bool("shellcode-unicode", false, "Use Unicode command line for unmanaged DLL entrypoints (Donut) (windows PE input only)")
f.Uint32("shellcode-oep", 0, "Override original entry point (OEP) (Donut) (windows PE input only)")
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
})
@@ -119,6 +129,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["shikata-ga-nai"] = carapace.ActionValues("386", "amd64").Tag("shikata-ga-nai architectures")
})
carapace.Gen(executeShellcodeCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to shellcode file (required)"))
completers.RegisterLocalFilePathPositionalCompletion(executeShellcodeCmd, 0)
sideloadCmd := &cobra.Command{
Use: consts.SideloadStr,
@@ -147,6 +158,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
carapace.Gen(sideloadCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to shared library file (required)"))
carapace.Gen(sideloadCmd).PositionalAnyCompletion(carapace.ActionValues().Usage("arguments to pass to the binary (optional)"))
completers.RegisterLocalFilePathPositionalCompletion(sideloadCmd, 0)
spawnDllCmd := &cobra.Command{
Use: consts.SpawnDllStr,
@@ -175,6 +187,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
carapace.Gen(spawnDllCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to DLL file (required)"))
carapace.Gen(spawnDllCmd).PositionalAnyCompletion(carapace.ActionValues().Usage("arguments to pass to the DLL entrypoint (optional)"))
completers.RegisterLocalFilePathPositionalCompletion(spawnDllCmd, 0)
migrateCmd := &cobra.Command{
Use: consts.MigrateStr,
@@ -260,6 +273,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["custom-exe"] = carapace.ActionFiles()
(*comp)["profile"] = generate.ProfileNameCompleter(con)
})
completers.RegisterLocalFilePathFlagCompletion(psExecCmd, "custom-exe")
carapace.Gen(psExecCmd).PositionalCompletion(carapace.ActionValues().Usage("hostname (required)"))
sshCmd := &cobra.Command{
@@ -290,6 +304,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["private-key"] = carapace.ActionFiles()
(*comp)["kerberos-keytab"] = carapace.ActionFiles()
})
completers.RegisterLocalFilePathFlagCompletions(sshCmd, "private-key", "kerberos-keytab")
carapace.Gen(sshCmd).PositionalCompletion(carapace.ActionValues().Usage("remote host to SSH to (required)"))
carapace.Gen(sshCmd).PositionalAnyCompletion(carapace.ActionValues().Usage("command line with arguments"))
+219 -1
View File
@@ -20,27 +20,44 @@ package exec
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"github.com/Binject/debug/pe"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/core"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/sliverpb"
wasmdonut "github.com/sliverarmory/wasm-donut"
"github.com/spf13/cobra"
"golang.org/x/term"
"google.golang.org/protobuf/proto"
)
const (
defaultExecuteShellcodeDonutEntropy = wasmdonut.DonutEntropyNone
defaultExecuteShellcodeDonutCompress = wasmdonut.DonutCompressNone
defaultExecuteShellcodeDonutExitOpt = wasmdonut.DonutExitThread
defaultExecuteShellcodeDonutBypass = wasmdonut.DonutBypassContinue
defaultExecuteShellcodeDonutHeaders = wasmdonut.DonutHeadersOverwrite
imageFileDLLMask = 0x2000
)
// ExecuteShellcodeCmd - Execute shellcode in-memory.
func ExecuteShellcodeCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
session, beacon := con.ActiveTarget.GetInteractive()
if session == nil && beacon == nil {
return
}
targetName := activeTargetName(session, beacon)
targetOS := activeTargetOS(session, beacon)
targetArch := activeTargetArch(session, beacon)
rwxPages, _ := cmd.Flags().GetBool("rwx-pages")
interactive, _ := cmd.Flags().GetBool("interactive")
@@ -60,6 +77,27 @@ func ExecuteShellcodeCmd(cmd *cobra.Command, con *console.SliverClient, args []s
con.PrintErrorf("Cannot use both `--pid` and `--interactive`\n")
return
}
shellcodeConfig, shellcodeFlagsChanged, err := parseExecuteShellcodeFlags(cmd)
if err != nil {
con.PrintErrorf("%s\n", err)
return
}
shouldConvertPE, isDLLHint := shouldConvertExecuteShellcodePE(shellcodePath, shellcodeFlagsChanged)
if shouldConvertPE {
if targetOS != "windows" {
con.PrintErrorf("PE input and --shellcode-* options are only supported for Windows targets in execute-shellcode\n")
return
}
con.PrintInfof("Converting PE input to shellcode ...\n")
shellcodeBin, err = donutShellcodeFromPE(shellcodeBin, targetArch, isDLLHint, shellcodeConfig)
if err != nil {
con.PrintErrorf("Failed to convert PE input to shellcode: %s\n", err)
return
}
}
shikataGaNai, _ := cmd.Flags().GetBool("shikata-ga-nai")
if shikataGaNai {
if !rwxPages {
@@ -96,7 +134,7 @@ func ExecuteShellcodeCmd(cmd *cobra.Command, con *console.SliverClient, args []s
return
}
ctrl := make(chan bool)
msg := fmt.Sprintf("Sending shellcode to %s ...", session.GetName())
msg := fmt.Sprintf("Sending shellcode to %s ...", targetName)
con.SpinUntil(msg, ctrl)
shellcodeTask, err := con.Rpc.Task(context.Background(), &sliverpb.TaskReq{
Data: shellcodeBin,
@@ -135,6 +173,186 @@ func PrintExecuteShellcode(task *sliverpb.Task, con *console.SliverClient) {
}
}
func parseExecuteShellcodeFlags(cmd *cobra.Command) (*clientpb.ShellcodeConfig, bool, error) {
shellcodeEntropy, _ := cmd.Flags().GetUint32("shellcode-entropy")
shellcodeCompressEnabled, _ := cmd.Flags().GetBool("shellcode-compress")
shellcodeExitOpt, _ := cmd.Flags().GetUint32("shellcode-exitopt")
shellcodeBypass, _ := cmd.Flags().GetUint32("shellcode-bypass")
shellcodeHeaders, _ := cmd.Flags().GetUint32("shellcode-headers")
shellcodeThread, _ := cmd.Flags().GetBool("shellcode-thread")
shellcodeUnicode, _ := cmd.Flags().GetBool("shellcode-unicode")
shellcodeOEP, _ := cmd.Flags().GetUint32("shellcode-oep")
anyChanged := cmd.Flags().Changed("shellcode-entropy") ||
cmd.Flags().Changed("shellcode-compress") ||
cmd.Flags().Changed("shellcode-exitopt") ||
cmd.Flags().Changed("shellcode-bypass") ||
cmd.Flags().Changed("shellcode-headers") ||
cmd.Flags().Changed("shellcode-thread") ||
cmd.Flags().Changed("shellcode-unicode") ||
cmd.Flags().Changed("shellcode-oep")
if shellcodeEntropy < 1 || shellcodeEntropy > 3 {
return nil, false, fmt.Errorf("shellcode-entropy must be between 1 and 3")
}
if shellcodeExitOpt < 1 || shellcodeExitOpt > 3 {
return nil, false, fmt.Errorf("shellcode-exitopt must be between 1 and 3")
}
if shellcodeBypass < 1 || shellcodeBypass > 3 {
return nil, false, fmt.Errorf("shellcode-bypass must be between 1 and 3")
}
if shellcodeHeaders < 1 || shellcodeHeaders > 2 {
return nil, false, fmt.Errorf("shellcode-headers must be 1 or 2")
}
shellcodeCompress := uint32(1)
if shellcodeCompressEnabled {
shellcodeCompress = 2
}
return &clientpb.ShellcodeConfig{
Entropy: shellcodeEntropy,
Compress: shellcodeCompress,
ExitOpt: shellcodeExitOpt,
Bypass: shellcodeBypass,
Headers: shellcodeHeaders,
Thread: shellcodeThread,
Unicode: shellcodeUnicode,
OEP: shellcodeOEP,
}, anyChanged, nil
}
func shouldConvertExecuteShellcodePE(shellcodePath string, shellcodeFlagsChanged bool) (bool, bool) {
ext := strings.ToLower(filepath.Ext(shellcodePath))
isPEByExt := ext == ".exe" || ext == ".dll"
return isPEByExt || shellcodeFlagsChanged, ext == ".dll"
}
func activeTargetName(session *clientpb.Session, beacon *clientpb.Beacon) string {
if session != nil {
return session.GetName()
}
if beacon != nil {
return beacon.GetName()
}
return "target"
}
func activeTargetOS(session *clientpb.Session, beacon *clientpb.Beacon) string {
if session != nil {
return strings.ToLower(session.GetOS())
}
if beacon != nil {
return strings.ToLower(beacon.GetOS())
}
return ""
}
func activeTargetArch(session *clientpb.Session, beacon *clientpb.Beacon) string {
if session != nil {
return session.GetArch()
}
if beacon != nil {
return beacon.GetArch()
}
return ""
}
type executeShellcodeDonutOptions struct {
entropy int
compress int
exitOpt int
bypass int
headers int
thread bool
unicode bool
oep uint32
}
func normalizeExecuteShellcodeDonutConfig(config *clientpb.ShellcodeConfig) executeShellcodeDonutOptions {
opts := executeShellcodeDonutOptions{
entropy: defaultExecuteShellcodeDonutEntropy,
compress: defaultExecuteShellcodeDonutCompress,
exitOpt: defaultExecuteShellcodeDonutExitOpt,
bypass: defaultExecuteShellcodeDonutBypass,
headers: defaultExecuteShellcodeDonutHeaders,
}
if config == nil {
return opts
}
if config.Entropy >= 1 && config.Entropy <= 3 {
opts.entropy = int(config.Entropy)
}
if config.Compress >= 1 && config.Compress <= 2 {
opts.compress = int(config.Compress)
}
if config.ExitOpt >= 1 && config.ExitOpt <= 3 {
opts.exitOpt = int(config.ExitOpt)
}
if config.Bypass >= 1 && config.Bypass <= 3 {
opts.bypass = int(config.Bypass)
}
if config.Headers >= 1 && config.Headers <= 2 {
opts.headers = int(config.Headers)
}
opts.thread = config.Thread
opts.unicode = config.Unicode
if config.OEP > 0 {
opts.oep = config.OEP
}
return opts
}
func donutShellcodeFromPE(data []byte, arch string, isDLLHint bool, config *clientpb.ShellcodeConfig) ([]byte, error) {
peFile, err := pe.NewFile(bytes.NewReader(data))
if err != nil {
return nil, err
}
isDLL := isDLLHint || ((peFile.FileHeader.Characteristics & imageFileDLLMask) != 0)
ext := ".exe"
if isDLL {
ext = ".dll"
}
opts := normalizeExecuteShellcodeDonutConfig(config)
result, err := wasmdonut.Generate(context.Background(), data, ext, wasmdonut.GenerateOptions{
Ext: ext,
Arch: getExecuteShellcodeDonutArch(arch),
Bypass: opts.bypass,
Headers: opts.headers,
Entropy: opts.entropy,
Compress: opts.compress,
ExitOpt: opts.exitOpt,
Thread: opts.thread,
Unicode: opts.unicode,
OEP: opts.oep,
})
if err != nil {
return nil, err
}
return addExecuteShellcodeStackCheck(result.Loader), nil
}
func getExecuteShellcodeDonutArch(arch string) int {
donutArch := wasmdonut.DonutArchX84
switch strings.ToLower(arch) {
case "x32", "x86", "386":
donutArch = wasmdonut.DonutArchX86
case "x64", "amd64":
donutArch = wasmdonut.DonutArchX64
case "x84":
donutArch = wasmdonut.DonutArchX84
}
return donutArch
}
func addExecuteShellcodeStackCheck(shellcode []byte) []byte {
stackCheckPrologue := []byte{
0x48, 0x83, 0xE4, 0xF0, // and rsp,0xfffffffffffffff0
0x48, 0x83, 0xC4, 0x08, // add rsp,0x8
}
return append(stackCheckPrologue, shellcode...)
}
func executeInteractive(cmd *cobra.Command, hostProc string, shellcode []byte, rwxPages bool, con *console.SliverClient) {
// Check active session
session := con.ActiveTarget.GetSessionInteractive()
@@ -0,0 +1,100 @@
package exec
import (
"testing"
"github.com/spf13/cobra"
)
func TestParseExecuteShellcodeFlagsDefaults(t *testing.T) {
cmd := newExecuteShellcodeFlagsTestCommand(t)
cfg, changed, err := parseExecuteShellcodeFlags(cmd)
if err != nil {
t.Fatalf("parseExecuteShellcodeFlags returned error: %v", err)
}
if changed {
t.Fatalf("expected changed=false with defaults")
}
if cfg == nil {
t.Fatalf("expected non-nil config")
}
if cfg.Entropy != 1 || cfg.Compress != 1 || cfg.ExitOpt != 1 || cfg.Bypass != 3 || cfg.Headers != 1 {
t.Fatalf("unexpected defaults: %+v", cfg)
}
if cfg.Thread || cfg.Unicode || cfg.OEP != 0 {
t.Fatalf("unexpected default bool/OEP values: %+v", cfg)
}
}
func TestParseExecuteShellcodeFlagsChanged(t *testing.T) {
cmd := newExecuteShellcodeFlagsTestCommand(t)
if err := cmd.Flags().Set("shellcode-entropy", "2"); err != nil {
t.Fatalf("set shellcode-entropy: %v", err)
}
if err := cmd.Flags().Set("shellcode-compress", "true"); err != nil {
t.Fatalf("set shellcode-compress: %v", err)
}
if err := cmd.Flags().Set("shellcode-thread", "true"); err != nil {
t.Fatalf("set shellcode-thread: %v", err)
}
cfg, changed, err := parseExecuteShellcodeFlags(cmd)
if err != nil {
t.Fatalf("parseExecuteShellcodeFlags returned error: %v", err)
}
if !changed {
t.Fatalf("expected changed=true")
}
if cfg.Entropy != 2 || cfg.Compress != 2 || !cfg.Thread {
t.Fatalf("unexpected changed config: %+v", cfg)
}
}
func TestParseExecuteShellcodeFlagsValidation(t *testing.T) {
cmd := newExecuteShellcodeFlagsTestCommand(t)
if err := cmd.Flags().Set("shellcode-bypass", "9"); err != nil {
t.Fatalf("set shellcode-bypass: %v", err)
}
if _, _, err := parseExecuteShellcodeFlags(cmd); err == nil {
t.Fatalf("expected validation error for shellcode-bypass")
}
}
func TestShouldConvertExecuteShellcodePE(t *testing.T) {
testCases := []struct {
name string
path string
flagsChanged bool
wantConvert bool
wantDLL bool
}{
{name: "exe by extension", path: "/tmp/payload.exe", flagsChanged: false, wantConvert: true, wantDLL: false},
{name: "dll by extension", path: "/tmp/payload.dll", flagsChanged: false, wantConvert: true, wantDLL: true},
{name: "raw shellcode", path: "/tmp/payload.bin", flagsChanged: false, wantConvert: false, wantDLL: false},
{name: "flags force conversion", path: "/tmp/payload.bin", flagsChanged: true, wantConvert: true, wantDLL: false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gotConvert, gotDLL := shouldConvertExecuteShellcodePE(tc.path, tc.flagsChanged)
if gotConvert != tc.wantConvert || gotDLL != tc.wantDLL {
t.Fatalf("unexpected result convert=%v dll=%v, expected convert=%v dll=%v", gotConvert, gotDLL, tc.wantConvert, tc.wantDLL)
}
})
}
}
func newExecuteShellcodeFlagsTestCommand(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "execute-shellcode"}
cmd.Flags().Uint32("shellcode-entropy", 1, "")
cmd.Flags().Bool("shellcode-compress", false, "")
cmd.Flags().Uint32("shellcode-exitopt", 1, "")
cmd.Flags().Uint32("shellcode-bypass", 3, "")
cmd.Flags().Uint32("shellcode-headers", 1, "")
cmd.Flags().Bool("shellcode-thread", false, "")
cmd.Flags().Bool("shellcode-unicode", false, "")
cmd.Flags().Uint32("shellcode-oep", 0, "")
return cmd
}
+3
View File
@@ -1,6 +1,7 @@
package extensions
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/command/use"
@@ -33,6 +34,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
extensionCmd.AddCommand(extensionLoadCmd)
carapace.Gen(extensionLoadCmd).PositionalCompletion(carapace.ActionDirectories().Usage("path to the extension directory"))
completers.RegisterLocalFilePathPositionalCompletion(extensionLoadCmd, 0)
extensionInstallCmd := &cobra.Command{
Use: consts.InstallStr,
@@ -45,6 +47,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
}
extensionCmd.AddCommand(extensionInstallCmd)
carapace.Gen(extensionInstallCmd).PositionalCompletion(carapace.ActionFiles().Usage("path to the extension .tar.gz or directory"))
completers.RegisterLocalFilePathPositionalCompletion(extensionInstallCmd, 0)
extensionRmCmd := &cobra.Command{
Use: consts.RmStr,
+3
View File
@@ -1,6 +1,7 @@
package filesystem
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/command/loot"
@@ -174,6 +175,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
carapace.ActionValues().Usage("path to the file or directory to download"),
carapace.ActionFiles().Usage("local path where the downloaded file will be saved (optional)"),
)
completers.RegisterLocalFilePathPositionalCompletion(downloadCmd, 1)
uploadCmd := &cobra.Command{
Use: consts.UploadStr,
@@ -194,6 +196,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
carapace.ActionFiles().Usage("local path to the file to upload"),
carapace.ActionValues().Usage("path to the file or directory to upload to (optional)"),
)
completers.RegisterLocalFilePathPositionalCompletion(uploadCmd, 0)
memfilesCmd := &cobra.Command{
Use: consts.MemfilesStr,
+7 -1
View File
@@ -3,6 +3,7 @@ package generate
import (
"strings"
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
shellcodeencoders "github.com/bishopfox/sliver/client/command/shellcode-encoders"
@@ -94,6 +95,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.BoolP("skip-tests", "s", false, "skip testing the traffic encoder (not recommended)")
})
carapace.Gen(trafficEncodersAddCmd).PositionalCompletion(carapace.ActionFiles("wasm").Tag("wasm files").Usage("local file path (expects .wasm)"))
completers.RegisterLocalFilePathPositionalCompletion(trafficEncodersAddCmd, 0)
trafficEncodersCmd.AddCommand(trafficEncodersAddCmd)
trafficEncodersRmCmd := &cobra.Command{
@@ -126,6 +128,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.BindFlagCompletions(regenerateCmd, func(comp *carapace.ActionMap) {
(*comp)["save"] = carapace.ActionFiles().Tag("directory/file to save implant")
})
completers.RegisterLocalFilePathFlagCompletion(regenerateCmd, "save")
carapace.Gen(regenerateCmd).PositionalCompletion(ImplantBuildNameCompleter(con))
// [ Profiles ] --------------------------------------------------------------
@@ -162,6 +165,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["save"] = carapace.ActionFiles().Tag("directory/file to save implant")
(*comp)["shellcode-encoder"] = shellcodeencoders.ShellcodeEncoderNameCompleter(con)
})
completers.RegisterLocalFilePathFlagCompletion(profilesGenerateCmd, "save")
carapace.Gen(profilesGenerateCmd).PositionalCompletion(ProfileNameCompleter(con))
profilesCmd.AddCommand(profilesGenerateCmd)
@@ -193,6 +197,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.BoolP("prepend-size", "p", false, "Prepend stage size")
f.StringP("compress", "c", "", "Compress stage (zlib, gzip, deflate9 or deflate)")
})
completers.RegisterLocalFilePathFlagCompletion(profilesStageCmd, "save")
carapace.Gen(profilesStageCmd).PositionalCompletion(ProfileNameCompleter(con))
profilesCmd.AddCommand(profilesStageCmd)
@@ -432,12 +437,13 @@ func coreImplantFlagCompletions(cmd *cobra.Command, con *console.SliverClient) {
(*comp)["arch"] = ArchCompleter(con)
(*comp)["strategy"] = carapace.ActionValuesDescribed([]string{"r", "random", "rd", "random domain", "s", "sequential"}...).Tag("C2 strategy")
(*comp)["format"] = FormatCompleter()
(*comp)["save"] = carapace.ActionFiles().Tag("directory/file to save implant")
(*comp)["save"] = completers.LocalFilePathCompleter().Tag("directory/file to save implant")
(*comp)[spoofMetadataFlagName] = carapace.ActionFiles().Tag("optional donor metadata file")
(*comp)["shellcode-encoder"] = shellcodeencoders.ShellcodeEncoderNameCompleter(con)
(*comp)["traffic-encoders"] = TrafficEncodersCompleter(con).UniqueList(",")
(*comp)["c2profile"] = HTTPC2Completer(con)
})
completers.RegisterLocalFilePathFlagCompletions(cmd, "save", "debug-file", spoofMetadataFlagName)
registerImplantTargetFlagCompletions(cmd, con)
}
+51
View File
@@ -0,0 +1,51 @@
package generate
import (
"testing"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/spf13/cobra"
)
func TestGenerateSaveFlagCompletionRegistered(t *testing.T) {
cmds := Commands(&console.SliverClient{})
generateCmd := commandByUse(cmds, consts.GenerateStr)
if generateCmd == nil {
t.Fatalf("missing %q command", consts.GenerateStr)
}
assertFlagHasDefaultFileCompletion(t, generateCmd, "save")
beaconCmd := commandByUse(generateCmd.Commands(), consts.BeaconStr)
if beaconCmd == nil {
t.Fatalf("missing %q subcommand", consts.BeaconStr)
}
assertFlagHasDefaultFileCompletion(t, beaconCmd, "save")
}
func assertFlagHasDefaultFileCompletion(t *testing.T, cmd *cobra.Command, flag string) {
t.Helper()
completionFn, ok := cmd.GetFlagCompletionFunc(flag)
if !ok {
t.Fatalf("%q missing %q flag completion func", cmd.Use, flag)
}
values, directive := completionFn(cmd, nil, "")
if len(values) != 0 {
t.Fatalf("%q %q completion should return no explicit values, got %d", cmd.Use, flag, len(values))
}
if directive != cobra.ShellCompDirectiveDefault {
t.Fatalf("%q %q completion directive mismatch: got=%v want=%v", cmd.Use, flag, directive, cobra.ShellCompDirectiveDefault)
}
}
func commandByUse(cmds []*cobra.Command, use string) *cobra.Command {
for _, cmd := range cmds {
if cmd.Use == use {
return cmd
}
}
return nil
}
+7 -5
View File
@@ -24,11 +24,12 @@ package help
import (
"fmt"
"image/color"
"strings"
"charm.land/lipgloss/v2"
consts "github.com/bishopfox/sliver/client/constants"
"github.com/bishopfox/sliver/client/theme"
"github.com/charmbracelet/lipgloss"
)
const (
@@ -436,8 +437,9 @@ On Windows, escaping is disabled. Instead, '\\' is treated as path separator.`
mountHelp = `[[.Bold]]Command:[[.Normal]] mount
[[.Bold]]About:[[.Normal]] Displays information about mounted drives on the system, including mount point, space metrics, and filesystem.`
executeShellcodeHelp = `[[.Bold]]Command:[[.Normal]] execute-shellcode [local path to raw shellcode]
[[.Bold]]About:[[.Normal]] Executes the given shellcode in the implant's process.
executeShellcodeHelp = `[[.Bold]]Command:[[.Normal]] execute-shellcode [local path to raw shellcode or PE]
[[.Bold]]About:[[.Normal]] Executes the given shellcode in the implant's process. On Windows targets, PE input can be
converted with Donut before execution and tuned with [[.Bold]]--shellcode-*[[.Normal]] flags.
[[.Bold]][[.Underline]]++ Shellcode ++[[.Normal]]
Shellcode files should be binary encoded, you can generate Sliver shellcode files with the generate command:
@@ -1432,14 +1434,14 @@ type helpStyleState struct {
bold bool
underline bool
fgSet bool
fg lipgloss.Color
fg color.Color
}
func (s *helpStyleState) reset() {
s.bold = false
s.underline = false
s.fgSet = false
s.fg = ""
s.fg = nil
}
func (s *helpStyleState) apply(token string) bool {
+54 -44
View File
@@ -5,10 +5,10 @@ import (
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/util"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)
@@ -62,9 +62,12 @@ type editorModel struct {
}
var (
cursorStyle = lipgloss.NewStyle().Reverse(true)
statusStyle = lipgloss.NewStyle().Reverse(true)
lineStyle = lipgloss.NewStyle()
lineStyle = lipgloss.NewStyle()
)
const (
ansiReverseOn = "\x1b[7m"
ansiReverseOff = "\x1b[0m"
)
func modifiedStyle() lipgloss.Style {
@@ -72,11 +75,6 @@ func modifiedStyle() lipgloss.Style {
return console.StyleWarning
}
func modifiedCursorStyle() lipgloss.Style {
// Read from the global console theme so theme.yaml changes are reflected.
return console.StyleWarning.Copy().Reverse(true)
}
func newEditorModel(data []byte, filename string, offset int) *editorModel {
copyData := append([]byte(nil), data...)
model := &editorModel{
@@ -114,30 +112,31 @@ func (m *editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.ensureCursorVisible()
return m, nil
case tea.KeyMsg:
case tea.KeyPressMsg:
if m.clearMessage {
m.message = ""
m.clearMessage = false
}
if msg.Type == tea.KeyCtrlC {
key := msg.Key()
if key.Mod.Contains(tea.ModCtrl) && key.Code == 'c' {
m.action = actionQuit
return m, tea.Quit
}
switch m.mode {
case modeCommand:
return m.handleCommand(msg)
return m.handleCommand(key)
case modeHexEdit:
return m.handleHexEdit(msg)
return m.handleHexEdit(key)
case modeASCIIEdit:
return m.handleASCIIEdit(msg)
return m.handleASCIIEdit(key)
default:
return m.handleNormal(msg)
return m.handleNormal(key)
}
}
return m, nil
}
func (m *editorModel) View() string {
func (m *editorModel) View() tea.View {
if m.width == 0 || m.height == 0 {
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
m.width = w
@@ -160,19 +159,33 @@ func (m *editorModel) View() string {
}
if m.height < 2 {
return b.String()
return hexEditorView(b.String())
}
b.WriteByte('\n')
b.WriteString(statusStyle.Width(m.width).Render(m.statusLine()))
b.WriteString(renderStatusBar(m.width, m.statusLine()))
if m.height < 3 {
return b.String()
return hexEditorView(b.String())
}
b.WriteByte('\n')
b.WriteString(lineStyle.Width(m.width).Render(m.commandLine()))
return b.String()
return hexEditorView(b.String())
}
func renderStatusBar(width int, text string) string {
return ansiReverseOn + lineStyle.Width(width).Render(text) + ansiReverseOff
}
func renderCursorCell(cell string) string {
return ansiReverseOn + cell + ansiReverseOff
}
func hexEditorView(content string) tea.View {
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m *editorModel) result() editorResult {
@@ -185,8 +198,8 @@ func (m *editorModel) result() editorResult {
}
}
func (m *editorModel) handleCommand(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
func (m *editorModel) handleCommand(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.mode = modeNormal
m.command = ""
@@ -201,14 +214,16 @@ func (m *editorModel) handleCommand(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case tea.KeyBackspace:
m.command = popRune(m.command)
case tea.KeyRunes:
m.command += string(msg.Runes)
default:
if key.Text != "" {
m.command += key.Text
}
}
return m, nil
}
func (m *editorModel) handleHexEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
func (m *editorModel) handleHexEdit(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.mode = modeNormal
m.nibbleSet = false
@@ -230,8 +245,8 @@ func (m *editorModel) handleHexEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyDown:
m.moveDown()
m.nibbleSet = false
case tea.KeyRunes:
for _, r := range msg.Runes {
default:
for _, r := range key.Text {
val, ok := hexNibble(r)
if !ok {
continue
@@ -250,8 +265,8 @@ func (m *editorModel) handleHexEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *editorModel) handleASCIIEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
func (m *editorModel) handleASCIIEdit(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.mode = modeNormal
case tea.KeyLeft:
@@ -262,8 +277,8 @@ func (m *editorModel) handleASCIIEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveUp()
case tea.KeyDown:
m.moveDown()
case tea.KeyRunes:
for _, r := range msg.Runes {
default:
for _, r := range key.Text {
if r < 0 || r > 0xFF {
continue
}
@@ -275,8 +290,8 @@ func (m *editorModel) handleASCIIEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *editorModel) handleNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
func (m *editorModel) handleNormal(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.pending = 0
case tea.KeyLeft:
@@ -287,8 +302,8 @@ func (m *editorModel) handleNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveUp()
case tea.KeyDown:
m.moveDown()
case tea.KeyRunes:
for _, r := range msg.Runes {
default:
for _, r := range key.Text {
if m.handleNormalRune(r) {
break
}
@@ -408,13 +423,8 @@ func (m *editorModel) renderLine(lineIndex int) string {
asciiRaw := string(printableByte(byteVal))
isModified := m.isModified(idx)
if idx == m.cursor {
if isModified {
hexCells[i] = modifiedCursorStyle().Render(hexRaw)
asciiCells[i] = modifiedCursorStyle().Render(asciiRaw)
} else {
hexCells[i] = cursorStyle.Render(hexRaw)
asciiCells[i] = cursorStyle.Render(asciiRaw)
}
hexCells[i] = renderCursorCell(hexRaw)
asciiCells[i] = renderCursorCell(asciiRaw)
continue
}
if isModified {
+12 -2
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bishopfox/sliver/client/command/filesystem"
"github.com/bishopfox/sliver/client/console"
"github.com/bishopfox/sliver/client/forms"
@@ -15,8 +16,9 @@ import (
"github.com/bishopfox/sliver/protobuf/sliverpb"
"github.com/bishopfox/sliver/util"
"github.com/bishopfox/sliver/util/encoders"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/colorprofile"
"github.com/spf13/cobra"
"golang.org/x/term"
"google.golang.org/protobuf/proto"
)
@@ -174,7 +176,15 @@ func HexEditCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
func runHexEditor(data []byte, filename string, offset int) (editorResult, error) {
model := newEditorModel(data, filename, offset)
program := tea.NewProgram(model, tea.WithAltScreen())
width, height := 80, 24
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
width, height = w, h
}
program := tea.NewProgram(
model,
tea.WithWindowSize(width, height),
tea.WithColorProfile(colorprofile.TrueColor),
)
finalModel, err := program.Run()
if err != nil {
return editorResult{}, err
+3
View File
@@ -19,6 +19,7 @@ package jobs
*/
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/generate"
"github.com/bishopfox/sliver/client/command/help"
@@ -155,6 +156,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["cert"] = carapace.ActionFiles().Tag("certificate file")
(*comp)["key"] = carapace.ActionFiles().Tag("key file")
})
completers.RegisterLocalFilePathFlagCompletions(httpsCmd, "cert", "key")
registerWebsiteFlagCompletion(httpsCmd, "website", con)
// Staging listeners
@@ -185,6 +187,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["key"] = carapace.ActionFiles().Tag("key file")
(*comp)["compress"] = carapace.ActionValues([]string{"zlib", "gzip", "deflate9", "none"}...).Tag("compression formats")
})
completers.RegisterLocalFilePathFlagCompletions(stageCmd, "cert", "key")
return []*cobra.Command{jobsCmd, mtlsCmd, wgCmd, dnsCmd, httpCmd, httpsCmd, stageCmd}
}
+3
View File
@@ -1,6 +1,7 @@
package loot
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -50,6 +51,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
})
carapace.Gen(lootAddCmd).PositionalCompletion(
carapace.ActionFiles().Tag("local loot file").Usage("The local file path to the loot"))
completers.RegisterLocalFilePathPositionalCompletion(lootAddCmd, 0)
lootRemoteCmd := &cobra.Command{
Use: consts.LootRemoteStr,
@@ -99,6 +101,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["save"] = carapace.ActionFiles().Tag("directory/file to save loot")
(*comp)["filter"] = FileTypeCompleter(con)
})
completers.RegisterLocalFilePathFlagCompletion(lootFetchCmd, "save")
lootRmCmd := &cobra.Command{
Use: consts.RmStr,
+2
View File
@@ -1,6 +1,7 @@
package processes
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -56,6 +57,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.BindFlagCompletions(procdumpCmd, func(comp *carapace.ActionMap) {
(*comp)["save"] = carapace.ActionFiles()
})
completers.RegisterLocalFilePathFlagCompletion(procdumpCmd, "save")
terminateCmd := &cobra.Command{
Use: consts.TerminateStr,
+2
View File
@@ -1,6 +1,7 @@
package registry
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -56,6 +57,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
f.StringP("name", "n", "", "name to assign loot (optional)")
f.StringP("type", "T", "", "force a specific loot type (file/cred) if looting (optional)")
})
completers.RegisterLocalFilePathFlagCompletion(registryReadHiveCmd, "save")
registryReadCmd.AddCommand(registryReadHiveCmd)
registryWriteCmd := &cobra.Command{
+2
View File
@@ -1,6 +1,7 @@
package screenshot
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -31,6 +32,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.BindFlagCompletions(screenshotCmd, func(comp *carapace.ActionMap) {
(*comp)["save"] = carapace.ActionFiles()
})
completers.RegisterLocalFilePathFlagCompletion(screenshotCmd, "save")
return []*cobra.Command{screenshotCmd}
}
+1 -1
View File
@@ -13,6 +13,7 @@ import (
"sync"
"time"
"charm.land/lipgloss/v2"
"github.com/bishopfox/sliver/client/assets"
"github.com/bishopfox/sliver/client/console"
clienttheme "github.com/bishopfox/sliver/client/theme"
@@ -21,7 +22,6 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"google.golang.org/grpc/connectivity"
)
@@ -1,6 +1,7 @@
package shellcodeencoders
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -48,7 +49,9 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["arch"] = ShellcodeEncoderArchCompleter(con)
(*comp)["output"] = carapace.ActionFiles().Tag("output file/directory")
})
completers.RegisterLocalFilePathFlagCompletion(shellcodeEncodersEncodeCmd, "output")
carapace.Gen(shellcodeEncodersEncodeCmd).PositionalCompletion(carapace.ActionFiles().Tag("shellcode file"))
completers.RegisterLocalFilePathPositionalAnyCompletion(shellcodeEncodersEncodeCmd)
shellcodeEncodersCmd.AddCommand(shellcodeEncodersEncodeCmd)
return []*cobra.Command{shellcodeEncodersCmd}
@@ -1,6 +1,7 @@
package sgn
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/rsteube/carapace"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -34,7 +35,9 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["arch"] = carapace.ActionValues("386", "amd64").Tag("shikata-ga-nai architectures")
(*comp)["save"] = carapace.ActionFiles().Tag("directory/file to save shellcode")
})
completers.RegisterLocalFilePathFlagCompletion(shikataGaNaiCmd, "save")
carapace.Gen(shikataGaNaiCmd).PositionalCompletion(carapace.ActionFiles().Tag("shellcode file"))
completers.RegisterLocalFilePathPositionalCompletion(shikataGaNaiCmd, 0)
return []*cobra.Command{shikataGaNaiCmd}
}
+1
View File
@@ -33,6 +33,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.BindFlagCompletions(updateCmd, func(comp *carapace.ActionMap) {
(*comp)["proxy"] = completers.LocalProxyCompleter()
})
completers.RegisterLocalFilePathFlagCompletion(updateCmd, "save")
versionCmd := &cobra.Command{
Use: consts.VersionStr,
+3
View File
@@ -1,6 +1,7 @@
package wasm
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -35,9 +36,11 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["file"] = carapace.ActionFiles()
(*comp)["dir"] = carapace.ActionDirectories()
})
completers.RegisterLocalFilePathFlagCompletion(wasmCmd, "file")
wasmComp := carapace.Gen(wasmCmd)
wasmComp.PositionalCompletion(carapace.ActionFiles().Usage("wasm/wasi module file (.wasm)"))
wasmComp.PositionalAnyCompletion(carapace.ActionValues().Usage("arguments to pass to the wasm module (optional)"))
completers.RegisterLocalFilePathPositionalCompletion(wasmCmd, 0)
wasmLsCmd := &cobra.Command{
Use: consts.LsStr,
+2
View File
@@ -4,6 +4,7 @@ import (
"context"
"strings"
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -127,6 +128,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
(*comp)["content"] = carapace.ActionFiles().Tag("content directory/files")
(*comp)["website"] = WebsiteNameCompleter(con)
})
completers.RegisterLocalFilePathFlagCompletion(websitesContentCmd, "content")
websitesCmd.AddCommand(websitesContentCmd)
websitesContentTypeCmd := &cobra.Command{
+1 -1
View File
@@ -41,7 +41,7 @@ func TestWebsiteRemoveConfirmationPrompt(t *testing.T) {
func TestWebsiteRemoveSuccessMessage(t *testing.T) {
got := websiteRemoveSuccessMessage("test", 2)
want := "removed test and 2 content items"
want := "Removed test and 2 content items"
if got != want {
t.Fatalf("unexpected message: got %q want %q", got, want)
}
+2
View File
@@ -1,6 +1,7 @@
package wireguard
import (
"github.com/bishopfox/sliver/client/command/completers"
"github.com/bishopfox/sliver/client/command/flags"
"github.com/bishopfox/sliver/client/command/help"
"github.com/bishopfox/sliver/client/console"
@@ -31,6 +32,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
flags.BindFlagCompletions(wgConfigCmd, func(comp *carapace.ActionMap) {
(*comp)["save"] = carapace.ActionFiles().Tag("directory/file to save config")
})
completers.RegisterLocalFilePathFlagCompletion(wgConfigCmd, "save")
return []*cobra.Command{wgConfigCmd}
}
+5 -6
View File
@@ -27,12 +27,11 @@ import (
"time"
"github.com/bishopfox/sliver/client/theme"
"github.com/charmbracelet/lipgloss"
)
type promptPalette struct {
Default lipgloss.Color
Mods map[int]lipgloss.Color
Default string
Mods map[int]string
}
type promptColors struct {
@@ -107,11 +106,11 @@ var promptTemplateCache struct {
func toPromptPalette(p theme.Palette) promptPalette {
out := promptPalette{
Default: lipgloss.Color(p.Default),
Mods: map[int]lipgloss.Color{},
Default: p.Default,
Mods: map[int]string{},
}
for k, v := range p.Mods {
out.Mods[k] = lipgloss.Color(v)
out.Mods[k] = v
}
return out
}
+5 -3
View File
@@ -19,8 +19,10 @@ package console
*/
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/bishopfox/sliver/client/theme"
"github.com/charmbracelet/lipgloss"
)
// TextStyle is the shared styling type for the client/server consoles.
@@ -87,13 +89,13 @@ func init() {
// Note: this is intentionally a "global theme" for the console, since most of
// the existing code expects these styles to be package-level values.
func ApplyTheme(t theme.Theme) {
palette := func(p theme.Palette) lipgloss.Color {
palette := func(p theme.Palette) color.Color {
if p.Default != "" {
return lipgloss.Color(p.Default)
}
return lipgloss.Color("#ffffff")
}
paletteMod := func(p theme.Palette, mod int) lipgloss.Color {
paletteMod := func(p theme.Palette, mod int) color.Color {
if mod != 0 {
if v, ok := p.Mods[mod]; ok && v != "" {
return lipgloss.Color(v)
+26 -9
View File
@@ -12,16 +12,17 @@ import (
func HuhTheme() *huh.Theme {
t := huh.ThemeBase()
border := DefaultMod(300)
text := DefaultMod(900)
dim := DefaultMod(500)
faint := DefaultMod(400)
current := Current()
border := paletteModV1(current.Default, 300)
text := paletteModV1(current.Default, 900)
dim := paletteModV1(current.Default, 500)
faint := paletteModV1(current.Default, 400)
primary := Primary()
accent := Secondary()
primary := paletteColorV1(current.Primary)
accent := paletteColorV1(current.Secondary)
success := Success()
danger := Danger()
success := paletteColorV1(current.Success)
danger := paletteColorV1(current.Danger)
// Focused styles.
t.Focused.Base = t.Focused.Base.BorderForeground(border)
@@ -45,7 +46,7 @@ func HuhTheme() *huh.Theme {
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(text).Background(primary)
t.Focused.Next = t.Focused.FocusedButton
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(lipgloss.Color(DefaultMod(100)))
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(paletteModV1(current.Default, 100))
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primary)
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(faint)
@@ -65,3 +66,19 @@ func HuhTheme() *huh.Theme {
return t
}
func paletteColorV1(p Palette) lipgloss.TerminalColor {
if p.Default != "" {
return lipgloss.Color(p.Default)
}
return lipgloss.Color("#ffffff")
}
func paletteModV1(p Palette, mod int) lipgloss.TerminalColor {
if mod != 0 {
if v, ok := p.Mods[mod]; ok && v != "" {
return lipgloss.Color(v)
}
}
return paletteColorV1(p)
}
+16 -15
View File
@@ -2,12 +2,13 @@ package theme
import (
"errors"
"image/color"
"os"
"path/filepath"
"sync"
"charm.land/lipgloss/v2"
"github.com/bishopfox/sliver/client/assets"
"github.com/charmbracelet/lipgloss"
"gopkg.in/yaml.v3"
)
@@ -106,7 +107,7 @@ func normalizePalette(p *Palette) {
}
}
func (t Theme) paletteColor(p Palette) lipgloss.Color {
func (t Theme) paletteColor(p Palette) color.Color {
if p.Default != "" {
return lipgloss.Color(p.Default)
}
@@ -114,7 +115,7 @@ func (t Theme) paletteColor(p Palette) lipgloss.Color {
return lipgloss.Color("#ffffff")
}
func (t Theme) paletteMod(p Palette, mod int) lipgloss.Color {
func (t Theme) paletteMod(p Palette, mod int) color.Color {
if mod != 0 {
if v, ok := p.Mods[mod]; ok && v != "" {
return lipgloss.Color(v)
@@ -124,43 +125,43 @@ func (t Theme) paletteMod(p Palette, mod int) lipgloss.Color {
}
// Primary returns the theme primary color.
func Primary() lipgloss.Color { return Current().paletteColor(Current().Primary) }
func PrimaryMod(mod int) lipgloss.Color {
func Primary() color.Color { return Current().paletteColor(Current().Primary) }
func PrimaryMod(mod int) color.Color {
t := Current()
return t.paletteMod(t.Primary, mod)
}
// Secondary returns the theme secondary color.
func Secondary() lipgloss.Color { return Current().paletteColor(Current().Secondary) }
func SecondaryMod(mod int) lipgloss.Color {
func Secondary() color.Color { return Current().paletteColor(Current().Secondary) }
func SecondaryMod(mod int) color.Color {
t := Current()
return t.paletteMod(t.Secondary, mod)
}
// Default returns the theme default/neutral color.
func Default() lipgloss.Color { return Current().paletteColor(Current().Default) }
func DefaultMod(mod int) lipgloss.Color {
func Default() color.Color { return Current().paletteColor(Current().Default) }
func DefaultMod(mod int) color.Color {
t := Current()
return t.paletteMod(t.Default, mod)
}
// Success returns the theme success color.
func Success() lipgloss.Color { return Current().paletteColor(Current().Success) }
func SuccessMod(mod int) lipgloss.Color {
func Success() color.Color { return Current().paletteColor(Current().Success) }
func SuccessMod(mod int) color.Color {
t := Current()
return t.paletteMod(t.Success, mod)
}
// Warning returns the theme warning color.
func Warning() lipgloss.Color { return Current().paletteColor(Current().Warning) }
func WarningMod(mod int) lipgloss.Color {
func Warning() color.Color { return Current().paletteColor(Current().Warning) }
func WarningMod(mod int) color.Color {
t := Current()
return t.paletteMod(t.Warning, mod)
}
// Danger returns the theme danger color.
func Danger() lipgloss.Color { return Current().paletteColor(Current().Danger) }
func DangerMod(mod int) lipgloss.Color {
func Danger() color.Color { return Current().paletteColor(Current().Danger) }
func DangerMod(mod int) color.Color {
t := Current()
return t.paletteMod(t.Danger, mod)
}
+202 -214
View File
@@ -1,6 +1,5 @@
#!/bin/bash
# Sliver Implant Framework
# Copyright (C) 2019 Bishop Fox
@@ -17,11 +16,14 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
set -u
set -o pipefail
SKIP_GENERATE=0
for arg in "$@"; do
if [ "$arg" = "--skip-generate" ]; then
SKIP_GENERATE=1
fi
if [ "$arg" = "--skip-generate" ]; then
SKIP_GENERATE=1
fi
done
echo "----------------------------------------------------------------"
@@ -30,222 +32,208 @@ echo " Recommended to only run on 16+ CPU cores and 32Gb+ RAM"
echo "----------------------------------------------------------------"
TAGS=osusergo,netgo,go_sqlite
TEST_TMP_ROOT="$(mktemp -d)"
if [ ! -d "$TEST_TMP_ROOT" ]; then
echo "Failed to create temporary test root directory" >&2
exit 1
fi
cleanup() {
local status=$?
trap - EXIT INT TERM
# Some long-running tests (notably in server/generate) may still have child
# build processes touching files under the temp root when cleanup starts.
if command -v pkill >/dev/null 2>&1; then
pkill -TERM -P $$ 2>/dev/null || true
pkill -TERM -f "$TEST_TMP_ROOT" 2>/dev/null || true
sleep 1
pkill -KILL -P $$ 2>/dev/null || true
pkill -KILL -f "$TEST_TMP_ROOT" 2>/dev/null || true
fi
wait 2>/dev/null || true
for _ in 1 2 3 4 5; do
rm -rf "$TEST_TMP_ROOT" 2>/dev/null || true
if [ ! -e "$TEST_TMP_ROOT" ]; then
break
fi
sleep 1
done
if [ -e "$TEST_TMP_ROOT" ]; then
echo "WARNING: Failed to fully clean temp dir: $TEST_TMP_ROOT" >&2
fi
exit "$status"
}
trap cleanup EXIT INT TERM
export SLIVER_ROOT_DIR="$TEST_TMP_ROOT/sliver"
export SLIVER_CLIENT_DIR="$TEST_TMP_ROOT/sliver-client"
export SLIVER_CLIENT_ROOT_DIR="$SLIVER_CLIENT_DIR" # legacy compatibility
export HOME="$TEST_TMP_ROOT/home"
export XDG_CONFIG_HOME="$TEST_TMP_ROOT/xdg-config"
export XDG_CACHE_HOME="$TEST_TMP_ROOT/xdg-cache"
export XDG_DATA_HOME="$TEST_TMP_ROOT/xdg-data"
export TMPDIR="$TEST_TMP_ROOT/tmp"
export TMP="$TMPDIR"
export TEMP="$TMPDIR"
export GOCACHE="$TEST_TMP_ROOT/go-cache"
export GOTMPDIR="$TEST_TMP_ROOT/go-tmp"
export GOFLAGS="${GOFLAGS:-} -mod=vendor"
mkdir -p \
"$SLIVER_ROOT_DIR" \
"$SLIVER_CLIENT_DIR" \
"$HOME" \
"$XDG_CONFIG_HOME" \
"$XDG_CACHE_HOME" \
"$XDG_DATA_HOME" \
"$TMPDIR" \
"$GOCACHE" \
"$GOTMPDIR"
export PATH="$SLIVER_ROOT_DIR/go/bin:$PATH"
echo "Using isolated temp directories:"
echo " SLIVER_ROOT_DIR=$SLIVER_ROOT_DIR"
echo " SLIVER_CLIENT_DIR=$SLIVER_CLIENT_DIR"
print_failure_logs() {
cat "$SLIVER_ROOT_DIR/logs/sliver.log" 2>/dev/null || true
}
run_test_cmd() {
local name="$1"
shift
echo
echo "==> $name"
if "$@"; then
return 0
fi
print_failure_logs
return 1
}
should_skip_package() {
local pkg="$1"
local tags="${2:-}"
local go_list_cmd=(go list -e -f '{{if .Error}}{{.Error}}{{end}}')
if [ -n "$tags" ]; then
go_list_cmd+=("-tags=$tags")
fi
go_list_cmd+=("$pkg")
local go_list_error
go_list_error="$("${go_list_cmd[@]}" 2>/dev/null || true)"
if [[ "$go_list_error" == *"build constraints exclude all Go files"* ]]; then
echo
echo "==> Skipping $pkg (unsupported on current platform)"
return 0
fi
return 1
}
collect_test_dirs() {
if command -v rg >/dev/null 2>&1; then
rg --files -g '*_test.go'
else
find client implant server util -type f -name '*_test.go' -print
fi
}
unpack_server_assets() {
if [ -x "./sliver-server" ]; then
if ./sliver-server unpack --force; then
return 0
fi
echo "sliver-server unpack failed, falling back to go run ./server unpack --force"
fi
go run -tags=server,go_sqlite ./server unpack --force
}
run_test_cmd "unpack server assets" unpack_server_assets || exit 1
TEST_DIRS=()
while IFS= read -r test_dir; do
TEST_DIRS+=("$test_dir")
done < <(collect_test_dirs | xargs -n1 dirname | sort -u)
CLIENT_TEST_PKGS=()
IMPLANT_TEST_PKGS=()
SERVER_UTIL_TEST_PKGS=()
for test_dir in "${TEST_DIRS[@]}"; do
pkg="./$test_dir"
case "$pkg" in
./server/c2 | ./server/generate)
# handled separately below
;;
./client/*)
CLIENT_TEST_PKGS+=("$pkg")
;;
./implant/*)
IMPLANT_TEST_PKGS+=("$pkg")
;;
./server/* | ./util*)
SERVER_UTIL_TEST_PKGS+=("$pkg")
;;
esac
done
## Client
# client / command / alias
if go test -tags=client,$TAGS ./client/command/alias ; then
:
else
exit 1
fi
# client / command / extensions
if go test -tags=client,$TAGS ./client/command/extensions ; then
:
else
exit 1
fi
# client / credentials
if go test -tags=client,$TAGS ./client/credentials ; then
:
else
exit 1
fi
## Util
# util
if go test -tags=server,$TAGS ./util ; then
:
else
exit 1
fi
# util / encoders
if go test -tags=server,$TAGS ./util/encoders/basex ; then
:
else
exit 1
fi
if go test -tags=server,$TAGS ./util/encoders ; then
:
else
exit 1
fi
# util / minisign
if go test -tags=server,$TAGS ./util/minisign ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
for pkg in "${CLIENT_TEST_PKGS[@]}"; do
if should_skip_package "$pkg" "client,$TAGS"; then
continue
fi
run_test_cmd "$pkg" go test -tags="client,$TAGS" "$pkg" || exit 1
done
## Implant
for pkg in "${IMPLANT_TEST_PKGS[@]}"; do
if should_skip_package "$pkg"; then
continue
fi
run_test_cmd "$pkg" go test "$pkg" || exit 1
done
# implant / sliver / extension
if go test ./implant/sliver/extension ; then
:
else
exit 1
fi
## Server + Util
for pkg in "${SERVER_UTIL_TEST_PKGS[@]}"; do
if should_skip_package "$pkg" "server,$TAGS"; then
continue
fi
case "$pkg" in
./server/assets/traffic-encoders | ./server/encoders)
run_test_cmd "$pkg" go test -timeout 10m -tags="server,$TAGS" "$pkg" || exit 1
;;
./server/rpc)
run_test_cmd "$pkg" go test -vet=off -timeout 30m -tags="server,$TAGS" "$pkg" || exit 1
;;
*)
run_test_cmd "$pkg" go test -tags="server,$TAGS" "$pkg" || exit 1
;;
esac
done
# implant / sliver / transports / dnsclient
if go test ./implant/sliver/transports/dnsclient ; then
:
else
exit 1
fi
## Server c2 (unit + e2e)
run_test_cmd "./server/c2" go test -tags="server,$TAGS" ./server/c2 || exit 1
run_test_cmd "./server/c2 (e2e yamux)" go test -tags="server,$TAGS,sliver_e2e" ./server/c2 -run 'Test(MTLS|WG)Yamux_' -count=1 || exit 1
run_test_cmd "./server/c2 (e2e dns)" go test -tags="server,$TAGS,sliver_e2e" ./server/c2 -run 'TestDNS_' -count=1 || exit 1
# implant / sliver / transports / wireguard
if go test ./implant/sliver/transports/wireguard ; then
:
else
exit 1
fi
## Server
# server / assets / traffic encoders
if go test -timeout 10m -tags=server,$TAGS ./server/assets/traffic-encoders ; then
:
else
exit 1
fi
# server / encoders
if go test -timeout 10m -tags=server,$TAGS ./server/encoders ; then
:
else
exit 1
fi
# server / website
if go test -tags=server,$TAGS ./server/website ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / loot
if go test -tags=server,$TAGS ./server/loot ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / certs
if go test -tags=server,$TAGS ./server/certs ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / cryptography
if go test -tags=server,$TAGS ./server/cryptography ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / encoders / shellcode / sgn
if go test -tags=server,$TAGS ./server/encoders/shellcode/sgn ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / encoders / shellcode / arm64
if go test -tags=server,$TAGS ./server/encoders/shellcode/arm64 ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / gogo / goname
if go test -tags=server,$TAGS ./server/gogo/goname ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / gogo
if go test -tags=server,$TAGS ./server/gogo ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / c2
if go test -tags=server,$TAGS ./server/c2 ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / c2 / e2e (mtls + wg + yamux)
SLIVER_ROOT_DIR_E2E="$(mktemp -d)"
if SLIVER_ROOT_DIR="$SLIVER_ROOT_DIR_E2E" go test -tags=server,$TAGS,sliver_e2e ./server/c2 -run 'Test(MTLS|WG)Yamux_' -count=1 ; then
:
else
cat "$SLIVER_ROOT_DIR_E2E/logs/sliver.log" 2>/dev/null || true
rm -rf "$SLIVER_ROOT_DIR_E2E"
exit 1
fi
# server / c2 / e2e (dns)
if SLIVER_ROOT_DIR="$SLIVER_ROOT_DIR_E2E" go test -tags=server,$TAGS,sliver_e2e ./server/c2 -run 'TestDNS_' -count=1 ; then
:
else
cat "$SLIVER_ROOT_DIR_E2E/logs/sliver.log" 2>/dev/null || true
rm -rf "$SLIVER_ROOT_DIR_E2E"
exit 1
fi
rm -rf "$SLIVER_ROOT_DIR_E2E"
# server / configs
if go test -tags=server,$TAGS ./server/configs ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / console
if go test -tags=server,$TAGS ./server/console ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
# server / generate
## Server generate
if [ "$SKIP_GENERATE" -eq 0 ]; then
export GOPROXY=off
if go test -tags=server,$TAGS ./server/generate -timeout 6h ; then
:
else
cat ~/.sliver/logs/sliver.log
exit 1
fi
export GOPROXY=off
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
# parallelism conservative by default to avoid OOM kills on smaller systems.
GENERATE_GOMAXPROCS="${SLIVER_GENERATE_GOMAXPROCS:-2}"
GENERATE_GO_P="${SLIVER_GENERATE_GO_P:-1}"
GENERATE_TEST_PARALLEL="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
run_test_cmd "./server/generate" \
env GOMAXPROCS="$GENERATE_GOMAXPROCS" \
go test -timeout 6h -p "$GENERATE_GO_P" -parallel "$GENERATE_TEST_PARALLEL" -tags="server,$TAGS" ./server/generate || exit 1
else
echo "Skipping ./server/generate tests (--skip-generate)"
fi
# server / rpc / spoof metadata
if go test -vet=off -tags=server,$TAGS ./server/rpc -run '^TestGenerateSpoofMetadataAppliesPETimestampOverBufnet$' -count=1 -timeout 30m ; then
:
else
cat ~/.sliver/logs/sliver.log 2>/dev/null || true
exit 1
echo
echo "Skipping ./server/generate tests (--skip-generate)"
fi
+20 -13
View File
@@ -9,6 +9,7 @@ replace github.com/reeflective/readline => github.com/moloch--/readline v0.0.0-2
replace github.com/reeflective/console => github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701
require (
charm.land/lipgloss/v2 v2.0.0
filippo.io/age v1.3.1
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a
@@ -17,13 +18,14 @@ require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/alecthomas/chroma v0.10.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3
github.com/charmbracelet/lipgloss v1.1.0
github.com/cheggaaa/pb/v3 v3.1.7
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.1
github.com/fatih/color v1.18.0
github.com/glebarez/sqlite v1.11.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang/protobuf v1.5.4
@@ -47,9 +49,9 @@ require (
github.com/reeflective/readline v1.1.4
github.com/rsteube/carapace v0.50.2
github.com/sirupsen/logrus v1.9.3
github.com/sliverarmory/beignet v0.0.2
github.com/sliverarmory/malasada v0.0.2
github.com/sliverarmory/wasm-donut v0.0.5
github.com/sliverarmory/beignet v0.0.3
github.com/sliverarmory/malasada v0.0.3
github.com/sliverarmory/wasm-donut v0.0.3
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
@@ -122,6 +124,10 @@ require (
github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/cschomburg/go-pushbullet v0.0.0-20171206132031-67759df45fbb // indirect
@@ -131,6 +137,7 @@ require (
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -204,6 +211,8 @@ require (
)
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.0
cloud.google.com/go/compute/metadata v0.9.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Binject/shellcode v0.0.0-20191101084904-a8a90e7d4563 // indirect
@@ -222,15 +231,13 @@ require (
github.com/carapace-sh/carapace v1.11.0
github.com/carapace-sh/carapace-shlex v1.1.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/x/ansi v0.11.6
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-iptables v0.8.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@@ -266,13 +273,13 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mark3labs/mcp-go v0.43.2
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
+34 -24
View File
@@ -4,6 +4,12 @@ c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAw
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
@@ -144,8 +150,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
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.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -176,30 +182,36 @@ github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/x
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 h1:KUeWGoKnmyrLaDIa0smE6pK5eFMZWNIxPGweQR12iLg=
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cheggaaa/pb/v3 v3.1.7 h1:2FsIW307kt7A/rz/ZI2lvPO+v3wKazzE4K/0LtTWsOI=
@@ -216,8 +228,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
@@ -506,8 +520,8 @@ github.com/lesnuages/snitch v0.6.0 h1:vOao32MdYDHYDcTAq2CKszDh6Dn8Bv1Gmgc8uVh193
github.com/lesnuages/snitch v0.6.0/go.mod h1:RfXlAVJYL7gUoZ9VMMon4LqrPiAjC8x62Ntbse4a4s0=
github.com/line/line-bot-sdk-go v7.8.0+incompatible h1:Uf9/OxV0zCVfqyvwZPH8CrdiHXXmMRa/L91G3btQblQ=
github.com/line/line-bot-sdk-go v7.8.0+incompatible/go.mod h1:0RjLjJEAU/3GIcHkC3av6O4jInAbt25nnZVmOFUgDBg=
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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
@@ -530,8 +544,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
@@ -663,14 +677,12 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/sliverarmory/beignet v0.0.2 h1:kUpztl/yEKp/U3FFulguC7r+qKjmBIg0AwLKJnZpDVA=
github.com/sliverarmory/beignet v0.0.2/go.mod h1:xXfTKBJqFBY/pakRFwJ+5+oGiSlg2CiCVUg/76kW1H4=
github.com/sliverarmory/malasada v0.0.2 h1:X2+7dNGPWfm4ijsr/rMvZlIMalskdCVpBZUYs50RHIA=
github.com/sliverarmory/malasada v0.0.2/go.mod h1:F96IgTOoX72FU995F+Jip0ni8Z5sXiYOwfmlRGobJkQ=
github.com/sliverarmory/beignet v0.0.3 h1:ssR6AK9lirVulHZhQMyMmVAi0FcFwWxlh9LwKCJQ4X0=
github.com/sliverarmory/beignet v0.0.3/go.mod h1:xXfTKBJqFBY/pakRFwJ+5+oGiSlg2CiCVUg/76kW1H4=
github.com/sliverarmory/malasada v0.0.3 h1:b2KG2NhQVfBIIrbP8rGJwmdh7o8LH6NKYKH1DF4No1U=
github.com/sliverarmory/malasada v0.0.3/go.mod h1:F96IgTOoX72FU995F+Jip0ni8Z5sXiYOwfmlRGobJkQ=
github.com/sliverarmory/wasm-donut v0.0.3 h1:sldbfAsz1YziULlhDKpU3DY/+/dAfv5gl26qSqHIpzE=
github.com/sliverarmory/wasm-donut v0.0.3/go.mod h1:D6OLBZBfFm2Tk857vqGqFS9uUi4CXqnqHxKqtONllVk=
github.com/sliverarmory/wasm-donut v0.0.5 h1:QWnkVy1mTgkmVg0bvJ8X9xqGGX2YI0pbpqt5uA3qw84=
github.com/sliverarmory/wasm-donut v0.0.5/go.mod h1:D6OLBZBfFm2Tk857vqGqFS9uUi4CXqnqHxKqtONllVk=
github.com/sony/sonyflake v1.0.0 h1:MpU6Ro7tfXwgn2l5eluf9xQvQJDROTBImNCfRXn/YeM=
github.com/sony/sonyflake v1.0.0/go.mod h1:Jv3cfhf/UFtolOTTRd3q4Nl6ENqM+KfyZ5PseKfZGF4=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@@ -926,8 +938,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+2 -6
View File
@@ -278,9 +278,7 @@ func TestProvider_ParseEnvProxy(t *testing.T) {
if tt.expectError == nil {
a.Nil(err)
} else {
if a.NotNil(err) {
a.Equal(tt.expectError.Error(), err.Error())
}
assertErrEqualIgnoreQuotes(t, tt.expectError, err)
}
})
}
@@ -325,9 +323,7 @@ func TestProvider_ConfigProviderParseEnvURL(t *testing.T) {
if tt.expectError == nil {
a.Nil(err)
} else {
if a.NotNil(err) {
a.Equal(tt.expectError.Error(), err.Error())
}
assertErrEqualIgnoreQuotes(t, tt.expectError, err)
}
})
}
+1 -3
View File
@@ -116,9 +116,7 @@ func TestNewProxy(t *testing.T) {
if tt.expectErr == nil {
a.Nil(err)
} else {
if a.NotNil(err) {
a.Equal(tt.expectErr.Error(), err.Error())
}
assertErrEqualIgnoreQuotes(t, tt.expectErr, err)
}
})
}
+22
View File
@@ -0,0 +1,22 @@
package proxy
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func assertErrEqualIgnoreQuotes(t *testing.T, expected error, actual error) {
t.Helper()
if expected == nil {
assert.NoError(t, actual)
return
}
if assert.Error(t, actual) {
expectedMsg := strings.ReplaceAll(expected.Error(), "\"", "")
actualMsg := strings.ReplaceAll(actual.Error(), "\"", "")
assert.Equal(t, expectedMsg, actualMsg)
}
}
+2 -6
View File
@@ -67,9 +67,7 @@ func TestParseURL(t *testing.T) {
if tt.expectError == nil {
a.Nil(err)
} else {
if a.NotNil(err) {
a.Equal(tt.expectError.Error(), err.Error())
}
assertErrEqualIgnoreQuotes(t, tt.expectError, err)
}
})
}
@@ -191,9 +189,7 @@ func TestSplitHostPort(t *testing.T) {
if tt.expectErr == nil {
a.Nil(err)
} else {
if a.NotNil(err) {
a.Equal(tt.expectErr.Error(), err.Error())
}
assertErrEqualIgnoreQuotes(t, tt.expectErr, err)
}
})
}
+16 -1
View File
@@ -100,6 +100,8 @@ func StartDNSListener(bindIface string, lport uint16, domains []string, canaries
server.startInitGC()
dnsLog.Infof("Starting DNS listener for %v (canaries: %v) ...", domains, canaries)
dns.HandleFunc(".", func(writer dns.ResponseWriter, req *dns.Msg) {
defer recoverAndLogPanic(dnsLog.Errorf, "dns request callback")
started := time.Now()
server.HandleDNSRequest(domains, canaries, writer, req)
dnsLog.Debugf("DNS server took %s", time.Since(started))
@@ -226,6 +228,8 @@ func (s *DNSSession) IncomingPendingEnvelope(msgID uint32, size uint32) *Pending
// ForwardCompletedEnvelope - Reassembles and forwards envelopes to core
func (s *DNSSession) ForwardCompletedEnvelope(msgID uint32, pending *PendingEnvelope) {
defer recoverAndLogPanic(dnsLog.Errorf, "dns ForwardCompletedEnvelope")
dnsLog.Debugf("[dns] dns session id: %d, msg id: %d completed message", s.ID, msgID)
s.incomingMutex.Lock()
delete(s.incomingEnvelopes, msgID) // Remove pending message
@@ -373,6 +377,8 @@ func (s *SliverDNSServer) startInitGC() {
s.gcStop = make(chan struct{})
s.gcDone = make(chan struct{})
go func() {
defer recoverAndLogPanic(dnsLog.Errorf, "dns init gc")
ticker := time.NewTicker(defaultPendingDNSInitGCInterval)
defer ticker.Stop()
defer close(s.gcDone)
@@ -420,6 +426,8 @@ func (s *SliverDNSServer) gcPendingInits() {
// ---------------------------
// Handles all DNS queries, first we determine if the query is C2 or a canary
func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writer dns.ResponseWriter, req *dns.Msg) {
defer recoverAndLogPanic(dnsLog.Errorf, "dns HandleDNSRequest")
if req == nil {
dnsLog.Info("req can not be nil")
return
@@ -682,6 +690,8 @@ func (s *SliverDNSServer) handleDNSSessionInit(domain string, msg *dnspb.DNSMess
}
go func() {
defer recoverAndLogPanic(dnsLog.Errorf, "dns session send loop")
dnsLog.Debugf("[dns] starting implant conn send loop")
for envelope := range dnsSession.ImplantConn.Send {
dnsSession.StageOutgoingEnvelope(envelope)
@@ -830,7 +840,10 @@ func (s *SliverDNSServer) handleDataFromImplant(domain string, msg *dnspb.DNSMes
pending := dnsSession.IncomingPendingEnvelope(msg.ID, msg.Size)
complete := pending.Insert(msg)
if complete {
go dnsSession.ForwardCompletedEnvelope(msg.ID, pending)
go func() {
defer recoverAndLogPanic(dnsLog.Errorf, "dns completed envelope dispatch")
dnsSession.ForwardCompletedEnvelope(msg.ID, pending)
}()
}
resp := new(dns.Msg)
@@ -981,6 +994,8 @@ func (s *SliverDNSServer) handleNOP(domain string, msg *dnspb.DNSMessage, checks
func (s *SliverDNSServer) handleCanary(req *dns.Msg) *dns.Msg {
// Don't block, return error as fast as possible
go func() {
defer recoverAndLogPanic(dnsLog.Errorf, "dns handleCanary")
reqDomain := strings.ToLower(req.Question[0].Name)
if !strings.HasSuffix(reqDomain, ".") {
reqDomain += "." // Ensure we have the FQDN
+12
View File
@@ -126,6 +126,8 @@ func StartMutualTLSListener(bindIface string, port uint16) (net.Listener, error)
}
func acceptSliverConnections(ln net.Listener) {
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls acceptSliverConnections")
for {
conn, err := ln.Accept()
if err != nil {
@@ -140,6 +142,8 @@ func acceptSliverConnections(ln net.Listener) {
}
func handleSliverConnection(conn net.Conn) {
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls handleSliverConnection")
mtlsLog.Infof("Accepted incoming connection: %s", conn.RemoteAddr())
implantConn := core.NewImplantConnection(consts.MtlsStr, conn.RemoteAddr().String())
@@ -174,6 +178,8 @@ func (c *mtlsBufferedConn) Read(p []byte) (int, error) {
}
func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnection) {
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls handleSliverConnectionYamux")
session, err := yamux.Server(conn, nil)
if err != nil {
mtlsLog.Errorf("Failed to initialize yamux session: %v", err)
@@ -196,6 +202,7 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
go func() {
defer closeDone()
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls yamux accept loop")
for {
stream, err := session.Accept()
if err != nil {
@@ -216,6 +223,7 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
defer func() {
<-streamSem
}()
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls yamux stream")
defer stream.Close()
envelope, err := socketReadEnvelope(stream)
@@ -239,6 +247,8 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
if handler, ok := handlers[envelope.Type]; ok {
mtlsLog.Debugf("Received new mtls message type %d, data: %s", envelope.Type, envelope.Data)
go func(envelope *sliverpb.Envelope) {
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls message handler")
respEnvelope := handler(implantConn, envelope.Data)
if respEnvelope != nil {
implantConn.Send <- respEnvelope
@@ -251,6 +261,7 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
go func() {
defer closeDone()
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls yamux sender loop")
for {
select {
case envelope := <-implantConn.Send:
@@ -264,6 +275,7 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
defer func() {
<-sendSem
}()
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls yamux sender stream")
stream, err := session.Open()
if err != nil {
+9
View File
@@ -0,0 +1,9 @@
package c2
import "runtime/debug"
func recoverAndLogPanic(logf func(string, ...interface{}), scope string) {
if recovered := recover(); recovered != nil {
logf("Recovered panic in %s: %v\n%s", scope, recovered, string(debug.Stack()))
}
}
+16
View File
@@ -168,6 +168,8 @@ func StartWGListener(port uint16, netstackPort uint16, keyExchangeListenPort uin
// acceptKeyExchangeConnection - accept connections to key exchange socket
func acceptKeyExchangeConnection(ln net.Listener) {
defer recoverAndLogPanic(wgLog.Errorf, "wireguard acceptKeyExchangeConnection")
wgLog.Printf("Polling for connections to key exchange listener")
for {
conn, err := ln.Accept()
@@ -188,6 +190,8 @@ func acceptKeyExchangeConnection(ln net.Listener) {
// Generate new implant wg keys. Generate new unique IP for implant.
// Write all retrieved data to socket connection.
func handleKeyExchangeConnection(conn net.Conn) {
defer recoverAndLogPanic(wgLog.Errorf, "wireguard handleKeyExchangeConnection")
wgLog.Infof("Handling connection to key exchange listener")
defer conn.Close()
@@ -213,6 +217,8 @@ func handleKeyExchangeConnection(conn net.Conn) {
}
func acceptWGSliverConnections(ln net.Listener) {
defer recoverAndLogPanic(wgLog.Errorf, "wireguard acceptWGSliverConnections")
for {
conn, err := ln.Accept()
if err != nil {
@@ -227,6 +233,8 @@ func acceptWGSliverConnections(ln net.Listener) {
}
func handleWGSliverConnection(conn net.Conn) {
defer recoverAndLogPanic(wgLog.Errorf, "wireguard handleWGSliverConnection")
wgLog.Infof("Accepted incoming connection: %s", conn.RemoteAddr())
implantConn := core.NewImplantConnection("wg", conn.RemoteAddr().String())
@@ -262,6 +270,8 @@ func (c *wgBufferedConn) Read(p []byte) (int, error) {
}
func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnection) {
defer recoverAndLogPanic(wgLog.Errorf, "wireguard handleWGSliverConnectionYamux")
session, err := yamux.Server(conn, nil)
if err != nil {
wgLog.Errorf("Failed to initialize yamux session: %v", err)
@@ -284,6 +294,7 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
go func() {
defer closeDone()
defer recoverAndLogPanic(wgLog.Errorf, "wireguard yamux accept loop")
for {
stream, err := session.Accept()
if err != nil {
@@ -304,6 +315,7 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
defer func() {
<-streamSem
}()
defer recoverAndLogPanic(wgLog.Errorf, "wireguard yamux stream")
defer stream.Close()
envelope, err := socketWGReadEnvelope(stream)
@@ -326,6 +338,8 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
if handler, ok := handlers[envelope.Type]; ok {
go func(envelope *sliverpb.Envelope) {
defer recoverAndLogPanic(wgLog.Errorf, "wireguard message handler")
respEnvelope := handler(implantConn, envelope.Data)
if respEnvelope != nil {
implantConn.Send <- respEnvelope
@@ -338,6 +352,7 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
go func() {
defer closeDone()
defer recoverAndLogPanic(wgLog.Errorf, "wireguard yamux sender loop")
for {
select {
case envelope := <-implantConn.Send:
@@ -351,6 +366,7 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
defer func() {
<-sendSem
}()
defer recoverAndLogPanic(wgLog.Errorf, "wireguard yamux sender stream")
stream, err := session.Open()
if err != nil {
+9 -2
View File
@@ -27,6 +27,7 @@ import (
"github.com/bishopfox/sliver/server/log"
"github.com/gofrs/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
@@ -66,7 +67,10 @@ func hostsSessionCallback(session *Session) {
}
if errors.Is(err, gorm.ErrRecordNotFound) {
coreLog.Infof("Session %v is from a new host", session.ID)
err := dbSession.Create(&models.Host{
err := dbSession.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "host_uuid"}},
DoNothing: true,
}).Create(&models.Host{
HostUUID: uuid.FromStringOrNil(session.UUID),
Hostname: session.Hostname,
OSVersion: session.OS,
@@ -94,7 +98,10 @@ func hostsBeaconCallback(beacon *models.Beacon) {
}
if errors.Is(err, gorm.ErrRecordNotFound) {
coreLog.Infof("Beacon %v is from a new host", beacon.ID)
err := dbSession.Create(&models.Host{
err := dbSession.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "host_uuid"}},
DoNothing: true,
}).Create(&models.Host{
HostUUID: uuid.FromStringOrNil(beacon.UUID.String()),
Hostname: beacon.Hostname,
OSVersion: beacon.OS,
+8 -1
View File
@@ -39,6 +39,11 @@ import (
// Backdoor - Inject a sliver payload in a file on the remote system
func (rpc *Server) Backdoor(ctx context.Context, req *clientpb.BackdoorReq) (*clientpb.Backdoor, error) {
resp := &clientpb.Backdoor{}
if req == nil || req.Request == nil {
return resp, ErrMissingRequestField
}
var (
name string
err error
@@ -55,8 +60,10 @@ func (rpc *Server) Backdoor(ctx context.Context, req *clientpb.BackdoorReq) (*cl
name = req.Name
}
resp := &clientpb.Backdoor{}
session := core.Sessions.Get(req.Request.SessionID)
if session == nil {
return nil, ErrInvalidSessionID
}
if session.OS != "windows" {
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("%s is currently not supported", session.OS))
}
+3
View File
@@ -52,6 +52,9 @@ func (rpc *Server) HijackDLL(ctx context.Context, req *clientpb.DllHijackReq) (*
resp := &clientpb.DllHijack{
Response: &commonpb.Response{},
}
if req == nil || req.Request == nil {
return resp, ErrMissingRequestField
}
session := core.Sessions.Get(req.Request.SessionID)
if session == nil {
return resp, ErrInvalidSessionID
+5
View File
@@ -37,6 +37,11 @@ func (rpc *Server) Kill(ctx context.Context, kill *sliverpb.KillReq) (*commonpb.
beacon *models.Beacon
err error
)
if kill == nil || kill.Request == nil {
return &commonpb.Empty{}, ErrMissingRequestField
}
session := core.Sessions.Get(kill.Request.SessionID)
if session == nil {
beacon, err = db.BeaconByID(kill.Request.BeaconID)
+10
View File
@@ -38,6 +38,11 @@ var (
func (rpc *Server) Msf(ctx context.Context, req *clientpb.MSFReq) (*sliverpb.Task, error) {
var os string
var arch string
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
if !req.Request.Async {
session := core.Sessions.Get(req.Request.SessionID)
if session == nil {
@@ -90,6 +95,11 @@ func (rpc *Server) Msf(ctx context.Context, req *clientpb.MSFReq) (*sliverpb.Tas
func (rpc *Server) MsfRemote(ctx context.Context, req *clientpb.MSFRemoteReq) (*sliverpb.Task, error) {
var os string
var arch string
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
if !req.Request.Async {
session := core.Sessions.Get(req.Request.SessionID)
if session == nil {
+4
View File
@@ -28,6 +28,10 @@ import (
// Portfwd - Open an in-band port forward
func (s *Server) Portfwd(ctx context.Context, req *sliverpb.PortfwdReq) (*sliverpb.Portfwd, error) {
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
session := core.Sessions.Get(req.Request.SessionID)
if session == nil {
return nil, ErrInvalidSessionID
+8
View File
@@ -83,6 +83,10 @@ func (rpc *Server) GetSystem(ctx context.Context, req *clientpb.GetSystemReq) (*
name string
)
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
session := core.Sessions.Get(req.Request.SessionID)
if session == nil {
return nil, ErrInvalidSessionID
@@ -159,6 +163,10 @@ func (rpc *Server) MakeToken(ctx context.Context, req *sliverpb.MakeTokenReq) (*
// GetPrivs - gRPC interface to get privilege information from the current process
func (rpc *Server) GetPrivs(ctx context.Context, req *sliverpb.GetPrivsReq) (*sliverpb.GetPrivs, error) {
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
sessionID := req.Request.SessionID
resp := &sliverpb.GetPrivs{Response: &commonpb.Response{}}
+4
View File
@@ -33,6 +33,10 @@ const maxNameLength = 32
// Reconfigure - Reconfigure a beacon/session
func (rpc *Server) Reconfigure(ctx context.Context, req *sliverpb.ReconfigureReq) (*sliverpb.Reconfigure, error) {
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
// We have to preserve these because GenericHandler clears them in req.Request
sessionID := req.Request.SessionID
beaconID := req.Request.BeaconID
+8
View File
@@ -38,6 +38,10 @@ func (rpc *Server) GetRportFwdListeners(ctx context.Context, req *sliverpb.Rport
// StartRportfwdListener - Instruct the implant to start a reverse port forward
func (rpc *Server) StartRportFwdListener(ctx context.Context, req *sliverpb.RportFwdStartListenerReq) (*sliverpb.RportFwdListener, error) {
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
resp := &sliverpb.RportFwdListener{Response: &commonpb.Response{}}
err := rpc.GenericHandler(req, resp)
if err != nil {
@@ -55,6 +59,10 @@ func (rpc *Server) StartRportFwdListener(ctx context.Context, req *sliverpb.Rpor
// StopRportfwdListener - Instruct the implant to stop a reverse port forward
func (rpc *Server) StopRportFwdListener(ctx context.Context, req *sliverpb.RportFwdStopListenerReq) (*sliverpb.RportFwdListener, error) {
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
resp := &sliverpb.RportFwdListener{Response: &commonpb.Response{}}
err := rpc.GenericHandler(req, resp)
if err != nil {
+8
View File
@@ -49,6 +49,10 @@ func (rpc *Server) GetSessions(ctx context.Context, _ *commonpb.Empty) (*clientp
// KillSession - Kill a session
func (rpc *Server) KillSession(ctx context.Context, kill *sliverpb.KillReq) (*commonpb.Empty, error) {
if kill == nil || kill.Request == nil {
return &commonpb.Empty{}, ErrMissingRequestField
}
session := core.Sessions.Get(kill.Request.SessionID)
if session == nil {
return &commonpb.Empty{}, ErrInvalidSessionID
@@ -75,6 +79,10 @@ func (rpc *Server) OpenSession(ctx context.Context, openSession *sliverpb.OpenSe
// CloseSession - Close an interactive session, but do not kill the remote process
func (rpc *Server) CloseSession(ctx context.Context, closeSession *sliverpb.CloseSession) (*commonpb.Empty, error) {
if closeSession == nil || closeSession.Request == nil {
return nil, ErrMissingRequestField
}
session := core.Sessions.Get(closeSession.Request.SessionID)
if session == nil {
return nil, ErrInvalidSessionID
+8
View File
@@ -36,6 +36,10 @@ var (
// Shell - Open an interactive shell
func (rpc *Server) Shell(ctx context.Context, req *sliverpb.ShellReq) (*sliverpb.Shell, error) {
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
session := core.Sessions.Get(req.Request.SessionID)
if session == nil {
return nil, ErrInvalidSessionID
@@ -62,6 +66,10 @@ func (rpc *Server) Shell(ctx context.Context, req *sliverpb.ShellReq) (*sliverpb
// ShellResize - Resize a shell PTY (best effort)
func (rpc *Server) ShellResize(ctx context.Context, req *sliverpb.ShellResizeReq) (*commonpb.Empty, error) {
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
session := core.Sessions.Get(req.Request.SessionID)
if session == nil {
return nil, ErrInvalidSessionID
+11
View File
@@ -155,6 +155,13 @@ func (s *Server) SocksProxy(stream rpcpb.SliverRPC_SocksProxyServer) error {
rpcLog.Warnf("Error on stream recv %s", err)
return rpcError(err)
}
if fromClient == nil {
continue
}
if fromClient.Request == nil {
rpcLog.Warnf("Ignoring SOCKS frame with missing request metadata for tunnel %d", fromClient.TunnelID)
continue
}
tunnelMutex.Lock()
activeTunnels[fromClient.TunnelID] = true // Mark this as an active tunnel
@@ -348,6 +355,10 @@ func (s *Server) SocksProxy(stream rpcpb.SliverRPC_SocksProxyServer) error {
if !ok {
break
}
if fromClient.Request == nil {
rpcLog.Error("Missing request metadata for SOCKS tunnel")
break
}
session := core.Sessions.Get(fromClient.Request.SessionID)
if session == nil {
+19
View File
@@ -67,6 +67,10 @@ func (rpc *Server) Migrate(ctx context.Context, req *clientpb.MigrateReq) (*sliv
var dbBeacon *models.Beacon
var err error
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
if !req.Request.Async { // is this a session?
session = core.Sessions.Get(req.Request.SessionID)
if session == nil {
@@ -197,6 +201,11 @@ func (rpc *Server) ExecuteAssembly(ctx context.Context, req *sliverpb.ExecuteAss
var beacon *clientpb.Beacon
var dbBeacon *models.Beacon
var err error
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
if !req.Request.Async {
session = core.Sessions.Get(req.Request.SessionID)
if session == nil {
@@ -267,6 +276,11 @@ func (rpc *Server) Sideload(ctx context.Context, req *sliverpb.SideloadReq) (*sl
err error
arch string
)
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
if !req.Request.Async {
session = core.Sessions.Get(req.Request.SessionID)
if session == nil {
@@ -315,6 +329,11 @@ func (rpc *Server) SpawnDll(ctx context.Context, req *sliverpb.InvokeSpawnDllReq
var beacon *clientpb.Beacon
var dbBeacon *models.Beacon
var err error
if req == nil || req.Request == nil {
return nil, ErrMissingRequestField
}
if !req.Request.Async {
session = core.Sessions.Get(req.Request.SessionID)
if session == nil {
+2 -2
View File
@@ -1,9 +1,9 @@
package assets
const (
goVersion = "1.25.6"
goVersion = "1.25.7"
garbleVersion = "1.25.7"
zigVersion = "0.15.2"
garbleVersion = "1.25.6"
zigSourceParam = "source=sliver"
zigMinisignPublicKey = "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U"
@@ -0,0 +1 @@
*.golden -text
+23
View File
@@ -0,0 +1,23 @@
.DS_Store
.envrc
examples/fullscreen/fullscreen
examples/help/help
examples/http/http
examples/list-default/list-default
examples/list-fancy/list-fancy
examples/list-simple/list-simple
examples/mouse/mouse
examples/pager/pager
examples/progress-download/color_vortex.blend
examples/progress-download/progress-download
examples/simple/simple
examples/spinner/spinner
examples/textinput/textinput
examples/textinputs/textinputs
examples/views/views
tutorials/basics/basics
tutorials/commands/commands
.idea
coverage.txt
dist/
+44
View File
@@ -0,0 +1,44 @@
version: "2"
run:
tests: false
linters:
enable:
- bodyclose
- exhaustive
- goconst
- godot
- gomoddirectives
- goprintffuncname
- gosec
- misspell
- nakedret
- nestif
- nilerr
- noctx
- nolintlint
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
- tparallel
- unconvert
- unparam
- whitespace
- wrapcheck
exclusions:
rules:
- text: '(slog|log)\.\w+'
linters:
- noctx
generated: lax
presets:
- common-false-positives
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofumpt
- goimports
exclusions:
generated: lax
+5
View File
@@ -0,0 +1,5 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
version: 2
includes:
- from_url:
url: charmbracelet/meta/main/goreleaser-lib.yaml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-2026 Charmbracelet, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+402
View File
@@ -0,0 +1,402 @@
# Bubble Tea
<p>
<img src="https://github.com/user-attachments/assets/ad408275-8799-488f-9303-441e7f869535" width="350"><br>
<a href="https://github.com/charmbracelet/bubbletea/releases"><img src="https://img.shields.io/github/release/charmbracelet/bubbletea.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc"><img src="https://godoc.org/charm.land/bubbletea/v2?status.svg" alt="GoDoc"></a>
<a href="https://github.com/charmbracelet/bubbletea/actions"><img src="https://github.com/charmbracelet/bubbletea/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status"></a>
</p>
The fun, functional and stateful way to build terminal apps. A Go framework
based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and
complex terminal applications, either inline, full-window, or a mix of both.
<p>
<img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif" width="100%" alt="Bubble Tea Example">
</p>
Bubble Tea is in use in production and includes a number of features and
performance optimizations we’ve added along the way. Among those is
a high-performance cell-based renderer, built-in color downsampling,
declarative views, high-fidelity keyboard and mouse handling, native clipboard
support, and more.
To get started, see the tutorial below, the [examples][examples], the
[docs][docs], and some common [resources](#libraries-we-use-with-bubble-tea).
> [!TIP]
>
> Upgrading from v1? Check out the [upgrade guide](./UPGRADE_GUIDE_V2.md), or
> point your LLM at it and let it go to town.
## By the way
Be sure to check out [Bubbles][bubbles], a library of common UI components for Bubble Tea.
<p>
<a href="https://github.com/charmbracelet/bubbles"><img src="https://github.com/user-attachments/assets/b6dc4638-b67a-4bfa-88d0-a8e8833c3ac9" width="172" alt="Bubbles Badge"></a>&nbsp;&nbsp;
<a href="https://github.com/charmbracelet/bubbles"><img src="https://stuff.charm.sh/bubbles-examples/textinput.gif" width="400" alt="Text Input Example from Bubbles"></a>
</p>
---
## Tutorial
Bubble Tea is based on the functional design paradigms of [The Elm
Architecture][elm], which happens to work nicely with Go. It's a delightful way
to build applications.
This tutorial assumes you have a working knowledge of Go.
By the way, the non-annotated source code for this program is available
[on GitHub][tut-source].
[elm]: https://guide.elm-lang.org/architecture/
[tut-source]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/basics
### Enough! Let's get to it.
For this tutorial, we're making a shopping list.
To start we'll define our package and import some libraries. Our only external
import will be the Bubble Tea library, which we'll call `tea` for short.
```go
package main
// These imports will be used later on the tutorial. If you save the file
// now, Go might complain they are unused, but that's fine.
// You may also need to run `go mod tidy` to download bubbletea and its
// dependencies.
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
```
Bubble Tea programs are comprised of a **model** that describes the application
state and three simple methods on that model:
- **Init**, a function that returns an initial command for the application to run.
- **Update**, a function that handles incoming events and updates the model accordingly.
- **View**, a function that renders the UI based on the data in the model.
### The Model
So let's start by defining our model which will store our application's state.
It can be any type, but a `struct` usually makes the most sense.
```go
type model struct {
choices []string // items on the to-do list
cursor int // which to-do list item our cursor is pointing at
selected map[int]struct{} // which to-do items are selected
}
```
## Initialization
Next, we’ll define our application’s initial state. `Init` can return a `Cmd`
that could perform some initial I/O. For now, we don’t need to do any I/O, so
for the command, we’ll just return `nil`, which translates to “no command.”
```go
func initialModel() model {
return model{
// Our to-do list is a grocery list
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
// A map which indicates which choices are selected. We're using
// the map like a mathematical set. The keys refer to the indexes
// of the `choices` slice, above.
selected: make(map[int]struct{}),
}
}
```
After that, we’ll define our application’s initial state in the `Init` method. `Init`
can return a `Cmd` that could perform some initial I/O. For now, we don't need
to do any I/O, so for the command, we'll just return `nil`, which translates to
"no command."
```go
func (m model) Init() tea.Cmd {
// Just return `nil`, which means "no I/O right now, please."
return nil
}
```
### The Update Method
Next up is the update method. The update function is called when “things
happen.” Its job is to look at what has happened and return an updated model in
response. It can also return a `Cmd` to make more things happen, but for now
don't worry about that part.
In our case, when a user presses the down arrow, `Update`’s job is to notice
that the down arrow was pressed and move the cursor accordingly (or not).
The “something happened” comes in the form of a `Msg`, which can be any type.
Messages are the result of some I/O that took place, such as a keypress, timer
tick, or a response from a server.
We usually figure out which type of `Msg` we received with a type switch, but
you could also use a type assertion.
For now, we'll just deal with `tea.KeyPressMsg` messages, which are
automatically sent to the update function when keys are pressed.
```go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
// Is it a key press?
case tea.KeyPressMsg:
// Cool, what was the actual key pressed?
switch msg.String() {
// These keys should exit the program.
case "ctrl+c", "q":
return m, tea.Quit
// The "up" and "k" keys move the cursor up
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
// The "down" and "j" keys move the cursor down
case "down", "j":
if m.cursor < len(m.choices)-1 {
m.cursor++
}
// The "enter" key and the space bar toggle the selected state
// for the item that the cursor is pointing at.
case "enter", "space":
_, ok := m.selected[m.cursor]
if ok {
delete(m.selected, m.cursor)
} else {
m.selected[m.cursor] = struct{}{}
}
}
}
// Return the updated model to the Bubble Tea runtime for processing.
// Note that we're not returning a command.
return m, nil
}
```
You may have noticed that <kbd>ctrl+c</kbd> and <kbd>q</kbd> above return
a `tea.Quit` command with the model. That’s a special command which instructs
the Bubble Tea runtime to quit, exiting the program.
### The View Method
At last, it’s time to render our UI. Of all the methods, the view is the
simplest. We look at the model in its current state and use it to build a
`tea.View`. The view declares our UI content and, optionally, terminal features
like alt screen mode, mouse tracking, cursor position, and more.
Because the view describes the entire UI of your application, you don’t have to
worry about redrawing logic and stuff like that. Bubble Tea takes care of it
for you.
```go
func (m model) View() tea.View {
// The header
s := "What should we buy at the market?\n\n"
// Iterate over our choices
for i, choice := range m.choices {
// Is the cursor pointing at this choice?
cursor := " " // no cursor
if m.cursor == i {
cursor = ">" // cursor!
}
// Is this choice selected?
checked := " " // not selected
if _, ok := m.selected[i]; ok {
checked = "x" // selected!
}
// Render the row
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
}
// The footer
s += "\nPress q to quit.\n"
// Send the UI for rendering
return tea.NewView(s)
}
```
### All Together Now
The last step is to simply run our program. We pass our initial model to
`tea.NewProgram` and let it rip:
```go
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
```
## What’s Next?
This tutorial covers the basics of building an interactive terminal UI, but
in the real world you'll also need to perform I/O. To learn about that have a
look at the [Command Tutorial][cmd]. It's pretty simple.
There are also several [Bubble Tea examples][examples] available and, of course,
there are [Go Docs][docs].
[cmd]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/commands/
[examples]: https://github.com/charmbracelet/bubbletea/tree/main/examples
[docs]: https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc
## Debugging
### Debugging with Delve
Since Bubble Tea apps assume control of stdin and stdout, you’ll need to run
delve in headless mode and then connect to it:
```bash
# Start the debugger
$ dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 .
API server listening at: 127.0.0.1:43000
# Connect to it from another terminal
$ dlv connect 127.0.0.1:43000
```
If you do not explicitly supply the `--listen` flag, the port used will vary
per run, so passing this in makes the debugger easier to use from a script
or your IDE of choice.
Additionally, we pass in `--api-version=2` because delve defaults to version 1
for backwards compatibility reasons. However, delve recommends using version 2
for all new development and some clients may no longer work with version 1.
For more information, see the [Delve documentation](https://github.com/go-delve/delve/tree/master/Documentation/api).
### Logging Stuff
You can’t really log to stdout with Bubble Tea because your TUI is busy
occupying that! You can, however, log to a file by including something like
the following prior to starting your Bubble Tea program:
```go
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
defer f.Close()
}
```
To see what’s being logged in real time, run `tail -f debug.log` while you run
your program in another window.
## Libraries we use with Bubble Tea
- [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on
- [Lip Gloss][lipgloss]: Style, format and layout tools for terminal applications
- [Harmonica][harmonica]: A spring animation library for smooth, natural motion
- [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components
- [ntcharts][ntcharts]: A terminal charting library built for Bubble Tea and [Lip Gloss][lipgloss]
[bubbles]: https://github.com/charmbracelet/bubbles
[lipgloss]: https://github.com/charmbracelet/lipgloss
[harmonica]: https://github.com/charmbracelet/harmonica
[bubblezone]: https://github.com/lrstanley/bubblezone
[ntcharts]: https://github.com/NimbleMarkets/ntcharts
## Bubble Tea in the Wild
There are over [18,000 applications](https://github.com/charmbracelet/bubbletea/network/dependents) built with Bubble Tea! Here are a handful of ’em.
### Staff favourites
- [chezmoi](https://github.com/twpayne/chezmoi): securely manage your dotfiles across multiple machines
- [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal
- [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues
- [Tetrigo](https://github.com/Broderick-Westrope/tetrigo): Tetris in the terminal
- [Signls](https://github.com/emprcl/signls): a generative midi sequencer designed for composition and live performance
- [Superfile](https://github.com/yorukot/superfile): a super file manager
### In Industry
- Microsoft Azure – [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform
- Daytona – [Daytona](https://github.com/daytonaio/daytona): an AI infrastructure platform
- Cockroach Labs – [CockroachDB](https://github.com/cockroachdb/cockroach): a cloud-native, high-availability distributed SQL database
- Truffle Security Co. – [Trufflehog](https://github.com/trufflesecurity/trufflehog): find leaked credentials
- NVIDIA – [container-canary](https://github.com/NVIDIA/container-canary): a container validator
- AWS – [eks-node-viewer](https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an EKS cluster
- MinIO – [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client
- Ubuntu – [Authd](https://github.com/ubuntu/authd): an authentication daemon for cloud-based identity providers
### Charm stuff
- [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash
- [Huh?](https://github.com/charmbracelet/huh): an interactive prompt and form toolkit
- [Mods](https://github.com/charmbracelet/mods): AI on the CLI, built for pipelines
- [Wishlist](https://github.com/charmbracelet/wishlist): an SSH directory (and bastion!)
### There’s so much more where that came from
For more applications built with Bubble Tea see [Charm & Friends][community].
Is there something cool you made with Bubble Tea you want to share? [PRs][community] are
welcome!
## Contributing
See [contributing][contribute].
[contribute]: https://github.com/charmbracelet/bubbletea/contribute
## Feedback
We’d love to hear your thoughts on this project. Feel free to drop us a note!
- [Twitter](https://twitter.com/charmcli)
- [The Fediverse](https://mastodon.social/@charmcli)
- [Discord](https://charm.sh/chat)
## Acknowledgments
Bubble Tea is based on the paradigms of [The Elm Architecture][elm] by Evan
Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk. It’s
inspired by the many great [_Zeichenorientierte Benutzerschnittstellen_][zb]
of days past.
[elm]: https://guide.elm-lang.org/architecture/
[gotea]: https://github.com/tj/go-tea
[zb]: https://de.wikipedia.org/wiki/Zeichenorientierte_Benutzerschnittstelle
[community]: https://github.com/charm-and-friends/charm-in-the-wild
## License
[MIT](https://github.com/charmbracelet/bubbletea/raw/main/LICENSE)
---
Part of [Charm](https://charm.sh).
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
+14
View File
@@ -0,0 +1,14 @@
# https://taskfile.dev
version: '3'
tasks:
lint:
desc: Run lint
cmds:
- golangci-lint run
test:
desc: Run tests
cmds:
- go test ./... {{.CLI_ARGS}}
+573
View File
@@ -0,0 +1,573 @@
# Bubble Tea v2 Upgrade Guide
This guide covers everything you need to change when upgrading from Bubble Tea v1 to v2. For a tour of all the exciting new features, check out the [What's New](WHATS_NEW_V2.md) doc.
> [!NOTE]
> We don't take API changes lightly and strive to make the upgrade process as simple as possible. If something feels way off, let us know.
## Migration Checklist
Here's the short version — a checklist you can follow top to bottom. Each item links to the relevant section below.
- [ ] [Update import paths](#import-paths)
- [ ] [Change `View() string` to `View() tea.View`](#view-returns-a-teaview-now)
- [ ] [Replace `tea.KeyMsg` with `tea.KeyPressMsg`](#key-messages)
- [ ] [Update key fields: `msg.Type` / `msg.Runes` / `msg.Alt`](#key-messages)
- [ ] [Replace `case " ":` with `case "space":`](#key-messages)
- [ ] [Update mouse message usage](#mouse-messages)
- [ ] [Rename mouse button constants](#mouse-messages)
- [ ] [Remove old program options → use View fields](#removed-program-options)
- [ ] [Remove imperative commands → use View fields](#removed-commands)
- [ ] [Remove old program methods](#removed-program-methods)
- [ ] [Rename `tea.WindowSize()` → `tea.RequestWindowSize`](#renamed-apis)
- [ ] [Replace `tea.Sequentially(...)` → `tea.Sequence(...)`](#renamed-apis)
## Import Paths
The module path changed to a vanity domain. Lip Gloss moved too.
```go
// Before
import tea "github.com/charmbracelet/bubbletea"
import "github.com/charmbracelet/lipgloss"
// After
import tea "charm.land/bubbletea/v2"
import "charm.land/lipgloss/v2"
```
## The Big Idea: Declarative Views
The single biggest change in v2 is the shift from **imperative commands** to **declarative View fields**. In v1, you'd use program options like `tea.WithAltScreen()` and commands like `tea.EnterAltScreen` to toggle terminal features on and off. In v2, you just set fields on the `tea.View` struct in your `View()` method and Bubble Tea handles the rest.
This means: no more startup option flags, no more toggle commands, no more fighting over state. Just declare what you want and Bubble Tea will make it so.
```go
// v1: imperative — scattered across NewProgram, Init, and Update
p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion())
// v2: declarative — everything lives in View()
func (m model) View() tea.View {
v := tea.NewView("Hello!")
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
```
Keep this in mind as you go through the rest of the guide — most of the "removed" things simply moved into View fields.
## View Returns a `tea.View` Now
The `View()` method no longer returns a `string`. It returns a `tea.View` struct.
```go
// Before:
func (m model) View() string {
return "Hello, world!"
}
// After:
func (m model) View() tea.View {
return tea.NewView("Hello, world!")
}
```
You can also use the longer form if you need to set additional fields:
```go
func (m model) View() tea.View {
var v tea.View
v.SetContent("Hello, world!")
v.AltScreen = true
return v
}
```
The `tea.View` struct has fields for everything that used to be controlled by options and commands:
| View Field | What It Does |
|---|---|
| `Content` | The rendered string (set via `SetContent()` or `NewView()`) |
| `AltScreen` | Enter/exit the alternate screen buffer |
| `MouseMode` | `MouseModeNone`, `MouseModeCellMotion`, or `MouseModeAllMotion` |
| `ReportFocus` | Enable focus/blur event reporting |
| `DisableBracketedPasteMode` | Disable bracketed paste |
| `WindowTitle` | Set the terminal window title |
| `Cursor` | Control cursor position, shape, color, and blink |
| `ForegroundColor` | Set the terminal foreground color |
| `BackgroundColor` | Set the terminal background color |
| `ProgressBar` | Show a native terminal progress bar |
| `KeyboardEnhancements` | Request keyboard enhancement features |
| `OnMouse` | Intercept mouse messages based on view content |
## Key Messages
Key messages got a major overhaul. Here's the quick rundown:
### `tea.KeyMsg` is now an interface
In v1, `tea.KeyMsg` was a struct you'd match on for key presses. In v2, it's an **interface** that covers both key presses and releases. For most code, you want `tea.KeyPressMsg`:
```go
// Before:
case tea.KeyMsg:
switch msg.String() {
case "q":
return m, tea.Quit
}
// After:
case tea.KeyPressMsg:
switch msg.String() {
case "q":
return m, tea.Quit
}
```
If you want to handle both presses and releases, use `tea.KeyMsg` and type-switch inside:
```go
case tea.KeyMsg:
switch key := msg.(type) {
case tea.KeyPressMsg:
// key press
case tea.KeyReleaseMsg:
// key release
}
```
### Key fields changed
| v1 | v2 | Notes |
|---|---|---|
| `msg.Type` | `msg.Code` | A `rune` — can be `tea.KeyEnter`, `'a'`, etc. |
| `msg.Runes` | `msg.Text` | Now a `string`, not `[]rune` |
| `msg.Alt` | `msg.Mod` | `msg.Mod.Contains(tea.ModAlt)` for alt, etc. |
| `tea.KeyRune` | — | Check `len(msg.Text) > 0` instead |
| `tea.KeyCtrlC` | — | Use `msg.String() == "ctrl+c"` or check `msg.Code` + `msg.Mod` |
### Space bar changed
Space bar now returns `"space"` instead of `" "` when using `msg.String()`:
```go
// Before:
case " ":
// After:
case "space":
```
`key.Code` is still `' '` and `key.Text` is still `" "`, but `String()` returns `"space"`.
### Ctrl+key matching
```go
// Before:
case tea.KeyCtrlC:
// ctrl+c
// After (option A — string matching):
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c":
// ctrl+c
}
// After (option B — field matching):
case tea.KeyPressMsg:
if msg.Code == 'c' && msg.Mod == tea.ModCtrl {
// ctrl+c
}
```
### New Key fields
These are new in v2 and don't have v1 equivalents:
- **`key.ShiftedCode`** — the shifted key code (e.g., `'B'` when pressing shift+b)
- **`key.BaseCode`** — the key on a US PC-101 layout (handy for international keyboards)
- **`key.IsRepeat`** — whether the key is auto-repeating (Kitty protocol / Windows Console only)
- **`key.Keystroke()`** — like `String()` but always includes modifier info
## Paste Messages
Paste events no longer come in as `tea.KeyMsg` with a `Paste` flag. They're now their own message types:
```go
// Before:
case tea.KeyMsg:
if msg.Paste {
m.text += string(msg.Runes)
}
// After:
case tea.PasteMsg:
m.text += msg.Content
case tea.PasteStartMsg:
// paste started
case tea.PasteEndMsg:
// paste ended
```
## Mouse Messages
### `tea.MouseMsg` is now an interface
In v1, `tea.MouseMsg` was a struct with `X`, `Y`, `Button`, etc. In v2, it's an **interface**. You get the coordinates by calling `msg.Mouse()`:
```go
// Before:
case tea.MouseMsg:
x, y := msg.X, msg.Y
// After:
case tea.MouseMsg:
mouse := msg.Mouse()
x, y := mouse.X, mouse.Y
```
### Mouse events are split by type
Instead of checking `msg.Action`, match on specific message types:
```go
// Before:
case tea.MouseMsg:
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
// left click
}
// After:
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
// left click
}
case tea.MouseReleaseMsg:
// release
case tea.MouseWheelMsg:
// scroll
case tea.MouseMotionMsg:
// movement
```
### Button constants renamed
| v1 | v2 |
|---|---|
| `tea.MouseButtonLeft` | `tea.MouseLeft` |
| `tea.MouseButtonRight` | `tea.MouseRight` |
| `tea.MouseButtonMiddle` | `tea.MouseMiddle` |
| `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` |
| `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` |
| `tea.MouseButtonWheelLeft` | `tea.MouseWheelLeft` |
| `tea.MouseButtonWheelRight` | `tea.MouseWheelRight` |
### `tea.MouseEvent` → `tea.Mouse`
The `MouseEvent` struct is gone. The new `Mouse` struct has `X`, `Y`, `Button`, and `Mod` fields.
### Mouse mode is now a View field
```go
// Before:
p := tea.NewProgram(model{}, tea.WithMouseCellMotion())
// After:
func (m model) View() tea.View {
v := tea.NewView("...")
v.MouseMode = tea.MouseModeCellMotion
return v
}
```
## Removed Program Options
These options no longer exist. They all moved to View fields.
| Removed Option | Do This Instead |
|---|---|
| `tea.WithAltScreen()` | `view.AltScreen = true` |
| `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` |
| `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` |
| `tea.WithReportFocus()` | `view.ReportFocus = true` |
| `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` |
| `tea.WithInputTTY()` | Just remove it — v2 always opens the TTY for input automatically |
| `tea.WithANSICompressor()` | Just remove it — the new renderer handles optimization automatically |
## Removed Commands
These commands no longer exist. Set the corresponding View field instead.
| Removed Command | Do This Instead |
|---|---|
| `tea.EnterAltScreen` | `view.AltScreen = true` |
| `tea.ExitAltScreen` | `view.AltScreen = false` |
| `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` |
| `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` |
| `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` |
| `tea.HideCursor` | `view.Cursor = nil` |
| `tea.ShowCursor` | `view.Cursor = &tea.Cursor{...}` or `tea.NewCursor(x, y)` |
| `tea.EnableBracketedPaste` | `view.DisableBracketedPasteMode = false` |
| `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = true` |
| `tea.EnableReportFocus` | `view.ReportFocus = true` |
| `tea.DisableReportFocus` | `view.ReportFocus = false` |
| `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` |
## Removed Program Methods
These methods on `*Program` are gone.
| Removed Method | Do This Instead |
|---|---|
| `p.Start()` | `p.Run()` |
| `p.StartReturningModel()` | `p.Run()` |
| `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` |
| `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` |
| `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` |
| `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` |
| `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` |
| `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` |
| `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` |
## Renamed APIs
| v1 | v2 | Notes |
|---|---|---|
| `tea.Sequentially(...)` | `tea.Sequence(...)` | `Sequentially` was already deprecated in v1 |
| `tea.WindowSize()` | `tea.RequestWindowSize` | Now returns `Msg` directly, not a `Cmd` |
## New Program Options
These are new in v2:
| Option | What It Does |
|---|---|
| `tea.WithColorProfile(p)` | Force a specific color profile (great for testing) |
| `tea.WithWindowSize(w, h)` | Set initial terminal size (great for testing) |
## Complete Before & After
Here's a minimal but complete program showing the most common migration patterns side by side.
**v1:**
```go
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
type model struct {
count int
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case " ":
m.count++
}
case tea.MouseMsg:
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
m.count++
}
}
return m, nil
}
func (m model) View() string {
return fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count)
}
func main() {
p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
```
**v2:**
```go
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
)
type model struct {
count int
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "space":
m.count++
}
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
m.count++
}
}
return m, nil
}
func (m model) View() tea.View {
v := tea.NewView(fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func main() {
p := tea.NewProgram(model{})
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
```
Notice how the `NewProgram` call got simpler? All the terminal feature flags moved into `View()` where they belong.
## Quick Reference
A flat old → new lookup table. Handy for search-and-replace and LLM-assisted migration.
### Import Paths
| v1 | v2 |
|---|---|
| `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` |
| `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` |
### Model Interface
| v1 | v2 |
|---|---|
| `View() string` | `View() tea.View` |
### Key Events
| v1 | v2 |
|---|---|
| `tea.KeyMsg` (struct) | `tea.KeyPressMsg` for presses, `tea.KeyMsg` (interface) for both |
| `msg.Type` | `msg.Code` |
| `msg.Runes` | `msg.Text` (string, not `[]rune`) |
| `msg.Alt` | `msg.Mod.Contains(tea.ModAlt)` |
| `tea.KeyRune` | check `len(msg.Text) > 0` |
| `tea.KeyCtrlC` | `msg.Code == 'c' && msg.Mod == tea.ModCtrl` or `msg.String() == "ctrl+c"` |
| `case " ":` (space) | `case "space":` |
### Mouse Events
| v1 | v2 |
|---|---|
| `tea.MouseMsg` (struct) | `tea.MouseMsg` (interface) — call `.Mouse()` for the data |
| `tea.MouseEvent` | `tea.Mouse` |
| `tea.MouseButtonLeft` | `tea.MouseLeft` |
| `tea.MouseButtonRight` | `tea.MouseRight` |
| `tea.MouseButtonMiddle` | `tea.MouseMiddle` |
| `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` |
| `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` |
| `msg.X`, `msg.Y` (direct) | `msg.Mouse().X`, `msg.Mouse().Y` |
### Options → View Fields
| v1 Option | v2 View Field |
|---|---|
| `tea.WithAltScreen()` | `view.AltScreen = true` |
| `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` |
| `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` |
| `tea.WithReportFocus()` | `view.ReportFocus = true` |
| `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` |
### Commands → View Fields
| v1 Command | v2 View Field |
|---|---|
| `tea.EnterAltScreen` / `tea.ExitAltScreen` | `view.AltScreen = true/false` |
| `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` |
| `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` |
| `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` |
| `tea.HideCursor` / `tea.ShowCursor` | `view.Cursor = nil` / `view.Cursor = &tea.Cursor{...}` |
| `tea.EnableBracketedPaste` / `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = false/true` |
| `tea.EnableReportFocus` / `tea.DisableReportFocus` | `view.ReportFocus = true/false` |
| `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` |
### Removed Options (No Replacement Needed)
| v1 Option | What Happened |
|---|---|
| `tea.WithInputTTY()` | v2 always opens the TTY for input automatically |
| `tea.WithANSICompressor()` | The new renderer handles optimization automatically |
### Removed Program Methods
| v1 Method | v2 Replacement |
|---|---|
| `p.Start()` | `p.Run()` |
| `p.StartReturningModel()` | `p.Run()` |
| `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` |
| `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` |
| `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` |
| `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` |
| `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` |
| `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` |
| `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` |
### Other Renames
| v1 | v2 |
|---|---|
| `tea.Sequentially(...)` | `tea.Sequence(...)` |
| `tea.WindowSize()` | `tea.RequestWindowSize` (now returns `Msg`, not `Cmd`) |
### New Program Options
| Option | Description |
|---|---|
| `tea.WithColorProfile(p)` | Force a specific color profile |
| `tea.WithWindowSize(w, h)` | Set initial window size (great for testing) |
## Feedback
Have thoughts on the v2 upgrade? We'd _love_ to hear about it. Let us know on…
- [Discord](https://charm.land/chat)
- [Matrix](https://charm.land/matrix)
- [Email](mailto:[email protected])
---
Part of [Charm](https://charm.land).
<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.land/charm-badge.jpg" width="400"></a>
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
+70
View File
@@ -0,0 +1,70 @@
package tea
// ClipboardMsg is a clipboard read message event. This message is emitted when
// a terminal receives an OSC52 clipboard read message event.
type ClipboardMsg struct {
Content string
Selection byte
}
// Clipboard returns the clipboard selection type. This will be one of the
// following values:
//
// - c: System clipboard.
// - p: Primary clipboard (X11/Wayland only).
func (e ClipboardMsg) Clipboard() byte {
return e.Selection
}
// String returns the string representation of the clipboard message.
func (e ClipboardMsg) String() string {
return e.Content
}
// setClipboardMsg is an internal message used to set the system clipboard
// using OSC52.
type setClipboardMsg string
// SetClipboard produces a command that sets the system clipboard using OSC52.
// Note that OSC52 is not supported in all terminals.
func SetClipboard(s string) Cmd {
return func() Msg {
return setClipboardMsg(s)
}
}
// readClipboardMsg is an internal message used to read the system clipboard
// using OSC52.
type readClipboardMsg struct{}
// ReadClipboard produces a command that reads the system clipboard using OSC52.
// Note that OSC52 is not supported in all terminals.
func ReadClipboard() Msg {
return readClipboardMsg{}
}
// setPrimaryClipboardMsg is an internal message used to set the primary
// clipboard using OSC52.
type setPrimaryClipboardMsg string
// SetPrimaryClipboard produces a command that sets the primary clipboard using
// OSC52. Primary clipboard selection is a feature present in X11 and Wayland
// only.
// Note that OSC52 is not supported in all terminals.
func SetPrimaryClipboard(s string) Cmd {
return func() Msg {
return setPrimaryClipboardMsg(s)
}
}
// readPrimaryClipboardMsg is an internal message used to read the primary
// clipboard using OSC52.
type readPrimaryClipboardMsg struct{}
// ReadPrimaryClipboard produces a command that reads the primary clipboard
// using OSC52. Primary clipboard selection is a feature present in X11 and
// Wayland only.
// Note that OSC52 is not supported in all terminals.
func ReadPrimaryClipboard() Msg {
return readPrimaryClipboardMsg{}
}
+91
View File
@@ -0,0 +1,91 @@
package tea
import (
"image/color"
uv "github.com/charmbracelet/ultraviolet"
)
// backgroundColorMsg is a message that requests the terminal background color.
type backgroundColorMsg struct{}
// RequestBackgroundColor is a command that requests the terminal background color.
func RequestBackgroundColor() Msg {
return backgroundColorMsg{}
}
// foregroundColorMsg is a message that requests the terminal foreground color.
type foregroundColorMsg struct{}
// RequestForegroundColor is a command that requests the terminal foreground color.
func RequestForegroundColor() Msg {
return foregroundColorMsg{}
}
// cursorColorMsg is a message that requests the terminal cursor color.
type cursorColorMsg struct{}
// RequestCursorColor is a command that requests the terminal cursor color.
func RequestCursorColor() Msg {
return cursorColorMsg{}
}
// ForegroundColorMsg represents a foreground color message. This message is
// emitted when the program requests the terminal foreground color with the
// [RequestForegroundColor] Cmd.
type ForegroundColorMsg struct{ color.Color }
// String returns the hex representation of the color.
func (e ForegroundColorMsg) String() string {
return uv.ForegroundColorEvent(e).String()
}
// IsDark returns whether the color is dark.
func (e ForegroundColorMsg) IsDark() bool {
return uv.ForegroundColorEvent(e).IsDark()
}
// BackgroundColorMsg represents a background color message. This message is
// emitted when the program requests the terminal background color with the
// [RequestBackgroundColor] Cmd.
//
// This is commonly used in [Update.Init] to get the terminal background color
// for style definitions. For that you'll want to call
// [BackgroundColorMsg.IsDark] to determine if the color is dark or light. For
// example:
//
// func (m Model) Init() (Model, Cmd) {
// return m, RequestBackgroundColor()
// }
//
// func (m Model) Update(msg Msg) (Model, Cmd) {
// switch msg := msg.(type) {
// case BackgroundColorMsg:
// m.styles = newStyles(msg.IsDark())
// }
// }
type BackgroundColorMsg struct{ color.Color }
// String returns the hex representation of the color.
func (e BackgroundColorMsg) String() string {
return uv.BackgroundColorEvent(e).String()
}
// IsDark returns whether the color is dark.
func (e BackgroundColorMsg) IsDark() bool {
return uv.BackgroundColorEvent(e).IsDark()
}
// CursorColorMsg represents a cursor color change message. This message is
// emitted when the program requests the terminal cursor color.
type CursorColorMsg struct{ color.Color }
// String returns the hex representation of the color.
func (e CursorColorMsg) String() string {
return uv.CursorColorEvent(e).String()
}
// IsDark returns whether the color is dark.
func (e CursorColorMsg) IsDark() bool {
return uv.CursorColorEvent(e).IsDark()
}
+175
View File
@@ -0,0 +1,175 @@
package tea
import (
"time"
)
// Batch performs a bunch of commands concurrently with no ordering guarantees
// about the results. Use a Batch to return several commands.
//
// Example:
//
// func (m model) Init() (Model, Cmd) {
// return m, tea.Batch(someCommand, someOtherCommand)
// }
func Batch(cmds ...Cmd) Cmd {
return compactCmds[BatchMsg](cmds)
}
// BatchMsg is a message used to perform a bunch of commands concurrently with
// no ordering guarantees. You can send a BatchMsg with Batch.
type BatchMsg []Cmd
// Sequence runs the given commands one at a time, in order. Contrast this with
// Batch, which runs commands concurrently.
func Sequence(cmds ...Cmd) Cmd {
return compactCmds[sequenceMsg](cmds)
}
// sequenceMsg is used internally to run the given commands in order.
type sequenceMsg []Cmd
// compactCmds ignores any nil commands in cmds, and returns the most direct
// command possible. That is, considering the non-nil commands, if there are
// none it returns nil, if there is exactly one it returns that command
// directly, else it returns the non-nil commands as type T.
func compactCmds[T ~[]Cmd](cmds []Cmd) Cmd {
var validCmds []Cmd
for _, c := range cmds {
if c == nil {
continue
}
validCmds = append(validCmds, c)
}
switch len(validCmds) {
case 0:
return nil
case 1:
return validCmds[0]
default:
return func() Msg {
return T(validCmds)
}
}
}
// Every is a command that ticks in sync with the system clock. So, if you
// wanted to tick with the system clock every second, minute or hour you
// could use this. It's also handy for having different things tick in sync.
//
// Because we're ticking with the system clock the tick will likely not run for
// the entire specified duration. For example, if we're ticking for one minute
// and the clock is at 12:34:20 then the next tick will happen at 12:35:00, 40
// seconds later.
//
// To produce the command, pass a duration and a function which returns
// a message containing the time at which the tick occurred.
//
// type TickMsg time.Time
//
// cmd := Every(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
//
// Beginners' note: Every sends a single message and won't automatically
// dispatch messages at an interval. To do that, you'll want to return another
// Every command after receiving your tick message. For example:
//
// type TickMsg time.Time
//
// // Send a message every second.
// func tickEvery() Cmd {
// return Every(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
// }
//
// func (m model) Init() (Model, Cmd) {
// // Start ticking.
// return m, tickEvery()
// }
//
// func (m model) Update(msg Msg) (Model, Cmd) {
// switch msg.(type) {
// case TickMsg:
// // Return your Every command again to loop.
// return m, tickEvery()
// }
// return m, nil
// }
//
// Every is analogous to Tick in the Elm Architecture.
func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
n := time.Now()
d := n.Truncate(duration).Add(duration).Sub(n)
t := time.NewTimer(d)
return func() Msg {
ts := <-t.C
t.Stop()
for len(t.C) > 0 {
<-t.C
}
return fn(ts)
}
}
// Tick produces a command at an interval independent of the system clock at
// the given duration. That is, the timer begins precisely when invoked,
// and runs for its entire duration.
//
// To produce the command, pass a duration and a function which returns
// a message containing the time at which the tick occurred.
//
// type TickMsg time.Time
//
// cmd := Tick(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
//
// Beginners' note: Tick sends a single message and won't automatically
// dispatch messages at an interval. To do that, you'll want to return another
// Tick command after receiving your tick message. For example:
//
// type TickMsg time.Time
//
// func doTick() Cmd {
// return Tick(time.Second, func(t time.Time) Msg {
// return TickMsg(t)
// })
// }
//
// func (m model) Init() (Model, Cmd) {
// // Start ticking.
// return m, doTick()
// }
//
// func (m model) Update(msg Msg) (Model, Cmd) {
// switch msg.(type) {
// case TickMsg:
// // Return your Tick command again to loop.
// return m, doTick()
// }
// return m, nil
// }
func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
t := time.NewTimer(d)
return func() Msg {
ts := <-t.C
t.Stop()
for len(t.C) > 0 {
<-t.C
}
return fn(ts)
}
}
type windowSizeMsg struct{}
// RequestWindowSize is a command that queries the terminal for its current
// size. It delivers the results to Update via a [WindowSizeMsg]. Keep in mind
// that WindowSizeMsgs will automatically be delivered to Update when the
// [Program] starts and when the window dimensions change so in many cases you
// will not need to explicitly invoke this command.
func RequestWindowSize() Msg {
return windowSizeMsg{}
}
+830
View File
@@ -0,0 +1,830 @@
package tea
import (
"bytes"
"fmt"
"image/color"
"io"
"runtime"
"strings"
"sync"
"github.com/charmbracelet/colorprofile"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/lucasb-eyer/go-colorful"
)
type cursedRenderer struct {
w io.Writer
buf bytes.Buffer // updates buffer to be flushed to [w]
scr *uv.TerminalRenderer
cellbuf uv.ScreenBuffer
lastView *View
env []string
term string // the terminal type $TERM
width, height int
mu sync.Mutex
profile colorprofile.Profile
logger uv.Logger
view View
hardTabs bool // whether to use hard tabs to optimize cursor movements
backspace bool // whether to use backspace to optimize cursor movements
mapnl bool
syncdUpdates bool // whether to use synchronized output mode for updates
starting bool // indicates whether the renderer is starting after being stopped
}
var _ renderer = &cursedRenderer{}
func newCursedRenderer(w io.Writer, env []string, width, height int) (s *cursedRenderer) {
s = new(cursedRenderer)
s.w = w
s.env = env
s.term = uv.Environ(env).Getenv("TERM")
s.width, s.height = width, height // This needs to happen before [cursedRenderer.reset].
s.cellbuf = uv.NewScreenBuffer(s.width, s.height)
reset(s)
return
}
// setLogger sets the logger for the renderer.
func (s *cursedRenderer) setLogger(logger uv.Logger) {
s.mu.Lock()
s.logger = logger
s.mu.Unlock()
}
// setOptimizations sets the cursor movement optimizations.
func (s *cursedRenderer) setOptimizations(hardTabs, backspace, mapnl bool) {
s.mu.Lock()
s.hardTabs = hardTabs
s.backspace = backspace
s.mapnl = mapnl
s.scr.SetTabStops(s.width)
s.scr.SetBackspace(s.backspace)
s.scr.SetMapNewline(s.mapnl)
s.mu.Unlock()
}
// start implements renderer.
func (s *cursedRenderer) start() {
s.mu.Lock()
defer s.mu.Unlock()
// Mark that we're starting. This is used to restore some state when
// starting the renderer again after it was stopped.
s.starting = true
if s.lastView == nil {
return
}
if s.lastView.AltScreen {
enableAltScreen(s, true, true)
}
enableTextCursor(s, s.lastView.Cursor != nil)
if s.lastView.Cursor != nil {
if s.lastView.Cursor.Color != nil {
col, ok := colorful.MakeColor(s.lastView.Cursor.Color)
if ok {
_, _ = s.scr.WriteString(ansi.SetCursorColor(col.Hex()))
}
}
curStyle := encodeCursorStyle(s.lastView.Cursor.Shape, s.lastView.Cursor.Blink)
if curStyle != 0 && curStyle != 1 {
_, _ = s.scr.WriteString(ansi.SetCursorStyle(curStyle))
}
}
if s.lastView.ForegroundColor != nil {
col, ok := colorful.MakeColor(s.lastView.ForegroundColor)
if ok {
_, _ = s.scr.WriteString(ansi.SetForegroundColor(col.Hex()))
}
}
if s.lastView.BackgroundColor != nil {
col, ok := colorful.MakeColor(s.lastView.BackgroundColor)
if ok {
_, _ = s.scr.WriteString(ansi.SetBackgroundColor(col.Hex()))
}
}
if !s.lastView.DisableBracketedPasteMode {
_, _ = s.scr.WriteString(ansi.SetModeBracketedPaste)
}
if s.lastView.ReportFocus {
_, _ = s.scr.WriteString(ansi.SetModeFocusEvent)
}
switch s.lastView.MouseMode {
case MouseModeNone:
case MouseModeCellMotion:
_, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr)
case MouseModeAllMotion:
_, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr)
}
if s.lastView.WindowTitle != "" {
_, _ = s.scr.WriteString(ansi.SetWindowTitle(s.lastView.WindowTitle))
}
if s.lastView.ProgressBar != nil {
setProgressBar(s, s.lastView.ProgressBar)
}
// Enable modifyOtherKeys and Kitty keyboard protocol.
// Both can coexist; terminals ignore what they don't support.
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
kittyFlags := ansi.KittyDisambiguateEscapeCodes
if s.lastView.KeyboardEnhancements.ReportEventTypes {
kittyFlags |= ansi.KittyReportEventTypes
}
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
}
// close implements renderer.
func (s *cursedRenderer) close() (err error) {
s.mu.Lock()
defer s.mu.Unlock()
// Exit the altScreen and show cursor before closing. It's important that
// we don't change the [cursedRenderer] altScreen and cursorHidden states
// so that we can restore them when we start the renderer again. This is
// used when the user suspends the program and then resumes it.
if lv := s.lastView; lv != nil { //nolint:nestif
// NOTE: The Kitty keyboard specs specify that the terminal should have
// two registries for the main and alt screens. We disable keyboard
// enhancements whenever we enter/exit alt screen mode in
// [cursedRenderer.flush].
// Here, we reset the keyboard protocol of the last screen used
// assuming the other screen is already reset when we switched screens.
_, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys)
_, _ = s.buf.WriteString(ansi.KittyKeyboard(0, 1))
// Go to the bottom of the screen.
// We need to go to the bottom of the screen regardless of whether
// we're in alt screen mode or not to avoid leaving the cursor in the
// middle in terminals that don't support alt screen mode.
s.scr.MoveTo(0, s.cellbuf.Height()-1)
_ = s.scr.Flush() // we need to flush to write the cursor movement
if lv.AltScreen {
enableAltScreen(s, false, true)
} else {
_, _ = s.scr.WriteString(ansi.EraseScreenBelow)
}
if lv.Cursor == nil {
enableTextCursor(s, true)
}
if !lv.DisableBracketedPasteMode {
_, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste)
}
if lv.ReportFocus {
_, _ = s.scr.WriteString(ansi.ResetModeFocusEvent)
}
switch lv.MouseMode {
case MouseModeNone:
case MouseModeCellMotion, MouseModeAllMotion:
_, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent +
ansi.ResetModeMouseAnyEvent +
ansi.ResetModeMouseExtSgr)
}
if lv.WindowTitle != "" {
// Clear the window title if it was set.
_, _ = s.scr.WriteString(ansi.SetWindowTitle(""))
}
if lc := lv.Cursor; lc != nil {
curShape := encodeCursorStyle(lc.Shape, lc.Blink)
if curShape != 0 && curShape != 1 {
// Reset the cursor style to default if it was set to something other
// blinking block.
_, _ = s.scr.WriteString(ansi.SetCursorStyle(0))
}
if lc.Color != nil {
_, _ = s.scr.WriteString(ansi.ResetCursorColor)
}
}
if lv.BackgroundColor != nil {
_, _ = s.scr.WriteString(ansi.ResetBackgroundColor)
}
if lv.ForegroundColor != nil {
_, _ = s.scr.WriteString(ansi.ResetForegroundColor)
}
if lv.ProgressBar != nil && lv.ProgressBar.State != ProgressBarNone {
_, _ = s.scr.WriteString(ansi.ResetProgressBar)
}
}
if s.cellbuf.Method == ansi.GraphemeWidth {
// Make sure to turn off Unicode mode (2027)
_, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore)
}
if err := s.scr.Flush(); err != nil {
return fmt.Errorf("bubbletea: error closing screen writer: %w", err)
}
if s.buf.Len() > 0 {
if s.logger != nil {
s.logger.Printf("output: %q", s.buf.String())
}
if _, err := io.Copy(s.w, &s.buf); err != nil {
return fmt.Errorf("bubbletea: error writing to screen: %w", err)
}
s.buf.Reset()
}
x, y := s.scr.Position()
// We want to clear the renderer state but not the cursor position. This is
// because we might be putting the tea process in the background, run some
// other process, and then return to the tea process. We want to keep the
// cursor position so that we can continue where we left off.
reset(s)
s.scr.SetPosition(x, y)
return nil
}
// writeString implements renderer.
func (s *cursedRenderer) writeString(str string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.scr.WriteString(str) //nolint:wrapcheck
}
// flush implements renderer.
func (s *cursedRenderer) flush(closing bool) error {
s.mu.Lock()
defer s.mu.Unlock()
view := s.view
frameArea := uv.Rect(0, 0, s.width, s.height)
if len(view.Content) == 0 {
// If the component is nil, we should clear the screen buffer.
frameArea.Max.Y = 0
}
content := uv.NewStyledString(view.Content)
if !view.AltScreen {
// We need to resizes the screen based on the frame height and
// terminal width. This is because the frame height can change based on
// the content of the frame. For example, if the frame contains a list
// of items, the height of the frame will be the number of items in the
// list. This is different from the alt screen buffer, which has a
// fixed height and width.
frameHeight := content.Height()
if frameHeight != frameArea.Dy() {
frameArea.Max.Y = frameHeight
}
}
if !s.starting && !closing && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() {
// No changes, nothing to do.
return nil
}
// We're no longer starting.
s.starting = false
if frameArea != s.cellbuf.Bounds() {
s.scr.Erase() // Force a full redraw to avoid artifacts.
// We need to reset the touched lines buffer to match the new height.
s.cellbuf.Touched = nil
// Resize the screen buffer to match the frame area. This is necessary
// to ensure that the screen buffer is the same size as the frame area
// and to avoid rendering issues when the frame area is smaller than
// the screen buffer.
s.cellbuf.Resize(frameArea.Dx(), frameArea.Dy())
}
// Clear our screen buffer before copying the new frame into it to ensure
// we erase any old content.
s.cellbuf.Clear()
content.Draw(s.cellbuf, s.cellbuf.Bounds())
// If the frame height is greater than the screen height, we drop the
// lines from the top of the buffer.
if frameHeight := frameArea.Dy(); frameHeight > s.height {
s.cellbuf.Lines = s.cellbuf.Lines[frameHeight-s.height:]
}
// Alt screen mode.
shouldUpdateAltScreen := (s.lastView == nil && view.AltScreen) || (s.lastView != nil && s.lastView.AltScreen != view.AltScreen)
if shouldUpdateAltScreen {
// We want to enter/exit altscreen mode but defer writing the actual
// sequences until we flush the rest of the updates. This is because we
// control the cursor visibility and we need to ensure that happens
// after entering/exiting alt screen mode. Some terminals have
// different cursor visibility states for main and alt screen modes and
// this ensures we handle that correctly.
enableAltScreen(s, view.AltScreen, false)
}
// bracketed paste mode.
if s.lastView == nil || view.DisableBracketedPasteMode != s.lastView.DisableBracketedPasteMode {
if !view.DisableBracketedPasteMode {
_, _ = s.scr.WriteString(ansi.SetModeBracketedPaste)
} else if s.lastView != nil {
_, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste)
}
}
// report focus events mode.
if s.lastView == nil || s.lastView.ReportFocus != view.ReportFocus {
if view.ReportFocus {
_, _ = s.scr.WriteString(ansi.SetModeFocusEvent)
} else if s.lastView != nil {
_, _ = s.scr.WriteString(ansi.ResetModeFocusEvent)
}
}
// mouse events mode.
if s.lastView == nil || view.MouseMode != s.lastView.MouseMode {
switch view.MouseMode {
case MouseModeNone:
if s.lastView != nil && s.lastView.MouseMode != MouseModeNone {
_, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent +
ansi.ResetModeMouseAnyEvent +
ansi.ResetModeMouseExtSgr)
}
case MouseModeCellMotion:
if s.lastView != nil && s.lastView.MouseMode == MouseModeAllMotion {
_, _ = s.scr.WriteString(ansi.ResetModeMouseAnyEvent)
}
_, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr)
case MouseModeAllMotion:
if s.lastView != nil && s.lastView.MouseMode == MouseModeCellMotion {
_, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent)
}
_, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr)
}
}
// Set window title.
if s.lastView == nil || view.WindowTitle != s.lastView.WindowTitle {
if s.lastView != nil || view.WindowTitle != "" {
_, _ = s.scr.WriteString(ansi.SetWindowTitle(view.WindowTitle))
}
}
// kitty keyboard protocol
if s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements ||
view.AltScreen != s.lastView.AltScreen {
// NOTE: We need to reset the keyboard protocol when switching
// between main and alt screen. This is because the specs specify
// two different states for the main and alt screen.
// Enable modifyOtherKeys and Kitty keyboard protocol.
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
kittyFlags := ansi.KittyDisambiguateEscapeCodes // always enable basic key disambiguation
if view.KeyboardEnhancements.ReportEventTypes {
kittyFlags |= ansi.KittyReportEventTypes
}
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
if !closing {
// Request keyboard enhancements when they change
_, _ = s.scr.WriteString(ansi.RequestKittyKeyboard)
}
}
// Set terminal colors.
var (
cc, lcc color.Color
lfg, lbg color.Color
)
if view.Cursor != nil {
cc = view.Cursor.Color
}
if s.lastView != nil {
if s.lastView.Cursor != nil {
lcc = s.lastView.Cursor.Color
}
lfg = s.lastView.ForegroundColor
lbg = s.lastView.BackgroundColor
}
for _, c := range []struct {
newColor color.Color
oldColor color.Color
reset string
setter func(string) string
}{
{newColor: cc, oldColor: lcc, reset: ansi.ResetCursorColor, setter: ansi.SetCursorColor},
{newColor: view.ForegroundColor, oldColor: lfg, reset: ansi.ResetForegroundColor, setter: ansi.SetForegroundColor},
{newColor: view.BackgroundColor, oldColor: lbg, reset: ansi.ResetBackgroundColor, setter: ansi.SetBackgroundColor},
} {
if c.newColor != c.oldColor {
if c.newColor == nil {
// Reset the color if it was set to nil.
_, _ = s.scr.WriteString(c.reset)
} else {
// Set the color.
col, ok := colorful.MakeColor(c.newColor)
if ok {
_, _ = s.scr.WriteString(c.setter(col.Hex()))
}
}
}
}
// Set cursor shape and blink if set.
var ccStyle, lcStyle int
var lcur *Cursor
ccur := view.Cursor
if lv := s.lastView; lv != nil {
lcur = lv.Cursor
}
if ccur != nil {
ccStyle = encodeCursorStyle(ccur.Shape, ccur.Blink)
}
if lcur != nil {
lcStyle = encodeCursorStyle(lcur.Shape, lcur.Blink)
}
if ccStyle != lcStyle {
_, _ = s.scr.WriteString(ansi.SetCursorStyle(ccStyle))
}
// Render progress bar if it's changed.
if (s.lastView == nil && view.ProgressBar != nil && view.ProgressBar.State != ProgressBarNone) ||
(s.lastView != nil && (s.lastView.ProgressBar == nil) != (view.ProgressBar == nil)) ||
(s.lastView != nil && s.lastView.ProgressBar != nil && view.ProgressBar != nil && *s.lastView.ProgressBar != *view.ProgressBar) {
// Render or clear the progress bar if it was added or removed.
setProgressBar(s, view.ProgressBar)
}
// Render and queue changes to the screen buffer.
s.scr.Render(s.cellbuf.RenderBuffer)
if cur := view.Cursor; cur != nil {
// MoveTo must come after [uv.TerminalRenderer.Render] because the
// cursor position might get updated during rendering.
s.scr.MoveTo(view.Cursor.X, view.Cursor.Y)
} else if !view.AltScreen {
// We don't want the cursor to be dangling at the end of the line in
// inline mode because it can cause unwanted line wraps in some
// terminals. So we move it to the beginning of the next line if
// necessary.
// This is only needed when the cursor is hidden because when it's
// visible, we already set its position above.
x, y := s.scr.Position()
if x >= s.width-1 {
s.scr.MoveTo(0, y)
}
}
if err := s.scr.Flush(); err != nil {
return fmt.Errorf("bubbletea: error flushing screen writer: %w", err)
}
// Check if we have any render updates to flush.
hasUpdates := s.buf.Len() > 0
// Cursor visibility.
didShowCursor := s.lastView != nil && s.lastView.Cursor != nil
showCursor := view.Cursor != nil
hideCursor := !showCursor
shouldUpdateCursorVis := (s.lastView == nil || didShowCursor != showCursor) || shouldUpdateAltScreen
// Build final output buffer with synchronized output or hide/show cursor
// updates. But first, enter/exit alt screen mode if needed.
//
// Here, we have two scenarios:
// 1. Synchronized output updates are supported. In this case, we want to
// wrap all updates, unless it's just a cursor visibility change, in
// synchronized output mode. This is because synchronized output mode
// takes care of rendering the updates atomically. In the case of
// just a cursor visibility change, we don't need to enter
// synchronized output mode because it's just a single sequence to
// flush out to the terminal.
//
// 2. We don't have synchronized output updates support. In this case, and
// if the cursor is visible or should be visible, we wrap the updates
// with hide/show cursor sequences to try and mitigate cursor
// flickering. This is terminal dependent and may still result in
// flickering in some terminals. It's the best effort we can do instead
// of showing the cursor flying around the screen during updates.
var buf bytes.Buffer
if shouldUpdateAltScreen {
// We always disable keyboard enhancements when switching screens
// because the terminal is expected to have two different keyboard
// registries for main and alt screens.
_, _ = buf.WriteString(ansi.ResetModifyOtherKeys)
_, _ = buf.WriteString(ansi.KittyKeyboard(0, 1))
if view.AltScreen {
// Entering alt screen mode.
buf.WriteString(ansi.SetModeAltScreenSaveCursor)
} else {
// Exiting alt screen mode.
buf.WriteString(ansi.ResetModeAltScreenSaveCursor)
}
}
if s.syncdUpdates {
if hasUpdates {
// We have synchronized output updates enabled.
buf.WriteString(ansi.SetModeSynchronizedOutput)
}
if shouldUpdateCursorVis && hideCursor {
// Do we need to update the cursor visibility to hidden? If so, do
// it here before writing any updates to the buffer.
_, _ = buf.WriteString(ansi.ResetModeTextCursorEnable)
}
} else if (shouldUpdateCursorVis && hideCursor) || (hasUpdates && showCursor && didShowCursor) {
_, _ = buf.WriteString(ansi.ResetModeTextCursorEnable)
}
if hasUpdates {
buf.Write(s.buf.Bytes())
}
if s.syncdUpdates {
if shouldUpdateCursorVis && showCursor {
// Do we need to update the cursor visibility to visible? If so, do
// it here after writing any updates to the buffer.
_, _ = buf.WriteString(ansi.SetModeTextCursorEnable)
}
if hasUpdates {
// Close synchronized output mode.
buf.WriteString(ansi.ResetModeSynchronizedOutput)
}
} else if (shouldUpdateCursorVis && showCursor) || (hasUpdates && showCursor && didShowCursor) {
_, _ = buf.WriteString(ansi.SetModeTextCursorEnable)
}
// Reset internal screen renderer buffer.
s.buf.Reset()
// If our updates flush buffer has content, write it to the output writer.
if buf.Len() > 0 {
if s.logger != nil {
s.logger.Printf("output: %q", buf.String())
}
if _, err := io.Copy(s.w, &buf); err != nil {
return fmt.Errorf("bubbletea: error flushing update to the writer: %w", err)
}
}
s.lastView = &view
return nil
}
// render implements renderer.
func (s *cursedRenderer) render(v View) {
s.mu.Lock()
defer s.mu.Unlock()
s.view = v
}
// reset implements renderer.
func (s *cursedRenderer) reset() {
s.mu.Lock()
reset(s)
s.mu.Unlock()
}
func reset(s *cursedRenderer) {
s.buf.Reset()
scr := uv.NewTerminalRenderer(&s.buf, s.env)
scr.SetColorProfile(s.profile)
scr.SetRelativeCursor(true) // Always start in inline mode
scr.SetFullscreen(false) // Always start in inline mode
scr.SetTabStops(s.width)
scr.SetBackspace(s.backspace)
scr.SetMapNewline(s.mapnl)
scr.SetScrollOptim(runtime.GOOS != "windows") // disable scroll optimization on Windows due to bugs in some terminals
s.scr = scr
}
// setColorProfile implements renderer.
func (s *cursedRenderer) setColorProfile(p colorprofile.Profile) {
s.mu.Lock()
s.profile = p
s.scr.SetColorProfile(p)
s.mu.Unlock()
}
// resize implements renderer.
func (s *cursedRenderer) resize(w, h int) {
s.mu.Lock()
// We need to mark the screen for clear to force a redraw. However, we
// only do so if we're using alt screen or the width has changed.
// That's because redrawing is expensive and we can avoid it if the
// width hasn't changed in inline mode. On the other hand, when using
// alt screen mode, we always want to redraw because some terminals
// would scroll the screen and our content would be lost.
s.scr.Erase()
s.width, s.height = w, h
s.scr.Resize(s.width, s.height)
s.mu.Unlock()
}
// clearScreen implements renderer.
func (s *cursedRenderer) clearScreen() {
s.mu.Lock()
// Move the cursor to the top left corner of the screen and trigger a full
// screen redraw.
s.scr.MoveTo(0, 0)
s.scr.Erase()
s.mu.Unlock()
}
// enableAltScreen sets the alt screen mode.
// Note that this writes to the buffer directly if write is true.
func enableAltScreen(s *cursedRenderer, enable bool, write bool) {
if enable {
enterAltScreen(s, write)
} else {
exitAltScreen(s, write)
}
}
func enterAltScreen(s *cursedRenderer, write bool) {
s.scr.SaveCursor()
if write {
s.buf.WriteString(ansi.SetModeAltScreenSaveCursor)
}
s.scr.SetFullscreen(true)
s.scr.SetRelativeCursor(false)
s.scr.Erase()
}
func exitAltScreen(s *cursedRenderer, write bool) {
s.scr.Erase()
s.scr.SetRelativeCursor(true)
s.scr.SetFullscreen(false)
if write {
s.buf.WriteString(ansi.ResetModeAltScreenSaveCursor)
}
s.scr.RestoreCursor()
}
// enableTextCursor sets the text cursor mode.
func enableTextCursor(s *cursedRenderer, enable bool) {
if enable {
_, _ = s.scr.WriteString(ansi.SetModeTextCursorEnable)
} else {
_, _ = s.scr.WriteString(ansi.ResetModeTextCursorEnable)
}
}
// setSyncdUpdates implements renderer.
func (s *cursedRenderer) setSyncdUpdates(syncd bool) {
s.mu.Lock()
s.syncdUpdates = syncd
s.mu.Unlock()
}
// setWidthMethod implements renderer.
func (s *cursedRenderer) setWidthMethod(method ansi.Method) {
s.mu.Lock()
if method == ansi.GraphemeWidth {
// Turn on Unicode mode (2027) for accurate grapheme width calculation.
// This is needed for proper rendering of wide characters and emojis.
_, _ = s.scr.WriteString(ansi.SetModeUnicodeCore)
} else if s.cellbuf.Method == ansi.GraphemeWidth {
// Turn off Unicode mode if we're switching away from grapheme width
// calculation to avoid issues with some terminals that might still be
// in Unicode mode and render characters incorrectly.
_, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore)
}
s.cellbuf.Method = method
s.mu.Unlock()
}
// insertAbove implements renderer.
func (s *cursedRenderer) insertAbove(str string) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(str) == 0 {
return nil
}
var sb strings.Builder
w, h := s.cellbuf.Width(), s.cellbuf.Height()
_, y := s.scr.Position()
// We need to scroll the screen up by the number of lines in the queue.
sb.WriteByte('\r')
down := h - y - 1
if down > 0 {
sb.WriteString(ansi.CursorDown(down))
}
lines := strings.Split(str, "\n")
offset := len(lines)
for _, line := range lines {
lineWidth := ansi.StringWidth(line)
if w > 0 && lineWidth > w {
offset += (lineWidth / w)
}
}
// Scroll the screen up by the offset to make room for the new lines.
sb.WriteString(strings.Repeat("\n", offset))
// XXX: Now go to the top of the screen, insert new lines, and write
// the queued strings. It is important to use [Screen.moveCursor]
// instead of [Screen.move] because we don't want to perform any checks
// on the cursor position.
up := offset + h - 1
sb.WriteString(ansi.CursorUp(up))
sb.WriteString(ansi.InsertLine(offset))
for _, line := range lines {
sb.WriteString(line)
sb.WriteString(ansi.EraseLineRight)
sb.WriteString("\r\n")
}
s.scr.SetPosition(0, 0)
if s.logger != nil {
s.logger.Printf("insert above: %q", sb.String())
}
_, err := io.WriteString(s.w, sb.String())
if err != nil {
return fmt.Errorf("bubbletea: error writing insert above to the writer: %w", err)
}
return nil
}
// onMouse implements renderer.
func (s *cursedRenderer) onMouse(m MouseMsg) Cmd {
if s.lastView != nil && s.lastView.OnMouse != nil {
return s.lastView.OnMouse(m)
}
return nil
}
func setProgressBar(s *cursedRenderer, pb *ProgressBar) {
if pb == nil {
_, _ = s.scr.WriteString(ansi.ResetProgressBar)
return
}
var seq string
switch pb.State {
case ProgressBarNone:
seq = ansi.ResetProgressBar
case ProgressBarDefault:
seq = ansi.SetProgressBar(pb.Value)
case ProgressBarError:
seq = ansi.SetErrorProgressBar(pb.Value)
case ProgressBarIndeterminate:
seq = ansi.SetIndeterminateProgressBar
case ProgressBarWarning:
seq = ansi.SetWarningProgressBar(pb.Value)
}
if seq != "" {
_, _ = s.scr.WriteString(seq)
}
}
func viewEquals(a, b *View) bool {
if a == nil || b == nil {
return false
}
if a.Content != b.Content ||
a.AltScreen != b.AltScreen ||
a.DisableBracketedPasteMode != b.DisableBracketedPasteMode ||
a.ReportFocus != b.ReportFocus ||
a.MouseMode != b.MouseMode ||
a.WindowTitle != b.WindowTitle ||
a.ForegroundColor != b.ForegroundColor ||
a.BackgroundColor != b.BackgroundColor ||
a.KeyboardEnhancements != b.KeyboardEnhancements {
return false
}
if (a.Cursor == nil) != (b.Cursor == nil) {
return false
}
if a.Cursor != nil && b.Cursor != nil {
if a.Cursor.X != b.Cursor.X ||
a.Cursor.Y != b.Cursor.Y ||
a.Cursor.Shape != b.Cursor.Shape ||
a.Cursor.Blink != b.Cursor.Blink ||
a.Cursor.Color != b.Cursor.Color {
return false
}
}
if (a.ProgressBar == nil) != (b.ProgressBar == nil) {
return false
}
if a.ProgressBar != nil && b.ProgressBar != nil {
if *a.ProgressBar != *b.ProgressBar {
return false
}
}
return true
}
+28
View File
@@ -0,0 +1,28 @@
package tea
// Position represents a position in the terminal.
type Position struct{ X, Y int }
// CursorPositionMsg is a message that represents the terminal cursor position.
type CursorPositionMsg struct {
X, Y int
}
// CursorShape represents a terminal cursor shape.
type CursorShape int
// Cursor shapes.
const (
CursorBlock CursorShape = iota
CursorUnderline
CursorBar
)
// requestCursorPosMsg is a message that requests the cursor position.
type requestCursorPosMsg struct{}
// RequestCursorPosition is a command that requests the cursor position.
// The cursor position will be sent as a [CursorPositionMsg] message.
func RequestCursorPosition() Msg {
return requestCursorPosMsg{}
}
+34
View File
@@ -0,0 +1,34 @@
package tea
import uv "github.com/charmbracelet/ultraviolet"
// EnvMsg is a message that represents the environment variables of the
// program. This is useful for getting the environment variables of programs
// running in a remote session like SSH. In that case, using [os.Getenv] would
// return the server's environment variables, not the client's.
//
// This message is sent to the program when it starts.
//
// Example:
//
// switch msg := msg.(type) {
// case EnvMsg:
// // What terminal type is being used?
// term := msg.Getenv("TERM")
// }
type EnvMsg uv.Environ
// Getenv returns the value of the environment variable named by the key. If
// the variable is not present in the environment, the value returned will be
// the empty string.
func (msg EnvMsg) Getenv(key string) (v string) {
return uv.Environ(msg).Getenv(key)
}
// LookupEnv retrieves the value of the environment variable named by the key.
// If the variable is present in the environment the value (which may be empty)
// is returned and the boolean is true. Otherwise the returned value will be
// empty and the boolean will be false.
func (msg EnvMsg) LookupEnv(key string) (s string, v bool) {
return uv.Environ(msg).LookupEnv(key)
}
+129
View File
@@ -0,0 +1,129 @@
package tea
import (
"io"
"os"
"os/exec"
)
// execMsg is used internally to run an ExecCommand sent with Exec.
type execMsg struct {
cmd ExecCommand
fn ExecCallback
}
// Exec is used to perform arbitrary I/O in a blocking fashion, effectively
// pausing the Program while execution is running and resuming it when
// execution has completed.
//
// Most of the time you'll want to use ExecProcess, which runs an exec.Cmd.
//
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
func Exec(c ExecCommand, fn ExecCallback) Cmd {
return func() Msg {
return execMsg{cmd: c, fn: fn}
}
}
// ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively
// pausing the Program while the command is running. After the *exec.Cmd exists
// the Program resumes. It's useful for spawning other interactive applications
// such as editors and shells from within a Program.
//
// To produce the command, pass an *exec.Cmd and a function which returns
// a message containing the error which may have occurred when running the
// ExecCommand.
//
// type VimFinishedMsg struct { err error }
//
// c := exec.Command("vim", "file.txt")
//
// cmd := ExecProcess(c, func(err error) Msg {
// return VimFinishedMsg{err: err}
// })
//
// Or, if you don't care about errors, you could simply:
//
// cmd := ExecProcess(exec.Command("vim", "file.txt"), nil)
//
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd {
return Exec(wrapExecCommand(c), fn)
}
// ExecCallback is used when executing an *exec.Command to return a message
// with an error, which may or may not be nil.
type ExecCallback func(error) Msg
// ExecCommand can be implemented to execute things in a blocking fashion in
// the current terminal.
type ExecCommand interface {
Run() error
SetStdin(io.Reader)
SetStdout(io.Writer)
SetStderr(io.Writer)
}
// wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand
// interface so it can be used with Exec.
func wrapExecCommand(c *exec.Cmd) ExecCommand {
return &osExecCommand{Cmd: c}
}
// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand
// interface.
type osExecCommand struct{ *exec.Cmd }
// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader.
func (c *osExecCommand) SetStdin(r io.Reader) {
// If unset, have the command use the same input as the terminal.
if c.Stdin == nil {
c.Stdin = r
}
}
// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer.
func (c *osExecCommand) SetStdout(w io.Writer) {
// If unset, have the command use the same output as the terminal.
if c.Stdout == nil {
c.Stdout = w
}
}
// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer.
func (c *osExecCommand) SetStderr(w io.Writer) {
// If unset, use stderr for the command's stderr
if c.Stderr == nil {
c.Stderr = w
}
}
// exec runs an ExecCommand and delivers the results to the program as a Msg.
func (p *Program) exec(c ExecCommand, fn ExecCallback) {
if err := p.releaseTerminal(false); err != nil {
// If we can't release input, abort.
if fn != nil {
go p.Send(fn(err))
}
return
}
c.SetStdin(p.input)
c.SetStdout(p.output)
c.SetStderr(os.Stderr)
// Execute system command.
if err := c.Run(); err != nil {
_ = p.RestoreTerminal() // also try to restore the terminal.
if fn != nil {
go p.Send(fn(err))
}
return
}
// Have the program re-capture input.
err := p.RestoreTerminal()
if fn != nil {
go p.Send(fn(err))
}
}
+9
View File
@@ -0,0 +1,9 @@
package tea
// FocusMsg represents a terminal focus message.
// This occurs when the terminal gains focus.
type FocusMsg struct{}
// BlurMsg represents a terminal blur message.
// This occurs when the terminal loses focus.
type BlurMsg struct{}
+54
View File
@@ -0,0 +1,54 @@
package tea
import (
uv "github.com/charmbracelet/ultraviolet"
)
// translateInputEvent translates an input event into a Bubble Tea Msg.
func (p *Program) translateInputEvent(e uv.Event) Msg {
switch e := e.(type) {
case uv.ClipboardEvent:
return ClipboardMsg(e)
case uv.ForegroundColorEvent:
return ForegroundColorMsg(e)
case uv.BackgroundColorEvent:
return BackgroundColorMsg(e)
case uv.CursorColorEvent:
return CursorColorMsg(e)
case uv.CursorPositionEvent:
return CursorPositionMsg(e)
case uv.FocusEvent:
return FocusMsg(e)
case uv.BlurEvent:
return BlurMsg(e)
case uv.KeyPressEvent:
return KeyPressMsg(e)
case uv.KeyReleaseEvent:
return KeyReleaseMsg(e)
case uv.MouseClickEvent:
return MouseClickMsg(e)
case uv.MouseMotionEvent:
return MouseMotionMsg(e)
case uv.MouseReleaseEvent:
return MouseReleaseMsg(e)
case uv.MouseWheelEvent:
return MouseWheelMsg(e)
case uv.PasteEvent:
return PasteMsg(e)
case uv.PasteStartEvent:
return PasteStartMsg(e)
case uv.PasteEndEvent:
return PasteEndMsg(e)
case uv.WindowSizeEvent:
return WindowSizeMsg(e)
case uv.CapabilityEvent:
return CapabilityMsg(e)
case uv.TerminalVersionEvent:
return TerminalVersionMsg(e)
case uv.KeyboardEnhancementsEvent:
return KeyboardEnhancementsMsg(e)
case uv.ModeReportEvent:
return ModeReportMsg(e)
}
return e
}
+371
View File
@@ -0,0 +1,371 @@
package tea
import (
"fmt"
uv "github.com/charmbracelet/ultraviolet"
)
const (
// KeyExtended is a special key code used to signify that a key event
// contains multiple runes.
KeyExtended = uv.KeyExtended
)
// Special key symbols.
const (
// Special keys.
KeyUp = uv.KeyUp
KeyDown = uv.KeyDown
KeyRight = uv.KeyRight
KeyLeft = uv.KeyLeft
KeyBegin = uv.KeyBegin
KeyFind = uv.KeyFind
KeyInsert = uv.KeyInsert
KeyDelete = uv.KeyDelete
KeySelect = uv.KeySelect
KeyPgUp = uv.KeyPgUp
KeyPgDown = uv.KeyPgDown
KeyHome = uv.KeyHome
KeyEnd = uv.KeyEnd
// Keypad keys.
KeyKpEnter = uv.KeyKpEnter
KeyKpEqual = uv.KeyKpEqual
KeyKpMultiply = uv.KeyKpMultiply
KeyKpPlus = uv.KeyKpPlus
KeyKpComma = uv.KeyKpComma
KeyKpMinus = uv.KeyKpMinus
KeyKpDecimal = uv.KeyKpDecimal
KeyKpDivide = uv.KeyKpDivide
KeyKp0 = uv.KeyKp0
KeyKp1 = uv.KeyKp1
KeyKp2 = uv.KeyKp2
KeyKp3 = uv.KeyKp3
KeyKp4 = uv.KeyKp4
KeyKp5 = uv.KeyKp5
KeyKp6 = uv.KeyKp6
KeyKp7 = uv.KeyKp7
KeyKp8 = uv.KeyKp8
KeyKp9 = uv.KeyKp9
// The following are keys defined in the Kitty keyboard protocol.
// XXX: Investigate the names of these keys.
KeyKpSep = uv.KeyKpSep
KeyKpUp = uv.KeyKpUp
KeyKpDown = uv.KeyKpDown
KeyKpLeft = uv.KeyKpLeft
KeyKpRight = uv.KeyKpRight
KeyKpPgUp = uv.KeyKpPgUp
KeyKpPgDown = uv.KeyKpPgDown
KeyKpHome = uv.KeyKpHome
KeyKpEnd = uv.KeyKpEnd
KeyKpInsert = uv.KeyKpInsert
KeyKpDelete = uv.KeyKpDelete
KeyKpBegin = uv.KeyKpBegin
// Function keys.
KeyF1 = uv.KeyF1
KeyF2 = uv.KeyF2
KeyF3 = uv.KeyF3
KeyF4 = uv.KeyF4
KeyF5 = uv.KeyF5
KeyF6 = uv.KeyF6
KeyF7 = uv.KeyF7
KeyF8 = uv.KeyF8
KeyF9 = uv.KeyF9
KeyF10 = uv.KeyF10
KeyF11 = uv.KeyF11
KeyF12 = uv.KeyF12
KeyF13 = uv.KeyF13
KeyF14 = uv.KeyF14
KeyF15 = uv.KeyF15
KeyF16 = uv.KeyF16
KeyF17 = uv.KeyF17
KeyF18 = uv.KeyF18
KeyF19 = uv.KeyF19
KeyF20 = uv.KeyF20
KeyF21 = uv.KeyF21
KeyF22 = uv.KeyF22
KeyF23 = uv.KeyF23
KeyF24 = uv.KeyF24
KeyF25 = uv.KeyF25
KeyF26 = uv.KeyF26
KeyF27 = uv.KeyF27
KeyF28 = uv.KeyF28
KeyF29 = uv.KeyF29
KeyF30 = uv.KeyF30
KeyF31 = uv.KeyF31
KeyF32 = uv.KeyF32
KeyF33 = uv.KeyF33
KeyF34 = uv.KeyF34
KeyF35 = uv.KeyF35
KeyF36 = uv.KeyF36
KeyF37 = uv.KeyF37
KeyF38 = uv.KeyF38
KeyF39 = uv.KeyF39
KeyF40 = uv.KeyF40
KeyF41 = uv.KeyF41
KeyF42 = uv.KeyF42
KeyF43 = uv.KeyF43
KeyF44 = uv.KeyF44
KeyF45 = uv.KeyF45
KeyF46 = uv.KeyF46
KeyF47 = uv.KeyF47
KeyF48 = uv.KeyF48
KeyF49 = uv.KeyF49
KeyF50 = uv.KeyF50
KeyF51 = uv.KeyF51
KeyF52 = uv.KeyF52
KeyF53 = uv.KeyF53
KeyF54 = uv.KeyF54
KeyF55 = uv.KeyF55
KeyF56 = uv.KeyF56
KeyF57 = uv.KeyF57
KeyF58 = uv.KeyF58
KeyF59 = uv.KeyF59
KeyF60 = uv.KeyF60
KeyF61 = uv.KeyF61
KeyF62 = uv.KeyF62
KeyF63 = uv.KeyF63
// The following are keys defined in the Kitty keyboard protocol.
// XXX: Investigate the names of these keys.
KeyCapsLock = uv.KeyCapsLock
KeyScrollLock = uv.KeyScrollLock
KeyNumLock = uv.KeyNumLock
KeyPrintScreen = uv.KeyPrintScreen
KeyPause = uv.KeyPause
KeyMenu = uv.KeyMenu
KeyMediaPlay = uv.KeyMediaPlay
KeyMediaPause = uv.KeyMediaPause
KeyMediaPlayPause = uv.KeyMediaPlayPause
KeyMediaReverse = uv.KeyMediaReverse
KeyMediaStop = uv.KeyMediaStop
KeyMediaFastForward = uv.KeyMediaFastForward
KeyMediaRewind = uv.KeyMediaRewind
KeyMediaNext = uv.KeyMediaNext
KeyMediaPrev = uv.KeyMediaPrev
KeyMediaRecord
KeyLowerVol = uv.KeyLowerVol
KeyRaiseVol = uv.KeyRaiseVol
KeyMute = uv.KeyMute
KeyLeftShift = uv.KeyLeftShift
KeyLeftAlt = uv.KeyLeftAlt
KeyLeftCtrl = uv.KeyLeftCtrl
KeyLeftSuper = uv.KeyLeftSuper
KeyLeftHyper = uv.KeyLeftHyper
KeyLeftMeta = uv.KeyLeftMeta
KeyRightShift = uv.KeyRightShift
KeyRightAlt = uv.KeyRightAlt
KeyRightCtrl = uv.KeyRightCtrl
KeyRightSuper = uv.KeyRightSuper
KeyRightHyper = uv.KeyRightHyper
KeyRightMeta = uv.KeyRightMeta
KeyIsoLevel3Shift = uv.KeyIsoLevel3Shift
KeyIsoLevel5Shift = uv.KeyIsoLevel5Shift
// Special names in C0.
KeyBackspace = uv.KeyBackspace
KeyTab = uv.KeyTab
KeyEnter = uv.KeyEnter
KeyReturn = uv.KeyReturn
KeyEscape = uv.KeyEscape
KeyEsc = uv.KeyEsc
// Special names in G0.
KeySpace = uv.KeySpace
)
// KeyPressMsg represents a key press message.
type KeyPressMsg Key
// String implements [fmt.Stringer] and is quite useful for matching key
// events. For details, on what this returns see [Key.String].
func (k KeyPressMsg) 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 KeyPressMsg) Keystroke() string {
return uv.Key(k).Keystroke()
}
// Key returns the underlying key event. This is a syntactic sugar for casting
// the key event to a [Key].
func (k KeyPressMsg) Key() Key {
return Key(k)
}
// KeyReleaseMsg represents a key release message.
type KeyReleaseMsg Key
// String implements [fmt.Stringer] and is quite useful for matching key
// events. For details, on what this returns see [Key.String].
func (k KeyReleaseMsg) 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 KeyReleaseMsg) Keystroke() string {
return uv.Key(k).Keystroke()
}
// Key returns the underlying key event. This is a convenience method and
// syntactic sugar to satisfy the [KeyMsg] interface, and cast the key event to
// [Key].
func (k KeyReleaseMsg) Key() Key {
return Key(k)
}
// KeyMsg represents a key event. This can be either a key press or a key
// release event.
type KeyMsg 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 msg := msg.(type) {
// case KeyPressMsg:
// switch msg.String() {
// case "enter":
// fmt.Println("you pressed enter!")
// case "a":
// fmt.Println("you pressed a!")
// }
// }
//
// // Switch on the key type (more foolproof)
// switch msg := msg.(type) {
// case KeyMsg:
// // catch both KeyPressMsg and KeyReleaseMsg
// switch key := msg.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 {
return uv.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 Key) Keystroke() string {
return uv.Key(k).Keystroke()
}
+41
View File
@@ -0,0 +1,41 @@
package tea
import (
"github.com/charmbracelet/x/ansi"
)
// KeyboardEnhancementsMsg is a message that gets sent when the terminal
// supports keyboard enhancements.
type KeyboardEnhancementsMsg struct {
// Flags is a bitmask of enabled keyboard enhancement features. A non-zero
// value indicates that at least we have key disambiguation support.
//
// See [ansi.KittyReportEventTypes] and other constants for details.
//
// Example:
//
// ```go
// // The hard way
// if msg.Flags&ansi.KittyReportEventTypes != 0 {
// // Terminal supports reporting different key event types
// }
//
// // The easy way
// if msg.SupportsEventTypes() {
// // Terminal supports reporting different key event types
// }
// ```
Flags int
}
// SupportsKeyDisambiguation returns whether the terminal supports key
// disambiguation (e.g., distinguishing between different modifier keys).
func (k KeyboardEnhancementsMsg) SupportsKeyDisambiguation() bool {
return k.Flags > 0
}
// SupportsEventTypes returns whether the terminal supports reporting
// different types of key events (press, release, and repeat).
func (k KeyboardEnhancementsMsg) SupportsEventTypes() bool {
return k.Flags&ansi.KittyReportEventTypes != 0
}
+53
View File
@@ -0,0 +1,53 @@
package tea
import (
"fmt"
"io"
"log"
"os"
"unicode"
)
// LogToFile sets up default logging to log to a file. This is helpful as we
// can't print to the terminal since our TUI is occupying it. If the file
// doesn't exist it will be created.
//
// Don't forget to close the file when you're done with it.
//
// f, err := LogToFile("debug.log", "debug")
// if err != nil {
// fmt.Println("fatal:", err)
// os.Exit(1)
// }
// defer f.Close()
func LogToFile(path string, prefix string) (*os.File, error) {
return LogToFileWith(path, prefix, log.Default())
}
// LogOptionsSetter is an interface implemented by stdlib's log and charm's log
// libraries.
type LogOptionsSetter interface {
SetOutput(io.Writer)
SetPrefix(string)
}
// LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter.
func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd
if err != nil {
return nil, fmt.Errorf("error opening file for logging: %w", err)
}
log.SetOutput(f)
// Add a space after the prefix if a prefix is being specified and it
// doesn't already have a trailing space.
if len(prefix) > 0 {
finalChar := prefix[len(prefix)-1]
if !unicode.IsSpace(rune(finalChar)) {
prefix += " "
}
}
log.SetPrefix(prefix)
return f, nil
}
+27
View File
@@ -0,0 +1,27 @@
package tea
import uv "github.com/charmbracelet/ultraviolet"
// KeyMod represents modifier keys.
type KeyMod = uv.KeyMod
// Modifier keys.
const (
ModShift = uv.ModShift
ModAlt = uv.ModAlt
ModCtrl = uv.ModCtrl
ModMeta = uv.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 = uv.ModHyper
ModSuper = uv.ModSuper // Windows/Command keys
// These are key lock states.
ModCapsLock = uv.ModCapsLock
ModNumLock = uv.ModNumLock
ModScrollLock = uv.ModScrollLock // Defined in Windows API only
)
+144
View File
@@ -0,0 +1,144 @@
package tea
import (
"fmt"
uv "github.com/charmbracelet/ultraviolet"
)
// MouseButton represents the button that was pressed during a mouse message.
type MouseButton = uv.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 = uv.MouseNone
MouseLeft = uv.MouseLeft
MouseMiddle = uv.MouseMiddle
MouseRight = uv.MouseRight
MouseWheelUp = uv.MouseWheelUp
MouseWheelDown = uv.MouseWheelDown
MouseWheelLeft = uv.MouseWheelLeft
MouseWheelRight = uv.MouseWheelRight
MouseBackward = uv.MouseBackward
MouseForward = uv.MouseForward
MouseButton10 = uv.MouseButton10
MouseButton11
)
// MouseMsg represents a mouse message. This is a generic mouse message that
// can represent any kind of mouse event.
type MouseMsg interface {
fmt.Stringer
// Mouse returns the underlying mouse event.
Mouse() Mouse
}
// Mouse represents a Mouse message. Use [MouseMsg] 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 msg := msg.(type) {
// case MouseMsg:
// m := msg.Mouse()
// fmt.Println("Mouse event:", m.X, m.Y, m)
// }
//
// // Only catch mouse click events
// switch msg := msg.(type) {
// case MouseClickMsg:
// fmt.Println("Mouse click event:", msg.X, msg.Y, msg)
// }
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) {
return uv.Mouse(m).String()
}
// MouseClickMsg represents a mouse button click message.
type MouseClickMsg Mouse
// String returns a string representation of the mouse click message.
func (e MouseClickMsg) String() string {
return Mouse(e).String()
}
// Mouse returns the underlying mouse event. This is a convenience method and
// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse
// event to [Mouse].
func (e MouseClickMsg) Mouse() Mouse {
return Mouse(e)
}
// MouseReleaseMsg represents a mouse button release message.
type MouseReleaseMsg Mouse
// String returns a string representation of the mouse release message.
func (e MouseReleaseMsg) String() string {
return Mouse(e).String()
}
// Mouse returns the underlying mouse event. This is a convenience method and
// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse
// event to [Mouse].
func (e MouseReleaseMsg) Mouse() Mouse {
return Mouse(e)
}
// MouseWheelMsg represents a mouse wheel message event.
type MouseWheelMsg Mouse
// String returns a string representation of the mouse wheel message.
func (e MouseWheelMsg) String() string {
return Mouse(e).String()
}
// Mouse returns the underlying mouse event. This is a convenience method and
// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse
// event to [Mouse].
func (e MouseWheelMsg) Mouse() Mouse {
return Mouse(e)
}
// MouseMotionMsg represents a mouse motion message.
type MouseMotionMsg Mouse
// String returns a string representation of the mouse motion message.
func (e MouseMotionMsg) 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 [MouseMsg] interface, and cast the mouse
// event to [Mouse].
func (e MouseMotionMsg) Mouse() Mouse {
return Mouse(e)
}
+53
View File
@@ -0,0 +1,53 @@
package tea
import (
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
)
// nilRenderer is a no-op renderer. It implements the Renderer interface but
// doesn't render anything to the terminal.
type nilRenderer struct{}
var _ renderer = nilRenderer{}
// start implements renderer.
func (n nilRenderer) start() {}
// clearScreen implements renderer.
func (n nilRenderer) clearScreen() {}
// insertAbove implements renderer.
func (n nilRenderer) insertAbove(string) error { return nil }
// resize implements renderer.
func (n nilRenderer) resize(int, int) {}
// setColorProfile implements renderer.
func (n nilRenderer) setColorProfile(colorprofile.Profile) {}
// flush implements the Renderer interface.
func (nilRenderer) flush(bool) error { return nil }
// close implements the Renderer interface.
func (nilRenderer) close() error { return nil }
// render implements the Renderer interface.
func (nilRenderer) render(View) {}
// reset implements the Renderer interface.
func (nilRenderer) reset() {}
// writeString implements the Renderer interface.
func (nilRenderer) writeString(string) (int, error) { return 0, nil }
// setSyncdUpdates implements the Renderer interface.
func (n nilRenderer) setSyncdUpdates(bool) {}
// setWidthMethod implements the Renderer interface.
func (n nilRenderer) setWidthMethod(ansi.Method) {}
// onMouse implements the Renderer interface.
func (n nilRenderer) onMouse(MouseMsg) Cmd {
return nil
}
+168
View File
@@ -0,0 +1,168 @@
package tea
import (
"context"
"io"
"sync/atomic"
"github.com/charmbracelet/colorprofile"
)
// ProgramOption is used to set options when initializing a Program. Program can
// accept a variable number of options.
//
// Example usage:
//
// p := NewProgram(model, WithInput(someInput), WithOutput(someOutput))
type ProgramOption func(*Program)
// WithContext lets you specify a context in which to run the Program. This is
// useful if you want to cancel the execution from outside. When a Program gets
// cancelled it will exit with an error ErrProgramKilled.
func WithContext(ctx context.Context) ProgramOption {
return func(p *Program) {
p.externalCtx = ctx
}
}
// WithOutput sets the output which, by default, is stdout. In most cases you
// won't need to use this.
func WithOutput(output io.Writer) ProgramOption {
return func(p *Program) {
p.output = output
}
}
// WithInput sets the input which, by default, is stdin. In most cases you
// won't need to use this. To disable input entirely pass nil.
//
// p := NewProgram(model, WithInput(nil))
func WithInput(input io.Reader) ProgramOption {
return func(p *Program) {
p.input = input
p.disableInput = input == nil
}
}
// WithEnvironment sets the environment variables that the program will use.
// This useful when the program is running in a remote session (e.g. SSH) and
// you want to pass the environment variables from the remote session to the
// program.
//
// Example:
//
// var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package
// pty, _, _ := sess.Pty()
// environ := append(sess.Environ(), "TERM="+pty.Term)
// p := tea.NewProgram(model, tea.WithEnvironment(environ)
func WithEnvironment(env []string) ProgramOption {
return func(p *Program) {
p.environ = env
}
}
// WithoutSignalHandler disables the signal handler that Bubble Tea sets up for
// Programs. This is useful if you want to handle signals yourself.
func WithoutSignalHandler() ProgramOption {
return func(p *Program) {
p.disableSignalHandler = true
}
}
// WithoutCatchPanics disables the panic catching that Bubble Tea does by
// default. If panic catching is disabled the terminal will be in a fairly
// unusable state after a panic because Bubble Tea will not perform its usual
// cleanup on exit.
func WithoutCatchPanics() ProgramOption {
return func(p *Program) {
p.disableCatchPanics = true
}
}
// WithoutSignals will ignore OS signals.
// This is mainly useful for testing.
func WithoutSignals() ProgramOption {
return func(p *Program) {
atomic.StoreUint32(&p.ignoreSignals, 1)
}
}
// WithoutRenderer disables the renderer. When this is set output and log
// statements will be plainly sent to stdout (or another output if one is set)
// without any rendering and redrawing logic. In other words, printing and
// logging will behave the same way it would in a non-TUI commandline tool.
// This can be useful if you want to use the Bubble Tea framework for a non-TUI
// application, or to provide an additional non-TUI mode to your Bubble Tea
// programs. For example, your program could behave like a daemon if output is
// not a TTY.
func WithoutRenderer() ProgramOption {
return func(p *Program) {
p.disableRenderer = true
}
}
// WithFilter supplies an event filter that will be invoked before Bubble Tea
// processes a tea.Msg. The event filter can return any tea.Msg which will then
// get handled by Bubble Tea instead of the original event. If the event filter
// returns nil, the event will be ignored and Bubble Tea will not process it.
//
// As an example, this could be used to prevent a program from shutting down if
// there are unsaved changes.
//
// Example:
//
// func filter(m tea.Model, msg tea.Msg) tea.Msg {
// if _, ok := msg.(tea.QuitMsg); !ok {
// return msg
// }
//
// model := m.(myModel)
// if model.hasChanges {
// return nil
// }
//
// return msg
// }
//
// p := tea.NewProgram(Model{}, tea.WithFilter(filter));
//
// if _,err := p.Run(); err != nil {
// fmt.Println("Error running program:", err)
// os.Exit(1)
// }
func WithFilter(filter func(Model, Msg) Msg) ProgramOption {
return func(p *Program) {
p.filter = filter
}
}
// WithFPS sets a custom maximum FPS at which the renderer should run. If
// less than 1, the default value of 60 will be used. If over 120, the FPS
// will be capped at 120.
func WithFPS(fps int) ProgramOption {
return func(p *Program) {
p.fps = fps
}
}
// WithColorProfile sets the color profile that the program will use. This is
// useful when you want to force a specific color profile. By default, Bubble
// Tea will try to detect the terminal's color profile from environment
// variables and terminfo capabilities. Use [tea.WithEnvironment] to set custom
// environment variables.
func WithColorProfile(profile colorprofile.Profile) ProgramOption {
return func(p *Program) {
p.profile = &profile
}
}
// WithWindowSize sets the initial size of the terminal window. This is useful
// when you need to set the initial size of the terminal window, for example
// during testing or when you want to run your program in a non-interactive
// environment.
func WithWindowSize(width, height int) ProgramOption {
return func(p *Program) {
p.width = width
p.height = height
}
}
+20
View File
@@ -0,0 +1,20 @@
package tea
// PasteMsg is an message that is emitted when a terminal receives pasted text
// using bracketed-paste.
type PasteMsg struct {
Content string
}
// String returns the pasted content as a string.
func (p PasteMsg) String() string {
return p.Content
}
// PasteStartMsg is an message that is emitted when the terminal starts the
// bracketed-paste text.
type PasteStartMsg struct{}
// PasteEndMsg is an message that is emitted when the terminal ends the
// bracketed-paste text.
type PasteEndMsg struct{}
+15
View File
@@ -0,0 +1,15 @@
package tea
import "github.com/charmbracelet/colorprofile"
// ColorProfileMsg is a message that describes the terminal's color profile.
// This message is send to the program's update function when the program is
// started.
//
// To upgrade the terminal color profile, use the `tea.RequestCapability`
// command to request the `RGB` and `Tc` terminfo capabilities. Bubble Tea will
// then cache the terminal's color profile and send a `ColorProfileMsg` to the
// program's update function.
type ColorProfileMsg struct {
colorprofile.Profile
}
+37
View File
@@ -0,0 +1,37 @@
package tea
// RawMsg is a message that contains a string to be printed to the terminal
// without any intermediate processing.
type RawMsg struct {
Msg any
}
// Raw is a command that prints the given string to the terminal without any
// formatting.
//
// This is intended for advanced use cases where you need to query the terminal
// or send escape sequences directly. Don't use this unless you know what
// you're doing :)
//
// Example:
//
// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// switch msg := msg.(type) {
// case input.PrimaryDeviceAttributesEvent:
// for _, attr := range msg {
// if attr == 4 {
// // We have Sixel graphics support!
// break
// }
// }
// }
//
// // Request the terminal primary device attributes to detect Sixel graphics
// // support.
// return m, tea.Raw(ansi.RequestPrimaryDeviceAttributes)
// }
func Raw(r any) Cmd {
return func() Msg {
return RawMsg{r}
}
}
+104
View File
@@ -0,0 +1,104 @@
package tea
import (
"fmt"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
)
const (
// defaultFramerate specifies the maximum interval at which we should
// update the view.
defaultFPS = 60
maxFPS = 120
)
// renderer is the interface for Bubble Tea renderers.
type renderer interface {
// start starts the renderer.
start()
// close closes the renderer and flushes any remaining data.
close() error
// render renders a frame to the output.
render(View)
// flush flushes the renderer's buffer to the output.
flush(closing bool) error
// reset resets the renderer's state to its initial state.
reset()
// insertAbove inserts unmanaged lines above the renderer.
insertAbove(string) error
// setSyncdUpdates sets whether to use synchronized updates.
setSyncdUpdates(bool)
// setWidthMethod sets the method for calculating the width of the terminal.
setWidthMethod(ansi.Method)
// resize notify the renderer of a terminal resize.
resize(int, int)
// setColorProfile sets the color profile.
setColorProfile(colorprofile.Profile)
// clearScreen clears the screen.
clearScreen()
// writeString writes a string to the renderer's output.
writeString(string) (int, error)
// onMouse handles a mouse event.
onMouse(MouseMsg) Cmd
}
type printLineMessage struct {
messageBody string
}
// Println prints above the Program. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Println (but similar to log.Println) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func Println(args ...any) Cmd {
return func() Msg {
return printLineMessage{
messageBody: fmt.Sprint(args...),
}
}
}
// Printf prints above the Program. It takes a format template followed by
// values similar to fmt.Printf. This output is unmanaged by the program and
// will persist across renders by the Program.
//
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
// its own line.
//
// If the altscreen is active no output will be printed.
func Printf(template string, args ...any) Cmd {
return func() Msg {
return printLineMessage{
messageBody: fmt.Sprintf(template, args...),
}
}
}
// encodeCursorStyle returns the integer value for the given cursor style and
// blink state.
func encodeCursorStyle(style CursorShape, blink bool) int {
// We're using the ANSI escape sequence values for cursor styles.
// We need to map both [style] and [steady] to the correct value.
style = (style * 2) + 1 //nolint:mnd
if !blink {
style++
}
return int(style)
}
+68
View File
@@ -0,0 +1,68 @@
package tea
import "github.com/charmbracelet/x/ansi"
// WindowSizeMsg is used to report the terminal size. It's sent to Update once
// initially and then on every terminal resize. Note that Windows does not
// have support for reporting when resizes occur as it does not support the
// SIGWINCH signal.
type WindowSizeMsg struct {
Width int
Height int
}
// ClearScreen is a special command that tells the program to clear the screen
// before the next update. This can be used to move the cursor to the top left
// of the screen and clear visual clutter when the alt screen is not in use.
//
// Note that it should never be necessary to call ClearScreen() for regular
// redraws.
func ClearScreen() Msg {
return clearScreenMsg{}
}
// clearScreenMsg is an internal message that signals to clear the screen.
// You can send a clearScreenMsg with ClearScreen.
type clearScreenMsg struct{}
// ModeReportMsg is a message that represents a mode report event (DECRPM).
//
// This is sent by the terminal in response to a request for a terminal mode
// report (DECRQM). It indicates the current setting of a specific terminal
// mode like cursor visibility, mouse tracking, etc.
//
// Example:
//
// ```go
// func (m model) Init() tea.Cmd {
// // Does my terminal support reporting focus events?
// return tea.Raw(ansi.RequestModeFocusEvent)
// }
//
// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// switch msg := msg.(type) {
// case tea.ModeReportMsg:
// if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() {
// // Terminal supports focus events
// m.supportsFocus = true
// }
// }
// return m, nil
// }
//
// func (m model) View() tea.View {
// var view tea.View
// view.ReportFocus = m.supportsFocus
// view.SetContent(fmt.Sprintf("Terminal supports focus events: %v", m.supportsFocus))
// return view
// }
// ```
//
// See: https://vt100.net/docs/vt510-rm/DECRPM.html
type ModeReportMsg struct {
// Mode is the mode number.
Mode ansi.Mode
// Value is the mode value.
Value ansi.ModeSetting
}
+33
View File
@@ -0,0 +1,33 @@
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos
// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos
package tea
import (
"os"
"os/signal"
"syscall"
)
// listenForResize sends messages (or errors) when the terminal resizes.
// Argument output should be the file descriptor for the terminal; usually
// os.Stdout.
func (p *Program) listenForResize(done chan struct{}) {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGWINCH)
defer func() {
signal.Stop(sig)
close(done)
}()
for {
select {
case <-p.ctx.Done():
return
case <-sig:
}
p.checkResize()
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build windows
// +build windows
package tea
// listenForResize is not available on windows because windows does not
// implement syscall.SIGWINCH.
func (p *Program) listenForResize(done chan struct{}) {
close(done)
}
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
package tea
// requestCapabilityMsg is an internal message that requests the terminal to
// send its Termcap/Terminfo response.
type requestCapabilityMsg string
// RequestCapability is a command that requests the terminal to send its
// Termcap/Terminfo response for the given capability.
//
// Bubble Tea recognizes the following capabilities and will use them to
// upgrade the program's color profile:
// - "RGB" Xterm direct color
// - "Tc" True color support
//
// Note: that some terminal's like Apple's Terminal.app do not support this and
// will send the wrong response to the terminal breaking the program's output.
//
// When the Bubble Tea advertises a non-TrueColor profile, you can use this
// command to query the terminal for its color capabilities. Example:
//
// switch msg := msg.(type) {
// case tea.ColorProfileMsg:
// if msg.Profile != colorprofile.TrueColor {
// return m, tea.Batch(
// tea.RequestCapability("RGB"),
// tea.RequestCapability("Tc"),
// )
// }
// }
func RequestCapability(s string) Cmd {
return func() Msg {
return requestCapabilityMsg(s)
}
}
// CapabilityMsg 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 CapabilityMsg struct {
Content string
}
// String returns the capability content as a string.
func (c CapabilityMsg) String() string {
return c.Content
}
+13
View File
@@ -0,0 +1,13 @@
//go:build dragonfly || freebsd
// +build dragonfly freebsd
package tea
import (
"github.com/charmbracelet/x/term"
"golang.org/x/sys/unix"
)
func (p *Program) checkOptimizedMovements(s *term.State) {
p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows && !darwin && !dragonfly && !freebsd && !linux && !solaris && !aix
// +build !windows,!darwin,!dragonfly,!freebsd,!linux,!solaris,!aix
package tea
import "github.com/charmbracelet/x/term"
func (*Program) checkOptimizedMovements(*term.State) {}
+14
View File
@@ -0,0 +1,14 @@
//go:build darwin || linux || solaris || aix
// +build darwin linux solaris aix
package tea
import (
"github.com/charmbracelet/x/term"
"golang.org/x/sys/unix"
)
func (p *Program) checkOptimizedMovements(s *term.State) {
p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0
p.useBackspace = s.Lflag&unix.BSDLY == unix.BS0
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
// +build windows
package tea
import "github.com/charmbracelet/x/term"
func (p *Program) checkOptimizedMovements(*term.State) {
p.useHardTabs = true
p.useBackspace = true
}
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff. Show more