detached 329d4e2

Merge branch 'main' into perf/swarm-detached-log-tail

commit
329d4e279ee13ca820eb05592145573c52b566b0
author
Patric Eckhart · · 2026-09-24 12:09 UTC
committer
GitHub · · 2026-09-24 12:09 UTC
merge of
d45d6ef + 0192a2e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +309 -19
M packages/agent/systemprompt.go +25 -0
@@ -2,6 +2,7 @@ package agent
22
33 import (
44 "fmt"
5+ "runtime"
56 "strings"
67 "time"
78 )
@@ -26,6 +27,8 @@ type SystemPromptOpts struct {
2627 Append []string // extra text appended at the end
2728 Now time.Time
2829 ZotDocsDir string
30+ GOOS string // defaults to runtime.GOOS; overridable for tests
31+ GOARCH string // defaults to runtime.GOARCH
2932 }
3033
3134 // BuildSystemPrompt constructs the system prompt.
@@ -79,9 +82,31 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
7982 }
8083
8184 fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd)
85+ sb.WriteString(platformLine(o.GOOS, o.GOARCH))
8286 return sb.String()
8387 }
8488
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+
85110 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.
86111
87112 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 end27 Append []string // extra text appended at the end
27 Now time.Time28 Now time.Time
28 ZotDocsDir string29 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) {
3030 }
3131 }
3232
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+
3353 func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) {
3454 got := BuildSystemPrompt(SystemPromptOpts{
3555 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 {
286286
287287 func shellDescription(shell shellCommand) string {
288288 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."
290290 }
291291 if shell.isBash {
292292 return fmt.Sprintf("Run a Bash command via %s -c. stdout+stderr merged.", shell.path)
@@ -296,5 +296,7 @@ func shellDescription(shell shellCommand) string {
296296
297297 func newShellCmd(ctx context.Context, command string) *exec.Cmd {
298298 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
300302 }
@@ -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 (
88 "time"
99 )
1010
11+// applyRawCmdLine is only needed for cmd.exe quoting on Windows.
12+func applyRawCmdLine(*exec.Cmd, shellCommand, string) {}
13+
1114 // setProcessGroup puts the command in its own process group so
1215 // killProcessGroup can target the entire tree including background
1316 // 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 so14 // setProcessGroup puts the command in its own process group so
12 // killProcessGroup can target the entire tree including background15 // 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
44
55 import (
66 "os/exec"
7+ "syscall"
78 "time"
89 )
910
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+
1027 func setProcessGroup(_ *exec.Cmd) {}
1128
1229 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) {
325325 {
326326 name: "Windows",
327327 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.",
329329 },
330330 }
331331 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
55 import (
66 "os"
77 "time"
8+ "unsafe"
9+
10+ "golang.org/x/sys/windows"
811 )
912
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+}
1440
1541 func (p *ProcTerm) SetNonblock(enable bool) error { return nil }
1642
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+ }
25155 }
26- return b[0], true, nil
27156 }
@@ -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 input13+// resizePollInterval bounds how long a size change goes unnoticed.
11-// records. For v1 we just ignore resize and rely on the next render14+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 through43+var (
18-// to a blocking read. This means bare Esc on Windows is only detected44+ 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]byte47+ procGetNumberOfConsoleE = kernel32.NewProc("GetNumberOfConsoleInputEvents")
22- _, err := in.Read(b[:])48+)
23- if err != nil {49+
24- return 0, false, err50+// 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 (
44 "io"
55 "os"
66 "path/filepath"
7+ "sync"
78 "time"
89
910 "golang.org/x/term"
@@ -31,9 +32,10 @@ type Terminal interface {
3132
3233 // ProcTerm is a Terminal bound to the current process's tty.
3334 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.
3739 }
3840
3941 // 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.File35+ out *os.File
35- in *os.File36+ 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close