Merge branch 'main' into perf/swarm-detached-log-tail
10 files · +309 -19
M packages/agent/systemprompt.go +25 -0
| @@ -2,6 +2,7 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "runtime" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "time" | |
| 7 | 8 | ) | |
| @@ -26,6 +27,8 @@ type SystemPromptOpts struct { | |||
| 26 | 27 | Append []string // extra text appended at the end | |
| 27 | 28 | Now time.Time | |
| 28 | 29 | ZotDocsDir string | |
| 30 | + | GOOS string // defaults to runtime.GOOS; overridable for tests | |
| 31 | + | GOARCH string // defaults to runtime.GOARCH | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | // BuildSystemPrompt constructs the system prompt. | |
| @@ -79,9 +82,31 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 84 | fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd) | |
| 85 | + | sb.WriteString(platformLine(o.GOOS, o.GOARCH)) | |
| 82 | 86 | return sb.String() | |
| 83 | 87 | } | |
| 84 | 88 | ||
| 89 | + | // platformLine tells the model which OS and shell dialect its commands | |
| 90 | + | // run under. Without it models default to POSIX syntax, and on Windows | |
| 91 | + | // every failed grep/ls/&&-chain round-trip wastes a tool call and its | |
| 92 | + | // tokens. Kept to one or two lines because it sits in the cached prefix. | |
| 93 | + | func platformLine(goos, goarch string) string { | |
| 94 | + | if goos == "" { | |
| 95 | + | goos = runtime.GOOS | |
| 96 | + | } | |
| 97 | + | if goarch == "" { | |
| 98 | + | goarch = runtime.GOARCH | |
| 99 | + | } | |
| 100 | + | line := fmt.Sprintf("Platform: %s/%s\n", goos, goarch) | |
| 101 | + | if goos == "windows" { | |
| 102 | + | line += windowsShellHint | |
| 103 | + | } | |
| 104 | + | return line | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | const windowsShellHint = `The bash tool runs cmd.exe /C, not a POSIX shell: use cmd syntax (dir, type, findstr, where, set, %VAR%, backslash paths); grep, ls, cat, sed, head, $VAR, and single quotes do not work. Keep commands simple: cmd's quoting breaks on nested double quotes, so prefer the read and glob tools for searching, git grep for code, and powershell -NoProfile -File <script.ps1> for anything complex. | |
| 108 | + | ` | |
| 109 | + | ||
| 85 | 110 | const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that. | |
| 86 | 111 | ||
| 87 | 112 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | |
| @@ -2,6 +2,7 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "runtime" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | ) | 8 | ) | ||
| @@ -26,6 +27,8 @@ type SystemPromptOpts struct { | |||||
| 26 | Append []string // extra text appended at the end | 27 | Append []string // extra text appended at the end | ||
| 27 | Now time.Time | 28 | Now time.Time | ||
| 28 | ZotDocsDir string | 29 | ZotDocsDir string | ||
| 30 | + | GOOS string // defaults to runtime.GOOS; overridable for tests | |||
| 31 | + | GOARCH string // defaults to runtime.GOARCH | |||
| 29 | } | 32 | } | ||
| 30 | 33 | ||||
| 31 | // BuildSystemPrompt constructs the system prompt. | 34 | // BuildSystemPrompt constructs the system prompt. | ||
| @@ -79,9 +82,31 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||||
| 79 | } | 82 | } | ||
| 80 | 83 | ||||
| 81 | fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd) | 84 | fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd) | ||
| 85 | + | sb.WriteString(platformLine(o.GOOS, o.GOARCH)) | |||
| 82 | return sb.String() | 86 | return sb.String() | ||
| 83 | } | 87 | } | ||
| 84 | 88 | ||||
| 89 | + | // platformLine tells the model which OS and shell dialect its commands | |||
| 90 | + | // run under. Without it models default to POSIX syntax, and on Windows | |||
| 91 | + | // every failed grep/ls/&&-chain round-trip wastes a tool call and its | |||
| 92 | + | // tokens. Kept to one or two lines because it sits in the cached prefix. | |||
| 93 | + | func platformLine(goos, goarch string) string { | |||
| 94 | + | if goos == "" { | |||
| 95 | + | goos = runtime.GOOS | |||
| 96 | + | } | |||
| 97 | + | if goarch == "" { | |||
| 98 | + | goarch = runtime.GOARCH | |||
| 99 | + | } | |||
| 100 | + | line := fmt.Sprintf("Platform: %s/%s\n", goos, goarch) | |||
| 101 | + | if goos == "windows" { | |||
| 102 | + | line += windowsShellHint | |||
| 103 | + | } | |||
| 104 | + | return line | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | const windowsShellHint = `The bash tool runs cmd.exe /C, not a POSIX shell: use cmd syntax (dir, type, findstr, where, set, %VAR%, backslash paths); grep, ls, cat, sed, head, $VAR, and single quotes do not work. Keep commands simple: cmd's quoting breaks on nested double quotes, so prefer the read and glob tools for searching, git grep for code, and powershell -NoProfile -File <script.ps1> for anything complex. | |||
| 108 | + | ` | |||
| 109 | + | ||||
| 85 | const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that. | 110 | const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that. | ||
| 86 | 111 | ||||
| 87 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | 112 | Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did. | ||
M packages/agent/systemprompt_test.go +20 -0
| @@ -30,6 +30,26 @@ func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) { | |||
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | func TestBuildSystemPromptPlatform(t *testing.T) { | |
| 34 | + | for _, tc := range []struct { | |
| 35 | + | goos string | |
| 36 | + | wantHint bool | |
| 37 | + | wantLabel string | |
| 38 | + | }{ | |
| 39 | + | {"windows", true, "Platform: windows/amd64"}, | |
| 40 | + | {"linux", false, "Platform: linux/amd64"}, | |
| 41 | + | {"darwin", false, "Platform: darwin/amd64"}, | |
| 42 | + | } { | |
| 43 | + | got := BuildSystemPrompt(SystemPromptOpts{CWD: "/w", GOOS: tc.goos, GOARCH: "amd64"}) | |
| 44 | + | if !strings.Contains(got, tc.wantLabel) { | |
| 45 | + | t.Errorf("%s: missing %q:\n%s", tc.goos, tc.wantLabel, got) | |
| 46 | + | } | |
| 47 | + | if strings.Contains(got, "cmd.exe /C") != tc.wantHint { | |
| 48 | + | t.Errorf("%s: windows shell hint present=%v, want %v", tc.goos, !tc.wantHint, tc.wantHint) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 33 | 53 | func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) { | |
| 34 | 54 | got := BuildSystemPrompt(SystemPromptOpts{ | |
| 35 | 55 | CWD: "/workspace", | |
| @@ -30,6 +30,26 @@ func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) { | |||||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | func TestBuildSystemPromptPlatform(t *testing.T) { | |||
| 34 | + | for _, tc := range []struct { | |||
| 35 | + | goos string | |||
| 36 | + | wantHint bool | |||
| 37 | + | wantLabel string | |||
| 38 | + | }{ | |||
| 39 | + | {"windows", true, "Platform: windows/amd64"}, | |||
| 40 | + | {"linux", false, "Platform: linux/amd64"}, | |||
| 41 | + | {"darwin", false, "Platform: darwin/amd64"}, | |||
| 42 | + | } { | |||
| 43 | + | got := BuildSystemPrompt(SystemPromptOpts{CWD: "/w", GOOS: tc.goos, GOARCH: "amd64"}) | |||
| 44 | + | if !strings.Contains(got, tc.wantLabel) { | |||
| 45 | + | t.Errorf("%s: missing %q:\n%s", tc.goos, tc.wantLabel, got) | |||
| 46 | + | } | |||
| 47 | + | if strings.Contains(got, "cmd.exe /C") != tc.wantHint { | |||
| 48 | + | t.Errorf("%s: windows shell hint present=%v, want %v", tc.goos, !tc.wantHint, tc.wantHint) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 33 | func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) { | 53 | func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) { | ||
| 34 | got := BuildSystemPrompt(SystemPromptOpts{ | 54 | got := BuildSystemPrompt(SystemPromptOpts{ | ||
| 35 | CWD: "/workspace", | 55 | CWD: "/workspace", | ||
M packages/agent/tools/bash.go +4 -2
| @@ -286,7 +286,7 @@ func isExecutableFile(path string) bool { | |||
| 286 | 286 | ||
| 287 | 287 | func shellDescription(shell shellCommand) string { | |
| 288 | 288 | if shell.flag == "/C" { | |
| 289 | - | return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged." | |
| 289 | + | return "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged." | |
| 290 | 290 | } | |
| 291 | 291 | if shell.isBash { | |
| 292 | 292 | return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path) | |
| @@ -296,5 +296,7 @@ func shellDescription(shell shellCommand) string { | |||
| 296 | 296 | ||
| 297 | 297 | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | |
| 298 | 298 | shell := currentShell() | |
| 299 | - | return exec.CommandContext(ctx, shell.path, shell.flag, command) | |
| 299 | + | cmd := exec.CommandContext(ctx, shell.path, shell.flag, command) | |
| 300 | + | applyRawCmdLine(cmd, shell, command) | |
| 301 | + | return cmd | |
| 300 | 302 | } | |
| @@ -286,7 +286,7 @@ func isExecutableFile(path string) bool { | |||||
| 286 | 286 | ||||
| 287 | func shellDescription(shell shellCommand) string { | 287 | func shellDescription(shell shellCommand) string { | ||
| 288 | if shell.flag == "/C" { | 288 | if shell.flag == "/C" { | ||
| 289 | - | return "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged." | 289 | + | return "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged." |
| 290 | } | 290 | } | ||
| 291 | if shell.isBash { | 291 | if shell.isBash { | ||
| 292 | return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path) | 292 | return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path) | ||
| @@ -296,5 +296,7 @@ func shellDescription(shell shellCommand) string { | |||||
| 296 | 296 | ||||
| 297 | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | 297 | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | ||
| 298 | shell := currentShell() | 298 | shell := currentShell() | ||
| 299 | - | return exec.CommandContext(ctx, shell.path, shell.flag, command) | 299 | + | cmd := exec.CommandContext(ctx, shell.path, shell.flag, command) |
| 300 | + | applyRawCmdLine(cmd, shell, command) | |||
| 301 | + | return cmd | |||
| 300 | } | 302 | } | ||
M packages/agent/tools/bash_unix.go +3 -0
| @@ -8,6 +8,9 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | // applyRawCmdLine is only needed for cmd.exe quoting on Windows. | |
| 12 | + | func applyRawCmdLine(*exec.Cmd, shellCommand, string) {} | |
| 13 | + | ||
| 11 | 14 | // setProcessGroup puts the command in its own process group so | |
| 12 | 15 | // killProcessGroup can target the entire tree including background | |
| 13 | 16 | // children spawned with &. | |
| @@ -8,6 +8,9 @@ import ( | |||||
| 8 | "time" | 8 | "time" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | // applyRawCmdLine is only needed for cmd.exe quoting on Windows. | |||
| 12 | + | func applyRawCmdLine(*exec.Cmd, shellCommand, string) {} | |||
| 13 | + | ||||
| 11 | // setProcessGroup puts the command in its own process group so | 14 | // setProcessGroup puts the command in its own process group so | ||
| 12 | // killProcessGroup can target the entire tree including background | 15 | // killProcessGroup can target the entire tree including background | ||
| 13 | // children spawned with &. | 16 | // children spawned with &. | ||
M packages/agent/tools/bash_windows.go +17 -0
| @@ -4,9 +4,26 @@ package tools | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os/exec" | |
| 7 | + | "syscall" | |
| 7 | 8 | "time" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 11 | + | // applyRawCmdLine hands the command to cmd.exe verbatim. Go's default | |
| 12 | + | // argument quoting escapes embedded double quotes as \", which cmd.exe | |
| 13 | + | // does not understand, so commands such as dir "C:\a b" or | |
| 14 | + | // findstr /c:"x y" reached cmd with literal backslashes and failed. | |
| 15 | + | // /S makes cmd strip exactly the outer quote pair and run the rest | |
| 16 | + | // unchanged. | |
| 17 | + | func applyRawCmdLine(cmd *exec.Cmd, shell shellCommand, command string) { | |
| 18 | + | if shell.flag != "/C" { | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | if cmd.SysProcAttr == nil { | |
| 22 | + | cmd.SysProcAttr = &syscall.SysProcAttr{} | |
| 23 | + | } | |
| 24 | + | cmd.SysProcAttr.CmdLine = `cmd /S /C "` + command + `"` | |
| 25 | + | } | |
| 26 | + | ||
| 10 | 27 | func setProcessGroup(_ *exec.Cmd) {} | |
| 11 | 28 | ||
| 12 | 29 | func killProcessGroup(cmd *exec.Cmd) { | |
| @@ -4,9 +4,26 @@ package tools | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "os/exec" | 6 | "os/exec" | ||
| 7 | + | "syscall" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 11 | + | // applyRawCmdLine hands the command to cmd.exe verbatim. Go's default | |||
| 12 | + | // argument quoting escapes embedded double quotes as \", which cmd.exe | |||
| 13 | + | // does not understand, so commands such as dir "C:\a b" or | |||
| 14 | + | // findstr /c:"x y" reached cmd with literal backslashes and failed. | |||
| 15 | + | // /S makes cmd strip exactly the outer quote pair and run the rest | |||
| 16 | + | // unchanged. | |||
| 17 | + | func applyRawCmdLine(cmd *exec.Cmd, shell shellCommand, command string) { | |||
| 18 | + | if shell.flag != "/C" { | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | if cmd.SysProcAttr == nil { | |||
| 22 | + | cmd.SysProcAttr = &syscall.SysProcAttr{} | |||
| 23 | + | } | |||
| 24 | + | cmd.SysProcAttr.CmdLine = `cmd /S /C "` + command + `"` | |||
| 25 | + | } | |||
| 26 | + | ||||
| 10 | func setProcessGroup(_ *exec.Cmd) {} | 27 | func setProcessGroup(_ *exec.Cmd) {} | ||
| 11 | 28 | ||||
| 12 | func killProcessGroup(cmd *exec.Cmd) { | 29 | func killProcessGroup(cmd *exec.Cmd) { | ||
A packages/agent/tools/bash_windows_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package tools | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Embedded double quotes must reach cmd.exe unchanged. Go's default | |
| 16 | + | // argument escaping turned them into \" which cmd does not understand. | |
| 17 | + | func TestBashWindowsQuotedArguments(t *testing.T) { | |
| 18 | + | dir := t.TempDir() | |
| 19 | + | sub := filepath.Join(dir, "a b") | |
| 20 | + | if err := os.MkdirAll(sub, 0o755); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("hello world\n"), 0o644); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | tool := &BashTool{CWD: dir} | |
| 27 | + | for _, tc := range []struct{ command, want string }{ | |
| 28 | + | {`dir /s /b "` + dir + `\SKILL.md"`, "SKILL.md"}, | |
| 29 | + | {`findstr /c:"hello world" "a b\SKILL.md"`, "hello world"}, | |
| 30 | + | {`echo "x y" & echo done`, "done"}, | |
| 31 | + | } { | |
| 32 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": tc.command}), nil) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | got := res.Content[0].(provider.TextBlock).Text | |
| 37 | + | if res.IsError || !strings.Contains(got, tc.want) { | |
| 38 | + | t.Errorf("%s: got\n%s", tc.command, got) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | //go:build windows | |||
| 2 | + | ||||
| 3 | + | package tools | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Embedded double quotes must reach cmd.exe unchanged. Go's default | |||
| 16 | + | // argument escaping turned them into \" which cmd does not understand. | |||
| 17 | + | func TestBashWindowsQuotedArguments(t *testing.T) { | |||
| 18 | + | dir := t.TempDir() | |||
| 19 | + | sub := filepath.Join(dir, "a b") | |||
| 20 | + | if err := os.MkdirAll(sub, 0o755); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("hello world\n"), 0o644); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | tool := &BashTool{CWD: dir} | |||
| 27 | + | for _, tc := range []struct{ command, want string }{ | |||
| 28 | + | {`dir /s /b "` + dir + `\SKILL.md"`, "SKILL.md"}, | |||
| 29 | + | {`findstr /c:"hello world" "a b\SKILL.md"`, "hello world"}, | |||
| 30 | + | {`echo "x y" & echo done`, "done"}, | |||
| 31 | + | } { | |||
| 32 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": tc.command}), nil) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | got := res.Content[0].(provider.TextBlock).Text | |||
| 37 | + | if res.IsError || !strings.Contains(got, tc.want) { | |||
| 38 | + | t.Errorf("%s: got\n%s", tc.command, got) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } | |||
M packages/agent/tools/tools_test.go +1 -1
| @@ -325,7 +325,7 @@ func TestShellDescription(t *testing.T) { | |||
| 325 | 325 | { | |
| 326 | 326 | name: "Windows", | |
| 327 | 327 | shell: shellCommand{path: "cmd", flag: "/C"}, | |
| 328 | - | want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.", | |
| 328 | + | want: "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged.", | |
| 329 | 329 | }, | |
| 330 | 330 | } | |
| 331 | 331 | for _, tt := range tests { | |
| @@ -325,7 +325,7 @@ func TestShellDescription(t *testing.T) { | |||||
| 325 | { | 325 | { | ||
| 326 | name: "Windows", | 326 | name: "Windows", | ||
| 327 | shell: shellCommand{path: "cmd", flag: "/C"}, | 327 | shell: shellCommand{path: "cmd", flag: "/C"}, | ||
| 328 | - | want: "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.", | 328 | + | want: "Run a Windows Command Prompt command via cmd /C (not bash: no grep/ls/cat/sed, no $VAR or single quotes; use findstr/dir/type/where and %VAR%). stdout+stderr merged.", |
| 329 | }, | 329 | }, | ||
| 330 | } | 330 | } | ||
| 331 | for _, tt := range tests { | 331 | for _, tt := range tests { | ||
M packages/tui/resize_windows.go +142 -13
| @@ -5,23 +5,152 @@ package tui | |||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
| 7 | 7 | "time" | |
| 8 | + | "unsafe" | |
| 9 | + | ||
| 10 | + | "golang.org/x/sys/windows" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | - | // Windows doesn't have SIGWINCH; resize events are signaled via input | |
| 11 | - | // records. For v1 we just ignore resize and rely on the next render | |
| 12 | - | // cycle to pick up the new size via term.GetSize. | |
| 13 | - | func (p *ProcTerm) installResizeHandler() {} | |
| 13 | + | // resizePollInterval bounds how long a size change goes unnoticed. | |
| 14 | + | const resizePollInterval = 100 * time.Millisecond | |
| 15 | + | ||
| 16 | + | // Windows has no SIGWINCH. Console resize events arrive as input records, | |
| 17 | + | // but stdin is read as a VT byte stream, so those records never reach us. | |
| 18 | + | // Poll the console size instead and fire the callbacks on every change. | |
| 19 | + | // This matters most under ConPTY hosts such as multiplexers, where the | |
| 20 | + | // pane width changes without any user input. | |
| 21 | + | func (p *ProcTerm) installResizeHandler() { | |
| 22 | + | p.resizeOnce.Do(func() { | |
| 23 | + | go func() { | |
| 24 | + | lastW, lastH := p.Size() | |
| 25 | + | ticker := time.NewTicker(resizePollInterval) | |
| 26 | + | defer ticker.Stop() | |
| 27 | + | for range ticker.C { | |
| 28 | + | w, h := p.Size() | |
| 29 | + | if w == lastW && h == lastH { | |
| 30 | + | continue | |
| 31 | + | } | |
| 32 | + | lastW, lastH = w, h | |
| 33 | + | for _, cb := range p.resizeCBs { | |
| 34 | + | cb() | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | }() | |
| 38 | + | }) | |
| 39 | + | } | |
| 14 | 40 | ||
| 15 | 41 | func (p *ProcTerm) SetNonblock(enable bool) error { return nil } | |
| 16 | 42 | ||
| 17 | - | // peekStdin on Windows: no non-blocking primitive in v1; fall through | |
| 18 | - | // to a blocking read. This means bare Esc on Windows is only detected | |
| 19 | - | // after the next keystroke. Good enough for v1. | |
| 20 | - | func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) { | |
| 21 | - | var b [1]byte | |
| 22 | - | _, err := in.Read(b[:]) | |
| 23 | - | if err != nil { | |
| 24 | - | return 0, false, err | |
| 43 | + | var ( | |
| 44 | + | kernel32 = windows.NewLazySystemDLL("kernel32.dll") | |
| 45 | + | procPeekConsoleInputW = kernel32.NewProc("PeekConsoleInputW") | |
| 46 | + | procReadConsoleInputW = kernel32.NewProc("ReadConsoleInputW") | |
| 47 | + | procGetNumberOfConsoleE = kernel32.NewProc("GetNumberOfConsoleInputEvents") | |
| 48 | + | ) | |
| 49 | + | ||
| 50 | + | // inputRecord mirrors the Win32 INPUT_RECORD layout (20 bytes). Only | |
| 51 | + | // key events are decoded; the union is kept as raw bytes. | |
| 52 | + | type inputRecord struct { | |
| 53 | + | eventType uint16 | |
| 54 | + | _ uint16 | |
| 55 | + | event [16]byte | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | const keyEvent = 0x0001 | |
| 59 | + | ||
| 60 | + | // yieldsByte reports whether a key-down can produce input in VT mode. | |
| 61 | + | // Navigation and function keys emit escape sequences even when their | |
| 62 | + | // UnicodeChar is zero. Key-ups and non-key records produce no bytes. | |
| 63 | + | func (r *inputRecord) yieldsByte() bool { | |
| 64 | + | if r.eventType != keyEvent { | |
| 65 | + | return false | |
| 66 | + | } | |
| 67 | + | keyDown := *(*int32)(unsafe.Pointer(&r.event[0])) | |
| 68 | + | if keyDown == 0 { | |
| 69 | + | return false | |
| 70 | + | } | |
| 71 | + | char := *(*uint16)(unsafe.Pointer(&r.event[10])) | |
| 72 | + | if char != 0 { | |
| 73 | + | return true | |
| 74 | + | } | |
| 75 | + | key := *(*uint16)(unsafe.Pointer(&r.event[4])) | |
| 76 | + | // Win32 virtual-key codes for PageUp/Down, End/Home, arrows, | |
| 77 | + | // Insert/Delete and F1-F24. ReadConsole translates these into VT | |
| 78 | + | // sequences, despite the input record having no Unicode character. | |
| 79 | + | return key >= 0x21 && key <= 0x28 || key == 0x2d || key == 0x2e || key >= 0x70 && key <= 0x87 | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // consoleHasByte checks pending console input without consuming bytes. | |
| 83 | + | // Leading records that would not produce a byte are discarded so the | |
| 84 | + | // handle stops signalling for them; otherwise a lone focus event would | |
| 85 | + | // make every wait return immediately while ReadFile still blocks. | |
| 86 | + | func consoleHasByte(h windows.Handle) (bool, error) { | |
| 87 | + | for { | |
| 88 | + | var count uint32 | |
| 89 | + | if r, _, err := procGetNumberOfConsoleE.Call(uintptr(h), uintptr(unsafe.Pointer(&count))); r == 0 { | |
| 90 | + | return false, err | |
| 91 | + | } | |
| 92 | + | if count == 0 { | |
| 93 | + | return false, nil | |
| 94 | + | } | |
| 95 | + | records := make([]inputRecord, count) | |
| 96 | + | var read uint32 | |
| 97 | + | if r, _, err := procPeekConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(count), uintptr(unsafe.Pointer(&read))); r == 0 { | |
| 98 | + | return false, err | |
| 99 | + | } | |
| 100 | + | skip := uint32(0) | |
| 101 | + | for skip < read && !records[skip].yieldsByte() { | |
| 102 | + | skip++ | |
| 103 | + | } | |
| 104 | + | if skip < read { | |
| 105 | + | return true, nil | |
| 106 | + | } | |
| 107 | + | if skip == 0 { | |
| 108 | + | return false, nil | |
| 109 | + | } | |
| 110 | + | if r, _, err := procReadConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(skip), uintptr(unsafe.Pointer(&read))); r == 0 { | |
| 111 | + | return false, err | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // peekStdin waits up to d for one byte of console input. It uses the | |
| 117 | + | // console handle's wait state plus PeekConsoleInput, so a missing reply | |
| 118 | + | // (for example an OSC 11 background query the host never answers) or a | |
| 119 | + | // bare Esc times out instead of blocking until the next keystroke. | |
| 120 | + | // Non-console stdin falls back to a blocking read. | |
| 121 | + | func peekStdin(in *os.File, d time.Duration) (byte, bool, error) { | |
| 122 | + | h := windows.Handle(in.Fd()) | |
| 123 | + | var mode uint32 | |
| 124 | + | if windows.GetConsoleMode(h, &mode) != nil { | |
| 125 | + | var b [1]byte | |
| 126 | + | if _, err := in.Read(b[:]); err != nil { | |
| 127 | + | return 0, false, err | |
| 128 | + | } | |
| 129 | + | return b[0], true, nil | |
| 130 | + | } | |
| 131 | + | deadline := time.Now().Add(d) | |
| 132 | + | for { | |
| 133 | + | ready, err := consoleHasByte(h) | |
| 134 | + | if err != nil { | |
| 135 | + | return 0, false, err | |
| 136 | + | } | |
| 137 | + | if ready { | |
| 138 | + | var b [1]byte | |
| 139 | + | if _, err := in.Read(b[:]); err != nil { | |
| 140 | + | return 0, false, err | |
| 141 | + | } | |
| 142 | + | return b[0], true, nil | |
| 143 | + | } | |
| 144 | + | remaining := time.Until(deadline) | |
| 145 | + | if remaining <= 0 { | |
| 146 | + | return 0, false, nil | |
| 147 | + | } | |
| 148 | + | event, err := windows.WaitForSingleObject(h, uint32(remaining.Milliseconds())+1) | |
| 149 | + | if err != nil { | |
| 150 | + | return 0, false, err | |
| 151 | + | } | |
| 152 | + | if event == uint32(windows.WAIT_TIMEOUT) { | |
| 153 | + | return 0, false, nil | |
| 154 | + | } | |
| 25 | 155 | } | |
| 26 | - | return b[0], true, nil | |
| 27 | 156 | } | |
| @@ -5,23 +5,152 @@ package tui | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | + | "unsafe" | |||
| 9 | + | ||||
| 10 | + | "golang.org/x/sys/windows" | |||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 10 | - | // Windows doesn't have SIGWINCH; resize events are signaled via input | 13 | + | // resizePollInterval bounds how long a size change goes unnoticed. |
| 11 | - | // records. For v1 we just ignore resize and rely on the next render | 14 | + | const resizePollInterval = 100 * time.Millisecond |
| 12 | - | // cycle to pick up the new size via term.GetSize. | 15 | + | |
| 13 | - | func (p *ProcTerm) installResizeHandler() {} | 16 | + | // Windows has no SIGWINCH. Console resize events arrive as input records, |
| 17 | + | // but stdin is read as a VT byte stream, so those records never reach us. | |||
| 18 | + | // Poll the console size instead and fire the callbacks on every change. | |||
| 19 | + | // This matters most under ConPTY hosts such as multiplexers, where the | |||
| 20 | + | // pane width changes without any user input. | |||
| 21 | + | func (p *ProcTerm) installResizeHandler() { | |||
| 22 | + | p.resizeOnce.Do(func() { | |||
| 23 | + | go func() { | |||
| 24 | + | lastW, lastH := p.Size() | |||
| 25 | + | ticker := time.NewTicker(resizePollInterval) | |||
| 26 | + | defer ticker.Stop() | |||
| 27 | + | for range ticker.C { | |||
| 28 | + | w, h := p.Size() | |||
| 29 | + | if w == lastW && h == lastH { | |||
| 30 | + | continue | |||
| 31 | + | } | |||
| 32 | + | lastW, lastH = w, h | |||
| 33 | + | for _, cb := range p.resizeCBs { | |||
| 34 | + | cb() | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | }() | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 14 | 40 | ||||
| 15 | func (p *ProcTerm) SetNonblock(enable bool) error { return nil } | 41 | func (p *ProcTerm) SetNonblock(enable bool) error { return nil } | ||
| 16 | 42 | ||||
| 17 | - | // peekStdin on Windows: no non-blocking primitive in v1; fall through | 43 | + | var ( |
| 18 | - | // to a blocking read. This means bare Esc on Windows is only detected | 44 | + | kernel32 = windows.NewLazySystemDLL("kernel32.dll") |
| 19 | - | // after the next keystroke. Good enough for v1. | 45 | + | procPeekConsoleInputW = kernel32.NewProc("PeekConsoleInputW") |
| 20 | - | func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) { | 46 | + | procReadConsoleInputW = kernel32.NewProc("ReadConsoleInputW") |
| 21 | - | var b [1]byte | 47 | + | procGetNumberOfConsoleE = kernel32.NewProc("GetNumberOfConsoleInputEvents") |
| 22 | - | _, err := in.Read(b[:]) | 48 | + | ) |
| 23 | - | if err != nil { | 49 | + | |
| 24 | - | return 0, false, err | 50 | + | // inputRecord mirrors the Win32 INPUT_RECORD layout (20 bytes). Only |
| 51 | + | // key events are decoded; the union is kept as raw bytes. | |||
| 52 | + | type inputRecord struct { | |||
| 53 | + | eventType uint16 | |||
| 54 | + | _ uint16 | |||
| 55 | + | event [16]byte | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | const keyEvent = 0x0001 | |||
| 59 | + | ||||
| 60 | + | // yieldsByte reports whether a key-down can produce input in VT mode. | |||
| 61 | + | // Navigation and function keys emit escape sequences even when their | |||
| 62 | + | // UnicodeChar is zero. Key-ups and non-key records produce no bytes. | |||
| 63 | + | func (r *inputRecord) yieldsByte() bool { | |||
| 64 | + | if r.eventType != keyEvent { | |||
| 65 | + | return false | |||
| 66 | + | } | |||
| 67 | + | keyDown := *(*int32)(unsafe.Pointer(&r.event[0])) | |||
| 68 | + | if keyDown == 0 { | |||
| 69 | + | return false | |||
| 70 | + | } | |||
| 71 | + | char := *(*uint16)(unsafe.Pointer(&r.event[10])) | |||
| 72 | + | if char != 0 { | |||
| 73 | + | return true | |||
| 74 | + | } | |||
| 75 | + | key := *(*uint16)(unsafe.Pointer(&r.event[4])) | |||
| 76 | + | // Win32 virtual-key codes for PageUp/Down, End/Home, arrows, | |||
| 77 | + | // Insert/Delete and F1-F24. ReadConsole translates these into VT | |||
| 78 | + | // sequences, despite the input record having no Unicode character. | |||
| 79 | + | return key >= 0x21 && key <= 0x28 || key == 0x2d || key == 0x2e || key >= 0x70 && key <= 0x87 | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // consoleHasByte checks pending console input without consuming bytes. | |||
| 83 | + | // Leading records that would not produce a byte are discarded so the | |||
| 84 | + | // handle stops signalling for them; otherwise a lone focus event would | |||
| 85 | + | // make every wait return immediately while ReadFile still blocks. | |||
| 86 | + | func consoleHasByte(h windows.Handle) (bool, error) { | |||
| 87 | + | for { | |||
| 88 | + | var count uint32 | |||
| 89 | + | if r, _, err := procGetNumberOfConsoleE.Call(uintptr(h), uintptr(unsafe.Pointer(&count))); r == 0 { | |||
| 90 | + | return false, err | |||
| 91 | + | } | |||
| 92 | + | if count == 0 { | |||
| 93 | + | return false, nil | |||
| 94 | + | } | |||
| 95 | + | records := make([]inputRecord, count) | |||
| 96 | + | var read uint32 | |||
| 97 | + | if r, _, err := procPeekConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(count), uintptr(unsafe.Pointer(&read))); r == 0 { | |||
| 98 | + | return false, err | |||
| 99 | + | } | |||
| 100 | + | skip := uint32(0) | |||
| 101 | + | for skip < read && !records[skip].yieldsByte() { | |||
| 102 | + | skip++ | |||
| 103 | + | } | |||
| 104 | + | if skip < read { | |||
| 105 | + | return true, nil | |||
| 106 | + | } | |||
| 107 | + | if skip == 0 { | |||
| 108 | + | return false, nil | |||
| 109 | + | } | |||
| 110 | + | if r, _, err := procReadConsoleInputW.Call(uintptr(h), uintptr(unsafe.Pointer(&records[0])), uintptr(skip), uintptr(unsafe.Pointer(&read))); r == 0 { | |||
| 111 | + | return false, err | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // peekStdin waits up to d for one byte of console input. It uses the | |||
| 117 | + | // console handle's wait state plus PeekConsoleInput, so a missing reply | |||
| 118 | + | // (for example an OSC 11 background query the host never answers) or a | |||
| 119 | + | // bare Esc times out instead of blocking until the next keystroke. | |||
| 120 | + | // Non-console stdin falls back to a blocking read. | |||
| 121 | + | func peekStdin(in *os.File, d time.Duration) (byte, bool, error) { | |||
| 122 | + | h := windows.Handle(in.Fd()) | |||
| 123 | + | var mode uint32 | |||
| 124 | + | if windows.GetConsoleMode(h, &mode) != nil { | |||
| 125 | + | var b [1]byte | |||
| 126 | + | if _, err := in.Read(b[:]); err != nil { | |||
| 127 | + | return 0, false, err | |||
| 128 | + | } | |||
| 129 | + | return b[0], true, nil | |||
| 130 | + | } | |||
| 131 | + | deadline := time.Now().Add(d) | |||
| 132 | + | for { | |||
| 133 | + | ready, err := consoleHasByte(h) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return 0, false, err | |||
| 136 | + | } | |||
| 137 | + | if ready { | |||
| 138 | + | var b [1]byte | |||
| 139 | + | if _, err := in.Read(b[:]); err != nil { | |||
| 140 | + | return 0, false, err | |||
| 141 | + | } | |||
| 142 | + | return b[0], true, nil | |||
| 143 | + | } | |||
| 144 | + | remaining := time.Until(deadline) | |||
| 145 | + | if remaining <= 0 { | |||
| 146 | + | return 0, false, nil | |||
| 147 | + | } | |||
| 148 | + | event, err := windows.WaitForSingleObject(h, uint32(remaining.Milliseconds())+1) | |||
| 149 | + | if err != nil { | |||
| 150 | + | return 0, false, err | |||
| 151 | + | } | |||
| 152 | + | if event == uint32(windows.WAIT_TIMEOUT) { | |||
| 153 | + | return 0, false, nil | |||
| 154 | + | } | |||
| 25 | } | 155 | } | ||
| 26 | - | return b[0], true, nil | |||
| 27 | } | 156 | } | ||
A packages/tui/resize_windows_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/binary" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResizeWatcherIsPerTerminal(t *testing.T) { | |
| 11 | + | first, second := &ProcTerm{}, &ProcTerm{} | |
| 12 | + | var started int | |
| 13 | + | first.resizeOnce.Do(func() { started++ }) | |
| 14 | + | first.resizeOnce.Do(func() { started++ }) | |
| 15 | + | second.resizeOnce.Do(func() { started++ }) | |
| 16 | + | if started != 2 { | |
| 17 | + | t.Fatalf("watchers started = %d, want one per terminal", started) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestConsoleRecordYieldsByte(t *testing.T) { | |
| 22 | + | for _, tc := range []struct { | |
| 23 | + | name string | |
| 24 | + | eventType uint16 | |
| 25 | + | keyDown bool | |
| 26 | + | key uint16 | |
| 27 | + | char uint16 | |
| 28 | + | want bool | |
| 29 | + | }{ | |
| 30 | + | {"character", keyEvent, true, 0x41, 'a', true}, | |
| 31 | + | {"key up", keyEvent, false, 0x26, 0, false}, | |
| 32 | + | {"arrow up", keyEvent, true, 0x26, 0, true}, | |
| 33 | + | {"delete", keyEvent, true, 0x2e, 0, true}, | |
| 34 | + | {"home", keyEvent, true, 0x24, 0, true}, | |
| 35 | + | {"function key", keyEvent, true, 0x70, 0, true}, | |
| 36 | + | {"shift", keyEvent, true, 0x10, 0, false}, | |
| 37 | + | {"focus", 0x0010, false, 0, 0, false}, | |
| 38 | + | } { | |
| 39 | + | t.Run(tc.name, func(t *testing.T) { | |
| 40 | + | r := inputRecord{eventType: tc.eventType} | |
| 41 | + | if tc.keyDown { | |
| 42 | + | binary.LittleEndian.PutUint32(r.event[0:4], 1) | |
| 43 | + | } | |
| 44 | + | binary.LittleEndian.PutUint16(r.event[4:6], tc.key) | |
| 45 | + | binary.LittleEndian.PutUint16(r.event[10:12], tc.char) | |
| 46 | + | if got := r.yieldsByte(); got != tc.want { | |
| 47 | + | t.Errorf("yieldsByte() = %v, want %v", got, tc.want) | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | //go:build windows | |||
| 2 | + | ||||
| 3 | + | package tui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResizeWatcherIsPerTerminal(t *testing.T) { | |||
| 11 | + | first, second := &ProcTerm{}, &ProcTerm{} | |||
| 12 | + | var started int | |||
| 13 | + | first.resizeOnce.Do(func() { started++ }) | |||
| 14 | + | first.resizeOnce.Do(func() { started++ }) | |||
| 15 | + | second.resizeOnce.Do(func() { started++ }) | |||
| 16 | + | if started != 2 { | |||
| 17 | + | t.Fatalf("watchers started = %d, want one per terminal", started) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestConsoleRecordYieldsByte(t *testing.T) { | |||
| 22 | + | for _, tc := range []struct { | |||
| 23 | + | name string | |||
| 24 | + | eventType uint16 | |||
| 25 | + | keyDown bool | |||
| 26 | + | key uint16 | |||
| 27 | + | char uint16 | |||
| 28 | + | want bool | |||
| 29 | + | }{ | |||
| 30 | + | {"character", keyEvent, true, 0x41, 'a', true}, | |||
| 31 | + | {"key up", keyEvent, false, 0x26, 0, false}, | |||
| 32 | + | {"arrow up", keyEvent, true, 0x26, 0, true}, | |||
| 33 | + | {"delete", keyEvent, true, 0x2e, 0, true}, | |||
| 34 | + | {"home", keyEvent, true, 0x24, 0, true}, | |||
| 35 | + | {"function key", keyEvent, true, 0x70, 0, true}, | |||
| 36 | + | {"shift", keyEvent, true, 0x10, 0, false}, | |||
| 37 | + | {"focus", 0x0010, false, 0, 0, false}, | |||
| 38 | + | } { | |||
| 39 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 40 | + | r := inputRecord{eventType: tc.eventType} | |||
| 41 | + | if tc.keyDown { | |||
| 42 | + | binary.LittleEndian.PutUint32(r.event[0:4], 1) | |||
| 43 | + | } | |||
| 44 | + | binary.LittleEndian.PutUint16(r.event[4:6], tc.key) | |||
| 45 | + | binary.LittleEndian.PutUint16(r.event[10:12], tc.char) | |||
| 46 | + | if got := r.yieldsByte(); got != tc.want { | |||
| 47 | + | t.Errorf("yieldsByte() = %v, want %v", got, tc.want) | |||
| 48 | + | } | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
M packages/tui/terminal.go +5 -3
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "io" | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "golang.org/x/term" | |
| @@ -31,9 +32,10 @@ type Terminal interface { | |||
| 31 | 32 | ||
| 32 | 33 | // ProcTerm is a Terminal bound to the current process's tty. | |
| 33 | 34 | type ProcTerm struct { | |
| 34 | - | out *os.File | |
| 35 | - | in *os.File | |
| 36 | - | resizeCBs []func() | |
| 35 | + | out *os.File | |
| 36 | + | in *os.File | |
| 37 | + | resizeCBs []func() | |
| 38 | + | resizeOnce sync.Once // Windows polls each terminal independently. | |
| 37 | 39 | } | |
| 38 | 40 | ||
| 39 | 41 | // NewProcTerm returns a Terminal bound to stdin/stdout. | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "io" | 4 | "io" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | + | "sync" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "golang.org/x/term" | 10 | "golang.org/x/term" | ||
| @@ -31,9 +32,10 @@ type Terminal interface { | |||||
| 31 | 32 | ||||
| 32 | // ProcTerm is a Terminal bound to the current process's tty. | 33 | // ProcTerm is a Terminal bound to the current process's tty. | ||
| 33 | type ProcTerm struct { | 34 | type ProcTerm struct { | ||
| 34 | - | out *os.File | 35 | + | out *os.File |
| 35 | - | in *os.File | 36 | + | in *os.File |
| 36 | - | resizeCBs []func() | 37 | + | resizeCBs []func() |
| 38 | + | resizeOnce sync.Once // Windows polls each terminal independently. | |||
| 37 | } | 39 | } | ||
| 38 | 40 | ||||
| 39 | // NewProcTerm returns a Terminal bound to stdin/stdout. | 41 | // NewProcTerm returns a Terminal bound to stdin/stdout. | ||