diff --git a/README.md b/README.md index ba901e4..c2ad4c7 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ yoink send deploy "hello world" --type --delay 100 # Send without pressing Enter (useful for prompts that read a single key) yoink send deploy "y" --no-enter +# Send a named key sequence (arrow keys, ctrl combos, function keys, etc.) +yoink send deploy --key up +yoink send deploy --key ctrl+c +yoink send deploy --key pagedown +yoink send deploy --key f5 + # Send sensitive input (passwords, tokens) — hidden from logs yoink send-redacted deploy "my-secret-token" @@ -84,8 +90,8 @@ yoink runs a lightweight daemon that holds PTY (pseudo-terminal) sessions open f - **log**: Shows buffered output (last 1000 lines kept per process). Use `--new` to see only lines since the last `--new` call - **wait**: Polls a process until it finishes, streaming new output to stdout on each cycle. Exits 0 on success, propagates the process exit code on failure, or exits 124 on timeout (matches GNU `timeout` convention). Flags: `--timeout` (seconds, 0=forever), `--poll` (seconds, default 2), `--message` (custom timeout error) - **snapshot** (`snap`): Renders the current VT100 screen of a process as plain text. Unlike `log`, this correctly handles TUI applications (htop, vim, Claude, etc.) by replaying their escape sequences through a VT100 emulator and returning only the rendered content. Works on both running and finished processes -- **send**: Writes text to the process stdin (fire-and-forget). Flags: `--type` (character by character), `--delay` (ms between chars, default 50), `--no-enter` (omit trailing newline) -- **send-redacted**: Same as send, but input is masked in logs. Supports the same `--type`/`--delay`/`--no-enter` flags +- **send**: Writes text to the process stdin (fire-and-forget). Flags: `--type` (character by character), `--delay` (ms between chars, default 50), `--no-enter` (omit trailing newline), `--key` (send a named key sequence — e.g. `up`, `down`, `ctrl+c`, `f1`, `pageup`; implies `--no-enter`) +- **send-redacted**: Same as send, but input is masked in logs. Supports the same flags - **attach**: Bridges your terminal to the process PTY for full interactive control - **kill**: Sends SIGTERM (or SIGKILL with --force) - **clean**: Removes finished processes from the list @@ -97,7 +103,7 @@ The daemon auto-starts when needed and stores its socket at `~/.yoink/yoink.sock | Feature | Linux | macOS | Windows | |---------|-------|-------|---------| | run/ls/log/wait/send/send-redacted/kill/clean | Yes | Yes | Planned | -| send --type / --delay / --no-enter | Yes | Yes | Planned | +| send --type / --delay / --no-enter / --key | Yes | Yes | Planned | | snapshot (VT100 screen render) | Yes | Yes | Planned | | attach (PTY reattach) | Yes | Yes | Planned | diff --git a/actions/send.go b/actions/send.go index 92f3ebf..369df75 100644 --- a/actions/send.go +++ b/actions/send.go @@ -9,6 +9,25 @@ import ( "github.com/urfave/cli" ) +// resolveInput returns the input string and whether no-enter should be implied, +// handling both plain text and --key mode. +func resolveInput(c *cli.Context) (input string, noEnter bool, err error) { + keyName := c.String("key") + hasText := c.NArg() >= 2 + + if keyName != "" && hasText { + return "", false, fmt.Errorf("--key and text input are mutually exclusive") + } + if keyName != "" { + seq, err := services.ResolveKey(keyName) + if err != nil { + return "", false, err + } + return seq, true, nil // key sequences never get a trailing newline + } + return strings.Join(c.Args()[1:], " "), c.Bool("no-enter"), nil +} + // SendAction sends input text to a running process. type SendAction struct { client *services.ClientService @@ -22,16 +41,21 @@ func NewSendAction(client *services.ClientService, redacted bool) *SendAction { // Execute sends the given text as stdin to the process. func (a *SendAction) Execute(c *cli.Context) error { - if c.NArg() < 2 { + keyName := c.String("key") + if c.NArg() < 1 || (c.NArg() < 2 && keyName == "") { cmd := "send" if a.redacted { cmd = "send-redacted" } - return fmt.Errorf("usage: yoink %s ", cmd) + return fmt.Errorf("usage: yoink %s [--key ]", cmd) } id, alias := parseProcessRef(c.Args().First()) - input := strings.Join(c.Args()[1:], " ") + + input, noEnter, err := resolveInput(c) + if err != nil { + return err + } resp, err := a.client.Do(domain.DaemonRequest{ Type: domain.ReqSend, @@ -41,7 +65,7 @@ func (a *SendAction) Execute(c *cli.Context) error { Redacted: a.redacted, TypeMode: c.Bool("type"), TypeDelay: c.Int("delay"), - NoEnter: c.Bool("no-enter"), + NoEnter: noEnter, }) if err != nil { return err diff --git a/actions/send_test.go b/actions/send_test.go new file mode 100644 index 0000000..bb59b8f --- /dev/null +++ b/actions/send_test.go @@ -0,0 +1,130 @@ +package actions + +import ( + "flag" + "testing" + + "github.com/urfave/cli" +) + +func newSendContext(t *testing.T, args []string, key string, noEnter bool) *cli.Context { + t.Helper() + app := cli.NewApp() + set := flag.NewFlagSet("send", flag.ContinueOnError) + set.String("key", "", "") + set.Bool("no-enter", false, "") + if err := set.Parse(args); err != nil { + t.Fatalf("flag parse error: %v", err) + } + if key != "" { + if err := set.Set("key", key); err != nil { + t.Fatalf("set key flag: %v", err) + } + } + if noEnter { + if err := set.Set("no-enter", "true"); err != nil { + t.Fatalf("set no-enter flag: %v", err) + } + } + return cli.NewContext(app, set, nil) +} + +func TestResolveInput(t *testing.T) { + tests := []struct { + name string + args []string // positional args (alias + optional text) + key string + noEnter bool + wantInput string + wantNoEnter bool + wantErr bool + }{ + // Plain text: returns text, respects --no-enter + { + name: "plain text without no-enter", + args: []string{"myproc", "hello"}, + wantInput: "hello", + wantNoEnter: false, + }, + { + name: "plain text with no-enter", + args: []string{"myproc", "hello"}, + noEnter: true, + wantInput: "hello", + wantNoEnter: true, + }, + { + name: "multi-word text joined with space", + args: []string{"myproc", "hello", "world"}, + wantInput: "hello world", + wantNoEnter: false, + }, + { + name: "no text args returns empty string", + args: []string{"myproc"}, + wantInput: "", + wantNoEnter: false, + }, + + // --key mode: resolves sequence, always implies no-enter + { + name: "--key up resolves to escape sequence", + args: []string{"myproc"}, + key: "up", + wantInput: "\x1b[A", + wantNoEnter: true, + }, + { + name: "--key ctrl+c resolves to 0x03", + args: []string{"myproc"}, + key: "ctrl+c", + wantInput: "\x03", + wantNoEnter: true, + }, + { + name: "--key is case-insensitive", + args: []string{"myproc"}, + key: "PageUp", + wantInput: "\x1b[5~", + wantNoEnter: true, + }, + + // Error: --key + text are mutually exclusive + { + name: "--key and text are mutually exclusive", + args: []string{"myproc", "sometext"}, + key: "up", + wantErr: true, + }, + + // Error: unknown key + { + name: "unknown key name returns error", + args: []string{"myproc"}, + key: "superkey", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := newSendContext(t, tt.args, tt.key, tt.noEnter) + input, noEnter, err := resolveInput(ctx) + if tt.wantErr { + if err == nil { + t.Errorf("resolveInput() expected error, got input=%q noEnter=%v", input, noEnter) + } + return + } + if err != nil { + t.Fatalf("resolveInput() unexpected error: %v", err) + } + if input != tt.wantInput { + t.Errorf("input = %q, want %q", input, tt.wantInput) + } + if noEnter != tt.wantNoEnter { + t.Errorf("noEnter = %v, want %v", noEnter, tt.wantNoEnter) + } + }) + } +} diff --git a/docs/README.md b/docs/README.md index ad35f77..28b39cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ yoink snapshot 1 --> yoinkd --> VT100 emulator framebuf yoink send 1 "y" --> yoinkd --> write to PTY fd (whole string + newline) yoink send 1 "y" --type --> yoinkd --> write to PTY fd char by char with delay yoink send 1 "y" --no-enter --> yoinkd --> write to PTY fd without trailing newline +yoink send 1 --key up --> yoinkd --> write ESC[A to PTY fd (named key sequence) yoink attach 1 --> yoinkd --> bridge terminal <-> PTY ``` diff --git a/flags.go b/flags.go index 7fc4c6d..107b909 100644 --- a/flags.go +++ b/flags.go @@ -60,3 +60,8 @@ var noEnterFlag = cli.BoolFlag{ Name: "no-enter", Usage: "Do not send a newline after the input", } + +var keyFlag = cli.StringFlag{ + Name: "key, k", + Usage: "Send a named key sequence instead of text (e.g. up, down, ctrl+c, f1, pageup)", +} diff --git a/main.go b/main.go index c79002c..8f98e55 100644 --- a/main.go +++ b/main.go @@ -93,7 +93,7 @@ func main() { Name: "send", Aliases: []string{"s"}, Usage: "Send input text to a running process", - Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag}, + Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag, keyFlag}, Action: sendAction.Execute, }, { diff --git a/services/keys.go b/services/keys.go new file mode 100644 index 0000000..5452709 --- /dev/null +++ b/services/keys.go @@ -0,0 +1,74 @@ +package services + +import ( + "fmt" + "strings" +) + +// keyMap maps human-readable key names to their PTY byte sequences. +var keyMap = map[string]string{ + // Arrow keys + "up": "\x1b[A", + "down": "\x1b[B", + "right": "\x1b[C", + "left": "\x1b[D", + + // Navigation + "home": "\x1b[H", + "end": "\x1b[F", + "pageup": "\x1b[5~", + "pgup": "\x1b[5~", + "pagedown": "\x1b[6~", + "pgdn": "\x1b[6~", + "delete": "\x1b[3~", + "del": "\x1b[3~", + "insert": "\x1b[2~", + + // Basic keys + "enter": "\r", + "tab": "\t", + "backspace": "\x7f", + "escape": "\x1b", + "esc": "\x1b", + + // Ctrl combos + "ctrl+a": "\x01", + "ctrl+b": "\x02", + "ctrl+c": "\x03", + "ctrl+d": "\x04", + "ctrl+e": "\x05", + "ctrl+f": "\x06", + "ctrl+k": "\x0b", + "ctrl+l": "\x0c", + "ctrl+n": "\x0e", + "ctrl+p": "\x10", + "ctrl+r": "\x12", + "ctrl+u": "\x15", + "ctrl+w": "\x17", + "ctrl+z": "\x1a", + "ctrl+]": "\x1d", + + // Function keys + "f1": "\x1bOP", + "f2": "\x1bOQ", + "f3": "\x1bOR", + "f4": "\x1bOS", + "f5": "\x1b[15~", + "f6": "\x1b[17~", + "f7": "\x1b[18~", + "f8": "\x1b[19~", + "f9": "\x1b[20~", + "f10": "\x1b[21~", + "f11": "\x1b[23~", + "f12": "\x1b[24~", +} + +// ResolveKey returns the PTY byte sequence for the given key name. +// Names are case-insensitive (e.g. "Up", "CTRL+C", "PageUp"). +func ResolveKey(name string) (string, error) { + seq, ok := keyMap[strings.ToLower(name)] + if !ok { + return "", fmt.Errorf("unknown key %q — run 'yoink send --help' for supported key names", name) + } + return seq, nil +} diff --git a/services/keys_test.go b/services/keys_test.go new file mode 100644 index 0000000..2023926 --- /dev/null +++ b/services/keys_test.go @@ -0,0 +1,80 @@ +package services + +import "testing" + +func TestResolveKey(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + // Arrow keys + {name: "up", input: "up", want: "\x1b[A"}, + {name: "down", input: "down", want: "\x1b[B"}, + {name: "right", input: "right", want: "\x1b[C"}, + {name: "left", input: "left", want: "\x1b[D"}, + + // Navigation + {name: "home", input: "home", want: "\x1b[H"}, + {name: "end", input: "end", want: "\x1b[F"}, + {name: "pageup", input: "pageup", want: "\x1b[5~"}, + {name: "pgup alias", input: "pgup", want: "\x1b[5~"}, + {name: "pagedown", input: "pagedown", want: "\x1b[6~"}, + {name: "pgdn alias", input: "pgdn", want: "\x1b[6~"}, + {name: "delete", input: "delete", want: "\x1b[3~"}, + {name: "del alias", input: "del", want: "\x1b[3~"}, + {name: "insert", input: "insert", want: "\x1b[2~"}, + + // Basic keys + {name: "enter", input: "enter", want: "\r"}, + {name: "tab", input: "tab", want: "\t"}, + {name: "backspace", input: "backspace", want: "\x7f"}, + {name: "escape", input: "escape", want: "\x1b"}, + {name: "esc alias", input: "esc", want: "\x1b"}, + + // Ctrl combos + {name: "ctrl+c", input: "ctrl+c", want: "\x03"}, + {name: "ctrl+d", input: "ctrl+d", want: "\x04"}, + {name: "ctrl+z", input: "ctrl+z", want: "\x1a"}, + {name: "ctrl+l", input: "ctrl+l", want: "\x0c"}, + {name: "ctrl+r", input: "ctrl+r", want: "\x12"}, + {name: "ctrl+u", input: "ctrl+u", want: "\x15"}, + {name: "ctrl+]", input: "ctrl+]", want: "\x1d"}, + + // Function keys + {name: "f1", input: "f1", want: "\x1bOP"}, + {name: "f4", input: "f4", want: "\x1bOS"}, + {name: "f5", input: "f5", want: "\x1b[15~"}, + {name: "f10", input: "f10", want: "\x1b[21~"}, + {name: "f12", input: "f12", want: "\x1b[24~"}, + + // Case insensitive + {name: "Up uppercase", input: "Up", want: "\x1b[A"}, + {name: "CTRL+C uppercase", input: "CTRL+C", want: "\x03"}, + {name: "PageUp mixed case", input: "PageUp", want: "\x1b[5~"}, + {name: "F1 uppercase", input: "F1", want: "\x1bOP"}, + + // Unknown key + {name: "unknown key", input: "superkey", wantErr: true}, + {name: "empty string", input: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveKey(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("ResolveKey(%q) expected error, got %q", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("ResolveKey(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("ResolveKey(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +}