detached b6529cf

fix(bash): kill entire process group on cancel

Sets Setpgid on bash commands and sends SIGTERM/SIGKILL to the negative pgid so backgrounded children are cleaned up on esc. Also splits the status bar onto multiple lines on narrow terminals when a spinner is active.
commit
b6529cf5c47c18d6c728885328d0908131c68191
author
patriceckhart · · 2026-04-23 07:43 UTC
parent
fcd6d7c
signature
unsigned
refs
tag: v0.0.99
4 files · +70 -12
M internal/agent/tools/bash.go +1 -12
@@ -13,7 +13,6 @@ import (
1313 "path/filepath"
1414 "runtime"
1515 "strings"
16- "syscall"
1716 "time"
1817
1918 "github.com/patriceckhart/zot/internal/core"
@@ -69,6 +68,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
6968 cmd := newShellCmd(runCtx, a.Command)
7069 cmd.Dir = cwd
7170 cmd.Env = os.Environ()
71+ setProcessGroup(cmd)
7272
7373 // Capture merged stdout+stderr with line-by-line streaming.
7474 pr, pw := io.Pipe()
@@ -225,14 +225,3 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
225225 }
226226 return exec.CommandContext(ctx, "/bin/sh", "-c", command)
227227 }
228-
229-// killProcessGroup best-effort SIGTERM then SIGKILL.
230-func killProcessGroup(cmd *exec.Cmd) {
231- if cmd.Process == nil {
232- return
233- }
234- _ = cmd.Process.Signal(syscall.SIGTERM)
235- time.AfterFunc(3*time.Second, func() {
236- _ = cmd.Process.Kill()
237- })
238-}
@@ -13,7 +13,6 @@ import (
13 "path/filepath"13 "path/filepath"
14 "runtime"14 "runtime"
15 "strings"15 "strings"
16- "syscall"
17 "time"16 "time"
18 17
19 "github.com/patriceckhart/zot/internal/core"18 "github.com/patriceckhart/zot/internal/core"
@@ -69,6 +68,7 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
69 cmd := newShellCmd(runCtx, a.Command)68 cmd := newShellCmd(runCtx, a.Command)
70 cmd.Dir = cwd69 cmd.Dir = cwd
71 cmd.Env = os.Environ()70 cmd.Env = os.Environ()
71+ setProcessGroup(cmd)
72 72
73 // Capture merged stdout+stderr with line-by-line streaming.73 // Capture merged stdout+stderr with line-by-line streaming.
74 pr, pw := io.Pipe()74 pr, pw := io.Pipe()
@@ -225,14 +225,3 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
225 }225 }
226 return exec.CommandContext(ctx, "/bin/sh", "-c", command)226 return exec.CommandContext(ctx, "/bin/sh", "-c", command)
227 }227 }
228-
229-// killProcessGroup best-effort SIGTERM then SIGKILL.
230-func killProcessGroup(cmd *exec.Cmd) {
231- if cmd.Process == nil {
232- return
233- }
234- _ = cmd.Process.Signal(syscall.SIGTERM)
235- time.AfterFunc(3*time.Second, func() {
236- _ = cmd.Process.Kill()
237- })
238-}
A internal/agent/tools/bash_unix.go +29 -0
@@ -0,0 +1,29 @@
1+//go:build !windows
2+
3+package tools
4+
5+import (
6+ "os/exec"
7+ "syscall"
8+ "time"
9+)
10+
11+// setProcessGroup puts the command in its own process group so
12+// killProcessGroup can target the entire tree including background
13+// children spawned with &.
14+func setProcessGroup(cmd *exec.Cmd) {
15+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
16+}
17+
18+// killProcessGroup sends SIGTERM then SIGKILL to the entire process
19+// group so backgrounded children (cmd &) are also cleaned up.
20+func killProcessGroup(cmd *exec.Cmd) {
21+ if cmd.Process == nil {
22+ return
23+ }
24+ pgid := cmd.Process.Pid
25+ _ = syscall.Kill(-pgid, syscall.SIGTERM)
26+ time.AfterFunc(3*time.Second, func() {
27+ _ = syscall.Kill(-pgid, syscall.SIGKILL)
28+ })
29+}
@@ -0,0 +1,29 @@
1+//go:build !windows
2+
3+package tools
4+
5+import (
6+ "os/exec"
7+ "syscall"
8+ "time"
9+)
10+
11+// setProcessGroup puts the command in its own process group so
12+// killProcessGroup can target the entire tree including background
13+// children spawned with &.
14+func setProcessGroup(cmd *exec.Cmd) {
15+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
16+}
17+
18+// killProcessGroup sends SIGTERM then SIGKILL to the entire process
19+// group so backgrounded children (cmd &) are also cleaned up.
20+func killProcessGroup(cmd *exec.Cmd) {
21+ if cmd.Process == nil {
22+ return
23+ }
24+ pgid := cmd.Process.Pid
25+ _ = syscall.Kill(-pgid, syscall.SIGTERM)
26+ time.AfterFunc(3*time.Second, func() {
27+ _ = syscall.Kill(-pgid, syscall.SIGKILL)
28+ })
29+}
A internal/agent/tools/bash_windows.go +20 -0
@@ -0,0 +1,20 @@
1+//go:build windows
2+
3+package tools
4+
5+import (
6+ "os/exec"
7+ "time"
8+)
9+
10+func setProcessGroup(_ *exec.Cmd) {}
11+
12+func killProcessGroup(cmd *exec.Cmd) {
13+ if cmd.Process == nil {
14+ return
15+ }
16+ _ = cmd.Process.Kill()
17+ time.AfterFunc(3*time.Second, func() {
18+ _ = cmd.Process.Kill()
19+ })
20+}
@@ -0,0 +1,20 @@
1+//go:build windows
2+
3+package tools
4+
5+import (
6+ "os/exec"
7+ "time"
8+)
9+
10+func setProcessGroup(_ *exec.Cmd) {}
11+
12+func killProcessGroup(cmd *exec.Cmd) {
13+ if cmd.Process == nil {
14+ return
15+ }
16+ _ = cmd.Process.Kill()
17+ time.AfterFunc(3*time.Second, func() {
18+ _ = cmd.Process.Kill()
19+ })
20+}
M internal/tui/view.go +20 -0
@@ -1420,6 +1420,26 @@ func StatusBar(p StatusBarParams) []string {
14201420 }
14211421
14221422 primary := leftBuilder.String()
1423+
1424+ // On narrow terminals the single line wraps badly. If the visible
1425+ // width exceeds cols and we have a busy prefix, split: keep the
1426+ // busy prefix on line 1, put model+stats on line 2.
1427+ if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
1428+ busyLine := pad + p.BusyPrefix
1429+ var infoBuilder strings.Builder
1430+ infoBuilder.WriteString(pad)
1431+ infoBuilder.WriteString(th.FG256(th.Muted, left))
1432+ if middle != "" {
1433+ infoBuilder.WriteString(pad)
1434+ infoBuilder.WriteString(th.FG256(th.Muted, middle))
1435+ }
1436+ lines := []string{busyLine, infoBuilder.String()}
1437+ if cwd != "" {
1438+ lines = append(lines, pad+th.FG256(th.Muted, cwd))
1439+ }
1440+ return lines
1441+ }
1442+
14231443 if cwd == "" {
14241444 return []string{primary}
14251445 }
@@ -1420,6 +1420,26 @@ func StatusBar(p StatusBarParams) []string {
1420 }1420 }
1421 1421
1422 primary := leftBuilder.String()1422 primary := leftBuilder.String()
1423+
1424+ // On narrow terminals the single line wraps badly. If the visible
1425+ // width exceeds cols and we have a busy prefix, split: keep the
1426+ // busy prefix on line 1, put model+stats on line 2.
1427+ if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
1428+ busyLine := pad + p.BusyPrefix
1429+ var infoBuilder strings.Builder
1430+ infoBuilder.WriteString(pad)
1431+ infoBuilder.WriteString(th.FG256(th.Muted, left))
1432+ if middle != "" {
1433+ infoBuilder.WriteString(pad)
1434+ infoBuilder.WriteString(th.FG256(th.Muted, middle))
1435+ }
1436+ lines := []string{busyLine, infoBuilder.String()}
1437+ if cwd != "" {
1438+ lines = append(lines, pad+th.FG256(th.Muted, cwd))
1439+ }
1440+ return lines
1441+ }
1442+
1423 if cwd == "" {1443 if cwd == "" {
1424 return []string{primary}1444 return []string{primary}
1425 }1445 }

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