detached d451e98

feat(agent): support context-free piped CLI input

commit
d451e98e530812c125a751d1076d82700ab3063f
author
brice · · 2026-08-10 13:56 UTC
committer
brice · · 2026-08-11 07:21 UTC
parent
2dc650c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +154 -18
M README.md +3 -0
@@ -202,6 +202,7 @@ The first time you launch a newer zut binary, the TUI shows the GitHub release n
202202 zut # interactive tui
203203 zut "fix the failing test" # tui, pre-filled prompt
204204 zut -p "list all go files" # print final text, exit
205+cat README.md | zut "summarize this text" # pipe input and infer print mode
205206 zut -p --stats stats.json "task" # print final text and write generation stats
206207 zut --json "refactor main.go" # newline-delimited json events, exit
207208 zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"
@@ -240,6 +241,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
240241 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
241242 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
242243 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
244+| `--no-context-files`, `-nc` | Disable discovery and loading of global and project `AGENTS.md` files. |
243245 | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |
244246
245247 ## Tools
@@ -260,6 +262,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
260262 - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands.
261263 - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.
262264 - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr.
265+- **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`.
263266 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
264267 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
265268
@@ -202,6 +202,7 @@ The first time you launch a newer zut binary, the TUI shows the GitHub release n
202 zut # interactive tui202 zut # interactive tui
203 zut "fix the failing test" # tui, pre-filled prompt203 zut "fix the failing test" # tui, pre-filled prompt
204 zut -p "list all go files" # print final text, exit204 zut -p "list all go files" # print final text, exit
205+cat README.md | zut "summarize this text" # pipe input and infer print mode
205 zut -p --stats stats.json "task" # print final text and write generation stats206 zut -p --stats stats.json "task" # print final text and write generation stats
206 zut --json "refactor main.go" # newline-delimited json events, exit207 zut --json "refactor main.go" # newline-delimited json events, exit
207 zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"208 zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize"
@@ -240,6 +241,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
240 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |241 | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). |
241 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |242 | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
242 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |243 | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
244+| `--no-context-files`, `-nc` | Disable discovery and loading of global and project `AGENTS.md` files. |
243 | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |245 | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. |
244 246
245 ## Tools247 ## Tools
@@ -260,6 +262,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
260 - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands.262 - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands.
261 - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.263 - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout.
262 - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr.264 - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr.
265+- **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`.
263 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).266 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
264 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.267 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
265 268
M docs/rpc.md +2 -2
@@ -23,9 +23,9 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom
2323
2424 ## Flags
2525
26-`zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.
26+`zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`, `--no-context-files`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.
2727
28-RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present.
28+RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity and docs guidance, while enabled append addenda remain present.
2929
3030 ## Auth
3131
@@ -23,9 +23,9 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom
23 23
24 ## Flags24 ## Flags
25 25
26-`zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.26+`zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`, `--no-context-files`. Sessions are disabled by default in RPC mode — the embedding application owns persistence.
27 27
28-RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present.28+RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity and docs guidance, while enabled append addenda remain present.
29 29
30 ## Auth30 ## Auth
31 31
M packages/agent/args.go +7 -0
@@ -106,6 +106,10 @@ type Args struct {
106106 // skill discovery, including built-ins.
107107 WithSkills bool
108108
109+ // NoContextFiles disables discovery and loading of AGENTS.md files
110+ // from $ZUT_HOME and the cwd's ancestor directories.
111+ NoContextFiles bool
112+
109113 // InsecureTLS skips TLS verification for custom inference endpoints.
110114 InsecureTLS bool
111115
@@ -275,6 +279,8 @@ func ParseArgs(in []string) (Args, error) {
275279 a.NoExt = true
276280 case "--no-skill", "--no-skills":
277281 a.NoSkill = true
282+ case "--no-context-files", "-nc":
283+ a.NoContextFiles = true
278284 case "--with-skills", "--with-skill":
279285 // Deprecated no-op: user skills are loaded by default.
280286 a.WithSkills = true
@@ -575,6 +581,7 @@ func PrintHelp(version string) {
575581 row{"-y, --yes", "accept zut run consent without prompting"},
576582 row{"--no-ext", "skip extension discovery for this run"},
577583 row{"--no-skill", "skip all skill discovery for this run"},
584+ row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"},
578585 )
579586 section("misc",
580587 row{"--max-steps N", "agent loop iteration cap (default: unlimited)"},
@@ -106,6 +106,10 @@ type Args struct {
106 // skill discovery, including built-ins.106 // skill discovery, including built-ins.
107 WithSkills bool107 WithSkills bool
108 108
109+ // NoContextFiles disables discovery and loading of AGENTS.md files
110+ // from $ZUT_HOME and the cwd's ancestor directories.
111+ NoContextFiles bool
112+
109 // InsecureTLS skips TLS verification for custom inference endpoints.113 // InsecureTLS skips TLS verification for custom inference endpoints.
110 InsecureTLS bool114 InsecureTLS bool
111 115
@@ -275,6 +279,8 @@ func ParseArgs(in []string) (Args, error) {
275 a.NoExt = true279 a.NoExt = true
276 case "--no-skill", "--no-skills":280 case "--no-skill", "--no-skills":
277 a.NoSkill = true281 a.NoSkill = true
282+ case "--no-context-files", "-nc":
283+ a.NoContextFiles = true
278 case "--with-skills", "--with-skill":284 case "--with-skills", "--with-skill":
279 // Deprecated no-op: user skills are loaded by default.285 // Deprecated no-op: user skills are loaded by default.
280 a.WithSkills = true286 a.WithSkills = true
@@ -575,6 +581,7 @@ func PrintHelp(version string) {
575 row{"-y, --yes", "accept zut run consent without prompting"},581 row{"-y, --yes", "accept zut run consent without prompting"},
576 row{"--no-ext", "skip extension discovery for this run"},582 row{"--no-ext", "skip extension discovery for this run"},
577 row{"--no-skill", "skip all skill discovery for this run"},583 row{"--no-skill", "skip all skill discovery for this run"},
584+ row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"},
578 )585 )
579 section("misc",586 section("misc",
580 row{"--max-steps N", "agent loop iteration cap (default: unlimited)"},587 row{"--max-steps N", "agent loop iteration cap (default: unlimited)"},
M packages/agent/args_test.go +12 -0
@@ -188,6 +188,18 @@ func TestParseArgsStatsRequiresPrintMode(t *testing.T) {
188188 }
189189 }
190190
191+func TestParseArgsNoContextFiles(t *testing.T) {
192+ for _, flag := range []string{"--no-context-files", "-nc"} {
193+ args, err := ParseArgs([]string{flag})
194+ if err != nil {
195+ t.Fatalf("ParseArgs(%q): %v", flag, err)
196+ }
197+ if !args.NoContextFiles {
198+ t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag)
199+ }
200+ }
201+}
202+
191203 func TestParseArgsStream(t *testing.T) {
192204 args, err := ParseArgs([]string{"--stream", "hi"})
193205 if err != nil {
@@ -188,6 +188,18 @@ func TestParseArgsStatsRequiresPrintMode(t *testing.T) {
188 }188 }
189 }189 }
190 190
191+func TestParseArgsNoContextFiles(t *testing.T) {
192+ for _, flag := range []string{"--no-context-files", "-nc"} {
193+ args, err := ParseArgs([]string{flag})
194+ if err != nil {
195+ t.Fatalf("ParseArgs(%q): %v", flag, err)
196+ }
197+ if !args.NoContextFiles {
198+ t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag)
199+ }
200+ }
201+}
202+
191 func TestParseArgsStream(t *testing.T) {203 func TestParseArgsStream(t *testing.T) {
192 args, err := ParseArgs([]string{"--stream", "hi"})204 args, err := ParseArgs([]string{"--stream", "hi"})
193 if err != nil {205 if err != nil {
M packages/agent/build.go +1 -1
@@ -842,7 +842,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
842842 summaries := toolSummaries(reg, args)
843843
844844 contextFiles := []ContextFile(nil)
845- if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {
845+ if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) {
846846 contextFiles = loadAgentsContext(args.CWD, ZutHome())
847847 }
848848 append_ := []string(nil)
@@ -842,7 +842,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
842 summaries := toolSummaries(reg, args)842 summaries := toolSummaries(reg, args)
843 843
844 contextFiles := []ContextFile(nil)844 contextFiles := []ContextFile(nil)
845- if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext {845+ if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) {
846 contextFiles = loadAgentsContext(args.CWD, ZutHome())846 contextFiles = loadAgentsContext(args.CWD, ZutHome())
847 }847 }
848 append_ := []string(nil)848 append_ := []string(nil)
M packages/agent/build_test.go +29 -0
@@ -708,6 +708,35 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
708708 }
709709 }
710710
711+func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) {
712+ zutHome := t.TempDir()
713+ cwd := t.TempDir()
714+ t.Setenv("ZUT_HOME", zutHome)
715+ t.Setenv("OPENAI_API_KEY", "test-key")
716+ if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
717+ t.Fatal(err)
718+ }
719+ if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil {
720+ t.Fatal(err)
721+ }
722+
723+ r, err := Resolve(Args{
724+ Provider: "openai",
725+ Model: "gpt-5",
726+ CWD: cwd,
727+ NoContextFiles: true,
728+ }, false)
729+ if err != nil {
730+ t.Fatal(err)
731+ }
732+ if len(r.ContextFiles) != 0 {
733+ t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles)
734+ }
735+ if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") {
736+ t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt)
737+ }
738+}
739+
711740 // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
712741 // startup failure caught by the user's screenshot: the persisted
713742 // config.json points at a model id that's no longer in the active
@@ -708,6 +708,35 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
708 }708 }
709 }709 }
710 710
711+func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) {
712+ zutHome := t.TempDir()
713+ cwd := t.TempDir()
714+ t.Setenv("ZUT_HOME", zutHome)
715+ t.Setenv("OPENAI_API_KEY", "test-key")
716+ if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
717+ t.Fatal(err)
718+ }
719+ if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil {
720+ t.Fatal(err)
721+ }
722+
723+ r, err := Resolve(Args{
724+ Provider: "openai",
725+ Model: "gpt-5",
726+ CWD: cwd,
727+ NoContextFiles: true,
728+ }, false)
729+ if err != nil {
730+ t.Fatal(err)
731+ }
732+ if len(r.ContextFiles) != 0 {
733+ t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles)
734+ }
735+ if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") {
736+ t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt)
737+ }
738+}
739+
711 // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the740 // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
712 // startup failure caught by the user's screenshot: the persisted741 // startup failure caught by the user's screenshot: the persisted
713 // config.json points at a model id that's no longer in the active742 // config.json points at a model id that's no longer in the active
M packages/agent/cli.go +27 -15
@@ -804,6 +804,9 @@ func runWithArgsRaw(rawArgs []string, version string) error {
804804 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
805805 }
806806
807+ if fi, err := os.Stdin.Stat(); err == nil {
808+ rawArgs = argsForStdin(rawArgs, fi.Mode())
809+ }
807810 args, err := ParseArgs(rawArgs)
808811 if err != nil {
809812 PrintHelp(version)
@@ -1009,11 +1012,8 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
10091012 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
10101013 }
10111014
1012- prompt := args.Prompt
1013- if prompt == "" {
1014- piped, _ := readAllStdin()
1015- prompt = strings.TrimSpace(piped)
1016- }
1015+ piped, _ := readAllStdin()
1016+ prompt := combinePromptInput(piped, args.Prompt)
10171017 if prompt == "" {
10181018 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
10191019 }
@@ -1094,11 +1094,8 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
10941094 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
10951095 }
10961096
1097- prompt := args.Prompt
1098- if prompt == "" {
1099- piped, _ := readAllStdin()
1100- prompt = strings.TrimSpace(piped)
1101- }
1097+ piped, _ := readAllStdin()
1098+ prompt := combinePromptInput(piped, args.Prompt)
11021099 if prompt == "" {
11031100 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
11041101 }
@@ -1218,11 +1215,8 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
12181215 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
12191216 }
12201217
1221- prompt := args.Prompt
1222- if prompt == "" {
1223- piped, _ := readAllStdin()
1224- prompt = strings.TrimSpace(piped)
1225- }
1218+ piped, _ := readAllStdin()
1219+ prompt := combinePromptInput(piped, args.Prompt)
12261220 if prompt == "" {
12271221 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
12281222 }
@@ -2891,6 +2885,24 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex
28912885 return next, nil
28922886 }
28932887
2888+func argsForStdin(rawArgs []string, mode os.FileMode) []string {
2889+ if mode&os.ModeCharDevice != 0 {
2890+ return rawArgs
2891+ }
2892+ return append([]string{"--print"}, rawArgs...)
2893+}
2894+
2895+func combinePromptInput(stdin, positional string) string {
2896+ parts := make([]string, 0, 2)
2897+ if stdin = strings.TrimSpace(stdin); stdin != "" {
2898+ parts = append(parts, stdin)
2899+ }
2900+ if positional = strings.TrimSpace(positional); positional != "" {
2901+ parts = append(parts, positional)
2902+ }
2903+ return strings.Join(parts, "\n")
2904+}
2905+
28942906 func readAllStdin() (string, error) {
28952907 fi, err := os.Stdin.Stat()
28962908 if err != nil {
@@ -804,6 +804,9 @@ func runWithArgsRaw(rawArgs []string, version string) error {
804 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)804 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
805 }805 }
806 806
807+ if fi, err := os.Stdin.Stat(); err == nil {
808+ rawArgs = argsForStdin(rawArgs, fi.Mode())
809+ }
807 args, err := ParseArgs(rawArgs)810 args, err := ParseArgs(rawArgs)
808 if err != nil {811 if err != nil {
809 PrintHelp(version)812 PrintHelp(version)
@@ -1009,11 +1012,8 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
1009 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()1012 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1010 }1013 }
1011 1014
1012- prompt := args.Prompt1015+ piped, _ := readAllStdin()
1013- if prompt == "" {1016+ prompt := combinePromptInput(piped, args.Prompt)
1014- piped, _ := readAllStdin()
1015- prompt = strings.TrimSpace(piped)
1016- }
1017 if prompt == "" {1017 if prompt == "" {
1018 return fmt.Errorf("print mode requires a prompt (arg or stdin)")1018 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
1019 }1019 }
@@ -1094,11 +1094,8 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
1094 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()1094 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1095 }1095 }
1096 1096
1097- prompt := args.Prompt1097+ piped, _ := readAllStdin()
1098- if prompt == "" {1098+ prompt := combinePromptInput(piped, args.Prompt)
1099- piped, _ := readAllStdin()
1100- prompt = strings.TrimSpace(piped)
1101- }
1102 if prompt == "" {1099 if prompt == "" {
1103 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")1100 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
1104 }1101 }
@@ -1218,11 +1215,8 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
1218 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()1215 defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1219 }1216 }
1220 1217
1221- prompt := args.Prompt1218+ piped, _ := readAllStdin()
1222- if prompt == "" {1219+ prompt := combinePromptInput(piped, args.Prompt)
1223- piped, _ := readAllStdin()
1224- prompt = strings.TrimSpace(piped)
1225- }
1226 if prompt == "" {1220 if prompt == "" {
1227 return fmt.Errorf("json mode requires a prompt (arg or stdin)")1221 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
1228 }1222 }
@@ -2891,6 +2885,24 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex
2891 return next, nil2885 return next, nil
2892 }2886 }
2893 2887
2888+func argsForStdin(rawArgs []string, mode os.FileMode) []string {
2889+ if mode&os.ModeCharDevice != 0 {
2890+ return rawArgs
2891+ }
2892+ return append([]string{"--print"}, rawArgs...)
2893+}
2894+
2895+func combinePromptInput(stdin, positional string) string {
2896+ parts := make([]string, 0, 2)
2897+ if stdin = strings.TrimSpace(stdin); stdin != "" {
2898+ parts = append(parts, stdin)
2899+ }
2900+ if positional = strings.TrimSpace(positional); positional != "" {
2901+ parts = append(parts, positional)
2902+ }
2903+ return strings.Join(parts, "\n")
2904+}
2905+
2894 func readAllStdin() (string, error) {2906 func readAllStdin() (string, error) {
2895 fi, err := os.Stdin.Stat()2907 fi, err := os.Stdin.Stat()
2896 if err != nil {2908 if err != nil {
A packages/agent/prompt_input_test.go +73 -0
@@ -0,0 +1,73 @@
1+package agent
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestArgsForStdinSelectsPrintModeForPipe(t *testing.T) {
9+ args, err := ParseArgs(argsForStdin([]string{"summarize"}, os.ModeNamedPipe))
10+ if err != nil {
11+ t.Fatal(err)
12+ }
13+ if args.Mode != ModePrint || args.Prompt != "summarize" {
14+ t.Fatalf("Mode=%q Prompt=%q, want print mode with prompt", args.Mode, args.Prompt)
15+ }
16+}
17+
18+func TestArgsForStdinPreservesTerminalAndExplicitModes(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ in []string
22+ mode os.FileMode
23+ want Mode
24+ }{
25+ {name: "terminal stays interactive", mode: os.ModeCharDevice, want: ModeInteractive},
26+ {name: "explicit stream overrides pipe default", in: []string{"--stream", "prompt"}, mode: os.ModeNamedPipe, want: ModeStream},
27+ {name: "explicit json overrides redirected file default", in: []string{"--json", "prompt"}, want: ModeJSON},
28+ {name: "explicit rpc overrides pipe default", in: []string{"--rpc"}, mode: os.ModeNamedPipe, want: ModeRPC},
29+ }
30+ for _, tc := range tests {
31+ t.Run(tc.name, func(t *testing.T) {
32+ args, err := ParseArgs(argsForStdin(tc.in, tc.mode))
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if args.Mode != tc.want {
37+ t.Fatalf("Mode=%q, want %q", args.Mode, tc.want)
38+ }
39+ })
40+ }
41+}
42+
43+func TestArgsForStdinAllowsPrintStats(t *testing.T) {
44+ args, err := ParseArgs(argsForStdin([]string{"--stats", "stats.json"}, os.ModeNamedPipe))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ if args.Mode != ModePrint || args.StatsPath != "stats.json" {
49+ t.Fatalf("Mode=%q StatsPath=%q, want print mode with stats path", args.Mode, args.StatsPath)
50+ }
51+}
52+
53+func TestCombinePromptInput(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ stdin string
57+ positional string
58+ want string
59+ }{
60+ {name: "stdin and instruction", stdin: "input text\n", positional: "summarize it", want: "input text\nsummarize it"},
61+ {name: "stdin only", stdin: " input text\n", want: "input text"},
62+ {name: "positional only", positional: " summarize it ", want: "summarize it"},
63+ {name: "empty", stdin: " \n", positional: " ", want: ""},
64+ }
65+
66+ for _, tc := range tests {
67+ t.Run(tc.name, func(t *testing.T) {
68+ if got := combinePromptInput(tc.stdin, tc.positional); got != tc.want {
69+ t.Fatalf("combinePromptInput(%q, %q) = %q, want %q", tc.stdin, tc.positional, got, tc.want)
70+ }
71+ })
72+ }
73+}
@@ -0,0 +1,73 @@
1+package agent
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestArgsForStdinSelectsPrintModeForPipe(t *testing.T) {
9+ args, err := ParseArgs(argsForStdin([]string{"summarize"}, os.ModeNamedPipe))
10+ if err != nil {
11+ t.Fatal(err)
12+ }
13+ if args.Mode != ModePrint || args.Prompt != "summarize" {
14+ t.Fatalf("Mode=%q Prompt=%q, want print mode with prompt", args.Mode, args.Prompt)
15+ }
16+}
17+
18+func TestArgsForStdinPreservesTerminalAndExplicitModes(t *testing.T) {
19+ tests := []struct {
20+ name string
21+ in []string
22+ mode os.FileMode
23+ want Mode
24+ }{
25+ {name: "terminal stays interactive", mode: os.ModeCharDevice, want: ModeInteractive},
26+ {name: "explicit stream overrides pipe default", in: []string{"--stream", "prompt"}, mode: os.ModeNamedPipe, want: ModeStream},
27+ {name: "explicit json overrides redirected file default", in: []string{"--json", "prompt"}, want: ModeJSON},
28+ {name: "explicit rpc overrides pipe default", in: []string{"--rpc"}, mode: os.ModeNamedPipe, want: ModeRPC},
29+ }
30+ for _, tc := range tests {
31+ t.Run(tc.name, func(t *testing.T) {
32+ args, err := ParseArgs(argsForStdin(tc.in, tc.mode))
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if args.Mode != tc.want {
37+ t.Fatalf("Mode=%q, want %q", args.Mode, tc.want)
38+ }
39+ })
40+ }
41+}
42+
43+func TestArgsForStdinAllowsPrintStats(t *testing.T) {
44+ args, err := ParseArgs(argsForStdin([]string{"--stats", "stats.json"}, os.ModeNamedPipe))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ if args.Mode != ModePrint || args.StatsPath != "stats.json" {
49+ t.Fatalf("Mode=%q StatsPath=%q, want print mode with stats path", args.Mode, args.StatsPath)
50+ }
51+}
52+
53+func TestCombinePromptInput(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ stdin string
57+ positional string
58+ want string
59+ }{
60+ {name: "stdin and instruction", stdin: "input text\n", positional: "summarize it", want: "input text\nsummarize it"},
61+ {name: "stdin only", stdin: " input text\n", want: "input text"},
62+ {name: "positional only", positional: " summarize it ", want: "summarize it"},
63+ {name: "empty", stdin: " \n", positional: " ", want: ""},
64+ }
65+
66+ for _, tc := range tests {
67+ t.Run(tc.name, func(t *testing.T) {
68+ if got := combinePromptInput(tc.stdin, tc.positional); got != tc.want {
69+ t.Fatalf("combinePromptInput(%q, %q) = %q, want %q", tc.stdin, tc.positional, got, tc.want)
70+ }
71+ })
72+ }
73+}

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