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
44 changes: 43 additions & 1 deletion command.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ var CommandFuncs = map[parser.CommandType]CommandFunc{
token.COPY: ExecuteCopy,
token.PASTE: ExecutePaste,
token.ENV: ExecuteEnv,
token.WAIT: ExecuteWait,
token.AWAIT_PROMPT: ExecuteAwaitPrompt,
token.WAIT: ExecuteWait,
}

// ExecuteNoop is a no-op command that does nothing.
Expand Down Expand Up @@ -168,6 +169,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 7777) 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 = 29
const numberOfCommands = 30
if len(parser.CommandTypes) != numberOfCommands {
t.Errorf("Expected %d commands, got %d", numberOfCommands, len(parser.CommandTypes))
}

const numberOfCommandFuncs = 29
const numberOfCommandFuncs = 30
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 @@ -50,6 +50,7 @@ var CommandTypes = []CommandType{ //nolint: deadcode
token.TAB,
token.TYPE,
token.UP,
token.AWAIT_PROMPT,
token.WAIT,
token.SOURCE,
token.SCREENSHOT,
Expand Down Expand Up @@ -169,6 +170,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 @@ -226,6 +229,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 @@ -33,7 +33,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 All @@ -59,6 +61,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 @@ -83,6 +87,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 7777 prompt marker 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]7777;\a (ESC ] 7777 ; BEL)
// - cmd.exe: $E]7777;$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]7777;\\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]7777;\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]7777;\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 + ']7777;' + [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 + ']7777;' + [char]7); Write-Host -ForegroundColor Blue -NoNewLine '>'; return ' ' }`,
},
},
cmdexe: {
Command: []string{"cmd.exe", "/k", "prompt=^> "},
Command: []string{"cmd.exe", "/k", "prompt=$E]7777;$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]7777;\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]7777;\\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]7777;\007\033[;38;2;91;86;224m>\033[m "},
},
}
42 changes: 42 additions & 0 deletions shell_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"strings"
"testing"
)

func TestShellPromptMarker(t *testing.T) {
// Every shell configuration should embed the OSC 7777 prompt marker
// so that AwaitPrompt can detect when a command has finished.
//
// The marker format varies by shell:
// - Most shells: \e]7777;\a (ESC ] 7777 ; BEL)
// - cmd.exe: $E]7777;$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, "7777") {
t.Errorf("Shell %q does not contain OSC 7777 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 @@ -99,6 +99,7 @@ const (
WINDOW_BAR = "WINDOW_BAR" //nolint:revive
WINDOW_BAR_SIZE = "WINDOW_BAR_SIZE" //nolint:revive
BORDER_RADIUS = "CORNER_RADIUS" //nolint:revive
AWAIT_PROMPT = "AWAIT_PROMPT" //nolint:revive
WAIT = "WAIT" //nolint:revive
WAIT_TIMEOUT = "WAIT_TIMEOUT" //nolint:revive
WAIT_PATTERN = "WAIT_PATTERN" //nolint:revive
Expand Down Expand Up @@ -156,6 +157,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 @@ -186,7 +188,7 @@ func IsCommand(t Type) bool {
case TYPE, SLEEP,
UP, DOWN, RIGHT, LEFT, PAGE_UP, PAGE_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
15 changes: 15 additions & 0 deletions vhs.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@ func (vhs *VHS) Setup() {
// Fit the terminal into the window
vhs.Page.MustEval("term.fit")

// Install a write hook to track prompt markers for the AwaitPrompt command.
vhs.Page.MustEval(`() => {
window.__vhs_prompt_count = 0;
var ow = term.write.bind(term);
term.write = function(d, c) {
var t = typeof d === 'string' ? d : new TextDecoder().decode(d);
var idx = t.indexOf('\x1b]7777;');
while (idx !== -1) {
window.__vhs_prompt_count++;
idx = t.indexOf('\x1b]7777;', idx + 1);
}
return ow(d, c);
};
}`)

_ = os.RemoveAll(vhs.Options.Video.Input)
_ = os.MkdirAll(vhs.Options.Video.Input, 0o750)
}
Expand Down