Skip to content
Open
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
104 changes: 73 additions & 31 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,37 +39,38 @@ type CommandFunc func(c parser.Command, v *VHS) error

// CommandFuncs maps command types to their executable functions.
var CommandFuncs = map[parser.CommandType]CommandFunc{
token.BACKSPACE: ExecuteKey(input.Backspace),
token.DELETE: ExecuteKey(input.Delete),
token.INSERT: ExecuteKey(input.Insert),
token.DOWN: ExecuteKey(input.ArrowDown),
token.ENTER: ExecuteKey(input.Enter),
token.LEFT: ExecuteKey(input.ArrowLeft),
token.RIGHT: ExecuteKey(input.ArrowRight),
token.SPACE: ExecuteKey(input.Space),
token.UP: ExecuteKey(input.ArrowUp),
token.TAB: ExecuteKey(input.Tab),
token.ESCAPE: ExecuteKey(input.Escape),
token.PAGE_UP: ExecuteKey(input.PageUp),
token.PAGE_DOWN: ExecuteKey(input.PageDown),
token.SCROLL_UP: ExecuteScroll(-1),
token.SCROLL_DOWN: ExecuteScroll(1),
token.HIDE: ExecuteHide,
token.REQUIRE: ExecuteRequire,
token.SHOW: ExecuteShow,
token.SET: ExecuteSet,
token.OUTPUT: ExecuteOutput,
token.SLEEP: ExecuteSleep,
token.TYPE: ExecuteType,
token.CTRL: ExecuteCtrl,
token.ALT: ExecuteAlt,
token.SHIFT: ExecuteShift,
token.ILLEGAL: ExecuteNoop,
token.SCREENSHOT: ExecuteScreenshot,
token.COPY: ExecuteCopy,
token.PASTE: ExecutePaste,
token.ENV: ExecuteEnv,
token.WAIT: ExecuteWait,
token.BACKSPACE: ExecuteKey(input.Backspace),
token.DELETE: ExecuteKey(input.Delete),
token.INSERT: ExecuteKey(input.Insert),
token.DOWN: ExecuteKey(input.ArrowDown),
token.ENTER: ExecuteKey(input.Enter),
token.LEFT: ExecuteKey(input.ArrowLeft),
token.RIGHT: ExecuteKey(input.ArrowRight),
token.SPACE: ExecuteKey(input.Space),
token.UP: ExecuteKey(input.ArrowUp),
token.TAB: ExecuteKey(input.Tab),
token.ESCAPE: ExecuteKey(input.Escape),
token.PAGE_UP: ExecuteKey(input.PageUp),
token.PAGE_DOWN: ExecuteKey(input.PageDown),
token.SCROLL_UP: ExecuteScroll(-1),
token.SCROLL_DOWN: ExecuteScroll(1),
token.HIDE: ExecuteHide,
token.REQUIRE: ExecuteRequire,
token.SHOW: ExecuteShow,
token.SET: ExecuteSet,
token.OUTPUT: ExecuteOutput,
token.SLEEP: ExecuteSleep,
token.TYPE: ExecuteType,
token.CTRL: ExecuteCtrl,
token.ALT: ExecuteAlt,
token.SHIFT: ExecuteShift,
token.ILLEGAL: ExecuteNoop,
token.SCREENSHOT: ExecuteScreenshot,
token.COPY: ExecuteCopy,
token.PASTE: ExecutePaste,
token.ENV: ExecuteEnv,
token.AWAIT_PROMPT: ExecuteAwaitPrompt,
token.WAIT: ExecuteWait,
}

// ExecuteNoop is a no-op command that does nothing.
Expand Down Expand Up @@ -208,6 +209,47 @@ func ExecuteWait(c parser.Command, v *VHS) error {
}
}

// ExecuteAwaitPrompt waits for the shell to emit a new prompt marker.
// It detects prompt markers (OSC 133;A) that are embedded in each shell's prompt
// configuration. Unlike Wait (which matches terminal content), AwaitPrompt
// detects when the shell has finished executing a command and is ready for input.
func ExecuteAwaitPrompt(c parser.Command, v *VHS) error {
timeout := v.Options.WaitTimeout
if c.Options != "" {
t, err := time.ParseDuration(c.Options)
if err != nil {
return fmt.Errorf("failed to parse duration: %w", err)
}
timeout = t
}

// Record the current prompt count so we can detect the next one.
baseline, err := v.PromptCount()
if err != nil {
return fmt.Errorf("failed to read prompt count: %w", err)
}

checkT := time.NewTicker(WaitTick)
defer checkT.Stop()
timeoutT := time.NewTimer(timeout)
defer timeoutT.Stop()

for {
select {
case <-checkT.C:
current, err := v.PromptCount()
if err != nil {
return fmt.Errorf("failed to read prompt count: %w", err)
}
if current > baseline {
return nil
}
case <-timeoutT.C:
return fmt.Errorf("timeout waiting for shell prompt (waited %s)", timeout)
}
}
}

// ExecuteCtrl is a CommandFunc that presses the argument keys and/or modifiers
// with the ctrl key held down on the running instance of vhs.
func ExecuteCtrl(c parser.Command, v *VHS) error {
Expand Down
4 changes: 2 additions & 2 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import (
)

func TestCommand(t *testing.T) {
const numberOfCommands = 31
const numberOfCommands = 32
if len(parser.CommandTypes) != numberOfCommands {
t.Errorf("Expected %d commands, got %d", numberOfCommands, len(parser.CommandTypes))
}

const numberOfCommandFuncs = 31
const numberOfCommandFuncs = 32
if len(CommandFuncs) != numberOfCommandFuncs {
t.Errorf("Expected %d commands, got %d", numberOfCommandFuncs, len(CommandFuncs))
}
Expand Down
9 changes: 9 additions & 0 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ var CommandTypes = []CommandType{
token.TAB,
token.TYPE,
token.UP,
token.AWAIT_PROMPT,
token.WAIT,
token.SOURCE,
token.SCREENSHOT,
Expand Down Expand Up @@ -173,6 +174,8 @@ func (p *Parser) parseCommand() []Command {
return []Command{p.parseRequire()}
case token.SHOW:
return []Command{p.parseShow()}
case token.AWAIT_PROMPT:
return []Command{p.parseAwaitPrompt()}
case token.WAIT:
return []Command{p.parseWait()}
case token.SOURCE:
Expand Down Expand Up @@ -230,6 +233,12 @@ func (p *Parser) parseWait() Command {
return cmd
}

func (p *Parser) parseAwaitPrompt() Command {
cmd := Command{Type: token.AWAIT_PROMPT}
cmd.Options = p.parseSpeed()
return cmd
}

// parseSpeed parses a typing speed indication.
//
// i.e. @<time>
Expand Down
62 changes: 61 additions & 1 deletion parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ Sleep 100ms
Sleep 3
Wait
Wait+Screen
Wait@100ms /foobar/`
Wait@100ms /foobar/
AwaitPrompt
AwaitPrompt@30s`

expected := []Command{
{Type: token.SET, Options: "TypingSpeed", Args: "100ms"},
Expand Down Expand Up @@ -63,6 +65,8 @@ Wait@100ms /foobar/`
{Type: token.WAIT, Args: "Line"},
{Type: token.WAIT, Args: "Screen"},
{Type: token.WAIT, Options: "100ms", Args: "Line foobar"},
{Type: token.AWAIT_PROMPT},
{Type: token.AWAIT_PROMPT, Options: "30s"},
}

l := lexer.New(input)
Expand All @@ -87,6 +91,62 @@ Wait@100ms /foobar/`
}
}

func TestParseAwaitPrompt(t *testing.T) {
t.Run("bare AwaitPrompt", func(t *testing.T) {
l := lexer.New("AwaitPrompt")
p := New(l)
cmds := p.Parse()
if len(cmds) != 1 {
t.Fatalf("Expected 1 command, got %d", len(cmds))
}
if cmds[0].Type != token.AWAIT_PROMPT {
t.Errorf("Expected AWAIT_PROMPT, got %s", cmds[0].Type)
}
if cmds[0].Options != "" {
t.Errorf("Expected empty options, got %s", cmds[0].Options)
}
})

t.Run("AwaitPrompt with timeout", func(t *testing.T) {
l := lexer.New("AwaitPrompt@30s")
p := New(l)
cmds := p.Parse()
if len(cmds) != 1 {
t.Fatalf("Expected 1 command, got %d", len(cmds))
}
if cmds[0].Type != token.AWAIT_PROMPT {
t.Errorf("Expected AWAIT_PROMPT, got %s", cmds[0].Type)
}
if cmds[0].Options != "30s" {
t.Errorf("Expected options '30s', got %s", cmds[0].Options)
}
})

t.Run("AwaitPrompt with millisecond timeout", func(t *testing.T) {
l := lexer.New("AwaitPrompt@500ms")
p := New(l)
cmds := p.Parse()
if len(cmds) != 1 {
t.Fatalf("Expected 1 command, got %d", len(cmds))
}
if cmds[0].Options != "500ms" {
t.Errorf("Expected options '500ms', got %s", cmds[0].Options)
}
})

t.Run("AwaitPrompt with minute timeout", func(t *testing.T) {
l := lexer.New("AwaitPrompt@2m")
p := New(l)
cmds := p.Parse()
if len(cmds) != 1 {
t.Fatalf("Expected 1 command, got %d", len(cmds))
}
if cmds[0].Options != "2m" {
t.Errorf("Expected options '2m', got %s", cmds[0].Options)
}
})
}

func TestParserErrors(t *testing.T) {
input := `
Type Enter
Expand Down
24 changes: 15 additions & 9 deletions shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ type Shell struct {
}

// Shells contains a mapping from shell names to their Shell struct.
//
// Each shell embeds an OSC 133;A prompt marker (FinalTerm shell integration) so
// that the AwaitPrompt command can detect when the shell has rendered a new
// prompt (i.e. is ready for input). The marker format varies by shell:
// - Most shells: \e]133;A\a (ESC ] 133;A BEL)
// - cmd.exe: $E]133;A$E\ (using ST terminator instead of BEL)
var Shells = map[string]Shell{
bash: {
Env: []string{"PS1=\\[\\e[38;2;90;86;224m\\]> \\[\\e[0m\\]", "BASH_SILENCE_DEPRECATION_WARNING=1"},
Env: []string{"PS1=\\[\\e]133;A\\a\\]\\[\\e[38;2;90;86;224m\\]> \\[\\e[0m\\]", "BASH_SILENCE_DEPRECATION_WARNING=1"},
Command: []string{"bash", "--noprofile", "--norc", "--login", "+o", "history"},
},
zsh: {
Env: []string{`PROMPT=%F{#5B56E0}> %F{reset_color}`},
Env: []string{"PROMPT=%{\x1b]133;A\x07%}%F{#5B56E0}> %F{reset_color}"},
Command: []string{"zsh", "--histnostore", "--no-rcs"},
},
fish: {
Expand All @@ -36,7 +42,7 @@ var Shells = map[string]Shell{
"--no-config",
"--private",
"-C", "function fish_greeting; end",
"-C", `function fish_prompt; set_color 5B56E0; echo -n "> "; set_color normal; end`,
"-C", `function fish_prompt; printf '\e]133;A\a'; set_color 5B56E0; echo -n "> "; set_color normal; end`,
},
},
powershell: {
Expand All @@ -46,7 +52,7 @@ var Shells = map[string]Shell{
"-NoExit",
"-NoProfile",
"-Command",
`Set-PSReadLineOption -HistorySaveStyle SaveNothing; function prompt { Write-Host '>' -NoNewLine -ForegroundColor Blue; return ' ' }`,
`Set-PSReadLineOption -HistorySaveStyle SaveNothing; function prompt { [Console]::Write([char]27 + ']133;A' + [char]7); Write-Host '>' -NoNewLine -ForegroundColor Blue; return ' ' }`,
},
},
pwsh: {
Expand All @@ -57,20 +63,20 @@ var Shells = map[string]Shell{
"-NoExit",
"-NoProfile",
"-Command",
`Set-PSReadLineOption -HistorySaveStyle SaveNothing; Function prompt { Write-Host -ForegroundColor Blue -NoNewLine '>'; return ' ' }`,
`Set-PSReadLineOption -HistorySaveStyle SaveNothing; Function prompt { [Console]::Write([char]27 + ']133;A' + [char]7); Write-Host -ForegroundColor Blue -NoNewLine '>'; return ' ' }`,
},
},
cmdexe: {
Command: []string{"cmd.exe", "/k", "prompt=^> "},
Command: []string{"cmd.exe", "/k", "prompt=$E]133;A$E\\^> "},
},
nushell: {
Command: []string{"nu", "--execute", "$env.PROMPT_COMMAND = {'\033[;38;2;91;86;224m>\033[m '}; $env.PROMPT_COMMAND_RIGHT = {''}"},
Command: []string{"nu", "--execute", "$env.PROMPT_COMMAND = {print -n '\033]133;A\007'; '\033[;38;2;91;86;224m>\033[m '}; $env.PROMPT_COMMAND_RIGHT = {''}"},
},
osh: {
Env: []string{"PS1=\\[\\e[38;2;90;86;224m\\]> \\[\\e[0m\\]"},
Env: []string{"PS1=\\[\\e]133;A\\a\\]\\[\\e[38;2;90;86;224m\\]> \\[\\e[0m\\]"},
Command: []string{"osh", "--norc"},
},
xonsh: {
Command: []string{"xonsh", "--no-rc", "-D", "PROMPT=\033[;38;2;91;86;224m>\033[m "},
Command: []string{"xonsh", "--no-rc", "-D", "PROMPT=\033]133;A\007\033[;38;2;91;86;224m>\033[m "},
},
}
43 changes: 43 additions & 0 deletions shell_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import (
"strings"
"testing"
)

func TestShellPromptMarker(t *testing.T) {
// Every shell configuration should embed the OSC 133;A prompt marker
// (FinalTerm shell integration) so that AwaitPrompt can detect when a
// command has finished.
//
// The marker format varies by shell:
// - Most shells: \e]133;A\a (ESC ] 133;A BEL)
// - cmd.exe: $E]133;A$E\ (using ST terminator instead of BEL)
shellNames := []string{
bash,
zsh,
fish,
powershell,
pwsh,
cmdexe,
nushell,
osh,
xonsh,
}

for _, name := range shellNames {
t.Run(name, func(t *testing.T) {
shell, ok := Shells[name]
if !ok {
t.Fatalf("Shell %q not found in Shells map", name)
}

// Combine env and command into a single string to search
combined := strings.Join(shell.Env, " ") + " " + strings.Join(shell.Command, " ")

if !strings.Contains(combined, "133") {
t.Errorf("Shell %q does not contain OSC 133;A marker.\nenv: %v\ncommand: %v", name, shell.Env, shell.Command)
}
})
}
}
9 changes: 9 additions & 0 deletions testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ func (v *VHS) Buffer() ([]string, error) {
return lines, nil
}

// PromptCount returns the number of prompt markers detected so far.
func (v *VHS) PromptCount() (int, error) {
buf, err := v.Page.Eval("() => window.__vhs_prompt_count || 0")
if err != nil {
return 0, fmt.Errorf("read prompt count: %w", err)
}
return buf.Value.Int(), nil
}

// CurrentLine returns the current line from the buffer.
func (v *VHS) CurrentLine() (string, error) {
buf, err := v.Page.Eval("() => term.buffer.active.getLine(term.buffer.active.cursorY+term.buffer.active.viewportY).translateToString().trimEnd()")
Expand Down
4 changes: 3 additions & 1 deletion token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const (
WINDOW_BAR = "WINDOW_BAR"
WINDOW_BAR_SIZE = "WINDOW_BAR_SIZE"
BORDER_RADIUS = "CORNER_RADIUS"
AWAIT_PROMPT = "AWAIT_PROMPT"
WAIT = "WAIT"
WAIT_TIMEOUT = "WAIT_TIMEOUT"
WAIT_PATTERN = "WAIT_PATTERN"
Expand Down Expand Up @@ -160,6 +161,7 @@ var Keywords = map[string]Type{
"LoopOffset": LOOP_OFFSET,
"WaitTimeout": WAIT_TIMEOUT,
"WaitPattern": WAIT_PATTERN,
"AwaitPrompt": AWAIT_PROMPT,
"Wait": WAIT,
"Source": SOURCE,
"CursorBlink": CURSOR_BLINK,
Expand Down Expand Up @@ -190,7 +192,7 @@ func IsCommand(t Type) bool {
case TYPE, SLEEP,
UP, DOWN, RIGHT, LEFT, PAGE_UP, PAGE_DOWN, SCROLL_UP, SCROLL_DOWN,
ENTER, BACKSPACE, DELETE, TAB,
ESCAPE, HOME, INSERT, END, CTRL, SOURCE, SCREENSHOT, COPY, PASTE, WAIT:
ESCAPE, HOME, INSERT, END, CTRL, SOURCE, SCREENSHOT, COPY, PASTE, AWAIT_PROMPT, WAIT:
return true
default:
return false
Expand Down
Loading