From 2614e7e1a1afce8271bb06b2b7280643119fa6fa Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 3 Apr 2026 21:29:56 +0200 Subject: [PATCH 1/7] feat: add --type, --delay, --no-enter flags to send command Enables simulating realistic typing interactions on PTY sessions: - --type: sends input character by character instead of all at once - --delay : configurable delay between characters (default 50ms), ideal for asciinema recordings - --no-enter: omits the trailing newline, useful for prompts that read one char at a time Refactors SendInput to accept a SendOptions struct (TypeMode, TypeDelay, NoEnter, Redacted) and adds 6 unit tests + 2 integration tests covering type mode, delay timing, no-enter, combined usage, and redaction in type mode. --- actions/send.go | 13 ++- domain/types.go | 21 ++-- flags.go | 16 +++ integration/cli_test.go | 78 ++++++++++++++ main.go | 2 + services/daemon_service.go | 9 +- services/process_manager.go | 40 +++++-- services/process_manager_test.go | 179 +++++++++++++++++++++++++++++-- 8 files changed, 329 insertions(+), 29 deletions(-) diff --git a/actions/send.go b/actions/send.go index c36b9fb..92f3ebf 100644 --- a/actions/send.go +++ b/actions/send.go @@ -34,11 +34,14 @@ func (a *SendAction) Execute(c *cli.Context) error { input := strings.Join(c.Args()[1:], " ") resp, err := a.client.Do(domain.DaemonRequest{ - Type: domain.ReqSend, - ID: id, - Alias: alias, - Input: input, - Redacted: a.redacted, + Type: domain.ReqSend, + ID: id, + Alias: alias, + Input: input, + Redacted: a.redacted, + TypeMode: c.Bool("type"), + TypeDelay: c.Int("delay"), + NoEnter: c.Bool("no-enter"), }) if err != nil { return err diff --git a/domain/types.go b/domain/types.go index 6af8cc6..daeeb4b 100644 --- a/domain/types.go +++ b/domain/types.go @@ -54,15 +54,18 @@ const ( // DaemonRequest is sent from client to daemon over the Unix socket. type DaemonRequest struct { - Type RequestType `json:"type"` - ID int `json:"id,omitempty"` - Alias string `json:"alias,omitempty"` - Command string `json:"command,omitempty"` - Input string `json:"input,omitempty"` - Lines int `json:"lines,omitempty"` - New bool `json:"new,omitempty"` - Force bool `json:"force,omitempty"` - Redacted bool `json:"redacted,omitempty"` + Type RequestType `json:"type"` + ID int `json:"id,omitempty"` + Alias string `json:"alias,omitempty"` + Command string `json:"command,omitempty"` + Input string `json:"input,omitempty"` + Lines int `json:"lines,omitempty"` + New bool `json:"new,omitempty"` + Force bool `json:"force,omitempty"` + Redacted bool `json:"redacted,omitempty"` + TypeMode bool `json:"type_mode,omitempty"` + TypeDelay int `json:"type_delay,omitempty"` // milliseconds between characters + NoEnter bool `json:"no_enter,omitempty"` } // DaemonResponse is sent from daemon to client over the Unix socket. diff --git a/flags.go b/flags.go index 226bf66..7fc4c6d 100644 --- a/flags.go +++ b/flags.go @@ -44,3 +44,19 @@ var messageFlag = cli.StringFlag{ Name: "message, m", Usage: "Custom error message on timeout", } + +var typeModeFlag = cli.BoolFlag{ + Name: "type", + Usage: "Send input character by character (simulates typing)", +} + +var typeDelayFlag = cli.IntFlag{ + Name: "delay, d", + Value: 50, + Usage: "Milliseconds between characters when using --type", +} + +var noEnterFlag = cli.BoolFlag{ + Name: "no-enter", + Usage: "Do not send a newline after the input", +} diff --git a/integration/cli_test.go b/integration/cli_test.go index 5111223..7c3691b 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -437,3 +437,81 @@ func TestIntegration_ProcessExitCode(t *testing.T) { t.Errorf("expected exit code 7, got %d", p.ExitCode) } } + +func TestIntegration_SendTypeMode(t *testing.T) { + env := newTestEnv(t) + + // Spawn cat which echoes back what it receives. + env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqRun, + Command: "cat", + Alias: "typer", + }) + + time.Sleep(300 * time.Millisecond) + + // Use the CLI --type flag with a short delay so the test stays fast. + out, err := env.run("send", "typer", "type-mode-works", "--type", "--delay", "5") + if err != nil { + t.Fatalf("send --type failed: %v\noutput: %s", err, out) + } + + time.Sleep(500 * time.Millisecond) + + logResp := env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqLog, + ID: 1, + Lines: 20, + }) + if !strings.Contains(logResp.Output, "type-mode-works") { + t.Errorf("expected 'type-mode-works' in output, got: %q", logResp.Output) + } +} + +func TestIntegration_SendNoEnter(t *testing.T) { + env := newTestEnv(t) + + // Script reads a line and echoes it back prefixed with "got-". + env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqRun, + Command: "sh -c 'read -r line; echo got-$line'", + Alias: "reader", + }) + + time.Sleep(300 * time.Millisecond) + + // Send partial input without newline via --no-enter. + _, err := env.run("send", "reader", "hello", "--no-enter") + if err != nil { + t.Fatalf("send --no-enter failed: %v", err) + } + + time.Sleep(300 * time.Millisecond) + + // Process should not have produced "got-" yet. + logResp := env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqLog, + ID: 1, + Lines: 20, + }) + if strings.Contains(logResp.Output, "got-") { + t.Errorf("did not expect 'got-' before newline was sent, got: %q", logResp.Output) + } + + // Now complete with just a newline. + _, err = env.run("send", "reader", "") + if err != nil { + t.Fatalf("send (newline completion) failed: %v", err) + } + + time.Sleep(500 * time.Millisecond) + + logResp = env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqLog, + ID: 1, + Lines: 20, + }) + if !strings.Contains(logResp.Output, "got-hello") { + t.Errorf("expected 'got-hello' after newline, got: %q", logResp.Output) + } +} diff --git a/main.go b/main.go index b827a07..735284b 100644 --- a/main.go +++ b/main.go @@ -92,11 +92,13 @@ func main() { Name: "send", Aliases: []string{"s"}, Usage: "Send input text to a running process", + Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag}, Action: sendAction.Execute, }, { Name: "send-redacted", Usage: "Send input to a process (redacted in logs)", + Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag}, Action: sendRedactedAction.Execute, }, { diff --git a/services/daemon_service.go b/services/daemon_service.go index baf7fe0..3d9e988 100644 --- a/services/daemon_service.go +++ b/services/daemon_service.go @@ -7,6 +7,7 @@ import ( "net" "os" "sync" + "time" "github.com/AxeForging/yoink/domain" "github.com/rs/zerolog" @@ -158,7 +159,13 @@ func (ds *DaemonServer) handleLog(conn net.Conn, req domain.DaemonRequest) { func (ds *DaemonServer) handleSend(conn net.Conn, req domain.DaemonRequest) { defer conn.Close() - if err := ds.pm.SendInput(req.ID, req.Input, req.Redacted); err != nil { + opts := SendOptions{ + Redacted: req.Redacted, + TypeMode: req.TypeMode, + TypeDelay: time.Duration(req.TypeDelay) * time.Millisecond, + NoEnter: req.NoEnter, + } + if err := ds.pm.SendInput(req.ID, req.Input, opts); err != nil { ds.sendResponse(conn, domain.DaemonResponse{Error: err.Error()}) return } diff --git a/services/process_manager.go b/services/process_manager.go index f05236b..68b20d8 100644 --- a/services/process_manager.go +++ b/services/process_manager.go @@ -230,9 +230,17 @@ func (pm *ProcessManager) GetLogEntries(id, lines int) ([]domain.LogEntry, error return mp.buffer.ReadLast(lines), nil } -// SendInput writes text followed by a newline to the process PTY. -// If redacted is true, the input is logged as [REDACTED] in the ring buffer. -func (pm *ProcessManager) SendInput(id int, input string, redacted bool) error { +// SendOptions controls how input is delivered to a process PTY. +type SendOptions struct { + Redacted bool + TypeMode bool // send character by character + TypeDelay time.Duration // delay between characters in type mode + NoEnter bool // omit the trailing newline +} + +// SendInput writes text to the process PTY. +// By default it appends a newline; use SendOptions to customise behaviour. +func (pm *ProcessManager) SendInput(id int, input string, opts SendOptions) error { pm.mu.RLock() mp, ok := pm.processes[id] pm.mu.RUnlock() @@ -244,7 +252,7 @@ func (pm *ProcessManager) SendInput(id int, input string, redacted bool) error { } // If redacted, register pattern to catch PTY echo - if redacted { + if opts.Redacted { mp.redactMu.Lock() mp.redactPatterns = append(mp.redactPatterns, input) mp.redactMu.Unlock() @@ -254,11 +262,29 @@ func (pm *ProcessManager) SendInput(id int, input string, redacted bool) error { mp.buffer.Write(domain.LogEntry{ Source: domain.SourceStdin, Text: input, - Redacted: redacted, + Redacted: opts.Redacted, }) - _, err := mp.pty.Write([]byte(input + "\n")) - return err + if opts.TypeMode { + for _, ch := range input { + if _, err := mp.pty.Write([]byte(string(ch))); err != nil { + return err + } + if opts.TypeDelay > 0 { + time.Sleep(opts.TypeDelay) + } + } + } else { + if _, err := mp.pty.Write([]byte(input)); err != nil { + return err + } + } + + if !opts.NoEnter { + _, err := mp.pty.Write([]byte("\n")) + return err + } + return nil } // FormatLogEntries formats log entries with source labels and redaction. diff --git a/services/process_manager_test.go b/services/process_manager_test.go index 2f2317c..d3c86bd 100644 --- a/services/process_manager_test.go +++ b/services/process_manager_test.go @@ -79,7 +79,7 @@ func TestProcessManager_SpawnAndSendInput(t *testing.T) { time.Sleep(200 * time.Millisecond) // let cat start - if err := pm.SendInput(id, "test-input-123", false); err != nil { + if err := pm.SendInput(id, "test-input-123", SendOptions{}); err != nil { t.Fatalf("SendInput failed: %v", err) } @@ -199,7 +199,7 @@ func TestProcessManager_SendInput_NotRunning(t *testing.T) { id, _ := pm.Spawn("echo fast", "") waitForState(t, pm, id, domain.StateDone, 5*time.Second) - err := pm.SendInput(id, "too late", false) + err := pm.SendInput(id, "too late", SendOptions{}) if err == nil { t.Error("expected error sending to finished process") } @@ -244,7 +244,7 @@ func TestProcessManager_LabeledOutput(t *testing.T) { } time.Sleep(200 * time.Millisecond) - pm.SendInput(id, "hello", false) + pm.SendInput(id, "hello", SendOptions{}) time.Sleep(500 * time.Millisecond) output, _ := pm.GetOutput(id, 100) @@ -266,7 +266,7 @@ func TestProcessManager_SendRedacted(t *testing.T) { } time.Sleep(200 * time.Millisecond) - pm.SendInput(id, "super-secret-token", true) + pm.SendInput(id, "super-secret-token", SendOptions{Redacted: true}) time.Sleep(500 * time.Millisecond) // Formatted output should show redacted @@ -308,7 +308,7 @@ func TestProcessManager_GetNewOutput(t *testing.T) { } time.Sleep(200 * time.Millisecond) - pm.SendInput(id, "first", false) + pm.SendInput(id, "first", SendOptions{}) waitForOutput(t, pm, id, "reply-first", 5*time.Second) // Let PTY echo settle time.Sleep(200 * time.Millisecond) @@ -332,7 +332,7 @@ func TestProcessManager_GetNewOutput(t *testing.T) { } // Write more - pm.SendInput(id, "second", false) + pm.SendInput(id, "second", SendOptions{}) waitForOutput(t, pm, id, "reply-second", 5*time.Second) time.Sleep(200 * time.Millisecond) @@ -464,7 +464,7 @@ func TestProcessManager_AliasWithOperations(t *testing.T) { // Resolve alias and use for operations resolved, _ := pm.ResolveID("interactive") - if err := pm.SendInput(resolved, "hello-alias", false); err != nil { + if err := pm.SendInput(resolved, "hello-alias", SendOptions{}); err != nil { t.Fatalf("SendInput via alias failed: %v", err) } @@ -476,3 +476,168 @@ func TestProcessManager_AliasWithOperations(t *testing.T) { } waitForState(t, pm, id, domain.StateFailed, 5*time.Second) } + +// --- Type mode tests --- + +func TestProcessManager_TypeMode_OutputAppears(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + // cat echoes back what it receives; with type mode the chars arrive one by one + // but the output should still contain the full string once newline is sent. + id, err := pm.Spawn("cat", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + if err := pm.SendInput(id, "typed-hello", SendOptions{TypeMode: true, TypeDelay: 5 * time.Millisecond}); err != nil { + t.Fatalf("SendInput (type mode) failed: %v", err) + } + + waitForOutput(t, pm, id, "typed-hello", 5*time.Second) +} + +func TestProcessManager_TypeMode_DelayIsRespected(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + id, err := pm.Spawn("cat", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + // 5 chars × 20ms = at least 100ms elapsed + input := "abcde" + delay := 20 * time.Millisecond + start := time.Now() + if err := pm.SendInput(id, input, SendOptions{TypeMode: true, TypeDelay: delay}); err != nil { + t.Fatalf("SendInput (type mode with delay) failed: %v", err) + } + elapsed := time.Since(start) + + minExpected := time.Duration(len([]rune(input))-1) * delay + if elapsed < minExpected { + t.Errorf("expected at least %v elapsed for type mode, got %v", minExpected, elapsed) + } + + waitForOutput(t, pm, id, input, 5*time.Second) +} + +func TestProcessManager_TypeMode_ZeroDelay(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + id, err := pm.Spawn("cat", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + // Zero delay: chars sent immediately, no sleep + if err := pm.SendInput(id, "nodelay", SendOptions{TypeMode: true, TypeDelay: 0}); err != nil { + t.Fatalf("SendInput (type mode, zero delay) failed: %v", err) + } + + waitForOutput(t, pm, id, "nodelay", 5*time.Second) +} + +func TestProcessManager_NoEnter_NoLineEcho(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + // Use a shell that echoes lines only after receiving a newline. + // send-without-enter should NOT produce a completed line in output. + id, err := pm.Spawn("sh -c 'read -r line; echo got-$line'", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + // Send partial input without newline — the read builtin won't complete yet. + if err := pm.SendInput(id, "partial", SendOptions{NoEnter: true}); err != nil { + t.Fatalf("SendInput (no-enter) failed: %v", err) + } + + time.Sleep(300 * time.Millisecond) + + // No "got-" line should have appeared yet because read is still waiting. + entries, _ := pm.GetLogEntries(id, 100) + for _, e := range entries { + if e.Source == domain.SourceStdout && strings.Contains(e.Text, "got-") { + t.Errorf("did not expect 'got-' output before newline, got: %q", e.Text) + } + } + + // Now complete with newline; process should echo got-partial. + if err := pm.SendInput(id, "", SendOptions{}); err != nil { + t.Fatalf("SendInput (newline completion) failed: %v", err) + } + + waitForOutput(t, pm, id, "got-partial", 5*time.Second) +} + +func TestProcessManager_TypeMode_WithRedaction(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + id, err := pm.Spawn("cat", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + if err := pm.SendInput(id, "secret-typed", SendOptions{TypeMode: true, TypeDelay: 5 * time.Millisecond, Redacted: true}); err != nil { + t.Fatalf("SendInput (type mode + redacted) failed: %v", err) + } + + time.Sleep(500 * time.Millisecond) + + output, _ := pm.GetOutput(id, 100) + if strings.Contains(output, "secret-typed") { + t.Errorf("redacted input should NOT appear in formatted output, got: %q", output) + } + if !strings.Contains(output, "********") { + t.Errorf("expected '********' for redacted input in output, got: %q", output) + } +} + +func TestProcessManager_TypeMode_NoEnter_Combined(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + // Type char by char without a trailing newline, then complete separately. + id, err := pm.Spawn("sh -c 'read -r line; echo got-$line'", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + if err := pm.SendInput(id, "combo", SendOptions{TypeMode: true, TypeDelay: 5 * time.Millisecond, NoEnter: true}); err != nil { + t.Fatalf("SendInput (type + no-enter) failed: %v", err) + } + + time.Sleep(200 * time.Millisecond) + + // Still no completed line. + entries, _ := pm.GetLogEntries(id, 100) + for _, e := range entries { + if e.Source == domain.SourceStdout && strings.Contains(e.Text, "got-") { + t.Errorf("did not expect 'got-' before newline, got: %q", e.Text) + } + } + + // Complete with newline. + if err := pm.SendInput(id, "", SendOptions{}); err != nil { + t.Fatalf("SendInput (newline) failed: %v", err) + } + + waitForOutput(t, pm, id, "got-combo", 5*time.Second) +} From a001eb5613af453bae573f3f9349525dca766e51 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 3 Apr 2026 21:34:33 +0200 Subject: [PATCH 2/7] docs: update README for --type, --delay, --no-enter flags on send --- README.md | 17 +++++++++++++++-- docs/README.md | 12 +++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7a44475..bd4d19f 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,21 @@ yoink log deploy --new # Send input to a process waiting for confirmation yoink send deploy "y" +# Send input character by character (simulates typing — great for asciinema recordings) +yoink send deploy "hello world" --type + +# Control typing speed with --delay (default 50ms) +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 sensitive input (passwords, tokens) — hidden from logs yoink send-redacted deploy "my-secret-token" +# Flags work on send-redacted too +yoink send-redacted deploy "my-secret" --type --delay 80 + # Full interactive reattach (Ctrl+] to detach) yoink attach deploy @@ -66,8 +78,8 @@ yoink runs a lightweight daemon that holds PTY (pseudo-terminal) sessions open f - **ls**: Lists all processes with state (running/done/failed), PID, exit code - **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) -- **send**: Writes text to the process stdin (fire-and-forget) -- **send-redacted**: Same as send, but input is masked in logs +- **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 - **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 @@ -79,6 +91,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 | | attach (PTY reattach) | Yes | Yes | Planned | ## Development diff --git a/docs/README.md b/docs/README.md index e5d1c80..1de4fe0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,11 +5,13 @@ yoink uses a daemon/client architecture: ``` -yoink run "cmd" --> yoinkd (daemon) --> PTY --> child process -yoink ls --> yoinkd --> process table -yoink log 1 --> yoinkd --> ring buffer (last N lines) -yoink send 1 "y" --> yoinkd --> write to PTY fd -yoink attach 1 --> yoinkd --> bridge terminal <-> PTY +yoink run "cmd" --> yoinkd (daemon) --> PTY --> child process +yoink ls --> yoinkd --> process table +yoink log 1 --> yoinkd --> ring buffer (last N lines) +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 attach 1 --> yoinkd --> bridge terminal <-> PTY ``` ### Daemon From 1284a798f3e433a1dbe3d1214ca95004fd3dfdab Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 3 Apr 2026 22:18:34 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20add=20snapshot=20command=20?= =?UTF-8?q?=E2=80=94=20VT100=20screen=20render=20for=20TUI=20processes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-process vt10x VT100 emulator that runs alongside the existing ring buffer. Raw PTY bytes are fed to the emulator in captureOutput so it always reflects the current rendered screen state. New command: yoink snapshot (alias: snap) - Renders the current terminal frame as clean plain text - Works correctly for TUI apps (htop, vim, Claude, etc.) where yoink log would return raw cursor-movement escape garbage - Works on both running and finished processes (last rendered frame) - Trims trailing whitespace per line and trailing blank rows 7 unit tests + 3 integration tests including top as a real TUI process, the canonical proof that snapshot > log for full-screen applications. --- README.md | 7 ++ actions/snapshot.go | 46 ++++++++ claude-chat.sh | 29 +++++ demo_type.sh | 78 ++++++++++++++ docs/README.md | 8 ++ domain/types.go | 1 + go.mod | 1 + go.sum | 2 + integration/cli_test.go | 88 +++++++++++++++ main.go | 7 ++ services/daemon_service.go | 12 +++ services/process_manager.go | 37 +++++++ services/process_manager_test.go | 179 +++++++++++++++++++++++++++++++ 13 files changed, 495 insertions(+) create mode 100644 actions/snapshot.go create mode 100755 claude-chat.sh create mode 100755 demo_type.sh diff --git a/README.md b/README.md index bd4d19f..ba901e4 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ yoink log deploy --lines 100 # Show only new lines since last --new call yoink log deploy --new +# Render the current screen of any process — works correctly for TUI apps +# (yoink log gives raw escape garbage for TUIs; snapshot renders the actual frame) +yoink snapshot htop +yoink snap htop # short alias + # Send input to a process waiting for confirmation yoink send deploy "y" @@ -78,6 +83,7 @@ yoink runs a lightweight daemon that holds PTY (pseudo-terminal) sessions open f - **ls**: Lists all processes with state (running/done/failed), PID, exit code - **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 - **attach**: Bridges your terminal to the process PTY for full interactive control @@ -92,6 +98,7 @@ The daemon auto-starts when needed and stores its socket at `~/.yoink/yoink.sock |---------|-------|-------|---------| | run/ls/log/wait/send/send-redacted/kill/clean | Yes | Yes | Planned | | send --type / --delay / --no-enter | Yes | Yes | Planned | +| snapshot (VT100 screen render) | Yes | Yes | Planned | | attach (PTY reattach) | Yes | Yes | Planned | ## Development diff --git a/actions/snapshot.go b/actions/snapshot.go new file mode 100644 index 0000000..8c78742 --- /dev/null +++ b/actions/snapshot.go @@ -0,0 +1,46 @@ +package actions + +import ( + "fmt" + + "github.com/AxeForging/yoink/domain" + "github.com/AxeForging/yoink/services" + "github.com/urfave/cli" +) + +// SnapshotAction renders the current VT100 screen of a process as plain text. +type SnapshotAction struct { + client *services.ClientService +} + +// NewSnapshotAction creates a SnapshotAction. +func NewSnapshotAction(client *services.ClientService) *SnapshotAction { + return &SnapshotAction{client: client} +} + +// Execute prints the rendered screen of the process to stdout. +func (a *SnapshotAction) Execute(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("usage: yoink snapshot ") + } + + id, alias := parseProcessRef(c.Args().First()) + + resp, err := a.client.Do(domain.DaemonRequest{ + Type: domain.ReqSnapshot, + ID: id, + Alias: alias, + }) + if err != nil { + return err + } + if !resp.Success { + return fmt.Errorf("%s", resp.Error) + } + + fmt.Print(resp.Output) + if len(resp.Output) > 0 && resp.Output[len(resp.Output)-1] != '\n' { + fmt.Println() + } + return nil +} diff --git a/claude-chat.sh b/claude-chat.sh new file mode 100755 index 0000000..5e664f8 --- /dev/null +++ b/claude-chat.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Plain-text Claude session — no TUI, works cleanly with yoink log +set -euo pipefail + +PNK=$'\033[38;5;212m' +GRN=$'\033[38;5;84m' +DIM=$'\033[2m' +RST=$'\033[0m' + +printf '%s\n' "${PNK}Claude Chat${RST} ${DIM}· plain-text session via yoink${RST}" +printf '%s\n' "${DIM}────────────────────────────────${RST}" +printf '\n' + +while IFS= read -r prompt; do + [[ -z "$prompt" ]] && continue + if [[ "$prompt" == "/exit" || "$prompt" == "exit" ]]; then + printf '%s\n' "${DIM}Session ended.${RST}" + exit 0 + fi + if [[ "$prompt" == "/plugins" || "$prompt" == "/tools" ]]; then + printf '%s\n' "${PNK}available tools (from claude --print):${RST}" + claude --print "list the tools and capabilities you have available in one short paragraph" 2>/dev/null + printf '\n' + continue + fi + printf '%s ' "${GRN}claude>${RST}" + claude --print "$prompt" 2>/dev/null + printf '\n' +done diff --git a/demo_type.sh b/demo_type.sh new file mode 100755 index 0000000..d653634 --- /dev/null +++ b/demo_type.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# yoink --type / --delay / --no-enter demo +# A real Claude session managed entirely through yoink. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +export PATH="$SCRIPT_DIR:$PATH" +export YOINK_SOCK="/tmp/yoink-demo-$$.sock" + +# ── colours ─────────────────────────────────────────────────────────────────── +DIM=$'\033[2m' +RST=$'\033[0m' +GRN=$'\033[38;5;84m' + +# ── helpers ─────────────────────────────────────────────────────────────────── + +# Type a command char by char, then execute it +run() { + printf "${GRN}❯${RST} " + for ((i=0; i<${#1}; i++)); do + printf '%s' "${1:$i:1}" + sleep 0.062 + done + printf '\n' + sleep 0.12 + eval "$1" +} + +comment() { printf "\n${DIM}# %s${RST}\n" "$1"; sleep 0.5; } + +# Drain --new cursor without displaying (swallows input echo and startup noise) +drain() { yoink log "$1" --new > /dev/null 2>&1 || true; } + +# ── scene ───────────────────────────────────────────────────────────────────── +clear +sleep 0.4 + +# 1. spawn the Claude session +comment "start a Claude session in the background" +run "yoink run --alias ai \"./claude-chat.sh\"" +sleep 2.5 +run "yoink log ai" +drain ai +sleep 0.8 + +# 2. first question — typing simulation +comment "send a prompt — every character typed individually" +run "yoink send ai \"name 3 essential unix tools, comma separated\" --type --delay 60" +sleep 0.5; drain ai # drain echo entries before response arrives +sleep 8 # wait for claude --print response (~5s + buffer) +run "yoink log ai --new" +sleep 1.0 + +# 3. ask what tools Claude has +comment "ask what tools Claude has available" +run "yoink send ai \"/plugins\" --type --delay 60" +sleep 0.5; drain ai +sleep 8 +run "yoink log ai --new" +sleep 1.0 + +# 4. --no-enter: compose across two sends +comment "--no-enter: hold the line open, then complete it" +run "yoink send ai \"what about Go —\" --type --delay 65 --no-enter" +sleep 0.3 +run "yoink send ai \" top 2 tools, one line\" --type --delay 65" +sleep 0.5; drain ai +sleep 8 +run "yoink log ai --new" +sleep 1.0 + +# 5. exit +comment "done — exit the session" +run "yoink send ai \"/exit\" --type --delay 70" +sleep 1.5 +run "yoink clean" +sleep 0.4 diff --git a/docs/README.md b/docs/README.md index 1de4fe0..ad35f77 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ yoink uses a daemon/client architecture: yoink run "cmd" --> yoinkd (daemon) --> PTY --> child process yoink ls --> yoinkd --> process table yoink log 1 --> yoinkd --> ring buffer (last N lines) +yoink snapshot 1 --> yoinkd --> VT100 emulator framebuffer (rendered screen) 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 @@ -19,9 +20,16 @@ yoink attach 1 --> yoinkd --> bridge terminal <-> PTY The daemon (`yoink daemon`) listens on `~/.yoink/yoink.sock`. It manages: - Process spawning with PTY allocation - Output ring buffers (1000 lines per process) +- VT100 emulator per process (for `snapshot` support) - Process lifecycle monitoring - Attach/detach multiplexing +### Snapshot vs Log + +`yoink log` reads raw lines from the ring buffer. For plain processes this is clean text, but TUI applications (htop, vim, any ncurses app) write cursor-movement and erase sequences that make `log` output unreadable. + +`yoink snapshot` feeds all PTY bytes through a `vt10x` VT100 emulator maintained per process. At any point you can call `snapshot` to get the current rendered screen as plain text — exactly what you'd see if you were attached to the terminal. It works on running and finished processes alike. + ### Protocol Client-daemon communication uses JSON over Unix socket. Each connection handles one request-response pair, except for `attach` which switches to raw byte forwarding after the handshake. diff --git a/domain/types.go b/domain/types.go index daeeb4b..cd554b5 100644 --- a/domain/types.go +++ b/domain/types.go @@ -50,6 +50,7 @@ const ( ReqKill RequestType = "kill" ReqClean RequestType = "clean" ReqShutdown RequestType = "shutdown" + ReqSnapshot RequestType = "snapshot" ) // DaemonRequest is sent from client to daemon over the Unix socket. diff --git a/go.mod b/go.mod index a084b31..3a4fb4e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/creack/pty v1.1.24 + github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 github.com/rs/zerolog v1.34.0 github.com/urfave/cli v1.22.17 golang.org/x/term v0.41.0 diff --git a/go.sum b/go.sum index f40450a..e93114f 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= diff --git a/integration/cli_test.go b/integration/cli_test.go index 7c3691b..7ae2835 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -515,3 +515,91 @@ func TestIntegration_SendNoEnter(t *testing.T) { t.Errorf("expected 'got-hello' after newline, got: %q", logResp.Output) } } + +func TestIntegration_Snapshot_PlainProcess(t *testing.T) { + env := newTestEnv(t) + + env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqRun, + Command: "sh -c 'printf \"\\033[2J\\033[3;5Hsnap-integration-test\"; sleep 300'", + Alias: "tui", + }) + + time.Sleep(400 * time.Millisecond) + + out, err := env.run("snapshot", "tui") + if err != nil { + t.Fatalf("snapshot failed: %v\noutput: %s", err, out) + } + if !strings.Contains(out, "snap-integration-test") { + t.Errorf("expected 'snap-integration-test' in snapshot, got: %q", out) + } +} + +func TestIntegration_Snapshot_ByAlias(t *testing.T) { + env := newTestEnv(t) + + env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqRun, + Command: "sh -c 'echo alias-snap-check; sleep 300'", + Alias: "checker", + }) + + time.Sleep(400 * time.Millisecond) + + out, err := env.run("snap", "checker") // test the "snap" alias + if err != nil { + t.Fatalf("snap alias failed: %v\noutput: %s", err, out) + } + if !strings.Contains(out, "alias-snap-check") { + t.Errorf("expected 'alias-snap-check' in snapshot, got: %q", out) + } +} + +func TestIntegration_Snapshot_TopCommand(t *testing.T) { + if _, err := exec.LookPath("top"); err != nil { + t.Skip("top not available on this system") + } + + env := newTestEnv(t) + + // Limit to 18 lines so output stays within the 24-row VT buffer regardless + // of how many processes are running. Works on Linux and macOS. + env.sendDaemonRequest(domain.DaemonRequest{ + Type: domain.ReqRun, + Command: "sh -c 'top -b -n 1 2>/dev/null | head -18 || top -l 1 2>/dev/null | head -18'", + Alias: "top", + }) + + // Wait for top to finish its single batch iteration. + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + time.Sleep(300 * time.Millisecond) + listResp := env.sendDaemonRequest(domain.DaemonRequest{Type: domain.ReqList}) + for _, p := range listResp.Processes { + if p.Alias == "top" && p.State == domain.StateDone { + goto ready + } + } + } + t.Fatal("timed out waiting for top to finish") +ready: + + out, err := env.run("snapshot", "top") + if err != nil { + t.Fatalf("snapshot of top failed: %v\noutput: %s", err, out) + } + + // top output always contains these fields regardless of platform + for _, want := range []string{"PID", "%"} { + if !strings.Contains(out, want) { + t.Errorf("expected %q in top snapshot, got: %q", want, out) + } + } + + // Prove the value: log would give raw escape garbage, snapshot gives clean text. + // Verify there are no raw CSI escape sequences in the snapshot output. + if strings.Contains(out, "\x1b[") { + t.Errorf("snapshot should not contain raw ANSI escape sequences, got: %q", out) + } +} diff --git a/main.go b/main.go index 735284b..c79002c 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ func main() { logAction := actions.NewLogAction(client) sendAction := actions.NewSendAction(client, false) sendRedactedAction := actions.NewSendAction(client, true) + snapshotAction := actions.NewSnapshotAction(client) attachAction := actions.NewAttachAction(client) waitAction := actions.NewWaitAction(client) killAction := actions.NewKillAction(client) @@ -101,6 +102,12 @@ func main() { Flags: []cli.Flag{typeModeFlag, typeDelayFlag, noEnterFlag}, Action: sendRedactedAction.Execute, }, + { + Name: "snapshot", + Aliases: []string{"snap"}, + Usage: "Render the current VT100 screen of a process as plain text", + Action: snapshotAction.Execute, + }, { Name: "attach", Aliases: []string{"a"}, diff --git a/services/daemon_service.go b/services/daemon_service.go index 3d9e988..7bfc141 100644 --- a/services/daemon_service.go +++ b/services/daemon_service.go @@ -103,6 +103,8 @@ func (ds *DaemonServer) handleConnection(conn net.Conn) { case domain.ReqAttach: ds.handleAttach(conn, req) return + case domain.ReqSnapshot: + ds.handleSnapshot(conn, req) case domain.ReqKill: ds.handleKill(conn, req) case domain.ReqClean: @@ -199,6 +201,16 @@ func (ds *DaemonServer) handleAttach(conn net.Conn, req domain.DaemonRequest) { conn.Close() } +func (ds *DaemonServer) handleSnapshot(conn net.Conn, req domain.DaemonRequest) { + defer conn.Close() + output, err := ds.pm.Snapshot(req.ID) + if err != nil { + ds.sendResponse(conn, domain.DaemonResponse{Error: err.Error()}) + return + } + ds.sendResponse(conn, domain.DaemonResponse{Success: true, Output: output}) +} + func (ds *DaemonServer) handleKill(conn net.Conn, req domain.DaemonRequest) { defer conn.Close() if err := ds.pm.Kill(req.ID, req.Force); err != nil { diff --git a/services/process_manager.go b/services/process_manager.go index 68b20d8..535d3a4 100644 --- a/services/process_manager.go +++ b/services/process_manager.go @@ -10,6 +10,7 @@ import ( "time" "github.com/AxeForging/yoink/domain" + "github.com/hinshun/vt10x" ) type managedProcess struct { @@ -17,6 +18,7 @@ type managedProcess struct { pty io.ReadWriteCloser cmd *exec.Cmd buffer *RingBuffer + vt vt10x.Terminal // VT100 emulator for snapshot support attachMu sync.Mutex attachOut io.Writer @@ -80,6 +82,7 @@ func (pm *ProcessManager) Spawn(command, alias string) (int, error) { pty: ptmx, cmd: cmd, buffer: buf, + vt: vt10x.New(vt10x.WithSize(80, 24)), } pm.mu.Lock() @@ -125,6 +128,8 @@ func (pm *ProcessManager) captureOutput(mp *managedProcess) { } mp.attachMu.Unlock() + mp.vt.Write(data) //nolint:errcheck + partial += string(data) for { idx := strings.Index(partial, "\n") @@ -303,6 +308,38 @@ func FormatLogEntries(entries []domain.LogEntry) string { return strings.Join(lines, "\n") } +// Snapshot returns the current rendered screen of the process as plain text. +// It works for both running and finished processes, and correctly handles TUI +// applications by replaying their escape sequences through a VT100 emulator. +func (pm *ProcessManager) Snapshot(id int) (string, error) { + pm.mu.RLock() + mp, ok := pm.processes[id] + pm.mu.RUnlock() + if !ok { + return "", fmt.Errorf("process %d not found", id) + } + return renderScreen(mp.vt), nil +} + +// renderScreen renders the VT100 terminal state into clean plain text, +// trimming trailing whitespace per line and trailing blank lines. +func renderScreen(term vt10x.Terminal) string { + raw := term.String() // String() locks the terminal internally + lines := strings.Split(raw, "\n") + + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + + // Drop trailing blank lines + end := len(lines) + for end > 0 && lines[end-1] == "" { + end-- + } + + return strings.Join(lines[:end], "\n") +} + // Kill sends a signal to the process. Use force=true for SIGKILL. func (pm *ProcessManager) Kill(id int, force bool) error { pm.mu.RLock() diff --git a/services/process_manager_test.go b/services/process_manager_test.go index d3c86bd..48b67f4 100644 --- a/services/process_manager_test.go +++ b/services/process_manager_test.go @@ -3,6 +3,7 @@ package services import ( + "os/exec" "strings" "testing" "time" @@ -641,3 +642,181 @@ func TestProcessManager_TypeMode_NoEnter_Combined(t *testing.T) { waitForOutput(t, pm, id, "got-combo", 5*time.Second) } + +// --- Snapshot tests --- + +func TestProcessManager_Snapshot_PlainText(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + // A plain process with no TUI — snapshot should still show its output. + id, err := pm.Spawn("sh -c 'echo hello-snap; sleep 300'", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + waitForOutput(t, pm, id, "hello-snap", 5*time.Second) + + snap, err := pm.Snapshot(id) + if err != nil { + t.Fatalf("Snapshot failed: %v", err) + } + if !strings.Contains(snap, "hello-snap") { + t.Errorf("expected 'hello-snap' in snapshot, got:\n%s", snap) + } +} + +func TestProcessManager_Snapshot_PositionedText(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + // Use ANSI cursor positioning — yoink log would show raw escape garbage, + // but snapshot should give clean rendered text at the correct coordinates. + id, err := pm.Spawn(`sh -c 'printf "\033[2J\033[3;5Hyoink-tui-test\033[5;1Hsecond-line"; sleep 300'`, "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(500 * time.Millisecond) + + snap, err := pm.Snapshot(id) + if err != nil { + t.Fatalf("Snapshot failed: %v", err) + } + if !strings.Contains(snap, "yoink-tui-test") { + t.Errorf("expected 'yoink-tui-test' at row 3 in snapshot, got:\n%s", snap) + } + if !strings.Contains(snap, "second-line") { + t.Errorf("expected 'second-line' at row 5 in snapshot, got:\n%s", snap) + } + + // Verify positioning: "yoink-tui-test" should appear before "second-line" + lines := strings.Split(snap, "\n") + row3, row5 := -1, -1 + for i, line := range lines { + if strings.Contains(line, "yoink-tui-test") { + row3 = i + } + if strings.Contains(line, "second-line") { + row5 = i + } + } + if row3 < 0 || row5 < 0 || row3 >= row5 { + t.Errorf("expected 'yoink-tui-test' (row %d) before 'second-line' (row %d)", row3, row5) + } +} + +func TestProcessManager_Snapshot_ClearAndRedraw(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + // Process that draws one thing, then clears and draws something else. + // Snapshot should reflect the FINAL screen state, not the history. + // yoink log would show both; snapshot shows only what's currently rendered. + id, err := pm.Spawn(`sh -c 'printf "initial-content"; sleep 0.2; printf "\033[2Jfinal-content"; sleep 300'`, "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(600 * time.Millisecond) + + snap, err := pm.Snapshot(id) + if err != nil { + t.Fatalf("Snapshot failed: %v", err) + } + if !strings.Contains(snap, "final-content") { + t.Errorf("expected 'final-content' in snapshot, got:\n%s", snap) + } + if strings.Contains(snap, "initial-content") { + t.Errorf("expected 'initial-content' to be cleared from snapshot, got:\n%s", snap) + } +} + +func TestProcessManager_Snapshot_FinishedProcess(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + // Snapshot works on finished processes — the VT holds the last frame. + id, err := pm.Spawn("echo snap-after-exit", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + waitForState(t, pm, id, domain.StateDone, 5*time.Second) + + snap, err := pm.Snapshot(id) + if err != nil { + t.Fatalf("Snapshot of finished process failed: %v", err) + } + if !strings.Contains(snap, "snap-after-exit") { + t.Errorf("expected 'snap-after-exit' in snapshot of finished process, got:\n%s", snap) + } +} + +func TestProcessManager_Snapshot_NotFound(t *testing.T) { + pm := NewProcessManager(100) + _, err := pm.Snapshot(999) + if err == nil { + t.Error("expected error for non-existent process") + } +} + +func TestProcessManager_Snapshot_ScreenDimensions(t *testing.T) { + pm := NewProcessManager(100) + defer pm.Shutdown() + + // VT emulator is 80×24. Content placed beyond col 80 or row 24 is clipped. + // Content within bounds must be reachable via snapshot. + id, err := pm.Spawn(`sh -c 'printf "\033[24;1Hlast-row"; sleep 300'`, "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + time.Sleep(300 * time.Millisecond) + + snap, err := pm.Snapshot(id) + if err != nil { + t.Fatalf("Snapshot failed: %v", err) + } + if !strings.Contains(snap, "last-row") { + t.Errorf("expected text at last row (row 24) in snapshot, got:\n%s", snap) + } +} + +func TestProcessManager_Snapshot_TopCommand(t *testing.T) { + // top is the canonical TUI process — log gives garbage, snapshot gives clean text. + if _, err := exec.LookPath("top"); err != nil { + t.Skip("top not available on this system") + } + + pm := NewProcessManager(100) + defer pm.Shutdown() + + // Limit to 18 lines so output fits within the 24-row VT buffer regardless + // of how many processes are running. Works on both Linux (top -b -n 1) and + // macOS (top -l 1). The column-header row (with "PID") is always within + // the first 10 lines of top output, well inside the 18-line window. + id, err := pm.Spawn("sh -c 'top -b -n 1 2>/dev/null | head -18 || top -l 1 2>/dev/null | head -18'", "") + if err != nil { + t.Fatalf("Spawn failed: %v", err) + } + + waitForState(t, pm, id, domain.StateDone, 10*time.Second) + + snap, err := pm.Snapshot(id) + if err != nil { + t.Fatalf("Snapshot of top failed: %v", err) + } + + // top output always contains these header fields within the first 18 lines + for _, want := range []string{"PID", "%"} { + if !strings.Contains(snap, want) { + t.Errorf("expected %q in top snapshot, got:\n%s", want, snap) + } + } + + // Key value: no raw ANSI escape sequences in snapshot output + if strings.Contains(snap, "\x1b[") { + t.Errorf("snapshot must not contain raw ANSI escape sequences, got:\n%s", snap) + } +} From 80bf503377ef3e71f0a2b4a9dc183bd7001344e2 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 3 Apr 2026 22:23:29 +0200 Subject: [PATCH 4/7] fix: move demo scripts to scripts/, allow .sh in structlint --- .structlint.yaml | 2 ++ claude-chat.sh => scripts/claude-chat.sh | 0 demo_type.sh => scripts/demo_type.sh | 0 3 files changed, 2 insertions(+) rename claude-chat.sh => scripts/claude-chat.sh (100%) rename demo_type.sh => scripts/demo_type.sh (100%) diff --git a/.structlint.yaml b/.structlint.yaml index 16201bb..f4f79ca 100644 --- a/.structlint.yaml +++ b/.structlint.yaml @@ -8,6 +8,7 @@ dir_structure: - "helpers/**" - "integration/**" - "docs/**" + - "scripts/**" - ".github/**" - ".claude/**" disallowedPaths: @@ -27,6 +28,7 @@ file_naming_pattern: - "*.go" - "*.mod" - "*.sum" + - "*.sh" - "*.yaml" - "*.yml" - "*.json" diff --git a/claude-chat.sh b/scripts/claude-chat.sh similarity index 100% rename from claude-chat.sh rename to scripts/claude-chat.sh diff --git a/demo_type.sh b/scripts/demo_type.sh similarity index 100% rename from demo_type.sh rename to scripts/demo_type.sh From 35607e5cc63503d835cb56e2a61d6f6aec07bb7f Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 3 Apr 2026 22:26:13 +0200 Subject: [PATCH 5/7] chore: remove demo scripts --- scripts/claude-chat.sh | 29 ---------------- scripts/demo_type.sh | 78 ------------------------------------------ 2 files changed, 107 deletions(-) delete mode 100755 scripts/claude-chat.sh delete mode 100755 scripts/demo_type.sh diff --git a/scripts/claude-chat.sh b/scripts/claude-chat.sh deleted file mode 100755 index 5e664f8..0000000 --- a/scripts/claude-chat.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Plain-text Claude session — no TUI, works cleanly with yoink log -set -euo pipefail - -PNK=$'\033[38;5;212m' -GRN=$'\033[38;5;84m' -DIM=$'\033[2m' -RST=$'\033[0m' - -printf '%s\n' "${PNK}Claude Chat${RST} ${DIM}· plain-text session via yoink${RST}" -printf '%s\n' "${DIM}────────────────────────────────${RST}" -printf '\n' - -while IFS= read -r prompt; do - [[ -z "$prompt" ]] && continue - if [[ "$prompt" == "/exit" || "$prompt" == "exit" ]]; then - printf '%s\n' "${DIM}Session ended.${RST}" - exit 0 - fi - if [[ "$prompt" == "/plugins" || "$prompt" == "/tools" ]]; then - printf '%s\n' "${PNK}available tools (from claude --print):${RST}" - claude --print "list the tools and capabilities you have available in one short paragraph" 2>/dev/null - printf '\n' - continue - fi - printf '%s ' "${GRN}claude>${RST}" - claude --print "$prompt" 2>/dev/null - printf '\n' -done diff --git a/scripts/demo_type.sh b/scripts/demo_type.sh deleted file mode 100755 index d653634..0000000 --- a/scripts/demo_type.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# yoink --type / --delay / --no-enter demo -# A real Claude session managed entirely through yoink. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$SCRIPT_DIR" -export PATH="$SCRIPT_DIR:$PATH" -export YOINK_SOCK="/tmp/yoink-demo-$$.sock" - -# ── colours ─────────────────────────────────────────────────────────────────── -DIM=$'\033[2m' -RST=$'\033[0m' -GRN=$'\033[38;5;84m' - -# ── helpers ─────────────────────────────────────────────────────────────────── - -# Type a command char by char, then execute it -run() { - printf "${GRN}❯${RST} " - for ((i=0; i<${#1}; i++)); do - printf '%s' "${1:$i:1}" - sleep 0.062 - done - printf '\n' - sleep 0.12 - eval "$1" -} - -comment() { printf "\n${DIM}# %s${RST}\n" "$1"; sleep 0.5; } - -# Drain --new cursor without displaying (swallows input echo and startup noise) -drain() { yoink log "$1" --new > /dev/null 2>&1 || true; } - -# ── scene ───────────────────────────────────────────────────────────────────── -clear -sleep 0.4 - -# 1. spawn the Claude session -comment "start a Claude session in the background" -run "yoink run --alias ai \"./claude-chat.sh\"" -sleep 2.5 -run "yoink log ai" -drain ai -sleep 0.8 - -# 2. first question — typing simulation -comment "send a prompt — every character typed individually" -run "yoink send ai \"name 3 essential unix tools, comma separated\" --type --delay 60" -sleep 0.5; drain ai # drain echo entries before response arrives -sleep 8 # wait for claude --print response (~5s + buffer) -run "yoink log ai --new" -sleep 1.0 - -# 3. ask what tools Claude has -comment "ask what tools Claude has available" -run "yoink send ai \"/plugins\" --type --delay 60" -sleep 0.5; drain ai -sleep 8 -run "yoink log ai --new" -sleep 1.0 - -# 4. --no-enter: compose across two sends -comment "--no-enter: hold the line open, then complete it" -run "yoink send ai \"what about Go —\" --type --delay 65 --no-enter" -sleep 0.3 -run "yoink send ai \" top 2 tools, one line\" --type --delay 65" -sleep 0.5; drain ai -sleep 8 -run "yoink log ai --new" -sleep 1.0 - -# 5. exit -comment "done — exit the session" -run "yoink send ai \"/exit\" --type --delay 70" -sleep 1.5 -run "yoink clean" -sleep 0.4 From e9d3140be35a96f6ad85b46844149170127189a8 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Fri, 3 Apr 2026 23:19:17 +0200 Subject: [PATCH 6/7] fix: correct PTY/VT size and strip kitty keyboard sequences from vt10x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set PTY and VT emulator to 220×50 (matched) so rendered screen geometry is consistent. Strip CSI sequences with non-digit prefix bytes (>, <, =) before feeding to vt10x — these are kitty keyboard protocol frames that vt10x misinterprets as DECRC (restore cursor), jumping the cursor to (0,0) and corrupting subsequent rendering in TUI apps. Adds regression tests for stripVTProblematic covering normal passthrough, each problematic prefix variant, and mixed interleaving. --- services/platform_unix.go | 3 +- services/process_manager.go | 33 ++++++++++++- services/process_manager_test.go | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/services/platform_unix.go b/services/platform_unix.go index ff9047f..0cc7bf1 100644 --- a/services/platform_unix.go +++ b/services/platform_unix.go @@ -11,8 +11,9 @@ import ( ) // StartProcessWithPTY starts the command with a pseudo-terminal attached. +// The PTY size is set to match the VT emulator size in process_manager.go (220×50). func StartProcessWithPTY(cmd *exec.Cmd) (*os.File, error) { - return pty.Start(cmd) + return pty.StartWithSize(cmd, &pty.Winsize{Rows: 50, Cols: 220}) } // DaemonSysProcAttr returns SysProcAttr for daemonizing on Unix (new session). diff --git a/services/process_manager.go b/services/process_manager.go index 535d3a4..401ac4d 100644 --- a/services/process_manager.go +++ b/services/process_manager.go @@ -82,7 +82,7 @@ func (pm *ProcessManager) Spawn(command, alias string) (int, error) { pty: ptmx, cmd: cmd, buffer: buf, - vt: vt10x.New(vt10x.WithSize(80, 24)), + vt: vt10x.New(vt10x.WithSize(220, 50)), } pm.mu.Lock() @@ -128,7 +128,7 @@ func (pm *ProcessManager) captureOutput(mp *managedProcess) { } mp.attachMu.Unlock() - mp.vt.Write(data) //nolint:errcheck + mp.vt.Write(stripVTProblematic(data)) //nolint:errcheck partial += string(data) for { @@ -321,6 +321,35 @@ func (pm *ProcessManager) Snapshot(id int) (string, error) { return renderScreen(mp.vt), nil } +// stripVTProblematic removes CSI sequences that vt10x misinterprets due to +// non-digit parameter prefix bytes ('>','<','='). These are kitty keyboard +// protocol sequences (CSI > n u, CSI < u, CSI > n m, CSI > n q) which have +// no visual effect but cause vt10x to call restoreCursor(), jumping the cursor +// to (0,0) and corrupting subsequent rendering. +func stripVTProblematic(data []byte) []byte { + out := make([]byte, 0, len(data)) + i := 0 + for i < len(data) { + if i+1 < len(data) && data[i] == 0x1b && data[i+1] == '[' { + j := i + 2 + if j < len(data) && (data[j] == '>' || data[j] == '<' || data[j] == '=') { + // Skip to final byte (0x40–0x7E) inclusive + for j < len(data) && (data[j] < 0x40 || data[j] > 0x7E) { + j++ + } + if j < len(data) { + j++ // include final byte in skip + } + i = j + continue + } + } + out = append(out, data[i]) + i++ + } + return out +} + // renderScreen renders the VT100 terminal state into clean plain text, // trimming trailing whitespace per line and trailing blank lines. func renderScreen(term vt10x.Terminal) string { diff --git a/services/process_manager_test.go b/services/process_manager_test.go index 48b67f4..d8445f0 100644 --- a/services/process_manager_test.go +++ b/services/process_manager_test.go @@ -820,3 +820,82 @@ func TestProcessManager_Snapshot_TopCommand(t *testing.T) { t.Errorf("snapshot must not contain raw ANSI escape sequences, got:\n%s", snap) } } + +func TestStripVTProblematic(t *testing.T) { + tests := []struct { + name string + input []byte + want []byte + }{ + { + name: "plain text passes through", + input: []byte("hello world"), + want: []byte("hello world"), + }, + { + name: "normal CSI erase screen passes through", + input: []byte("\x1b[2J"), + want: []byte("\x1b[2J"), + }, + { + name: "normal CSI color passes through", + input: []byte("\x1b[31mred\x1b[0m"), + want: []byte("\x1b[31mred\x1b[0m"), + }, + { + name: "normal CSI cursor move passes through", + input: []byte("\x1b[1;1H"), + want: []byte("\x1b[1;1H"), + }, + { + // kitty keyboard protocol: CSI > 1 u (enable keyboard enhancement) + name: "CSI > 1 u stripped", + input: []byte("\x1b[>1u"), + want: []byte{}, + }, + { + // kitty keyboard protocol: CSI < u (disable keyboard enhancement) + name: "CSI < u stripped", + input: []byte("\x1b[ 4;2 m (modifyOtherKeys) + name: "CSI > 4;2 m stripped", + input: []byte("\x1b[>4;2m"), + want: []byte{}, + }, + { + // kitty keyboard: CSI = 1 u (push flags) + name: "CSI = 1 u stripped", + input: []byte("\x1b[=1u"), + want: []byte{}, + }, + { + // kitty sequence surrounded by normal output + name: "kitty sequence stripped from surrounding text", + input: []byte("before\x1b[>1uafter"), + want: []byte("beforeafter"), + }, + { + // multiple problematic sequences interleaved with normal ones + name: "multiple sequences mixed", + input: []byte("\x1b[2J\x1b[>1u\x1b[H\x1b[ Date: Fri, 3 Apr 2026 23:22:13 +0200 Subject: [PATCH 7/7] fix: strip ANSI escape sequences from log output to prevent terminal corruption TUI processes (gemini, claude, htop, etc.) emit sequences like \x1b[?25l (hide cursor) that execute in the caller's terminal when yoink log prints them, breaking cursor visibility and display. stripANSI() removes all CSI, OSC, and two-byte ESC sequences from log text at format time, leaving the raw ring buffer untouched. Adds regression tests covering hide/show cursor, color codes, OSC titles, alternate screen, and cursor positioning sequences. --- services/process_manager.go | 57 ++++++++++++++++++++++++- services/process_manager_test.go | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/services/process_manager.go b/services/process_manager.go index 401ac4d..37948aa 100644 --- a/services/process_manager.go +++ b/services/process_manager.go @@ -293,6 +293,8 @@ func (pm *ProcessManager) SendInput(id int, input string, opts SendOptions) erro } // FormatLogEntries formats log entries with source labels and redaction. +// ANSI/VT escape sequences are stripped so that log output never corrupts the +// caller's terminal (e.g. \x1b[?25l from a TUI process hiding the cursor). func FormatLogEntries(entries []domain.LogEntry) string { if len(entries) == 0 { return "" @@ -303,11 +305,64 @@ func FormatLogEntries(entries []domain.LogEntry) string { if e.Redacted { text = "********" } - lines[i] = fmt.Sprintf("[%s] %s", e.Source, text) + lines[i] = fmt.Sprintf("[%s] %s", e.Source, stripANSI(text)) } return strings.Join(lines, "\n") } +// stripANSI removes all ANSI/VT escape sequences from s so that the result is +// safe to print to any terminal without side-effects. It handles: +// - CSI sequences: ESC [ +// - OSC sequences: ESC ] ... BEL or ESC ] ... ESC \ +// - All other two-byte ESC sequences (Fe / Fs / Fp) +func stripANSI(s string) string { + b := []byte(s) + out := make([]byte, 0, len(b)) + i := 0 + for i < len(b) { + if b[i] != 0x1b || i+1 >= len(b) { + out = append(out, b[i]) + i++ + continue + } + next := b[i+1] + switch next { + case '[': // CSI — skip param/intermediate bytes then one final byte + j := i + 2 + // param bytes: 0x30–0x3F + for j < len(b) && b[j] >= 0x30 && b[j] <= 0x3F { + j++ + } + // intermediate bytes: 0x20–0x2F + for j < len(b) && b[j] >= 0x20 && b[j] <= 0x2F { + j++ + } + // final byte: 0x40–0x7E + if j < len(b) && b[j] >= 0x40 && b[j] <= 0x7E { + j++ + } + i = j + case ']': // OSC — skip until BEL or ESC \ + j := i + 2 + for j < len(b) { + if b[j] == 0x07 { // BEL + j++ + break + } + if b[j] == 0x1b && j+1 < len(b) && b[j+1] == '\\' { // ST + j += 2 + break + } + j++ + } + i = j + default: // any other two-byte ESC sequence + i += 2 + } + } + return string(out) +} + // Snapshot returns the current rendered screen of the process as plain text. // It works for both running and finished processes, and correctly handles TUI // applications by replaying their escape sequences through a VT100 emulator. diff --git a/services/process_manager_test.go b/services/process_manager_test.go index d8445f0..ff29a7d 100644 --- a/services/process_manager_test.go +++ b/services/process_manager_test.go @@ -821,6 +821,79 @@ func TestProcessManager_Snapshot_TopCommand(t *testing.T) { } } +func TestStripANSI(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "plain text unchanged", + input: "hello world", + want: "hello world", + }, + { + name: "color codes stripped", + input: "\x1b[31mred\x1b[0m", + want: "red", + }, + { + name: "hide cursor stripped (terminal corruption prevention)", + input: "\x1b[?25l", + want: "", + }, + { + name: "show cursor stripped", + input: "\x1b[?25h", + want: "", + }, + { + name: "cursor position stripped", + input: "\x1b[1;1H", + want: "", + }, + { + name: "erase display stripped", + input: "\x1b[2J", + want: "", + }, + { + name: "OSC title sequence stripped", + input: "\x1b]0;title\x07rest", + want: "rest", + }, + { + name: "OSC with string terminator stripped", + input: "\x1b]2;title\x1b\\rest", + want: "rest", + }, + { + name: "other two-byte ESC sequence stripped", + input: "\x1b=text", + want: "text", + }, + { + name: "text preserved between stripped sequences", + input: "\x1b[31mhello\x1b[0m world", + want: "hello world", + }, + { + name: "alternate screen escape stripped", + input: "\x1b[?1049htext\x1b[?1049l", + want: "text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripANSI(tt.input) + if got != tt.want { + t.Errorf("stripANSI(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestStripVTProblematic(t *testing.T) { tests := []struct { name string