Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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 |

Expand Down
32 changes: 28 additions & 4 deletions actions/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <id|alias> <input>", cmd)
return fmt.Errorf("usage: yoink %s <id|alias> <input> [--key <name>]", 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,
Expand All @@ -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
Expand Down
130 changes: 130 additions & 0 deletions actions/send_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
5 changes: 5 additions & 0 deletions flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand Down
74 changes: 74 additions & 0 deletions services/keys.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading