",
+ Short: "Git command",
+ GroupID: "tools",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("Git command invoked without a specific subcommand")
+ cmd.Usage()
+ },
+ }
+ rootCmd.AddCommand(gitCmd)
+
+ cloneCmd := &cobra.Command{
+ Use: "clone REPO_URL [DESTINATION]",
+ Short: "Clone a repository",
+ Args: cobra.RangeArgs(1, 2),
+ Run: func(cmd *cobra.Command, args []string) {
+ repoURL := args[0]
+ destination := "./"
+ if len(args) == 2 {
+ destination = args[1]
+ }
+
+ fmt.Printf("Cloning repository: %s to %s\n", repoURL, destination)
+
+ gitArgs := []string{"clone", repoURL, destination}
+
+ gitCmd := exec.Command("git", gitArgs...)
+ gitCmd.Stdout = os.Stdout
+ gitCmd.Stderr = os.Stderr
+
+ err := gitCmd.Run()
+ if err != nil {
+ fmt.Printf("Git clone failed: %s\n", err)
+ os.Exit(1)
+ }
+ },
+ }
+
+ cloneCmd.Flags().StringP("branch", "b", "", "Checkout a specific branch")
+ cloneCmd.Flags().Bool("bare", false, "Create a bare repository")
+
+ gitCmd.AddCommand(cloneCmd)
+
+ checkoutCmd := &cobra.Command{
+ Use: "checkout [flags] [BRANCH]",
+ Short: "Switch branches or restore working tree files",
+ Args: cobra.MaximumNArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ if len(args) == 0 {
+ // Default behavior, print help message
+ cmd.Help()
+ return
+ }
+
+ branch := args[0]
+
+ // Implementation logic for git checkout command
+
+ // Customize or extend the logic as needed
+
+ fmt.Println("Running git checkout command with branch:", branch)
+ },
+ }
+
+ checkoutCmd.Flags().BoolP("force", "f", false, "Force checkout")
+ checkoutCmd.Flags().BoolP("create", "b", false, "Create and checkout a new branch")
+ gitCmd.AddCommand(checkoutCmd)
+
+ commitCmd := &cobra.Command{
+ Use: "commit [flags]",
+ Short: "Record changes to the repository",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("Running git commit command")
+
+ // Implementation logic for git commit command
+
+ // Customize or extend the logic as needed
+ },
+ }
+
+ commitCmd.Flags().StringP("message", "m", "", "Commit message")
+ commitCmd.Flags().Bool("amend", false, "Amend the previous commit")
+ gitCmd.AddCommand(commitCmd)
+
+ pullCmd := &cobra.Command{
+ Use: "pull [flags]",
+ Short: "Fetch from and integrate with another repository or a local branch",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("Running git pull command")
+
+ // Implementation logic for git pull command
+
+ // Customize or extend the logic as needed
+ },
+ }
+
+ pullCmd.Flags().String("rebase", "", "Rebase local branch onto fetched branch")
+ gitCmd.AddCommand(pullCmd)
+
+ pushCmd := &cobra.Command{
+ Use: "push [flags]",
+ Short: "Update remote refs along with associated objects",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("Running git push command")
+
+ // Implementation logic for git push command
+
+ // Customize or extend the logic as needed
+ },
+ }
+
+ pushCmd.Flags().BoolP("force", "f", false, "Force push")
+ pushCmd.Flags().Bool("tags", false, "Push tags")
+
+ // Add more flags or logic as needed
+ gitCmd.AddCommand(pushCmd)
+
+ downloadCmd := &cobra.Command{
+ Use: "download [flags] URL DESTINATION",
+ Short: "Download a file from a URL",
+ GroupID: "filesystem",
+ Args: cobra.ExactArgs(2),
+ Run: func(cmd *cobra.Command, args []string) {
+ url := args[0]
+ destination := args[1]
+
+ // Implementation logic for download command
+
+ // Customize or extend the logic as needed
+
+ fmt.Printf("Downloading file from URL: %s to destination: %s\n", url, destination)
+ },
+ }
+
+ downloadCmd.Flags().BoolP("verbose", "v", false, "Print verbose output")
+ downloadCmd.Flags().StringP("user-agent", "u", "", "Set the User-Agent header")
+ rootCmd.AddCommand(downloadCmd)
+
+ encryptCmd := &cobra.Command{
+ Use: "encrypt [flags] FILE",
+ Short: "Encrypt a file",
+ GroupID: "filesystem",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ file := args[0]
+
+ // Implementation logic for encrypt command
+
+ // Customize or extend the logic as needed
+
+ fmt.Println("Encrypting file:", file)
+ },
+ }
+
+ encryptCmd.Flags().StringP("algorithm", "a", "aes256", "Set the encryption algorithm")
+ encryptCmd.Flags().BoolP("force", "f", false, "Force encryption")
+ encryptCmd.Flags().StringP("key", "k", "", "Specify the encryption key")
+ encryptCmd.Flags().StringP("output", "o", "", "Specify the output file")
+
+ rootCmd.AddCommand(encryptCmd)
+
+ searchCmd := &cobra.Command{
+ Use: "search [flags] QUERY",
+ Short: "Search for a query",
+ GroupID: "filesystem",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ query := args[0]
+
+ // Implementation logic for search command
+
+ // Customize or extend the logic as needed
+
+ fmt.Println("Searching for query:", query)
+ },
+ }
+ searchCmd.Flags().BoolP("case-sensitive", "c", false, "Perform case-sensitive search")
+ searchCmd.Flags().BoolP("regex", "r", false, "Interpret the query as a regular expression")
+ searchCmd.Flags().BoolP("verbose", "v", false, "Print verbose output")
+
+ rootCmd.AddCommand(searchCmd)
+
+ mySlice := []string{"a", "b", "c"}
+
+ backupCmd := &cobra.Command{
+ Use: "backup [flags] SOURCE DESTINATION",
+ Short: "Create a backup of a file or directory",
+ GroupID: "filesystem",
+ Args: cobra.ExactArgs(2),
+ Run: func(cmd *cobra.Command, args []string) {
+ source := args[0]
+ destination := args[1]
+
+ flagsVal, _ := cmd.Flags().GetStringSlice("test-slice")
+
+ // Implementation logic for backup command
+ fmt.Printf("mySlice: %v\n", mySlice)
+ fmt.Printf("mySlice flags: %v\n", flagsVal)
+ fmt.Printf("mySlice flags length: %v\n", len(flagsVal))
+ fmt.Printf("mySlice length: %v\n", len(mySlice))
+
+ fmt.Printf("Creating backup of %s to %s\n", source, destination)
+ },
+ }
+
+ backupCmd.Flags().BoolP("incremental", "i", false, "Perform incremental backup")
+ backupCmd.Flags().StringP("compression", "c", "gzip", "Specify the compression algorithm")
+ backupCmd.Flags().Bool("dry-run", false, "Perform a dry run without actually creating the backup")
+ backupCmd.Flags().StringSliceVarP(&mySlice, "test-slice", "T", mySlice, "Testing the shit")
+ rootCmd.AddCommand(backupCmd)
+
+ renameCmd := &cobra.Command{
+ Use: "rename [flags] FILE NEW_NAME",
+ Short: "Rename a file",
+ GroupID: "filesystem",
+ Args: cobra.ExactArgs(2),
+ Run: func(cmd *cobra.Command, args []string) {
+ file := args[0]
+ newName := args[1]
+
+ // Implementation logic for rename command
+
+ fmt.Printf("Renaming file %s to %s\n", file, newName)
+ },
+ }
+
+ renameCmd.Flags().BoolP("force", "f", false, "Force rename, even if the new name already exists")
+ renameCmd.Flags().BoolP("preserve-extension", "p", false, "Preserve the file extension while renaming")
+
+ rootCmd.AddCommand(renameCmd)
+
+ deployCmd := &cobra.Command{
+ Use: "deploy [flags] FILE",
+ Short: "Deploy a file",
+ GroupID: "deployment",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ file := args[0]
+
+ // Implementation logic for deploy command
+
+ fmt.Println("Deploying file:", file)
+ },
+ }
+
+ deployCmd.Flags().BoolP("verbose", "v", false, "Print verbose output")
+ deployCmd.Flags().StringP("target", "t", "", "Specify the deployment target")
+ deployCmd.Flags().Bool("clean", false, "Perform a clean deployment, removing previous versions")
+
+ rootCmd.AddCommand(deployCmd)
+
+ deployWebCmd := &cobra.Command{
+ Use: "web [flags]",
+ Short: "Deploy a web application",
+ Run: func(cmd *cobra.Command, args []string) {
+ // Implementation logic for deploying a web application
+
+ fmt.Println("Deploying web application")
+ },
+ }
+
+ deployCmd.AddCommand(deployWebCmd)
+
+ deployAPICmd := &cobra.Command{
+ Use: "api [flags]",
+ Short: "Deploy an API service",
+ Run: func(cmd *cobra.Command, args []string) {
+ // Implementation logic for deploying an API service
+
+ fmt.Println("Deploying API service")
+ },
+ }
+
+ deployCmd.AddCommand(deployAPICmd)
+
+ deployDatabaseCmd := &cobra.Command{
+ Use: "database [flags]",
+ Short: "Deploy a database",
+ Run: func(cmd *cobra.Command, args []string) {
+ // Implementation logic for deploying a database
+
+ fmt.Println("Deploying database")
+ },
+ }
+
+ deployCmd.AddCommand(deployDatabaseCmd)
+
+ localCmd := &cobra.Command{
+ Use: "local [flags] FILE",
+ Short: "Deploy a file locally",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ file := args[0]
+
+ // Implementation logic for local subcommand
+
+ fmt.Println("Deploying file locally:", file)
+ },
+ }
+
+ localCmd.Flags().BoolP("verbose", "v", false, "Print verbose output")
+
+ deployCmd.AddCommand(localCmd)
+
+ remoteCmd := &cobra.Command{
+ Use: "remote [flags] FILE",
+ Short: "Deploy a file remotely",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ file := args[0]
+
+ // Implementation logic for remote subcommand
+
+ fmt.Println("Deploying file remotely:", file)
+ },
+ }
+
+ remoteCmd.Flags().BoolP("verbose", "v", false, "Print verbose output")
+ remoteCmd.Flags().StringP("host", "h", "", "Specify the remote host")
+
+ deployCmd.AddCommand(remoteCmd)
+
+ cloudCmd := &cobra.Command{
+ Use: "cloud [flags] FILE",
+ Short: "Deploy a file to the cloud",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ file := args[0]
+
+ // Implementation logic for cloud subcommand
+
+ fmt.Println("Deploying file to the cloud:", file)
+ },
+ }
+
+ cloudCmd.Flags().BoolP("verbose", "v", false, "Print verbose output")
+ cloudCmd.Flags().StringP("provider", "p", "", "Specify the cloud provider")
+
+ deployCmd.AddCommand(cloudCmd)
+
+ //
+ // Completions ----------------------------------------------------------------- //
+ //
+
+ // For each of the commands above, generate the carapace.Carapace for the command.
+ // Then create a map carapace.FlagMap, and add file completion to all flags requiring
+ // a file argument.
+ for _, cmd := range rootCmd.Commands() {
+ c := carapace.Gen(cmd)
+
+ if cmd.Args != nil {
+ c.PositionalAnyCompletion(
+ carapace.ActionCallback(func(c carapace.Context) carapace.Action {
+ return carapace.ActionFiles()
+ }),
+ )
+ }
+
+ flagMap := make(carapace.ActionMap)
+
+ cmd.Flags().VisitAll(func(f *pflag.Flag) {
+ if f.Name == "file" || strings.Contains(f.Usage, "file") {
+ flagMap[f.Name] = carapace.ActionFiles()
+ }
+ })
+
+ if cmd.Name() == "ssh" {
+ // Generate a list of random hosts to use as positional arguments
+ hosts := make([]string, 0)
+ for i := range 10 {
+ hosts = append(hosts, fmt.Sprintf("host%d", i))
+ }
+
+ c.PositionalCompletion(carapace.ActionValues(hosts...))
+ }
+
+ if cmd.Name() == "encrypt" {
+ cmd.Flags().VisitAll(func(f *pflag.Flag) {
+ if f.Name == "algorithm" {
+ flagMap[f.Name] = carapace.ActionValues("aes", "des", "blowfish")
+ }
+ })
+ }
+
+ c.FlagCompletion(flagMap)
+ }
+
+ // Add the readline-feature demo commands AFTER the generic completion
+ // loop above, so their custom (e.g. async) completers are not replaced
+ // by the default file completion applied to every command with args.
+ for _, cmd := range readlineFeatureCommands(app) {
+ rootCmd.AddCommand(cmd)
+ }
+
+ rootCmd.SetHelpCommandGroupID("core")
+ rootCmd.InitDefaultHelpCmd()
+ rootCmd.CompletionOptions.DisableDefaultCmd = true
+ rootCmd.DisableFlagsInUseLine = true
+
+ return rootCmd
+ }
+}
diff --git a/console/example/main.go b/console/example/main.go
new file mode 100644
index 0000000..ccd45ac
--- /dev/null
+++ b/console/example/main.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/chainreactors/tui/console"
+)
+
+const (
+ shortUsage = "Console application example, with cobra commands/flags/completions generated from structs"
+)
+
+func main() {
+ // Instantiate a new app, with a single, default menu.
+ // All defaults are set, and nothing is needed to make it work.
+ app := console.New("example")
+
+ // Global Setup ------------------------------------------------- //
+ app.NewlineBefore = true
+ app.NewlineAfter = true
+
+ app.SetPrintLogo(func(_ *console.Console) {
+ fmt.Print(`
+ _____ __ _ _ _ _____ _
+ | __ \ / _| | | | (_) / ____| | |
+ | |__) |___ ___| |_| | ___ ___| |_ ___ _____ | | ___ _ __ ___ ___ | | ___
+ | _ // _ \/ _ \ _| |/ _ \/ __| __| \ \ / / _ \ | | / _ \| '_ \/ __|/ _ \| |/ _ \
+ | | \ \ __/ __/ | | | __/ (__| |_| |\ V / __/ | |___| (_) | | | \__ \ (_) | | __/
+ |_| \_\___|\___|_| |_|\___|\___|\__|_| \_/ \___| \_____\___/|_| |_|___/\___/|_|\___|
+
+`)
+ })
+
+ // Main Menu Setup ---------------------------------------------- //
+
+ // By default the shell as created a single menu and
+ // made it current, so you can access it and set it up.
+ menu := app.ActiveMenu()
+
+ // Set some custom prompt handlers for this menu.
+ setupPrompt(menu)
+
+ // Register a passive hint provider on the shell, demonstrating the readline
+ // hint lanes (passive provider / async transient / completion hints).
+ setupReadlineHints(app)
+
+ // All menus currently each have a distinct, in-memory history source.
+ // Replace the main (current) menu's history with one writing to our
+ // application history file. The default history is named after its menu.
+ hist, _ := embeddedHistory(".example-history")
+ menu.AddHistorySource("local history", hist)
+
+ // We bind a special handler for this menu, which will exit the
+ // application (with confirm), when the shell readline receives
+ // a Ctrl-D keystroke. You can map any error to any handler.
+ menu.AddInterrupt(io.EOF, exitCtrlD)
+
+ // Make a command yielder for our main menu.
+ // menu.SetCommands(makeflagsCommands(app))
+ // Thanks ChatGPT for generating this for us!
+ menu.SetCommands(mainMenuCommands(app))
+
+ // Client Menu Setup -------------------------------------------- //
+
+ // Create another menu, different from the main one.
+ // It will have its own command tree, prompt engine, history sources, etc.
+ clientMenu := app.NewMenu("client")
+
+ // Here, for the sake of demonstrating custom interrupt
+ // handlers and for sparing use to write a dedicated command,
+ // we use a custom interrupt handler to switch back to main menu.
+ clientMenu.AddInterrupt(io.EOF, errorCtrlSwitchMenu)
+
+ // Add some commands to our client menu.
+ // This is an example of binding "traditionally defined" cobra.Commands.
+ clientMenu.SetCommands(makeClientCommands(app))
+
+ // Run the app -------------------------------------------------- //
+
+ // Everything is ready for a tour.
+ // Run the console and take a look around.
+ app.Start()
+}
diff --git a/console/example/menu.go b/console/example/menu.go
new file mode 100644
index 0000000..b58ca05
--- /dev/null
+++ b/console/example/menu.go
@@ -0,0 +1,172 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/chainreactors/tui/console"
+)
+
+// In here we create some menus which hold different command trees.
+func createMenus(c *console.Console) {
+ clientMenu := c.NewMenu("client")
+
+ // Here, for the sake of demonstrating custom interrupt
+ // handlers and for sparing use to write a dedicated command,
+ // we use a custom interrupt handler to switch back to main menu.
+ clientMenu.AddInterrupt(io.EOF, errorCtrlSwitchMenu)
+
+ // Add some commands to our client menu.
+ // This is an example of binding "traditionally defined" cobra.Commands.
+ clientMenu.SetCommands(makeClientCommands(c))
+}
+
+// errorCtrlSwitchMenu is a custom interrupt handler which will
+// switch back to the main menu when the current menu receives
+// a CtrlD (io.EOF) error.
+func errorCtrlSwitchMenu(c *console.Console) {
+ fmt.Println("Switching back to main menu")
+ c.SwitchMenu("")
+}
+
+// A little set of commands for the client menu, (wrapped so that
+// we can pass the console to them, because the console is local).
+func makeClientCommands(app *console.Console) console.Commands {
+ return func() *cobra.Command {
+ root := &cobra.Command{}
+
+ ticker := &cobra.Command{
+ Use: "ticker",
+ Short: "Triggers some asynchronous notifications to the shell, demonstrating async logging",
+ Run: func(cmd *cobra.Command, args []string) {
+ menu := app.ActiveMenu()
+ timer := time.Tick(2 * time.Second)
+ messages := []string{
+ "Info: notification 1",
+ "Info: notification 2",
+ "Warning: notification 3",
+ "Info: notification 4",
+ "Error: done notifying",
+ }
+ go func() {
+ count := 0
+ for {
+ <-timer
+ if count == 5 {
+ app.Printf("This message is more important, printing it below the prompt and in every menu")
+ return
+ }
+ menu.TransientPrintf("%s", messages[count])
+ count++
+ }
+ }()
+ },
+ }
+ root.AddCommand(ticker)
+
+ main := &cobra.Command{
+ Use: "main",
+ Short: "A command to return to the main menu (you can also use CtrlD for the same result)",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Println("Switching back to main menu")
+ app.SwitchMenu("")
+ },
+ }
+ root.AddCommand(main)
+
+ shell := &cobra.Command{
+ Use: "!",
+ Short: "Execute the remaining arguments with system shell",
+ DisableFlagParsing: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return errors.New("command requires one or more arguments")
+ }
+
+ path, err := exec.LookPath(args[0])
+ if err != nil {
+ return err
+ }
+
+ shellCmd := exec.Command(path, args[1:]...)
+
+ // Load OS environment
+ shellCmd.Env = os.Environ()
+
+ out, err := shellCmd.CombinedOutput()
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(string(out))
+
+ return nil
+ },
+ }
+ root.AddCommand(shell)
+
+ interruptible := &cobra.Command{
+ Use: "interrupt",
+ Short: "A command which prints a few status messages, but can be interrupted with CtrlC",
+ DisableFlagParsing: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ menu := app.ActiveMenu()
+ timer := time.Tick(2 * time.Second)
+ messages := []string{
+ "Info: notification 1",
+ "Info: notification 2",
+ "Warning: notification 3",
+ "Info: notification 4",
+ "Error: done notifying",
+ }
+ count := 0
+ for {
+ select {
+ case <-menu.Context().Done():
+ menu.TransientPrintf("Interrupted")
+ return nil
+ case <-timer:
+ if count == 5 {
+ return nil
+ }
+ menu.TransientPrintf("%s", messages[count]+"\n")
+ count++
+ }
+ }
+ },
+ }
+ root.AddCommand(interruptible)
+
+ return root
+ }
+}
+
+// setupPrompt is a function which sets up the prompts for the main menu.
+func setupPrompt(m *console.Menu) {
+ p := m.Prompt()
+
+ p.Primary = func() string {
+ prompt := "\x1b[33mexample\x1b[0m [main] in \x1b[34m%s\x1b[0m\n> "
+ wd, _ := os.Getwd()
+
+ dir, err := filepath.Rel(os.Getenv("HOME"), wd)
+ if err != nil {
+ dir = filepath.Base(wd)
+ }
+
+ return fmt.Sprintf(prompt, dir)
+ }
+
+ p.Right = func() string {
+ return "\x1b[1;30m" + time.Now().Format("03:04:05.000") + "\x1b[0m"
+ }
+
+ p.Transient = func() string { return "\x1b[1;30m" + ">> " + "\x1b[0m" }
+}
diff --git a/console/example/prompt.omp.json b/console/example/prompt.omp.json
new file mode 100644
index 0000000..6385741
--- /dev/null
+++ b/console/example/prompt.omp.json
@@ -0,0 +1,63 @@
+{
+ "$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json",
+ "blocks": [
+ {
+ "alignment": "left",
+ "newline": true,
+ "segments": [
+ {
+ "foreground": "lightRed",
+ "style": "plain",
+ "template": " {{ .UserName }}@{{ .HostName }} ",
+ "type": "session"
+ },
+ {
+ "foreground": "cyan",
+ "properties": {
+ "style": "folder"
+ },
+ "style": "plain",
+ "template": "<#ffffff>in> {{ .Path }} ",
+ "type": "path"
+ },
+ {
+ "style": "plain",
+ "template": " {{ .Type }}(<#df2e1c>{{ .Path }}>) ",
+ "type": "module"
+ }
+ ],
+ "type": "prompt"
+ },
+ {
+ "alignment": "right",
+ "segments": [
+ {
+ "foreground": "#ff94df",
+ "properties": {
+ "branch_icon": " <#ff94df>\ue0a0 >",
+ "fetch_stash_count": true
+ },
+ "style": "plain",
+ "template": "<#ffffff>on> {{ .HEAD }}{{ if gt .StashCount 0 }} \uf692 {{ .StashCount }}{{ end }} ",
+ "type": "git"
+ }
+ ],
+ "type": "rprompt"
+ },
+ {
+ "alignment": "left",
+ "newline": true,
+ "segments": [
+ {
+ "foreground": "lightGreen",
+ "style": "plain",
+ "template": "\u276f",
+ "type": "text"
+ }
+ ],
+ "type": "prompt"
+ }
+ ],
+ "final_space": true,
+ "version": 2
+}
diff --git a/console/features_test.go b/console/features_test.go
new file mode 100644
index 0000000..2eb8a61
--- /dev/null
+++ b/console/features_test.go
@@ -0,0 +1,124 @@
+package console
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "testing"
+)
+
+func TestHandleInterruptMatching(t *testing.T) {
+ c := New("test")
+ m := c.ActiveMenu()
+
+ var fired []string
+ sentinel := errors.New("boom")
+
+ m.AddInterrupt(sentinel, func(*Console) { fired = append(fired, "sentinel") })
+ m.AddInterrupt(io.EOF, func(*Console) { fired = append(fired, "eof") })
+
+ // errors.Is match: a wrapped io.EOF should reach the io.EOF handler.
+ fired = nil
+ m.handleInterrupt(fmt.Errorf("read failed: %w", io.EOF))
+ if !reflect_equal(fired, []string{"eof"}) {
+ t.Fatalf("wrapped io.EOF fired %v, want [eof]", fired)
+ }
+
+ // String fallback: a distinct error value with the same message as the
+ // registered sentinel should still match (the historical pattern).
+ fired = nil
+ m.handleInterrupt(errors.New("boom"))
+ if !reflect_equal(fired, []string{"sentinel"}) {
+ t.Fatalf("same-message error fired %v, want [sentinel]", fired)
+ }
+
+ // No match: nothing fires.
+ fired = nil
+ m.handleInterrupt(errors.New("unrelated"))
+ if len(fired) != 0 {
+ t.Fatalf("unrelated error fired %v, want none", fired)
+ }
+}
+
+func TestMenuNewlineOverrides(t *testing.T) {
+ c := New("test")
+ c.NewlineAfter = true
+ c.NewlineBefore = false
+ c.NewlineWhenEmpty = false
+ m := c.ActiveMenu()
+
+ // With no override, the menu inherits the console defaults.
+ if !m.newlineAfter() {
+ t.Fatal("newlineAfter: expected inherited true")
+ }
+ if m.newlineBefore() {
+ t.Fatal("newlineBefore: expected inherited false")
+ }
+ if m.newlineWhenEmpty() {
+ t.Fatal("newlineWhenEmpty: expected inherited false")
+ }
+
+ // Overrides take precedence over the console default.
+ m.SetNewlineAfter(false)
+ m.SetNewlineBefore(true)
+ m.SetNewlineWhenEmpty(true)
+
+ if m.newlineAfter() {
+ t.Fatal("newlineAfter: expected override false")
+ }
+ if !m.newlineBefore() {
+ t.Fatal("newlineBefore: expected override true")
+ }
+ if !m.newlineWhenEmpty() {
+ t.Fatal("newlineWhenEmpty: expected override true")
+ }
+
+ // Changing the console default no longer affects an overridden menu.
+ c.NewlineAfter = true
+ if m.newlineAfter() {
+ t.Fatal("newlineAfter: override should shadow console default")
+ }
+}
+
+func TestMenuEmptyCharsOverride(t *testing.T) {
+ c := New("test")
+ m := c.ActiveMenu()
+
+ // Inherits the console default set.
+ if string(m.emptyCharSet()) != string(c.EmptyChars) {
+ t.Fatalf("emptyCharSet inherited = %q, want %q", string(m.emptyCharSet()), string(c.EmptyChars))
+ }
+
+ // Override.
+ m.SetEmptyChars('x', 'y')
+ if string(m.emptyCharSet()) != "xy" {
+ t.Fatalf("emptyCharSet override = %q, want %q", string(m.emptyCharSet()), "xy")
+ }
+
+ // No arguments clears the override, restoring inheritance.
+ m.SetEmptyChars()
+ if string(m.emptyCharSet()) != string(c.EmptyChars) {
+ t.Fatalf("emptyCharSet after clear = %q, want %q", string(m.emptyCharSet()), string(c.EmptyChars))
+ }
+}
+
+func TestConsoleDefaultSignals(t *testing.T) {
+ c := New("test")
+ if len(c.Signals) != len(defaultTrapSignals) {
+ t.Fatalf("default Signals = %v, want %v", c.Signals, defaultTrapSignals)
+ }
+}
+
+// reflect_equal is a tiny string-slice comparison helper to avoid importing
+// reflect for a single use.
+func reflect_equal(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/console/filters_test.go b/console/filters_test.go
new file mode 100644
index 0000000..2155bed
--- /dev/null
+++ b/console/filters_test.go
@@ -0,0 +1,84 @@
+package console
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+// buildFilterTree returns a small command tree:
+//
+// root
+// âââ net (filter: "windows")
+// âââ scan (no annotations -> inherits from parent)
+//
+// plus a standalone, unannotated "free" command.
+func buildFilterTree() (net, scan, free *cobra.Command) {
+ root := &cobra.Command{Use: "root"}
+ net = &cobra.Command{Use: "net", Annotations: map[string]string{CommandFilterKey: "windows"}}
+ scan = &cobra.Command{Use: "scan"}
+ free = &cobra.Command{Use: "free"}
+
+ root.AddCommand(net, free)
+ net.AddCommand(scan)
+
+ return net, scan, free
+}
+
+func TestActiveFiltersFor(t *testing.T) {
+ c := New("test")
+ menu := c.ActiveMenu()
+ net, scan, free := buildFilterTree()
+
+ // No filter active yet: nothing is filtered, even annotated commands.
+ if got := menu.ActiveFiltersFor(net); len(got) != 0 {
+ t.Fatalf("before HideCommands: ActiveFiltersFor(net) = %q, want none", got)
+ }
+
+ // Activate the "windows" filter.
+ c.HideCommands("windows")
+
+ if got := menu.ActiveFiltersFor(net); !reflect.DeepEqual(got, []string{"windows"}) {
+ t.Fatalf("ActiveFiltersFor(net) = %q, want [windows]", got)
+ }
+
+ // A child with no annotations inherits its parent's active filters.
+ if got := menu.ActiveFiltersFor(scan); !reflect.DeepEqual(got, []string{"windows"}) {
+ t.Fatalf("ActiveFiltersFor(scan) = %q, want [windows] (inherited)", got)
+ }
+
+ // An unrelated, unannotated command is never filtered.
+ if got := menu.ActiveFiltersFor(free); len(got) != 0 {
+ t.Fatalf("ActiveFiltersFor(free) = %q, want none", got)
+ }
+
+ // Removing the filter restores availability.
+ c.ShowCommands("windows")
+ if got := menu.ActiveFiltersFor(net); len(got) != 0 {
+ t.Fatalf("after ShowCommands: ActiveFiltersFor(net) = %q, want none", got)
+ }
+}
+
+func TestCheckIsAvailable(t *testing.T) {
+ c := New("test")
+ menu := c.ActiveMenu()
+ net, scan, free := buildFilterTree()
+
+ // A nil command is always available.
+ if err := menu.CheckIsAvailable(nil); err != nil {
+ t.Fatalf("CheckIsAvailable(nil) = %v, want nil", err)
+ }
+
+ c.HideCommands("windows")
+
+ if err := menu.CheckIsAvailable(net); err == nil {
+ t.Fatal("CheckIsAvailable(net) = nil, want error (command is filtered)")
+ }
+ if err := menu.CheckIsAvailable(scan); err == nil {
+ t.Fatal("CheckIsAvailable(scan) = nil, want error (inherited filter)")
+ }
+ if err := menu.CheckIsAvailable(free); err != nil {
+ t.Fatalf("CheckIsAvailable(free) = %v, want nil (not filtered)", err)
+ }
+}
diff --git a/console/fork_test.go b/console/fork_test.go
new file mode 100644
index 0000000..35b74e8
--- /dev/null
+++ b/console/fork_test.go
@@ -0,0 +1,104 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ "github.com/chainreactors/tui/readline"
+ rlterm "github.com/chainreactors/tui/readline/terminal"
+)
+
+func TestNewWithTerminalBindsShellAndConsoleOutput(t *testing.T) {
+ var out bytes.Buffer
+ term := rlterm.Stream(strings.NewReader(""), &out, &out, rlterm.NewControl(false, 80, 24))
+ c := NewWithTerminal("testapp", term)
+
+ if c.terminal != term {
+ t.Fatal("console did not retain caller-provided terminal")
+ }
+ if c.Shell().Terminal != term {
+ t.Fatal("readline shell did not retain caller-provided terminal")
+ }
+
+ c.isExecuting.Store(true)
+ defer c.isExecuting.Store(false)
+
+ if _, err := c.Printf("log %s", "message"); err != nil {
+ t.Fatalf("Printf returned error: %v", err)
+ }
+ if got := out.String(); got != "log message" {
+ t.Fatalf("terminal output = %q, want %q", got, "log message")
+ }
+}
+
+func TestDisplayNewlinesUseTerminalOutput(t *testing.T) {
+ var out bytes.Buffer
+ term := rlterm.Stream(strings.NewReader(""), &out, &out, rlterm.NewControl(false, 80, 24))
+ c := NewWithTerminal("testapp", term)
+
+ c.NewlineBefore = true
+ c.displayPreRun("cmd")
+ if got := out.String(); got != "\n" {
+ t.Fatalf("displayPreRun wrote %q, want newline", got)
+ }
+
+ out.Reset()
+ c.NewlineAfter = true
+ c.displayPostRun("cmd")
+ if got := out.String(); got != "\n" {
+ t.Fatalf("displayPostRun wrote %q, want newline", got)
+ }
+}
+
+func TestExecuteExportedRunsCommands(t *testing.T) {
+ c := NewWithTerminal("testapp", rlterm.Stream(strings.NewReader(""), ioDiscard{}, ioDiscard{}, rlterm.NewControl(false, 80, 24)))
+ menu := c.ActiveMenu()
+
+ ran := false
+ menu.SetCommands(func() *cobra.Command {
+ root := &cobra.Command{Use: "root"}
+ root.AddCommand(&cobra.Command{
+ Use: "run",
+ Run: func(*cobra.Command, []string) {
+ ran = true
+ },
+ })
+ return root
+ })
+ menu.resetPreRun()
+
+ if err := c.Execute(context.Background(), menu, []string{"run"}, false); err != nil {
+ t.Fatalf("Execute returned error: %v", err)
+ }
+ if !ran {
+ t.Fatal("Execute did not run target command")
+ }
+ if c.isExecuting.Load() {
+ t.Fatal("Execute left console in executing state")
+ }
+}
+
+func TestCompletionInlineSuggestionBridge(t *testing.T) {
+ c := NewWithTerminal("testapp", rlterm.Stream(strings.NewReader(""), ioDiscard{}, ioDiscard{}, rlterm.NewControl(false, 80, 24)))
+
+ comps := readline.CompleteRaw([]readline.Completion{{Value: "status "}})
+ comps.PREFIX = "sta"
+ c.setInlineSuggestion([]rune("sta"), 3, comps)
+
+ if got := c.Shell().GetInlineSuggestion(); got != "status" {
+ t.Fatalf("inline suggestion = %q, want %q", got, "status")
+ }
+
+ c.setInlineSuggestion([]rune("sta"), 1, comps)
+ if got := c.Shell().GetInlineSuggestion(); got != "" {
+ t.Fatalf("inline suggestion after mid-line cursor = %q, want empty", got)
+ }
+}
+
+type ioDiscard struct{}
+
+func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil }
diff --git a/console/go.mod b/console/go.mod
index 785e472..8f9ec56 100644
--- a/console/go.mod
+++ b/console/go.mod
@@ -1,23 +1,23 @@
module github.com/chainreactors/tui/console
-go 1.21
+go 1.25.0
require (
- github.com/carapace-sh/carapace v1.7.1
- github.com/chainreactors/tui/readline v1.1.3
+ github.com/carapace-sh/carapace v1.11.6
+ github.com/chainreactors/tui/readline v1.2.2
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
- github.com/spf13/cobra v1.9.1
- github.com/spf13/pflag v1.0.9
- golang.org/x/exp v0.0.0-20220827204233-334a2380cb91
- mvdan.cc/sh/v3 v3.7.0
+ github.com/spf13/cobra v1.10.2
+ github.com/spf13/pflag v1.0.10
+ golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9
+ mvdan.cc/sh/v3 v3.13.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
- github.com/carapace-sh/carapace-shlex v1.0.1 // indirect
+ github.com/carapace-sh/carapace-shlex v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
- golang.org/x/sys v0.8.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/console/go.sum b/console/go.sum
index 605b708..87dd307 100644
--- a/console/go.sum
+++ b/console/go.sum
@@ -1,15 +1,18 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/carapace-sh/carapace v1.7.1 h1:GjMjPNEMHhTstneZD2M3Ypjb+lW5YNEV1AfYmRhsG4c=
-github.com/carapace-sh/carapace v1.7.1/go.mod h1:fHdo3nEFe1QnIXxeA/Z1O9dCI83sfCsKfxrogpHfgtM=
-github.com/carapace-sh/carapace-shlex v1.0.1 h1:ww0JCgWpOVuqWG7k3724pJ18Lq8gh5pHQs9j3ojUs1c=
-github.com/carapace-sh/carapace-shlex v1.0.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M=
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/carapace-sh/carapace v1.11.6 h1:fUZv+oAMgbiDEpNPNis4n35tzqE3h8yshOohLJ2Mz4Y=
+github.com/carapace-sh/carapace v1.11.6/go.mod h1:5MUSHyLN9GGb5/NY/j9VI68/TcZV4ApRCAHGg4WeU0s=
+github.com/carapace-sh/carapace-shlex v1.1.1 h1:ccmNeetAYZOk4IcV36youFDsXusT9uCNW2Njkw+QS+Q=
+github.com/carapace-sh/carapace-shlex v1.1.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
-github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
+github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
+github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@@ -23,24 +26,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY=
-github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw=
-golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
-golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
+golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
-mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
+mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
+mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
diff --git a/console/highlight_cache_test.go b/console/highlight_cache_test.go
new file mode 100644
index 0000000..6e07393
--- /dev/null
+++ b/console/highlight_cache_test.go
@@ -0,0 +1,40 @@
+package console
+
+import (
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+func TestHighlightCacheInvalidation(t *testing.T) {
+ c := New("test")
+ menu := c.ActiveMenu()
+ menu.SetCommands(func() *cobra.Command {
+ root := &cobra.Command{Use: "root"}
+ root.AddCommand(&cobra.Command{Use: "net", Run: func(*cobra.Command, []string) {}})
+ return root
+ })
+ menu.resetPreRun()
+
+ in := []rune("net")
+ first := c.highlightSyntax(in)
+
+ cached := c.hlCache.Load()
+ if cached == nil || cached.input != "net" {
+ t.Fatalf("expected cache populated for %q, got %+v", "net", cached)
+ }
+ if cached.output != first {
+ t.Fatalf("cached output %q != returned %q", cached.output, first)
+ }
+
+ // Same input is served from cache and yields the same result.
+ if second := c.highlightSyntax(in); second != first {
+ t.Fatalf("second highlight %q != first %q", second, first)
+ }
+
+ // Regenerating the command tree invalidates the cache.
+ menu.resetPreRun()
+ if c.hlCache.Load() != nil {
+ t.Fatal("expected highlight cache cleared after resetPreRun")
+ }
+}
diff --git a/console/highlighter.go b/console/highlighter.go
deleted file mode 100644
index 48f53a6..0000000
--- a/console/highlighter.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package console
-
-import (
- "strings"
-
- "github.com/spf13/cobra"
-)
-
-var (
- seqFgGreen = "\x1b[32m"
- seqFgYellow = "\x1b[33m"
- seqFgReset = "\x1b[39m"
-
- seqBrightWigth = "\x1b[38;05;244m"
-)
-
-// Base text effects.
-var (
- reset = "\x1b[0m"
- bold = "\x1b[1m"
- dim = "\x1b[2m"
- underscore = "\x1b[4m"
- blink = "\x1b[5m"
- reverse = "\x1b[7m"
-
- // Effects reset.
- boldReset = "\x1b[22m" // 21 actually causes underline instead
- dimReset = "\x1b[22m"
- underscoreReset = "\x1b[24m"
- blinkReset = "\x1b[25m"
- reverseReset = "\x1b[27m"
-)
-
-// SetDefaultCommandHighlight allows the user to change the highlight color for a command in the default syntax
-// highlighter using an ansi code.
-// This action has no effect if a custom syntax highlighter for the shell is set.
-// By default, the highlight code is green ("\x1b[32m").
-func (c *Console) SetDefaultCommandHighlight(seq string) {
- c.cmdHighlight = seq
-}
-
-// SetDefaultFlagHighlight allows the user to change the highlight color for a flag in the default syntax
-// highlighter using an ansi color code.
-// This action has no effect if a custom syntax highlighter for the shell is set.
-// By default, the highlight code is grey ("\x1b[38;05;244m").
-func (c *Console) SetDefaultFlagHighlight(seq string) {
- c.flagHighlight = seq
-}
-
-// highlightSyntax - Entrypoint to all input syntax highlighting in the Wiregost console.
-func (c *Console) highlightSyntax(input []rune) (line string) {
- // Split the line as shellwords
- args, unprocessed, err := split(string(input), true)
- if err != nil {
- args = append(args, unprocessed)
- }
-
- highlighted := make([]string, 0) // List of processed words, append to
- remain := args // List of words to process, draw from
- trimmed := trimSpacesMatch(remain) // Match stuff against trimmed words
-
- // Highlight the root command when found.
- cmd, _, _ := c.activeMenu().Find(trimmed)
- if cmd != nil {
- highlighted, remain = c.highlightCommand(highlighted, args, cmd)
- }
-
- // Highlight command flags
- highlighted, remain = c.highlightCommandFlags(highlighted, remain, cmd)
-
- // Done with everything, add remainind, non-processed words
- highlighted = append(highlighted, remain...)
-
- // Join all words.
- line = strings.Join(highlighted, "")
-
- return line
-}
-
-func (c *Console) highlightCommand(done, args []string, _ *cobra.Command) ([]string, []string) {
- highlighted := make([]string, 0)
- var rest []string
-
- if len(args) == 0 {
- return done, args
- }
-
- // Highlight the root command when found, or any of its aliases.
- for _, cmd := range c.activeMenu().Commands() {
- // Change 1: Highlight based on first arg in usage rather than the entire usage itself
- cmdFound := strings.Split(cmd.Use, " ")[0] == strings.TrimSpace(args[0])
-
- for _, alias := range cmd.Aliases {
- if alias == strings.TrimSpace(args[0]) {
- cmdFound = true
- break
- }
- }
-
- if cmdFound {
- highlighted = append(highlighted, bold+c.cmdHighlight+args[0]+seqFgReset+boldReset)
- rest = args[1:]
-
- return append(done, highlighted...), rest
- }
- }
-
- return append(done, highlighted...), args
-}
-
-func (c *Console) highlightCommandFlags(done, args []string, _ *cobra.Command) ([]string, []string) {
- highlighted := make([]string, 0)
- var rest []string
-
- if len(args) == 0 {
- return done, args
- }
-
- for _, arg := range args {
- if strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "--") {
- highlighted = append(highlighted, bold+c.flagHighlight+arg+seqFgReset+boldReset)
- } else {
- highlighted = append(highlighted, arg)
- }
- }
-
- return append(done, highlighted...), rest
-}
diff --git a/console/internal/completion/complete.go b/console/internal/completion/complete.go
new file mode 100644
index 0000000..7c27144
--- /dev/null
+++ b/console/internal/completion/complete.go
@@ -0,0 +1,32 @@
+package completion
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/carapace-sh/carapace/pkg/style"
+ "github.com/carapace-sh/carapace/pkg/xdg"
+)
+
+// DefaultStyleConfig sets some default styles for completion.
+func DefaultStyleConfig() {
+ // If carapace config file is found, just return.
+ if dir, err := xdg.UserConfigDir(); err == nil {
+ _, err := os.Stat(fmt.Sprintf("%v/carapace/styles.json", dir))
+ if err == nil {
+ return
+ }
+ }
+
+ // Overwrite all default styles for color
+ for i := 1; i < 13; i++ {
+ styleStr := fmt.Sprintf("carapace.Highlight%d", i)
+ style.Set(styleStr, "bright-white")
+ }
+
+ // Overwrite all default styles for flags
+ style.Set("carapace.FlagArg", "bright-white")
+ style.Set("carapace.FlagMultiArg", "bright-white")
+ style.Set("carapace.FlagNoArg", "bright-white")
+ style.Set("carapace.FlagOptArg", "bright-white")
+}
diff --git a/console/internal/completion/line.go b/console/internal/completion/line.go
new file mode 100644
index 0000000..25d22f0
--- /dev/null
+++ b/console/internal/completion/line.go
@@ -0,0 +1,255 @@
+package completion
+
+import (
+ "bytes"
+ "errors"
+ "regexp"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/chainreactors/tui/console/internal/line"
+)
+
+// SplitArgs splits the line in valid words, prepares them in various ways before calling
+// the completer with them, and also determines which parts of them should be used as
+// prefixes, in the completions and/or in the line.
+func SplitArgs(line []rune, pos int) (args []string, prefixComp, prefixLine string) {
+ line = line[:pos]
+
+ // Remove all colors from the string
+ line = []rune(strip(string(line)))
+
+ // Split the line as shellwords, return them if all went fine.
+ args, remain, err := splitCompWords(string(line))
+
+ // We might have either no error and args, or no error and
+ // the cursor ready to complete a new word (last character
+ // in line is a space).
+ // In some of those cases we append a single dummy argument
+ // for the completer to understand we want a new word comp.
+ mustComplete, args, remain := mustComplete(line, args, remain, err)
+ if mustComplete {
+ return sanitizeArgs(args), "", remain
+ }
+
+ // But the completion candidates themselves might need slightly
+ // different prefixes, for an optimal completion experience.
+ arg, prefixComp, prefixLine := adjustQuotedPrefix(remain, err)
+
+ // The remainder is everything following the open charater.
+ // Pass it as is to the carapace completion engine.
+ args = append(args, arg)
+
+ return sanitizeArgs(args), prefixComp, prefixLine
+}
+
+func mustComplete(line []rune, args []string, remain string, err error) (bool, []string, string) {
+ dummyArg := ""
+
+ // Empty command line, complete the root command.
+ if len(args) == 0 || len(line) == 0 {
+ return true, append(args, dummyArg), remain
+ }
+
+ // If we have an error, we must handle it later.
+ if err != nil {
+ return false, args, remain
+ }
+
+ lastChar := line[len(line)-1]
+
+ // No remain and a trailing space means we want to complete
+ // for the next word, except when this last space was escaped.
+ if remain == "" && unicode.IsSpace(lastChar) {
+ if strings.HasSuffix(string(line), "\\ ") {
+ return true, args, args[len(args)-1]
+ }
+
+ return true, append(args, dummyArg), remain
+ }
+
+ // Else there is a character under the cursor, which means we are
+ // in the middle/at the end of a posentially completed word.
+ return true, args, remain
+}
+
+func adjustQuotedPrefix(remain string, err error) (arg, comp, input string) {
+ arg = remain
+
+ switch {
+ case errors.Is(err, line.ErrUnterminatedDoubleQuote):
+ comp = "\""
+ input = comp + arg
+ case errors.Is(err, line.ErrUnterminatedSingleQuote):
+ comp = "'"
+ input = comp + arg
+ case errors.Is(err, line.ErrUnterminatedEscape):
+ arg = strings.ReplaceAll(arg, "\\", "")
+ }
+
+ return arg, comp, input
+}
+
+// sanitizeArg unescapes a restrained set of characters.
+func sanitizeArgs(args []string) (sanitized []string) {
+ for _, arg := range args {
+ arg = replacer.Replace(arg)
+ sanitized = append(sanitized, arg)
+ }
+
+ return sanitized
+}
+
+// split has been copied from go-shellquote and slightly modified so as to also
+// return the remainder when the parsing failed because of an unterminated quote.
+func splitCompWords(input string) (words []string, remainder string, err error) {
+ var buf bytes.Buffer
+ words = make([]string, 0)
+
+ for len(input) > 0 {
+ // skip any splitChars at the start
+ char, read := utf8.DecodeRuneInString(input)
+ if strings.ContainsRune(line.SplitChars, char) {
+ input = input[read:]
+ continue
+ } else if char == line.EscapeChar {
+ // Look ahead for escaped newline so we can skip over it
+ next := input[read:]
+ if len(next) == 0 {
+ remainder = string(line.EscapeChar)
+ err = line.ErrUnterminatedEscape
+
+ return words, remainder, err
+ }
+
+ c2, l2 := utf8.DecodeRuneInString(next)
+ if c2 == '\n' {
+ input = next[l2:]
+ continue
+ }
+ }
+
+ var word string
+
+ word, input, err = splitCompWord(input, &buf)
+ if err != nil {
+ return words, word + input, err
+ }
+
+ words = append(words, word)
+ }
+
+ return words, remainder, nil
+}
+
+// splitWord has been modified to return the remainder of the input (the part that has not been
+// added to the buffer) even when an error is returned.
+func splitCompWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) {
+ buf.Reset()
+
+raw:
+ {
+ cur := input
+ for len(cur) > 0 {
+ char, read := utf8.DecodeRuneInString(cur)
+ cur = cur[read:]
+ switch {
+ case char == line.SingleChar:
+ buf.WriteString(input[0 : len(input)-len(cur)-read])
+ input = cur
+ goto single
+ case char == line.DoubleChar:
+ buf.WriteString(input[0 : len(input)-len(cur)-read])
+ input = cur
+ goto double
+ case char == line.EscapeChar:
+ buf.WriteString(input[0 : len(input)-len(cur)-read])
+ buf.WriteRune(char)
+ input = cur
+ goto escape
+ case strings.ContainsRune(line.SplitChars, char):
+ buf.WriteString(input[0 : len(input)-len(cur)-read])
+ return buf.String(), cur, nil
+ }
+ }
+ if len(input) > 0 {
+ buf.WriteString(input)
+ input = ""
+ }
+ goto done
+ }
+
+escape:
+ {
+ if len(input) == 0 {
+ input = buf.String() + input
+ return "", input, line.ErrUnterminatedEscape
+ }
+ c, l := utf8.DecodeRuneInString(input)
+ if c != '\n' {
+ buf.WriteString(input[:l])
+ }
+ input = input[l:]
+ }
+
+ goto raw
+
+single:
+ {
+ i := strings.IndexRune(input, line.SingleChar)
+ if i == -1 {
+ return "", input, line.ErrUnterminatedSingleQuote
+ }
+ buf.WriteString(input[0:i])
+ input = input[i+1:]
+ goto raw
+ }
+
+double:
+ {
+ cur := input
+ for len(cur) > 0 {
+ c, read := utf8.DecodeRuneInString(cur)
+ cur = cur[read:]
+ switch c {
+ case line.DoubleChar:
+ buf.WriteString(input[0 : len(input)-len(cur)-read])
+ input = cur
+ goto raw
+ case line.EscapeChar:
+ // bash only supports certain escapes in double-quoted strings
+ char2, l2 := utf8.DecodeRuneInString(cur)
+ cur = cur[l2:]
+ if strings.ContainsRune(line.DoubleEscapeChars, char2) {
+ buf.WriteString(input[0 : len(input)-len(cur)-read-l2])
+
+ if char2 != '\n' {
+ buf.WriteRune(char2)
+ }
+ input = cur
+ }
+ }
+ }
+
+ return "", input, line.ErrUnterminatedDoubleQuote
+ }
+
+done:
+ return buf.String(), input, nil
+}
+
+const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
+
+var re = regexp.MustCompile(ansi)
+
+// strip removes all ANSI escaped color sequences in a string.
+func strip(str string) string {
+ return re.ReplaceAllString(str, "")
+}
+
+var replacer = strings.NewReplacer(
+ "\n", ` `,
+ "\t", ` `,
+ "\\ ", " ", // User-escaped spaces in words.
+)
diff --git a/console/internal/completion/line_test.go b/console/internal/completion/line_test.go
new file mode 100644
index 0000000..d3168dd
--- /dev/null
+++ b/console/internal/completion/line_test.go
@@ -0,0 +1,109 @@
+package completion
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/chainreactors/tui/console/internal/line"
+)
+
+func TestSplitCompWords(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantWords []string
+ wantRemainder string
+ wantErr error
+ }{
+ {"empty", "", []string{}, "", nil},
+ {"two words", "echo hello", []string{"echo", "hello"}, "", nil},
+ {"single quoted", "echo 'hello world'", []string{"echo", "hello world"}, "", nil},
+ {"double quoted", `echo "hello world"`, []string{"echo", "hello world"}, "", nil},
+ {"unterminated single", "echo 'foo", []string{"echo"}, "foo", line.ErrUnterminatedSingleQuote},
+ {"unterminated double", `echo "foo`, []string{"echo"}, "foo", line.ErrUnterminatedDoubleQuote},
+ {"trailing backslash", `echo foo\`, []string{"echo"}, `foo\`, line.ErrUnterminatedEscape},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ words, remainder, err := splitCompWords(tc.input)
+ if err != tc.wantErr {
+ t.Fatalf("splitCompWords(%q) err = %v, want %v", tc.input, err, tc.wantErr)
+ }
+ if !reflect.DeepEqual(words, tc.wantWords) {
+ t.Fatalf("splitCompWords(%q) words = %q, want %q", tc.input, words, tc.wantWords)
+ }
+ if remainder != tc.wantRemainder {
+ t.Fatalf("splitCompWords(%q) remainder = %q, want %q", tc.input, remainder, tc.wantRemainder)
+ }
+ })
+ }
+}
+
+func TestAdjustQuotedPrefix(t *testing.T) {
+ tests := []struct {
+ name string
+ remain string
+ err error
+ wantArg string
+ wantComp string
+ wantInput string
+ }{
+ {"no error", "foo", nil, "foo", "", ""},
+ {"double quote", "foo", line.ErrUnterminatedDoubleQuote, "foo", `"`, `"foo`},
+ {"single quote", "foo", line.ErrUnterminatedSingleQuote, "foo", "'", "'foo"},
+ {"escape strips backslashes", `fo\o`, line.ErrUnterminatedEscape, "foo", "", ""},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ arg, comp, input := adjustQuotedPrefix(tc.remain, tc.err)
+ if arg != tc.wantArg || comp != tc.wantComp || input != tc.wantInput {
+ t.Fatalf("adjustQuotedPrefix(%q) = (%q, %q, %q), want (%q, %q, %q)",
+ tc.remain, arg, comp, input, tc.wantArg, tc.wantComp, tc.wantInput)
+ }
+ })
+ }
+}
+
+func TestSanitizeArgs(t *testing.T) {
+ got := sanitizeArgs([]string{"a\nb", "c\td", `e\ f`})
+ want := []string{"a b", "c d", "e f"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("sanitizeArgs = %q, want %q", got, want)
+ }
+}
+
+func TestSplitArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantArgs []string
+ wantPrefixComp string
+ wantPrefixLine string
+ }{
+ {"empty line completes root", "", []string{""}, "", ""},
+ {"partial word", "cmd", []string{"cmd"}, "", ""},
+ {"trailing space starts new word", "cmd ", []string{"cmd", ""}, "", ""},
+ {"two words", "cmd arg", []string{"cmd", "arg"}, "", ""},
+ {"unterminated double quote", `cmd "foo`, []string{"cmd", "foo"}, `"`, `"foo`},
+ {"unterminated single quote", "cmd 'foo", []string{"cmd", "foo"}, "'", "'foo"},
+ {"color codes stripped", "\x1b[32mcmd\x1b[0m", []string{"cmd"}, "", ""},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ runes := []rune(tc.input)
+ args, prefixComp, prefixLine := SplitArgs(runes, len(runes))
+ if !reflect.DeepEqual(args, tc.wantArgs) {
+ t.Fatalf("SplitArgs(%q) args = %q, want %q", tc.input, args, tc.wantArgs)
+ }
+ if prefixComp != tc.wantPrefixComp {
+ t.Fatalf("SplitArgs(%q) prefixComp = %q, want %q", tc.input, prefixComp, tc.wantPrefixComp)
+ }
+ if prefixLine != tc.wantPrefixLine {
+ t.Fatalf("SplitArgs(%q) prefixLine = %q, want %q", tc.input, prefixLine, tc.wantPrefixLine)
+ }
+ })
+ }
+}
diff --git a/console/internal/line/highlight.go b/console/internal/line/highlight.go
new file mode 100644
index 0000000..41ad87e
--- /dev/null
+++ b/console/internal/line/highlight.go
@@ -0,0 +1,78 @@
+package line
+
+import (
+ "slices"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+var (
+ // Base text effects.
+ Reset = "\x1b[0m"
+ Bold = "\x1b[1m"
+ Dim = "\x1b[2m"
+ Underscore = "\x1b[4m"
+ Blink = "\x1b[5m"
+ Reverse = "\x1b[7m"
+
+ // Effects reset.
+ BoldReset = "\x1b[22m" // 21 actually causes underline instead
+ DimReset = "\x1b[22m"
+ UnderscoreReset = "\x1b[24m"
+ BlinkReset = "\x1b[25m"
+ ReverseReset = "\x1b[27m"
+
+ // Colors
+ GreenFG = "\x1b[32m"
+ YellowFG = "\x1b[33m"
+ ResetFG = "\x1b[39m"
+ BrightWhiteFG = "\x1b[38;05;244m"
+)
+
+// HighlightCommand applies highlighting to commands in an input line.
+func HighlightCommand(done, args []string, root *cobra.Command, cmdColor string) ([]string, []string) {
+ highlighted := make([]string, 0)
+ var rest []string
+
+ if len(args) == 0 {
+ return done, args
+ }
+
+ // Highlight the root command when found, or any of its aliases.
+ for _, cmd := range root.Commands() {
+ // Highlight based on first arg in usage rather than the entire usage itself,
+ // or on any of the command's aliases.
+ name := strings.TrimSpace(args[0])
+ cmdFound := strings.Split(cmd.Use, " ")[0] == name || slices.Contains(cmd.Aliases, name)
+
+ if cmdFound {
+ highlighted = append(highlighted, Bold+cmdColor+args[0]+ResetFG+BoldReset)
+ rest = args[1:]
+
+ return append(done, highlighted...), rest
+ }
+ }
+
+ return append(done, highlighted...), args
+}
+
+// HighlightCommand applies highlighting to command flags in an input line.
+func HighlightCommandFlags(done, args []string, flagColor string) ([]string, []string) {
+ highlighted := make([]string, 0)
+ var rest []string
+
+ if len(args) == 0 {
+ return done, args
+ }
+
+ for _, arg := range args {
+ if strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "--") {
+ highlighted = append(highlighted, Bold+flagColor+arg+ResetFG+BoldReset)
+ } else {
+ highlighted = append(highlighted, arg)
+ }
+ }
+
+ return append(done, highlighted...), rest
+}
diff --git a/console/internal/line/highlight_test.go b/console/internal/line/highlight_test.go
new file mode 100644
index 0000000..5aac0e4
--- /dev/null
+++ b/console/internal/line/highlight_test.go
@@ -0,0 +1,69 @@
+package line
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+// TestHighlightCommandAlias is a regression test for a bug where a command
+// invoked through one of its aliases was never highlighted: the alias branch
+// used to `break` out of the loop before reaching the highlight block.
+func TestHighlightCommandAlias(t *testing.T) {
+ root := &cobra.Command{Use: "app"}
+ root.AddCommand(&cobra.Command{Use: "deploy host", Aliases: []string{"d", "dep"}})
+
+ tests := []struct {
+ name string
+ arg string
+ want bool // whether arg should be highlighted as a command
+ }{
+ {"canonical name", "deploy", true},
+ {"first alias", "d", true},
+ {"second alias", "dep", true},
+ {"unknown word", "nope", false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ done, _ := HighlightCommand(nil, []string{tc.arg}, root, GreenFG)
+
+ highlighted := len(done) > 0 && strings.Contains(done[0], GreenFG)
+ if highlighted != tc.want {
+ t.Fatalf("arg %q: highlighted=%v, want %v (got %q)", tc.arg, highlighted, tc.want, done)
+ }
+ })
+ }
+}
+
+func TestHighlightCommandFlags(t *testing.T) {
+ args := []string{"--verbose", "target", "-x", "value"}
+ done, _ := HighlightCommandFlags(nil, args, BrightWhiteFG)
+
+ if len(done) != len(args) {
+ t.Fatalf("HighlightCommandFlags returned %d words, want %d: %q", len(done), len(args), done)
+ }
+
+ tests := []struct {
+ idx int
+ shouldHighlit bool
+ raw string
+ }{
+ {0, true, "--verbose"},
+ {1, false, "target"},
+ {2, true, "-x"},
+ {3, false, "value"},
+ }
+
+ for _, tc := range tests {
+ got := done[tc.idx]
+ colored := strings.Contains(got, BrightWhiteFG)
+ if colored != tc.shouldHighlit {
+ t.Errorf("word %q: highlighted=%v, want %v (got %q)", tc.raw, colored, tc.shouldHighlit, got)
+ }
+ if !strings.Contains(got, tc.raw) {
+ t.Errorf("word %d: %q does not contain original %q", tc.idx, got, tc.raw)
+ }
+ }
+}
diff --git a/console/line.go b/console/internal/line/line.go
similarity index 63%
rename from console/line.go
rename to console/internal/line/line.go
index fe7b19e..130c77b 100644
--- a/console/line.go
+++ b/console/internal/line/line.go
@@ -1,33 +1,32 @@
-package console
+package line
import (
"bytes"
"errors"
- "regexp"
"strings"
"unicode/utf8"
+ "github.com/kballard/go-shellquote"
"mvdan.cc/sh/v3/syntax"
)
var (
- splitChars = " \n\t"
- singleChar = '\''
- doubleChar = '"'
- escapeChar = '\\'
- doubleEscapeChars = "$`\"\n\\"
+ SplitChars = " \n\t"
+ SingleChar = '\''
+ DoubleChar = '"'
+ EscapeChar = '\\'
+ DoubleEscapeChars = "$`\"\n\\"
)
var (
- errUnterminatedSingleQuote = errors.New("unterminated single-quoted string")
- errUnterminatedDoubleQuote = errors.New("unterminated double-quoted string")
- errUnterminatedEscape = errors.New("unterminated backslash-escape")
+ ErrUnterminatedSingleQuote = errors.New("unterminated single-quoted string")
+ ErrUnterminatedDoubleQuote = errors.New("unterminated double-quoted string")
+ ErrUnterminatedEscape = errors.New("unterminated backslash-escape")
)
-// parse is in charge of removing all comments from the input line
+// Parse is in charge of removing all comments from the input line
// before execution, and if successfully parsed, split into words.
-func (c *Console) parse(line string) (args []string, err error) {
-
+func Parse(line string) (args []string, err error) {
lineReader := strings.NewReader(line)
parser := syntax.NewParser(syntax.KeepComments(false))
@@ -45,41 +44,23 @@ func (c *Console) parse(line string) (args []string, err error) {
}
// Split the line into shell words.
- return shellSplit(parsedLine.String())
- //return shellquote.Split(parsedLine.String())
-}
-
-func shellSplit(command string) (args []string, err error) {
- re := regexp.MustCompile(`[^\s"']+|"([^"]*)"|'([^']*)'`)
- matches := re.FindAllStringSubmatch(command, -1)
-
- var parts []string
- for _, match := range matches {
- if match[1] != "" { // Matched double-quoted part
- parts = append(parts, match[1])
- } else if match[2] != "" { // Matched single-quoted part
- parts = append(parts, match[2])
- } else { // Unquoted part
- parts = append(parts, match[0])
- }
- }
- return parts, nil
+ return shellquote.Split(parsedLine.String())
}
// acceptMultiline determines if the line just accepted is complete (in which case
// we should execute it), or incomplete (in which case we must read in multiline).
-func (c *Console) acceptMultiline(line []rune) (accept bool) {
+func AcceptMultiline(line []rune) (accept bool) {
// Errors are either: unterminated quotes, or unterminated escapes.
- _, _, err := split(string(line), false)
+ _, _, err := Split(string(line), false)
if err == nil {
return true
}
// Currently, unterminated quotes are obvious to treat: keep reading.
switch err {
- case errUnterminatedDoubleQuote, errUnterminatedSingleQuote:
+ case ErrUnterminatedDoubleQuote, ErrUnterminatedSingleQuote:
return false
- case errUnterminatedEscape:
+ case ErrUnterminatedEscape:
if len(line) > 0 && line[len(line)-1] == '\\' {
return false
}
@@ -90,16 +71,55 @@ func (c *Console) acceptMultiline(line []rune) (accept bool) {
return true
}
-// split has been copied from go-shellquote and slightly modified so as to also
+// IsEmpty checks if a given input line is empty.
+// It accepts a list of characters that we consider to be irrelevant,
+// that is, if the given line only contains these characters, it will
+// be considered empty.
+func IsEmpty(line string, emptyChars ...rune) bool {
+ empty := true
+
+ for _, r := range line {
+ if !strings.ContainsRune(string(emptyChars), r) {
+ empty = false
+ break
+ }
+ }
+
+ return empty
+}
+
+// UnescapeValue is used When the completer has returned us some completions,
+// we sometimes need to post-process them a little before passing them to our shell.
+func UnescapeValue(prefixComp, prefixLine, val string) string {
+ quoted := strings.HasPrefix(prefixLine, "\"") ||
+ strings.HasPrefix(prefixLine, "'")
+
+ if quoted {
+ val = strings.ReplaceAll(val, "\\ ", " ")
+ }
+
+ return val
+}
+
+// TrimSpaces removes all leading/trailing spaces from words
+func TrimSpaces(remain []string) (trimmed []string) {
+ for _, word := range remain {
+ trimmed = append(trimmed, strings.TrimSpace(word))
+ }
+
+ return
+}
+
+// Split has been copied from go-shellquote and slightly modified so as to also
// return the remainder when the parsing failed because of an unterminated quote.
-func split(input string, hl bool) (words []string, remainder string, err error) {
+func Split(input string, hl bool) (words []string, remainder string, err error) {
var buf bytes.Buffer
words = make([]string, 0)
for len(input) > 0 {
// skip any splitChars at the start
c, l := utf8.DecodeRuneInString(input)
- if strings.ContainsRune(splitChars, c) {
+ if strings.ContainsRune(SplitChars, c) {
// Keep these characters in the result when higlighting the line.
if hl {
if len(words) == 0 {
@@ -112,15 +132,15 @@ func split(input string, hl bool) (words []string, remainder string, err error)
input = input[l:]
continue
- } else if c == escapeChar {
+ } else if c == EscapeChar {
// Look ahead for escaped newline so we can skip over it
next := input[l:]
if len(next) == 0 {
if hl {
- remainder = string(escapeChar)
+ remainder = string(EscapeChar)
}
- err = errUnterminatedEscape
+ err = ErrUnterminatedEscape
return words, remainder, err
}
@@ -166,22 +186,22 @@ raw:
for len(cur) > 0 {
c, l := utf8.DecodeRuneInString(cur)
cur = cur[l:]
- if c == singleChar {
+ if c == SingleChar {
buf.WriteString(input[0 : len(input)-len(cur)-l])
input = cur
goto single
- } else if c == doubleChar {
+ } else if c == DoubleChar {
buf.WriteString(input[0 : len(input)-len(cur)-l])
input = cur
goto double
- } else if c == escapeChar {
+ } else if c == EscapeChar {
buf.WriteString(input[0 : len(input)-len(cur)-l])
if hl {
buf.WriteRune(c)
}
input = cur
goto escape
- } else if strings.ContainsRune(splitChars, c) {
+ } else if strings.ContainsRune(SplitChars, c) {
buf.WriteString(input[0 : len(input)-len(cur)-l])
if hl {
buf.WriteRune(c)
@@ -203,7 +223,7 @@ escape:
if hl {
input = buf.String() + input
}
- return "", input, errUnterminatedEscape
+ return "", input, ErrUnterminatedEscape
}
c, l := utf8.DecodeRuneInString(input)
if c == '\n' {
@@ -218,25 +238,25 @@ escape:
single:
{
- i := strings.IndexRune(input, singleChar)
+ i := strings.IndexRune(input, SingleChar)
if i == -1 {
if hl {
- input = buf.String() + seqFgYellow + string(singleChar) + input
+ input = buf.String() + YellowFG + string(SingleChar) + input
}
- return "", input, errUnterminatedSingleQuote
+ return "", input, ErrUnterminatedSingleQuote
}
// Catch up opening quote
if hl {
- buf.WriteString(seqFgYellow)
- buf.WriteRune(singleChar)
+ buf.WriteString(YellowFG)
+ buf.WriteRune(SingleChar)
}
buf.WriteString(input[0:i])
input = input[i+1:]
if hl {
- buf.WriteRune(singleChar)
- buf.WriteString(seqFgReset)
+ buf.WriteRune(SingleChar)
+ buf.WriteString(ResetFG)
}
goto raw
}
@@ -247,10 +267,10 @@ double:
for len(cur) > 0 {
c, l := utf8.DecodeRuneInString(cur)
cur = cur[l:]
- if c == doubleChar {
+ if c == DoubleChar {
// Catch up opening quote
if hl {
- buf.WriteString(seqFgYellow)
+ buf.WriteString(YellowFG)
buf.WriteRune(c)
}
@@ -258,15 +278,15 @@ double:
if hl {
buf.WriteRune(c)
- buf.WriteString(seqFgReset)
+ buf.WriteString(ResetFG)
}
input = cur
goto raw
- } else if c == escapeChar && !hl {
+ } else if c == EscapeChar && !hl {
// bash only supports certain escapes in double-quoted strings
c2, l2 := utf8.DecodeRuneInString(cur)
cur = cur[l2:]
- if strings.ContainsRune(doubleEscapeChars, c2) {
+ if strings.ContainsRune(DoubleEscapeChars, c2) {
buf.WriteString(input[0 : len(input)-len(cur)-l-l2])
if c2 == '\n' {
// newline is special, skip the backslash entirely
@@ -279,33 +299,12 @@ double:
}
if hl {
- input = buf.String() + seqFgYellow + string(doubleChar) + input
+ input = buf.String() + YellowFG + string(DoubleChar) + input
}
- return "", input, errUnterminatedDoubleQuote
+ return "", input, ErrUnterminatedDoubleQuote
}
done:
return buf.String(), input, nil
}
-
-func trimSpacesMatch(remain []string) (trimmed []string) {
- for _, word := range remain {
- trimmed = append(trimmed, strings.TrimSpace(word))
- }
-
- return
-}
-
-func (c *Console) lineEmpty(line string) bool {
- empty := true
-
- for _, r := range line {
- if !strings.ContainsRune(string(c.EmptyChars), r) {
- empty = false
- break
- }
- }
-
- return empty
-}
diff --git a/console/internal/line/line_test.go b/console/internal/line/line_test.go
new file mode 100644
index 0000000..192e589
--- /dev/null
+++ b/console/internal/line/line_test.go
@@ -0,0 +1,156 @@
+package line
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestParse(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want []string
+ wantErr bool
+ }{
+ {"empty", "", nil, false},
+ {"simple", "echo hello", []string{"echo", "hello"}, false},
+ {"extra spaces collapse", "echo hello world", []string{"echo", "hello", "world"}, false},
+ {"trailing comment", "echo hello # a comment", []string{"echo", "hello"}, false},
+ {"comment only", "# just a comment", nil, false},
+ {"single quotes", "echo 'hello world'", []string{"echo", "hello world"}, false},
+ {"double quotes", `echo "hello world"`, []string{"echo", "hello world"}, false},
+ {"quoted hash not a comment", "echo '# not a comment'", []string{"echo", "# not a comment"}, false},
+ {"unterminated single quote", "echo 'oops", nil, true},
+ {"unterminated double quote", `echo "oops`, nil, true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := Parse(tc.input)
+ if tc.wantErr {
+ if err == nil {
+ t.Fatalf("Parse(%q): expected error, got nil (words=%q)", tc.input, got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("Parse(%q): unexpected error: %v", tc.input, err)
+ }
+ if len(got) == 0 && len(tc.want) == 0 {
+ return
+ }
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("Parse(%q) = %q, want %q", tc.input, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSplit(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantWords []string
+ wantErr error
+ }{
+ {"empty", "", []string{}, nil},
+ {"simple", "echo hello", []string{"echo", "hello"}, nil},
+ {"single quotes", "echo 'hello world'", []string{"echo", "hello world"}, nil},
+ {"double quotes", `echo "hello world"`, []string{"echo", "hello world"}, nil},
+ {"escaped space", `echo foo\ bar`, []string{"echo", "foo bar"}, nil},
+ {"unterminated single", "echo 'oops", []string{"echo"}, ErrUnterminatedSingleQuote},
+ {"unterminated double", `echo "oops`, []string{"echo"}, ErrUnterminatedDoubleQuote},
+ {"trailing backslash", `echo foo\`, []string{"echo"}, ErrUnterminatedEscape},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ words, _, err := Split(tc.input, false)
+ if !errors.Is(err, tc.wantErr) {
+ t.Fatalf("Split(%q) err = %v, want %v", tc.input, err, tc.wantErr)
+ }
+ if !reflect.DeepEqual(words, tc.wantWords) {
+ t.Fatalf("Split(%q) words = %q, want %q", tc.input, words, tc.wantWords)
+ }
+ })
+ }
+}
+
+func TestAcceptMultiline(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want bool
+ }{
+ {"complete", "echo hello", true},
+ {"complete quoted", `echo "hello world"`, true},
+ {"unterminated single quote", "echo 'oops", false},
+ {"unterminated double quote", `echo "oops`, false},
+ {"trailing backslash", `echo foo\`, false},
+ {"empty", "", true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := AcceptMultiline([]rune(tc.input)); got != tc.want {
+ t.Fatalf("AcceptMultiline(%q) = %v, want %v", tc.input, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestIsEmpty(t *testing.T) {
+ empty := []rune{' ', '\t'}
+
+ tests := []struct {
+ name string
+ input string
+ chars []rune
+ want bool
+ }{
+ {"empty string", "", empty, true},
+ {"only spaces", " ", empty, true},
+ {"spaces and tabs", " \t \t ", empty, true},
+ {"has content", " x ", empty, false},
+ {"content no chars", "abc", nil, false},
+ {"newline not in set", "\n", empty, false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := IsEmpty(tc.input, tc.chars...); got != tc.want {
+ t.Fatalf("IsEmpty(%q) = %v, want %v", tc.input, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestTrimSpaces(t *testing.T) {
+ got := TrimSpaces([]string{" a ", "b\t", "\tc"})
+ want := []string{"a", "b", "c"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("TrimSpaces = %q, want %q", got, want)
+ }
+}
+
+func TestUnescapeValue(t *testing.T) {
+ tests := []struct {
+ name string
+ prefixLine string
+ val string
+ want string
+ }{
+ {"double-quoted unescapes spaces", `"foo`, `bar\ baz`, "bar baz"},
+ {"single-quoted unescapes spaces", `'foo`, `bar\ baz`, "bar baz"},
+ {"unquoted left as-is", "foo", `bar\ baz`, `bar\ baz`},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := UnescapeValue("", tc.prefixLine, tc.val); got != tc.want {
+ t.Fatalf("UnescapeValue(%q, %q) = %q, want %q", tc.prefixLine, tc.val, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/console/internal/strutil/template.go b/console/internal/strutil/template.go
new file mode 100644
index 0000000..59b96c2
--- /dev/null
+++ b/console/internal/strutil/template.go
@@ -0,0 +1,20 @@
+package strutil
+
+import (
+ "io"
+ "strings"
+ "text/template"
+)
+
+// Template executes the given template text on data, writing the result to w.
+func Template(w io.Writer, text string, data any) error {
+ t := template.New("top")
+ t.Funcs(templateFuncs)
+ template.Must(t.Parse(text))
+
+ return t.Execute(w, data)
+}
+
+var templateFuncs = template.FuncMap{
+ "trim": strings.TrimSpace,
+}
diff --git a/console/internal/strutil/template_test.go b/console/internal/strutil/template_test.go
new file mode 100644
index 0000000..3bfd5d9
--- /dev/null
+++ b/console/internal/strutil/template_test.go
@@ -0,0 +1,52 @@
+package strutil
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestTemplate(t *testing.T) {
+ tests := []struct {
+ name string
+ text string
+ data any
+ want string
+ }{
+ {
+ name: "simple field",
+ text: "Hello {{.Name}}",
+ data: map[string]any{"Name": "world"},
+ want: "Hello world",
+ },
+ {
+ name: "trim func",
+ text: "[{{trim .S}}]",
+ data: map[string]any{"S": " padded "},
+ want: "[padded]",
+ },
+ {
+ name: "range over slice",
+ text: "{{range .Items}}{{.}},{{end}}",
+ data: map[string]any{"Items": []string{"a", "b", "c"}},
+ want: "a,b,c,",
+ },
+ {
+ name: "no substitution",
+ text: "static text",
+ data: nil,
+ want: "static text",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var b strings.Builder
+ if err := Template(&b, tc.text, tc.data); err != nil {
+ t.Fatalf("Template(%q): unexpected error: %v", tc.text, err)
+ }
+ if got := b.String(); got != tc.want {
+ t.Fatalf("Template(%q) = %q, want %q", tc.text, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/console/prompt.go b/console/internal/ui/prompt.go
similarity index 65%
rename from console/prompt.go
rename to console/internal/ui/prompt.go
index e44d678..515e5f2 100644
--- a/console/prompt.go
+++ b/console/internal/ui/prompt.go
@@ -1,6 +1,7 @@
-package console
+package ui
import (
+ "bytes"
"fmt"
"strings"
@@ -16,27 +17,25 @@ type Prompt struct {
Transient func() string // Transient is used if the console shell is configured to be transient.
Right func() string // Right is the prompt printed on the right side of the screen.
Tooltip func(word string) string // Tooltip is used to hint on the root command, replacing right prompts if not empty.
-
- console *Console
}
-func newPrompt(app *Console) *Prompt {
- prompt := &Prompt{console: app}
+// NewPrompt requires the name of the application and the current menu,
+// as well as the current menu output buffer to produce a new, default prompt.
+func NewPrompt(appName, menuName string, stdout *bytes.Buffer) *Prompt {
+ prompt := &Prompt{}
prompt.Primary = func() string {
- promptStr := app.name
-
- menu := app.activeMenu()
+ promptStr := appName
- if menu.name == "" {
+ if menuName == "" {
return promptStr + " > "
}
- promptStr += fmt.Sprintf(" [%s]", menu.name)
+ promptStr += fmt.Sprintf(" [%s]", menuName)
// If the buffered command output is not empty,
// add a special status indicator to the prompt.
- if strings.TrimSpace(menu.out.String()) != "" {
+ if strings.TrimSpace(stdout.String()) != "" {
promptStr += " $(...)"
}
@@ -46,21 +45,18 @@ func newPrompt(app *Console) *Prompt {
return prompt
}
-// bind reassigns the prompt printing functions to the shell helpers.
-func (p *Prompt) bind(shell *readline.Shell) {
+// BindPrompt reassigns the prompt printing functions to the shell helpers.
+func BindPrompt(p *Prompt, shell *readline.Shell) {
prompt := shell.Prompt
- // If the user has bound its own primary prompt and the shell
- // must leave a newline after command/log output, wrap its function
- // to add a newline before the prompt.
+ // Guard against a nil primary prompt, since the shell calls this on
+ // every render. Newlines around the prompt are handled by readline.
primary := func() string {
if p.Primary == nil {
return ""
}
- prompt := p.Primary()
-
- return prompt
+ return p.Primary()
}
prompt.Primary(primary)
diff --git a/console/interrupt.go b/console/interrupt.go
index a70ba33..c0f952c 100644
--- a/console/interrupt.go
+++ b/console/interrupt.go
@@ -1,23 +1,30 @@
package console
+import "errors"
+
// AddInterrupt registers a handler to run when the console receives
// a given interrupt error from the underlying readline shell.
//
// On most systems, the following errors will be returned with keypresses:
// - Linux/MacOS/Windows : Ctrl-C will return os.Interrupt.
//
+// The incoming error is matched against the registered one with errors.Is
+// first (so wrapped errors and sentinel values work as expected), falling
+// back to comparing their messages for errors that are merely value-equal
+// (e.g. two distinct errors.New with the same text).
+//
// Many will want to use this to switch menus. Note that these interrupt errors only
// work when the console is NOT currently executing a command, only when reading input.
func (m *Menu) AddInterrupt(err error, handler func(c *Console)) {
- m.mutex.RLock()
+ m.mutex.Lock()
m.interruptHandlers[err] = handler
- m.mutex.RUnlock()
+ m.mutex.Unlock()
}
// DelInterrupt removes one or more interrupt handlers from the menu registered ones.
// If no error is passed as argument, all handlers are removed.
func (m *Menu) DelInterrupt(errs ...error) {
- m.mutex.RLock()
+ m.mutex.Lock()
if len(errs) == 0 {
m.interruptHandlers = make(map[error]func(c *Console))
} else {
@@ -25,30 +32,30 @@ func (m *Menu) DelInterrupt(errs ...error) {
delete(m.interruptHandlers, err)
}
}
- m.mutex.RUnlock()
+ m.mutex.Unlock()
}
func (m *Menu) handleInterrupt(err error) {
- m.console.mutex.RLock()
- m.console.isExecuting = true
- m.console.mutex.RUnlock()
+ m.console.isExecuting.Store(true)
+ defer m.console.isExecuting.Store(false)
- defer func() {
- m.console.mutex.RLock()
- m.console.isExecuting = false
- m.console.mutex.RUnlock()
- }()
-
- // TODO: this is not a very, very safe way of comparing
- // errors. I'm not sure what to right now with this, but
- // from my (unreliable) expectations and usage, I see and
- // use things like errors.New(os.Interrupt.String()), so
- // the string itself is likely to change in the future.
+ // Match with errors.Is first so sentinel and wrapped errors behave
+ // correctly, then fall back to comparing messages for errors that are
+ // only value-equal (the historically supported errors.New(...) pattern).
//
- // But if people use their own third-party errors... nothing is guaranteed.
+ // Snapshot the matching handlers under the lock, then run them once
+ // released: a handler is free to mutate the menu (e.g. SwitchMenu)
+ // without deadlocking, and the map can't be written mid-iteration.
+ m.mutex.RLock()
+ matched := make([]func(c *Console), 0, len(m.interruptHandlers))
for herr, handler := range m.interruptHandlers {
- if err.Error() == herr.Error() {
- handler(m.console)
+ if errors.Is(err, herr) || err.Error() == herr.Error() {
+ matched = append(matched, handler)
}
}
+ m.mutex.RUnlock()
+
+ for _, handler := range matched {
+ handler(m.console)
+ }
}
diff --git a/console/menu.go b/console/menu.go
index 2befb3c..7812f92 100644
--- a/console/menu.go
+++ b/console/menu.go
@@ -4,16 +4,21 @@ import (
"bytes"
"errors"
"fmt"
- "io"
"strings"
"sync"
- "text/template"
"github.com/spf13/cobra"
+ "github.com/chainreactors/tui/console/internal/strutil"
+ "github.com/chainreactors/tui/console/internal/ui"
"github.com/chainreactors/tui/readline"
)
+// Prompt - A prompt is a set of functions that return the strings to print
+// for each prompt type. The console will call these functions to retrieve
+// the prompt strings to print. Each menu has its own prompt.
+type Prompt = ui.Prompt
+
// Menu - A menu is a simple way to seggregate commands based on
// the environment to which they belong. For instance, when using a menu
// specific to some host/user, or domain of activity, commands will vary.
@@ -51,6 +56,13 @@ type Menu struct {
historyNames []string
histories map[string]readline.History
+ // Per-menu overrides of the console newline behavior. When a *bool is nil
+ // (or emptyChars is nil), the corresponding Console default is used.
+ nlBefore *bool
+ nlAfter *bool
+ nlWhenEmpty *bool
+ emptyChars []rune
+
// Concurrency management
mutex *sync.RWMutex
}
@@ -59,18 +71,18 @@ func newMenu(name string, console *Console) *Menu {
menu := &Menu{
console: console,
name: name,
- prompt: newPrompt(console),
Command: &cobra.Command{},
out: bytes.NewBuffer(nil),
interruptHandlers: make(map[error]func(c *Console)),
histories: make(map[string]readline.History),
mutex: &sync.RWMutex{},
- ErrorHandler: func(err error) error {
- fmt.Fprintf(console.terminal.Err, "Error: %s\n", err)
- return nil
- },
+ ErrorHandler: defaultErrorHandler,
}
+ // Prompt setup
+ prompt := (ui.NewPrompt(console.name, name, menu.out))
+ menu.prompt = (*Prompt)(prompt)
+
// Add a default in memory history to each menu
// This source is dropped if another source is added
// to the menu via `AddHistorySource()`.
@@ -93,11 +105,84 @@ func (m *Menu) Prompt() *Prompt {
return m.prompt
}
+// SetNewlineBefore overrides Console.NewlineBefore for this menu only.
+func (m *Menu) SetNewlineBefore(v bool) {
+ m.mutex.Lock()
+ m.nlBefore = &v
+ m.mutex.Unlock()
+}
+
+// SetNewlineAfter overrides Console.NewlineAfter for this menu only.
+func (m *Menu) SetNewlineAfter(v bool) {
+ m.mutex.Lock()
+ m.nlAfter = &v
+ m.mutex.Unlock()
+}
+
+// SetNewlineWhenEmpty overrides Console.NewlineWhenEmpty for this menu only.
+func (m *Menu) SetNewlineWhenEmpty(v bool) {
+ m.mutex.Lock()
+ m.nlWhenEmpty = &v
+ m.mutex.Unlock()
+}
+
+// SetEmptyChars overrides Console.EmptyChars for this menu only. Passing no
+// arguments clears the override, restoring the console default.
+func (m *Menu) SetEmptyChars(chars ...rune) {
+ m.mutex.Lock()
+ m.emptyChars = chars
+ m.mutex.Unlock()
+}
+
+func (m *Menu) newlineBefore() bool {
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+
+ if m.nlBefore != nil {
+ return *m.nlBefore
+ }
+
+ return m.console.NewlineBefore
+}
+
+func (m *Menu) newlineAfter() bool {
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+
+ if m.nlAfter != nil {
+ return *m.nlAfter
+ }
+
+ return m.console.NewlineAfter
+}
+
+func (m *Menu) newlineWhenEmpty() bool {
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+
+ if m.nlWhenEmpty != nil {
+ return *m.nlWhenEmpty
+ }
+
+ return m.console.NewlineWhenEmpty
+}
+
+func (m *Menu) emptyCharSet() []rune {
+ m.mutex.RLock()
+ defer m.mutex.RUnlock()
+
+ if m.emptyChars != nil {
+ return m.emptyChars
+ }
+
+ return m.console.EmptyChars
+}
+
// AddHistorySource adds a source of history commands that will
// be accessible to the shell when the menu is active.
func (m *Menu) AddHistorySource(name string, source readline.History) {
- m.mutex.RLock()
- defer m.mutex.RUnlock()
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
if len(m.histories) == 1 && m.historyNames[0] == m.defaultHistoryName() {
delete(m.histories, m.defaultHistoryName())
@@ -112,8 +197,8 @@ func (m *Menu) AddHistorySource(name string, source readline.History) {
// to the specified "filepath" parameter. On the first call to this function,
// the default in-memory history source is removed.
func (m *Menu) AddHistorySourceFile(name string, filepath string) {
- m.mutex.RLock()
- defer m.mutex.RUnlock()
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
if len(m.histories) == 1 && m.historyNames[0] == m.defaultHistoryName() {
delete(m.histories, m.defaultHistoryName())
@@ -212,16 +297,16 @@ func (m *Menu) CheckIsAvailable(cmd *cobra.Command) error {
return nil
}
- m.console.mutex.Lock()
- defer m.console.mutex.Unlock()
filters := m.ActiveFiltersFor(cmd)
if len(filters) == 0 {
return nil
}
+ errTemplate := m.errorFilteredCommandTemplate(filters)
+
var bufErr strings.Builder
- err := tmpl(&bufErr, m.errorFilteredCommandTemplate(filters), map[string]interface{}{
+ err := strutil.Template(&bufErr, errTemplate, map[string]interface{}{
"menu": m,
"cmd": cmd,
"filters": filters,
@@ -236,9 +321,22 @@ func (m *Menu) CheckIsAvailable(cmd *cobra.Command) error {
// ActiveFiltersFor returns all the active menu filters that a given command
// does not declare as compliant with (added with console.Hide/ShowCommand()).
func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string {
+ // Snapshot the console filters once under a read lock, then walk the
+ // command tree lock-free. The previous version held a write lock and
+ // recursed into itself while holding it, which both serialized every
+ // completion/highlight render and risked a self-deadlock on the
+ // (non-reentrant) mutex whenever the parent-subtree branch was taken.
+ m.console.mutex.RLock()
+ consoleFilters := append([]string(nil), m.console.filters...)
+ m.console.mutex.RUnlock()
+
+ return activeFiltersFor(cmd, consoleFilters)
+}
+
+func activeFiltersFor(cmd *cobra.Command, consoleFilters []string) []string {
if cmd.Annotations == nil {
if cmd.HasParent() {
- return m.ActiveFiltersFor(cmd.Parent())
+ return activeFiltersFor(cmd.Parent(), consoleFilters)
}
return nil
@@ -249,7 +347,7 @@ func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string {
var filters []string
for _, cmdFilter := range strings.Split(filterStr, ",") {
- for _, filter := range m.console.filters {
+ for _, filter := range consoleFilters {
if cmdFilter != "" && cmdFilter == filter {
filters = append(filters, cmdFilter)
}
@@ -261,7 +359,7 @@ func (m *Menu) ActiveFiltersFor(cmd *cobra.Command) []string {
}
// Any parent that is hidden make its whole subtree hidden also.
- return m.ActiveFiltersFor(cmd.Parent())
+ return activeFiltersFor(cmd.Parent(), consoleFilters)
}
// SetErrFilteredCommandTemplate sets the error template to be used
@@ -278,6 +376,30 @@ func (m *Menu) resetPreRun() {
defer m.mutex.Unlock()
// Commands
+ m.regenerate()
+
+ // Reset or adjust any buffered command output.
+ m.resetCmdOutput()
+
+ // Prompt binding
+ prompt := (*ui.Prompt)(m.Prompt())
+ ui.BindPrompt(prompt, m.console.shell)
+}
+
+// resetCommands regenerates the menu command tree and re-applies filtering,
+// without rebinding the prompt or touching the command-output buffer. It is the
+// lighter reset used on the completion hot path, where the prompt is already
+// bound and no command output was produced.
+func (m *Menu) resetCommands() {
+ m.mutex.Lock()
+ defer m.mutex.Unlock()
+
+ m.regenerate()
+}
+
+// regenerate rebuilds the command tree and hides filtered commands.
+// It assumes m.mutex is already held.
+func (m *Menu) regenerate() {
if m.cmds != nil {
m.Command = m.cmds()
}
@@ -288,24 +410,17 @@ func (m *Menu) resetPreRun() {
}
}
- // Hide commands that are not available
+ // Hide commands that are not available.
m.hideFilteredCommands(m.Command)
- // Menu setup
- m.resetCmdOutput() // Reset or adjust any buffered command output.
- m.prompt.bind(m.console.shell) // Prompt binding
+ // The command tree just changed, so any memoized highlight is now stale.
+ m.console.hlCache.Store(nil)
}
// hide commands that are filtered so that they are not
// shown in the help strings or proposed as completions.
func (m *Menu) hideFilteredCommands(root *cobra.Command) {
for _, cmd := range root.Commands() {
- // Always hide carapace internal helper commands.
- if cmd.Name() == "_carapace" {
- cmd.Hidden = true
- continue
- }
-
// Don't override commands if they are already hidden
if cmd.Hidden {
continue
@@ -314,9 +429,6 @@ func (m *Menu) hideFilteredCommands(root *cobra.Command) {
if filters := m.ActiveFiltersFor(cmd); len(filters) > 0 {
cmd.Hidden = true
}
-
- // Recurse into subcommands to hide nested _carapace commands.
- m.hideFilteredCommands(cmd)
}
}
@@ -324,7 +436,7 @@ func (m *Menu) resetCmdOutput() {
buf := strings.TrimSpace(m.out.String())
// If our command has printed everything to stdout, nothing to do.
- if len(buf) == 0 || buf == "" {
+ if len(buf) == 0 {
m.out.Reset()
return
}
@@ -352,16 +464,3 @@ func (m *Menu) errorFilteredCommandTemplate(filters []string) string {
return `Command {{.cmd.Name}} is only available for: {{range .filters }}
- {{.}} {{end}}`
}
-
-// tmpl executes the given template text on data, writing the result to w.
-func tmpl(w io.Writer, text string, data interface{}) error {
- t := template.New("top")
- t.Funcs(templateFuncs)
- template.Must(t.Parse(text))
-
- return t.Execute(w, data)
-}
-
-var templateFuncs = template.FuncMap{
- "trim": strings.TrimSpace,
-}
diff --git a/console/newline_display_test.go b/console/newline_display_test.go
new file mode 100644
index 0000000..10c9070
--- /dev/null
+++ b/console/newline_display_test.go
@@ -0,0 +1,108 @@
+package console
+
+import (
+ "io"
+ "os"
+ "testing"
+)
+
+// captureStdout redirects os.Stdout for the duration of fn and returns what was
+// written. The display functions print via fmt.Println, which targets
+// os.Stdout directly.
+func captureStdout(t *testing.T, fn func()) string {
+ t.Helper()
+
+ orig := os.Stdout
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("os.Pipe: %v", err)
+ }
+
+ os.Stdout = w
+ fn()
+ os.Stdout = orig
+
+ if err := w.Close(); err != nil {
+ t.Fatalf("close writer: %v", err)
+ }
+
+ out, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ _ = r.Close()
+
+ return string(out)
+}
+
+func TestDisplayNewlineMatrix(t *testing.T) {
+ // A newline is printed iff: enabled && (whenEmpty || input is non-empty).
+ cases := []struct {
+ name string
+ enabled bool
+ whenEmpty bool
+ input string
+ wantNewline bool
+ }{
+ {"disabled/empty", false, false, "", false},
+ {"disabled/nonempty", false, false, "cmd", false},
+ {"disabled/whenEmpty/nonempty", false, true, "cmd", false},
+
+ {"enabled/nonempty", true, false, "cmd", true},
+ {"enabled/empty", true, false, "", false},
+ {"enabled/spaces-are-empty", true, false, " \t ", false},
+
+ {"enabled/whenEmpty/empty", true, true, "", true},
+ {"enabled/whenEmpty/nonempty", true, true, "cmd", true},
+ {"enabled/whenEmpty/spaces", true, true, " \t ", true},
+ }
+
+ for _, tc := range cases {
+ want := ""
+ if tc.wantNewline {
+ want = "\n"
+ }
+
+ t.Run("pre/"+tc.name, func(t *testing.T) {
+ c := New("test")
+ c.NewlineBefore = tc.enabled
+ c.NewlineWhenEmpty = tc.whenEmpty
+
+ got := captureStdout(t, func() { c.displayPreRun(tc.input) })
+ if got != want {
+ t.Fatalf("displayPreRun(%q) printed %q, want %q", tc.input, got, want)
+ }
+ })
+
+ t.Run("post/"+tc.name, func(t *testing.T) {
+ c := New("test")
+ c.NewlineAfter = tc.enabled
+ c.NewlineWhenEmpty = tc.whenEmpty
+
+ got := captureStdout(t, func() { c.displayPostRun(tc.input) })
+ if got != want {
+ t.Fatalf("displayPostRun(%q) printed %q, want %q", tc.input, got, want)
+ }
+ })
+ }
+}
+
+// TestDisplayNewlineMenuOverride checks that a per-menu newline override is
+// honored by the display path even when the console default differs.
+func TestDisplayNewlineMenuOverride(t *testing.T) {
+ c := New("test")
+ c.NewlineAfter = false // console default: off
+ c.ActiveMenu().SetNewlineAfter(true)
+
+ if got := captureStdout(t, func() { c.displayPostRun("cmd") }); got != "\n" {
+ t.Fatalf("menu override on: displayPostRun printed %q, want %q", got, "\n")
+ }
+
+ // And the inverse: console on, menu override off.
+ c.NewlineAfter = true
+ c.ActiveMenu().SetNewlineAfter(false)
+
+ if got := captureStdout(t, func() { c.displayPostRun("cmd") }); got != "" {
+ t.Fatalf("menu override off: displayPostRun printed %q, want %q", got, "")
+ }
+}
diff --git a/console/run.go b/console/run.go
index 3ee3524..d78ddd5 100644
--- a/console/run.go
+++ b/console/run.go
@@ -10,6 +10,8 @@ import (
"github.com/kballard/go-shellquote"
"github.com/spf13/cobra"
+
+ "github.com/chainreactors/tui/console/internal/line"
)
// Start - Start the console application (readline loop). Blocking.
@@ -19,7 +21,20 @@ func (c *Console) Start() error {
return c.StartContext(context.Background())
}
-// StartContext is like console.Start(). with a user-provided context.
+// StartContext is like console.Start(), with a user-provided context.
+//
+// Cancellation model: each command runs with a context derived from ctx,
+// accessible from within the command via cmd.Context(). When the console
+// traps one of its Signals (SIGINT/SIGTERM/SIGQUIT by default) while a command
+// is running, that command's context is cancelled and any registered interrupt
+// handler for the menu is invoked. Cancelling ctx itself does the same on the
+// next command boundary.
+//
+// Because cobra cannot preempt a running command, a long-running command is
+// only actually interrupted if it observes cancellation itself: select on
+// cmd.Context().Done() (or pass cmd.Context() to context-aware callees) and
+// return promptly. A command that ignores its context keeps running in its
+// goroutine until it finishes, even though the prompt has already been freed.
func (c *Console) StartContext(ctx context.Context) error {
c.loadActiveHistories()
@@ -31,6 +46,8 @@ func (c *Console) StartContext(ctx context.Context) error {
lastLine := "" // used to check if last read line is empty.
for {
+ // Print a newline after the last output if NewlineAfter is true
+ // and the last line was not empty.
c.displayPostRun(lastLine)
// Always ensure we work with the active menu, with freshly
@@ -45,15 +62,10 @@ func (c *Console) StartContext(ctx context.Context) error {
}
// Block and read user input.
- line, err := c.Readline()
-
- c.displayPostRun(line)
-
+ input, err := c.Readline()
if err != nil {
menu.handleInterrupt(err)
-
- lastLine = line
-
+ lastLine = input
continue
}
@@ -63,15 +75,15 @@ func (c *Console) StartContext(ctx context.Context) error {
menu = c.activeMenu()
// Parse the line with bash-syntax, removing comments.
- resolvedLine := c.ResolvePasteReferences(line)
- args, err := c.parse(resolvedLine)
+ resolvedInput := c.ResolvePasteReferences(input)
+ args, err := line.Parse(resolvedInput)
if err != nil {
menu.ErrorHandler(ParseError{newError(err, "Parsing error")})
continue
}
if len(args) == 0 {
- lastLine = line
+ lastLine = input
continue
}
@@ -83,6 +95,10 @@ func (c *Console) StartContext(ctx context.Context) error {
continue
}
+ // Print a newline before executing the command if NewlineBefore is true
+ // and the last line was not empty.
+ c.displayPreRun(input)
+
// Run all pre-run hooks and the command itself
// Don't check the error: if its a cobra error,
// the library user is responsible for setting
@@ -92,7 +108,7 @@ func (c *Console) StartContext(ctx context.Context) error {
menu.ErrorHandler(ExecutionError{newError(err, "")})
}
- lastLine = line
+ lastLine = input
}
}
@@ -108,7 +124,7 @@ func (m *Menu) RunCommandArgs(ctx context.Context, args []string) (err error) {
m.resetPreRun()
// Run the command and associated helpers.
- return m.console.Execute(ctx, m, args, !m.console.isExecuting)
+ return m.console.Execute(ctx, m, args, !m.console.isExecuting.Load())
}
// RunCommandLine is the equivalent of menu.RunCommandArgs(), but accepts
@@ -136,16 +152,10 @@ func (m *Menu) RunCommandLine(ctx context.Context, line string) (err error) {
// command is running, the menu's root command will be overwritten.
func (c *Console) Execute(ctx context.Context, menu *Menu, args []string, async bool) error {
if !async {
- c.mutex.RLock()
- c.isExecuting = true
- c.mutex.RUnlock()
+ c.isExecuting.Store(true)
}
- defer func() {
- c.mutex.RLock()
- c.isExecuting = false
- c.mutex.RUnlock()
- }()
+ defer c.isExecuting.Store(false)
// Our root command of interest, used throughout this function.
cmd := menu.Command
@@ -157,7 +167,6 @@ func (c *Console) Execute(ctx context.Context, menu *Menu, args []string, async
return err
}
- // Reset all flags to their default values.
resetFlagsDefaults(target)
// Console-wide pre-run hooks, cannot.
@@ -175,7 +184,11 @@ func (c *Console) Execute(ctx context.Context, menu *Menu, args []string, async
cmd.SetContext(ctx)
// Start monitoring keyboard and OS signals.
+ // signal.Stop releases the channel registration once the command
+ // returns: without it, every command execution would leak a channel
+ // in the os/signal package for the lifetime of the process.
sigchan := c.monitorSignals()
+ defer signal.Stop(sigchan)
// And start the command execution.
go c.executeCommand(cmd, cancel)
@@ -251,44 +264,43 @@ func (c *Console) runLineHooks(args []string) ([]string, error) {
return processed, nil
}
-func (c *Console) displayPreRun(line string) {
- if c.NewlineBefore {
- if !c.NewlineWhenEmpty {
- if !c.lineEmpty(line) {
- fmt.Fprintln(c.terminal.Out)
- }
- } else {
- fmt.Fprintln(c.terminal.Out)
- }
+func (c *Console) displayPreRun(input string) {
+ menu := c.activeMenu()
+
+ if menu.newlineBefore() && (menu.newlineWhenEmpty() || !line.IsEmpty(input, menu.emptyCharSet()...)) {
+ fmt.Fprintln(c.terminal.Out)
}
}
func (c *Console) displayPostRun(lastLine string) {
- if c.NewlineAfter {
- if !c.NewlineWhenEmpty {
- if !c.lineEmpty(lastLine) {
- fmt.Fprintln(c.terminal.Out)
- }
- } else {
- fmt.Fprintln(c.terminal.Out)
- }
+ menu := c.activeMenu()
+
+ if menu.newlineAfter() && (menu.newlineWhenEmpty() || !line.IsEmpty(lastLine, menu.emptyCharSet()...)) {
+ fmt.Fprintln(c.terminal.Out)
}
c.printed = false
}
+// defaultTrapSignals are the OS signals the console traps while a command is
+// running when Console.Signals has not been customized.
+var defaultTrapSignals = []os.Signal{
+ syscall.SIGINT,
+ syscall.SIGTERM,
+ syscall.SIGQUIT,
+}
+
// monitorSignals - Monitor the signals that can be sent to the process
// while a command is running. We want to be able to cancel the command.
-func (c *Console) monitorSignals() <-chan os.Signal {
+func (c *Console) monitorSignals() chan os.Signal {
sigchan := make(chan os.Signal, 1)
- signal.Notify(
- sigchan,
- syscall.SIGINT,
- syscall.SIGTERM,
- syscall.SIGQUIT,
- // syscall.SIGKILL,
- )
+ signals := c.Signals
+ if len(signals) == 0 {
+ signals = defaultTrapSignals
+ }
+
+ signal.Notify(sigchan, signals...)
return sigchan
}
diff --git a/console/signals_unix_test.go b/console/signals_unix_test.go
new file mode 100644
index 0000000..5796c7b
--- /dev/null
+++ b/console/signals_unix_test.go
@@ -0,0 +1,35 @@
+//go:build unix
+
+package console
+
+import (
+ "os"
+ "os/signal"
+ "syscall"
+ "testing"
+ "time"
+)
+
+// TestMonitorSignalsCustom verifies that monitorSignals honors a customized
+// Console.Signals set. SIGUSR1 is used because it is not part of the default
+// trapped set and is not sent by the test harness.
+func TestMonitorSignalsCustom(t *testing.T) {
+ c := New("test")
+ c.Signals = []os.Signal{syscall.SIGUSR1}
+
+ ch := c.monitorSignals()
+ defer signal.Stop(ch)
+
+ if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil {
+ t.Fatalf("failed to raise SIGUSR1: %v", err)
+ }
+
+ select {
+ case got := <-ch:
+ if got != syscall.SIGUSR1 {
+ t.Fatalf("received %v, want SIGUSR1", got)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for the custom signal")
+ }
+}
diff --git a/docs/readline-console-upstream-sync.md b/docs/readline-console-upstream-sync.md
new file mode 100644
index 0000000..875a75a
--- /dev/null
+++ b/docs/readline-console-upstream-sync.md
@@ -0,0 +1,348 @@
+# readline / console upstream sync
+
+æŦææĄŖį¨äēåŽææ `tui` å
åĩį `readline`ã`console` fork åæĨå°å޿𿿰䏿¸¸īŧåšļæįģææ˛æˇå `D:\Programing\go` ä¸ä¸¤ä¸Ēéŋæįģ´æ¤åēã
+
+## įŽæ
+
+- åŽæšä¸æ¸¸éčŋ git merge čŋå
ĨæŦå°įģ´æ¤åæ¯īŧéŋå
æå¨čĻįå¯ŧč´å垿čŖã
+- `D:\Programing\go\readline` å `D:\Programing\go\console` äŋæä¸ē坿įģæ´æ°įéŋæ forkã
+- `D:\Programing\go\chainreactors\tui\readline` å `D:\Programing\go\chainreactors\tui\console` åĒæĨæļ厞įģ mergeãæĩč¯éčŋįįģæã
+- æ¯æŦĄåæĨåčŊæ¸
æĨåįīŧ䏿¸¸å¸ĻæĨäēäģäšãæŦå°čŋäŋįäēåĒäē fork æšå¨ãæ¯åĻå¯åŽå
¨æĨåã
+
+## åēåŽčˇ¯åž
+
+| į¨é | čˇ¯åž |
+| --- | --- |
+| tui ä¸ģäģåē | `D:\Programing\go\chainreactors\tui` |
+| readline éŋæåē | `D:\Programing\go\readline` |
+| console éŋæåē | `D:\Programing\go\console` |
+| 临æļ merge æ šįŽåŊ | `D:\Programing\go\tui-upstream-merge` |
+| readline merge worktree | `D:\Programing\go\tui-upstream-merge\readline` |
+| console merge worktree | `D:\Programing\go\tui-upstream-merge\console` |
+
+## 忝įēĻåŽ
+
+| äģåē | åŽæščŋį̝ | åŽæšåæ¯ | æŦå°įģ´æ¤åæ¯ | 临æļ merge 忝 |
+| --- | --- | --- | --- | --- |
+| `readline` | `origin=https://github.com/reeflective/readline` | `origin/master` | `iom` | `codex/tui-merge` |
+| `console` | `origin=https://github.com/reeflective/console` | `origin/main` | `iom` | `codex/tui-merge` |
+| `tui` | `origin=https://github.com/chainreactors/tui` | `origin/master` | feature branch | n/a |
+
+`iom` æ¯éŋæįģ´æ¤åæ¯ãä¸čĻå¨åŽæš `master/main` 䏿žæŦå°æšå¨ã
+
+## æŦå° fork åŋ
éĄģäŋįįčŊå
+
+`readline`:
+
+- module path: `github.com/chainreactors/tui/readline`
+- `terminal` å
å `NewShellWithTerminal`
+- čĒåŽäš terminal input/output/controlãremote carrier æ¯æ
+- active terminal output/controlãresizeãraw mode éé
+- paste transformerãbracketed paste normalizeãpending input č¯ģå
+- inline suggestion API 忏˛æ
+- clipboard copy/paste åŊäģ¤
+
+`console`:
+
+- module path: `github.com/chainreactors/tui/console`
+- `replace github.com/chainreactors/tui/readline => ../readline`
+- `NewWithTerminal`
+- terminal-aware `Printf` / `TransientPrintf` / newline čžåē
+- paste reference API å `ResolvePasteReferences`
+- `StartContext` č¯ģååč§Ŗæ paste reference
+- å¯ŧåēį `Console.Execute`
+- completion panic recoverãéč `_carapace`ãéįŊŽ Cobra/pflag įļæ
+- inline suggestion 寚æĨ readline
+- `Suggestion` å
ŧ厚įąģå
+
+čŋäēčŊååŋ
éĄģæååŊæĩč¯čĻįãåæĨ䏿¸¸åīŧåĻæį¸å
ŗæĩč¯å¤ąč´Ĩīŧäŧå
夿æ¯åĻ蝝å /蝝æšäēæŦå° fork čŊåīŧä¸čĻä¸ēäēæĨå䏿¸¸į´æĨå 餿ĩč¯ã
+
+## åŽæåæĨæĩį¨
+
+åģēčŽŽæ¯æä¸æŦĄīŧæå¨ tui release åæ§čĄä¸æŦĄã
+
+### 1. æ´æ°čŋį̝äŋĄæ¯
+
+```powershell
+cd D:\Programing\go\readline
+git fetch origin --tags
+
+cd D:\Programing\go\console
+git fetch origin --tags
+
+cd D:\Programing\go\chainreactors\tui
+git fetch origin
+```
+
+### 2. æŖæĨéŋæå翝åĻåš˛å
+
+```powershell
+cd D:\Programing\go\readline
+git status --short --branch
+
+cd D:\Programing\go\console
+git status --short --branch
+```
+
+åĻæ `D:\Programing\go\readline` æ `D:\Programing\go\console` ææĒæä礿šå¨īŧä¸čĻį´æĨå¨åįŽåŊ mergeãäŊŋį¨ä¸éĸį worktree æĩį¨īŧéŋå
čĻį፿ˇåˇĨäŊã
+
+### 3. åå¤ä¸´æļ merge worktree
+
+åĻææ§ worktree åå¨īŧå
įĄŽčŽ¤æ˛ĄææĒäŋåå
厚īŧåå é¤įŽåŊåšļ pruneīŧ
+
+```powershell
+cd D:\Programing\go\readline
+git worktree list
+git worktree prune
+
+cd D:\Programing\go\console
+git worktree list
+git worktree prune
+```
+
+ååģēæ°į merge worktreeīŧ
+
+```powershell
+New-Item -ItemType Directory -Force D:\Programing\go\tui-upstream-merge | Out-Null
+
+cd D:\Programing\go\readline
+git worktree add -B codex/tui-merge D:\Programing\go\tui-upstream-merge\readline iom
+
+cd D:\Programing\go\console
+git worktree add -B codex/tui-merge D:\Programing\go\tui-upstream-merge\console iom
+```
+
+### 4. æåŊå tui å
åĩåēåæĨä¸ē merge čĩˇįš
+
+čŋ䏿ĨåĒį¨äē莊éŋæåēåįĄŽäģŖčĄ¨åŊå `tui` å
åĩįļæīŧįļåå merge åŽæšä¸æ¸¸ã
+
+å
dry-runīŧ
+
+```powershell
+robocopy D:\Programing\go\chainreactors\tui\readline D:\Programing\go\tui-upstream-merge\readline /MIR /XD .git .idea /L
+robocopy D:\Programing\go\chainreactors\tui\console D:\Programing\go\tui-upstream-merge\console /MIR /XD .git .idea /XF example_linux_amd64 example_windows_amd64.exe /L
+```
+
+įĄŽčŽ¤čžåēįŦĻåéĸæåæ§čĄīŧ
+
+```powershell
+robocopy D:\Programing\go\chainreactors\tui\readline D:\Programing\go\tui-upstream-merge\readline /MIR /XD .git .idea
+if ($LASTEXITCODE -gt 7) { exit $LASTEXITCODE } else { $global:LASTEXITCODE = 0 }
+
+robocopy D:\Programing\go\chainreactors\tui\console D:\Programing\go\tui-upstream-merge\console /MIR /XD .git .idea /XF example_linux_amd64 example_windows_amd64.exe
+if ($LASTEXITCODE -gt 7) { exit $LASTEXITCODE } else { $global:LASTEXITCODE = 0 }
+```
+
+æäē¤åŊå tui fork baselineīŧ
+
+```powershell
+cd D:\Programing\go\tui-upstream-merge\readline
+git add -A
+git commit -m "sync tui readline fork state"
+
+cd D:\Programing\go\tui-upstream-merge\console
+git add -A
+git commit -m "sync tui console fork state"
+```
+
+åĻææ˛Ąæååīŧ`git commit` äŧæį¤ē nothing to commitīŧå¯äģĨįģ§įģä¸ä¸æĨã
+
+### 5. merge åŽæšä¸æ¸¸
+
+```powershell
+cd D:\Programing\go\tui-upstream-merge\readline
+git merge --no-edit origin/master
+
+cd D:\Programing\go\tui-upstream-merge\console
+git merge --no-edit origin/main
+```
+
+åĻææå˛įĒīŧå¤įååīŧ
+
+- éģ莤æĨååŽæšææ°įģæåéæã
+- åĒéæ°äŋįæŦææĄŖååēį fork čŊåã
+- ä¸äŋį `.idea`ãæŦå°äēčŋåļã临æļæäģļã
+- ä¸ä¸ēäēčŋå˛įĒčåé䏿¸¸æĩč¯ãåšļåäŋŽå¤ãsignal äŋŽå¤ãdisplay/refactorã
+
+æŖæĨå˛įĒæ¯åĻæ¸
åŽīŧ
+
+```powershell
+git diff --name-only --diff-filter=U
+rg -n "<<<<<<<|=======|>>>>>>>" -S .
+```
+
+æŗ¨æīŧ`rg` å¯čŊåšé
æŽéåįŦĻ串éį `=======`īŧäžåĻå¸ŽåŠææŦãäģĨ `git diff --name-only --diff-filter=U` ä¸ēįŠēä¸ēåã
+
+### 6. æ ŧåŧåãæ´įäžčĩãæĩč¯
+
+`readline`:
+
+```powershell
+cd D:\Programing\go\tui-upstream-merge\readline
+$files = rg --files -g '*.go'
+if ($files) { gofmt -w $files }
+go mod tidy
+go test ./...
+```
+
+`console`:
+
+```powershell
+cd D:\Programing\go\tui-upstream-merge\console
+$files = rg --files -g '*.go'
+if ($files) { gofmt -w $files }
+go mod tidy
+go test ./...
+```
+
+éįšå
ŗæŗ¨ fork ååŊæĩč¯īŧ
+
+- `readline/fork_test.go`
+- `readline/terminal/fork_test.go`
+- `console/fork_test.go`
+- `console/paste_test.go`
+
+čŋäēæĩč¯į¨äē鞿ĸ䏿¸¸åæĨæļå ææŦå° terminalãpasteãinline suggestionãExecute įåčŊã
+
+äžčĩååīŧ
+
+- ä¸å ä¸ē `go mod tidy` æ æåįē§äžčĩã
+- åĻæåĒæ¯åˇĨå
ˇéžč§Ŗæå¯ŧč´ `golang.org/x/sys`ã`golang.org/x/exp` åįē§īŧäŧå
åēåŽååŽæšä¸æ¸¸įæŦã
+- åĻææŦå° fork æ°čŊåįĄŽåŽéčĻæ°åĸäžčĩīŧ莰åŊåå ã
+
+### 7. æäē¤ merge įģæ
+
+```powershell
+cd D:\Programing\go\tui-upstream-merge\readline
+git add -A
+git commit -m "merge upstream readline"
+
+cd D:\Programing\go\tui-upstream-merge\console
+git add -A
+git commit -m "merge upstream console"
+```
+
+åϿ厞įģæäē¤ååäŋŽäēäžčĩææĩč¯īŧå¯į¨īŧ
+
+```powershell
+git add -A
+git commit --amend --no-edit
+```
+
+### 8. æ´æ°éŋæ `iom` 忝
+
+åĒæåŊåįŽåŊåˇĨäŊåēåš˛åæļæ§čĄã
+
+```powershell
+cd D:\Programing\go\readline
+git status --short --branch
+git switch iom
+git merge --ff-only codex/tui-merge
+
+cd D:\Programing\go\console
+git status --short --branch
+git switch iom
+git merge --ff-only codex/tui-merge
+```
+
+åĻæåįŽåŊä¸åš˛åīŧå
ä¸čĻæ´æ° `iom`ãäŋį worktree merge commitīŧį፿ˇæšå¨å¤įåŽåå fast-forwardã
+
+### 9. copy å tui
+
+å
äģ `master` æ°åģē PR 忝īŧ
+
+```powershell
+cd D:\Programing\go\chainreactors\tui
+git switch master
+git pull --ff-only
+git switch -c chore/sync-readline-console-upstream
+```
+
+å
dry-runīŧ
+
+```powershell
+robocopy D:\Programing\go\tui-upstream-merge\readline D:\Programing\go\chainreactors\tui\readline /MIR /XD .git .idea /L
+robocopy D:\Programing\go\tui-upstream-merge\console D:\Programing\go\chainreactors\tui\console /MIR /XD .git .idea /XF example_linux_amd64 example_windows_amd64.exe /L
+```
+
+įĄŽčŽ¤åæ§čĄīŧ
+
+```powershell
+robocopy D:\Programing\go\tui-upstream-merge\readline D:\Programing\go\chainreactors\tui\readline /MIR /XD .git .idea
+if ($LASTEXITCODE -gt 7) { exit $LASTEXITCODE } else { $global:LASTEXITCODE = 0 }
+
+robocopy D:\Programing\go\tui-upstream-merge\console D:\Programing\go\chainreactors\tui\console /MIR /XD .git .idea /XF example_linux_amd64 example_windows_amd64.exe
+if ($LASTEXITCODE -gt 7) { exit $LASTEXITCODE } else { $global:LASTEXITCODE = 0 }
+```
+
+### 10. å¨ tui 䏿ĩč¯åæäē¤
+
+```powershell
+cd D:\Programing\go\chainreactors\tui
+go test ./...
+git status --short
+git add readline console
+git commit -m "sync readline and console with upstream"
+```
+
+åĻæ `go.mod` / `go.sum` å 忍ĄåååéčĻæ´æ°īŧååįŦæŖæĨåå å
Ĩ commitã
+
+### 11. ååģē PR
+
+```powershell
+git push -u origin chore/sync-readline-console-upstream
+```
+
+PR æčŋ°åģē莎å
åĢīŧ
+
+- åŽæš upstream commitīŧ
+ - readline: `git -C D:\Programing\go\tui-upstream-merge\readline rev-parse --short origin/master`
+ - console: `git -C D:\Programing\go\tui-upstream-merge\console rev-parse --short origin/main`
+- æŦå° merge commitīŧ
+ - readline: `git -C D:\Programing\go\tui-upstream-merge\readline rev-parse --short HEAD`
+ - console: `git -C D:\Programing\go\tui-upstream-merge\console rev-parse --short HEAD`
+- æĩč¯įģæīŧ
+ - `go test ./...` in readline
+ - `go test ./...` in console
+ - `go test ./...` in tui
+- äŋįįæŦå° fork čŊåã
+- Go version ååãåĻæåŽæšåæ¨Ąå `go.mod` æåäē `go` directiveīŧæįĄŽč¯´ææ¯åĻæĨåã
+
+## åŋĢéåˇŽåŧæŖæĨ
+
+æĨį fork ä¸åŽæšææ°čŋåˇŽåĒäēæäģļīŧ
+
+```powershell
+cd D:\Programing\go\tui-upstream-merge\readline
+git diff --shortstat origin/master HEAD
+git diff --name-status origin/master HEAD
+
+cd D:\Programing\go\tui-upstream-merge\console
+git diff --shortstat origin/main HEAD
+git diff --name-status origin/main HEAD
+```
+
+æĨįæŦå°äŋįæäē¤ååŽæšæäē¤īŧ
+
+```powershell
+cd D:\Programing\go\tui-upstream-merge\readline
+git log --oneline --decorate --left-right --cherry-pick HEAD...origin/master
+
+cd D:\Programing\go\tui-upstream-merge\console
+git log --oneline --decorate --left-right --cherry-pick HEAD...origin/main
+```
+
+## åŊå厞įĨįļæ
+
+2026-06-27 åˇ˛åŽæä¸æŦĄ merge éĒč¯īŧ
+
+- `readline`
+ - official latest: `088046b`
+ - merge result: `916e20f`
+ - test: `go test ./...` passed
+- `console`
+ - official latest: `7002774`
+ - merge result: `92517a0`
+ - test: `go test ./...` passed
+
+æŗ¨æīŧåŊååŽæš `readline` / `console` 忍Ąåä¸ē `go 1.25.0`īŧč `tui` æ šæ¨Ąåæ¯ `go 1.24.2`ãcopy å `tui` åšļå PR åīŧéčĻįĄŽčŽ¤æ¯åĻæĨå忍Ąå Go įæŦčĻæąã
diff --git a/readline/.github/workflows/codacy.yml b/readline/.github/workflows/codacy.yml
index a5208e1..6b1b70a 100644
--- a/readline/.github/workflows/codacy.yml
+++ b/readline/.github/workflows/codacy.yml
@@ -36,7 +36,7 @@ jobs:
steps:
# Checkout the repository to the GitHub Actions runner
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
- name: Run Codacy Analysis CLI
diff --git a/readline/.github/workflows/codeql.yml b/readline/.github/workflows/codeql.yml
index a7ed8ae..4d538e6 100644
--- a/readline/.github/workflows/codeql.yml
+++ b/readline/.github/workflows/codeql.yml
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
diff --git a/readline/.github/workflows/dependency-review.yml b/readline/.github/workflows/dependency-review.yml
index 0d4a013..3ea91b4 100644
--- a/readline/.github/workflows/dependency-review.yml
+++ b/readline/.github/workflows/dependency-review.yml
@@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
diff --git a/readline/.github/workflows/go.yml b/readline/.github/workflows/go.yml
index 6889e1e..211c747 100644
--- a/readline/.github/workflows/go.yml
+++ b/readline/.github/workflows/go.yml
@@ -18,12 +18,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
- go-version: 1.22.6
+ go-version-file: go.mod
- name: Build
run: go build -v ./...
@@ -37,17 +37,17 @@ jobs:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
- go-version: 1.22.6
+ go-version-file: go.mod
- name: Build
run: go build -v ./...
shell: powershell
- # - name: Run coverage
- # run: go test -v ./...
- # shell: powershell
+ - name: Run coverage
+ run: go test -v ./...
+ shell: powershell
diff --git a/readline/.gitignore b/readline/.gitignore
new file mode 100644
index 0000000..358d197
--- /dev/null
+++ b/readline/.gitignore
@@ -0,0 +1,3 @@
+.gemini
+
+readline.wiki/
diff --git a/readline/.golangci.yml b/readline/.golangci.yml
new file mode 100644
index 0000000..d2e4f09
--- /dev/null
+++ b/readline/.golangci.yml
@@ -0,0 +1,177 @@
+# Repo-local golangci-lint configuration for reeflective/readline.
+#
+# Derived from the shared reeflective config, with a few project-specific tunes:
+# - varnamelen/goconst/mnd disabled: short names in short scopes are idiomatic
+# here, and bulk literal/number extraction adds churn without value.
+# - gocognit threshold raised to 30 to match cyclop/gocyclo; the few genuinely
+# complex hot-path parsers/display functions are intentionally dense.
+# - nestif relaxed slightly (5 -> 7); the flagged blocks are shallow.
+# - funlen/gocognit excluded for tests (table-driven tests are naturally long).
+# - godox disabled: TODO/FIXME markers are kept intentionally as tracking notes.
+version: "2"
+run:
+ modules-download-mode: readonly
+ tests: true
+ allow-parallel-runners: true
+linters:
+ enable:
+ - asasalint
+ - bidichk
+ - bodyclose
+ - canonicalheader
+ - containedctx
+ - contextcheck
+ - copyloopvar
+ - cyclop
+ - decorder
+ - dogsled
+ - dupl
+ - dupword
+ - durationcheck
+ - err113
+ - errchkjson
+ - errname
+ - errorlint
+ - exptostd
+ - fatcontext
+ - forcetypeassert
+ - funlen
+ - ginkgolinter
+ - gocheckcompilerdirectives
+ - gochecksumtype
+ - gocognit
+ - gocritic
+ - gocyclo
+ - godot
+ - goheader
+ - gomoddirectives
+ - goprintffuncname
+ - gosec
+ - gosmopolitan
+ - grouper
+ - iface
+ - importas
+ - inamedparam
+ - interfacebloat
+ - intrange
+ - ireturn
+ - loggercheck
+ - maintidx
+ - makezero
+ - mirror
+ - misspell
+ - nakedret
+ - nestif
+ - nilerr
+ - nilnesserr
+ - nilnil
+ - noctx
+ - nolintlint
+ - nosprintfhostport
+ - paralleltest
+ - perfsprint
+ - prealloc
+ - predeclared
+ - protogetter
+ - reassign
+ - recvcheck
+ - revive
+ - sloglint
+ - spancheck
+ - tagalign
+ - tagliatelle
+ - testableexamples
+ - testifylint
+ - thelper
+ - tparallel
+ - unconvert
+ - unparam
+ - usestdlibvars
+ - usetesting
+ - wastedassign
+ - wsl
+ - zerologlint
+ disable:
+ - asciicheck
+ - depguard
+ - exhaustive
+ - exhaustruct
+ - forbidigo
+ - gochecknoglobals
+ - gochecknoinits
+ - goconst
+ - godox
+ - gomodguard
+ - lll
+ - mnd
+ - musttag
+ - nlreturn
+ - nonamedreturns
+ - promlinter
+ - rowserrcheck
+ - sqlclosecheck
+ - testpackage
+ - varnamelen
+ - whitespace
+ - wrapcheck
+ settings:
+ cyclop:
+ max-complexity: 30
+ package-average: 10
+ errcheck:
+ check-type-assertions: true
+ exhaustive:
+ check:
+ - switch
+ - map
+ funlen:
+ lines: 100
+ statements: 50
+ gocognit:
+ min-complexity: 30
+ nestif:
+ min-complexity: 7
+ paralleltest:
+ ignore-missing: true
+ wsl:
+ allow-separated-leading-comment: true
+ allow-cuddle-declarations: true
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ rules:
+ - linters:
+ - dupl
+ - errcheck
+ - funlen
+ - gocognit
+ - gocyclo
+ - gosec
+ path: _test\.go
+ - linters:
+ - lll
+ source: '^//go:generate '
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+issues:
+ max-same-issues: 0
+formatters:
+ enable:
+ - gofumpt
+ - goimports
+ settings:
+ goimports:
+ local-prefixes:
+ - github.com/reeflective
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/readline/README.md b/readline/README.md
index 22d6629..2f81384 100644
--- a/readline/README.md
+++ b/readline/README.md
@@ -4,23 +4,23 @@
-
-
+
-
+
-
+
-
-
+
@@ -48,17 +48,17 @@ It is used, between others, to power the [console](https://github.com/reeflectiv
- Cross-platform (Linux / MacOS / Windows)
- Full `.inputrc` support (all commands/options)
- Extensive test suite and almost full coverage of core code
-- [Extended list](https://github.com/reeflective/readline/wiki/Keymaps-&-Commands) of additional commands/options (edition/completion/history)
-- Complete [multiline edition/movement support](https://github.com/reeflective/readline/wiki/Multiline)
+- [Extended list](https://github.com/chainreactors/tui/readline/wiki/Keymaps-&-Commands) of additional commands/options (edition/completion/history)
+- Complete [multiline edition/movement support](https://github.com/chainreactors/tui/readline/wiki/Multiline)
- Command-line edition in `$EDITOR`/`$VISUAL` support
-- [Programmable API](https://github.com/reeflective/readline/wiki/Programmable-Commands), with failure-safe access to core components
-- Support for an [arbitrary number of history sources](https://github.com/reeflective/readline/wiki/History-Sources)
+- [Programmable API](https://github.com/chainreactors/tui/readline/wiki/Programmable-Commands), with failure-safe access to core components
+- Support for an [arbitrary number of history sources](https://github.com/chainreactors/tui/readline/wiki/History-Sources)
### Emacs / Standard
- Native Emacs commands
-- Emacs-style [macro engine](https://github.com/reeflective/readline/wiki/Macros#emacs) (not working across multiple calls)
-- Keywords [switching](https://github.com/reeflective/readline/wiki/Keymaps-&-Commands#modifying-text) (operators, booleans, hex/binary/digit) with iterations
+- Emacs-style [macro engine](https://github.com/chainreactors/tui/readline/wiki/Macros#emacs) (not working across multiple calls)
+- Keywords [switching](https://github.com/chainreactors/tui/readline/wiki/Keymaps-&-Commands#modifying-text) (operators, booleans, hex/binary/digit) with iterations
- Command/mode cursor status indicator
- Complete undo/redo history
- Command status/arg/iterations hint display
@@ -66,101 +66,101 @@ It is used, between others, to power the [console](https://github.com/reeflectiv
### Vim
- Near-native Vim mode
-- Vim [text objects](https://github.com/reeflective/readline/wiki/Keymaps-&-Commands#text-objects) (code blocks, words/blank/shellwords)
+- Vim [text objects](https://github.com/chainreactors/tui/readline/wiki/Keymaps-&-Commands#text-objects) (code blocks, words/blank/shellwords)
- Extended surround select/change/add functionality, with highlighting
- Vim Visual/Operator pending mode & cursor styles indications
- Vim Insert and Replace (once/many)
- All Vim registers, with completion support
-- [Vim-style](https://github.com/reeflective/readline/wiki/Macros#vim) macro recording (`q`) and invocation (`@`)
+- [Vim-style](https://github.com/chainreactors/tui/readline/wiki/Macros#vim) macro recording (`q`) and invocation (`@`)
### Interface
-- Support for PS1/PS2/RPROMPT/transient/tooltip [prompts](https://github.com/reeflective/readline/wiki/Prompts) (compatible with [oh-my-posh](https://github.com/JanDeDobbeleer/oh-my-posh))
-- Extended completion system, [keymap-based and configurable](https://github.com/reeflective/readline/wiki/Keymaps-&-Commands#completion), easy to populate & use
+- Support for PS1/PS2/RPROMPT/transient/tooltip [prompts](https://github.com/chainreactors/tui/readline/wiki/Prompts) (compatible with [oh-my-posh](https://github.com/JanDeDobbeleer/oh-my-posh))
+- Extended completion system, [keymap-based and configurable](https://github.com/chainreactors/tui/readline/wiki/Keymaps-&-Commands#completion), easy to populate & use
- Multiple completion display styles, with color support.
- Completion & History incremental search system & highlighting (fuzzy-search).
- Automatic & context-aware suffix removal for efficient flags/path/list completion.
- Optional asynchronous autocomplete
-- Builtin & programmable [syntax highlighting](https://github.com/reeflective/readline/wiki/Syntax-Highlighting)
+- Builtin & programmable [syntax highlighting](https://github.com/chainreactors/tui/readline/wiki/Syntax-Highlighting)
## Documentation
Readline is used by the [console library](https://github.com/reeflective/console) and its [example binary](https://github.com/reeflective/console/tree/main/example). To get a grasp of the
functionality provided by readline and its default configuration, install and start the binary.
-The documentation is available on the [repository wiki](https://github.com/reeflective/readline/wiki), for both users and developers.
+The documentation is available on the [repository wiki](https://github.com/chainreactors/tui/readline/wiki), for both users and developers.
## Showcases
- Emacs edition
(This extract is quite a pity, because its author is not using Emacs and does not know many of its shortcuts)
-
+
- Vim edition
-
+
- Undo/redo line history
-
+
- Keyword switching & selection
Switching various keywords
-
+
Using regexp-based selection to grab parts of words (here, URL components)
-
+
- Vim selection & movements (basic)
-
+
- Vim surround (selection and change)
Selecting/adding/changing surround regions
-
+
Surround and change in shellwords, matching brackets, etc.
-
+
- Vim registers (with completion)
-
+
- History movements/completion/use/search
History movement, completion and some other other widgets
-
+
- Completion
Classic mode & incremental search mode
-
+
Suffix-autoremoval
-
+
- Prompts
-
+
- Logging
-
+
- Inputrc init file reload
-
+
- Multiline edition
-
+
- Macros
Emacs
-
+
Vim
-
+
## Status
diff --git a/readline/commands_test.go b/readline/commands_test.go
new file mode 100644
index 0000000..6e482a6
--- /dev/null
+++ b/readline/commands_test.go
@@ -0,0 +1,46 @@
+package readline
+
+import (
+ "maps"
+ "testing"
+)
+
+// allCommands aggregates every command set the shell registers with its keymap
+// engine (see Shell.init in shell.go). The builders only construct maps of
+// method values, so a zero Shell is enough to enumerate them.
+func allCommands() commands {
+ rl := new(Shell)
+
+ all := make(commands)
+ for _, set := range []commands{
+ rl.standardCommands(),
+ rl.viCommands(),
+ rl.historyCommands(),
+ rl.completionCommands(),
+ } {
+ maps.Copy(all, set)
+ }
+
+ return all
+}
+
+// TestArrowNavigationCommandsRegistered guards that every line/history
+// navigation action bound by the library's own default keymaps resolves to a
+// registered command. The vi-insert down-arrow binding (down-line-or-search)
+// previously resolved to nothing and was a silent no-op; this is its
+// regression guard, plus its siblings so the whole arrow-nav set stays wired.
+func TestArrowNavigationCommandsRegistered(t *testing.T) {
+ all := allCommands()
+
+ for _, action := range []string{
+ "up-line-or-search",
+ "down-line-or-search",
+ "up-line-or-history",
+ "down-line-or-history",
+ "down-line-or-select",
+ } {
+ if _, ok := all[action]; !ok {
+ t.Errorf("navigation action %q is bound in the default keymaps but has no registered command", action)
+ }
+ }
+}
diff --git a/readline/completion.go b/readline/completion.go
index ebdeb56..6cb2c03 100644
--- a/readline/completion.go
+++ b/readline/completion.go
@@ -57,6 +57,9 @@ func (rl *Shell) completeWord() {
rl.startMenuComplete(rl.commandCompletion)
if rl.Config.GetBool("menu-complete-display-prefix") {
+ // Insert the prefix shared by all candidates, then display the
+ // menu without selecting one (GNU menu-complete-display-prefix).
+ rl.completer.InsertCommonPrefix()
return
}
}
@@ -82,8 +85,10 @@ func (rl *Shell) insertCompletions() {
}
// Insert each match, cancel insertion with preserving
- // the candidate just inserted in the line, for each.
- for i := 0; i < rl.completer.Matches(); i++ {
+ // the candidate just inserted in the line, for each. The match count is
+ // invariant here: Select only moves the highlight and Cancel(false,false)
+ // only restores the line buffer, neither alters the candidate groups.
+ for range rl.completer.Matches() {
rl.completer.Select(1, 0)
rl.completer.Cancel(false, false)
}
@@ -114,6 +119,8 @@ func (rl *Shell) menuComplete() {
// Immediately select only if not asked to display first.
if rl.Config.GetBool("menu-complete-display-prefix") {
+ // Insert the prefix shared by all candidates before displaying.
+ rl.completer.InsertCommonPrefix()
return
}
}
@@ -220,6 +227,22 @@ func (rl *Shell) menuIncrementalSearch() {
rl.completer.IsearchStart("completions", false, false)
}
+// RefreshCompletions regenerates the currently active completion menu from the
+// cached completer and repaints, so completions produced asynchronously (for
+// instance by a background producer that updates a cache the completer reads)
+// can be shown in place without the user pressing a key.
+//
+// It is safe to call from any goroutine. If no completion menu is active it is
+// a clean no-op. The regeneration runs on the Readline goroutine (rendering
+// stays single-writer). If the user has a candidate selected, that selection is
+// preserved across the refresh: the same candidate (matched by tag+value) is
+// re-selected in the rebuilt menu, or, if it no longer exists in the new
+// results, the menu is left active with no selection.
+func (rl *Shell) RefreshCompletions() {
+ rl.completer.RequestRegen()
+ rl.Keys.RequestRefresh()
+}
+
//
// Utilities --------------------------------------------------------------------------
//
diff --git a/readline/completions.go b/readline/completions.go
index f193d80..361eadd 100644
--- a/readline/completions.go
+++ b/readline/completions.go
@@ -13,6 +13,8 @@ type Completion = completion.Candidate
// including usage strings, messages, and suffix matchers for autoremoval.
// Some of those additional settings will apply to all contained candidates,
// except when these candidates have their own corresponding settings.
+//
+//nolint:recvcheck // Fluent builder: value-receiver setters; only EachValue/merge/convert use a pointer (intentional).
type Completions struct {
values completion.RawValues
messages completion.Messages
@@ -106,7 +108,7 @@ func CompleteStyledValuesDescribed(values ...string) Completions {
return Completions{values: vals}
}
-// CompleteMessage ads a help message to display along with
+// CompleteMessage adds a help message to display along with
// or in places where no completions can be generated.
func CompleteMessage(msg string, args ...any) Completions {
comps := Completions{}
@@ -125,19 +127,6 @@ func CompleteRaw(values []Completion) Completions {
return Completions{values: completion.RawValues(values)}
}
-// Message displays a help messages in places where no completions can be generated.
-func Message(msg string, args ...any) Completions {
- comps := Completions{}
-
- if len(args) > 0 {
- msg = fmt.Sprintf(msg, args...)
- }
-
- comps.messages.Add(msg)
-
- return comps
-}
-
// Suppress suppresses specific error messages using regular expressions.
func (c Completions) Suppress(expr ...string) Completions {
if err := c.messages.Suppress(expr...); err != nil {
@@ -206,7 +195,7 @@ func (c Completions) UsageF(f func() string) Completions {
// CompleteValues("yes").Style("35")
// CompleteValues("no").Style("255")
func (c Completions) Style(style string) Completions {
- return c.StyleF(func(s string) string {
+ return c.StyleF(func(_ string) string {
return style
})
}
@@ -239,7 +228,7 @@ func (c Completions) StyleF(f func(s string) string) Completions {
//
// CompleteValues("192.168.1.1", "127.0.0.1").Tag("interfaces").
func (c Completions) Tag(tag string) Completions {
- return c.TagF(func(value string) string {
+ return c.TagF(func(_ string) string {
return tag
})
}
diff --git a/readline/emacs.go b/readline/emacs.go
index 402aa20..a2acc26 100644
--- a/readline/emacs.go
+++ b/readline/emacs.go
@@ -8,13 +8,15 @@ import (
"unicode"
"github.com/atotto/clipboard"
+ "github.com/rivo/uniseg"
+
"github.com/chainreactors/tui/readline/inputrc"
"github.com/chainreactors/tui/readline/internal/color"
"github.com/chainreactors/tui/readline/internal/completion"
+ "github.com/chainreactors/tui/readline/internal/core"
"github.com/chainreactors/tui/readline/internal/keymap"
"github.com/chainreactors/tui/readline/internal/strutil"
"github.com/chainreactors/tui/readline/internal/term"
- "github.com/rivo/uniseg"
)
// standardCommands returns all standard/emacs commands.
@@ -34,9 +36,6 @@ func (rl *Shell) standardCommands() commands {
// Modes
"emacs-editing-mode": rl.emacsEditingMode,
- "copy-to-clipboard": rl.copyToClipboard,
- "paste-from-clipboard": rl.pasteFromClipboard,
-
// Moving
"forward-char": rl.forwardChar,
"backward-char": rl.backwardChar,
@@ -61,6 +60,7 @@ func (rl *Shell) standardCommands() commands {
"tab-insert": rl.tabInsert,
"self-insert": rl.selfInsert,
"bracketed-paste-begin": rl.bracketedPasteBegin,
+ "skip-csi-sequence": rl.skipCsiSequence,
"transpose-chars": rl.transposeChars,
"transpose-words": rl.transposeWords,
"shell-transpose-words": rl.shellTransposeWords,
@@ -70,11 +70,13 @@ func (rl *Shell) standardCommands() commands {
"overwrite-mode": rl.overwriteMode,
"delete-horizontal-whitespace": rl.deleteHorizontalWhitespace,
- "delete-word": rl.deleteWord,
- "quote-region": rl.quoteRegion,
- "quote-line": rl.quoteLine,
- "keyword-increase": rl.keywordIncrease,
- "keyword-decrease": rl.keywordDecrease,
+ "delete-word": rl.deleteWord,
+ "quote-region": rl.quoteRegion,
+ "quote-line": rl.quoteLine,
+ "keyword-increase": rl.keywordIncrease,
+ "keyword-decrease": rl.keywordDecrease,
+ "copy-to-clipboard": rl.copyToClipboard,
+ "paste-from-clipboard": rl.pasteFromClipboard,
// Killing & yanking
"kill-line": rl.killLine,
@@ -292,8 +294,8 @@ func (rl *Shell) downLine() {
func (rl *Shell) clearScreen() {
rl.History.SkipSave()
- term.Print(term.CursorTopLeft)
- term.Print(term.ClearScreen)
+ fmt.Print(term.CursorTopLeft)
+ fmt.Print(term.ClearScreen)
rl.Display.PrintPrimaryPrompt()
}
@@ -303,8 +305,8 @@ func (rl *Shell) clearScreen() {
func (rl *Shell) clearDisplay() {
rl.History.SkipSave()
- term.Print(term.CursorTopLeft)
- term.Print(term.ClearDisplay)
+ fmt.Print(term.CursorTopLeft)
+ fmt.Print(term.ClearDisplay)
rl.Display.PrintPrimaryPrompt()
}
@@ -424,10 +426,7 @@ func (rl *Shell) selfInsert() {
rl.completer.TrimSuffix()
key := rl.Keys.Caller()
- if len(key) == 0 {
- // todo: temp fix
- return
- }
+
// Handle autopair insertion (for the closer only)
searching, _, _ := rl.completer.NonIncrementallySearching()
isearch := rl.Keymap.Local() == keymap.Isearch
@@ -440,6 +439,7 @@ func (rl *Shell) selfInsert() {
var quoted []rune
var length int
+
if rl.Config.GetBool("output-meta") && key[0] != inputrc.Esc {
quoted = append(quoted, key[0])
length = uniseg.StringWidth(string(quoted))
@@ -456,6 +456,37 @@ func (rl *Shell) bracketedPasteBegin() {
rl.InsertPastedText(string(rl.Keys.ReadUntilSequence([]byte(BracketedPasteEnd))))
}
+// skipCsiSequence consumes the remainder of a CSI escape sequence (the GNU
+// readline skip-csi-sequence command). Terminals encode many special keys as
+// "ESC [" followed by parameter/intermediate bytes (0x20-0x3F) and a single
+// final byte (0x40-0x7E) -- e.g. F5 is "\e[15~", Shift-Tab is "\e[Z". When such
+// a key has no binding, its trailing bytes would otherwise be self-inserted as
+// stray characters. Bound to "\e[", this command catches any CSI sequence that
+// no longer/exact binding claimed and swallows the rest of it, so unrecognised
+// keys do nothing.
+//
+// It is intentionally left unbound by default (as in GNU readline); enable it
+// from inputrc by binding the sequence "\e[" to the skip-csi-sequence command.
+func (rl *Shell) skipCsiSequence() {
+ rl.History.SkipSave()
+
+ // Whatever the dispatcher already consumed of the sequence, drain the rest:
+ // keep popping parameter/intermediate bytes, and stop once we consume the
+ // terminating byte (the final byte, or any non-CSI byte). The keys of a CSI
+ // sequence arrive in a single terminal read, so they are already buffered;
+ // if the buffer empties we simply stop rather than block on a partial one.
+ for {
+ key, empty := core.PopKey(rl.Keys)
+ if empty {
+ return
+ }
+
+ if key < 0x20 || key >= 0x40 {
+ return
+ }
+ }
+}
+
// Drag the character before point forward over the character
// at point, moving point forward as well. If point is at the
// end of the line, then this transposes the two characters
@@ -1238,7 +1269,7 @@ func (rl *Shell) abort() {
key := rl.Keys.Caller()
if key[0] == rune(inputrc.Unescape(`\C-C`)[0]) {
quoted, _ := strutil.Quote(key[0])
- term.Print(string(quoted))
+ fmt.Print(string(quoted))
}
}
@@ -1435,7 +1466,7 @@ func (rl *Shell) insertComment() {
// can be made part of an inputrc file.
func (rl *Shell) dumpFunctions() {
rl.Display.ClearHelpers()
- term.Println()
+ fmt.Println()
defer func() {
rl.Prompt.PrimaryPrint()
@@ -1452,7 +1483,7 @@ func (rl *Shell) dumpFunctions() {
// can be made part of an inputrc file.
func (rl *Shell) dumpVariables() {
rl.Display.ClearHelpers()
- term.Println()
+ fmt.Println()
defer func() {
rl.Prompt.PrimaryPrint()
@@ -1460,7 +1491,7 @@ func (rl *Shell) dumpVariables() {
}()
// Get all variables and their values, alphabetically sorted.
- var variables []string
+ variables := make([]string, 0, len(rl.Config.Vars))
for variable := range rl.Config.Vars {
variables = append(variables, variable)
@@ -1472,12 +1503,12 @@ func (rl *Shell) dumpVariables() {
if rl.Iterations.IsSet() {
for _, variable := range variables {
value := rl.Config.Vars[variable]
- term.Printf("set %s %v\n", variable, value)
+ fmt.Printf("set %s %v\n", variable, value)
}
} else {
for _, variable := range variables {
value := rl.Config.Vars[variable]
- term.Printf("%s is set to `%v'\n", variable, value)
+ fmt.Printf("%s is set to `%v'\n", variable, value)
}
}
}
@@ -1488,7 +1519,7 @@ func (rl *Shell) dumpVariables() {
// can be made part of an inputrc file.
func (rl *Shell) dumpMacros() {
rl.Display.ClearHelpers()
- term.Println()
+ fmt.Println()
defer func() {
rl.Prompt.PrimaryPrint()
@@ -1514,12 +1545,12 @@ func (rl *Shell) dumpMacros() {
if rl.Iterations.IsSet() {
for _, key := range macroBinds {
action := inputrc.Escape(binds[inputrc.Unescape(key)].Action)
- term.Printf("\"%s\": \"%s\"\n", key, action)
+ fmt.Printf("\"%s\": \"%s\"\n", key, action)
}
} else {
for _, key := range macroBinds {
action := inputrc.Escape(binds[inputrc.Unescape(key)].Action)
- term.Printf("%s outputs %s\n", key, action)
+ fmt.Printf("%s outputs %s\n", key, action)
}
}
}
@@ -1624,35 +1655,32 @@ func (rl *Shell) selectKeywordPrev() {
rl.selection.Visual(false)
}
-// copyToClipboard å°åŊåéä¸įå
åŽšææ´čĄå¤åļå°įŗģįģåĒč´´æŋ
+// copyToClipboard copies the current selection, or the whole line when nothing
+// is selected, to the system clipboard.
func (rl *Shell) copyToClipboard() {
- rl.History.SkipSave()
-
- // æŖæĨæ¯åĻæéä¸įææŦ
var text string
if rl.selection.Active() {
text = rl.selection.Text()
} else {
- // åĻææ˛Ąæéä¸īŧå¤åļæ´čĄ
text = string(*rl.line)
}
-
- // å°ææŦå¤åļå°åĒč´´æŋ
- clipboard.WriteAll(text)
+ if text == "" {
+ rl.History.SkipSave()
+ return
+ }
+ if err := clipboard.WriteAll(text); err != nil {
+ rl.Hint.SetTemporary(color.FgRed + "copy failed")
+ return
+ }
+ rl.Hint.SetTemporary(color.Dim + "copied")
}
-// pasteFromClipboard äģåĒč´´æŋčˇåå
厚åšļį˛č´´å°åŊåå
æ äŊįŊŽ
+// pasteFromClipboard inserts text from the system clipboard at the cursor.
func (rl *Shell) pasteFromClipboard() {
- rl.History.Save()
-
- // äģåĒč´´æŋč¯ģåå
厚
text, err := clipboard.ReadAll()
if err != nil {
rl.Hint.SetTemporary(color.FgRed + "paste failed")
return
}
-
- // å°å
厚æå
Ĩå°å
æ äŊįŊŽ
- rl.cursor.InsertAt([]rune(text)...)
- rl.Display.Refresh()
+ rl.InsertPastedText(text)
}
diff --git a/readline/emacs_test.go b/readline/emacs_test.go
new file mode 100644
index 0000000..8c0fcee
--- /dev/null
+++ b/readline/emacs_test.go
@@ -0,0 +1,101 @@
+package readline
+
+import (
+ "testing"
+
+ "github.com/chainreactors/tui/readline/internal/core"
+ "github.com/chainreactors/tui/readline/internal/history"
+)
+
+// feedPaste builds a minimal shell, feeds a bracketed-paste payload terminated
+// by the end sequence, runs the paste handler, and returns the resulting line.
+func feedPaste(t *testing.T, payload string) string {
+ t.Helper()
+
+ line := new(core.Line)
+ rl := &Shell{
+ Keys: new(core.Keys),
+ line: line,
+ cursor: core.NewCursor(line),
+ }
+
+ // The handler consumes keys until it sees the paste-end sequence, so the
+ // terminator must be fed too â otherwise it would block waiting for input.
+ rl.Keys.Feed(false, []rune(payload+"\x1b[201~")...)
+ rl.bracketedPasteBegin()
+
+ return string(*line)
+}
+
+// TestBracketedPasteNormalizesCarriageReturns guards that pasted line breaks,
+// which terminals deliver as \r or \r\n, are stored as \n. Raw carriage
+// returns corrupt the line buffer and its multiline rendering.
+func TestBracketedPasteNormalizesCarriageReturns(t *testing.T) {
+ cases := []struct {
+ name string
+ payload string
+ want string
+ }{
+ {"crlf", "a\r\nb", "a\nb"},
+ {"bare cr", "a\rb", "a\nb"},
+ {"mixed", "one\r\ntwo\rthree", "one\ntwo\nthree"},
+ {"no newline", "plain", "plain"},
+ {"already lf", "a\nb", "a\nb"},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := feedPaste(t, c.payload); got != c.want {
+ t.Fatalf("paste %q => %q, want %q", c.payload, got, c.want)
+ }
+ })
+ }
+}
+
+// drainKeys pops everything remaining in the key buffer and returns it.
+func drainKeys(rl *Shell) string {
+ var b []byte
+
+ for {
+ key, empty := core.PopKey(rl.Keys)
+ if empty {
+ return string(b)
+ }
+
+ b = append(b, key)
+ }
+}
+
+// TestSkipCsiSequence drives skip-csi-sequence over the bytes that remain after
+// the dispatcher has matched the leading "\e[": it must swallow the parameter
+// bytes and the single final byte, while leaving any following keystrokes
+// untouched.
+func TestSkipCsiSequence(t *testing.T) {
+ cases := []struct {
+ name string
+ feed string // bytes left in the buffer after "\e[" was matched
+ wantLeft string // what must remain unconsumed afterwards
+ }{
+ {"function key params and final", "15~", ""},
+ {"single final byte", "Z", ""},
+ {"modified arrow with params", "1;5D", ""},
+ {"stops at final, keeps following keys", "15~abc", "abc"},
+ {"empty buffer is a no-op", "", ""},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ rl := &Shell{Keys: new(core.Keys), History: &history.Sources{}}
+
+ if c.feed != "" {
+ rl.Keys.Feed(false, []rune(c.feed)...)
+ }
+
+ rl.skipCsiSequence()
+
+ if got := drainKeys(rl); got != c.wantLeft {
+ t.Fatalf("skip of %q left %q, want %q", c.feed, got, c.wantLeft)
+ }
+ })
+ }
+}
diff --git a/readline/fork_test.go b/readline/fork_test.go
new file mode 100644
index 0000000..f8ab7a0
--- /dev/null
+++ b/readline/fork_test.go
@@ -0,0 +1,56 @@
+package readline
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ rlterm "github.com/chainreactors/tui/readline/terminal"
+)
+
+func TestNewShellWithTerminalUsesCustomInput(t *testing.T) {
+ term := rlterm.Stream(strings.NewReader("alpha<>beta"), ioDiscard{}, ioDiscard{}, rlterm.NewControl(false, 40, 10))
+ rl := NewShellWithTerminal(term)
+
+ got := string(rl.Keys.ReadUntilSequence([]byte("<>")))
+ if got != "alpha" {
+ t.Fatalf("ReadUntilSequence read %q, want %q", got, "alpha")
+ }
+
+ rest := string(rl.Keys.Read())
+ if rest != "beta" {
+ t.Fatalf("remaining buffered input = %q, want %q", rest, "beta")
+ }
+}
+
+func TestShellPrintfUsesTerminalOutput(t *testing.T) {
+ var out bytes.Buffer
+ term := rlterm.Stream(strings.NewReader(""), &out, &out, rlterm.NewControl(false, 40, 10))
+ rl := NewShellWithTerminal(term)
+
+ if _, err := rl.Printf("hello %s", "terminal"); err != nil {
+ t.Fatalf("Printf returned error: %v", err)
+ }
+
+ if !strings.Contains(out.String(), "hello terminal") {
+ t.Fatalf("terminal output %q does not contain formatted message", out.String())
+ }
+}
+
+func TestInlineSuggestionAPI(t *testing.T) {
+ rl := NewShellWithTerminal(rlterm.Stream(strings.NewReader(""), ioDiscard{}, ioDiscard{}, rlterm.NewControl(false, 40, 10)))
+
+ rl.SetInlineSuggestion("status --verbose")
+ if got := rl.GetInlineSuggestion(); got != "status --verbose" {
+ t.Fatalf("inline suggestion = %q", got)
+ }
+
+ rl.ClearInlineSuggestion()
+ if got := rl.GetInlineSuggestion(); got != "" {
+ t.Fatalf("cleared inline suggestion = %q, want empty", got)
+ }
+}
+
+type ioDiscard struct{}
+
+func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil }
diff --git a/readline/go.mod b/readline/go.mod
index 4ceaff9..b4cf944 100644
--- a/readline/go.mod
+++ b/readline/go.mod
@@ -1,13 +1,12 @@
module github.com/chainreactors/tui/readline
-go 1.21
+go 1.25.0
-require (
- golang.org/x/exp v0.0.0-20220827204233-334a2380cb91
- golang.org/x/sys v0.8.0
-)
+require golang.org/x/sys v0.45.0
require (
github.com/atotto/clipboard v0.1.4
- github.com/rivo/uniseg v0.4.4
+ github.com/creack/pty v1.1.24
+ github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
+ github.com/rivo/uniseg v0.4.7
)
diff --git a/readline/go.sum b/readline/go.sum
index 994a1e2..8531fe0 100644
--- a/readline/go.sum
+++ b/readline/go.sum
@@ -1,8 +1,10 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
-github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw=
-golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
-golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
+github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/readline/history.go b/readline/history.go
index a2a873e..797b818 100644
--- a/readline/history.go
+++ b/readline/history.go
@@ -60,6 +60,7 @@ func (rl *Shell) historyCommands() commands {
"vi-down-line-or-history": rl.viDownLineOrHistory,
"up-line-or-history": rl.upLineOrHistory,
"up-line-or-search": rl.upLineOrSearch,
+ "down-line-or-search": rl.downLineOrSearch,
"down-line-or-select": rl.downLineOrSelect,
"infer-next-history": rl.inferNextHistory,
"beginning-of-buffer-or-history": rl.beginningOfBufferOrHistory,
@@ -439,6 +440,22 @@ func (rl *Shell) upLineOrSearch() {
}
}
+// If the cursor is not on the last line of the buffer, move down a line.
+// Otherwise, search forward in the history for a line matching the buffer.
+// This is the symmetric counterpart of up-line-or-search; without it the
+// default vi-insert down-arrow binding resolved to an unregistered command
+// and did nothing.
+func (rl *Shell) downLineOrSearch() {
+ rl.History.SkipSave()
+
+ switch {
+ case rl.cursor.LinePos() < rl.line.Lines():
+ rl.cursor.LineMove(1)
+ default:
+ rl.historySearchForward()
+ }
+}
+
// If the cursor is on the last line of the buffer, start an incremental
// search forward on the history lines. Otherwise, move up a line in the buffer.
func (rl *Shell) downLineOrSelect() {
diff --git a/readline/inputrc/inputrc_test.go b/readline/inputrc/inputrc_test.go
index c4264a2..219f75c 100644
--- a/readline/inputrc/inputrc_test.go
+++ b/readline/inputrc/inputrc_test.go
@@ -3,6 +3,7 @@ package inputrc
import (
"bytes"
"embed"
+ "errors"
"fmt"
"io/fs"
"os/user"
@@ -166,11 +167,13 @@ func TestDecodeKey(t *testing.T) {
func newConfig() (*Config, map[string][]string) {
cfg := NewDefaultConfig(WithConfigReadFileFunc(readTestdata))
keys := make(map[string][]string)
- cfg.Funcs["$custom"] = func(k, v string) error {
+ // The error return is mandated by the Config.Funcs signature, so unparam's
+ // "always nil" is expected here.
+ cfg.Funcs["$custom"] = func(k, v string) error { //nolint:unparam
keys[k] = append(keys[k], v)
return nil
}
- cfg.Funcs[""] = func(k, v string) error {
+ cfg.Funcs[""] = func(k, v string) error { //nolint:unparam
keys[k] = append(keys[k], v)
return nil
}
@@ -186,6 +189,9 @@ func readTest(t *testing.T, name string) [][]byte {
t.Fatalf("expected no error, got: %v", err)
}
+ // Correct for Windows line endings, as test files are embedded.
+ buf = bytes.ReplaceAll(buf, []byte("\r\n"), []byte("\n"))
+
return bytes.Split(buf, []byte(delimiter))
}
@@ -204,7 +210,7 @@ func buildOpts(t *testing.T, buf []byte) []Option {
lines := bytes.Split(bytes.TrimSpace(buf), []byte{'\n'})
var opts []Option
- for i := 0; i < len(lines); i++ {
+ for i := range lines {
line := bytes.TrimSpace(lines[i])
// If the line is empty, keep going
if len(line) == 0 {
@@ -378,6 +384,8 @@ func parseBool(t *testing.T, buf []byte) bool {
return false
}
+var errInvalidTestData = errors.New("test data is invalid")
+
func readTestdata(name string) ([]byte, error) {
switch name {
case "/home/ken/.inputrc", "\\home\\ken\\_inputrc":
@@ -391,9 +399,12 @@ func readTestdata(name string) ([]byte, error) {
return nil, err
}
+ // Correct for Windows line endings, as test files are embedded.
+ buf = bytes.ReplaceAll(buf, []byte("\r\n"), []byte("\n"))
+
v := bytes.Split(buf, []byte(delimiter))
if len(v) != 3 {
- return nil, fmt.Errorf("test data %s is invalid", name)
+ return nil, fmt.Errorf("%w: %s", errInvalidTestData, name)
}
return v[1], nil
diff --git a/readline/inputrc/parse.go b/readline/inputrc/parse.go
index 4292d7e..f5c0971 100644
--- a/readline/inputrc/parse.go
+++ b/readline/inputrc/parse.go
@@ -159,7 +159,8 @@ func (p *Parser) readNext(seq []rune, pos, end int) (string, string, token, erro
// does not bind a key) if a space follows the key declaration. made a
// decision to instead return an error if the : is missing in all cases.
// seek :
- for ; pos < end && seq[pos] != ':'; pos++ {
+ for pos < end && seq[pos] != ':' {
+ pos++
}
if pos == end || seq[pos] != ':' {
@@ -486,8 +487,10 @@ func (tok token) String() string {
// findNonSpace finds first non space rune in r, returning end if not found.
func findNonSpace(r []rune, i, end int) int {
- for ; i < end && unicode.IsSpace(r[i]); i++ {
+ for i < end && unicode.IsSpace(r[i]) {
+ i++
}
+
return i
}
@@ -507,11 +510,11 @@ func findStringEnd(seq []rune, pos, end int) (int, bool) {
quote := seq[pos]
for pos++; pos < end; pos++ {
- switch char = seq[pos]; {
- case char == '\\':
+ switch char = seq[pos]; char {
+ case '\\':
pos++
continue
- case char == quote:
+ case quote:
return pos + 1, true
}
}
@@ -618,6 +621,8 @@ func decodeRunes(r []rune, i, end int) string {
*/
// unescapeRunes decodes escaped string sequence.
+//
+//nolint:gocognit,gocyclo,cyclop,funlen // Escape-decoding state machine: intentionally dense, splitting it would obscure the cases.
func unescapeRunes(r []rune, i, end int) string {
var seq []rune
var char0, char1, char2, char3, char4, char5 rune
diff --git a/readline/internal/color/color.go b/readline/internal/color/color.go
index be344aa..de3bdfc 100644
--- a/readline/internal/color/color.go
+++ b/readline/internal/color/color.go
@@ -89,6 +89,14 @@ func Fmt(color string) string {
// string, including all escape codes found between and immediately around
// those characters (including surrounding 1st and 80th ones).
func Trim(input string, maxPrintableLength int) string {
+ // A non-positive budget cannot be honoured by the slicing below (it would
+ // panic on input[:negative]); callers reach this on very narrow terminals
+ // where the available column width is smaller than the trailing padding.
+ // Treat it as "nothing fits" and return empty rather than crashing.
+ if maxPrintableLength <= 0 {
+ return ""
+ }
+
if len(input) < maxPrintableLength {
return input
}
@@ -201,5 +209,13 @@ var re = regexp.MustCompile(ansi)
// Strip removes all ANSI escaped color sequences in a string.
func Strip(str string) string {
+ // Fast path: every match in the ansi pattern must begin with an ESC (0x1B)
+ // or CSI (0x9B) introducer, so a string containing neither has nothing to
+ // strip. This avoids a regex pass and an allocation for the common case of
+ // plain (uncolored) completion values, measured on every candidate.
+ if strings.IndexByte(str, 0x1B) == -1 && strings.IndexByte(str, 0x9B) == -1 {
+ return str
+ }
+
return re.ReplaceAllString(str, "")
}
diff --git a/readline/internal/color/color_test.go b/readline/internal/color/color_test.go
new file mode 100644
index 0000000..3a3bcbb
--- /dev/null
+++ b/readline/internal/color/color_test.go
@@ -0,0 +1,51 @@
+package color
+
+import "testing"
+
+func TestStrip(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"plain", "plain value", "plain value"},
+ {"empty", "", ""},
+ {"utf8 no escape", "cafÊ â rÊsumÊ", "cafÊ â rÊsumÊ"},
+ {"esc colored", "\x1b[31mred\x1b[0m", "red"},
+ {"esc surrounding", "a\x1b[1mb\x1b[0mc", "abc"},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := Strip(c.in); got != c.want {
+ t.Fatalf("Strip(%q) = %q, want %q", c.in, got, c.want)
+ }
+ })
+ }
+}
+
+func TestTrim(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ max int
+ want string
+ }{
+ {"shorter than max", "abc", 10, "abc"},
+ {"exact trim", "abcdef", 3, "abc"},
+ {"zero budget", "abcdef", 0, ""},
+ // A negative budget is reachable from completion column math on a very
+ // narrow terminal (maxDisplayWidth - trailingValueLen < 0). It must not
+ // panic on input[:negative].
+ {"negative budget", "abcdef", -4, ""},
+ {"trim keeps escapes", "\x1b[31mabcdef\x1b[0m", 3, "\x1b[31mabc"},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := Trim(c.in, c.max); got != c.want {
+ t.Fatalf("Trim(%q, %d) = %q, want %q", c.in, c.max, got, c.want)
+ }
+ })
+ }
+}
diff --git a/readline/internal/completion/completion.go b/readline/internal/completion/completion.go
index dd46657..0345cb5 100644
--- a/readline/internal/completion/completion.go
+++ b/readline/internal/completion/completion.go
@@ -13,11 +13,6 @@ type Candidate struct {
Style string // An arbitrary string of color/text effects to use when displaying the completion.
Tag string // All completions with the same tag are grouped together and displayed under the tag heading.
- // A list of runes that are automatically trimmed when a space or a non-nil character is
- // inserted immediately after the completion. This is used for slash-autoremoval in path
- // completions, comma-separated completions, etc.
- noSpace SuffixMatcher
-
displayLen int // Real length of the displayed candidate, that is not counting escaped sequences.
descLen int
}
diff --git a/readline/internal/completion/display.go b/readline/internal/completion/display.go
index d94ab09..bbded34 100644
--- a/readline/internal/completion/display.go
+++ b/readline/internal/completion/display.go
@@ -3,7 +3,6 @@ package completion
import (
"bufio"
"fmt"
- "regexp"
"strings"
"github.com/chainreactors/tui/readline/internal/color"
@@ -21,22 +20,25 @@ func Display(eng *Engine, maxRows int) {
// little more time. The engine itself is responsible for
// deleting those lists when it deems them useless.
if eng.Matches() == 0 || eng.skipDisplay {
- term.Print(term.ClearLineAfter)
+ term.WriteString(term.ClearLineAfter)
return
}
// The final completions string to print.
completions := term.ClearLineAfter
+ var completionsSb31 strings.Builder
for _, group := range eng.groups {
- completions += eng.renderCompletions(group)
+ completionsSb31.WriteString(eng.renderCompletions(group))
}
+ completions += completionsSb31.String()
+
// Crop the completions so that it fits within our terminal
completions, eng.usedY = eng.cropCompletions(completions, maxRows)
if completions != "" {
- term.Print(completions)
+ term.WriteString(completions)
}
}
@@ -125,12 +127,13 @@ func (e *Engine) highlightDisplay(grp *group, val Candidate, pad, col int, selec
candidate += color.Reset
}
} else {
- // Highlight the prefix if any and configured for it.
- if e.config.GetBool("colored-completion-prefix") && e.prefix != "" {
- if prefixMatch, err := regexp.Compile("^" + e.prefix); err == nil {
- prefixColored := color.Bold + color.FgBlue + e.prefix + color.BoldReset + color.FgDefault + style
- candidate = prefixMatch.ReplaceAllString(candidate, prefixColored)
- }
+ // Highlight the prefix if any and configured for it. The previous
+ // regexp ("^"+prefix) was compiled once per candidate per render and
+ // mismatched prefixes containing regex metacharacters; an anchored
+ // literal prefix is just a HasPrefix check plus a slice.
+ if e.config.GetBool("colored-completion-prefix") && e.prefix != "" && strings.HasPrefix(candidate, e.prefix) {
+ prefixColored := color.Bold + color.FgBlue + e.prefix + color.BoldReset + color.FgDefault + style
+ candidate = prefixColored + candidate[len(e.prefix):]
}
candidate = style + candidate + color.Reset
@@ -197,16 +200,21 @@ func (e *Engine) cutCompletionsBelow(scanner *bufio.Scanner, maxRows int) (strin
var count int
var cropped string
+ var croppedSb200 strings.Builder
+
for scanner.Scan() {
line := scanner.Text()
if count < maxRows-1 {
- cropped += line + term.NewlineReturn
+ croppedSb200.WriteString(line + term.NewlineReturn)
+
count++
} else {
break
}
}
+ cropped += croppedSb200.String()
+
cropped = strings.TrimSuffix(cropped, term.NewlineReturn)
// Add hint for remaining completions, if any.
@@ -228,6 +236,8 @@ func (e *Engine) cutCompletionsAboveBelow(scanner *bufio.Scanner, maxRows, absPo
var cropped string
var count int
+ var croppedSb231 strings.Builder
+
for scanner.Scan() {
line := scanner.Text()
@@ -238,13 +248,16 @@ func (e *Engine) cutCompletionsAboveBelow(scanner *bufio.Scanner, maxRows, absPo
}
if count > cutAbove && count <= absPos {
- cropped += line + term.NewlineReturn
+ croppedSb231.WriteString(line + term.NewlineReturn)
+
count++
} else {
break
}
}
+ cropped += croppedSb231.String()
+
cropped = strings.TrimSuffix(cropped, term.NewlineReturn)
count -= cutAbove + 1
diff --git a/readline/internal/completion/engine.go b/readline/internal/completion/engine.go
index 64f365f..dffacb4 100644
--- a/readline/internal/completion/engine.go
+++ b/readline/internal/completion/engine.go
@@ -2,8 +2,10 @@ package completion
import (
"regexp"
+ "sync/atomic"
"github.com/chainreactors/tui/readline/inputrc"
+ "github.com/chainreactors/tui/readline/internal/color"
"github.com/chainreactors/tui/readline/internal/core"
"github.com/chainreactors/tui/readline/internal/keymap"
"github.com/chainreactors/tui/readline/internal/ui"
@@ -37,6 +39,7 @@ type Engine struct {
auto bool // Is the engine autocompleting ?
autoForce bool // Special autocompletion mode (isearch-style)
skipDisplay bool // Don't display completions if there are some.
+ regenReq int32 // Atomic: an async regeneration of the menu was requested.
// Incremental search
IsearchRegex *regexp.Regexp // Holds the current search regex match
@@ -112,6 +115,121 @@ func (e *Engine) GenerateCached() {
e.GenerateWith(e.cached)
}
+// RequestRegen marks that the active completion menu should be regenerated from
+// the cached completer on the next refresh. It is safe to call from any
+// goroutine; the regeneration itself runs on the main loop (see ApplyRegen),
+// preserving the single-writer rendering invariant.
+func (e *Engine) RequestRegen() {
+ atomic.StoreInt32(&e.regenReq, 1)
+}
+
+// ApplyRegen regenerates the menu from the cached completer if a regeneration
+// was requested (RequestRegen) and a menu is currently active. It runs on the
+// main loop before a refresh. If a candidate was selected, the same candidate
+// is re-selected in the rebuilt menu when it still exists (see
+// regenPreservingSelection); otherwise the menu is left with no selection.
+func (e *Engine) ApplyRegen() {
+ if atomic.SwapInt32(&e.regenReq, 0) == 0 {
+ return
+ }
+
+ if !e.IsActive() {
+ return
+ }
+
+ e.regenPreservingSelection()
+}
+
+// regenPreservingSelection rebuilds the completion menu from the cached
+// completer in response to an asynchronous refresh, keeping the user's current
+// selection if the same candidate is still present in the new results.
+//
+// Selection is restored by CONTENT (tag+value), not position: prepare() rebuilds
+// and re-sorts the groups, so cell coordinates and group pointers do not survive
+// an async update â only the candidate's identity does.
+func (e *Engine) regenPreservingSelection() {
+ // Use the cached completer (explicit menu) or, failing that, the autocomplete
+ // completer (as-you-type menus, which never set e.cached). Without this
+ // fallback an autocomplete-driven menu â the common case in richer frontends â
+ // could not be refreshed while a candidate is selected.
+ completer := e.cached
+ if completer == nil {
+ completer = e.autoCompleter
+ }
+
+ if completer == nil {
+ return
+ }
+
+ // Incremental search maintains its own match/selection lifecycle; don't
+ // interfere with it, just recompute as before.
+ if e.keymap.Local() == keymap.Isearch {
+ e.GenerateCached()
+ return
+ }
+
+ // Remember the selected candidate's identity, then drop the virtual
+ // insertion so the completer regenerates against the REAL line. Otherwise
+ // the inserted candidate skews the completion context and the menu cannot
+ // update (it would appear frozen for as long as a candidate is selected).
+ selTag, selValue := e.selected.Tag, e.selected.Value
+ hadSelection := selValue != ""
+
+ if hadSelection {
+ e.cancelCompletedLine()
+ }
+
+ // Rebuild the candidate pool. Unlike Generate(), this deliberately does NOT
+ // auto-accept a transient unique candidate: during async streaming a
+ // momentary single result must not commit the line and close the menu.
+ e.prepare(completer())
+
+ if e.noCompletions() {
+ e.ClearMenu(true)
+ return
+ }
+
+ // Restore the selection by re-selecting the same candidate, if it survived.
+ // If it is gone, the menu is left active with no virtual selection.
+ if hadSelection && e.selectCandidate(selTag, selValue) {
+ e.insertCandidate()
+ }
+}
+
+// selectCandidate positions the menu selector on the (first) candidate matching
+// the given tag and value, making its group the current one. Values are matched
+// with escape sequences stripped, since the stored selection is stripped too.
+// It returns false if no such candidate exists in the current groups.
+func (e *Engine) selectCandidate(tag, value string) bool {
+ want := color.Strip(value)
+
+ for _, grp := range e.groups {
+ if tag != "" && grp.tag != tag {
+ continue
+ }
+
+ for y, row := range grp.rows {
+ for x, cand := range row {
+ if color.Strip(cand.Value) != want {
+ continue
+ }
+
+ for _, g := range e.groups {
+ g.isCurrent = false
+ }
+
+ grp.isCurrent = true
+ grp.posX = x
+ grp.posY = y
+
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
// SkipDisplay avoids printing completions below the
// input line, but still enables cycling through them.
func (e *Engine) SkipDisplay() {
@@ -288,6 +406,16 @@ func (e *Engine) Matches() int {
return comps
}
+// DisplaySkipped reports whether completion display is suppressed.
+func (e *Engine) DisplaySkipped() bool {
+ return e.skipDisplay
+}
+
+// ResetUsedRows clears the cached displayed row count.
+func (e *Engine) ResetUsedRows() {
+ e.usedY = 0
+}
+
// Line returns the relevant input line at the time this function is called:
// if a candidate is currently selected, the line returned is the one containing
// the candidate. If no candidate is selected, the normal input line is returned.
diff --git a/readline/internal/completion/group.go b/readline/internal/completion/group.go
index c4b51a4..748dd8a 100644
--- a/readline/internal/completion/group.go
+++ b/readline/internal/completion/group.go
@@ -1,9 +1,8 @@
package completion
import (
- "golang.org/x/exp/slices"
"math"
- "sort"
+ "slices"
"strconv"
"strings"
@@ -54,7 +53,7 @@ func (e *Engine) newCompletionGroup(comps Values, tag string, vals RawValues, de
// Global actions to take on all values.
if !grp.noSort {
- sort.Stable(vals)
+ vals.sortStable()
}
// Initial processing of our assigned values:
@@ -124,9 +123,8 @@ func (g *group) initCompletionsGrid(comps RawValues) {
}
pairLength := g.longestValueDescribed(comps)
- if pairLength > g.termWidth {
- pairLength = g.termWidth
- }
+ pairLength = min(g.termWidth, pairLength)
+
maxColumns := g.termWidth / pairLength
if g.list || maxColumns < 0 {
maxColumns = 1
@@ -156,7 +154,6 @@ func (g *group) initCompletionAliased(domains []Candidate) {
func (g *group) createDescribedRows(values []Candidate) ([][]Candidate, []string) {
descriptionMap := make(map[string][]Candidate)
uniqueDescriptions := make([]string, 0)
- rows := make([][]Candidate, 0)
// Separate duplicates and store them.
for i, description := range values {
@@ -169,6 +166,8 @@ func (g *group) createDescribedRows(values []Candidate) ([][]Candidate, []string
}
// Sorting helps with easier grids.
+ rows := make([][]Candidate, 0, len(uniqueDescriptions))
+
for _, description := range uniqueDescriptions {
row := descriptionMap[description]
rows = append(rows, row)
@@ -353,9 +352,8 @@ func (g *group) trimDisplay(comp Candidate, pad, col int) (candidate, padded str
// Get the allowed length for this column.
// The display can never be longer than terminal width.
maxDisplayWidth := g.columnsWidth[col] + 1
- if maxDisplayWidth > g.termWidth {
- maxDisplayWidth = g.termWidth
- }
+ maxDisplayWidth = min(g.termWidth, maxDisplayWidth)
+
val = sanitizer.Replace(val)
if comp.displayLen > maxDisplayWidth {
@@ -412,10 +410,7 @@ func (g *group) getPad(value Candidate, columnIndex int, desc bool) int {
// to the terminal size: we are not really sure
// of where offsets might be set in the code...
column := columns[columnIndex]
- if column > g.termWidth-1 {
- column = g.termWidth - 1
- }
-
+ column = min(g.termWidth-1, column)
padding := column - valLen
if padding < 0 {
@@ -636,7 +631,7 @@ func createGrid(values []Candidate, rowCount, maxColumns int) [][]Candidate {
grid := make([][]Candidate, rowCount)
- for i := 0; i < rowCount; i++ {
+ for i := range rowCount {
grid[i] = createRow(values, maxColumns, i)
}
@@ -646,10 +641,7 @@ func createGrid(values []Candidate, rowCount, maxColumns int) [][]Candidate {
func createRow(domains []Candidate, maxColumns, rowIndex int) []Candidate {
rowStart := rowIndex * maxColumns
rowEnd := (rowIndex + 1) * maxColumns
-
- if rowEnd > len(domains) {
- rowEnd = len(domains)
- }
+ rowEnd = min(len(domains), rowEnd)
return domains[rowStart:rowEnd]
}
diff --git a/readline/internal/completion/insert.go b/readline/internal/completion/insert.go
index 547a019..07e7db5 100644
--- a/readline/internal/completion/insert.go
+++ b/readline/internal/completion/insert.go
@@ -111,6 +111,77 @@ func (e *Engine) refreshLine() {
}
}
+// InsertCommonPrefix extends the input line with the longest prefix shared by
+// every current completion candidate, beyond what the user has already typed.
+// It implements the insertion half of the GNU readline menu-complete-display-prefix
+// option ("display the common prefix of the list of possible completions before
+// cycling"); the caller invokes it only when that option is set. When the
+// candidates share nothing past the typed prefix it is a no-op, and the menu is
+// simply displayed as before.
+func (e *Engine) InsertCommonPrefix() {
+ common := e.commonPrefix()
+
+ // Only extend the typed word; never shorten or re-case it.
+ if len([]rune(common)) <= len([]rune(e.prefix)) {
+ return
+ }
+
+ // Replace the typed prefix with the longer shared one, mirroring how
+ // acceptCandidate swaps the prefix for a full candidate value.
+ e.cursor.Move(-1 * len(e.prefix))
+ e.line.Cut(e.cursor.Pos(), e.cursor.Pos()+len(e.prefix))
+ e.cursor.InsertAt([]rune(common)...)
+
+ // Keep the engine prefix in sync so the menu highlighting and any
+ // subsequent candidate insertion stay consistent with the new word.
+ e.prefix = common
+}
+
+// commonPrefix returns the longest string that prefixes every current candidate's
+// inserted value, or "" when they share nothing (or there are no candidates).
+// The comparison is case-sensitive: candidates differing only in case stop the
+// prefix early, a conservative choice that never inserts characters not shared
+// verbatim by all candidates.
+func (e *Engine) commonPrefix() string {
+ var (
+ common string
+ seen bool
+ )
+
+ for _, grp := range e.groups {
+ for _, row := range grp.rows {
+ for _, cand := range row {
+ if !seen {
+ common = cand.Value
+ seen = true
+
+ continue
+ }
+
+ common = commonStringPrefix(common, cand.Value)
+ if common == "" {
+ return ""
+ }
+ }
+ }
+ }
+
+ return common
+}
+
+// commonStringPrefix returns the longest rune-aligned common prefix of a and b.
+func commonStringPrefix(a, b string) string {
+ ra, rb := []rune(a), []rune(b)
+ bound := min(len(ra), len(rb))
+
+ i := 0
+ for i < bound && ra[i] == rb[i] {
+ i++
+ }
+
+ return string(ra[:i])
+}
+
// acceptCandidate inserts the currently selected candidate into the real input line.
func (e *Engine) acceptCandidate() {
cur := e.currentGroup()
diff --git a/readline/internal/completion/insert_test.go b/readline/internal/completion/insert_test.go
new file mode 100644
index 0000000..b730fd9
--- /dev/null
+++ b/readline/internal/completion/insert_test.go
@@ -0,0 +1,108 @@
+package completion
+
+import (
+ "testing"
+
+ "github.com/chainreactors/tui/readline/internal/core"
+)
+
+// newPrefixEngine builds a minimal engine whose menu already holds the given
+// candidate values (spread across two groups to exercise multi-group walking),
+// with the real line containing the already-typed prefix and the cursor at its
+// end -- the state completeWord/menuComplete are in when the display-prefix
+// option fires.
+func newPrefixEngine(typed string, values ...string) (*Engine, *core.Line) {
+ l := core.Line([]rune(typed))
+ line := &l
+ cur := core.NewCursor(line)
+ cur.Set(line.Len())
+
+ var groups []*group
+
+ for i, v := range values {
+ // Alternate candidates between two groups so commonPrefix must walk
+ // more than one group and more than one row.
+ gi := i % 2
+ for len(groups) <= gi {
+ groups = append(groups, &group{})
+ }
+
+ groups[gi].rows = append(groups[gi].rows, []Candidate{{Value: v}})
+ }
+
+ e := &Engine{
+ line: line,
+ cursor: cur,
+ prefix: typed,
+ groups: groups,
+ }
+
+ return e, line
+}
+
+func TestCommonStringPrefix(t *testing.T) {
+ cases := []struct{ a, b, want string }{
+ {"foobar", "foobaz", "fooba"},
+ {"foo", "foobar", "foo"},
+ {"abc", "xyz", ""},
+ {"", "foo", ""},
+ {"cafÊ", "cafard", "caf"}, // rune-aligned, must not split the 'Ê'
+ }
+
+ for _, c := range cases {
+ if got := commonStringPrefix(c.a, c.b); got != c.want {
+ t.Errorf("commonStringPrefix(%q, %q) = %q, want %q", c.a, c.b, got, c.want)
+ }
+ }
+}
+
+func TestInsertCommonPrefix(t *testing.T) {
+ t.Run("extends to shared prefix", func(t *testing.T) {
+ e, line := newPrefixEngine("foo", "foobar", "foobaz", "foobat")
+ e.InsertCommonPrefix()
+
+ if got := string(*line); got != "fooba" {
+ t.Fatalf("line = %q, want %q", got, "fooba")
+ }
+
+ if e.prefix != "fooba" {
+ t.Fatalf("engine prefix = %q, want %q", e.prefix, "fooba")
+ }
+
+ if e.cursor.Pos() != 5 {
+ t.Fatalf("cursor = %d, want 5 (end of extended word)", e.cursor.Pos())
+ }
+ })
+
+ t.Run("no shared prefix beyond typed is a no-op", func(t *testing.T) {
+ e, line := newPrefixEngine("f", "foo", "fbar")
+ e.InsertCommonPrefix()
+
+ // "foo" and "fbar" share only "f", which equals the typed prefix.
+ if got := string(*line); got != "f" {
+ t.Fatalf("line = %q, want unchanged %q", got, "f")
+ }
+
+ if e.prefix != "f" {
+ t.Fatalf("engine prefix = %q, want unchanged %q", e.prefix, "f")
+ }
+ })
+
+ t.Run("single candidate extends fully", func(t *testing.T) {
+ e, line := newPrefixEngine("re", "readline")
+ e.InsertCommonPrefix()
+
+ if got := string(*line); got != "readline" {
+ t.Fatalf("line = %q, want %q", got, "readline")
+ }
+ })
+
+ t.Run("no candidates is a no-op", func(t *testing.T) {
+ e, line := newPrefixEngine("foo")
+ e.InsertCommonPrefix()
+
+ if got := string(*line); got != "foo" {
+ t.Fatalf("line = %q, want unchanged %q", got, "foo")
+ }
+ })
+}
diff --git a/readline/internal/completion/message.go b/readline/internal/completion/message.go
index ce9a26d..15a19ba 100644
--- a/readline/internal/completion/message.go
+++ b/readline/internal/completion/message.go
@@ -8,6 +8,8 @@ import (
// Messages is a list of messages to be displayed
// below the input line, above completions. It is
// used to show usage and/or error status hints.
+//
+//nolint:recvcheck // IsEmpty/Get read via value receiver; init/Add/Suppress/Merge mutate via pointer (intentional).
type Messages struct {
messages map[string]bool
}
@@ -32,7 +34,7 @@ func (m *Messages) Add(s string) {
// Get returns the list of messages to display.
func (m Messages) Get() []string {
- messages := make([]string, 0)
+ messages := make([]string, 0, len(m.messages))
for message := range m.messages {
messages = append(messages, message)
}
diff --git a/readline/internal/completion/suffix.go b/readline/internal/completion/suffix.go
index f0e4944..052fca0 100644
--- a/readline/internal/completion/suffix.go
+++ b/readline/internal/completion/suffix.go
@@ -6,6 +6,8 @@ import (
)
// SuffixMatcher is a type managing suffixes for a given list of completions.
+//
+//nolint:recvcheck // Matches reads via value receiver; Add/Merge mutate via pointer (intentional).
type SuffixMatcher struct {
string
pos int // Used to know if the saved suffix matcher is deprecated
diff --git a/readline/internal/completion/syntax.go b/readline/internal/completion/syntax.go
index d0f9c58..4fbbb4f 100644
--- a/readline/internal/completion/syntax.go
+++ b/readline/internal/completion/syntax.go
@@ -32,34 +32,32 @@ func AutopairInsertOrJump(key rune, line *core.Line, cur *core.Cursor) (skipInse
matcher, closer := strutil.SurroundType(key)
if !matcher {
- return
+ return false
}
if closer && cur.Char() == key {
- skipInsert = true
-
cur.Inc()
- return
+ return true
}
// If we are currently inside a quoted string, we don't want to insert pairs.
// This also effectively allows closing the quote we are currently in.
if key == '"' || key == '\'' {
if unclosed, _ := strutil.GetQuotedWordStart((*line)[:cur.Pos()]); unclosed {
- return
+ return false
}
}
switch {
case closer && key != '\'' && key != '"':
- return
+ return false
default:
_, closeChar := strutil.MatchSurround(key)
line.Insert(cur.Pos(), closeChar)
}
- return
+ return false
}
// AutopairDelete checks if the character under the cursor is an opening pair
diff --git a/readline/internal/completion/syntax_test.go b/readline/internal/completion/syntax_test.go
new file mode 100644
index 0000000..1a02220
--- /dev/null
+++ b/readline/internal/completion/syntax_test.go
@@ -0,0 +1,87 @@
+package completion
+
+import (
+ "testing"
+
+ "github.com/chainreactors/tui/readline/internal/core"
+)
+
+func TestAutopairInsertOrJump(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ cursor int
+ key rune
+ wantLine string
+ wantSkip bool
+ wantCursor int // Relative to original if not specified? No, absolute.
+ }{
+ {
+ name: "Empty line, insert quote",
+ line: "",
+ cursor: 0,
+ key: '"',
+ wantLine: "\"", // Function inserts closer
+ wantSkip: false, // selfInsert will insert opener
+ wantCursor: 0, // Cursor stays same (caller handles insert)
+ },
+ {
+ name: "Inside quote, type closing quote",
+ line: "\"foo",
+ cursor: 4,
+ key: '"',
+ wantLine: "\"foo", // Should NOT insert pair
+ wantSkip: false, // selfInsert will insert '"' -> "foo"
+ wantCursor: 4,
+ },
+ {
+ name: "Balanced quotes, type new quote",
+ line: "\"foo\"",
+ cursor: 5,
+ key: '"',
+ wantLine: "\"foo\"\"", // Inserts closer
+ wantSkip: false,
+ wantCursor: 5,
+ },
+ {
+ name: "Escaped quote inside double, type quote",
+ line: "\"foo \\\"",
+ cursor: 7,
+ key: '"',
+ wantLine: "\"foo \\\"", // Should detect unclosed and NOT insert pair
+ wantSkip: false,
+ wantCursor: 7,
+ },
+ {
+ name: "Jump over closing quote",
+ line: "\"foo\"",
+ cursor: 4, // before last "
+ key: '"',
+ wantLine: "\"foo\"",
+ wantSkip: true,
+ wantCursor: 5, // Inc
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ line := core.Line([]rune(tt.line))
+ cur := core.NewCursor(&line)
+ cur.Set(tt.cursor)
+
+ skip := AutopairInsertOrJump(tt.key, &line, cur)
+
+ if skip != tt.wantSkip {
+ t.Errorf("AutopairInsertOrJump() skip = %v, want %v", skip, tt.wantSkip)
+ }
+
+ if string(line) != tt.wantLine {
+ t.Errorf("AutopairInsertOrJump() line = %q, want %q", string(line), tt.wantLine)
+ }
+
+ if cur.Pos() != tt.wantCursor {
+ t.Errorf("AutopairInsertOrJump() cursor = %v, want %v", cur.Pos(), tt.wantCursor)
+ }
+ })
+ }
+}
diff --git a/readline/internal/completion/values.go b/readline/internal/completion/values.go
index 5db020e..d5445fb 100644
--- a/readline/internal/completion/values.go
+++ b/readline/internal/completion/values.go
@@ -1,6 +1,7 @@
package completion
import (
+ "sort"
"strings"
)
@@ -98,3 +99,27 @@ func (c RawValues) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
func (c RawValues) Less(i, j int) bool {
return strings.ToLower(c[i].Value) < strings.ToLower(c[j].Value)
}
+
+// sortStable orders the values case-insensitively by Value, stably. It folds
+// each value to lower case exactly once (paired sort) rather than twice per
+// comparison as the Less method does, which matters on large candidate sets
+// sorted on every keystroke. The ordering is identical to sort.Stable(c).
+func (c RawValues) sortStable() {
+ type keyed struct {
+ key string
+ value Candidate
+ }
+
+ pairs := make([]keyed, len(c))
+ for i, val := range c {
+ pairs[i] = keyed{key: strings.ToLower(val.Value), value: val}
+ }
+
+ sort.SliceStable(pairs, func(i, j int) bool {
+ return pairs[i].key < pairs[j].key
+ })
+
+ for i := range pairs {
+ c[i] = pairs[i].value
+ }
+}
diff --git a/readline/internal/completion/values_test.go b/readline/internal/completion/values_test.go
new file mode 100644
index 0000000..4b8bb25
--- /dev/null
+++ b/readline/internal/completion/values_test.go
@@ -0,0 +1,84 @@
+package completion
+
+import (
+ "sort"
+ "testing"
+)
+
+// referenceSort is the original sorting path (sort.Stable + the Less method),
+// kept here so we can assert sortStable produces an identical ordering.
+func referenceSort(c RawValues) RawValues {
+ out := make(RawValues, len(c))
+ copy(out, c)
+ sort.Stable(out)
+
+ return out
+}
+
+func TestSortStableMatchesReference(t *testing.T) {
+ cases := [][]string{
+ {"Banana", "apple", "Cherry", "banana", "APPLE", "cherry"},
+ {"z", "a", "m", "A", "Z", "M"},
+ {"git push", "git pull", "git Push", "GIT", "git"},
+ {},
+ {"only"},
+ }
+
+ for _, values := range cases {
+ raw := make(RawValues, len(values))
+ for i, v := range values {
+ raw[i] = Candidate{Value: v}
+ }
+
+ want := referenceSort(raw)
+
+ got := make(RawValues, len(raw))
+ copy(got, raw)
+ got.sortStable()
+
+ for i := range want {
+ if got[i].Value != want[i].Value {
+ t.Fatalf("ordering mismatch for %v at %d: got %q, want %q",
+ values, i, got[i].Value, want[i].Value)
+ }
+ }
+ }
+}
+
+func benchValues(n int) RawValues {
+ base := make(RawValues, n)
+ for i := range base {
+ // Mixed case so the comparator does real case-folding work.
+ base[i] = Candidate{Value: string(rune('A'+(i%26))) + string(rune('a'+(i*7%26)))}
+ }
+
+ return base
+}
+
+// BenchmarkSortStable vs BenchmarkReferenceSort: the keyed sort folds each
+// value once (~n allocations) instead of twice per comparison (~n*log n).
+func BenchmarkSortStable(b *testing.B) {
+ base := benchValues(1000)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ for range b.N {
+ work := make(RawValues, len(base))
+ copy(work, base)
+ work.sortStable()
+ }
+}
+
+func BenchmarkReferenceSort(b *testing.B) {
+ base := benchValues(1000)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ for range b.N {
+ work := make(RawValues, len(base))
+ copy(work, base)
+ sort.Stable(work)
+ }
+}
diff --git a/readline/internal/core/api_windows.go b/readline/internal/core/api_windows.go
index ad8881c..deac0a4 100644
--- a/readline/internal/core/api_windows.go
+++ b/readline/internal/core/api_windows.go
@@ -9,6 +9,8 @@ import (
"reflect"
"syscall"
"unsafe"
+
+ "github.com/chainreactors/tui/readline/internal/term"
)
var (
@@ -167,6 +169,10 @@ func setConsoleCursorPosition(c *_COORD) error {
// GetCursorPos returns the current cursor position on Windows.
func (k *Keys) GetCursorPos() (x, y int) {
+ // Flush any buffered frame output so the console cursor reflects everything
+ // printed so far before we read its position.
+ term.FlushBuffer()
+
t := new(_CONSOLE_SCREEN_BUFFER_INFO)
kernel.GetConsoleScreenBufferInfo(
stdout,
diff --git a/readline/internal/core/cursor.go b/readline/internal/core/cursor.go
index 6a6d9f6..77ffbd9 100644
--- a/readline/internal/core/cursor.go
+++ b/readline/internal/core/cursor.go
@@ -246,15 +246,20 @@ func (c *Cursor) LineMove(lines int) {
return
}
+ // Each step lands the cursor at the target column, or at the end of a
+ // shorter line (on its trailing newline). We deliberately do NOT clamp off
+ // that newline here: LineMove is keymap-agnostic, and the per-command
+ // post-step in Shell.execute already applies CheckCommand in vi-command
+ // mode and CheckAppend otherwise. Clamping here as well double-applied the
+ // command-mode rule, leaving the cursor one column short of the line end in
+ // emacs and vi-insert modes.
if lines < 0 {
- for i := 0; i < -1*lines; i++ {
+ for range -lines {
c.moveLineUp()
- c.CheckCommand()
}
} else {
- for i := 0; i < lines; i++ {
+ for range lines {
c.moveLineDown()
- c.CheckCommand()
}
}
}
@@ -287,7 +292,7 @@ func (c *Cursor) AtBeginningOfLine() bool {
newlines := c.line.newlines()
- for line := 0; line < len(newlines); line++ {
+ for line := range newlines {
epos := newlines[line][0]
if epos == c.pos-1 {
return true
@@ -306,7 +311,7 @@ func (c *Cursor) AtEndOfLine() bool {
newlines := c.line.newlines()
- for line := 0; line < len(newlines); line++ {
+ for line := range newlines {
epos := newlines[line][0]
if epos == c.pos+1 {
return true
@@ -394,7 +399,7 @@ func (c *Cursor) moveLineDown() {
newlines := c.line.newlines()
- for line := 0; line < len(newlines); line++ {
+ for line := range newlines {
end := newlines[line][0]
if line < c.LinePos() {
begin = end
diff --git a/readline/internal/core/cursor_test.go b/readline/internal/core/cursor_test.go
index ffc3ddf..0f31744 100644
--- a/readline/internal/core/cursor_test.go
+++ b/readline/internal/core/cursor_test.go
@@ -762,6 +762,85 @@ func TestCursor_LineMove(t *testing.T) {
}
}
+// TestCursor_LineMove_LandsAtLineEnd is the regression guard for the multiline
+// off-by-one: moving onto a shorter (non-empty) line must leave the cursor at
+// that line's end (its trailing newline = the append position), not one column
+// short of it. LineMove used to apply CheckCommand on every step, which clamped
+// the cursor off the newline even for emacs/vi-insert, where end-of-line is a
+// valid position.
+func TestCursor_LineMove_LandsAtLineEnd(t *testing.T) {
+ // indices: a0 b1 c2 d3 e4 f5 \n6 x7 \n8 g9 h10 i11 j12 k13 l14
+ line := Line("abcdef\nx\nghijkl")
+
+ // On line 0, column 5 ('f'); move down onto the 1-char line "x".
+ c := &Cursor{line: &line, pos: 5, mark: -1}
+ c.LineMove(1)
+
+ // End of "x" is the trailing newline at index 8 (append position).
+ if c.Pos() != 8 {
+ t.Fatalf("down onto short line: pos %d, want 8 (end of line, not one short)", c.Pos())
+ }
+
+ if c.LinePos() != 1 {
+ t.Fatalf("down onto short line: line %d, want 1", c.LinePos())
+ }
+}
+
+// TestCursor_LineMove_ModeClampContract documents the division of labour:
+// LineMove leaves the cursor at the end-of-line position, and the caller's
+// post-step check decides whether that is allowed. Shell.execute applies
+// CheckAppend in emacs/vi-insert (end-of-line kept) and CheckCommand in
+// vi-command (clamped onto the last char).
+func TestCursor_LineMove_ModeClampContract(t *testing.T) {
+ line := Line("abcdef\nx\nghijkl")
+
+ // emacs / vi-insert: CheckAppend keeps the end-of-line position.
+ emacs := &Cursor{line: &line, pos: 5, mark: -1}
+ emacs.LineMove(1)
+ emacs.CheckAppend()
+
+ if emacs.Pos() != 8 {
+ t.Fatalf("emacs post-move: pos %d, want 8 (end of line kept)", emacs.Pos())
+ }
+
+ // vi-command: CheckCommand clamps off the newline onto 'x'.
+ vicmd := &Cursor{line: &line, pos: 5, mark: -1}
+ vicmd.LineMove(1)
+ vicmd.CheckCommand()
+
+ if vicmd.Pos() != 7 {
+ t.Fatalf("vi-command post-move: pos %d, want 7 (clamped onto last char)", vicmd.Pos())
+ }
+}
+
+// TestCursor_LineMove_StickyColumn checks that vertical motion across lines that
+// are all long enough preserves the column, and a down-then-up round trip
+// returns to the starting position.
+func TestCursor_LineMove_StickyColumn(t *testing.T) {
+ // Three equal-length lines: a0..f5 \n6 g7..l12 \n13 m14..r19
+ line := Line("abcdef\nghijkl\nmnopqr")
+
+ c := &Cursor{line: &line, pos: 3, mark: -1} // line 0, column 3 ('d')
+
+ c.LineMove(1)
+
+ if c.Pos() != 10 { // line 1, column 3 ('j')
+ t.Fatalf("down one: pos %d, want 10", c.Pos())
+ }
+
+ c.LineMove(1)
+
+ if c.Pos() != 17 { // line 2, column 3 ('p')
+ t.Fatalf("down two: pos %d, want 17", c.Pos())
+ }
+
+ c.LineMove(-2)
+
+ if c.Pos() != 3 { // back to start
+ t.Fatalf("round trip up: pos %d, want 3", c.Pos())
+ }
+}
+
func TestCursor_OnEmptyLine(t *testing.T) {
type fields struct {
pos int
diff --git a/readline/internal/core/iterations.go b/readline/internal/core/iterations.go
index 5255939..3d6b496 100644
--- a/readline/internal/core/iterations.go
+++ b/readline/internal/core/iterations.go
@@ -48,11 +48,12 @@ func (i *Iterations) Get() int {
times, err := strconv.Atoi(i.times)
// Any invalid value is still one time.
- if err != nil && strings.HasPrefix(i.times, "-") {
+ switch {
+ case err != nil && strings.HasPrefix(i.times, "-"):
times = -1
- } else if err != nil && times == 0 {
+ case err != nil && times == 0:
times = 1
- } else if times == 0 && strings.HasPrefix(i.times, "-") {
+ case times == 0 && strings.HasPrefix(i.times, "-"):
times = -1
}
diff --git a/readline/internal/core/keys.go b/readline/internal/core/keys.go
index adacce5..b2437a8 100644
--- a/readline/internal/core/keys.go
+++ b/readline/internal/core/keys.go
@@ -8,9 +8,10 @@ import (
"regexp"
"sync"
+ "github.com/rivo/uniseg"
+
"github.com/chainreactors/tui/readline/inputrc"
"github.com/chainreactors/tui/readline/internal/strutil"
- "github.com/rivo/uniseg"
)
const (
@@ -24,6 +25,10 @@ var Stdin io.ReadCloser = os.Stdin
var rxRcvCursorPos = regexp.MustCompile(`\x1b\[([0-9]+);([0-9]+)R`)
+// errInputWake is returned by the input read when it was interrupted by an
+// async refresh request (RequestRefresh) rather than by actual key input.
+var errInputWake = errors.New("readline: input wake")
+
// Keys is used to read, manage and use keys input by the shell user.
type Keys struct {
input io.Reader
@@ -35,17 +40,24 @@ type Keys struct {
reading bool // Currently reading keys out of the main loop.
keysOnce chan []byte // Passing keys from the main routine.
cursor chan []byte // Cursor coordinates has been read on stdin.
- resize chan bool // Resize events on Windows are sent on stdin. USED IN WINDOWS
+ resize chan bool //nolint:unused // Resize events on Windows are sent on stdin; consumed only by the windows build.
+
+ wakeMu sync.Mutex // Guards the wake fields against RequestRefresh (other goroutine).
+ wakeR int // Read end of the async-refresh wake pipe.
+ wakeW int // Write end of the async-refresh wake pipe.
+ wakeReady bool // Whether the wake pipe is initialized and usable.
- eof bool // EOF has been reached.
- cfg *inputrc.Config // Configuration file used for meta key settings
- mutex sync.RWMutex // Concurrency safety
+ eof bool // EOF has been reached.
+ readErr error // First non-EOF, non-wake input failure (e.g. a revoked tty).
+ cfg *inputrc.Config // Configuration file used for meta key settings
+ mutex sync.RWMutex // Concurrency safety
}
// SetInput sets the reader used for keyboard input.
func (k *Keys) SetInput(input io.Reader) {
k.mutex.Lock()
defer k.mutex.Unlock()
+
k.input = input
}
@@ -53,9 +65,11 @@ func (k *Keys) inputReader() io.Reader {
k.mutex.RLock()
input := k.input
k.mutex.RUnlock()
+
if input != nil {
return input
}
+
return Stdin
}
@@ -89,8 +103,26 @@ func WaitAvailableKeys(keys *Keys, cfg *inputrc.Config) {
// We will either read keyBuf from user, or an EOF
// send by ourselves, because we pause reading.
keyBuf, err := keys.readInputFiltered()
- if err != nil && errors.Is(err, io.EOF) {
- keys.eof = true
+ if err != nil {
+ // Wake: an async refresh was requested (RequestRefresh) â return
+ // with no keys so the main loop repaints the (possibly updated) UI
+ // and waits again. EOF: input stream closed. Any other error (e.g.
+ // a revoked tty) is a real read failure: record it so the main loop
+ // can surface it and exit, instead of spinning on it forever.
+ switch {
+ case errors.Is(err, errInputWake):
+ case errors.Is(err, io.EOF):
+ keys.mutex.Lock()
+ keys.eof = true
+ keys.mutex.Unlock()
+ default:
+ keys.mutex.Lock()
+ if keys.readErr == nil {
+ keys.readErr = err
+ }
+ keys.mutex.Unlock()
+ }
+
return
}
@@ -110,22 +142,51 @@ func WaitAvailableKeys(keys *Keys, cfg *inputrc.Config) {
keyBuf = []byte(strutil.ConvertMeta([]rune(string(keyBuf))))
}
- keys.mutex.Lock()
+ keys.mutex.RLock()
keys.buf = append(keys.buf, keyBuf...)
- keys.mutex.Unlock()
+ keys.mutex.RUnlock()
}
return
}
}
+// Empty reports whether there are no keys available to dispatch: neither
+// buffered input keys nor macro-fed keys. The main loop uses this to detect a
+// bare async-refresh wake (which returns from WaitAvailableKeys with no keys).
+func (k *Keys) Empty() bool {
+ k.mutex.RLock()
+ defer k.mutex.RUnlock()
+
+ return len(k.buf) == 0 && len(k.macroKeys) == 0
+}
+
+// IsEOF returns true if the input stream has reached the end.
+func (k *Keys) IsEOF() bool {
+ k.mutex.RLock()
+ defer k.mutex.RUnlock()
+
+ return k.eof
+}
+
+// ReadError returns the first non-EOF input failure observed while reading keys
+// (for instance a revoked tty), or nil. Async-refresh wakes are not errors and
+// are never reported here. The main loop uses this to exit cleanly instead of
+// spinning on an unrecoverable input stream.
+func (k *Keys) ReadError() error {
+ k.mutex.RLock()
+ defer k.mutex.RUnlock()
+
+ return k.readErr
+}
+
// PeekKey returns the first key in the stack, without removing it.
func PeekKey(keys *Keys) (key byte, empty bool) {
switch {
case len(keys.buf) > 0:
key = keys.buf[0]
case len(keys.macroKeys) > 0:
- key = byte(keys.macroKeys[0])
+ key = byte(keys.macroKeys[0]) //nolint:gosec // G115: byte-keyed API; macro keys are control bytes, truncation is intentional.
default:
return byte(0), true
}
@@ -141,7 +202,7 @@ func PopKey(keys *Keys) (key byte, empty bool) {
key = keys.buf[0]
keys.buf = keys.buf[1:]
case len(keys.macroKeys) > 0:
- key = byte(keys.macroKeys[0])
+ key = byte(keys.macroKeys[0]) //nolint:gosec // G115: byte-keyed API; macro keys are control bytes, truncation is intentional.
keys.macroKeys = keys.macroKeys[1:]
default:
return byte(0), true
@@ -229,7 +290,7 @@ func PopForce(keys *Keys) (key byte, empty bool) {
key = keys.buf[0]
keys.buf = keys.buf[1:]
case len(keys.macroKeys) > 0:
- key = byte(keys.macroKeys[0])
+ key = byte(keys.macroKeys[0]) //nolint:gosec // G115: byte-keyed API; macro keys are control bytes, truncation is intentional.
keys.macroKeys = keys.macroKeys[1:]
default:
return byte(0), true
@@ -259,9 +320,14 @@ func FlushUsed(keys *Keys) {
defer keys.mutex.Unlock()
}
+// Read drains the currently buffered pending keys.
func (k *Keys) Read() []byte {
+ k.mutex.Lock()
+ defer k.mutex.Unlock()
+
buf := k.buf
- k.buf = []byte{}
+ k.buf = nil
+
return buf
}
@@ -269,15 +335,15 @@ func (k *Keys) Read() []byte {
// returns them instead of storing them in the stack, along with
// an indication on whether this key is an escape/abort one.
func (k *Keys) ReadKey() (key rune, isAbort bool) {
- k.mutex.Lock()
+ k.mutex.RLock()
k.keysOnce = make(chan []byte)
k.reading = true
- k.mutex.Unlock()
+ k.mutex.RUnlock()
defer func() {
- k.mutex.Lock()
+ k.mutex.RLock()
k.reading = false
- k.mutex.Unlock()
+ k.mutex.RUnlock()
}()
switch {
@@ -287,9 +353,28 @@ func (k *Keys) ReadKey() (key rune, isAbort bool) {
case k.waiting:
buf := <-k.keysOnce
+ if len(buf) == 0 {
+ return rune(0), true
+ }
+
key = []rune(string(buf))[0]
default:
- buf, _ := k.readInputFiltered()
+ // Read until we get an actual key: ignore async-refresh wakes
+ // (errInputWake) and empty reads, which carry no key to return.
+ var buf []byte
+ for len(buf) == 0 {
+ b, err := k.readInputFiltered()
+ if err != nil && !errors.Is(err, errInputWake) {
+ break
+ }
+
+ buf = b
+ }
+
+ if len(buf) == 0 {
+ return rune(0), true
+ }
+
key = []rune(string(buf))[0]
}
@@ -315,7 +400,7 @@ func (k *Keys) Pop() (key byte, empty bool) {
key = k.buf[0]
k.buf = k.buf[1:]
case len(k.macroKeys) > 0:
- key = byte(k.macroKeys[0])
+ key = byte(k.macroKeys[0]) //nolint:gosec // G115: byte-keyed API; macro keys are control bytes, truncation is intentional.
k.macroKeys = k.macroKeys[1:]
default:
return byte(0), true
@@ -351,16 +436,19 @@ func (k *Keys) Feed(begin bool, keys ...rune) {
}
}
-// IsReading returns true if the keys system is currently reading input
+// IsReading returns true if the keys system is currently reading input.
func (k *Keys) IsReading() bool {
k.mutex.RLock()
defer k.mutex.RUnlock()
+
return k.reading
}
+// IsWaiting returns true if the keys system is currently waiting for input.
func (k *Keys) IsWaiting() bool {
k.mutex.RLock()
defer k.mutex.RUnlock()
+
return k.waiting
}
@@ -368,59 +456,63 @@ func (k *Keys) IsWaiting() bool {
func (k *Keys) HasPendingInput() bool {
k.mutex.RLock()
defer k.mutex.RUnlock()
- return len(k.buf) > 0
-}
-// IsEOF returns true if the input stream has reached the end.
-func (k *Keys) IsEOF() bool {
- k.mutex.RLock()
- defer k.mutex.RUnlock()
- return k.eof
+ return len(k.buf) > 0
}
-// ReadUntilSequence reads bytes from the key buffer and stdin until the
-// end sequence is found. Returns all content before the end sequence.
-// The end sequence itself is consumed and discarded.
-// Used by bracketed paste to read until \e[201~.
-const maxPasteSize = 1 << 20 // 1MB safety limit
-
+// ReadUntilSequence reads bytes from the key buffer and input reader until the
+// end sequence is found. The end sequence itself is consumed and discarded.
func (k *Keys) ReadUntilSequence(end []byte) []byte {
+ if len(end) == 0 {
+ return nil
+ }
+
var result []byte
for {
- // Check if the end sequence is in the current buffer
+ k.mutex.Lock()
+ if len(k.macroKeys) > 0 {
+ k.buf = append(k.buf, []byte(string(k.macroKeys))...)
+ k.macroKeys = nil
+ }
if idx := bytes.Index(k.buf, end); idx >= 0 {
result = append(result, k.buf[:idx]...)
k.buf = k.buf[idx+len(end):]
+ k.mutex.Unlock()
return result
}
- // Safety limit to prevent infinite reads
if len(result)+len(k.buf) > maxPasteSize {
result = append(result, k.buf...)
k.buf = nil
+ k.mutex.Unlock()
return result
}
- // Keep buffer tail that might contain partial end sequence match
safe := len(k.buf) - len(end) + 1
if safe > 0 {
result = append(result, k.buf[:safe]...)
k.buf = k.buf[safe:]
}
+ k.mutex.Unlock()
- // Read more from stdin
newKeys, err := k.readInputFiltered()
if err != nil {
+ k.mutex.Lock()
result = append(result, k.buf...)
k.buf = nil
+ k.mutex.Unlock()
return result
}
+ k.mutex.Lock()
k.buf = append(k.buf, newKeys...)
+ k.mutex.Unlock()
}
}
+const maxPasteSize = 1 << 20
+
func (k *Keys) extractCursorPos(keys []byte) (cursor, remain []byte) {
if !rxRcvCursorPos.Match(keys) {
return cursor, keys
diff --git a/readline/internal/core/keys_test.go b/readline/internal/core/keys_test.go
new file mode 100644
index 0000000..83cb9af
--- /dev/null
+++ b/readline/internal/core/keys_test.go
@@ -0,0 +1,124 @@
+package core
+
+import (
+ "errors"
+ "io"
+ "testing"
+
+ "github.com/chainreactors/tui/readline/inputrc"
+)
+
+// errReadFailed is a static stand-in for a non-EOF input failure in tests.
+var errReadFailed = errors.New("read failed")
+
+// readStep is one scripted result of a stub stdin Read.
+type readStep struct {
+ data []byte
+ err error
+}
+
+// stubReadCloser plays back a fixed sequence of Read results, then EOF. It is
+// not an *os.File, so the Unix key reader's poll path is skipped and reads fall
+// straight through to this stub.
+type stubReadCloser struct {
+ steps []readStep
+ index int
+}
+
+func (s *stubReadCloser) Read(p []byte) (int, error) {
+ if s.index >= len(s.steps) {
+ return 0, io.EOF
+ }
+
+ step := s.steps[s.index]
+ s.index++
+
+ n := copy(p, step.data)
+
+ return n, step.err
+}
+
+func (s *stubReadCloser) Close() error { return nil }
+
+// withStubStdin swaps the package stdin for the duration of a test.
+func withStubStdin(t *testing.T, steps ...readStep) {
+ t.Helper()
+
+ original := Stdin
+ Stdin = &stubReadCloser{steps: steps}
+
+ t.Cleanup(func() { Stdin = original })
+}
+
+// TestWaitAvailableKeysMarksEOF verifies a closed stream is recorded as EOF and
+// is not mistaken for a read error.
+func TestWaitAvailableKeysMarksEOF(t *testing.T) {
+ withStubStdin(t, readStep{err: io.EOF})
+
+ keys := &Keys{}
+ WaitAvailableKeys(keys, &inputrc.Config{})
+
+ if !keys.IsEOF() {
+ t.Fatal("expected EOF to be recorded")
+ }
+
+ if err := keys.ReadError(); err != nil {
+ t.Fatalf("expected no read error on EOF, got %v", err)
+ }
+}
+
+// TestWaitAvailableKeysRecordsNonEOFReadError verifies a non-EOF failure (e.g. a
+// revoked tty) is surfaced via ReadError instead of being swallowed, which is
+// what previously left the input loop spinning.
+func TestWaitAvailableKeysRecordsNonEOFReadError(t *testing.T) {
+ want := errReadFailed
+
+ withStubStdin(t, readStep{err: want})
+
+ keys := &Keys{}
+ WaitAvailableKeys(keys, &inputrc.Config{})
+
+ if keys.IsEOF() {
+ t.Fatal("non-EOF failure must not be marked as EOF")
+ }
+
+ if err := keys.ReadError(); !errors.Is(err, want) {
+ t.Fatalf("expected read error %v, got %v", want, err)
+ }
+}
+
+// TestReadKeyReturnsAbortOnEOF verifies ReadKey aborts cleanly on EOF rather
+// than indexing into an empty buffer and panicking (Vim f/F/t/T motions).
+func TestReadKeyReturnsAbortOnEOF(t *testing.T) {
+ withStubStdin(t, readStep{err: io.EOF})
+
+ keys := &Keys{}
+
+ key, isAbort := keys.ReadKey()
+
+ if !isAbort {
+ t.Fatal("expected ReadKey to abort on EOF")
+ }
+
+ if key != 0 {
+ t.Fatalf("expected zero key on EOF, got %q", key)
+ }
+}
+
+// TestReadKeySkipsEmptyReads verifies ReadKey keeps waiting past an empty read
+// and returns the next real key.
+func TestReadKeySkipsEmptyReads(t *testing.T) {
+ withStubStdin(t, readStep{}, readStep{data: []byte("x")})
+
+ keys := &Keys{}
+
+ key, isAbort := keys.ReadKey()
+
+ if isAbort {
+ t.Fatal("expected ReadKey to continue after an empty read")
+ }
+
+ if key != 'x' {
+ t.Fatalf("expected key %q, got %q", 'x', key)
+ }
+}
diff --git a/readline/internal/core/keys_unix.go b/readline/internal/core/keys_unix.go
index 2673fe8..3802e26 100644
--- a/readline/internal/core/keys_unix.go
+++ b/readline/internal/core/keys_unix.go
@@ -3,45 +3,45 @@
package core
import (
- "bytes"
"errors"
"io"
"os"
- "os/signal"
"strconv"
- "syscall"
- "time"
- "github.com/chainreactors/tui/readline/internal/term"
"golang.org/x/sys/unix"
-)
-
-const cursorPosTimeout = 200 * time.Millisecond
-var errCursorPosTimeout = errors.New("cursor position read timeout")
+ "github.com/chainreactors/tui/readline/internal/term"
+)
// GetCursorPos returns the current cursor position in the terminal.
// It is safe to call this function even if the shell is reading input.
func (k *Keys) GetCursorPos() (x, y int) {
- disable := func() (int, int) { return -1, -1 }
+ reader := k.inputReader()
+ if control := term.CurrentControl(); control != nil {
+ if !control.IsTerminal() {
+ return -1, -1
+ }
+ } else if fd, ok := readerFd(reader); !ok || !term.IsTerminal(fd) {
+ return -1, -1
+ }
+
+ disable := func() (int, int) {
+ os.Stderr.WriteString("\r\ngetCursorPos() not supported by terminal emulator, disabling....\r\n")
+ return -1, -1
+ }
var cursor []byte
- var pending []byte
+ var match [][]string
- deadline := time.Now().Add(cursorPosTimeout)
+ // Flush any buffered frame output first: the cursor position we are about
+ // to query is only correct once the prompt printed so far is actually on
+ // screen, not still sitting in the output buffer.
+ term.FlushBuffer()
-drain:
- for {
- select {
- case <-k.cursor:
- default:
- break drain
- }
- }
+ // Echo the query and wait for the main key reading routine to send us the
+ // response back.
+ term.WriteString("\x1b[6n")
- // Echo the query and wait for the main key
- // reading routine to send us the response back.
- term.Print("\x1b[6n")
// In order not to get stuck with an input that might be user-one
// (like when the user typed before the shell is fully started, and yet not having
// queried cursor yet), we keep reading from stdin until we find the cursor response.
@@ -49,108 +49,65 @@ drain:
for {
switch {
case k.waiting, k.reading:
- remaining := time.Until(deadline)
- if remaining <= 0 {
- return -1, -1
- }
-
- select {
- case cursor = <-k.cursor:
- case <-time.After(remaining):
- return -1, -1
- }
-
- indices := rxRcvCursorPos.FindSubmatchIndex(cursor)
- if indices == nil {
- k.mutex.Lock()
- k.buf = append(k.buf, cursor...)
- k.mutex.Unlock()
- continue
- }
+ cursor = <-k.cursor
+ default:
+ buf := make([]byte, keyScanBufSize)
- y, err := strconv.Atoi(string(cursor[indices[2]:indices[3]]))
+ read, err := reader.Read(buf)
if err != nil {
return disable()
}
- x, err = strconv.Atoi(string(cursor[indices[4]:indices[5]]))
- if err != nil {
- return disable()
- }
+ cursor = buf[:read]
+ }
- return x, y
+ // We have read (or have been passed) something.
+ if len(cursor) == 0 {
+ return disable()
+ }
- default:
- remaining := time.Until(deadline)
- if remaining <= 0 {
- return -1, -1
- }
+ // Attempt to locate cursor response in it.
+ match = rxRcvCursorPos.FindAllStringSubmatch(string(cursor), 1)
- buf := make([]byte, keyScanBufSize)
+ // If there is something but not cursor answer, its user input.
+ if len(match) == 0 && len(cursor) > 0 {
+ k.mutex.RLock()
+ k.buf = append(k.buf, cursor...)
+ k.mutex.RUnlock()
- read, err := readInputWithTimeout(k.inputReader(), buf, remaining)
- if err != nil {
- if errors.Is(err, errCursorPosTimeout) {
- return -1, -1
- }
- return disable()
- }
-
- pending = append(pending, buf[:read]...)
-
- indices := rxRcvCursorPos.FindSubmatchIndex(pending)
- if indices != nil {
- prefix := pending[:indices[0]]
- suffix := pending[indices[1]:]
- if len(prefix) > 0 || len(suffix) > 0 {
- k.mutex.Lock()
- k.buf = append(k.buf, prefix...)
- k.buf = append(k.buf, suffix...)
- k.mutex.Unlock()
- }
-
- y, err := strconv.Atoi(string(pending[indices[2]:indices[3]]))
- if err != nil {
- return disable()
- }
-
- x, err = strconv.Atoi(string(pending[indices[4]:indices[5]]))
- if err != nil {
- return disable()
- }
-
- return x, y
- }
+ continue
+ }
- // No cursor response yet: flush anything that cannot be part of it.
- start := bytes.LastIndex(pending, []byte{0x1b, '['})
- switch {
- case start == -1:
- if len(pending) > 0 {
- k.mutex.Lock()
- k.buf = append(k.buf, pending...)
- k.mutex.Unlock()
- pending = nil
- }
- case start > 0:
- k.mutex.Lock()
- k.buf = append(k.buf, pending[:start]...)
- k.mutex.Unlock()
- pending = pending[start:]
- }
+ // And if empty, then we should abort.
+ if len(match) == 0 {
+ return disable()
}
+
+ break
}
+
+ // We know that we have a cursor answer, process it.
+ y, err := strconv.Atoi(match[0][1])
+ if err != nil {
+ return disable()
+ }
+
+ x, err = strconv.Atoi(match[0][2])
+ if err != nil {
+ return disable()
+ }
+
+ return x, y
}
func (k *Keys) readInputFiltered() (keys []byte, err error) {
- // Start reading from os.Stdin in the background.
- // We will either read keys from user, or an EOF
- // send by ourselves, because we pause reading.
+ // Wait for input to be readable, or for an async refresh request, then
+ // read one chunk. A refresh request returns errInputWake with no keys.
buf := make([]byte, keyScanBufSize)
- read, err := k.inputReader().Read(buf)
- if err != nil && errors.Is(err, io.EOF) {
- return
+ read, err := k.readStdin(buf)
+ if err != nil {
+ return nil, err
}
// Always attempt to extract cursor position info.
@@ -158,93 +115,126 @@ func (k *Keys) readInputFiltered() (keys []byte, err error) {
cursor, keys := k.extractCursorPos(buf[:read])
if len(cursor) > 0 {
- select {
- case k.cursor <- cursor:
- default:
- }
+ k.cursor <- cursor
}
return keys, nil
}
-func readInputWithTimeout(reader io.Reader, buf []byte, timeout time.Duration) (int, error) {
- file, ok := reader.(*os.File)
- if !ok {
- type readResult struct {
- n int
- err error
- }
- ch := make(chan readResult, 1)
- go func() {
- n, err := reader.Read(buf)
- ch <- readResult{n: n, err: err}
- }()
- select {
- case result := <-ch:
- return result.n, result.err
- case <-time.After(timeout):
- return 0, errCursorPosTimeout
- }
+// readStdin reads one chunk of input, but first waits (via poll) for stdin to
+// become readable OR for an async refresh to be requested with RequestRefresh.
+// On a refresh request it returns errInputWake with no data. If wake support is
+// unavailable (no pipe, or Stdin exposes no pollable fd) it falls back to a
+// plain blocking read, so input always works.
+func (k *Keys) readStdin(buf []byte) (int, error) {
+ k.wakeMu.Lock()
+ ready := k.wakeReady
+ wakeR := k.wakeR
+ k.wakeMu.Unlock()
+
+ reader := k.inputReader()
+ fd, ok := readerFd(reader)
+ if !ok || !ready {
+ return reader.Read(buf)
}
- fds := []unix.PollFd{{
- Fd: int32(file.Fd()),
- Events: unix.POLLIN,
- }}
-
- deadline := time.Now().Add(timeout)
-
for {
- remaining := time.Until(deadline)
- if remaining <= 0 {
- return 0, errCursorPosTimeout
- }
-
- timeoutMs := int(remaining / time.Millisecond)
- if timeoutMs <= 0 && remaining > 0 {
- timeoutMs = 1
+ fds := []unix.PollFd{
+ {Fd: int32(fd), Events: unix.POLLIN}, //nolint:gosec // G115: OS file descriptors are small non-negative ints.
+ {Fd: int32(wakeR), Events: unix.POLLIN}, //nolint:gosec // G115: OS file descriptors are small non-negative ints.
}
- n, err := unix.Poll(fds, timeoutMs)
- if err != nil {
+ if _, err := unix.Poll(fds, -1); err != nil {
if errors.Is(err, unix.EINTR) {
continue
}
- return 0, err
+
+ // Polling failed (e.g. an fd type without poll support):
+ // fall back to a plain blocking read.
+ return reader.Read(buf)
}
- if n == 0 {
- return 0, errCursorPosTimeout
+
+ // Real input takes priority, so a coincident wake never delays a
+ // keystroke; the wake byte stays pending and is serviced once idle.
+ if fds[0].Revents&(unix.POLLIN|unix.POLLHUP|unix.POLLERR) != 0 {
+ return reader.Read(buf)
}
- revents := fds[0].Revents
- if revents&(unix.POLLIN|unix.POLLHUP|unix.POLLERR) == 0 {
- return 0, errCursorPosTimeout
+ if fds[1].Revents&unix.POLLIN != 0 {
+ k.drainWake()
+ return 0, errInputWake
}
+ }
+}
- return file.Read(buf)
+// InitWake creates the pipe used to interrupt a blocking input read when an
+// async refresh is requested. Safe to call repeatedly; on failure it leaves
+// wake support disabled (async repaints then coalesce into the next keystroke).
+func (k *Keys) InitWake() {
+ k.CloseWake()
+
+ var p [2]int
+ if err := unix.Pipe(p[:]); err != nil {
+ return
}
+
+ _ = unix.SetNonblock(p[0], true)
+ _ = unix.SetNonblock(p[1], true)
+
+ k.wakeMu.Lock()
+ k.wakeR, k.wakeW = p[0], p[1]
+ k.wakeReady = true
+ k.wakeMu.Unlock()
}
-// GetTerminalResize for Unix systems using SIGWINCH signal
-func GetTerminalResize(keys *Keys) <-chan bool {
- resizeChan := make(chan bool, 1)
+// CloseWake tears down the wake pipe.
+func (k *Keys) CloseWake() {
+ k.wakeMu.Lock()
+ defer k.wakeMu.Unlock()
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, syscall.SIGWINCH)
+ if !k.wakeReady {
+ return
+ }
- go func() {
- for {
- <-sigChan
- isWaiting := keys.waiting
+ _ = unix.Close(k.wakeR)
+ _ = unix.Close(k.wakeW)
+ k.wakeReady = false
+}
- if !isWaiting {
- select {
- case resizeChan <- true:
- default:
- }
- }
+// RequestRefresh wakes an idle Readline loop so it repaints its UI. It is safe
+// to call from any goroutine and is the primitive behind async UI updates (for
+// instance ui.Hint.SetTransient). It is a no-op when wake support is
+// unavailable, in which case the update is shown at the next keystroke.
+func (k *Keys) RequestRefresh() {
+ k.wakeMu.Lock()
+ defer k.wakeMu.Unlock()
+
+ if !k.wakeReady {
+ return
+ }
+
+ // Non-blocking single-byte write. The pipe is drained on wake, and a full
+ // pipe already means a wake is pending, so dropping the write is harmless.
+ _, _ = unix.Write(k.wakeW, []byte{0})
+}
+
+// drainWake empties the wake pipe so a single request does not re-trigger.
+func (k *Keys) drainWake() {
+ var scratch [16]byte
+
+ for {
+ n, err := unix.Read(k.wakeR, scratch[:])
+ if n <= 0 || err != nil {
+ return
}
- }()
+ }
+}
+
+// readerFd returns the file descriptor backing reader, if it exposes one.
+func readerFd(reader io.Reader) (int, bool) {
+ if f, ok := reader.(interface{ Fd() uintptr }); ok {
+ return int(f.Fd()), true
+ }
- return resizeChan
+ return 0, false
}
diff --git a/readline/internal/core/keys_windows.go b/readline/internal/core/keys_windows.go
index 0514aae..bed17cc 100644
--- a/readline/internal/core/keys_windows.go
+++ b/readline/internal/core/keys_windows.go
@@ -4,14 +4,9 @@
package core
import (
- "errors"
- "io"
- "os"
- "time"
"unsafe"
"github.com/chainreactors/tui/readline/inputrc"
- "github.com/chainreactors/tui/readline/internal/term"
)
// Windows-specific special key codes.
@@ -53,19 +48,19 @@ const (
charBackspace = 127
)
-// dwControlKeyState flags from Windows Console API.
-const (
- _RIGHT_ALT_PRESSED = 0x0001
- _LEFT_ALT_PRESSED = 0x0002
- _RIGHT_CTRL_PRESSED = 0x0004
- _LEFT_CTRL_PRESSED = 0x0008
- _SHIFT_PRESSED = 0x0010
-)
-
func init() {
Stdin = newRawReader()
}
+// GetTerminalResize sends booleans over a channel to notify resize events on Windows.
+// This functions uses the keys reader because on Windows, resize events are sent through
+// stdin, not with syscalls like unix's syscall.SIGWINCH.
+func GetTerminalResize(keys *Keys) <-chan bool {
+ keys.resize = make(chan bool, 1)
+
+ return keys.resize
+}
+
// readInputFiltered on Windows needs to check for terminal resize events.
func (k *Keys) readInputFiltered() (keys []byte, err error) {
for {
@@ -75,7 +70,11 @@ func (k *Keys) readInputFiltered() (keys []byte, err error) {
buf := make([]byte, keyScanBufSize)
read, err := k.inputReader().Read(buf)
- if err != nil && errors.Is(err, io.EOF) {
+ if err != nil {
+ // EOF (stream closed) or any other read failure (e.g. a revoked
+ // console handle): propagate it so WaitAvailableKeys records EOF or
+ // surfaces the error, instead of swallowing it and spinning on a
+ // dead stdin. Mirrors the Unix reader's behaviour.
return keys, err
}
@@ -100,28 +99,29 @@ func (k *Keys) readInputFiltered() (keys []byte, err error) {
}
}
-// rawReader translates Windows input to ANSI sequences,
-// to provide the same behavior as Unix terminals.
-type rawReader struct{}
+// InitWake is a no-op on Windows: the async-refresh wake (poll-based on Unix)
+// is not yet supported here, so async UI updates appear at the next keystroke.
+func (k *Keys) InitWake() {}
-// newRawReader returns a new rawReader for Windows.
-func newRawReader() *rawReader {
- return new(rawReader)
-}
+// CloseWake is a no-op on Windows.
+func (k *Keys) CloseWake() {}
-// isCtrl returns true if Ctrl is pressed in this event's dwControlKeyState.
-func isCtrl(state dword) bool {
- return state&(_LEFT_CTRL_PRESSED|_RIGHT_CTRL_PRESSED) != 0
-}
+// RequestRefresh is a no-op on Windows (async wake unsupported); async UI
+// updates appear at the next keystroke.
+func (k *Keys) RequestRefresh() {}
-// isAlt returns true if Alt is pressed in this event's dwControlKeyState.
-func isAlt(state dword) bool {
- return state&(_LEFT_ALT_PRESSED|_RIGHT_ALT_PRESSED) != 0
+// rawReader translates Windows input to ANSI sequences,
+// to provide the same behavior as Unix terminals.
+type rawReader struct {
+ ctrlKey bool
+ altKey bool
+ shiftKey bool
}
-// isShift returns true if Shift is pressed in this event's dwControlKeyState.
-func isShift(state dword) bool {
- return state&_SHIFT_PRESSED != 0
+// newRawReader returns a new rawReader for Windows.
+func newRawReader() *rawReader {
+ r := new(rawReader)
+ return r
}
// Read reads input record from stdin on Windows.
@@ -142,9 +142,18 @@ next:
return 0, err
}
- // Skip focus events.
+ // First deal with terminal focus events, which reset some stuff
if ir.EventType == EVENT_FOCUS {
- goto next
+ ker := (*_FOCUS_EVENT_RECORD)(unsafe.Pointer(&ir.Event[0]))
+
+ // If are with Tab Active and losing the focus,
+ // ignore this Alt key that is most likely the "Alt-Tab"
+ // system shortcut on Windows for Tab switching.
+ // QUESTION: Should we also do this to some other modifiers ?
+ if !ker.bSetFocus && r.altKey {
+ r.altKey = false
+ goto next
+ }
}
// Keep resize events for the display engine to use.
@@ -156,55 +165,49 @@ next:
goto next
}
+ // Reset modifiers if key is released.
ker := (*_KEY_EVENT_RECORD)(unsafe.Pointer(&ir.Event[0]))
-
- // Skip key-up events.
- if ker.bKeyDown == 0 {
- goto next
- }
-
- // Skip standalone modifier key presses.
- switch ker.wVirtualKeyCode {
- case VK_CONTROL, VK_LCONTROL, VK_RCONTROL,
- VK_MENU,
- VK_SHIFT, VK_LSHIFT, VK_RSHIFT:
+ if ker.bKeyDown == 0 { // keyup
+ if r.ctrlKey || r.altKey || r.shiftKey {
+ switch ker.wVirtualKeyCode {
+ case VK_RCONTROL, VK_LCONTROL, VK_CONTROL:
+ r.ctrlKey = false
+ case VK_MENU: // alt
+ r.altKey = false
+ case VK_SHIFT, VK_LSHIFT, VK_RSHIFT:
+ r.shiftKey = false
+ }
+ }
goto next
}
- // Use per-event dwControlKeyState for reliable modifier detection.
- // This avoids stale modifier state when the terminal synthesizes
- // key events (e.g., bracketed paste after Ctrl+V interception).
- ctrlKey := isCtrl(ker.dwControlKeyState)
- altKey := isAlt(ker.dwControlKeyState)
- shiftKey := isShift(ker.dwControlKeyState)
-
- // Keypad, special and arrow keys (unicodeChar == 0).
+ // Keypad, special and arrow keys.
if ker.unicodeChar == 0 {
- if modifiers, target := r.translateSeq(ker, ctrlKey, altKey, shiftKey); target != 0 {
+ if modifiers, target := r.translateSeq(ker); target != 0 {
return r.writeEsc(buf, append(modifiers, target)...)
}
goto next
}
- {
- char := rune(ker.unicodeChar)
-
- // Encode keys with modifiers.
- switch {
- case shiftKey && char == charTab:
- return r.writeEsc(buf, 91, 90)
- case ctrlKey && char == charBackspace:
- char = charCtrlH
- case !ctrlKey && char == charCtrlH:
- char = charBackspace
- case ctrlKey:
- char = inputrc.Encontrol(char)
- case altKey:
- char = inputrc.Enmeta(char)
- }
+ char := rune(ker.unicodeChar)
- return r.write(buf, char)
+ // Encode keys with modifiers.
+ // Deal with the last (Windows) exceptions to the rule.
+ switch {
+ case r.shiftKey && char == charTab:
+ return r.writeEsc(buf, 91, 90)
+ case r.ctrlKey && char == charBackspace:
+ char = charCtrlH
+ case !r.ctrlKey && char == charCtrlH:
+ char = charBackspace
+ case r.ctrlKey:
+ char = inputrc.Encontrol(char)
+ case r.altKey:
+ char = inputrc.Enmeta(char)
}
+
+ // Else, the key is a normal character.
+ return r.write(buf, char)
}
// Close is a stub to satisfy io.Closer.
@@ -223,17 +226,29 @@ func (r *rawReader) write(b []byte, char ...rune) (int, error) {
return n, nil
}
-func (r *rawReader) translateSeq(ker *_KEY_EVENT_RECORD, ctrlKey, altKey, shiftKey bool) (modifiers []rune, target rune) {
- // Encode keys with modifiers by default.
+func (r *rawReader) translateSeq(ker *_KEY_EVENT_RECORD) (modifiers []rune, target rune) {
+ // Encode keys with modifiers by default,
+ // unless the modifier is pressed alone.
modifiers = append(modifiers, 91)
// Modifiers add a default sequence, which is the good sequence for arrow keys by default.
+ // The first rune is this sequence might be modified below, if the target is a special key
+ // but not an arrow key.
+ switch ker.wVirtualKeyCode {
+ case VK_RCONTROL, VK_LCONTROL, VK_CONTROL:
+ r.ctrlKey = true
+ case VK_MENU: // alt
+ r.altKey = true
+ case VK_SHIFT, VK_LSHIFT, VK_RSHIFT:
+ r.shiftKey = true
+ }
+
switch {
- case ctrlKey:
+ case r.ctrlKey:
modifiers = append(modifiers, 49, 59, 53)
- case altKey:
+ case r.altKey:
modifiers = append(modifiers, 49, 59, 51)
- case shiftKey:
+ case r.shiftKey:
modifiers = append(modifiers, 49, 59, 50)
}
@@ -279,27 +294,3 @@ func (r *rawReader) translateSeq(ker *_KEY_EVENT_RECORD, ctrlKey, altKey, shiftK
return
}
-
-// GetTerminalResize sends booleans over a channel to notify resize events on Windows.
-// This functions uses the keys reader because on Windows, resize events are sent through
-// stdin, not with syscalls like unix's syscall.SIGWINCH.
-func GetTerminalResize(keys *Keys) <-chan bool {
- keys.resize = make(chan bool, 1)
- prevWidth, prevHeight, _ := term.GetSize(int(os.Stdout.Fd()))
- go func() {
- for {
- width, height, err := term.GetSize(int(os.Stdout.Fd()))
- if err != nil {
- break
- }
-
- if width != prevWidth || height != prevHeight {
- prevWidth, prevHeight = width, height
- //fmt.Println("windows resize")
- keys.resize <- true
- }
- time.Sleep(500 * time.Millisecond)
- }
- }()
- return keys.resize
-}
diff --git a/readline/internal/core/line.go b/readline/internal/core/line.go
index ddf14e2..be9744a 100644
--- a/readline/internal/core/line.go
+++ b/readline/internal/core/line.go
@@ -37,6 +37,7 @@ func (l *Line) Insert(pos int, chars ...rune) {
for end > 0 && chars[end-1] == 0 {
end--
}
+
chars = chars[:end]
// Invalid position cancels the insertion
@@ -96,7 +97,6 @@ func (l *Line) CutRune(pos int) {
default:
*l = slices.Delete([]rune(*l), pos, pos+1)
}
-
}
// Len returns the length of the line.
@@ -284,32 +284,34 @@ func DisplayLine(l *Line, indent int) {
for _, r := range *l {
if r == '\n' {
builtLine.WriteString(color.BgDefault)
+
if lineLen < term.GetWidth() {
builtLine.WriteString(term.ClearLineAfter)
}
+
builtLine.WriteString(term.NewlineReturn)
- builtLine.WriteString(fmt.Sprintf("\x1b[%dC", indent)) // Equivalent of term.MoveCursorForwards
+ fmt.Fprintf(&builtLine, "\x1b[%dC", indent) // Equivalent of term.MoveCursorForwards
builtLine.WriteString(term.ClearLineBefore)
lineLen = 0
} else {
builtLine.WriteRune(r)
+
lineLen++
}
-
}
if l.Len() > 0 && (*l)[l.Len()-1] == '\n' {
builtLine.WriteString(color.BgDefault)
builtLine.WriteString(term.ClearLineAfter)
builtLine.WriteString(term.NewlineReturn)
- builtLine.WriteString(fmt.Sprintf("\x1b[%dC", indent)) // Equivalent of term.MoveCursorForwards
+ fmt.Fprintf(&builtLine, "\x1b[%dC", indent) // Equivalent of term.MoveCursorForwards
builtLine.WriteString(term.ClearLineBefore)
}
builtLine.WriteString(color.BgDefault)
- term.Print(builtLine.String())
+ term.WriteString(builtLine.String())
}
// CoordinatesLine returns the number of real terminal lines on which the input line spans, considering
@@ -346,6 +348,7 @@ func CoordinatesLine(l *Line, indent int) (int, int) {
// the number of newlines - 1.
func (l *Line) Lines() int {
var count int
+
for _, r := range *l {
if r == inputrc.Newline {
count++
@@ -428,7 +431,9 @@ func (l *Line) Tokenize(cpos int) ([]string, int, int) {
var index, pos int
var punc bool
- split := make([]string, 1)
+ // Start with a single empty accumulator word; the loop appends to the
+ // last element via split[len(split)-1], so it must be non-empty.
+ split := []string{""}
for i, char := range line {
switch {
@@ -492,7 +497,9 @@ func (l *Line) TokenizeSpace(cpos int) ([]string, int, int) {
cpos = l.checkPosRange(cpos)
var index, pos int
- split := make([]string, 1)
+ // Start with a single empty accumulator word; the loop appends to the
+ // last element via split[len(split)-1], so it must be non-empty.
+ split := []string{""}
var newline bool
for i, char := range line {
diff --git a/readline/internal/core/line_test.go b/readline/internal/core/line_test.go
index b33208c..4dcc9dd 100644
--- a/readline/internal/core/line_test.go
+++ b/readline/internal/core/line_test.go
@@ -1,9 +1,13 @@
package core
import (
+ "bytes"
+ "io"
+ "os"
"reflect"
"testing"
+ "github.com/chainreactors/tui/readline/internal/color"
"github.com/chainreactors/tui/readline/internal/term"
)
@@ -57,7 +61,7 @@ func TestLine_Insert(t *testing.T) {
}
for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
+ t.Run(test.name, func(_ *testing.T) {
test.l.Insert(test.args.pos, test.args.r...)
})
@@ -115,7 +119,7 @@ func TestLine_InsertBetween(t *testing.T) {
}
for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
+ t.Run(test.name, func(_ *testing.T) {
test.l.InsertBetween(test.args.bpos, test.args.epos, test.args.r...)
})
@@ -165,7 +169,7 @@ func TestLine_Cut(t *testing.T) {
}
for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
+ t.Run(test.name, func(_ *testing.T) {
test.l.Cut(test.args.bpos, test.args.epos)
})
@@ -214,7 +218,7 @@ func TestLine_CutRune(t *testing.T) {
}
for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
+ t.Run(test.name, func(_ *testing.T) {
test.l.CutRune(test.args.pos)
})
@@ -1037,6 +1041,11 @@ func TestLine_TokenizeBlock(t *testing.T) {
}
func TestDisplayLine(t *testing.T) {
+ indent := 10
+ line := Line("basic -f \"commands.go,line.go\" -cp=/usr --option [value1 value2]")
+ multiline := Line("basic -f \"commands.go \nanother testing\" --alternate \"another\nquote\" -v { expression here } -a [value1 value2]")
+ longMultiline := Line("longer than 80 characters, which is the term width reported when running go test \nanother line ending on newline \n")
+
type args struct {
indent int
}
@@ -1044,15 +1053,68 @@ func TestDisplayLine(t *testing.T) {
name string
l *Line
args args
+ want string
}{
- // TODO: Add test cases.
+ {
+ name: "Empty line buffer",
+ l: new(Line),
+ args: args{indent: indent},
+ want: color.BgDefault,
+ },
+ {
+ name: "Single line buffer",
+ l: &line,
+ args: args{indent: indent},
+ want: string(line) + color.BgDefault,
+ },
+ {
+ name: "Multiline buffer",
+ l: &multiline,
+ args: args{indent: indent},
+ want: "basic -f \"commands.go " + color.BgDefault + term.ClearLineAfter + "\r\n" +
+ "\x1b[10C" + term.ClearLineBefore + "another testing\" --alternate \"another" + color.BgDefault + term.ClearLineAfter + "\r\n" +
+ "\x1b[10C" + term.ClearLineBefore + "quote\" -v { expression here } -a [value1 value2]" + color.BgDefault,
+ },
+ {
+ name: "Long multiline buffer",
+ l: &longMultiline,
+ args: args{indent: indent},
+ want: "longer than 80 characters, which is the term width reported when running go test " + color.BgDefault + "\r\n" +
+ "\x1b[10C" + term.ClearLineBefore + "another line ending on newline " + color.BgDefault + term.ClearLineAfter + "\r\n" +
+ "\x1b[10C" + term.ClearLineBefore + color.BgDefault + term.ClearLineAfter + "\r\n" +
+ "\x1b[10C" + term.ClearLineBefore + color.BgDefault,
+ },
}
+ savedStdout := os.Stdout
+
for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
+ r, w, err := os.Pipe()
+ if err != nil {
+ os.Stdout = savedStdout
+
+ t.Fatalf("pipe: %s", err)
+ }
+
+ os.Stdout = w
+
+ t.Run(tt.name, func(_ *testing.T) {
DisplayLine(tt.l, tt.args.indent)
})
+
+ w.Close()
+
+ var buf bytes.Buffer
+ io.Copy(&buf, r)
+
+ if buf.String() != tt.want {
+ t.Errorf("DisplayLine: got\n%q\nwant\n%q", buf.String(), tt.want)
+ }
+
+ r.Close()
}
+
+ os.Stdout = savedStdout
}
func TestCoordinatesLine(t *testing.T) {
@@ -1064,8 +1126,7 @@ func TestCoordinatesLine(t *testing.T) {
getTermWidth = func() int { return 80 }
type args struct {
- indent int
- suggested string
+ indent int
}
tests := []struct {
name string
@@ -1074,6 +1135,13 @@ func TestCoordinatesLine(t *testing.T) {
wantX int
wantY int
}{
+ {
+ name: "Empty line buffer",
+ l: new(Line),
+ args: args{indent: indent},
+ wantY: 0,
+ wantX: indent,
+ },
{
name: "Single line buffer",
l: &line,
diff --git a/readline/internal/core/selection.go b/readline/internal/core/selection.go
index 43eb01f..187fbed 100644
--- a/readline/internal/core/selection.go
+++ b/readline/internal/core/selection.go
@@ -304,9 +304,10 @@ func (s *Selection) Surround(bchar, echar rune) {
defer s.Reset()
- var buf []rune
+ text := []rune(s.Text())
+ buf := make([]rune, 0, 1+len(text)+1)
buf = append(buf, bchar)
- buf = append(buf, []rune(s.Text())...)
+ buf = append(buf, text...)
buf = append(buf, echar)
// The begin and end positions of the selection
@@ -340,7 +341,7 @@ func (s *Selection) SelectAWord() (bpos, epos int) {
// And only select spaces after it if the word selected is not preceded
// by spaces as well, or if we started the selection within this word.
- bpos, _ = s.adjustWordSelection(spaceBefore, spaceUnder, spaceAfter, bpos)
+ bpos = s.adjustWordSelection(spaceBefore, spaceUnder, spaceAfter, bpos)
if !s.Active() || bpos < cpos {
s.Mark(bpos)
@@ -375,7 +376,7 @@ func (s *Selection) SelectABlankWord() (bpos, epos int) {
// And only select spaces after it if the word selected is not preceded
// by spaces as well, or if we started the selection within this word.
- bpos, _ = s.adjustWordSelection(spaceBefore, spaceUnder, spaceAfter, bpos)
+ bpos = s.adjustWordSelection(spaceBefore, spaceUnder, spaceAfter, bpos)
if !s.Active() || bpos < s.cursor.Pos() {
s.Mark(bpos)
@@ -722,15 +723,13 @@ func isSpace(char rune) bool {
// adjustWordSelection adjust the beginning and end of a word (blank or not) selection, depending
// on whether it's surrounded by spaces, and if selection started from a whitespace or within word.
-func (s *Selection) adjustWordSelection(_, under, after bool, bpos int) (int, int) {
- var epos int
-
+func (s *Selection) adjustWordSelection(_, under, after bool, bpos int) int {
if after && !under {
s.cursor.Inc()
s.cursor.ToFirstNonSpace(true)
s.cursor.Dec()
} else if !after {
- epos = s.cursor.Pos()
+ epos := s.cursor.Pos()
s.cursor.Set(bpos - 1)
s.cursor.ToFirstNonSpace(false)
s.cursor.Inc()
@@ -738,9 +737,7 @@ func (s *Selection) adjustWordSelection(_, under, after bool, bpos int) (int, in
s.cursor.Set(epos)
}
- epos = s.cursor.Pos()
-
- return bpos, epos
+ return bpos
}
func (s *Selection) matchKeyword(buf []rune, bbpos int, next bool) (name string, found bool, bpos, epos int) {
@@ -797,10 +794,10 @@ func (s *Selection) matchKeyword(buf []rune, bbpos int, next bool) (name string,
if next {
done = func(i int) bool { return i <= len(matchersNames) }
- move = func(inc int) int { return kpos + 1 }
+ move = func(_ int) int { return kpos + 1 }
} else {
done = func(i int) bool { return i > 0 }
- move = func(inc int) int { return kpos - 1 }
+ move = func(_ int) int { return kpos - 1 }
}
// Try the different matchers until one succeeds, and select the first/last capturing group.
diff --git a/readline/internal/core/selection_test.go b/readline/internal/core/selection_test.go
index b484046..2877442 100644
--- a/readline/internal/core/selection_test.go
+++ b/readline/internal/core/selection_test.go
@@ -1259,7 +1259,7 @@ func TestSelection_SelectKeyword(t *testing.T) {
var gotKbpos, gotKepos int
var gotMatch bool
- for i := 0; i < test.args.cycles; i++ {
+ for range test.args.cycles {
gotKbpos, gotKepos, gotMatch = sel.SelectKeyword(test.args.bpos, test.args.epos, test.args.next)
}
diff --git a/readline/internal/display/completion_test.go b/readline/internal/display/completion_test.go
new file mode 100644
index 0000000..e1b46b1
--- /dev/null
+++ b/readline/internal/display/completion_test.go
@@ -0,0 +1,104 @@
+//go:build unix
+
+package display_test
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestAsyncRefreshCompletions proves #99 on the async-refresh rail: completions
+// produced asynchronously (a background goroutine grows the result set and
+// calls Shell.RefreshCompletions) rebuild an already-open menu in place, with
+// no keystroke from the user.
+func TestAsyncRefreshCompletions(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "P> ",
+ cols: 80,
+ rows: 24,
+ asyncComp: true,
+ asyncMS: 400,
+ })
+ c.waitForScreen("P>")
+
+ // Open the completion menu (possible-completions): it displays the initial
+ // candidates, and "alpaca" is not among them yet.
+ c.send("\x1b?")
+ screen := c.waitForScreen("alpha")
+
+ if strings.Contains(screen, "alpaca") {
+ t.Fatalf("alpaca should not be present before the async refresh:\n%s", screen)
+ }
+
+ // Send NO further input: the async producer grows the result set and calls
+ // RefreshCompletions, which must regenerate the open menu in place so the
+ // new candidate appears on its own.
+ c.waitForScreen("alpaca")
+}
+
+// TestAsyncRefreshCompletionsNoMenuIsNoop verifies RefreshCompletions is a clean
+// no-op when no menu is active: it must not spontaneously open one.
+func TestAsyncRefreshCompletionsNoMenuIsNoop(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "P> ",
+ cols: 80,
+ rows: 24,
+ asyncComp: true,
+ asyncMS: 300,
+ })
+ c.waitForScreen("P>")
+
+ // Do not open a menu. Wait past the async RefreshCompletions call; with no
+ // active menu it must do nothing, so no candidate ever appears.
+ time.Sleep(900 * time.Millisecond)
+
+ screen := c.screen()
+ if strings.Contains(screen, "alpha") || strings.Contains(screen, "alpaca") {
+ t.Fatalf("RefreshCompletions must no-op when no menu is active, but a menu appeared:\n%s", screen)
+ }
+}
+
+// TestAsyncRefreshKeepsSelection verifies #99 option B for an explicit menu:
+// when a candidate is selected, an async RefreshCompletions both (a) updates the
+// menu in place with newly produced candidates and (b) keeps the selection on
+// the same candidate (matched by content), rather than freezing the menu or
+// dropping the selection. The new candidate sorts first, so the grid re-sorts â
+// proving the restore is content-based, not positional.
+func TestAsyncRefreshKeepsSelection(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "P> ",
+ cols: 80,
+ rows: 24,
+ asyncComp: true,
+ asyncMS: 1500, // fire after we have selected a candidate
+ })
+ c.waitForScreen("P>")
+
+ // Show the menu (no selection yet) and wait for the initial candidates.
+ c.send("\x1b?")
+ c.waitForScreen("alpha")
+
+ // Select a candidate by navigating down to "alpine" (virtual insertion shows
+ // it in the input line).
+ c.send("\x1b[B") // select alpha
+ c.send("\x1b[B") // select alpine
+ screen := c.waitForScreen("P> alpine")
+
+ if strings.Contains(screen, "alpaca") {
+ t.Fatalf("alpaca should not be present before the async refresh:\n%s", screen)
+ }
+
+ // The async producer adds "alpaca" (which sorts first) and calls
+ // RefreshCompletions while "alpine" is selected: the menu must update AND
+ // the selection must persist.
+ c.waitForScreen("alpaca")
+
+ final := c.screen()
+ first := strings.TrimRight(strings.SplitN(final, "\n", 2)[0], " ")
+
+ if first != "P> alpine" {
+ t.Fatalf("selection not preserved across async refresh: first line = %q want %q\n%s",
+ first, "P> alpine", final)
+ }
+}
diff --git a/readline/internal/display/display_unix.go b/readline/internal/display/display_unix.go
index 5eddcd3..78c75b5 100644
--- a/readline/internal/display/display_unix.go
+++ b/readline/internal/display/display_unix.go
@@ -4,23 +4,22 @@
package display
import (
- "github.com/chainreactors/tui/readline/internal/core"
+ "os"
+ "os/signal"
+ "syscall"
+
"github.com/chainreactors/tui/readline/internal/term"
)
// WatchResize redisplays the interface on terminal resize events.
func WatchResize(eng *Engine) chan<- bool {
- resizeChannel := core.GetTerminalResize(eng.keys)
done := make(chan bool, 1)
- output := term.Output()
- control := term.CurrentControl()
+
+ resizeChannel := make(chan os.Signal, 1)
+ signal.Notify(resizeChannel, syscall.SIGWINCH)
unregister := term.OnResize(func(_, _ int) {
- if eng.keys != nil && !eng.keys.IsReading() && !eng.keys.IsWaiting() {
- restore := term.Activate(output, control)
- eng.completer.GenerateCached()
- eng.Refresh()
- restore()
- }
+ eng.completer.RequestRegen()
+ eng.keys.RequestRefresh()
})
go func() {
@@ -28,12 +27,12 @@ func WatchResize(eng *Engine) chan<- bool {
for {
select {
case <-resizeChannel:
- if eng.keys != nil && !eng.keys.IsReading() && !eng.keys.IsWaiting() {
- restore := term.Activate(output, control)
- eng.completer.GenerateCached()
- eng.Refresh()
- restore()
- }
+ // Route the regeneration + repaint through the input wake so
+ // they run on the Readline goroutine, instead of mutating the
+ // completion/display state and writing to stdout from here
+ // (which races with the main loop).
+ eng.completer.RequestRegen()
+ eng.keys.RequestRefresh()
case <-done:
return
}
diff --git a/readline/internal/display/drift_test.go b/readline/internal/display/drift_test.go
new file mode 100644
index 0000000..40940eb
--- /dev/null
+++ b/readline/internal/display/drift_test.go
@@ -0,0 +1,164 @@
+//go:build unix
+
+package display_test
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestNoPromptDriftWithHint reproduces a reported bug: when a hint is displayed
+// below the input line, every refresh repositioned the prompt one row too high,
+// so the whole UI crept upward by one row per keystroke. The anchor (top) line
+// of a multi-line prompt must stay on the same screen row as the user types.
+func TestNoPromptDriftWithHint(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "ANCHOR\nP> ",
+ cols: 80,
+ rows: 24,
+ transient: "STATUS-HINT",
+ })
+ c.waitForScreen("ANCHOR")
+ c.waitForScreen("STATUS-HINT")
+
+ chars := []string{"a", "b", "c", "d"}
+ rows := make([]int, 0, len(chars))
+
+ for _, ch := range chars {
+ c.send(ch)
+ time.Sleep(150 * time.Millisecond)
+
+ screen := c.screen()
+ rows = append(rows, rowIndex(screen, "ANCHOR"))
+ }
+
+ for i, r := range rows {
+ if r != rows[0] {
+ t.Fatalf("prompt drifted: ANCHOR row per keystroke = %v (moved at step %d)\nlast screen:\n%s",
+ rows, i, c.screen())
+ }
+ }
+}
+
+// TestNoPromptDriftWithHintAndAutocomplete is the compound case from the report:
+// autocomplete menu + hint, multi-line prompt. The anchor line must not drift.
+func TestNoPromptDriftWithHintAndAutocomplete(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "ANCHOR\nP> ",
+ cols: 80,
+ rows: 24,
+ autocomplete: true,
+ hintProvider: true,
+ })
+ c.waitForScreen("ANCHOR")
+
+ chars := strings.Split("alp", "")
+ rows := make([]int, 0, len(chars))
+
+ for _, ch := range chars {
+ c.send(ch)
+ time.Sleep(200 * time.Millisecond)
+
+ screen := c.screen()
+ rows = append(rows, rowIndex(screen, "ANCHOR"))
+ }
+
+ for i, r := range rows {
+ if r != rows[0] {
+ t.Fatalf("prompt drifted with autocomplete: ANCHOR row per keystroke = %v (moved at step %d)\nlast screen:\n%s",
+ rows, i, c.screen())
+ }
+ }
+}
+
+// TestNoPromptDriftAcrossAsyncWakes reproduces the core bug: each async-refresh
+// wake (e.g. a transient hint pushed from another goroutine) repainted the UI
+// one row too high, so repeated async updates crept the prompt upward â even
+// with no keystroke. The anchor line must stay put across many wakes.
+func TestNoPromptDriftAcrossAsyncWakes(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "ANCHOR\nP> ",
+ cols: 80,
+ rows: 24,
+ asyncMS: 150,
+ asyncRepeat: 5,
+ })
+ c.waitForScreen("ANCHOR")
+
+ rows := make([]int, 0, 5)
+
+ for i := range 5 {
+ c.waitForScreen(fmt.Sprintf("ASYNCPING-%d", i))
+ rows = append(rows, rowIndex(c.screen(), "ANCHOR"))
+ }
+
+ for i, r := range rows {
+ if r != rows[0] {
+ t.Fatalf("prompt drifted across async wakes: ANCHOR row per update = %v (moved at update %d)\nlast screen:\n%s",
+ rows, i, c.screen())
+ }
+ }
+}
+
+// TestNoPromptDriftAsyncWakesWithRightPrompt is the closest match to the example
+// console: a multi-line prompt with a right-side prompt (reaching the far-right
+// column), updated by repeated async transient hints.
+func TestNoPromptDriftAsyncWakesWithRightPrompt(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "ANCHOR\nP> ",
+ cols: 80,
+ rows: 24,
+ rightPrompt: "12:00:00.000",
+ asyncMS: 150,
+ asyncRepeat: 5,
+ })
+ c.waitForScreen("ANCHOR")
+
+ rows := make([]int, 0, 5)
+
+ for i := range 5 {
+ c.waitForScreen(fmt.Sprintf("ASYNCPING-%d", i))
+ rows = append(rows, rowIndex(c.screen(), "ANCHOR"))
+ }
+
+ for i, r := range rows {
+ if r != rows[0] {
+ t.Fatalf("prompt drifted across async wakes (with right prompt): ANCHOR row = %v (moved at %d)\n%s",
+ rows, i, c.screen())
+ }
+ }
+}
+
+// TestNoPromptDriftTwoHintLanes reproduces the root cause directly: with TWO
+// active hint lanes (a provider hint plus an async transient), the hint-row
+// count was over-counted, so each refresh crept the prompt up one row.
+func TestNoPromptDriftTwoHintLanes(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "ANCHOR\nP> ",
+ cols: 80,
+ rows: 24,
+ hintProvider: true, // provider hint = lane 1 (once the line is non-empty)
+ asyncMS: 150,
+ asyncRepeat: 5, // transient hint = lane 2
+ })
+ c.waitForScreen("ANCHOR")
+
+ c.send("x") // make the provider hint non-empty
+ c.waitForScreen("HINT:x")
+
+ rows := make([]int, 0, 5)
+
+ for i := range 5 {
+ c.waitForScreen(fmt.Sprintf("ASYNCPING-%d", i))
+ rows = append(rows, rowIndex(c.screen(), "ANCHOR"))
+ }
+
+ for i, r := range rows {
+ if r != rows[0] {
+ t.Fatalf("prompt drifted with two hint lanes: ANCHOR row = %v (moved at %d)\n%s",
+ rows, i, c.screen())
+ }
+ }
+}
diff --git a/readline/internal/display/engine.go b/readline/internal/display/engine.go
index 46c8039..d5100b9 100644
--- a/readline/internal/display/engine.go
+++ b/readline/internal/display/engine.go
@@ -1,6 +1,7 @@
package display
import (
+ "regexp"
"strings"
"github.com/chainreactors/tui/readline/inputrc"
@@ -33,12 +34,17 @@ type Engine struct {
compRows int
primaryPrinted bool
+ // commentRegex is the compiled comment-highlight pattern. It is rebuilt
+ // only when the comment-begin option changes, not on every refresh.
+ commentToken string
+ commentRegex *regexp.Regexp
+
// UI components
keys *core.Keys
line *core.Line
suggested core.Line
cursor *core.Cursor
- inlineSuggestion string // Inline suggestion (fish-style gray text)
+ inlineSuggestion string
selection *core.Selection
histories *history.Sources
prompt *ui.Prompt
@@ -86,7 +92,6 @@ func (e *Engine) inlineSuggestionApplies(currentLine string) bool {
if e.inlineSuggestion == "" {
return false
}
-
if e.cursor.Pos() != e.line.Len() {
return false
}
@@ -94,43 +99,6 @@ func (e *Engine) inlineSuggestionApplies(currentLine string) bool {
return strings.HasPrefix(e.inlineSuggestion, currentLine) && len(e.inlineSuggestion) > len(currentLine)
}
-// Refresh recomputes and redisplays the entire readline interface, except
-// the first lines of the primary prompt when the latter is a multiline one.
-func (e *Engine) Refresh() {
- term.Print(term.HideCursor)
-
- // Trigger autocomplete early so that inline suggestions are ready
- // before displayLine() is called. This ensures fish-style suggestions
- // appear immediately as the user types.
- e.completer.Autocomplete()
-
- // Go back to the first column, and if the primary prompt
- // was not printed yet, back up to the line's beginning row.
- term.MoveCursorBackwards(term.GetWidth())
-
- if !e.primaryPrinted {
- term.MoveCursorUp(e.cursorRow)
- }
-
- // Print either all or the last line of the prompt.
- e.prompt.LastPrint()
-
- // Get all positions required for the redisplay to come:
- // prompt end (thus indentation), cursor positions, etc.
- e.computeCoordinates(true)
-
- // Print the line, and any of the secondary and right prompts.
- e.displayLine()
- e.displayMultilinePrompts()
-
- // Display hints and completions, go back
- // to the start of the line, then to cursor.
- e.displayHelpers()
- e.cursorHintToLineStart()
- e.lineStartToCursorPos()
- term.Print(term.ShowCursor)
-}
-
// PrintPrimaryPrompt redraws the primary prompt.
// There are relatively few cases where you want to use this.
// It is currently only used when using clear-screen commands.
@@ -141,8 +109,11 @@ func (e *Engine) PrintPrimaryPrompt() {
// ClearHelpers clears the hint and completion sections below the line.
func (e *Engine) ClearHelpers() {
+ term.BeginBuffer()
+ defer term.EndBuffer()
+
e.CursorBelowLine()
- term.Print(term.ClearScreenBelow)
+ term.WriteString(term.ClearScreenBelow)
term.MoveCursorUp(1)
term.MoveCursorUp(e.lineRows)
@@ -161,7 +132,9 @@ func (e *Engine) ResetHelpers() {
// hints, completions and some right prompts, the shell will put the
// display at the start of the line immediately following the line.
func (e *Engine) AcceptLine() {
- e.ClearInlineSuggestion()
+ term.BeginBuffer()
+ defer term.EndBuffer()
+
e.CursorToLineStart()
e.computeCoordinates(false)
@@ -170,14 +143,14 @@ func (e *Engine) AcceptLine() {
term.MoveCursorBackwards(term.GetWidth())
term.MoveCursorDown(e.lineRows)
term.MoveCursorForwards(e.lineCol)
- term.Print(term.ClearScreenBelow)
+ term.WriteString(term.ClearScreenBelow)
// Reprint the right-side prompt if it's not a tooltip one.
e.prompt.RightPrint(e.lineCol, false)
// Go below this non-suggested line and clear everything.
term.MoveCursorBackwards(term.GetWidth())
- term.Print(term.NewlineReturn)
+ term.WriteString(term.NewlineReturn)
}
// RefreshTransient goes back to the first line of the input buffer
@@ -187,6 +160,9 @@ func (e *Engine) RefreshTransient() {
return
}
+ term.BeginBuffer()
+ defer term.EndBuffer()
+
// Go to the beginning of the primary prompt.
e.CursorToLineStart()
term.MoveCursorUp(e.prompt.PrimaryUsed())
@@ -194,7 +170,7 @@ func (e *Engine) RefreshTransient() {
// And redisplay the transient/primary/line.
e.prompt.TransientPrint()
e.displayLine()
- term.Print(term.NewlineReturn)
+ term.WriteString(term.NewlineReturn)
}
// CursorToLineStart moves the cursor just after the primary prompt.
@@ -213,23 +189,7 @@ func (e *Engine) CursorToLineStart() {
func (e *Engine) CursorBelowLine() {
term.MoveCursorUp(e.cursorRow)
term.MoveCursorDown(e.lineRows)
- term.Print(term.NewlineReturn)
-}
-
-// lineStartToCursorPos can be used if the cursor is currently
-// at the very start of the input line, that is just after the
-// last character of the prompt.
-func (e *Engine) lineStartToCursorPos() {
- term.MoveCursorDown(e.cursorRow)
- term.MoveCursorBackwards(term.GetWidth())
- term.MoveCursorForwards(e.cursorCol)
-}
-
-// cursor is on the line below the last line of input.
-func (e *Engine) cursorHintToLineStart() {
- term.MoveCursorUp(1)
- term.MoveCursorUp(e.lineRows - e.cursorRow)
- e.CursorToLineStart()
+ term.WriteString(term.NewlineReturn)
}
func (e *Engine) computeCoordinates(suggested bool) {
@@ -241,15 +201,28 @@ func (e *Engine) computeCoordinates(suggested bool) {
e.suggested = e.histories.Suggest(e.line)
}
- // Get the position of the line's beginning by querying
- // the terminal for the cursor position.
- e.startCols, e.startRows = e.keys.GetCursorPos()
+ // Recompute the passive provider hint from the current line, so it tracks
+ // the input as it changes. Runs every refresh, on the main loop goroutine.
+ e.hint.UpdateProvided([]rune(*e.line), e.cursor.Pos())
+
+ // Get the position of the line's beginning by querying the terminal for the
+ // cursor position. Some environments (PTY test harnesses, minimal emulators,
+ // constrained CI) don't reliably answer the "ESC[6n" query, so consumers can
+ // turn the cursor-position-probe option off; we then fall back to a position
+ // derived from the printed prompt width.
+ if e.opts.GetBool("cursor-position-probe") {
+ e.startCols, e.startRows = e.keys.GetCursorPos()
+ } else {
+ e.startCols, e.startRows = -1, -1
+ }
if e.startCols > 0 {
e.startCols--
}
- // Cursor position might be misleading if invalid (negative).
+ // Cursor column might be misleading if invalid (negative), or unavailable
+ // because probing is disabled: fall back to the printed prompt width. This
+ // is exact whenever the input line starts at column 0 (the common case).
if e.startCols == -1 {
e.startCols = e.prompt.LastUsed()
}
@@ -257,16 +230,16 @@ func (e *Engine) computeCoordinates(suggested bool) {
e.cursorCol, e.cursorRow = core.CoordinatesCursor(e.cursor, e.startCols)
// Get the number of rows used by the line, and the end line X pos.
- displayLine := e.line
currentLine := string(*e.line)
if e.opts.GetBool("history-autosuggest") && suggested && len(e.suggested) > e.line.Len() {
- displayLine = &e.suggested
+ e.lineCol, e.lineRows = core.CoordinatesLine(&e.suggested, e.startCols)
} else if e.inlineSuggestionApplies(currentLine) {
inlineLine := core.Line{}
inlineLine.Set([]rune(e.inlineSuggestion)...)
- displayLine = &inlineLine
+ e.lineCol, e.lineRows = core.CoordinatesLine(&inlineLine, e.startCols)
+ } else {
+ e.lineCol, e.lineRows = core.CoordinatesLine(e.line, e.startCols)
}
- e.lineCol, e.lineRows = core.CoordinatesLine(displayLine, e.startCols)
e.primaryPrinted = false
}
@@ -290,17 +263,13 @@ func (e *Engine) displayLine() {
// Apply visual selections highlighting if any
line = e.highlightLine([]rune(line), *e.selection)
- // Track if we added any suggestion suffix
+ // Get the subset of the suggested line to print.
suggestionAdded := false
-
- // Get the subset of the suggested line to print (history autosuggest).
if len(e.suggested) > e.line.Len() && e.opts.GetBool("history-autosuggest") {
line += color.Dim + color.Fmt(color.Fg+"242") + string(e.suggested[e.line.Len():]) + color.Reset
suggestionAdded = true
}
- // If no history suggestion was added, try the inline suggestion.
- // Only show when cursor is at end of line.
currentLine := string(*e.line)
if !suggestionAdded && e.inlineSuggestionApplies(currentLine) {
suffix := e.inlineSuggestion[len(currentLine):]
@@ -316,49 +285,9 @@ func (e *Engine) displayLine() {
// Adjust the cursor if the line fits exactly in the terminal width.
if e.lineCol == 0 {
- term.Print(term.NewlineReturn)
- term.Print(term.ClearLineAfter)
- }
-}
-
-func (e *Engine) displayMultilinePrompts() {
- // If we have more than one line, write the columns.
- if e.line.Lines() > 1 {
- term.MoveCursorUp(e.lineRows)
- term.MoveCursorBackwards(term.GetWidth())
- e.prompt.MultilineColumnPrint()
+ term.WriteString(term.NewlineReturn)
+ term.WriteString(term.ClearLineAfter)
}
-
- // Then if we have a line at all, rewrite the last column
- // character with any secondary prompt available.
- if e.line.Lines() > 0 {
- term.MoveCursorBackwards(term.GetWidth())
- e.prompt.SecondaryPrint()
- term.MoveCursorBackwards(term.GetWidth())
- term.MoveCursorForwards(e.lineCol)
- }
-
- // Then prompt the right-sided prompt if possible.
- e.prompt.RightPrint(e.lineCol, true)
-}
-
-// displayHelpers renders the hint and completion sections.
-// It assumes that the cursor is on the last line of input,
-// and goes back to this same line after displaying this.
-func (e *Engine) displayHelpers() {
- term.Print(term.NewlineReturn)
- term.Print(term.ClearScreenBelow)
-
- // Display hint and completions.
- ui.DisplayHint(e.hint)
- e.hintRows = ui.CoordinatesHint(e.hint)
- completion.Display(e.completer, e.AvailableHelperLines())
- e.compRows = completion.Coordinates(e.completer)
-
- // Go back to the first line below the input line.
- term.MoveCursorBackwards(term.GetWidth())
- term.MoveCursorUp(e.compRows)
- term.MoveCursorUp(ui.CoordinatesHint(e.hint))
}
// AvailableHelperLines returns the number of lines available below the hint section.
diff --git a/readline/internal/display/highlight.go b/readline/internal/display/highlight.go
index 851d999..ff0e769 100644
--- a/readline/internal/display/highlight.go
+++ b/readline/internal/display/highlight.go
@@ -11,7 +11,9 @@ import (
"github.com/chainreactors/tui/readline/internal/core"
)
-var rxColorEscape = regexp.MustCompile(`\x1b\[[0-9;]+m`)
+// ansiEscapeRegex matches SGR color escape sequences embedded in a line. It is
+// compiled once at package load: getHighlights runs on every display refresh.
+var ansiEscapeRegex = regexp.MustCompile(`\x1b\[[0-9;]+m`)
// highlightLine applies visual/selection highlighting to a line.
// The provided line might already have been highlighted by a user-provided
@@ -22,26 +24,32 @@ func (e *Engine) highlightLine(line []rune, selection core.Selection) string {
colors := e.getHighlights(line, sorted)
var highlighted string
- var highlightedSb strings.Builder
// And apply highlighting before each rune.
+ var highlightedSb25 strings.Builder
+
for i, r := range line {
if highlight, found := colors[i]; found {
- highlightedSb.WriteString(string(highlight))
+ highlightedSb25.WriteString(string(highlight))
}
- highlightedSb.WriteRune(r)
+ highlightedSb25.WriteRune(r)
}
- highlighted += highlightedSb.String()
+ highlighted += highlightedSb25.String()
- // Finally, highlight comments using a regex.
+ // Finally, highlight comments using a regex. The pattern only depends on
+ // the comment-begin option, so compile it lazily and reuse it until that
+ // option changes, rather than recompiling on every refresh.
comment := strings.Trim(e.opts.GetString("comment-begin"), "\"")
- commentPattern := fmt.Sprintf(`(^|\s)%s.*`, comment)
+ if comment != e.commentToken || e.commentRegex == nil {
+ e.commentToken = comment
+ e.commentRegex, _ = regexp.Compile(fmt.Sprintf(`(^|\s)%s.*`, comment))
+ }
- if commentsMatch, err := regexp.Compile(commentPattern); err == nil {
+ if e.commentRegex != nil {
commentColor := color.SGRStart + color.Fg + "244" + color.SGREnd
- highlighted = commentsMatch.ReplaceAllString(highlighted, fmt.Sprintf("%s${0}%s", commentColor, color.Reset))
+ highlighted = e.commentRegex.ReplaceAllString(highlighted, fmt.Sprintf("%s${0}%s", commentColor, color.Reset))
}
highlighted += color.Reset
@@ -105,9 +113,7 @@ func (e *Engine) getHighlights(line []rune, sorted []core.Selection) map[int][]r
// Find any highlighting already applied on the line,
// and keep the indexes so that we can skip those.
- var colors [][]int
-
- colors = rxColorEscape.FindAllStringIndex(string(line), -1)
+ colors := ansiEscapeRegex.FindAllStringIndex(string(line), -1)
// marks that started highlighting, but not done yet.
regions := make([]core.Selection, 0)
@@ -216,14 +222,6 @@ func (e *Engine) hlReset(regions []core.Selection, line []rune, pos int) ([]core
// foreground := e.opts.GetString("active-region-start-color")
line = append(line, []rune(color.ReverseReset)...)
line = append(line, []rune(color.BgDefault)...)
- // if background == "" && foreground == "" && !matcher {
- // line = append(line, []rune(color.ReverseReset)...)
- // } else {
- //
- // line = append(line, []rune(color.BgDefault)...)
- // }
- //
- // line = append(line, []rune(color.ReverseReset)...)
}
}
diff --git a/readline/internal/display/hint_test.go b/readline/internal/display/hint_test.go
new file mode 100644
index 0000000..29b11a3
--- /dev/null
+++ b/readline/internal/display/hint_test.go
@@ -0,0 +1,144 @@
+//go:build unix
+
+package display_test
+
+import (
+ "strings"
+ "testing"
+)
+
+// rowIndex returns the index of the first screen row containing substr, or -1.
+func rowIndex(screen, substr string) int {
+ for i, row := range strings.Split(screen, "\n") {
+ if strings.Contains(row, substr) {
+ return i
+ }
+ }
+
+ return -1
+}
+
+// TestHintProviderTracksLine checks that a registered passive hint provider is
+// re-evaluated as the input changes: the provided lane echoes the current line.
+func TestHintProviderTracksLine(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "PROMPT> ",
+ cols: 80,
+ rows: 24,
+ hintProvider: true,
+ })
+ c.waitForScreen("PROMPT>")
+
+ c.send("abc")
+ c.waitForScreen("HINT:abc")
+
+ // As the line grows, the provided hint tracks it.
+ c.send("d")
+ c.waitForScreen("HINT:abcd")
+}
+
+// TestHintProviderAboveTransient verifies lane precedence: the passive provider
+// hint renders strictly above the transient (async status) hint.
+func TestHintProviderAboveTransient(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "PROMPT> ",
+ cols: 80,
+ rows: 24,
+ hintProvider: true,
+ transient: "ASYNCMSG",
+ })
+ c.waitForScreen("PROMPT>")
+
+ c.send("abc")
+ screen := c.waitUntil(func(s string) bool {
+ return strings.Contains(s, "HINT:abc") && strings.Contains(s, "ASYNCMSG")
+ })
+
+ provided := rowIndex(screen, "HINT:abc")
+ transient := rowIndex(screen, "ASYNCMSG")
+
+ if provided < 0 || transient < 0 {
+ t.Fatalf("both hints must be present (provided=%d transient=%d):\n%s",
+ provided, transient, screen)
+ }
+
+ if provided >= transient {
+ t.Fatalf("provider hint (row %d) must render above transient hint (row %d):\n%s",
+ provided, transient, screen)
+ }
+}
+
+// TestTransientSurvivesIsearchAndRendersAbove checks the two guarantees that
+// make the transient lane useful for async status: entering incremental search
+// (which owns the completion/text lane) must NOT clear the transient hint, and
+// the transient hint must render above that completion-lane hint.
+func TestTransientSurvivesIsearchAndRendersAbove(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "PROMPT> ",
+ cols: 80,
+ rows: 24,
+ transient: "ASYNCMSG",
+ })
+ c.waitForScreen("ASYNCMSG")
+
+ // Ctrl-R enters incremental search, which sets the completion/text lane.
+ c.send("\x12")
+ screen := c.waitForScreen("inc-search")
+
+ // The transient hint must have survived the isearch taking over the
+ // completion lane.
+ transient := rowIndex(screen, "ASYNCMSG")
+ isearch := rowIndex(screen, "inc-search")
+
+ if transient < 0 {
+ t.Fatalf("transient hint was lost when entering incremental search:\n%s", screen)
+ }
+
+ if transient >= isearch {
+ t.Fatalf("transient hint (row %d) must render above the isearch hint (row %d):\n%s",
+ transient, isearch, screen)
+ }
+}
+
+// TestAsyncTransientWhileIdle proves the async-refresh wake: a transient hint
+// pushed from another goroutine while the shell is idle (blocked waiting for
+// input) must appear WITHOUT any keystroke being sent.
+func TestAsyncTransientWhileIdle(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "PROMPT> ",
+ cols: 80,
+ rows: 24,
+ asyncMS: 200,
+ })
+ c.waitForScreen("PROMPT>")
+
+ // Deliberately send NO input: the only thing that can make this appear is
+ // the wake repainting the idle loop.
+ c.waitForScreen("ASYNCPING")
+}
+
+// TestAsyncRefreshKeepsLayout guards against the async repaint corrupting the
+// render bookkeeping ("prints a mess / jumps around"): after an idle async
+// repaint, the prompt must still render exactly once and accept aligned input.
+func TestAsyncRefreshKeepsLayout(t *testing.T) {
+ c := startConsole(t, consoleConfig{
+ prompt: "PROMPT> ",
+ cols: 80,
+ rows: 24,
+ asyncMS: 200,
+ })
+ c.waitForScreen("ASYNCPING")
+
+ // Now type after the async repaint and confirm the line is intact.
+ c.send("abc")
+ screen := c.waitForScreen("PROMPT> abc")
+
+ if got := countLine(screen, "PROMPT>"); got != 1 {
+ t.Fatalf("prompt should render exactly once after async repaint, got %d:\n%s", got, screen)
+ }
+
+ first := strings.TrimRight(strings.SplitN(screen, "\n", 2)[0], " ")
+ if first != "PROMPT> abc" {
+ t.Fatalf("input misaligned after async repaint:\n got: %q\n want: %q", first, "PROMPT> abc")
+ }
+}
diff --git a/readline/internal/display/pty_harness_test.go b/readline/internal/display/pty_harness_test.go
new file mode 100644
index 0000000..defb23f
--- /dev/null
+++ b/readline/internal/display/pty_harness_test.go
@@ -0,0 +1,456 @@
+//go:build unix
+
+package display_test
+
+// This file provides a PTY-backed, virtual-terminal test harness for the shell.
+//
+// Why a subprocess under a real PTY (instead of swapping os.Stdin/os.Stdout
+// in-process)? The library writes raw escape sequences directly to os.Stdout
+// and the internal/term package captures the stdout/stderr file handles in its
+// init(), before any test could redirect them. Running the shell in a child
+// process that is given the PTY as its real std{in,out,err} is therefore the
+// faithful way to exercise the true byte stream a terminal would see.
+//
+// The harness feeds the child's output into a vt10x virtual terminal so tests
+// can assert on the *rendered* screen, and it auto-responds to cursor-position
+// (DSR "ESC[6n") queries so that GetCursorPos() does not block forever
+// consuming our keystrokes (see internal/core/keys_unix.go).
+//
+// It lives as an external test package (display_test) so it can drive the full
+// shell via the root readline package without an import cycle.
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/creack/pty"
+ "github.com/hinshun/vt10x"
+
+ "github.com/chainreactors/tui/readline"
+)
+
+const (
+ childEnvVar = "READLINE_PTY_CHILD"
+ promptEnvVar = "READLINE_PTY_PROMPT"
+ prefillEnvVar = "READLINE_PTY_PREFILL"
+ noProbeEnvVar = "READLINE_PTY_NOPROBE"
+ hintProviderEnvVar = "READLINE_PTY_HINTPROVIDER"
+ transientEnvVar = "READLINE_PTY_TRANSIENT"
+ asyncMSEnvVar = "READLINE_PTY_ASYNC_MS"
+ asyncCompEnvVar = "READLINE_PTY_ASYNCCOMP"
+ autocompleteEnvVar = "READLINE_PTY_AUTOCOMPLETE"
+)
+
+// TestMain lets this test binary double as the process-under-test: when the
+// harness re-execs us with READLINE_PTY_CHILD=1, we run a minimal readline app
+// instead of the test suite.
+func TestMain(m *testing.M) {
+ if os.Getenv(childEnvVar) == "1" {
+ runPTYChild()
+ return
+ }
+
+ os.Exit(m.Run())
+}
+
+// runPTYChild runs one Readline() round with a deterministic prompt, then
+// prints the accepted line (or error) wrapped in markers the harness matches on.
+func runPTYChild() {
+ // Optionally push the prompt down the screen first, so tests can place it
+ // at (or near) the bottom of the terminal window. The value is the number
+ // of blank lines to print before starting the shell.
+ if n, err := strconv.Atoi(os.Getenv(prefillEnvVar)); err == nil && n > 0 {
+ fmt.Fprint(os.Stdout, strings.Repeat("\r\n", n))
+ }
+
+ rl := readline.NewShell()
+
+ if os.Getenv(noProbeEnvVar) == "1" {
+ rl.Config.Set("cursor-position-probe", false)
+ }
+
+ // Register a passive hint provider that echoes the current line, so tests
+ // can observe the provided lane tracking input.
+ if os.Getenv(hintProviderEnvVar) == "1" {
+ rl.Hint.SetProvider(func(line []rune, _ int) []rune {
+ if len(line) == 0 {
+ return nil
+ }
+
+ return []rune("HINT:" + string(line))
+ })
+ }
+
+ // Seed a transient (async status) hint before reading, so tests can assert
+ // it renders and survives completion/isearch activity.
+ if msg := os.Getenv(transientEnvVar); msg != "" {
+ rl.Hint.SetTransient(msg)
+ }
+
+ // Async completion: a completer whose result set grows when a background
+ // goroutine flips a flag and calls RefreshCompletions (no keystroke). Used
+ // to exercise in-place menu regeneration (#99).
+ if os.Getenv(asyncCompEnvVar) == "1" {
+ var extra int32
+
+ // Prefix-sharing values so the same completer works for both explicit
+ // menus (empty prefix) and as-you-type autocomplete (typing "alp"). The
+ // added value sorts FIRST, so the grid re-sorts on growth â exercising
+ // content-based (not positional) selection restore.
+ rl.Completer = func(_ []rune, _ int) readline.Completions {
+ values := []string{"alpha", "alpine"}
+ if atomic.LoadInt32(&extra) == 1 {
+ values = append(values, "alpaca")
+ }
+
+ return readline.CompleteValues(values...)
+ }
+
+ if ms, err := strconv.Atoi(os.Getenv(asyncMSEnvVar)); err == nil && ms > 0 {
+ go func() {
+ time.Sleep(time.Duration(ms) * time.Millisecond)
+ atomic.StoreInt32(&extra, 1)
+ rl.RefreshCompletions()
+ }()
+ }
+ } else if ms, err := strconv.Atoi(os.Getenv(asyncMSEnvVar)); err == nil && ms > 0 {
+ // Push transient hint(s) from another goroutine AFTER the read loop has
+ // started and is idle, to exercise the async-refresh wake (no keystroke).
+ // READLINE_PTY_ASYNC_REPEAT controls how many updates are pushed.
+ repeat := 1
+ if n, err := strconv.Atoi(os.Getenv("READLINE_PTY_ASYNC_REPEAT")); err == nil && n > 0 {
+ repeat = n
+ }
+
+ go func() {
+ for i := range repeat {
+ time.Sleep(time.Duration(ms) * time.Millisecond)
+ rl.Hint.SetTransient(fmt.Sprintf("ASYNCPING-%d", i))
+ }
+ }()
+ }
+
+ // As-you-type autocomplete. Composes with asyncComp (which sets a growing
+ // completer); otherwise installs a static completer (several values + a usage
+ // hint) to reproduce the hint+menu redraw path.
+ if os.Getenv(autocompleteEnvVar) == "1" {
+ rl.Config.Set("autocomplete", true)
+
+ if rl.Completer == nil {
+ rl.Completer = func(_ []rune, _ int) readline.Completions {
+ return readline.CompleteValues("alpha", "alef", "alpine", "almond").Usage("pick a word")
+ }
+ }
+ }
+
+ prompt := os.Getenv(promptEnvVar)
+ if prompt == "" {
+ prompt = "> "
+ }
+
+ rl.Prompt.Primary(func() string { return prompt })
+
+ // Optional right-side prompt (like the example's clock): it is re-rendered
+ // every refresh and reaches the far-right column.
+ if rp := os.Getenv("READLINE_PTY_RIGHTPROMPT"); rp != "" {
+ rl.Prompt.Right(func() string { return rp })
+ }
+
+ line, err := rl.Readline()
+ if err != nil {
+ fmt.Fprintf(os.Stdout, "\r\n[ERR:%s]\r\n", err)
+ os.Exit(0)
+ }
+
+ fmt.Fprintf(os.Stdout, "\r\n[LINE:%s]\r\n", line)
+ os.Exit(0)
+}
+
+// console drives a child shell over a PTY and mirrors its output into a vt10x
+// virtual terminal for screen assertions.
+type console struct {
+ t *testing.T
+ cmd *exec.Cmd
+ ptmx *os.File
+ term vt10x.Terminal
+
+ mu sync.Mutex // guards term (writer + reads), probeCount and DSR replies
+ done chan struct{}
+ probeCount int // number of "ESC[6n" cursor-position queries observed
+
+ // probeReply, if non-nil, computes the DSR reply bytes for an "ESC[6n"
+ // cursor-position query, letting tests simulate a misbehaving terminal.
+ // If nil, the emulator's true cursor position is reported (1-based).
+ probeReply func(cur vt10x.Cursor) string
+}
+
+// consoleConfig configures a PTY-backed test console.
+type consoleConfig struct {
+ prompt string
+ cols, rows int
+ // prefill is the number of blank lines printed before the prompt, used to
+ // push the prompt down the window (e.g. to the bottom row).
+ prefill int
+ // noProbe disables the shell's cursor-position probing in the child.
+ noProbe bool
+ // hintProvider registers a passive hint provider in the child that echoes
+ // the current line as "HINT:".
+ hintProvider bool
+ // transient, if non-empty, seeds a transient (async status) hint in the
+ // child before the read loop starts.
+ transient string
+ // asyncMS, if > 0, makes the child push a transient hint ("ASYNCPING")
+ // from another goroutine after that many milliseconds, once idle. With
+ // asyncComp set, it instead grows the completer and calls
+ // RefreshCompletions after that delay.
+ asyncMS int
+ // asyncComp installs a completer whose results grow ("charlie" is added)
+ // when the async goroutine fires RefreshCompletions.
+ asyncComp bool
+ // asyncRepeat, with asyncMS > 0, pushes that many transient hint updates
+ // (each one an async-refresh wake), to detect drift across wake refreshes.
+ asyncRepeat int
+ // rightPrompt, if set, installs a right-side prompt reaching the far-right
+ // column (like the example console's clock).
+ rightPrompt string
+ // autocomplete turns on as-you-type autocompletion with a static completer
+ // (several values + a usage hint), to exercise the hint+menu redraw path.
+ autocomplete bool
+ // probeReply, if non-nil, computes the DSR reply for an "ESC[6n" query,
+ // letting tests simulate a terminal that reports a wrong cursor position.
+ probeReply func(vt10x.Cursor) string
+}
+
+// newConsole spawns the child shell under a PTY of the given size, with the
+// given primary prompt, and starts mirroring its output into the emulator.
+func newConsole(t *testing.T, prompt string, cols, rows int) *console {
+ t.Helper()
+
+ return startConsole(t, consoleConfig{prompt: prompt, cols: cols, rows: rows})
+}
+
+// startConsole spawns the child shell under a PTY using the full config and
+// starts mirroring its output into the emulator.
+func startConsole(t *testing.T, cfg consoleConfig) *console {
+ t.Helper()
+
+ cmd := exec.CommandContext(context.Background(), os.Args[0])
+ cmd.Env = append(os.Environ(),
+ childEnvVar+"=1",
+ promptEnvVar+"="+cfg.prompt,
+ fmt.Sprintf("%s=%d", prefillEnvVar, cfg.prefill),
+ "INPUTRC=/dev/null", // don't pick up a developer's ~/.inputrc
+ "TERM=xterm-256color",
+ )
+
+ if cfg.noProbe {
+ cmd.Env = append(cmd.Env, noProbeEnvVar+"=1")
+ }
+
+ if cfg.hintProvider {
+ cmd.Env = append(cmd.Env, hintProviderEnvVar+"=1")
+ }
+
+ if cfg.transient != "" {
+ cmd.Env = append(cmd.Env, transientEnvVar+"="+cfg.transient)
+ }
+
+ if cfg.asyncMS > 0 {
+ cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%d", asyncMSEnvVar, cfg.asyncMS))
+ }
+
+ if cfg.asyncComp {
+ cmd.Env = append(cmd.Env, asyncCompEnvVar+"=1")
+ }
+
+ if cfg.autocomplete {
+ cmd.Env = append(cmd.Env, autocompleteEnvVar+"=1")
+ }
+
+ if cfg.asyncRepeat > 0 {
+ cmd.Env = append(cmd.Env, fmt.Sprintf("READLINE_PTY_ASYNC_REPEAT=%d", cfg.asyncRepeat))
+ }
+
+ if cfg.rightPrompt != "" {
+ cmd.Env = append(cmd.Env, "READLINE_PTY_RIGHTPROMPT="+cfg.rightPrompt)
+ }
+
+ ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: uint16(cfg.rows), Cols: uint16(cfg.cols)})
+ if err != nil {
+ t.Fatalf("start child under pty: %v", err)
+ }
+
+ c := &console{
+ t: t,
+ cmd: cmd,
+ ptmx: ptmx,
+ term: vt10x.New(vt10x.WithSize(cfg.cols, cfg.rows)),
+ done: make(chan struct{}),
+ probeReply: cfg.probeReply,
+ }
+
+ go c.readLoop()
+
+ t.Cleanup(c.close)
+
+ return c
+}
+
+// readLoop copies child output into the emulator and answers DSR queries.
+func (c *console) readLoop() {
+ defer close(c.done)
+
+ buf := make([]byte, 4096)
+
+ for {
+ n, err := c.ptmx.Read(buf)
+ if n > 0 {
+ chunk := buf[:n]
+
+ c.mu.Lock()
+ _, _ = c.term.Write(chunk)
+ c.mu.Unlock()
+
+ // Respond to "ESC[6n" cursor-position reports. The prompt bytes
+ // preceding the query are already applied above, so the emulator's
+ // cursor reflects the true start-of-line column.
+ //
+ // NOTE: this assumes the query is not split across read boundaries,
+ // which holds in practice because the shell emits prompt+query in
+ // one write. A buffering scanner can be added if that changes.
+ if bytes.Contains(chunk, []byte("\x1b[6n")) {
+ c.mu.Lock()
+ c.probeCount++
+ c.mu.Unlock()
+
+ c.replyCursorPos()
+ }
+ }
+
+ if err != nil {
+ return
+ }
+ }
+}
+
+// replyCursorPos writes a DSR cursor-position report (1-based row;col), or the
+// custom reply from probeReply when a test wants to simulate a bad terminal.
+func (c *console) replyCursorPos() {
+ c.mu.Lock()
+ cur := c.term.Cursor()
+ fn := c.probeReply
+ c.mu.Unlock()
+
+ reply := fmt.Sprintf("\x1b[%d;%dR", cur.Y+1, cur.X+1)
+ if fn != nil {
+ reply = fn(cur)
+ }
+
+ if reply != "" {
+ _, _ = c.ptmx.WriteString(reply)
+ }
+}
+
+// send writes raw bytes to the child as if typed by the user.
+func (c *console) send(s string) {
+ c.t.Helper()
+
+ if _, err := c.ptmx.WriteString(s); err != nil {
+ c.t.Fatalf("send %q: %v", s, err)
+ }
+}
+
+// probeQueries returns how many "ESC[6n" cursor-position queries the child has
+// sent so far.
+func (c *console) probeQueries() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ return c.probeCount
+}
+
+// screen returns the current rendered contents of the virtual terminal.
+func (c *console) screen() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ return c.term.String()
+}
+
+// screenWaitTimeout bounds how long the screen-polling helpers wait before
+// failing the test. Every call site used the same value, so it lives here.
+//
+// The pollers return the instant the awaited content appears, so this ceiling
+// never slows a passing test -- it only caps how long we wait before declaring
+// failure. It is deliberately generous: under `go test -race` on a loaded CI
+// runner the whole PTY/render pipeline is starved (race instrumentation adds
+// several-fold overhead), and a tight bound turns that into spurious timeouts
+// for the async-refresh tests (locally they settle in well under 2s).
+const screenWaitTimeout = 15 * time.Second
+
+// waitForScreen polls until the rendered screen contains substr, or fails the
+// test on timeout. It returns the (last) screen contents either way.
+func (c *console) waitForScreen(substr string) string {
+ c.t.Helper()
+
+ deadline := time.Now().Add(screenWaitTimeout)
+
+ for {
+ s := c.screen()
+ if strings.Contains(s, substr) {
+ return s
+ }
+
+ if time.Now().After(deadline) {
+ c.t.Fatalf("timed out waiting for %q on screen; got:\n%s", substr, s)
+ return s
+ }
+
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+// waitUntil polls the rendered screen until cond returns true, or fails the
+// test on timeout. Returns the last screen contents.
+func (c *console) waitUntil(cond func(screen string) bool) string {
+ c.t.Helper()
+
+ deadline := time.Now().Add(screenWaitTimeout)
+
+ for {
+ s := c.screen()
+ if cond(s) {
+ return s
+ }
+
+ if time.Now().After(deadline) {
+ c.t.Fatalf("timed out waiting for screen condition; got:\n%s", s)
+ return s
+ }
+
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+// close tears the child and PTY down. Registered via t.Cleanup, so it must
+// never hang even when a test fails mid-flight: we close the PTY (which EOFs
+// the child's reads), then force-kill as a backstop before reaping.
+func (c *console) close() {
+ _ = c.ptmx.Close()
+
+ if c.cmd.Process != nil {
+ _ = c.cmd.Process.Kill()
+ }
+
+ _ = c.cmd.Wait()
+ <-c.done
+}
diff --git a/readline/internal/display/refresh.go b/readline/internal/display/refresh.go
new file mode 100644
index 0000000..bd2ba2d
--- /dev/null
+++ b/readline/internal/display/refresh.go
@@ -0,0 +1,341 @@
+package display
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/chainreactors/tui/readline/internal/color"
+ "github.com/chainreactors/tui/readline/internal/completion"
+ "github.com/chainreactors/tui/readline/internal/core"
+ "github.com/chainreactors/tui/readline/internal/strutil"
+ "github.com/chainreactors/tui/readline/internal/term"
+ "github.com/chainreactors/tui/readline/internal/ui"
+)
+
+// Refresh recomputes and redisplays the entire readline interface, except
+// the first lines of the primary prompt when the latter is a multiline one.
+func (e *Engine) Refresh() {
+ // Buffer the whole frame and flush once, so the terminal never shows a
+ // partial repaint and we issue a single write instead of dozens.
+ term.BeginBuffer()
+ defer term.EndBuffer()
+
+ // 1. Preparation & Coordinates
+ term.WriteString(term.HideCursor)
+ // Go back to the first column, and if the primary prompt
+ // was not printed yet, back up to the line's beginning row.
+ term.MoveCursorBackwards(term.GetWidth())
+
+ if !e.primaryPrinted {
+ term.MoveCursorUp(e.cursorRow)
+ }
+ // 2. Primary Prompt
+ e.prompt.LastPrint()
+ // Compute Coordinates: StartPos, LineHeight, CursorPos (row/col).
+ e.computeCoordinates(true)
+
+ // Ensure that the indicator is printed if the prompt is empty,
+ // and that we have enough space to print the line.
+ e.ensureIndicatorSpace()
+
+ // Recompute coordinates with the new indentation/cursor position.
+ if e.line.Lines() > 0 {
+ e.cursorCol, e.cursorRow = core.CoordinatesCursor(e.cursor, e.startCols)
+ e.lineCol, e.lineRows = core.CoordinatesLine(e.line, e.startCols)
+ }
+
+ // Ensure that we have enough space to print the line.
+ // We probe the terminal to verify that we are not at the bottom of the screen.
+ // If we are, we scroll the screen to make space for the line.
+ e.ensureInputSpace()
+
+ // Keep a multi-line prompt's upper lines correct now that the start row is
+ // settled: they are printed only once and not otherwise refreshed, so a
+ // scroll (typically at the bottom of the window) would leave them stale.
+ e.repaintPromptUpperLines()
+
+ e.completer.Autocomplete()
+
+ // 3. Input Area Rendering
+ e.renderInputArea()
+
+ // 4. Helpers Rendering
+ // We clear everything below the input area to ensure that no artifacts
+ // from previous renders (like longer lines or helpers) remain visible.
+ //
+ // We need to move one row below the input, clear everything there, and
+ // come back. However, CUD (\x1b[1B) is a no-op on the last terminal
+ // row, so we check whether we're already at the bottom. If we are,
+ // there's nothing below to clear and we can skip. If we're not, we use
+ // CUD + clear + CUU to clean up artifacts from previous renders.
+ termHeight := term.GetLength()
+ atBottom := (e.startRows + e.lineRows) >= termHeight
+
+ if !atBottom {
+ term.MoveCursorDown(1)
+ term.MoveCursorBackwards(term.GetWidth())
+ term.WriteString(term.ClearScreenBelow)
+ term.MoveCursorUp(1)
+ term.MoveCursorForwards(e.lineCol)
+ }
+
+ e.renderHelpers()
+
+ // 5. Final Cursor Positioning
+ // The cursor is currently at the end of the input line (lineRows, lineCol).
+ // We need to move it to the actual cursor position (cursorRow, cursorCol).
+ if e.lineRows > e.cursorRow {
+ term.MoveCursorUp(e.lineRows - e.cursorRow)
+ }
+
+ term.MoveCursorBackwards(term.GetWidth())
+ term.MoveCursorForwards(e.cursorCol)
+
+ term.WriteString(term.ShowCursor)
+}
+
+// repaintPromptUpperLines reprints the upper lines of a multi-line prompt at
+// the (now settled) start row. Those lines are printed only once initially and
+// are not otherwise refreshed, so when the view scrolls -- typically when the
+// prompt sits at the bottom of the window -- they would be left stale or
+// overwritten (issue #98 / reeflective/console#78). The cursor is at the
+// input-line start on entry and is restored there on return.
+func (e *Engine) repaintPromptUpperLines() {
+ rows := e.prompt.PrimaryUsed()
+ if rows == 0 {
+ return
+ }
+
+ // Go to the first prompt row at column 0, repaint the upper lines (each
+ // ends in a newline, leaving us at column 0 of the last prompt-line row),
+ // then restore the cursor to the input-line start.
+ term.MoveCursorBackwards(term.GetWidth())
+ term.MoveCursorUp(rows)
+ e.prompt.UpperPrint()
+ term.MoveCursorForwards(e.startCols)
+}
+
+func (e *Engine) renderInputArea() {
+ e.displayLineRefactored()
+ e.renderMultilineIndicators()
+ e.renderRightPrompt()
+}
+
+func (e *Engine) renderHelpers() {
+ // 1. Check if we have anything to print.
+ hintRows := ui.CoordinatesHint(e.hint)
+ compMatches := e.completer.Matches()
+ compSkip := e.completer.DisplaySkipped()
+
+ // Refresh() already cleared below the input line before calling us,
+ // so no additional clear is needed here.
+
+ if hintRows == 0 && (compMatches == 0 || compSkip) {
+ e.hintRows = 0
+ e.compRows = 0
+
+ return
+ }
+
+ term.WriteString(term.NewlineReturn)
+
+ // 3. Display Hints
+ ui.DisplayHint(e.hint)
+ e.hintRows = ui.CoordinatesHint(e.hint)
+
+ // 4. Display Completions
+ if compMatches > 0 && !compSkip {
+ completion.Display(e.completer, e.AvailableHelperLines())
+ e.compRows = completion.Coordinates(e.completer)
+ } else {
+ e.completer.ResetUsedRows()
+ e.compRows = 0
+ }
+
+ // 5. Restore Cursor to the "bottom of input area"
+ // The cursor is currently at the bottom of the helpers.
+ // We need to move it back up to the line just below the input text.
+ term.MoveCursorUp(e.compRows)
+ term.MoveCursorUp(e.hintRows)
+ term.MoveCursorUp(1)
+
+ // We are now on the same row as the end of the input line,
+ // but at column 0. We need to move to e.lineCol.
+ term.MoveCursorForwards(e.lineCol)
+}
+
+func (e *Engine) renderRightPrompt() {
+ e.prompt.RightPrint(e.lineCol, true)
+
+ // Restore cursor to the end of the input line.
+ term.MoveCursorBackwards(term.GetWidth())
+ term.MoveCursorForwards(e.lineCol)
+}
+
+func (e *Engine) ensureIndicatorSpace() {
+ // Determine the width of the multiline indicator.
+ // We need to ensure that the indentation of the input line is at least
+ // as wide as the indicator, otherwise the indicator will overwrite the text
+ // on subsequent lines.
+ var indicatorWidth int
+ if e.opts.GetBool("multiline-column-numbered") {
+ indicatorWidth = len(strconv.Itoa(1)) + 1
+ } else {
+ indicatorWidth = 2
+ }
+
+ // Adjust indentation if the primary prompt is empty,
+ // because we will print a column indicator on the first line.
+ if e.prompt.LastUsed() == 0 && e.line.Lines() > 0 {
+ var indicator string
+ if e.opts.GetBool("multiline-column-numbered") {
+ indicator = fmt.Sprintf(color.FgBlackBright+"%d"+color.Reset+" ", 1)
+ } else {
+ indicator = ui.DefaultMultilineColumn
+ }
+
+ e.startCols += indicatorWidth
+ // Print the indicator on the first line.
+ term.WriteString(indicator)
+ } else if e.line.Lines() > 0 && e.startCols < indicatorWidth {
+ // If the prompt is shorter than the indicator, pad with spaces
+ // to ensure the input text starts aligned with subsequent lines
+ // and isn't overwritten by the indicator.
+ padding := indicatorWidth - e.startCols
+ term.Printf("%*s", padding, "")
+
+ e.startCols = indicatorWidth
+ }
+}
+
+func (e *Engine) ensureInputSpace() {
+ // The input area occupies lineRows+1 visual rows starting at startRows, and
+ // the redraw/helper machinery always steps one further row below it (e.g. the
+ // "move down 1, clear below, move up 1" sequences). When the prompt is
+ // rendered at the bottom of the window that trailing row does not exist: the
+ // terminal clamps our downward moves while the paired upward moves still
+ // travel, so the row bookkeeping drifts and the prompt's lines get
+ // overwritten/overlapped (issue #98, reeflective/console#78).
+ //
+ // startRows was just probed in computeCoordinates, so we can tell purely from
+ // it whether the area plus its trailing row runs past the bottom, and scroll
+ // the screen up by exactly the missing rows (adjusting startRows to match)
+ // without issuing another cursor-position query.
+ // Reserving space requires the cursor's absolute row, which only the
+ // cursor-position probe provides. When probing is disabled or unavailable
+ // (startRows < 1), we cannot detect the bottom of the window, so we skip
+ // this step -- the documented degraded behavior is that a prompt at the very
+ // bottom may overlap (see disable-cursor-position-probe).
+ if e.startRows < 1 {
+ return
+ }
+
+ reserve := e.lineRows + 1
+
+ deficit := (e.startRows + reserve) - term.GetLength()
+ if deficit <= 0 {
+ return
+ }
+
+ // We are at the input-line start. Drop to the bottom of the input area
+ // (clamped at the last row), emit newlines to scroll the screen up by the
+ // deficit, then climb back to the new input-line start.
+ term.MoveCursorDown(e.lineRows)
+
+ for range deficit {
+ term.WriteString(term.NewlineReturn)
+ }
+
+ e.startRows -= deficit
+
+ term.MoveCursorUp(reserve)
+ term.MoveCursorForwards(e.startCols)
+}
+
+func (e *Engine) displayLineRefactored() {
+ var line string
+ // Apply user-defined highlighter to the input line.
+ if e.highlighter != nil {
+ line = e.highlighter(*e.line)
+ } else {
+ line = string(*e.line)
+ }
+ // Highlight matching parenthesis
+ if e.opts.GetBool("blink-matching-paren") {
+ core.HighlightMatchers(e.selection)
+ defer core.ResetMatchers(e.selection)
+ }
+ // Apply visual selections highlighting if any
+ line = e.highlightLine([]rune(line), *e.selection)
+ suggestionAdded := false
+ // Get the subset of the suggested line to print.
+ if len(e.suggested) > e.line.Len() && e.opts.GetBool("history-autosuggest") {
+ line += color.Dim + color.Fmt(color.Fg+"242") + string(e.suggested[e.line.Len():]) + color.Reset
+ suggestionAdded = true
+ }
+
+ currentLine := string(*e.line)
+ if !suggestionAdded && e.inlineSuggestionApplies(currentLine) {
+ suffix := e.inlineSuggestion[len(currentLine):]
+ line += color.Dim + color.Fmt(color.Fg+"242") + suffix + color.Reset
+ }
+ // Format tabs as spaces, for consistent display
+ line = strutil.FormatTabs(line) + term.ClearLineAfter
+ // And display the line.
+ e.suggested.Set([]rune(line)...)
+ core.DisplayLine(&e.suggested, e.startCols)
+}
+
+func (e *Engine) renderMultilineIndicators() {
+ // Check if we have multiple lines to manage.
+ if e.line.Lines() == 0 {
+ return
+ }
+
+ // 1. Determine if we need to print columns.
+ columns := e.opts.GetBool("multiline-column") ||
+ e.opts.GetBool("multiline-column-numbered") ||
+ e.opts.GetString("multiline-column-custom") != ""
+ promptEmpty := e.prompt.LastUsed() == 0
+
+ // If no columns are requested and the prompt is not empty, we have nothing to do.
+ if !columns && !promptEmpty {
+ return
+ }
+
+ // 2. Move to the top of the input area (first line).
+ term.MoveCursorUp(e.lineRows)
+ term.MoveCursorBackwards(term.GetWidth())
+
+ // 3. Print the indicators for subsequent lines (1..N).
+ printedLines := 0
+ numbered := e.opts.GetBool("multiline-column-numbered")
+
+ // Indicators
+ pipe := ui.DefaultMultilineColumn
+
+ for i := 1; i <= e.line.Lines(); i++ {
+ term.WriteString("\n")
+
+ switch {
+ case numbered:
+ term.Printf(color.FgBlackBright+"%d"+color.Reset+" ", i+1)
+ case i == e.line.Lines():
+ e.prompt.SecondaryPrint()
+ default:
+ term.WriteString(pipe)
+ }
+
+ printedLines++
+ }
+
+ // 4. Return cursor to the bottom of the input area.
+ correction := e.lineRows - printedLines
+ if correction > 0 {
+ term.MoveCursorDown(correction)
+ }
+
+ // 5. Restore horizontal position to the end of the input text.
+ term.MoveCursorBackwards(term.GetWidth())
+ term.MoveCursorForwards(e.lineCol)
+}
diff --git a/readline/internal/display/render_test.go b/readline/internal/display/render_test.go
new file mode 100644
index 0000000..bf27702
--- /dev/null
+++ b/readline/internal/display/render_test.go
@@ -0,0 +1,194 @@
+//go:build unix
+
+package display_test
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/hinshun/vt10x"
+)
+
+// countLine reports how many of the screen's rows begin with prefix.
+func countLine(screen, prefix string) int {
+ var n int
+
+ for _, row := range strings.Split(screen, "\n") {
+ if strings.HasPrefix(row, prefix) {
+ n++
+ }
+ }
+
+ return n
+}
+
+// TestRenderPromptAndInput is the baseline end-to-end golden-screen test: boot
+// a shell under a PTY, check the prompt renders, type a line, check it appears
+// next to the prompt, then submit it and check it is accepted and returned.
+func TestRenderPromptAndInput(t *testing.T) {
+ c := newConsole(t, "PROMPT> ", 80, 24)
+
+ c.waitForScreen("PROMPT>")
+
+ c.send("hello world")
+ screen := c.waitForScreen("PROMPT> hello world")
+
+ firstLine := strings.SplitN(screen, "\n", 2)[0]
+ if got, want := strings.TrimRight(firstLine, " "), "PROMPT> hello world"; got != want {
+ t.Fatalf("first line misaligned:\n got: %q\n want: %q", got, want)
+ }
+
+ c.send("\r")
+ c.waitForScreen("[LINE:hello world]")
+}
+
+// TestRenderWrappingAlignment guards multi-row layout: with a well-behaved
+// terminal, a line that wraps onto a second row renders with the prompt exactly
+// once and the input flush against it.
+func TestRenderWrappingAlignment(t *testing.T) {
+ // 40-col terminal, 8-col prompt -> 32 text columns on the first row.
+ c := newConsole(t, "PROMPT> ", 40, 24)
+ c.waitForScreen("PROMPT>")
+
+ // Type 40 'x': 32 land on row 0 (after the prompt), 8 wrap to row 1.
+ c.send(strings.Repeat("x", 40))
+ screen := c.waitUntil(func(s string) bool {
+ return strings.Count(s, "x") >= 40
+ })
+
+ rows := strings.Split(screen, "\n")
+
+ if got := countLine(screen, "PROMPT>"); got != 1 {
+ t.Fatalf("prompt should render exactly once, got %d times:\n%s", got, screen)
+ }
+
+ if want := "PROMPT> " + strings.Repeat("x", 32); strings.TrimRight(rows[0], " ") != want {
+ t.Fatalf("row 0 wrong:\n got: %q\n want: %q", strings.TrimRight(rows[0], " "), want)
+ }
+
+ if want := strings.Repeat("x", 8); strings.TrimRight(rows[1], " ") != want {
+ t.Fatalf("row 1 (wrapped continuation) wrong:\n got: %q\n want: %q",
+ strings.TrimRight(rows[1], " "), want)
+ }
+}
+
+// TestRenderMultilinePromptAtBottom guards the bottom-of-window case: a
+// multi-line primary prompt rendered on the last row of the terminal must keep
+// all of its lines (including the input line) instead of overlapping them.
+//
+// Before ensureInputSpace reserved the prompt+input height, the terminal
+// scrolled underneath the prompt, the row bookkeeping drifted, and the prompt's
+// last (input) line was pushed off the bottom and lost.
+func TestRenderMultilinePromptAtBottom(t *testing.T) {
+ const rows = 10
+
+ // A 4-line prompt whose last line is the input line, placed on the very
+ // last row of the window (prefill pushes it down). The defect showed on the
+ // initial render, before any input.
+ c := startConsole(t, consoleConfig{
+ prompt: "L1\nL2\nL3\nL4> ",
+ cols: 20,
+ rows: rows,
+ prefill: rows - 1,
+ })
+
+ c.waitForScreen("L1")
+ time.Sleep(250 * time.Millisecond) // let the render settle
+
+ screen := c.screen()
+
+ // Every line of the prompt â crucially the last/input line â must survive.
+ for _, line := range []string{"L1", "L2", "L3", "L4>"} {
+ if !strings.Contains(screen, line) {
+ t.Fatalf("prompt line %q was lost when rendering at the bottom of the window:\n%s",
+ line, screen)
+ }
+ }
+}
+
+// TestRenderMultilinePromptWrapAtBottom guards the harder compound case: a
+// multi-line prompt at the bottom of the window whose input also wraps onto
+// extra rows. The prompt's upper lines (printed once, not otherwise refreshed)
+// must survive the scrolling instead of being lost.
+func TestRenderMultilinePromptWrapAtBottom(t *testing.T) {
+ const rows = 10
+
+ c := startConsole(t, consoleConfig{
+ prompt: "TOP\nP> ",
+ cols: 20,
+ rows: rows,
+ prefill: rows - 1,
+ })
+
+ c.waitForScreen("P>")
+
+ // Type enough to wrap the input onto a second/third visual row.
+ c.send(strings.Repeat("y", 50))
+ screen := c.waitUntil(func(s string) bool {
+ return strings.Count(s, "y") >= 50
+ })
+
+ for _, line := range []string{"TOP", "P> "} {
+ if !strings.Contains(screen, line) {
+ t.Fatalf("prompt line %q was lost when a multi-line prompt wrapped at the bottom:\n%s",
+ line, screen)
+ }
+ }
+}
+
+// TestCursorProbeEnabledByDefault confirms probing is on by default: a normal
+// render issues at least one "ESC[6n" cursor-position query.
+func TestCursorProbeEnabledByDefault(t *testing.T) {
+ c := newConsole(t, "PROMPT> ", 80, 24)
+ c.waitForScreen("PROMPT>")
+
+ // The prompt string is printed before the first probe, so poll for the
+ // query rather than checking immediately after the prompt appears.
+ deadline := time.Now().Add(3 * time.Second)
+ for c.probeQueries() == 0 && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ if c.probeQueries() == 0 {
+ t.Fatal("expected at least one ESC[6n cursor-position query with probing enabled")
+ }
+}
+
+// TestRenderWithCursorProbeDisabled verifies the cursor-position-probe option:
+// when turned off, the shell sends no "ESC[6n" query and renders correctly from
+// the printed prompt width alone â even against a terminal that would have lied
+// about the cursor position. This is the supported fallback for PTY harnesses
+// and constrained terminals (#101).
+func TestRenderWithCursorProbeDisabled(t *testing.T) {
+ // This responder would report a bogus column; with probing disabled it must
+ // never be consulted.
+ lying := func(vt10x.Cursor) string { return "\x1b[1;1R" }
+
+ c := startConsole(t, consoleConfig{
+ prompt: "PROMPT> ",
+ cols: 40,
+ rows: 24,
+ noProbe: true,
+ probeReply: lying,
+ })
+ c.waitForScreen("PROMPT>")
+
+ c.send(strings.Repeat("x", 40)) // wrap to a second row
+ screen := c.waitUntil(func(s string) bool {
+ return strings.Count(s, "x") >= 40
+ })
+
+ if got := c.probeQueries(); got != 0 {
+ t.Fatalf("expected no ESC[6n queries when probing is disabled, got %d", got)
+ }
+
+ if got := countLine(screen, "PROMPT>"); got != 1 {
+ t.Fatalf("prompt should render exactly once with probing disabled, got %d:\n%s", got, screen)
+ }
+
+ row0 := strings.TrimRight(strings.SplitN(screen, "\n", 2)[0], " ")
+ if want := "PROMPT> " + strings.Repeat("x", 32); row0 != want {
+ t.Fatalf("row 0 misaligned with probing disabled:\n got: %q\n want: %q", row0, want)
+ }
+}
diff --git a/readline/internal/editor/buffers.go b/readline/internal/editor/buffers.go
index 5be2bcf..1378d0f 100644
--- a/readline/internal/editor/buffers.go
+++ b/readline/internal/editor/buffers.go
@@ -197,11 +197,13 @@ func (reg *Buffers) Reset() {
// Complete returns the contents of all buffers as a structured list of completions.
func (reg *Buffers) Complete() completion.Values {
- vals := make([]completion.Candidate, 0)
-
// Alpha and numbered registers
- vals = append(vals, reg.completeNumRegs()...)
- vals = append(vals, reg.completeAlphaRegs()...)
+ numRegs := reg.completeNumRegs()
+ alphaRegs := reg.completeAlphaRegs()
+
+ vals := make([]completion.Candidate, 0, len(numRegs)+len(alphaRegs))
+ vals = append(vals, numRegs...)
+ vals = append(vals, alphaRegs...)
// Disable sorting, force list long and add hint.
comps := completion.AddRaw(vals)
@@ -279,16 +281,17 @@ func (reg *Buffers) writeAlpha(register rune, buf []rune) {
}
func (reg *Buffers) completeNumRegs() []completion.Candidate {
- regs := make([]completion.Candidate, 0)
tag := color.Dim + "num ([0-9])" + color.Reset
- var nums []int
+ nums := make([]int, 0, len(reg.num))
for reg := range reg.num {
nums = append(nums, reg)
}
sort.Ints(nums)
+ regs := make([]completion.Candidate, 0, len(nums))
+
for _, num := range nums {
buf := reg.num[num]
display := strings.ReplaceAll(string(buf), "\n", ` `)
@@ -306,16 +309,17 @@ func (reg *Buffers) completeNumRegs() []completion.Candidate {
}
func (reg *Buffers) completeAlphaRegs() []completion.Candidate {
- regs := make([]completion.Candidate, 0)
tag := color.Dim + "alpha ([a-z], [A-Z])" + color.Reset
- var lett []rune
+ lett := make([]rune, 0, len(reg.alpha))
for slot := range reg.alpha {
lett = append(lett, slot)
}
sort.Slice(lett, func(i, j int) bool { return i < j })
+ regs := make([]completion.Candidate, 0, len(lett))
+
for _, letter := range lett {
buf := reg.alpha[letter]
display := strings.ReplaceAll(string(buf), "\n", ` `)
diff --git a/readline/internal/editor/editor.go b/readline/internal/editor/editor.go
index 2e23d04..981d1c3 100644
--- a/readline/internal/editor/editor.go
+++ b/readline/internal/editor/editor.go
@@ -4,7 +4,7 @@
package editor
import (
- "crypto/md5"
+ "crypto/sha256"
"encoding/hex"
"errors"
"fmt"
@@ -44,7 +44,8 @@ func writeToFile(buf []byte, filename string) (string, error) {
if filename == "" {
fileID := strconv.Itoa(time.Now().Nanosecond()) + ":" + string(buf)
- h := md5.New()
+ // Not security-sensitive: only used to derive a unique temp-file name.
+ h := sha256.New()
_, err := h.Write([]byte(fileID))
if err != nil {
diff --git a/readline/internal/editor/editor_unix.go b/readline/internal/editor/editor_unix.go
index a5cb2fc..84c11b1 100644
--- a/readline/internal/editor/editor_unix.go
+++ b/readline/internal/editor/editor_unix.go
@@ -3,6 +3,7 @@
package editor
import (
+ "context"
"errors"
"fmt"
"os"
@@ -27,12 +28,14 @@ func (reg *Buffers) EditBuffer(buf []rune, filename, filetype string, emacs bool
args := []string{}
if filetype != "" {
- args = append(args, fmt.Sprintf("-c 'set filetype=%s", filetype))
+ args = append(args, "-c 'set filetype="+filetype)
}
args = append(args, name)
- cmd := exec.Command(editor, args...)
+ // context.Background(): EditBuffer has no caller-supplied context to thread,
+ // and the editor runs for as long as the user keeps it open.
+ cmd := exec.CommandContext(context.Background(), editor, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
diff --git a/readline/internal/history/file.go b/readline/internal/history/file.go
index 58a884e..ae6ae29 100644
--- a/readline/internal/history/file.go
+++ b/readline/internal/history/file.go
@@ -30,6 +30,8 @@ type Item struct {
}
// NewSourceFromFile returns a new history source writing to and reading from a file.
+//
+//nolint:ireturn // Returns the Source interface by design: the concrete type is unexported.
func NewSourceFromFile(file string) (Source, error) {
var err error
diff --git a/readline/internal/history/history.go b/readline/internal/history/history.go
index 7220f54..b33d599 100644
--- a/readline/internal/history/history.go
+++ b/readline/internal/history/history.go
@@ -7,10 +7,10 @@ var defaultSourceName = "default history"
// logs the history to memory ([]string to be precise).
type Source interface {
// Append takes the line and returns an updated number of lines or an error
- Write(string) (int, error)
+ Write(line string) (int, error)
// GetLine takes the historic line number and returns the line or an error
- GetLine(int) (string, error)
+ GetLine(pos int) (string, error)
// Len returns the number of history lines
Len() int
@@ -31,6 +31,8 @@ type memory struct {
}
// NewInMemoryHistory creates a new in-memory command history source.
+//
+//nolint:ireturn // Returns the Source interface by design: the concrete type is unexported.
func NewInMemoryHistory() Source {
return new(memory)
}
diff --git a/readline/internal/history/sources.go b/readline/internal/history/sources.go
index e45dc10..0481eb9 100644
--- a/readline/internal/history/sources.go
+++ b/readline/internal/history/sources.go
@@ -280,6 +280,8 @@ func (h *Sources) OnLastSource() bool {
}
// Current returns the current/active history source.
+//
+//nolint:ireturn // Returns the Source interface by design: sources are stored as the interface.
func (h *Sources) Current() Source {
if len(h.list) == 0 {
return nil
@@ -308,9 +310,14 @@ func (h *Sources) Write(infer bool) {
continue
}
- // Don't write it if the history source has reached
- // the maximum number of lines allowed (inputrc)
- if h.maxEntries == 0 || h.maxEntries >= history.Len() {
+ // Don't write it if the history source has reached the maximum
+ // number of lines allowed (inputrc history-size). maxEntries <= 0
+ // means unlimited (the in-memory default is -1); a positive cap
+ // stops appending once the source is full. The previous condition
+ // was inverted -- it skipped every write while the source was still
+ // under the cap, so any configured history-size disabled writing
+ // entirely.
+ if h.maxEntries > 0 && history.Len() >= h.maxEntries {
continue
}
@@ -484,8 +491,13 @@ func Complete(h *Sources, forward, filter bool, maxLines int, regex *regexp.Rege
h.hint.Set(color.Bold + color.FgCyanBright + h.names[h.sourcePos] + color.Reset)
- compLines := make([]completion.Candidate, 0)
- printedLines := make([]string, 0)
+ capHint := history.Len()
+ if maxLines >= 0 && maxLines < capHint {
+ capHint = maxLines + 1
+ }
+
+ compLines := make([]completion.Candidate, 0, capHint)
+ seen := make(map[string]bool, capHint)
// Set up iteration clauses
var (
@@ -523,17 +535,14 @@ func Complete(h *Sources, forward, filter bool, maxLines int, regex *regexp.Rege
continue
}
- // If this history line is a duplicate of an existing one,
- // remove the existing one and keep this one as it's more recent.
- if yes, pos := contains(printedLines, line); yes {
- printedLines = append(printedLines[:pos], printedLines[pos+1:]...)
- printedLines = append(printedLines, line)
-
+ // Skip lines already emitted. The first occurrence in scan order (the
+ // most recent line, for a reverse scan) is the one kept, matching the
+ // previous linear-dedup behavior but in O(1) instead of O(n) per line.
+ if seen[line] {
continue
}
- // Add to the list of printed lines if we have a new one.
- printedLines = append(printedLines, line)
+ seen[line] = true
display := strings.ReplaceAll(line, "\n", ` `)
@@ -594,6 +603,13 @@ func (h *Sources) match(match *core.Line, cur *core.Cursor, usePos, fwd, regex b
histPos = history.Len() - h.hpos
}
+ // The string to match against is invariant across the whole scan, so
+ // compute it once here instead of rebuilding it for every history entry.
+ cline := string(*match)
+ if cur != nil && cur.Pos() < match.Len() {
+ cline = cline[:cur.Pos()]
+ }
+
for done(histPos) {
// Fetch the next/prev line and adapt its length.
histPos = move(histPos)
@@ -603,21 +619,12 @@ func (h *Sources) match(match *core.Line, cur *core.Cursor, usePos, fwd, regex b
return line, pos, found
}
- cline := string(*match)
- if cur != nil && cur.Pos() < match.Len() {
- cline = cline[:cur.Pos()]
- }
-
// Matching: either as substring (regex) or since beginning.
switch {
case regex:
- regexLine, err := regexp.Compile(regexp.QuoteMeta(cline))
- if err != nil {
- continue
- }
-
- // Go to next line if not matching as a substring.
- if !regexLine.MatchString(histline) {
+ // regexp.QuoteMeta + MatchString is exactly a literal substring
+ // test, so match directly instead of compiling a regex per entry.
+ if !strings.Contains(histline, cline) {
continue
}
@@ -688,25 +695,3 @@ func (h *Sources) setLineCursorMatch(next string) {
h.cursor.Set(h.line.Len())
}
}
-
-func contains(s []string, e string) (bool, int) {
- for i, a := range s {
- if a == e {
- return true, i
- }
- }
-
- return false, 0
-}
-
-func removeDuplicates(source []string) []string {
- list := []string{}
-
- for _, item := range source {
- if no, _ := contains(list, item); no {
- list = append(list, item)
- }
- }
-
- return list
-}
diff --git a/readline/internal/history/sources_test.go b/readline/internal/history/sources_test.go
new file mode 100644
index 0000000..ed6958c
--- /dev/null
+++ b/readline/internal/history/sources_test.go
@@ -0,0 +1,85 @@
+package history
+
+import (
+ "testing"
+
+ "github.com/chainreactors/tui/readline/internal/core"
+)
+
+// newTestSources builds a minimal Sources backed by a single in-memory source,
+// enough to exercise Write's cap logic without a full shell.
+func newTestSources(maxEntries int) (*Sources, *memory, *core.Line) {
+ line := new(core.Line)
+ mem := new(memory)
+
+ src := &Sources{
+ line: line,
+ list: map[string]Source{defaultSourceName: mem},
+ names: []string{defaultSourceName},
+ maxEntries: maxEntries,
+ }
+
+ return src, mem, line
+}
+
+// writeLine sets the working line and writes it to history.
+func writeLine(src *Sources, line *core.Line, text string) {
+ *line = core.Line([]rune(text))
+
+ src.Write(false)
+}
+
+// TestWriteUnlimitedPersists guards the default (history-size unset -> -1):
+// every distinct line must be written.
+func TestWriteUnlimitedPersists(t *testing.T) {
+ src, mem, line := newTestSources(-1)
+
+ writeLine(src, line, "one")
+ writeLine(src, line, "two")
+ writeLine(src, line, "three")
+
+ if mem.Len() != 3 {
+ t.Fatalf("unlimited history wrote %d lines, want 3", mem.Len())
+ }
+}
+
+// TestWriteUnderCapPersists is the direct regression guard for the inverted
+// condition: with a positive cap and the source still under it, writes MUST
+// land. The old `maxEntries >= Len()` skipped them all.
+func TestWriteUnderCapPersists(t *testing.T) {
+ src, mem, line := newTestSources(5)
+
+ writeLine(src, line, "one")
+ writeLine(src, line, "two")
+
+ if mem.Len() != 2 {
+ t.Fatalf("capped history under the limit wrote %d lines, want 2 (inverted-condition regression)", mem.Len())
+ }
+}
+
+// TestWriteStopsAtCap verifies the cap is actually enforced: once the source
+// holds maxEntries lines, further distinct lines are not appended.
+func TestWriteStopsAtCap(t *testing.T) {
+ src, mem, line := newTestSources(2)
+
+ writeLine(src, line, "one")
+ writeLine(src, line, "two")
+ writeLine(src, line, "three") // should be refused: already at cap
+
+ if mem.Len() != 2 {
+ t.Fatalf("history grew to %d lines past its cap of 2", mem.Len())
+ }
+}
+
+// TestWriteZeroMaxIsUnlimited documents that maxEntries == 0 is treated as
+// unlimited (writes happen), matching the `> 0` guard.
+func TestWriteZeroMaxIsUnlimited(t *testing.T) {
+ src, mem, line := newTestSources(0)
+
+ writeLine(src, line, "one")
+ writeLine(src, line, "two")
+
+ if mem.Len() != 2 {
+ t.Fatalf("maxEntries==0 wrote %d lines, want 2 (should be unlimited)", mem.Len())
+ }
+}
diff --git a/readline/internal/keymap/completion.go b/readline/internal/keymap/completion.go
index 1f0c024..4033feb 100644
--- a/readline/internal/keymap/completion.go
+++ b/readline/internal/keymap/completion.go
@@ -1,7 +1,7 @@
package keymap
import (
- "golang.org/x/exp/slices"
+ "slices"
"github.com/chainreactors/tui/readline/inputrc"
)
diff --git a/readline/internal/keymap/config.go b/readline/internal/keymap/config.go
index 71e4662..582f1da 100644
--- a/readline/internal/keymap/config.go
+++ b/readline/internal/keymap/config.go
@@ -26,6 +26,13 @@ var readlineOptions = map[string]interface{}{
"history-autosuggest": false,
"multiline-column": true,
"multiline-column-numbered": false,
+
+ // Terminal
+ // cursor-position-probe controls whether the display engine queries the
+ // terminal for the cursor position ("ESC[6n"). Disable it in environments
+ // that don't reliably answer (PTY test harnesses, minimal emulators,
+ // constrained CI); the engine then falls back to the printed prompt width.
+ "cursor-position-probe": true,
}
// ReloadConfig parses all valid .inputrc configurations and immediately
@@ -41,16 +48,17 @@ func (m *Engine) ReloadConfig(opts ...inputrc.Option) (err error) {
//
// This library implements various additional commands and keymaps.
// Parse the configuration with a specific App name, ignoring errors.
- inputrc.UserDefault(user, m.config, inputrc.WithApp("go"))
+ _ = inputrc.UserDefault(user, m.config, inputrc.WithApp("go"))
// Parse user configurations.
//
// Those default settings are the base options often needed
// by /etc/inputrc on various Linux distros (for special keys).
- defaults := []inputrc.Option{
+ defaults := make([]inputrc.Option, 0, 2+len(opts))
+ defaults = append(defaults,
inputrc.WithMode("emacs"),
inputrc.WithTerm(os.Getenv("TERM")),
- }
+ )
opts = append(defaults, opts...)
@@ -83,7 +91,7 @@ func (m *Engine) ReloadConfig(opts ...inputrc.Option) (err error) {
func (m *Engine) loadBuiltinOptions() {
for name, value := range readlineOptions {
if val := m.config.Get(name); val == nil {
- m.config.Set(name, value)
+ _ = m.config.Set(name, value)
}
}
}
@@ -143,7 +151,7 @@ func printBindsReadable(commands []string, all map[string][]string) {
switch {
case len(commandBinds) == 0:
case len(commandBinds) > 5:
- var firstBinds []string
+ firstBinds := make([]string, 0, 5)
for i := 0; i < 5; i++ {
firstBinds = append(firstBinds, "\""+commandBinds[i]+"\"")
diff --git a/readline/internal/keymap/cursor.go b/readline/internal/keymap/cursor.go
index 380057e..b01b5c7 100644
--- a/readline/internal/keymap/cursor.go
+++ b/readline/internal/keymap/cursor.go
@@ -59,14 +59,14 @@ func (m *Engine) PrintCursor(keymap Mode) {
modeSet := strings.TrimSpace(m.config.GetString(cursorOptname))
if _, valid := cursors[CursorStyle(modeSet)]; valid {
- term.Print(cursors[CursorStyle(modeSet)])
+ term.WriteString(cursors[CursorStyle(modeSet)])
return
}
if defaultCur, valid := defaultCursors[keymap]; valid {
- term.Print(cursors[defaultCur])
+ term.WriteString(cursors[defaultCur])
return
}
- term.Print(cursors[cursor])
+ term.WriteString(cursors[cursor])
}
diff --git a/readline/internal/keymap/dispatch.go b/readline/internal/keymap/dispatch.go
index d977a8c..946c4b2 100644
--- a/readline/internal/keymap/dispatch.go
+++ b/readline/internal/keymap/dispatch.go
@@ -187,7 +187,7 @@ func (m *Engine) matchBind(keys []byte, binds map[string]inputrc.Bind) (inputrc.
var prefixed []inputrc.Bind
// Make a sorted list with all keys in the binds map.
- var sequences []string
+ sequences := make([]string, 0, len(binds))
for sequence := range binds {
sequences = append(sequences, sequence)
}
@@ -275,6 +275,7 @@ func (m *Engine) handleEscape(main bool) (bind inputrc.Bind, cmd func(), pref bo
func (m *Engine) makeMatch(active, prefixed inputrc.Bind) (prefix bool) {
m.active = active
m.prefixed = prefixed
+
return m.prefixed.Action != ""
}
diff --git a/readline/internal/keymap/dispatch_test.go b/readline/internal/keymap/dispatch_test.go
new file mode 100644
index 0000000..6ccc326
--- /dev/null
+++ b/readline/internal/keymap/dispatch_test.go
@@ -0,0 +1,101 @@
+package keymap
+
+import (
+ "testing"
+
+ "github.com/chainreactors/tui/readline/inputrc"
+ "github.com/chainreactors/tui/readline/internal/core"
+ "github.com/chainreactors/tui/readline/internal/strutil"
+)
+
+// builtinBindMaps returns every keymap exactly as the dispatcher sees it after
+// a default engine is built (GNU defaults + this library's builtin overlays).
+func builtinBindMaps(t *testing.T) map[string]map[string]inputrc.Bind {
+ t.Helper()
+
+ _, cfg := NewEngine(&core.Keys{}, &core.Iterations{})
+
+ return cfg.Binds
+}
+
+// collisions returns, for a keymap, the ConvertMeta key bytes that more than one
+// sequence maps to with DIFFERING binds â inputs whose exact match would depend
+// on iteration order if matchBind did not impose a deterministic order.
+func collisions(binds map[string]inputrc.Bind) map[string][]inputrc.Bind {
+ bySeq := make(map[string][]inputrc.Bind)
+
+ for sequence, bind := range binds {
+ seq := strutil.ConvertMeta([]rune(sequence))
+ bySeq[seq] = append(bySeq[seq], bind)
+ }
+
+ out := make(map[string][]inputrc.Bind)
+
+ for seq, list := range bySeq {
+ differ := false
+
+ for _, b := range list[1:] {
+ if b != list[0] {
+ differ = true
+
+ break
+ }
+ }
+
+ if differ {
+ out[seq] = list
+ }
+ }
+
+ return out
+}
+
+// TestMatchBindSortIsLoadBearing documents why matchBind must impose a
+// deterministic order: the default keymaps DO contain sequences that collapse
+// (via ConvertMeta) to the same key bytes with different actions, e.g. ESC-prefixed
+// meta bindings overlapping a self-insert. With those collisions present, dropping
+// the ordering (as a naive optimization would) makes the exact `match` depend on
+// Go's randomized map iteration â i.e. nondeterministic keybind resolution. If
+// this ever reports zero collisions, the determinism requirement can be revisited.
+func TestMatchBindSortIsLoadBearing(t *testing.T) {
+ binds := builtinBindMaps(t)[string(Emacs)]
+
+ cols := collisions(binds)
+ if len(cols) == 0 {
+ t.Fatal("expected ConvertMeta collisions in the emacs keymap; if truly none, matchBind no longer needs to order its matches")
+ }
+
+ t.Logf("emacs keymap has %d colliding key sequences whose exact match is disambiguated only by matchBind's ordering", len(cols))
+}
+
+// TestMatchBindResolvesDeterministically guards the property the ordering buys:
+// for a colliding input, matchBind must return the SAME exact match on every
+// call despite map iteration randomness. Runs many times so a regression to
+// unordered iteration would be caught.
+func TestMatchBindResolvesDeterministically(t *testing.T) {
+ eng := &Engine{}
+ binds := builtinBindMaps(t)[string(Emacs)]
+
+ cols := collisions(binds)
+ if len(cols) == 0 {
+ t.Skip("no collisions to probe")
+ }
+
+ // Pick one colliding input deterministically (smallest key bytes).
+ var probe string
+
+ for seq := range cols {
+ if probe == "" || seq < probe {
+ probe = seq
+ }
+ }
+
+ want, _ := eng.matchBind([]byte(probe), binds)
+
+ for range 64 {
+ got, _ := eng.matchBind([]byte(probe), binds)
+ if got != want {
+ t.Fatalf("matchBind(%q) is nondeterministic: got %+v, first saw %+v", probe, got, want)
+ }
+ }
+}
diff --git a/readline/internal/keymap/emacs.go b/readline/internal/keymap/emacs.go
index 62851f9..db59d7a 100644
--- a/readline/internal/keymap/emacs.go
+++ b/readline/internal/keymap/emacs.go
@@ -6,8 +6,12 @@ var unescape = inputrc.Unescape
// emacsKeys are the default keymaps in Emacs mode.
var emacsKeys = map[string]inputrc.Bind{
- unescape(`\C-D`): {Action: "end-of-file"},
- unescape(`\C-h`): {Action: "backward-kill-word"},
+ unescape(`\C-D`): {Action: "end-of-file"},
+ // NOTE: \C-h is deliberately NOT overridden here. Many terminals send ^H
+ // for Backspace, and the GNU default (inputrc DefaultBinds) binds \C-h to
+ // backward-delete-char. Binding it to backward-kill-word made Backspace
+ // delete a whole word on those terminals; leaving it unset restores the
+ // standard char-delete behaviour.
unescape(`\C-N`): {Action: "down-line-or-history"},
unescape(`\C-P`): {Action: "up-line-or-history"},
unescape(`\C-x\C-b`): {Action: "vi-match"},
diff --git a/readline/internal/keymap/emacs_test.go b/readline/internal/keymap/emacs_test.go
new file mode 100644
index 0000000..f6d5f53
--- /dev/null
+++ b/readline/internal/keymap/emacs_test.go
@@ -0,0 +1,29 @@
+package keymap
+
+import (
+ "testing"
+
+ "github.com/chainreactors/tui/readline/inputrc"
+ "github.com/chainreactors/tui/readline/internal/core"
+)
+
+// TestEmacsBackspaceDeletesChar guards the \C-h binding: on many terminals
+// Backspace sends ^H, so \C-h must resolve to backward-delete-char (the GNU
+// default), not backward-kill-word, which would delete a whole word on every
+// Backspace. The emacsKeys overlay must therefore NOT override the default.
+func TestEmacsBackspaceDeletesChar(t *testing.T) {
+ keys := &core.Keys{}
+ iters := &core.Iterations{}
+
+ _, cfg := NewEngine(keys, iters)
+
+ got := cfg.Binds[string(Emacs)][inputrc.Unescape(`\C-h`)].Action
+
+ if got == "backward-kill-word" {
+ t.Fatal(`\C-h is bound to backward-kill-word: Backspace would delete a whole word on terminals that send ^H`)
+ }
+
+ if got != "backward-delete-char" {
+ t.Fatalf(`\C-h resolved to %q, want "backward-delete-char"`, got)
+ }
+}
diff --git a/readline/internal/keymap/engine.go b/readline/internal/keymap/engine.go
index 1764d70..a79f08e 100644
--- a/readline/internal/keymap/engine.go
+++ b/readline/internal/keymap/engine.go
@@ -36,7 +36,7 @@ func NewEngine(keys *core.Keys, i *core.Iterations, opts ...inputrc.Option) (*En
}
// Load the inputrc configurations and set up related things.
- modes.ReloadConfig(opts...)
+ _ = modes.ReloadConfig(opts...)
return modes, modes.config
}
@@ -130,7 +130,7 @@ func (m *Engine) IsEmacs() bool {
// to the screen. If inputrcFormat is true, it displays it formatted such that
// the output can be reused in an .inputrc file.
func (m *Engine) PrintBinds(keymap string, inputrcFormat bool) {
- var commands []string
+ commands := make([]string, 0, len(m.commands))
for command := range m.commands {
commands = append(commands, command)
@@ -180,7 +180,7 @@ func (m *Engine) InputIsTerminator() bool {
binds[sequence] = inputrc.Bind{Action: "abort", Macro: false}
}
- bind, _, _, _ := m.dispatchKeys(binds)
+ bind, _, _, _ := m.dispatchKeys(binds) //nolint:dogsled // Only the resolved bind is needed here.
return bind.Action == "abort"
}
diff --git a/readline/internal/keymap/pending.go b/readline/internal/keymap/pending.go
index 888111e..eaba7ac 100644
--- a/readline/internal/keymap/pending.go
+++ b/readline/internal/keymap/pending.go
@@ -1,17 +1,5 @@
package keymap
-import "github.com/chainreactors/tui/readline/inputrc"
-
-// action is represents the action of a widget, the number of times
-// this widget needs to be run, and an optional operator argument.
-// Most of the time we don't need this operator.
-//
-// Those actions are mostly used by widgets which make the shell enter
-// the Vim operator pending mode, and thus require another key to be read.
-type action struct {
- command inputrc.Bind
-}
-
// Pending registers a command as waiting for another command to run first,
// such as yank/delete/change actions, which accept/require a movement command.
func (m *Engine) Pending() {
diff --git a/readline/internal/macro/engine.go b/readline/internal/macro/engine.go
index 9f3f474..194d342 100644
--- a/readline/internal/macro/engine.go
+++ b/readline/internal/macro/engine.go
@@ -158,7 +158,7 @@ func (e *Engine) PrintLastMacro() {
// PrintAllMacros dumps all macros to the screen, which one line
// per saved macro sequence, next to its corresponding key ID.
func (e *Engine) PrintAllMacros() {
- var macroIDs []rune
+ macroIDs := make([]rune, 0, len(e.macros))
for key := range e.macros {
macroIDs = append(macroIDs, key)
diff --git a/readline/internal/strutil/key.go b/readline/internal/strutil/key.go
index 5f1d4d9..f424baf 100644
--- a/readline/internal/strutil/key.go
+++ b/readline/internal/strutil/key.go
@@ -9,9 +9,9 @@ func ConvertMeta(keys []rune) string {
return string(keys)
}
- converted := make([]rune, 0)
+ converted := make([]rune, 0, len(keys))
- for i := 0; i < len(keys); i++ {
+ for i := range keys {
char := keys[i]
if !inputrc.IsMeta(char) {
diff --git a/readline/internal/strutil/keyword.go b/readline/internal/strutil/keyword.go
index 72ee969..7063671 100644
--- a/readline/internal/strutil/keyword.go
+++ b/readline/internal/strutil/keyword.go
@@ -112,7 +112,7 @@ func switchHexa(word string, inc int) (done bool, switched string, bpos, epos in
match := hexadecimal.FindString(word)
if match == "" {
- return
+ return done, switched, bpos, epos
}
done = true
@@ -131,7 +131,7 @@ func switchHexa(word string, inc int) (done bool, switched string, bpos, epos in
num, err := strconv.ParseInt(hexVal, 16, 64)
if err != nil {
done = false
- return
+ return done, switched, bpos, epos
}
max64Bit := big.NewInt(maxInt)
@@ -185,7 +185,7 @@ func switchBinary(word string, inc int) (done bool, switched string, bpos, epos
match := binary.FindString(word)
if match == "" {
- return
+ return done, switched, bpos, epos
}
done = true
@@ -200,7 +200,7 @@ func switchBinary(word string, inc int) (done bool, switched string, bpos, epos
num, err := strconv.ParseInt(binVal, 2, 64)
if err != nil {
done = false
- return
+ return done, switched, bpos, epos
}
max64Bit := big.NewInt(maxInt)
@@ -305,7 +305,7 @@ func switchBoolean(word string, _ bool, _ int) (done bool, switched string, bpos
switched, done = booleans[strings.ToLower(word)]
if !done {
- return
+ return done, switched, bpos, epos
}
done = true
@@ -322,7 +322,7 @@ func switchBoolean(word string, _ bool, _ int) (done bool, switched string, bpos
return done, switched, bpos, epos
}
-func switchWeekday(word string, inc bool, _ int) (done bool, switched string, bpos, epos int) {
+func switchWeekday(_ string, _ bool, _ int) (done bool, switched string, bpos, epos int) {
return
}
@@ -348,7 +348,7 @@ func switchOperator(word string, _ bool, _ int) (done bool, switched string, bpo
switched, done = operators[strings.ToLower(word)]
if !done {
- return
+ return done, switched, bpos, epos
}
done = true
diff --git a/readline/internal/strutil/split.go b/readline/internal/strutil/split.go
index 1541a28..e154abf 100644
--- a/readline/internal/strutil/split.go
+++ b/readline/internal/strutil/split.go
@@ -49,8 +49,9 @@ func Split(input string) (words []string, err error) {
next := input[l:]
if len(next) == 0 {
err = errUnterminatedEscape
- return
+ return words, err
}
+
c2, l2 := utf8.DecodeRuneInString(next)
if c2 == '\n' {
input = next[l2:]
@@ -59,13 +60,16 @@ func Split(input string) (words []string, err error) {
}
var word string
+
word, input, err = splitWord(input, &buf)
if err != nil {
- return
+ return words, err
}
+
words = append(words, word)
}
- return
+
+ return words, err
}
func splitWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) {
@@ -77,19 +81,20 @@ raw:
for len(cur) > 0 {
c, l := utf8.DecodeRuneInString(cur)
cur = cur[l:]
- if c == singleChar {
+ switch {
+ case c == singleChar:
buf.WriteString(input[0 : len(input)-len(cur)-l])
input = cur
goto single
- } else if c == doubleChar {
+ case c == doubleChar:
buf.WriteString(input[0 : len(input)-len(cur)-l])
input = cur
goto double
- } else if c == escapeChar {
+ case c == escapeChar:
buf.WriteString(input[0 : len(input)-len(cur)-l])
input = cur
goto escape
- } else if strings.ContainsRune(splitChars, c) {
+ case strings.ContainsRune(splitChars, c):
buf.WriteString(input[0 : len(input)-len(cur)-l])
return buf.String(), cur, nil
}
@@ -107,13 +112,13 @@ escape:
return "", "", errUnterminatedEscape
}
c, l := utf8.DecodeRuneInString(input)
- if c == '\n' {
- // a backslash-escaped newline is elided from the output entirely
- } else {
+ // A backslash-escaped newline is elided from the output entirely.
+ if c != '\n' {
buf.WriteString(input[:l])
}
input = input[l:]
}
+
goto raw
single:
@@ -133,19 +138,19 @@ double:
for len(cur) > 0 {
c, l := utf8.DecodeRuneInString(cur)
cur = cur[l:]
- if c == doubleChar {
+ switch c {
+ case doubleChar:
buf.WriteString(input[0 : len(input)-len(cur)-l])
input = cur
goto raw
- } else if c == escapeChar {
+ case escapeChar:
// bash only supports certain escapes in double-quoted strings
c2, l2 := utf8.DecodeRuneInString(cur)
cur = cur[l2:]
if strings.ContainsRune(doubleEscapeChars, c2) {
buf.WriteString(input[0 : len(input)-len(cur)-l-l2])
- if c2 == '\n' {
- // newline is special, skip the backslash entirely
- } else {
+ // A newline is special: skip the backslash entirely (write nothing).
+ if c2 != '\n' {
buf.WriteRune(c2)
}
input = cur
diff --git a/readline/internal/strutil/surround_test.go b/readline/internal/strutil/surround_test.go
new file mode 100644
index 0000000..7d6aa64
--- /dev/null
+++ b/readline/internal/strutil/surround_test.go
@@ -0,0 +1,95 @@
+package strutil
+
+import (
+ "testing"
+)
+
+func TestGetQuotedWordStart(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ wantUnclosed bool
+ wantPos int
+ }{
+ {
+ name: "Empty",
+ line: "",
+ wantUnclosed: false,
+ wantPos: -1,
+ },
+ {
+ name: "Single word",
+ line: "word",
+ wantUnclosed: false,
+ wantPos: -1,
+ },
+ {
+ name: "Unclosed double",
+ line: "\"word",
+ wantUnclosed: true,
+ wantPos: 0,
+ },
+ {
+ name: "Closed double",
+ line: "\"word\"",
+ wantUnclosed: false,
+ wantPos: -1, // Or whatever dpos is left at? dpos tracks OPENING.
+ // If closed, inDouble is false. Returns false, -1.
+ },
+ {
+ name: "Unclosed single",
+ line: "'word",
+ wantUnclosed: true,
+ wantPos: 0,
+ },
+ {
+ name: "Escaped quote in double",
+ line: "\"word \\\"",
+ wantUnclosed: true,
+ wantPos: 0,
+ },
+ {
+ name: "Escaped quote in single (literal)",
+ line: "'word \\'",
+ wantUnclosed: false,
+ wantPos: -1,
+ },
+ {
+ name: "Nested quotes (single in double)",
+ line: "\"'\"",
+ wantUnclosed: false,
+ wantPos: -1,
+ },
+ {
+ name: "Nested quotes (double in single)",
+ line: "'\"'",
+ wantUnclosed: false,
+ wantPos: -1,
+ },
+ {
+ name: "Balanced nested",
+ line: "\"'hello'\"",
+ wantUnclosed: false,
+ wantPos: -1,
+ },
+ {
+ name: "Multiple words unclosed",
+ line: "hello \"world",
+ wantUnclosed: true,
+ wantPos: 6,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ unclosed, pos := GetQuotedWordStart([]rune(tt.line))
+ if unclosed != tt.wantUnclosed {
+ t.Errorf("GetQuotedWordStart() unclosed = %v, want %v", unclosed, tt.wantUnclosed)
+ }
+
+ if unclosed && pos != tt.wantPos {
+ t.Errorf("GetQuotedWordStart() pos = %v, want %v", pos, tt.wantPos)
+ }
+ })
+ }
+}
diff --git a/readline/internal/term/codes.go b/readline/internal/term/codes.go
index 359ea06..8e3b54e 100644
--- a/readline/internal/term/codes.go
+++ b/readline/internal/term/codes.go
@@ -16,6 +16,9 @@ const (
RestoreCursorPos = "\x1b8"
HideCursor = "\x1b[?25l"
ShowCursor = "\x1b[?25h"
+
+ BracketedPasteStart = "\x1b[?2004h"
+ BracketedPasteEnd = "\x1b[?2004l"
)
// Some core keys needed by some stuff.
diff --git a/readline/internal/term/output.go b/readline/internal/term/output.go
new file mode 100644
index 0000000..b250876
--- /dev/null
+++ b/readline/internal/term/output.go
@@ -0,0 +1,106 @@
+package term
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "sync"
+)
+
+// renderOut, when non-nil, overrides the destination for rendered terminal
+// output. It is a test seam; in production it stays nil and output goes to the
+// active terminal output. Output is distinct from termFile (stderr), used only
+// for terminal *queries* (size, cursor position).
+var renderOut io.Writer
+
+func out() io.Writer {
+ if renderOut != nil {
+ return renderOut
+ }
+
+ return Output()
+}
+
+// Output buffering lets a whole frame (one Refresh) be coalesced into a single
+// write to the terminal instead of dozens of small writes. This removes the
+// inter-write flicker a terminal can show mid-frame and cuts write syscalls per
+// keystroke from many to one.
+//
+// All rendering happens on the readline goroutine, so this state is effectively
+// single-threaded; the mutex is a cheap guard against accidental concurrent use
+// (e.g. a stray write from another goroutine) rather than a hot contention path.
+var (
+ outMu sync.Mutex
+ outDepth int
+ outBuf *bufio.Writer
+)
+
+// BeginBuffer starts buffering terminal writes. Calls nest: only the outermost
+// BeginBuffer/EndBuffer pair allocates and flushes the buffer, so render
+// entrypoints can be wrapped independently without coordinating.
+func BeginBuffer() {
+ outMu.Lock()
+ defer outMu.Unlock()
+
+ outDepth++
+ if outDepth == 1 {
+ outBuf = bufio.NewWriterSize(out(), 64*1024)
+ }
+}
+
+// EndBuffer flushes and tears down the buffer when leaving the outermost frame.
+// It is safe to call unmatched (it no-ops at depth 0), so it can be deferred
+// even if BeginBuffer is on an early-return path.
+func EndBuffer() {
+ outMu.Lock()
+ defer outMu.Unlock()
+
+ if outDepth == 0 {
+ return
+ }
+
+ outDepth--
+
+ if outDepth == 0 && outBuf != nil {
+ _ = outBuf.Flush()
+ outBuf = nil
+ }
+}
+
+// FlushBuffer pushes everything buffered so far to the terminal without ending
+// the frame. It must be called before any operation that READS the terminal and
+// depends on prior output being on screen â chiefly an ESC[6n cursor-position
+// query (see GetCursorPos): otherwise the prompt would still sit in the buffer
+// and the reported cursor position would be wrong.
+func FlushBuffer() {
+ outMu.Lock()
+ defer outMu.Unlock()
+
+ if outBuf != nil {
+ _ = outBuf.Flush()
+ }
+}
+
+// WriteString writes s to the terminal: into the frame buffer when one is
+// active, otherwise straight to the output. Empty strings are dropped so callers
+// need not guard.
+func WriteString(s string) {
+ if s == "" {
+ return
+ }
+
+ outMu.Lock()
+ defer outMu.Unlock()
+
+ if outDepth > 0 && outBuf != nil {
+ _, _ = io.WriteString(outBuf, s)
+ return
+ }
+
+ _, _ = io.WriteString(out(), s)
+}
+
+// Printf formats and writes to the terminal, honouring the frame buffer.
+func Printf(format string, a ...interface{}) {
+ WriteString(fmt.Sprintf(format, a...))
+}
diff --git a/readline/internal/term/output_test.go b/readline/internal/term/output_test.go
new file mode 100644
index 0000000..8f53ab5
--- /dev/null
+++ b/readline/internal/term/output_test.go
@@ -0,0 +1,126 @@
+package term
+
+import (
+ "bytes"
+ "testing"
+)
+
+// countWriter records how many times Write was called (i.e. how many syscalls a
+// real terminal would see) and accumulates the bytes for content assertions.
+type countWriter struct {
+ writes int
+ buf bytes.Buffer
+}
+
+func (c *countWriter) Write(p []byte) (int, error) {
+ c.writes++
+
+ return c.buf.Write(p)
+}
+
+// TestWriteStringDirect: with no active buffer, each non-empty WriteString is a
+// direct write, and empty strings are dropped.
+func TestWriteStringDirect(t *testing.T) {
+ cw := &countWriter{}
+ renderOut = cw
+
+ defer func() { renderOut = nil }()
+
+ WriteString("hello")
+ WriteString("") // dropped, no write
+ WriteString(" world")
+
+ if got := cw.buf.String(); got != "hello world" {
+ t.Fatalf("content = %q, want %q", got, "hello world")
+ }
+
+ if cw.writes != 2 {
+ t.Fatalf("direct writes = %d, want 2", cw.writes)
+ }
+}
+
+// TestBufferCoalescesToSingleWrite: a whole frame's worth of writes is held
+// until EndBuffer, then flushed as one write â the core flicker/syscall win.
+func TestBufferCoalescesToSingleWrite(t *testing.T) {
+ cw := &countWriter{}
+ renderOut = cw
+
+ defer func() { renderOut = nil }()
+
+ BeginBuffer()
+ WriteString("\x1b[?25l")
+ WriteString("prompt> ")
+ WriteString("line")
+
+ if cw.writes != 0 {
+ t.Fatalf("expected nothing on screen before EndBuffer, got %d writes", cw.writes)
+ }
+
+ EndBuffer()
+
+ if cw.writes != 1 {
+ t.Fatalf("expected a single coalesced write, got %d", cw.writes)
+ }
+
+ if got := cw.buf.String(); got != "\x1b[?25lprompt> line" {
+ t.Fatalf("content = %q", got)
+ }
+}
+
+// TestBufferNestingFlushesOnce: nested Begin/End pairs only flush at the
+// outermost End, so render entrypoints can wrap independently.
+func TestBufferNestingFlushesOnce(t *testing.T) {
+ cw := &countWriter{}
+ renderOut = cw
+
+ defer func() { renderOut = nil }()
+
+ BeginBuffer()
+ WriteString("a")
+ BeginBuffer() // nested
+ WriteString("b")
+ EndBuffer() // inner: must NOT flush
+
+ if cw.writes != 0 {
+ t.Fatalf("inner EndBuffer flushed early: %d writes", cw.writes)
+ }
+
+ WriteString("c")
+ EndBuffer() // outer: flush
+
+ if cw.writes != 1 {
+ t.Fatalf("expected one flush, got %d", cw.writes)
+ }
+
+ if got := cw.buf.String(); got != "abc" {
+ t.Fatalf("content = %q, want abc", got)
+ }
+}
+
+// TestFlushBufferMidFrame: FlushBuffer pushes what's buffered so far (e.g.
+// before an ESC[6n query) without ending the frame.
+func TestFlushBufferMidFrame(t *testing.T) {
+ cw := &countWriter{}
+ renderOut = cw
+
+ defer func() { renderOut = nil }()
+
+ BeginBuffer()
+ WriteString("prompt")
+ FlushBuffer() // the prompt must now be on screen before a cursor query
+
+ if cw.writes != 1 || cw.buf.String() != "prompt" {
+ t.Fatalf("FlushBuffer did not flush partial frame: writes=%d content=%q", cw.writes, cw.buf.String())
+ }
+
+ WriteString("line")
+ EndBuffer()
+
+ if cw.writes != 2 {
+ t.Fatalf("expected 2 writes (mid-flush + end), got %d", cw.writes)
+ }
+
+ if got := cw.buf.String(); got != "promptline" {
+ t.Fatalf("content = %q", got)
+ }
+}
diff --git a/readline/internal/term/term.go b/readline/internal/term/term.go
index d232fd1..e801bd1 100644
--- a/readline/internal/term/term.go
+++ b/readline/internal/term/term.go
@@ -10,51 +10,49 @@ import (
"sync"
)
-// Those variables are very important to realine low-level code: all virtual terminal
-// escape sequences should always be sent and read through the raw terminal file, even
-// if people start using io.MultiWriters and os.Pipes involving basic IO.
-var (
- stdoutTerm *os.File
- stdinTerm *os.File
- stderrTerm *os.File
-
- outputs sync.Map
- controls sync.Map
-)
-
-func init() {
- stdoutTerm = os.Stdout
- stdinTerm = os.Stdin
- stderrTerm = os.Stderr
-}
+// termFile is the file descriptor used for all low-level terminal queries (size,
+// cursor position) and escape sequences. We deliberately use stderr rather than
+// stdout: stdout is the stream most likely to be redirected (e.g. `app | other`),
+// whereas stderr usually stays attached to the controlling terminal, giving a
+// reliable terminal size even when the program's output is piped.
+var termFile = os.Stderr
// fallback terminal width when we can't get it through query.
var defaultTermWidth = 80
// Control is the terminal capability subset readline internals need.
type Control interface {
+ IsTerminal() bool
Size() (cols, rows int)
OnResize(func(cols, rows int)) func()
}
+var (
+ outputs sync.Map
+ controls sync.Map
+)
+
// Activate binds output/control to the current goroutine until the returned
// restore function is called.
func Activate(output io.Writer, control Control) func() {
id := goid()
oldOutput, hadOutput := outputs.Load(id)
oldControl, hadControl := controls.Load(id)
+
if output != nil {
outputs.Store(id, output)
}
if control != nil {
controls.Store(id, control)
}
+
return func() {
if hadOutput {
outputs.Store(id, oldOutput)
} else {
outputs.Delete(id)
}
+
if hadControl {
controls.Store(id, oldControl)
} else {
@@ -70,7 +68,8 @@ func Output() io.Writer {
return w
}
}
- return stdoutTerm
+
+ return os.Stdout
}
// CurrentControl returns the control bound to the current readline session.
@@ -80,6 +79,7 @@ func CurrentControl() Control {
return c
}
}
+
return nil
}
@@ -88,28 +88,22 @@ func Print(a ...interface{}) (int, error) {
return fmt.Fprint(Output(), a...)
}
-// Printf writes to the active terminal output.
-func Printf(format string, a ...interface{}) (int, error) {
- return fmt.Fprintf(Output(), format, a...)
-}
-
// Println writes to the active terminal output.
func Println(a ...interface{}) (int, error) {
return fmt.Fprintln(Output(), a...)
}
-// GetWidth returns the width of Stdout or 80 if the width cannot be established.
+// GetWidth returns the width of the terminal or 80 if it cannot be established.
func GetWidth() (termWidth int) {
- if control, ok := controls.Load(goid()); ok {
- if c, ok := control.(Control); ok && c != nil {
- width, _ := c.Size()
- if width > 0 {
- return width
- }
+ if control := CurrentControl(); control != nil {
+ width, _ := control.Size()
+ if width > 0 {
+ return width
}
}
+
var err error
- fd := int(stdoutTerm.Fd())
+ fd := int(termFile.Fd())
termWidth, _, err = GetSize(fd)
if err != nil || termWidth == 0 {
@@ -122,15 +116,14 @@ func GetWidth() (termWidth int) {
// GetLength returns the length of the terminal
// (Y length), or 80 if it cannot be established.
func GetLength() int {
- if control, ok := controls.Load(goid()); ok {
- if c, ok := control.(Control); ok && c != nil {
- _, length := c.Size()
- if length > 0 {
- return length
- }
+ if control := CurrentControl(); control != nil {
+ _, length := control.Size()
+ if length > 0 {
+ return length
}
}
- termFd := int(stdoutTerm.Fd())
+
+ termFd := int(termFile.Fd())
_, length, err := GetSize(termFd)
if err != nil || length == 0 {
@@ -140,19 +133,27 @@ func GetLength() int {
return length
}
+func printf(format string, a ...interface{}) {
+ WriteString(fmt.Sprintf(format, a...))
+}
+
// OnResize registers a resize callback on the active terminal control.
func OnResize(fn func(cols, rows int)) func() {
- if control, ok := controls.Load(goid()); ok {
- if c, ok := control.(Control); ok && c != nil {
- return c.OnResize(fn)
- }
+ if control := CurrentControl(); control != nil {
+ return control.OnResize(fn)
}
+
return func() {}
}
-func printf(format string, a ...interface{}) {
- s := fmt.Sprintf(format, a...)
- Print(s)
+// EnableBracketedPaste enables bracketed paste mode.
+func EnableBracketedPaste() {
+ WriteString(BracketedPasteStart)
+}
+
+// DisableBracketedPaste disables bracketed paste mode.
+func DisableBracketedPaste() {
+ WriteString(BracketedPasteEnd)
}
func goid() uint64 {
@@ -162,9 +163,11 @@ func goid() uint64 {
if len(fields) < 2 {
return 0
}
+
id, err := strconv.ParseUint(fields[1], 10, 64)
if err != nil {
return 0
}
+
return id
}
diff --git a/readline/internal/ui/hint.go b/readline/internal/ui/hint.go
index b9c317b..5f69870 100644
--- a/readline/internal/ui/hint.go
+++ b/readline/internal/ui/hint.go
@@ -2,8 +2,10 @@ package ui
import (
"strings"
+ "sync"
"github.com/chainreactors/tui/readline/internal/color"
+ "github.com/chainreactors/tui/readline/internal/core"
"github.com/chainreactors/tui/readline/internal/strutil"
"github.com/chainreactors/tui/readline/internal/term"
)
@@ -11,18 +13,65 @@ import (
// Hint is in charge of printing the usage messages below the input line.
// Various other UI components have access to it so that they can feed
// specialized usage messages to it, like completions.
+//
+// The hint area is composed of several independent lanes, rendered top to
+// bottom in the following order:
+//
+// - persistent: status set by internal commands (Vim registers, iterations).
+// - provided: passive hint computed from the current input line by a
+// user-registered hint provider (see Shell.SetHintProvider).
+// - transient: one-shot status messages, typically pushed from an
+// asynchronous producer (see Shell.SetTransientHint). These survive
+// keystrokes that drive the completion/isearch lane.
+// - text: hints owned by the completion and incremental-search engines,
+// plus temporary command messages.
+//
+// Keeping these in distinct lanes lets passive hinting, async status reporting
+// and completion hints coexist without clobbering one another.
+//
+// All fields are guarded by mu: the transient lane in particular may be written
+// from another goroutine while the main loop renders, so every accessor takes
+// the lock.
type Hint struct {
+ mu sync.RWMutex
text []rune
persistent []rune
+ provided []rune
+ transient []rune
cleanup bool
temp bool
set bool
+
+ // provider, when set, computes the passive (provided) hint from the current
+ // input line and cursor. It is re-evaluated on every refresh by the display
+ // engine through UpdateProvided.
+ provider func(line []rune, cursor int) []rune
+
+ // refresh, when set, wakes the render loop after an async lane change (e.g.
+ // SetTransient from another goroutine). Wired by the shell to the input wake
+ // primitive; integrators do not call it directly.
+ refresh func()
+}
+
+// NewHint creates a hint area wired to wake the render loop on asynchronous lane
+// changes (such as SetTransient called from another goroutine), through the
+// keys' input wake primitive.
+func NewHint(keys *core.Keys) *Hint {
+ hint := &Hint{}
+ if keys != nil {
+ hint.refresh = keys.RequestRefresh
+ }
+
+ return hint
}
// Set sets the hint message to the given text.
// Generally, this hint message will persist until either a command
// or the completion system overwrites it, or if hint.Reset() is called.
func (h *Hint) Set(hint string) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
h.text = []rune(hint)
h.set = true
}
@@ -30,6 +79,9 @@ func (h *Hint) Set(hint string) {
// SetTemporary sets a hint message that will be cleared at the next keypress
// or command being run, which generally coincides with the next redisplay.
func (h *Hint) SetTemporary(hint string) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
h.text = []rune(hint)
h.set = true
h.temp = true
@@ -38,11 +90,102 @@ func (h *Hint) SetTemporary(hint string) {
// Persist adds a hint message to be persistently
// displayed until hint.ResetPersist() is called.
func (h *Hint) Persist(hint string) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
h.persistent = []rune(hint)
}
+// SetProvided sets the passive (provider) hint lane directly. It is normally
+// recomputed from the current input line on every refresh (see SetProvider);
+// an empty string clears the lane. This lane renders below the persistent lane
+// and above the transient and completion lanes.
+func (h *Hint) SetProvided(hint string) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ h.provided = []rune(hint)
+}
+
+// SetProvider registers a function that computes the passive (provided) hint
+// from the current input line and cursor position. It returns the hint text to
+// show (return nil/empty for no hint). The provider is re-evaluated on every
+// refresh, so the hint tracks the input as it changes.
+//
+// This is the "passive/background hinting" lane: it renders above the transient
+// (async status) lane and the completion hints. Pass nil to remove the provider.
+func (h *Hint) SetProvider(provider func(line []rune, cursor int) []rune) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ h.provider = provider
+
+ if provider == nil {
+ h.provided = make([]rune, 0)
+ }
+}
+
+// UpdateProvided re-evaluates the registered provider (if any) against the given
+// line and cursor and stores the result in the passive (provided) lane. It is
+// called by the display engine on each refresh. The provider runs outside the
+// lock so it may safely query shell state.
+func (h *Hint) UpdateProvided(line []rune, cursor int) {
+ h.mu.RLock()
+ provider := h.provider
+ h.mu.RUnlock()
+
+ if provider == nil {
+ return
+ }
+
+ h.SetProvided(string(provider(line, cursor)))
+}
+
+// SetTransient sets the transient (async status) hint lane. It is safe to call
+// from any goroutine, and wakes an idle render loop so the message appears
+// immediately. The message persists until ClearTransient is called or it is
+// replaced; crucially it is NOT cleared by the completion or incremental search
+// engines. It renders above the completion lane and below the provider lane.
+func (h *Hint) SetTransient(hint string) {
+ h.mu.Lock()
+ h.transient = []rune(hint)
+ refresh := h.refresh
+ h.mu.Unlock()
+
+ if refresh != nil {
+ refresh()
+ }
+}
+
+// ClearTransient drops the transient (async status) hint lane. It is safe to
+// call from any goroutine, and wakes an idle render loop so the change is shown.
+func (h *Hint) ClearTransient() {
+ h.mu.Lock()
+ h.cleanup = h.cleanup || len(h.transient) > 0
+ h.transient = make([]rune, 0)
+ refresh := h.refresh
+ h.mu.Unlock()
+
+ if refresh != nil {
+ refresh()
+ }
+}
+
+// SetRefreshFunc registers a callback used to wake the render loop when an
+// async hint lane changes (e.g. SetTransient from another goroutine). The shell
+// wires this to the input wake primitive; integrators do not call it directly.
+func (h *Hint) SetRefreshFunc(refresh func()) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ h.refresh = refresh
+}
+
// Text returns the current hint text.
func (h *Hint) Text() string {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
return string(h.text)
}
@@ -51,11 +194,19 @@ func (h *Hint) Text() string {
// is an active hint, in which case they might want to append to
// it instead of overwriting it altogether (like in isearch mode).
func (h *Hint) Len() int {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
return len(h.text)
}
-// Reset removes the hint message.
+// Reset removes the hint message. It only clears the completion/command lane
+// (text); the persistent, provider and transient lanes are left untouched, so
+// async status and passive hints survive completion/isearch activity.
func (h *Hint) Reset() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
h.text = make([]rune, 0)
h.temp = false
h.set = false
@@ -63,21 +214,32 @@ func (h *Hint) Reset() {
// ResetPersist drops the persistent hint section.
func (h *Hint) ResetPersist() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
h.cleanup = len(h.persistent) > 0
h.persistent = make([]rune, 0)
}
-// DisplayHint prints the hint (persistent and/or temporary) sections.
+// DisplayHint prints the hint (persistent, provided, transient and/or
+// completion) sections.
func DisplayHint(hint *Hint) {
+ hint.mu.Lock()
+ defer hint.mu.Unlock()
+
+ // The completion/command (text) lane supports one-shot temporary messages:
+ // keep them for this render, clear them on the next.
if hint.temp && hint.set {
hint.set = false
} else if hint.temp {
- hint.Reset()
+ hint.text = make([]rune, 0)
+ hint.temp = false
+ hint.set = false
}
- if len(hint.text) == 0 && len(hint.persistent) == 0 {
+ if hint.empty() {
if hint.cleanup {
- term.Print(term.ClearLineAfter)
+ term.WriteString(term.ClearLineAfter)
}
hint.cleanup = false
@@ -85,7 +247,7 @@ func DisplayHint(hint *Hint) {
return
}
- text := hint.renderHint()
+ text := hint.renderLocked()
if strutil.RealLength(text) == 0 {
return
@@ -94,17 +256,27 @@ func DisplayHint(hint *Hint) {
text += term.ClearLineAfter + color.Reset
if len(text) > 0 {
- term.Print(text)
+ term.WriteString(text)
}
}
-func (h *Hint) renderHint() (text string) {
- if len(h.persistent) > 0 {
- text += string(h.persistent) + term.NewlineReturn
- }
+// empty reports whether every hint lane is empty. The caller must hold the lock.
+func (h *Hint) empty() bool {
+ return len(h.text) == 0 &&
+ len(h.persistent) == 0 &&
+ len(h.provided) == 0 &&
+ len(h.transient) == 0
+}
- if len(h.text) > 0 {
- text += string(h.text) + term.NewlineReturn
+// renderLocked builds the hint block from all lanes. The caller must hold the
+// lock (read or write).
+func (h *Hint) renderLocked() (text string) {
+ // Top to bottom: status, passive provider hint, async transient status,
+ // then completion/command hints.
+ for _, lane := range [][]rune{h.persistent, h.provided, h.transient, h.text} {
+ if len(lane) > 0 {
+ text += string(lane) + term.NewlineReturn
+ }
}
if strutil.RealLength(text) == 0 {
@@ -118,27 +290,39 @@ func (h *Hint) renderHint() (text string) {
}
// CoordinatesHint returns the number of terminal rows used by the hint.
+//
+// Each non-empty lane occupies its own row (wrapping when wider than the
+// terminal), matching exactly what DisplayHint prints (one NewlineReturn per
+// lane). It is counted from the lanes directly rather than from the rendered
+// string, so the inter-lane separators do not get miscounted as extra rows.
func CoordinatesHint(hint *Hint) int {
- text := hint.renderHint()
-
- // Nothing to do if no real text
- text = strings.TrimSuffix(text, term.ClearLineAfter+term.NewlineReturn)
+ hint.mu.RLock()
+ defer hint.mu.RUnlock()
- if strutil.RealLength(text) == 0 {
- return 0
+ width := term.GetWidth()
+ if width <= 0 {
+ width = 80
}
- // Otherwise compute the real length/span.
usedY := 0
- lines := strings.Split(text, term.ClearLineAfter)
- for i, line := range lines {
- x, y := strutil.LineSpan([]rune(line), i, 0)
- if x != 0 {
- y++
+ for _, lane := range [][]rune{hint.persistent, hint.provided, hint.transient, hint.text} {
+ if len(lane) == 0 {
+ continue
}
- usedY += y
+ // A lane may itself contain embedded newlines; count each sub-line,
+ // wrapping when it is wider than the terminal.
+ for _, sub := range strings.Split(string(lane), "\n") {
+ length := strutil.RealLength(sub)
+
+ rows := length / width
+ if length%width != 0 || rows == 0 {
+ rows++
+ }
+
+ usedY += rows
+ }
}
return usedY
diff --git a/readline/internal/ui/hint_test.go b/readline/internal/ui/hint_test.go
new file mode 100644
index 0000000..b6d3741
--- /dev/null
+++ b/readline/internal/ui/hint_test.go
@@ -0,0 +1,47 @@
+package ui
+
+import "testing"
+
+// TestCoordinatesHintRowCount guards against a row-count regression in the hint
+// area: CoordinatesHint must return exactly one row per non-empty lane (the same
+// number of rows DisplayHint actually prints). A previous implementation split
+// the rendered string on the per-line clear-sequence and double-counted every
+// lane after the first (returning 2N-1 rows for N lanes), which made the helper
+// repaint move the cursor up too far and drift the whole prompt upward by one
+// row on every refresh that showed two or more hint lanes.
+func TestCoordinatesHintRowCount(t *testing.T) {
+ cases := []struct {
+ name string
+ persistent, provided, transient, text string
+ want int
+ }{
+ {"none", "", "", "", "", 0},
+ {"one-text", "", "", "", "hello", 1},
+ {"one-provided", "", "passive hint", "", "", 1},
+ {"provided-and-transient", "", "passive hint", "async status", "", 2},
+ {"persistent-and-text", "register", "", "", "completion", 2},
+ {"three-lanes", "register", "passive", "async", "", 3},
+ {"all-four-lanes", "register", "passive", "async", "completion", 4},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ h := &Hint{}
+ h.persistent = []rune(c.persistent)
+ h.provided = []rune(c.provided)
+ h.transient = []rune(c.transient)
+ h.text = []rune(c.text)
+
+ // Normalize empty strings to empty slices so len()==0 lanes are skipped.
+ for _, p := range []*[]rune{&h.persistent, &h.provided, &h.transient, &h.text} {
+ if len(*p) == 0 {
+ *p = nil
+ }
+ }
+
+ if got := CoordinatesHint(h); got != c.want {
+ t.Fatalf("CoordinatesHint = %d, want %d", got, c.want)
+ }
+ })
+ }
+}
diff --git a/readline/internal/ui/prompt.go b/readline/internal/ui/prompt.go
index 7050078..80e0270 100644
--- a/readline/internal/ui/prompt.go
+++ b/readline/internal/ui/prompt.go
@@ -6,15 +6,18 @@ import (
"strings"
"github.com/chainreactors/tui/readline/inputrc"
+ "github.com/chainreactors/tui/readline/internal/color"
"github.com/chainreactors/tui/readline/internal/core"
"github.com/chainreactors/tui/readline/internal/keymap"
"github.com/chainreactors/tui/readline/internal/strutil"
"github.com/chainreactors/tui/readline/internal/term"
)
-const (
- secondaryPromptDefault = "\x1b[1;30m\U00002514 \x1b[0m"
- multilineColumnDefault = "\x1b[1;30m\U00002502 \x1b[0m"
+var (
+ // DefaultSecondaryPrompt is the default prompt to use for secondary lines.
+ DefaultSecondaryPrompt = color.FgBlackBright + "\U00002514 " + color.Reset
+ // DefaultMultilineColumn is the default prompt to use for multiline columns.
+ DefaultMultilineColumn = color.FgBlackBright + "\U00002502 " + color.Reset
)
// Prompt stores all prompt rendering/generation functions and is
@@ -108,10 +111,10 @@ func (p *Prompt) PrimaryPrint() {
// Print the various lines.
if prompt != "" {
- term.Print(prompt)
+ term.WriteString(prompt)
}
- term.Print(lastPrompt)
+ term.WriteString(lastPrompt)
// And compute coordinates
p.primaryRows = strings.Count(prompt, "\n")
@@ -125,6 +128,32 @@ func (p *Prompt) PrimaryUsed() int {
return p.primaryRows
}
+// UpperPrint reprints every line of the primary prompt except the last one.
+// It is used to keep a multi-line prompt's upper lines correct after the view
+// has scrolled (e.g. when the prompt is rendered at the bottom of the window),
+// since those lines are not otherwise repainted on a refresh.
+//
+// The cursor must be positioned at the first prompt row, column 0. On return
+// the cursor is at column 0 of the last prompt line's row (the upper lines each
+// end with a newline).
+func (p *Prompt) UpperPrint() {
+ if p.primaryF == nil || p.primaryRows == 0 {
+ return
+ }
+
+ upper, _ := p.formatPrimaryLines(p.primaryF())
+ if upper == "" {
+ return
+ }
+
+ // Clear each upper line as we reprint it, so a shorter prompt evaluation
+ // does not leave stale characters behind.
+ lines := strings.Split(strings.TrimSuffix(upper, "\n"), "\n")
+ for _, line := range lines {
+ term.WriteString(line + term.ClearLineAfter + term.NewlineReturn)
+ }
+}
+
// LastPrint prints the last line of the primary prompt, if the latter
// spans on several lines. If not, this function will actually print
// the entire primary prompt, and PrimaryPrint() will not print anything.
@@ -145,7 +174,7 @@ func (p *Prompt) LastPrint() {
prompt := p.formatLastPrompt(lines[len(lines)-1])
- term.Print(prompt)
+ term.WriteString(prompt)
p.primaryCols = strutil.RealLength(prompt)
}
@@ -177,11 +206,11 @@ func (p *Prompt) LastUsed() int {
// which is always activated when the current input line is a multiline one.
func (p *Prompt) SecondaryPrint() {
if p.secondaryF != nil {
- term.Print(p.secondaryF())
+ term.WriteString(p.secondaryF())
return
}
- term.Print(secondaryPromptDefault)
+ term.WriteString(DefaultSecondaryPrompt)
}
// MultilineColumnPrint prints the multiline editor column status indicator.
@@ -193,28 +222,26 @@ func (p *Prompt) MultilineColumnPrint() {
switch {
case numbered:
- column := ""
- for pos := 0; pos < p.line.Lines(); pos++ {
- column += fmt.Sprintf("\n\x1b[1;30m%d\x1b[0m", pos+2)
+ var column strings.Builder
+ for pos := range p.line.Lines() {
+ fmt.Fprintf(&column, "\n"+color.FgBlackBright+"%d"+color.Reset+" ", pos+2)
}
- term.Print(column)
-
+ term.WriteString(column.String())
case len(custom) > 0:
- column := ""
- for pos := 0; pos < p.line.Lines(); pos++ {
- column += fmt.Sprintf("\n%s\x1b[0m", custom)
+ var column strings.Builder
+ for range p.line.Lines() {
+ fmt.Fprintf(&column, "\n%s\x1b[0m", custom)
}
- term.Print(column)
-
+ term.WriteString(column.String())
case defaultCol:
- column := ""
- for pos := 0; pos < p.line.Lines(); pos++ {
- column += "\n" + multilineColumnDefault
+ var column strings.Builder
+ for range p.line.Lines() {
+ column.WriteString("\n" + DefaultMultilineColumn)
}
- term.Print(column)
+ term.WriteString(column.String())
}
}
@@ -238,9 +265,9 @@ func (p *Prompt) RightPrint(startColumn int, force bool) {
}
if prompt, canPrint := p.formatRightPrompt(rprompt, startColumn); canPrint {
- term.Print(prompt)
+ term.WriteString(prompt)
} else {
- term.Print(term.ClearLineAfter)
+ term.WriteString(term.ClearLineAfter)
}
}
@@ -253,10 +280,10 @@ func (p *Prompt) TransientPrint() {
// Clean everything below where the prompt will be printed.
term.MoveCursorBackwards(term.GetWidth())
term.MoveCursorUp(p.primaryRows)
- term.Print(term.ClearScreenBelow)
+ term.WriteString(term.ClearScreenBelow)
// And print the prompt
- term.Print(p.transientF())
+ term.WriteString(p.transientF())
}
// Refreshing returns true if the prompt is currently redisplaying
diff --git a/readline/paste.go b/readline/paste.go
index d0828b7..042f950 100644
--- a/readline/paste.go
+++ b/readline/paste.go
@@ -33,9 +33,13 @@ func (rl *Shell) InsertPastedText(text string) {
if text == "" {
return
}
- rl.History.Save()
+ if rl.History != nil {
+ rl.History.Save()
+ }
rl.cursor.InsertAt([]rune(text)...)
- rl.Display.Refresh()
+ if rl.Display != nil {
+ rl.Display.Refresh()
+ }
}
// HandleBracketedPastePending handles bytes that remain after an Escape binding
diff --git a/readline/readline.go b/readline/readline.go
index cbd7d28..c499371 100644
--- a/readline/readline.go
+++ b/readline/readline.go
@@ -65,18 +65,15 @@ func (rl *Shell) Readline() (string, error) {
}
}
- // Prompts and cursor styles
- rl.Display.PrintPrimaryPrompt()
-
- // Enable bracketed paste mode so terminals wrap pasted content
- // in \e[200~ ... \e[201~ markers instead of injecting raw chars.
- // Placed after prompt printing to avoid VT sequence interference.
if rl.Config.GetBool("enable-bracketed-paste") {
- term.Print("\033[?2004h")
- defer term.Print("\033[?2004l")
+ term.EnableBracketedPaste()
+ defer term.DisableBracketedPaste()
}
+
+ // Prompts and cursor styles
+ rl.Display.PrintPrimaryPrompt()
defer rl.Display.RefreshTransient()
- defer term.Print(keymap.CursorStyle("default"))
+ defer term.WriteString(keymap.CursorStyle("default").String())
rl.init()
@@ -84,6 +81,12 @@ func (rl *Shell) Readline() (string, error) {
resize := display.WatchResize(rl.Display)
defer close(resize)
+ // Async UI refresh: let background goroutines wake an idle loop (e.g. to
+ // show a transient hint pushed from another goroutine) via the input wake
+ // primitive. Torn down when the call returns.
+ rl.Keys.InitWake()
+ defer rl.Keys.CloseWake()
+
for {
// Whether or not the command is resolved, let the macro
// engine record the keys if currently recording a macro.
@@ -95,6 +98,11 @@ func (rl *Shell) Readline() (string, error) {
// been consumed but did not match any command.
core.FlushUsed(rl.Keys)
+ // Apply any async-requested completion regeneration (RefreshCompletions)
+ // here on the main loop, before refreshing, so an active menu rebuilds
+ // in place while rendering stays single-writer.
+ rl.completer.ApplyRegen()
+
// Since we always update helpers after being asked to read
// for user input again, we do it before actually reading it.
rl.Display.Refresh()
@@ -104,12 +112,25 @@ func (rl *Shell) Readline() (string, error) {
// the macro engine has fed some keys in bulk when running one.
core.WaitAvailableKeys(rl.Keys, rl.Config)
+ // A non-EOF read failure (e.g. the tty was revoked) is unrecoverable:
+ // return it so the caller can exit cleanly instead of the loop spinning
+ // on the dead input stream.
+ if err := rl.Keys.ReadError(); err != nil {
+ return "", err
+ }
+
// If the input is closed, we must return the line
// and the error so that the caller can handle it.
if rl.Keys.IsEOF() {
return "", io.EOF
}
+ // A bare async-refresh wake leaves no keys to dispatch: loop back to
+ // repaint the (possibly updated) UI and wait for input again.
+ if rl.Keys.Empty() {
+ continue
+ }
+
// 1 - Local keymap (Completion/Isearch/Vim operator pending).
bind, command, prefixed := keymap.MatchLocal(rl.Keymap)
if prefixed {
diff --git a/readline/shell.go b/readline/shell.go
index 205c2a1..284b92a 100644
--- a/readline/shell.go
+++ b/readline/shell.go
@@ -95,6 +95,7 @@ func NewShellWithTerminal(t *rlterm.Terminal, opts ...inputrc.Option) *Shell {
if t.Err == nil {
t.Err = t.Out
}
+
shell := new(Shell)
shell.Terminal = t
@@ -125,7 +126,7 @@ func NewShellWithTerminal(t *rlterm.Terminal, opts ...inputrc.Option) *Shell {
shell.Opts = opts
// User interface
- hint := new(ui.Hint)
+ hint := ui.NewHint(keys)
prompt := ui.NewPrompt(line, cursor, keymaps, config)
macros := macro.NewEngine(keys, hint)
history := history.NewSources(line, cursor, hint, config)
@@ -172,11 +173,12 @@ func (rl *Shell) Selection() *core.Selection { return rl.selection }
func (rl *Shell) Printf(msg string, args ...any) (n int, err error) {
restore := term.Activate(rl.Terminal.Out, rl.Terminal.Control)
defer restore()
+
// First go back to the last line of the input line,
// and clear everything below (hints and completions).
rl.Display.CursorBelowLine()
term.MoveCursorBackwards(term.GetWidth())
- term.Print(term.ClearScreenBelow)
+ term.WriteString(term.ClearScreenBelow)
// Skip a line, and print the formatted message.
n, err = fmt.Fprintf(rl.Terminal.Out, msg+"\n", args...)
@@ -193,15 +195,15 @@ func (rl *Shell) Printf(msg string, args ...any) (n int, err error) {
func (rl *Shell) PrintTransientf(msg string, args ...any) (n int, err error) {
restore := term.Activate(rl.Terminal.Out, rl.Terminal.Control)
defer restore()
+
// First go back to the beginning of the line/prompt, and
// clear everything below (prompt/line/hints/completions).
rl.Display.CursorToLineStart()
term.MoveCursorBackwards(term.GetWidth())
term.MoveCursorUp(rl.Prompt.PrimaryUsed())
- term.Print(term.ClearScreenBelow)
+ term.WriteString(term.ClearScreenBelow)
// Print the logged message.
- // Ensure exactly one trailing newline to separate message from redrawn prompt.
formatted := fmt.Sprintf(msg, args...)
if !strings.HasSuffix(formatted, "\n") {
formatted += "\n"
@@ -215,7 +217,7 @@ func (rl *Shell) PrintTransientf(msg string, args ...any) (n int, err error) {
return
}
-// SetInlineSuggestion sets an inline suggestion to display after the cursor (fish-style).
+// SetInlineSuggestion sets an inline suggestion to display after the cursor.
func (rl *Shell) SetInlineSuggestion(suggestion string) {
rl.Display.SetInlineSuggestion(suggestion)
}
@@ -235,6 +237,7 @@ func (rl *Shell) Refresh() {
if rl == nil || rl.Display == nil {
return
}
+
restore := term.Activate(rl.Terminal.Out, rl.Terminal.Control)
defer restore()
rl.Display.Refresh()
diff --git a/readline/terminal/fork_test.go b/readline/terminal/fork_test.go
new file mode 100644
index 0000000..2708156
--- /dev/null
+++ b/readline/terminal/fork_test.go
@@ -0,0 +1,31 @@
+package terminal
+
+import "testing"
+
+func TestStreamControlSizeAndResizeCallbacks(t *testing.T) {
+ control := NewControl(true, 80, 24)
+ if !control.IsTerminal() {
+ t.Fatal("control should report terminal")
+ }
+
+ var gotCols, gotRows int
+ unregister := control.OnResize(func(cols, rows int) {
+ gotCols, gotRows = cols, rows
+ })
+
+ control.SetSize(120, 40)
+ if gotCols != 120 || gotRows != 40 {
+ t.Fatalf("resize callback got %dx%d, want 120x40", gotCols, gotRows)
+ }
+
+ cols, rows := control.Size()
+ if cols != 120 || rows != 40 {
+ t.Fatalf("control size = %dx%d, want 120x40", cols, rows)
+ }
+
+ unregister()
+ control.SetSize(100, 30)
+ if gotCols != 120 || gotRows != 40 {
+ t.Fatalf("resize callback fired after unregister: %dx%d", gotCols, gotRows)
+ }
+}
diff --git a/readline/vim.go b/readline/vim.go
index f124b7b..6594e06 100644
--- a/readline/vim.go
+++ b/readline/vim.go
@@ -666,7 +666,7 @@ func (rl *Shell) viChangeChar() {
switch {
case rl.selection.Active() && rl.selection.IsVisual():
// In visual mode, we replace all chars of the selection
- rl.selection.ReplaceWith(func(r rune) rune {
+ rl.selection.ReplaceWith(func(_ rune) rune {
return key
})
default: