detached a995321

Write requested help to stdout

commit
a99532125e3d969741a756adcf4d09359668e708
author
patriceckhart · · 2026-08-21 17:37 UTC
parent
b3121d6
signature
unsigned
3 files · +78 -20
M packages/agent/args.go +22 -18
@@ -325,12 +325,16 @@ func ParseArgs(in []string) (Args, error) {
325325 return a, nil
326326 }
327327
328-// PrintHelp writes the help text to stderr. When stderr is a TTY it
329-// uses the same palette as zot's TUI; when redirected it falls back to
330-// plain text with no ANSI escapes.
328+// PrintHelp writes the help text to stderr for argument-error paths.
331329 func PrintHelp(version string) {
330+ printHelp(os.Stderr, version)
331+}
332+
333+// printHelp writes help to out. When out is a TTY it uses the same palette as
334+// zot's TUI; when redirected it falls back to plain text with no ANSI escapes.
335+func printHelp(out *os.File, version string) {
332336 th := tui.Dark
333- fd := int(os.Stderr.Fd())
337+ fd := int(out.Fd())
334338 useColor := term.IsTerminal(fd)
335339 style := func(c int, s string) string {
336340 if !useColor {
@@ -364,35 +368,35 @@ func PrintHelp(version string) {
364368 }
365369 type row struct{ left, right string }
366370 section := func(title string, rows ...row) {
367- fmt.Fprintln(os.Stderr)
368- fmt.Fprintln(os.Stderr, assistant(title))
369- fmt.Fprintln(os.Stderr, rule)
371+ fmt.Fprintln(out)
372+ fmt.Fprintln(out, assistant(title))
373+ fmt.Fprintln(out, rule)
370374 narrow := width < 100
371375 for _, r := range rows {
372376 if narrow {
373- fmt.Fprintf(os.Stderr, " %s\n", fg(r.left))
374- fmt.Fprintf(os.Stderr, " %s\n", muted(r.right))
375- fmt.Fprintln(os.Stderr)
377+ fmt.Fprintf(out, " %s\n", fg(r.left))
378+ fmt.Fprintf(out, " %s\n", muted(r.right))
379+ fmt.Fprintln(out)
376380 continue
377381 }
378382 left := r.left
379383 if len([]rune(left)) < leftW {
380384 left += strings.Repeat(" ", leftW-len([]rune(left)))
381385 }
382- fmt.Fprintf(os.Stderr, " %s %s\n", fg(left), muted(r.right))
386+ fmt.Fprintf(out, " %s %s\n", fg(left), muted(r.right))
383387 }
384388 }
385389
386- fmt.Fprintln(os.Stderr)
390+ fmt.Fprintln(out)
387391 var headline string
388392 if useColor {
389393 headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("zot. yet another coding agent harness."))
390394 } else {
391395 headline = "zot. yet another coding agent harness."
392396 }
393- fmt.Fprintln(os.Stderr, headline)
394- fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands."))
395- fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version))
397+ fmt.Fprintln(out, headline)
398+ fmt.Fprintln(out, muted("ask anything, or type /help inside the tui to see commands."))
399+ fmt.Fprintf(out, "%s %s\n", muted("version:"), fg(version))
396400
397401 section("modes",
398402 row{"zot", "interactive tui"},
@@ -457,7 +461,7 @@ func PrintHelp(version string) {
457461 row{"-h, --help", "show this help"},
458462 row{"-v, --version", "show version info"},
459463 )
460- fmt.Fprintln(os.Stderr)
461- fmt.Fprintln(os.Stderr, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md"))
462- fmt.Fprintln(os.Stderr)
464+ fmt.Fprintln(out)
465+ fmt.Fprintln(out, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md"))
466+ fmt.Fprintln(out)
463467 }
@@ -325,12 +325,16 @@ func ParseArgs(in []string) (Args, error) {
325 return a, nil325 return a, nil
326 }326 }
327 327
328-// PrintHelp writes the help text to stderr. When stderr is a TTY it328+// PrintHelp writes the help text to stderr for argument-error paths.
329-// uses the same palette as zot's TUI; when redirected it falls back to
330-// plain text with no ANSI escapes.
331 func PrintHelp(version string) {329 func PrintHelp(version string) {
330+ printHelp(os.Stderr, version)
331+}
332+
333+// printHelp writes help to out. When out is a TTY it uses the same palette as
334+// zot's TUI; when redirected it falls back to plain text with no ANSI escapes.
335+func printHelp(out *os.File, version string) {
332 th := tui.Dark336 th := tui.Dark
333- fd := int(os.Stderr.Fd())337+ fd := int(out.Fd())
334 useColor := term.IsTerminal(fd)338 useColor := term.IsTerminal(fd)
335 style := func(c int, s string) string {339 style := func(c int, s string) string {
336 if !useColor {340 if !useColor {
@@ -364,35 +368,35 @@ func PrintHelp(version string) {
364 }368 }
365 type row struct{ left, right string }369 type row struct{ left, right string }
366 section := func(title string, rows ...row) {370 section := func(title string, rows ...row) {
367- fmt.Fprintln(os.Stderr)371+ fmt.Fprintln(out)
368- fmt.Fprintln(os.Stderr, assistant(title))372+ fmt.Fprintln(out, assistant(title))
369- fmt.Fprintln(os.Stderr, rule)373+ fmt.Fprintln(out, rule)
370 narrow := width < 100374 narrow := width < 100
371 for _, r := range rows {375 for _, r := range rows {
372 if narrow {376 if narrow {
373- fmt.Fprintf(os.Stderr, " %s\n", fg(r.left))377+ fmt.Fprintf(out, " %s\n", fg(r.left))
374- fmt.Fprintf(os.Stderr, " %s\n", muted(r.right))378+ fmt.Fprintf(out, " %s\n", muted(r.right))
375- fmt.Fprintln(os.Stderr)379+ fmt.Fprintln(out)
376 continue380 continue
377 }381 }
378 left := r.left382 left := r.left
379 if len([]rune(left)) < leftW {383 if len([]rune(left)) < leftW {
380 left += strings.Repeat(" ", leftW-len([]rune(left)))384 left += strings.Repeat(" ", leftW-len([]rune(left)))
381 }385 }
382- fmt.Fprintf(os.Stderr, " %s %s\n", fg(left), muted(r.right))386+ fmt.Fprintf(out, " %s %s\n", fg(left), muted(r.right))
383 }387 }
384 }388 }
385 389
386- fmt.Fprintln(os.Stderr)390+ fmt.Fprintln(out)
387 var headline string391 var headline string
388 if useColor {392 if useColor {
389 headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("zot. yet another coding agent harness."))393 headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("zot. yet another coding agent harness."))
390 } else {394 } else {
391 headline = "zot. yet another coding agent harness."395 headline = "zot. yet another coding agent harness."
392 }396 }
393- fmt.Fprintln(os.Stderr, headline)397+ fmt.Fprintln(out, headline)
394- fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands."))398+ fmt.Fprintln(out, muted("ask anything, or type /help inside the tui to see commands."))
395- fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version))399+ fmt.Fprintf(out, "%s %s\n", muted("version:"), fg(version))
396 400
397 section("modes",401 section("modes",
398 row{"zot", "interactive tui"},402 row{"zot", "interactive tui"},
@@ -457,7 +461,7 @@ func PrintHelp(version string) {
457 row{"-h, --help", "show this help"},461 row{"-h, --help", "show this help"},
458 row{"-v, --version", "show version info"},462 row{"-v, --version", "show version info"},
459 )463 )
460- fmt.Fprintln(os.Stderr)464+ fmt.Fprintln(out)
461- fmt.Fprintln(os.Stderr, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md"))465+ fmt.Fprintln(out, assistant("see also: docs/extensions.md, docs/rpc.md, docs/skills.md"))
462- fmt.Fprintln(os.Stderr)466+ fmt.Fprintln(out)
463 }467 }
M packages/agent/args_test.go +55 -1
@@ -1,6 +1,12 @@
11 package agent
22
3-import "testing"
3+import (
4+ "bytes"
5+ "os"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+)
410
511 func TestParseArgsExplicitEmptySystemPromptIsSet(t *testing.T) {
612 args, err := ParseArgs([]string{"--system-prompt", ""})
@@ -81,3 +87,51 @@ func TestParseArgsStream(t *testing.T) {
8187 t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt)
8288 }
8389 }
90+
91+func TestRunHelpHelperProcess(t *testing.T) {
92+ if os.Getenv("ZOT_HELP_HELPER") == "" {
93+ return
94+ }
95+ if err := runWithArgsRaw(strings.Fields(os.Getenv("ZOT_HELP_HELPER")), "test"); err != nil {
96+ os.Exit(1)
97+ }
98+ os.Exit(0)
99+}
100+
101+func TestHelpOutputStreams(t *testing.T) {
102+ run := func(args string) (stdout, stderr string, err error) {
103+ t.Helper()
104+ cmd := exec.Command(os.Args[0], "-test.run=^TestRunHelpHelperProcess$")
105+ cmd.Env = append(os.Environ(), "ZOT_HELP_HELPER="+args)
106+ var outBuf, errBuf bytes.Buffer
107+ cmd.Stdout = &outBuf
108+ cmd.Stderr = &errBuf
109+ err = cmd.Run()
110+ return outBuf.String(), errBuf.String(), err
111+ }
112+
113+ stdout, stderr, err := run("--help")
114+ if err != nil {
115+ t.Fatalf("--help returned %v; stderr:\n%s", err, stderr)
116+ }
117+ if !strings.Contains(stdout, "zot. yet another coding agent harness.") {
118+ t.Fatalf("stdout does not contain help text:\n%s", stdout)
119+ }
120+ if stderr != "" {
121+ t.Fatalf("stderr = %q, want empty", stderr)
122+ }
123+ if strings.Contains(stdout, "\x1b[") {
124+ t.Fatalf("redirected help contains ANSI escapes: %q", stdout)
125+ }
126+
127+ stdout, stderr, err = run("--unknown")
128+ if err == nil {
129+ t.Fatal("unknown flag exited successfully")
130+ }
131+ if stdout != "" {
132+ t.Fatalf("stdout = %q, want empty for an argument error", stdout)
133+ }
134+ if !strings.Contains(stderr, "zot. yet another coding agent harness.") {
135+ t.Fatalf("stderr does not contain help text:\n%s", stderr)
136+ }
137+}
@@ -1,6 +1,12 @@
1 package agent1 package agent
2 2
3-import "testing"3+import (
4+ "bytes"
5+ "os"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+)
4 10
5 func TestParseArgsExplicitEmptySystemPromptIsSet(t *testing.T) {11 func TestParseArgsExplicitEmptySystemPromptIsSet(t *testing.T) {
6 args, err := ParseArgs([]string{"--system-prompt", ""})12 args, err := ParseArgs([]string{"--system-prompt", ""})
@@ -81,3 +87,51 @@ func TestParseArgsStream(t *testing.T) {
81 t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt)87 t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt)
82 }88 }
83 }89 }
90+
91+func TestRunHelpHelperProcess(t *testing.T) {
92+ if os.Getenv("ZOT_HELP_HELPER") == "" {
93+ return
94+ }
95+ if err := runWithArgsRaw(strings.Fields(os.Getenv("ZOT_HELP_HELPER")), "test"); err != nil {
96+ os.Exit(1)
97+ }
98+ os.Exit(0)
99+}
100+
101+func TestHelpOutputStreams(t *testing.T) {
102+ run := func(args string) (stdout, stderr string, err error) {
103+ t.Helper()
104+ cmd := exec.Command(os.Args[0], "-test.run=^TestRunHelpHelperProcess$")
105+ cmd.Env = append(os.Environ(), "ZOT_HELP_HELPER="+args)
106+ var outBuf, errBuf bytes.Buffer
107+ cmd.Stdout = &outBuf
108+ cmd.Stderr = &errBuf
109+ err = cmd.Run()
110+ return outBuf.String(), errBuf.String(), err
111+ }
112+
113+ stdout, stderr, err := run("--help")
114+ if err != nil {
115+ t.Fatalf("--help returned %v; stderr:\n%s", err, stderr)
116+ }
117+ if !strings.Contains(stdout, "zot. yet another coding agent harness.") {
118+ t.Fatalf("stdout does not contain help text:\n%s", stdout)
119+ }
120+ if stderr != "" {
121+ t.Fatalf("stderr = %q, want empty", stderr)
122+ }
123+ if strings.Contains(stdout, "\x1b[") {
124+ t.Fatalf("redirected help contains ANSI escapes: %q", stdout)
125+ }
126+
127+ stdout, stderr, err = run("--unknown")
128+ if err == nil {
129+ t.Fatal("unknown flag exited successfully")
130+ }
131+ if stdout != "" {
132+ t.Fatalf("stdout = %q, want empty for an argument error", stdout)
133+ }
134+ if !strings.Contains(stderr, "zot. yet another coding agent harness.") {
135+ t.Fatalf("stderr does not contain help text:\n%s", stderr)
136+ }
137+}
M packages/agent/cli.go +1 -1
@@ -231,7 +231,7 @@ func runWithArgsRaw(rawArgs []string, version string) error {
231231 return err
232232 }
233233 if args.Help {
234- PrintHelp(version)
234+ printHelp(os.Stdout, version)
235235 return nil
236236 }
237237 if args.Version {
@@ -231,7 +231,7 @@ func runWithArgsRaw(rawArgs []string, version string) error {
231 return err231 return err
232 }232 }
233 if args.Help {233 if args.Help {
234- PrintHelp(version)234+ printHelp(os.Stdout, version)
235 return nil235 return nil
236 }236 }
237 if args.Version {237 if args.Version {

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