Compare commits

..
Author SHA1 Message Date
anandh8x 91f98c66d8 refactor(cli): extract the headless turn-budget decision 2026-09-12 21:57:17 +05:30
anandh8x c3d2fce6eb test(cli,config): cover the headless turn-budget default 2026-09-12 21:49:06 +05:30
anandh8x 0bb14894ec feat(exec): default headless runs to the turn-budget ceiling
The shared 80-turn default is sized for the TUI, where the user can
say "continue" and the run resumes. Headless exec has nobody to do
that, so benchmark evals truncated at 80 turns mid-implementation.
An unset budget now resolves to MaxTurnsCeiling for exec only; any
explicit source (flag, mode preset, ZERO_MAX_TURNS, config file) and
exec-profile budgets keep their precedence. The TUI default is
unchanged.
2026-09-12 21:44:00 +05:30
anandh8x 4c91718d7e feat(config): report whether the tool-turn budget was explicitly set
ResolvedConfig.MaxTurns is ambiguous today: a configured 80 and the
built-in default 80 are indistinguishable, so a caller that wants a
different default cannot tell "user chose this" from "nobody set it".
FileConfig now records at each merge gate (config files, provider
command, ZERO_MAX_TURNS, CLI overrides) when a source supplies a
positive maxTurns, and Resolve surfaces it as MaxTurnsSet.
2026-09-12 21:43:05 +05:30
24 changed files with 119 additions and 1785 deletions

No files matched your search

+23
View File
@@ -49,6 +49,14 @@ const (
exitInterrupted = 130
)
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
// An interactive run can be continued past the shared interactive default by the
// user; an exec run has nobody to unstick it, so evals and automation would
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
// still finite against a genuinely runaway loop, which the loop's empty-turn
// guard also cuts independently of this budget.
const defaultExecMaxTurns = config.MaxTurnsCeiling
type execOutputFormat string
type execInputFormat string
@@ -320,6 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
}
resolved.MaxTurns = execTurnBudget(resolved)
var displacedMaxTurns int
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
@@ -1222,6 +1231,20 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
}
// execTurnBudget applies the headless default to the resolved turn budget: an
// interactive run can be continued past the shared default by the user, but an
// exec run has nobody to unstick it, so an unconfigured budget starts at the
// documented ceiling instead of truncating mid-task. Explicit sources
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
// MaxTurnsSet and pass through; an exec-profile budget still wins via
// applyProfileTurnBudget afterward.
func execTurnBudget(resolved config.ResolvedConfig) int {
if resolved.MaxTurnsSet {
return resolved.MaxTurns
}
return defaultExecMaxTurns
}
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
// The profile displaces the RESOLVED value, not the flag: a pinned budget
// always wins and the profile backs off entirely, while an env/config budget
+20
View File
@@ -1975,3 +1975,23 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
}
}
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
cases := []struct {
name string
resolved config.ResolvedConfig
want int
}{
// A headless run has no user to continue past the shared interactive
// default, so an unconfigured budget starts at the ceiling.
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
}
for _, tc := range cases {
if got := execTurnBudget(tc.resolved); got != tc.want {
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
}
}
}
+5
View File
@@ -166,6 +166,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Providers: providers,
Provider: active,
MaxTurns: cfg.MaxTurns,
MaxTurnsSet: cfg.maxTurnsSet,
MCP: cfg.MCP,
Sandbox: cfg.Sandbox,
Notify: cfg.Notify,
@@ -234,6 +235,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
}
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
dst.maxTurnsSet = true
}
for _, provider := range src.Providers {
mergeProvider(dst, provider)
@@ -294,6 +296,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
}
if src.MaxTurns > 0 {
dst.MaxTurns = src.MaxTurns
dst.maxTurnsSet = true
}
for _, provider := range src.Providers {
candidate := providerMergeCandidate(*dst, provider)
@@ -606,6 +609,7 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
n = MaxTurnsCeiling
}
cfg.MaxTurns = n
cfg.maxTurnsSet = true
}
}
@@ -726,6 +730,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
}
if overrides.MaxTurns > 0 {
cfg.MaxTurns = overrides.MaxTurns
cfg.maxTurnsSet = true
}
if overrides.Sandbox.Enabled != nil {
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
+42
View File
@@ -2252,3 +2252,45 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
t.Fatalf("error = %v", err)
}
}
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
const providerJSON = `{
"activeProvider": "p",
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
}`
providerWithTurns := strings.Replace(providerJSON, `"activeProvider": "p"`, `"activeProvider": "p", "maxTurns": 40`, 1)
cases := []struct {
name string
opts ResolveOptions
set bool
}{
{"built-in default only", ResolveOptions{
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
}, false},
{"user config", ResolveOptions{UserConfigPath: writeConfig(t, providerWithTurns)}, true},
{"project config", ResolveOptions{
UserConfigPath: writeConfig(t, providerJSON),
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
}, true},
{"env", ResolveOptions{
Env: map[string]string{MaxTurnsEnv: "120"},
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
}, true},
{"cli override", ResolveOptions{
Overrides: Overrides{
MaxTurns: 42,
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
},
}, true},
}
for _, tc := range cases {
resolved, err := Resolve(tc.opts)
if err != nil {
t.Fatalf("%s: Resolve() error = %v", tc.name, err)
}
if resolved.MaxTurnsSet != tc.set {
t.Fatalf("%s: MaxTurnsSet = %v, want %v (MaxTurns=%d)", tc.name, resolved.MaxTurnsSet, tc.set, resolved.MaxTurns)
}
}
}
+13 -4
View File
@@ -364,6 +364,10 @@ type FileConfig struct {
LocalControl LocalControlConfig `json:"localControl,omitempty"`
STT STTConfig `json:"stt,omitempty"`
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
// maxTurnsSet records that some merge source supplied a positive maxTurns —
// i.e. the value is configured, not the built-in default. Unexported like
// Tools.deferThresholdSet: merge bookkeeping, not a config key.
maxTurnsSet bool
// Extra preserves top-level fields written by newer Zero versions or
// extensions so a read-modify-write through this version is non-destructive.
Extra map[string]json.RawMessage `json:"-"`
@@ -461,10 +465,15 @@ type Overrides struct {
}
type ResolvedConfig struct {
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
// MaxTurnsSet reports whether MaxTurns came from an explicit source (a
// config file, a provider command, ZERO_MAX_TURNS, or CLI overrides) rather
// than the built-in default — callers that need a different default can
// tell the two apart.
MaxTurnsSet bool
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
-43
View File
@@ -224,49 +224,6 @@ func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContin
return result, nil
}
// WriteInput writes bytes to a retained interactive process's stdin without
// collecting output. Unlike Continue it never drains the pending output
// buffer, so a concurrent write_stdin poll still sees everything the process
// emitted; callers that only need the rolling tail use Snapshot instead.
func (manager *ProcessManager) WriteInput(id int, data []byte) error {
process, ok := manager.get(id)
if !ok {
return ErrProcessNotFound
}
process.touch()
if len(data) == 0 {
return nil
}
if !process.tty || process.stdin == nil {
return ErrProcessStdinDisabled
}
if _, err := process.stdin.Write(data); err != nil && !process.doneClosed() {
return err
}
return nil
}
// ResizeInput updates the PTY window size of a retained interactive process
// so an attached terminal can fill its viewport. Non-positive dimensions are
// a no-op; platforms without PTY support report the transport's error.
func (manager *ProcessManager) ResizeInput(id int, cols, rows int) error {
if cols <= 0 || rows <= 0 {
return nil
}
process, ok := manager.get(id)
if !ok {
return ErrProcessNotFound
}
process.touch()
if !process.tty || process.stdin == nil {
return ErrProcessStdinDisabled
}
if err := resizePTY(process.stdin, cols, rows); err != nil && !process.doneClosed() {
return err
}
return nil
}
func clampInitialProcessWait(wait time.Duration) time.Duration {
return min(wait, maxInteractiveYield)
}
-121
View File
@@ -2,7 +2,6 @@ package execution
import (
"context"
"errors"
"io"
"os"
"os/exec"
@@ -86,126 +85,6 @@ func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
}
}
func TestProcessManagerWriteInputUnknownProcess(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
if err := manager.WriteInput(4242, []byte("x")); !errors.Is(err, ErrProcessNotFound) {
t.Fatalf("WriteInput unknown id = %v, want ErrProcessNotFound", err)
}
}
func TestProcessManagerWriteInputRejectsPipeProcess(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test command uses a POSIX shell")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.Command("/bin/sh", "-c", "sleep 30")
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
}, time.Millisecond)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer manager.Stop(started.ProcessID)
if started.TTY {
t.Fatal("pipe process reported a TTY")
}
if err := manager.WriteInput(started.ProcessID, []byte("x")); !errors.Is(err, ErrProcessStdinDisabled) {
t.Fatalf("WriteInput pipe process = %v, want ErrProcessStdinDisabled", err)
}
if err := manager.WriteInput(started.ProcessID, nil); err != nil {
t.Fatalf("WriteInput empty data = %v, want nil", err)
}
}
func TestProcessManagerWriteInputDoesNotDrainPendingOutput(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("PTY sessions are only available on Linux")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.CommandContext(context.Background(), "cat")
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
CommandText: "cat", TTY: true,
}, 50*time.Millisecond)
if err != nil {
t.Skipf("PTY transport unavailable: %v", err)
}
if !started.TTY {
t.Skip("PTY transport fell back to pipes")
}
defer manager.Stop(started.ProcessID)
if err := manager.WriteInput(started.ProcessID, []byte("hello\r")); err != nil {
t.Fatalf("WriteInput: %v", err)
}
// The PTY echoes input back into the output stream, so the rolling recent
// tail shows what was typed without anything being drained.
deadline := time.Now().Add(5 * time.Second)
for {
snapshot, ok := manager.Snapshot(started.ProcessID)
if ok && strings.Contains(snapshot.RecentOutput, "hello") {
break
}
if time.Now().After(deadline) {
t.Fatalf("recent output never echoed the input: %q", snapshot.RecentOutput)
}
time.Sleep(10 * time.Millisecond)
}
// WriteInput must not have consumed the pending buffer: a write_stdin-style
// Continue still collects the echoed bytes.
continued, err := manager.Continue(context.Background(), ProcessContinue{
ProcessID: started.ProcessID, Wait: 200 * time.Millisecond,
})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if !strings.Contains(continued.Output, "hello") {
t.Fatalf("WriteInput drained the pending output: continued output = %q", continued.Output)
}
}
func TestProcessManagerResizeInputUnknownProcess(t *testing.T) {
manager := NewProcessManager(ProcessManagerOptions{})
if err := manager.ResizeInput(4242, 100, 40); !errors.Is(err, ErrProcessNotFound) {
t.Fatalf("ResizeInput unknown id = %v, want ErrProcessNotFound", err)
}
}
func TestProcessManagerResizeInputUpdatesWindowSize(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("PTY sessions are only available on Linux")
}
root := t.TempDir()
manager := NewProcessManager(ProcessManagerOptions{})
command := exec.CommandContext(context.Background(), "/bin/sh", "-c", "sleep 0.3; stty size")
started, err := manager.Start(context.Background(), ProcessStart{
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
CommandText: "stty size", TTY: true,
}, time.Millisecond)
if err != nil {
t.Skipf("PTY transport unavailable: %v", err)
}
if !started.TTY {
t.Skip("PTY transport fell back to pipes")
}
defer manager.Stop(started.ProcessID)
if err := manager.ResizeInput(started.ProcessID, 100, 40); err != nil {
t.Fatalf("ResizeInput: %v", err)
}
continued, err := manager.Continue(context.Background(), ProcessContinue{
ProcessID: started.ProcessID, Wait: 2 * time.Second,
})
if err != nil {
t.Fatalf("Continue: %v", err)
}
if combined := started.Output + continued.Output; !strings.Contains(combined, "40 100") {
t.Fatalf("stty size output = %q, want %q", combined, "40 100")
}
}
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
reaped := make(chan struct{})
close(reaped)
-4
View File
@@ -11,7 +11,3 @@ import (
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
return nil, nil, errors.New("pty transport is unavailable on this platform")
}
func resizePTY(_ io.Writer, _, _ int) error {
return errors.New("pty transport is unavailable on this platform")
}
-16
View File
@@ -48,9 +48,6 @@ func openPTY() (*os.File, *os.File, error) {
_ = master.Close()
return nil, nil, err
}
// A fresh PTY defaults to a 0x0 window; seed a sane size so the process
// never sees zero dimensions before an attached client reports its own.
_ = unix.IoctlSetWinsize(masterFD, unix.TIOCSWINSZ, &unix.Winsize{Row: 24, Col: 80})
pts, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN)
if err != nil {
_ = master.Close()
@@ -83,16 +80,3 @@ func hardenPTYProcess(command *exec.Cmd) {
return nil
}
}
// resizePTY reports a new window size to the PTY behind an interactive
// process's stdin writer, so full-screen attached terminals drive the
// session's own line wrapping.
func resizePTY(w io.Writer, cols, rows int) error {
file, ok := w.(interface{ Fd() uintptr })
if !ok {
return errors.New("pty transport is unavailable on this platform")
}
return unix.IoctlSetWinsize(int(file.Fd()), unix.TIOCSWINSZ, &unix.Winsize{
Row: uint16(rows), Col: uint16(cols),
})
}
+5 -32
View File
@@ -47,11 +47,8 @@ type ExecSessionSnapshot = execution.ProcessSnapshot
type ExecSessionController interface {
ExecSessions() []ExecSessionSnapshot
ExecSession(id int) (ExecSessionSnapshot, bool)
StopExecSession(id int) bool
StopAllExecSessions() []int
WriteExecSessionInput(id int, data []byte) error
ResizeExecSession(id int, cols, rows int) error
}
type execCommandTool struct {
@@ -98,7 +95,7 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
},
"justification": {Type: "string", Description: "User-facing approval question for `require_escalated`; omit otherwise."},
"prefix_rule": {Type: "array", Items: &PropertySchema{Type: "string"}, Description: "Reusable approval prefix for this command, only with `sandbox_permissions: \"require_escalated\"`; keep it narrow, for example [\"git\", \"pull\"]."},
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes. Use true for commands that may prompt for input (sudo, ssh, interactive installers): the user can then type into the session directly. Setuid tools such as sudo also need sandbox_permissions \"require_escalated\", since the sandbox blocks privilege escalation.", Default: false},
"tty": {Type: "boolean", Description: "True allocates a PTY for the command; false or omitted uses plain pipes.", Default: false},
},
Required: []string{"cmd"},
AdditionalProperties: false,
@@ -134,10 +131,6 @@ func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
return tool.manager.List()
}
func (tool execCommandTool) ExecSession(id int) (ExecSessionSnapshot, bool) {
return tool.manager.Snapshot(id)
}
func (tool execCommandTool) StopExecSession(id int) bool {
return tool.manager.Stop(id)
}
@@ -146,14 +139,6 @@ func (tool execCommandTool) StopAllExecSessions() []int {
return tool.manager.StopAll()
}
func (tool execCommandTool) WriteExecSessionInput(id int, data []byte) error {
return tool.manager.WriteInput(id, data)
}
func (tool execCommandTool) ResizeExecSession(id int, cols, rows int) error {
return tool.manager.ResizeInput(id, cols, rows)
}
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
commandText, err := execCommandArg(args)
if err != nil {
@@ -187,14 +172,8 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
// tty:true exists exactly to run prompting commands, so the
// non-interactive guard steps aside and the PTY path gets the session.
if !ttyRequested {
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
result := interactiveBlockResult(interactive)
result.Output += "\nRerun with tty:true if the user should interact with it."
return result
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
return interactiveBlockResult(interactive)
}
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, workdir)
if err != nil {
@@ -474,7 +453,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
if input.exited && input.exitCode != 0 && !input.interrupted {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted, input.tty)
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
meta["shell_issue"] = issue.Kind
@@ -596,7 +575,7 @@ func executionChangeSummaries(changes []execution.Change) []execution.Change {
return summaries
}
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool, tty bool) string {
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
output = strings.TrimRight(output, "\r\n")
parts := []string{}
if output != "" {
@@ -614,18 +593,12 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
parts = append(parts, "interrupted: true")
}
parts = append(parts, fmt.Sprintf("exit_code: %d", exitCode))
if exitCode != 0 && strings.Contains(output, "no new privileges") {
parts = append(parts, `Hint: this command needs sandbox_permissions "require_escalated" (the sandbox blocks setuid); retry with it and tty:true if it prompts.`)
}
} else {
if output == "" {
parts = append(parts, "Command is still running.")
}
parts = append(parts, fmt.Sprintf("session_id: %d", sessionID))
parts = append(parts, fmt.Sprintf("Use write_stdin with session_id %d and empty chars to poll; send chars \"\\u0003\" to interrupt/stop it.", sessionID))
if tty {
parts = append(parts, fmt.Sprintf("This session has a terminal and the user can already type into it (it opened in their TUI; /attach %d reopens it). If it is waiting on a password or confirmation, say so in one line, then keep polling with write_stdin (empty chars, yield_time_ms 60000) until it exits. Do not end your turn while it is running, and never ask for the password in chat.", sessionID))
}
}
return strings.Join(parts, "\n")
}
-53
View File
@@ -894,56 +894,3 @@ func TestTruncateExecOutputPreservesUTF8(t *testing.T) {
t.Fatalf("truncated output is not valid UTF-8: %q", truncated)
}
}
func TestFormatExecCommandOutputTTYAttachHint(t *testing.T) {
running := formatExecCommandOutput("", 1007, false, 0, false, true)
if !strings.Contains(running, "/attach 1007 reopens it") || !strings.Contains(running, "Do not end your turn while it is running") {
t.Fatalf("tty running session should point at /attach and keep-poll guidance: %q", running)
}
exited := formatExecCommandOutput("", 1007, true, 0, false, true)
if strings.Contains(exited, "/attach") {
t.Fatalf("exited session should not mention /attach: %q", exited)
}
pipes := formatExecCommandOutput("", 1007, false, 0, false, false)
if strings.Contains(pipes, "/attach") {
t.Fatalf("non-tty session should not mention /attach: %q", pipes)
}
}
func TestExecCommandInteractiveBlockSuggestsTTY(t *testing.T) {
tool := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager())
result := tool.Run(context.Background(), map[string]any{"cmd": "ssh host.example.com"})
if result.Status != StatusError || result.Meta["safety_block"] != "interactive_command" {
t.Fatalf("expected interactive safety block, got meta=%#v output=%q", result.Meta, result.Output)
}
if !strings.Contains(result.Output, "tty:true") {
t.Fatalf("block output should suggest tty:true: %q", result.Output)
}
}
func TestExecCommandTTYSkipsInteractiveBlock(t *testing.T) {
tool := NewScopedExecCommandTool(t.TempDir(), nil, newExecSessionManager())
// ssh to a bogus host fails fast on its own; the point is that the
// interactive guard must not fire ahead of the tty path.
result := tool.Run(context.Background(), map[string]any{
"cmd": "ssh host.example.com", "tty": true, "yield_time_ms": 250,
})
if result.Meta["safety_block"] == "interactive_command" {
t.Fatalf("tty:true should not hit the interactive block: %#v", result.Meta)
}
}
func TestFormatExecCommandOutputNoNewPrivilegesHint(t *testing.T) {
blocked := formatExecCommandOutput(`sudo: The "no new privileges" flag is set`, 1007, true, 1, false, true)
if !strings.Contains(blocked, `sandbox_permissions "require_escalated"`) {
t.Fatalf("no_new_privs failure should hint at require_escalated: %q", blocked)
}
normal := formatExecCommandOutput("some other error", 1007, true, 1, false, true)
if strings.Contains(normal, "require_escalated") {
t.Fatalf("unrelated failure should not hint at require_escalated: %q", normal)
}
blockedOK := formatExecCommandOutput(`sudo: The "no new privileges" flag is set`, 1007, true, 0, false, true)
if strings.Contains(blockedOK, "require_escalated") {
t.Fatalf("zero-exit output should not hint at require_escalated: %q", blockedOK)
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ func (m model) attachmentThumbnailVisible(width int) bool {
}
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
+4 -94
View File
@@ -6,8 +6,6 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -32,21 +30,15 @@ func (m model) backgroundTerminalSessions() []tools.ExecSessionSnapshot {
}
func (m model) backgroundTerminalSummary() string {
sessions := m.backgroundTerminalSessions()
if len(sessions) == 0 {
count := len(m.backgroundTerminalSessions())
if count == 0 {
return ""
}
plural := ""
if len(sessions) != 1 {
if count != 1 {
plural = "s"
}
summary := fmt.Sprintf("%d background terminal%s running · /ps to view · /stop to close", len(sessions), plural)
for _, session := range sessions {
if session.TTY {
return summary + " · /attach to type into it"
}
}
return summary
return fmt.Sprintf("%d background terminal%s running · /ps to view · /stop to close", count, plural)
}
func (m model) stopAllBackgroundTerminalSessions() []int {
@@ -79,12 +71,6 @@ func (m model) backgroundTerminalsText() string {
Rows: rows,
}}
card.Actions = []string{"/stop <session_id>", "/stop"}
for _, session := range sessions {
if session.TTY {
card.Actions = append([]string{"/attach <session_id>"}, card.Actions...)
break
}
}
return renderCommandCardTranscript(card)
}
@@ -126,82 +112,6 @@ func (m model) stopBackgroundTerminalsText(input string) string {
return renderStopBackgroundTerminalsCard([]int{id}, "")
}
// attachTerminalCommand implements /attach: with an explicit session id it
// validates and opens the attach overlay; bare, it picks the one interactive
// session or lists the choices.
func (m model) attachTerminalCommand(input string) (model, tea.Cmd) {
controller, ok := m.execSessionController()
if !ok {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
Title: "Background Terminals",
Summary: []string{"unavailable"},
Sections: []commandCardSection{{
Rows: []commandRow{{Text: "exec_command is not registered."}},
}},
})})
return m, nil
}
// The overlay composites into the main transcript viewport; in a view that
// replaces it (subchat, detailed transcript, another overlay) it would
// capture keystrokes while invisible, so refuse to open there.
if !m.terminalAttachCanOpen() {
return m.showTransientNoticeInline("Leave the current view first", transientNoticeInfo), nil
}
input = strings.TrimSpace(input)
if input != "" {
id, err := strconv.Atoi(input)
if err != nil || id <= 0 {
return m.appendAttachCard("invalid session id", "Usage: /attach [session_id]"), nil
}
snapshot, found := controller.ExecSession(id)
if !found {
return m.appendAttachCard("not found", fmt.Sprintf("No running terminal session %d.", id)), nil
}
if !snapshot.TTY {
return m.appendAttachCard("not interactive", fmt.Sprintf("Session %d has no terminal — it was started without tty:true (or this platform has no PTY), so it can't take input. Use /stop %d to end it.", id, id)), nil
}
return m.openTerminalAttach(id)
}
var interactive []tools.ExecSessionSnapshot
for _, session := range controller.ExecSessions() {
if session.TTY {
interactive = append(interactive, session)
}
}
switch len(interactive) {
case 0:
return m.showTransientNoticeInline("No interactive terminal sessions running.", transientNoticeInfo), nil
case 1:
return m.openTerminalAttach(interactive[0].ID)
}
rows := make([]commandRow, 0, len(interactive))
now := m.now()
for _, session := range interactive {
rows = append(rows, commandRow{Text: formatBackgroundTerminalRow(session, now)})
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
Title: "Background Terminals",
Summary: []string{fmt.Sprintf("%d interactive sessions", len(interactive))},
Sections: []commandCardSection{{
Title: "running",
Rows: rows,
}},
Actions: []string{"/attach <session_id>"},
})})
return m, nil
}
func (m model) appendAttachCard(summary string, text string) model {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: renderCommandCardTranscript(commandCard{
Title: "Background Terminals",
Summary: []string{summary},
Sections: []commandCardSection{{
Rows: []commandRow{{Text: text}},
}},
})})
return m
}
func renderStopBackgroundTerminalsCard(stopped []int, note string) string {
card := commandCard{Title: "Background Terminals"}
if len(stopped) == 0 {
-141
View File
@@ -6,7 +6,6 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -14,11 +13,6 @@ type fakeExecSessionTool struct {
sessions []tools.ExecSessionSnapshot
stopped []int
stopAll bool
writes [][]byte
resizeCalls int
resizeCols int
resizeRows int
}
func (tool *fakeExecSessionTool) Name() string { return tools.ExecCommandToolName }
@@ -41,15 +35,6 @@ func (tool *fakeExecSessionTool) ExecSessions() []tools.ExecSessionSnapshot {
return append([]tools.ExecSessionSnapshot(nil), tool.sessions...)
}
func (tool *fakeExecSessionTool) ExecSession(id int) (tools.ExecSessionSnapshot, bool) {
for _, session := range tool.sessions {
if session.ID == id {
return session, true
}
}
return tools.ExecSessionSnapshot{}, false
}
func (tool *fakeExecSessionTool) StopExecSession(id int) bool {
for _, session := range tool.sessions {
if session.ID == id {
@@ -69,35 +54,6 @@ func (tool *fakeExecSessionTool) StopAllExecSessions() []int {
return ids
}
func (tool *fakeExecSessionTool) WriteExecSessionInput(id int, data []byte) error {
for _, session := range tool.sessions {
if session.ID != id {
continue
}
if !session.TTY {
return execution.ErrProcessStdinDisabled
}
tool.writes = append(tool.writes, append([]byte(nil), data...))
return nil
}
return execution.ErrProcessNotFound
}
func (tool *fakeExecSessionTool) ResizeExecSession(id int, cols, rows int) error {
for _, session := range tool.sessions {
if session.ID != id {
continue
}
if !session.TTY {
return execution.ErrProcessStdinDisabled
}
tool.resizeCalls++
tool.resizeCols, tool.resizeRows = cols, rows
return nil
}
return execution.ErrProcessNotFound
}
func modelWithFakeExecSessions(tool *fakeExecSessionTool, now time.Time) model {
registry := tools.NewRegistry()
registry.Register(tool)
@@ -171,103 +127,6 @@ func TestStopBackgroundTerminalsTextRejectsInvalidSessionID(t *testing.T) {
}
}
func TestBackgroundTerminalsTextAdvertisesAttachForTTY(t *testing.T) {
now := time.Unix(200, 0)
m := modelWithFakeExecSessions(&fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{{
ID: 1000, Command: "sudo apt install x", StartedAt: now, Status: "running", TTY: true,
}},
}, now)
text := m.backgroundTerminalsText()
if !strings.Contains(text, "/attach <session_id>") {
t.Fatalf("/ps card should offer /attach for a tty session:\n%s", text)
}
if summary := m.backgroundTerminalSummary(); !strings.HasSuffix(summary, " · /attach to type into it") {
t.Fatalf("summary = %q, want /attach hint", summary)
}
}
func TestAttachCommandNeedsController(t *testing.T) {
m := model{now: func() time.Time { return time.Unix(100, 0) }}
next, _ := m.attachTerminalCommand("7")
if !transcriptContains(next.transcript, "exec_command is not registered.") {
t.Fatalf("transcript = %#v", next.transcript)
}
}
func TestAttachCommandExplicitID(t *testing.T) {
now := time.Unix(200, 0)
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
},
}
m := modelWithFakeExecSessions(tool, now)
next, cmd := m.attachTerminalCommand("7")
if cmd == nil || next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
t.Fatalf("expected attach to session 7, state=%#v cmd=%v", next.terminalAttach, cmd)
}
next, _ = m.attachTerminalCommand("8")
if !transcriptContains(next.transcript, "Session 8 has no terminal") {
t.Fatalf("non-tty session should be refused: %#v", next.transcript)
}
next, _ = m.attachTerminalCommand("9")
if !transcriptContains(next.transcript, "No running terminal session 9.") {
t.Fatalf("missing session should be reported: %#v", next.transcript)
}
next, _ = m.attachTerminalCommand("abc")
if !transcriptContains(next.transcript, "Usage: /attach [session_id]") {
t.Fatalf("invalid id should show usage: %#v", next.transcript)
}
}
func TestAttachCommandBarePicksSingleTTY(t *testing.T) {
now := time.Unix(200, 0)
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
},
}
m := modelWithFakeExecSessions(tool, now)
next, _ := m.attachTerminalCommand("")
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
t.Fatalf("bare /attach should pick the only tty session, state=%#v", next.terminalAttach)
}
}
func TestAttachCommandBareNoneOrSeveral(t *testing.T) {
now := time.Unix(200, 0)
m := modelWithFakeExecSessions(&fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 8, TTY: false, Status: "running", Command: "sleep 30", StartedAt: now},
},
}, now)
next, _ := m.attachTerminalCommand("")
if next.terminalAttach != nil || !strings.Contains(next.transientNotice.text, "No interactive terminal sessions running.") {
t.Fatalf("bare /attach with no tty sessions: notice=%q state=%#v", next.transientNotice.text, next.terminalAttach)
}
m = modelWithFakeExecSessions(&fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: now},
{ID: 9, TTY: true, Status: "running", Command: "top", StartedAt: now},
},
}, now)
next, _ = m.attachTerminalCommand("")
if next.terminalAttach != nil {
t.Fatal("bare /attach with several tty sessions should list, not attach")
}
if !transcriptContains(next.transcript, "/attach <session_id>") || !transcriptContains(next.transcript, "cat") || !transcriptContains(next.transcript, "top") {
t.Fatalf("expected a choice card listing both sessions: %#v", next.transcript)
}
}
func TestQuitStopsBackgroundTerminals(t *testing.T) {
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{{ID: 1000, StartedAt: time.Unix(100, 0), Status: "running"}},
-4
View File
@@ -306,10 +306,6 @@ func btwMessageRunID(msg tea.Msg) (int, bool) {
return typed.runID, true
case toolCallStreamStartMsg:
return typed.runID, true
case interactiveExecStartMsg:
return typed.runID, true
case terminalAutoAttachTickMsg:
return typed.runID, true
case toolCallStreamDeltaMsg:
return typed.runID, true
case planUpdateMsg:
-8
View File
@@ -17,7 +17,6 @@ const (
commandPermissions
commandPS
commandStop
commandAttach
commandSandboxSetup
commandProvider
commandModel
@@ -141,13 +140,6 @@ var commandDefinitions = []commandDefinition{
description: "Stop running background terminal sessions.",
kind: commandStop,
},
{
name: "/attach",
usage: "/attach [session_id]",
group: commandGroupRuntime,
description: "Type directly into a running interactive terminal session.",
kind: commandAttach,
},
{
name: "/sandbox-setup",
usage: "/sandbox-setup",
-2
View File
@@ -977,8 +977,6 @@ func TestParseBackgroundTerminalCommands(t *testing.T) {
{input: "/ps", kind: commandPS, text: ""},
{input: "/stop", kind: commandStop, text: ""},
{input: "/stop 1000", kind: commandStop, text: "1000"},
{input: "/attach", kind: commandAttach, text: ""},
{input: "/attach 7", kind: commandAttach, text: "7"},
}
for _, tc := range cases {
got := parseCommand(tc.input)
+1 -63
View File
@@ -253,9 +253,6 @@ type model struct {
composerSelection composerSelectionState
dictation dictationController
sttKeyPrompt *sttKeyPromptState
terminalAttach *terminalAttachState
terminalAutoAttach *terminalAutoAttachState
terminalAttachSeen map[int]bool
// plan holds the sticky plan panel state (steps, expansion, timings)
// synced from the update_plan tool. See plan_panel.go.
plan planPanelState
@@ -1248,7 +1245,7 @@ func (m *model) stopPRWatcher() {
func (m model) noBlockingModal() bool {
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
m.sttKeyPrompt == nil && m.renamePrompt == nil && m.terminalAttach == nil
m.sttKeyPrompt == nil && m.renamePrompt == nil
}
func (m model) quit() (tea.Model, tea.Cmd) {
@@ -1450,11 +1447,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.MouseMsg:
// Attached to a live terminal: the overlay owns the viewport, so mouse
// events go nowhere rather than hitting transcript selection below.
if m.terminalAttach != nil {
return m, nil
}
if m.setup.visible {
return m.handleSetupMouse(msg)
}
@@ -1541,11 +1533,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.attachClipboardImage(msg.data, msg.mediaType), nil
case tea.PasteMsg:
// While attached to a live terminal, a paste forwards verbatim to the
// PTY stdin — never into the composer or transcript.
if m.terminalAttach != nil {
return m.handleTerminalAttachPaste(msg.Content)
}
// A paste into the cloud-STT key prompt fills the key (the common way to
// enter an API key), not the composer.
if m.sttKeyPrompt != nil {
@@ -1578,20 +1565,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleVoiceCaptureRelease()
}
return m, nil
case terminalAttachTickMsg:
return m.refreshTerminalAttach()
case interactiveExecStartMsg:
if msg.runID != m.activeRunID {
return m, nil
}
m.terminalAutoAttach = &terminalAutoAttachState{
runID: msg.runID,
known: msg.known,
deadline: m.now().Add(terminalAutoAttachTimeout),
}
return m, terminalAutoAttachTickCmd(msg.runID)
case terminalAutoAttachTickMsg:
return m.pollTerminalAutoAttach(msg)
case tea.KeyPressMsg:
if m.petDragActive {
pixelDrag := m.petPixelDrag
@@ -1631,11 +1604,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.sttKeyPrompt != nil {
return m.handleSTTKeyPromptKey(msg)
}
// Attached to a live terminal session: keystrokes (including Ctrl+C,
// which maps to 0x03 for the process) go to the PTY until Esc detaches.
if m.terminalAttach != nil {
return m.handleTerminalAttachKey(msg)
}
if m.renamePrompt != nil {
return m.handleSessionRenameKey(msg)
}
@@ -2489,9 +2457,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// Size the composer so long input scrolls horizontally with the cursor
// visible instead of being clipped invisibly past the right edge.
m.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
// An attached terminal fills the viewport, so the PTY tracks the
// live size instead of a fixed default.
m = m.resizeAttachedTerminalPTY()
// The title bar prints once into native scrollback when the inline
// renderer is active. In alt-screen mode it stays pinned inside View.
if !m.altScreen && !m.headerPrinted && msg.Width > 0 {
@@ -2667,7 +2632,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.runCancel = nil
m.activeRunID = 0
m.terminalAutoAttach = nil
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
// A fully successful turn means the task is done. Weaker models often
// forget the final update_plan, leaving the panel stuck mid-progress;
@@ -3171,11 +3135,8 @@ func (m model) transcriptView() string {
mcpOverlay := m.mcpManagerOverlay(width)
pickerOverlay := m.pickerOverlay(width)
sttKeyOverlay := m.sttKeyPromptOverlay(width)
terminalAttachOverlay := m.terminalAttachOverlay(width)
viewportOverlay := ""
switch {
case terminalAttachOverlay != "":
viewportOverlay = terminalAttachOverlay
case sttKeyOverlay != "":
viewportOverlay = sttKeyOverlay
case helpOverlayContent != "":
@@ -3243,13 +3204,6 @@ func (m model) pinnedTitleBar(width int) string {
func (m model) footerView(width int) string {
var footer strings.Builder
// An attached terminal owns the keyboard and the viewport; the composer is
// inert, so like the ask_user and permission modals only the status line
// renders. Frame math routes through footerView, so this also shrinks the
// footer rect the overlay's row budget is derived from.
if m.terminalAttach != nil {
return m.footerStatusLine(width)
}
if m.renamePrompt != nil {
footer.WriteString(m.sessionRenamePromptView(width))
footer.WriteString("\n")
@@ -4785,8 +4739,6 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
case commandStop:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.stopBackgroundTerminalsText(command.text)})
return m, nil
case commandAttach:
return m.attachTerminalCommand(command.text)
case commandSandboxSetup:
return m.startSandboxSetupCommand(command.text)
case commandProvider:
@@ -5288,7 +5240,6 @@ func (m model) beginRun(cancel context.CancelFunc) model {
}
m.runID++
m.activeRunID = m.runID
m.terminalAutoAttach = nil
m.runCancel = cancel
m.pending = true
// Clear per-run tracking state so stale specialists and plans from the
@@ -5446,7 +5397,6 @@ func (m *model) cancelRun() {
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.terminalAutoAttach = nil
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.pendingPermission = nil
@@ -5810,18 +5760,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
// live status. The child session ID is not known yet (it's created
// inside the executor), so we use the tool call ID as a temporary
// key and reconcile on the result.
// A tty exec_command wants the attach overlay: the session registers
// with the process manager inside the tool's Run, so the update loop
// polls for it on a tick.
if call.Name == tools.ExecCommandToolName && execCallWantsTTY(call.Arguments) && m.runtimeMessageSink != nil {
known := map[int]bool{}
if controller, ok := m.execSessionController(); ok {
for _, session := range controller.ExecSessions() {
known[session.ID] = true
}
}
m.runtimeMessageSink(interactiveExecStartMsg{runID: runID, known: known})
}
if call.Name == "Task" {
name, desc := parseTaskCallArgs(call.Arguments)
if m.runtimeMessageSink != nil {
+1 -1
View File
@@ -299,7 +299,7 @@ func (m model) wantsMouseCapture() bool {
if m.mouseReleased {
return false // user released the mouse for native text selection/copy
}
return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.terminalAttach != nil || m.suggestionsActive())
return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive())
}
func (m model) setupWantsMouseCapture() bool {
+1 -1
View File
@@ -411,7 +411,7 @@ func (m model) petLayoutActive() bool {
func (m model) petObscuringModalActive() bool {
return m.pendingAskUser != nil || m.pendingSpecReview != nil || m.providerWizard != nil ||
m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != nil
m.sttKeyPrompt != nil || m.renamePrompt != nil
}
func (m model) petComposerReservedColumns(width int) int {
+2 -2
View File
@@ -300,7 +300,7 @@ func TestPromptSubmitPersistsPermissionSessionEvents(t *testing.T) {
// not one of the 4 semantic steps this test drives — process them so the
// model state advances, but don't count them toward the budget.
switch runtimeMsg.(type) {
case toolCallStreamStartMsg, toolCallStreamDeltaMsg, interactiveExecStartMsg:
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
continue
}
received++
@@ -407,7 +407,7 @@ func TestPermissionWaitDoesNotCountTowardTurnElapsed(t *testing.T) {
updated, _ = next.Update(runtimeMsg)
next = updated.(model)
switch runtimeMsg.(type) {
case toolCallStreamStartMsg, toolCallStreamDeltaMsg, interactiveExecStartMsg:
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
continue
case permissionRequestMsg:
clockNanos.Store(base.Add(92 * time.Second).UnixNano())
-446
View File
@@ -1,446 +0,0 @@
package tui
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/execution"
)
// terminalAttachState is the modal "attach to a live terminal" overlay: while
// it is set, keystrokes are forwarded verbatim to the session's PTY stdin and
// the overlay shows a rolling tail of its output. The bytes the user types are
// never inspected, stored, or logged — the PTY's own line discipline (e.g.
// sudo's no-echo prompt) handles masking.
type terminalAttachState struct {
sessionID int
command string
output string
ptyCols int
ptyRows int
bracketedPaste bool
}
const terminalAttachTickInterval = 100 * time.Millisecond
type terminalAttachTickMsg struct{}
func terminalAttachTickCmd() tea.Cmd {
return tea.Tick(terminalAttachTickInterval, func(time.Time) tea.Msg {
return terminalAttachTickMsg{}
})
}
// terminalAutoAttachState watches a run for a brand-new tty exec session so the
// attach overlay can open on it without the user typing /attach.
type terminalAutoAttachState struct {
runID int
known map[int]bool
deadline time.Time
}
const terminalAutoAttachTimeout = 60 * time.Second
// interactiveExecStartMsg signals that the active run just invoked exec_command
// with tty:true; the session registers with the process manager moments later,
// so the tick polls for it. The baseline session set is captured in OnToolCall,
// before the tool runs: Program.Send only queues the message, so snapshotting
// in Update could already see the new session and exclude it as pre-existing.
type interactiveExecStartMsg struct {
runID int
known map[int]bool
}
// The tick carries the run it belongs to so the /btw router can deliver it to
// the hidden parent model instead of dropping it on the visible side model.
type terminalAutoAttachTickMsg struct {
runID int
}
func terminalAutoAttachTickCmd(runID int) tea.Cmd {
return tea.Tick(terminalAttachTickInterval, func(time.Time) tea.Msg {
return terminalAutoAttachTickMsg{runID: runID}
})
}
// execCallWantsTTY reports whether an exec_command tool call's arguments JSON
// requests a PTY. Malformed JSON means false.
func execCallWantsTTY(arguments string) bool {
var args struct {
TTY bool `json:"tty"`
}
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
return false
}
return args.TTY
}
// openTerminalAttach attaches to an interactive exec session. Callers check
// TTY eligibility first; a session that is already gone or exited surfaces as a
// transient notice instead of an empty overlay.
func (m model) openTerminalAttach(id int) (model, tea.Cmd) {
state := &terminalAttachState{sessionID: id}
if controller, ok := m.execSessionController(); ok {
snapshot, found := controller.ExecSession(id)
switch {
case !found:
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d ended.", id), transientNoticeInfo), nil
case snapshot.Status != "running":
exitCode := 0
if snapshot.ExitCode != nil {
exitCode = *snapshot.ExitCode
}
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d finished (exit %d).", id, exitCode), transientNoticeInfo), nil
default:
state.command = snapshot.Command
state.output = snapshot.RecentOutput
state.bracketedPaste = bracketedPasteMode(snapshot.RecentOutput, false)
}
}
m.terminalAttach = state
if m.terminalAttachSeen == nil {
m.terminalAttachSeen = map[int]bool{}
}
m.terminalAttachSeen[id] = true
m.clearSuggestions()
m = m.resizeAttachedTerminalPTY()
return m, terminalAttachTickCmd()
}
// resizeAttachedTerminalPTY reports the overlay's output area to the PTY so
// the session lays out at the size it is actually displayed at. Errors are
// ignored: platforms without PTY support have nothing to resize.
func (m model) resizeAttachedTerminalPTY() model {
state := m.terminalAttach
if state == nil {
return m
}
width := chatWidth(m.width)
// The box border and inset take 4 cells; one more is the cursor
// reservation, matching the width renderTerminalTail draws at.
cols := maxInt(1, width-5)
rows := m.terminalAttachViewportRows(width)
if cols == state.ptyCols && rows == state.ptyRows {
return m
}
controller, ok := m.execSessionController()
if !ok {
return m
}
state.ptyCols, state.ptyRows = cols, rows
_ = controller.ResizeExecSession(state.sessionID, cols, rows)
return m
}
// refreshTerminalAttach polls the session snapshot for the overlay tail. When
// the session exits or disappears the overlay closes itself with a notice;
// detaching also stops the tick because the handler drops it while
// terminalAttach is nil.
func (m model) refreshTerminalAttach() (model, tea.Cmd) {
state := m.terminalAttach
if state == nil {
return m, nil
}
controller, ok := m.execSessionController()
if !ok {
m.terminalAttach = nil
return m, nil
}
snapshot, found := controller.ExecSession(state.sessionID)
if !found {
m.terminalAttach = nil
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d ended.", state.sessionID), transientNoticeInfo), nil
}
state.output = snapshot.RecentOutput
state.bracketedPaste = bracketedPasteMode(snapshot.RecentOutput, state.bracketedPaste)
if snapshot.Status != "running" {
m.terminalAttach = nil
exitCode := 0
if snapshot.ExitCode != nil {
exitCode = *snapshot.ExitCode
}
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d finished (exit %d).", state.sessionID, exitCode), transientNoticeInfo), nil
}
return m, terminalAttachTickCmd()
}
// pollTerminalAutoAttach runs the auto-attach watcher: while the run is live it
// looks for a new tty session and opens the overlay on it, giving up at the
// deadline (a permission prompt can hold the session start for a while).
func (m model) pollTerminalAutoAttach(msg terminalAutoAttachTickMsg) (model, tea.Cmd) {
state := m.terminalAutoAttach
if state == nil || msg.runID != state.runID {
return m, nil
}
if m.activeRunID != state.runID || !m.pending || m.now().After(state.deadline) {
m.terminalAutoAttach = nil
return m, nil
}
controller, ok := m.execSessionController()
if !ok {
return m, terminalAutoAttachTickCmd(state.runID)
}
for _, session := range controller.ExecSessions() {
if !session.TTY || session.Status != "running" || state.known[session.ID] || m.terminalAttachSeen[session.ID] {
continue
}
if !m.terminalAttachCanOpen() || m.terminalAttach != nil {
// A modal (permission prompt, picker, …) or an existing attach owns
// the viewport; keep watching so the overlay opens once it clears.
return m, terminalAutoAttachTickCmd(state.runID)
}
m.terminalAutoAttach = nil
return m.openTerminalAttach(session.ID)
}
return m, terminalAutoAttachTickCmd(state.runID)
}
// terminalAttachCanOpen reports whether the attach overlay would actually be
// rendered right now: beyond the modal prompts, the subchat drill-in, file
// view, setup wizard, help/run-details overlays, the detailed transcript, and
// active suggestions each replace or cover the viewport it draws into, so
// opening then would capture keys into an invisible overlay.
func (m model) terminalAttachCanOpen() bool {
return m.noBlockingModalExceptAttach() &&
!m.helpOverlay && !m.leaderHelpOverlay && !m.runDetailsOpen &&
!m.subchat.active && !m.fileView.active && !m.setup.visible &&
!m.transcriptDetailed && !m.suggestionsActive()
}
// noBlockingModalExceptAttach is noBlockingModal without the attach overlay's
// own term, for the auto-attach watcher deciding whether another modal owns
// the viewport.
func (m model) noBlockingModalExceptAttach() bool {
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
m.sttKeyPrompt == nil && m.renamePrompt == nil
}
// writeTerminalAttachInput forwards bytes to the session stdin. The bytes are
// written through untouched and never echoed into the transcript, a notice, or
// the overlay. Errors only update overlay state or a generic notice.
func (m model) writeTerminalAttachInput(state *terminalAttachState, data []byte) model {
controller, ok := m.execSessionController()
if !ok {
m.terminalAttach = nil
return m
}
err := controller.WriteExecSessionInput(state.sessionID, data)
switch {
case errors.Is(err, execution.ErrProcessNotFound):
m.terminalAttach = nil
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d ended.", state.sessionID), transientNoticeInfo)
case errors.Is(err, execution.ErrProcessStdinDisabled):
m.terminalAttach = nil
return m.showTransientNoticeInline(fmt.Sprintf("Terminal session %d no longer accepts input.", state.sessionID), transientNoticeWarning)
case err != nil:
return m.showTransientNoticeInline("Could not send input to session "+strconv.Itoa(state.sessionID)+".", transientNoticeWarning)
}
return m
}
// handleTerminalAttachKey owns every keystroke while attached: Esc detaches,
// anything else maps to PTY bytes and goes to the process (so Ctrl+C reaches
// it as 0x03 instead of triggering the TUI's own exit confirmation).
func (m model) handleTerminalAttachKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
state := m.terminalAttach
if keyIs(msg, tea.KeyEsc) {
m.terminalAttach = nil
return m.showTransientNoticeInline(
fmt.Sprintf("Detached from session %d — it keeps running; /attach %d to return.", state.sessionID, state.sessionID),
transientNoticeInfo), nil
}
data, ok := ptyInputBytes(msg)
if !ok {
return m, nil
}
return m.writeTerminalAttachInput(state, data), nil
}
// handleTerminalAttachPaste forwards a paste to the PTY. Bubble Tea strips
// the outer terminal's bracketed-paste markers, so when the child enabled
// bracketed paste itself the framing is re-added around the content.
func (m model) handleTerminalAttachPaste(content string) (tea.Model, tea.Cmd) {
state := m.terminalAttach
if content == "" {
return m, nil
}
data := []byte(content)
if state.bracketedPaste {
data = append(append([]byte("\x1b[200~"), data...), "\x1b[201~"...)
}
return m.writeTerminalAttachInput(state, data), nil
}
// bracketedPasteMode tracks the child's bracketed-paste mode from its output:
// the last enable/disable sequence wins, and output without either keeps the
// previous state since RecentOutput is a rolling window that can lose it.
func bracketedPasteMode(output string, current bool) bool {
on := strings.LastIndex(output, "\x1b[?2004h")
off := strings.LastIndex(output, "\x1b[?2004l")
switch {
case on < 0 && off < 0:
return current
case on > off:
return true
default:
return false
}
}
// ptyInputBytes maps a keystroke to the byte sequence a terminal would send.
// Esc is deliberately unmapped: it detaches instead of reaching the process.
func ptyInputBytes(msg tea.KeyMsg) ([]byte, bool) {
key := msg.Key()
if keyHasMod(msg, tea.ModCtrl) {
if key.Code >= 'a' && key.Code <= 'z' {
return []byte{byte(key.Code - 'a' + 1)}, true
}
return nil, false
}
if keyAlt(msg) {
if text := keyText(msg); text != "" {
return append([]byte{0x1b}, text...), true
}
return nil, false
}
switch key.Code {
case tea.KeyEnter:
return []byte("\r"), true
case tea.KeyTab:
return []byte("\t"), true
case tea.KeyBackspace:
return []byte{0x7f}, true
case tea.KeyDelete:
return []byte("\x1b[3~"), true
case tea.KeyUp:
return []byte("\x1b[A"), true
case tea.KeyDown:
return []byte("\x1b[B"), true
case tea.KeyRight:
return []byte("\x1b[C"), true
case tea.KeyLeft:
return []byte("\x1b[D"), true
case tea.KeyHome:
return []byte("\x1b[H"), true
case tea.KeyEnd:
return []byte("\x1b[F"), true
case tea.KeyPgUp:
return []byte("\x1b[5~"), true
case tea.KeyPgDown:
return []byte("\x1b[6~"), true
}
if text := keyText(msg); text != "" {
return []byte(text), true
}
return nil, false
}
// renderTerminalTail turns raw PTY output into display lines: carriage returns
// overwrite their line from column 0 (progress-bar style), ANSI sequences are
// stripped, remaining control runes are dropped (tab expands to spaces), and
// the result is the last rows lines top-aligned and padded to exactly rows.
func renderTerminalTail(raw string, width, rows int) []string {
if rows < 0 {
rows = 0
}
raw = strings.ReplaceAll(raw, "\r\n", "\n")
source := strings.Split(raw, "\n")
lines := make([]string, 0, len(source))
for _, line := range source {
lines = append(lines, sanitizeTerminalLine(line, width))
}
if len(lines) > rows {
lines = lines[len(lines)-rows:]
}
for len(lines) < rows {
lines = append(lines, "")
}
return lines
}
func sanitizeTerminalLine(line string, width int) string {
var resolved []rune
for _, segment := range strings.Split(line, "\r") {
runes := []rune(segment)
if len(runes) < len(resolved) {
runes = append(runes, resolved[len(runes):]...)
}
resolved = runes
}
stripped := ansi.Strip(string(resolved))
var builder strings.Builder
for _, r := range stripped {
switch {
case r == '\t':
builder.WriteString(" ")
case unicode.IsControl(r):
default:
builder.WriteRune(r)
}
}
if width <= 0 {
return builder.String()
}
return fitStyledLine(builder.String(), width)
}
// terminalAttachViewportRows is the number of output rows the attached
// terminal gets: the transcript viewport's body height minus the box's two
// border lines and its footer hint line, so the bordered overlay fills the
// viewport exactly. The same frame math the compositor uses keeps the two in
// sync — and footerView already collapses to the status line while attached.
func (m model) terminalAttachViewportRows(width int) int {
if m.height <= 0 {
return 10
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
return maxInt(6, frame.bodyRect.height-3)
}
func (m model) terminalAttachOverlay(width int) string {
state := m.terminalAttach
if state == nil {
return ""
}
command := compactCommandOutputText(state.command)
if command == "" {
command = "command"
}
// styledBlockFillTitle drops the title when it can't fit the top rule, so
// truncate the command to whatever the border can carry.
titlePrefix := zeroTheme.accent.Render("●") + " Terminal "
titleSuffix := " [running]"
titleBudget := width - 5 - lipgloss.Width(titleSuffix)
title := titlePrefix + truncateRunes(command, maxInt(0, titleBudget-lipgloss.Width(titlePrefix))) + titleSuffix
innerWidth := maxInt(1, width-4)
// Reserve one cell so the cursor never pushes a full line past the edge.
tail := renderTerminalTail(state.output, innerWidth-1, m.terminalAttachViewportRows(width))
cursor := -1
for index := len(tail) - 1; index >= 0; index-- {
if strings.TrimSpace(ansi.Strip(tail[index])) != "" {
cursor = index
break
}
}
if cursor < 0 && len(tail) > 0 {
cursor = 0
}
if cursor >= 0 {
tail[cursor] += zeroTheme.accent.Render("▌")
}
footer := fmt.Sprintf("Esc detach · Ctrl+C goes to the process · /stop %d to kill", state.sessionID)
lines := make([]string, 0, len(tail)+1)
lines = append(lines, tail...)
lines = append(lines, zeroTheme.faint.Render(footer))
return styledBlockFillTitle(width, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
}
-748
View File
@@ -1,748 +0,0 @@
package tui
import (
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func attachedModel(t *testing.T) (model, *fakeExecSessionTool) {
t.Helper()
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{{
ID: 7,
TTY: true,
Status: "running",
Command: "sudo apt install x",
RecentOutput: "[sudo] password for me: ",
StartedAt: time.Unix(100, 0),
}},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
m.width = 100
m.height = 30
return m, tool
}
func TestOpenTerminalAttachSetsState(t *testing.T) {
m, _ := attachedModel(t)
next, cmd := m.openTerminalAttach(7)
if cmd == nil {
t.Fatal("openTerminalAttach should schedule a refresh tick")
}
state := next.terminalAttach
if state == nil || state.sessionID != 7 {
t.Fatalf("state = %#v", state)
}
if state.command != "sudo apt install x" || state.output != "[sudo] password for me: " {
t.Fatalf("state did not pick up the snapshot: %#v", state)
}
if !next.terminalAttachSeen[7] {
t.Fatal("openTerminalAttach should mark the session as seen")
}
}
func TestTerminalAttachForwardsKeys(t *testing.T) {
m, tool := attachedModel(t)
next, _ := m.openTerminalAttach(7)
for _, msg := range []tea.Msg{testKeyText("s"), testKey(tea.KeyEnter)} {
updated, _ := next.Update(msg)
next = updated.(model)
}
if next.terminalAttach == nil {
t.Fatal("typing should stay attached")
}
if len(tool.writes) != 2 || string(tool.writes[0]) != "s" || string(tool.writes[1]) != "\r" {
t.Fatalf("writes = %#v, want [s \\r]", tool.writes)
}
}
func TestTerminalAttachForwardsCtrlC(t *testing.T) {
m, tool := attachedModel(t)
next, _ := m.openTerminalAttach(7)
updated, _ := next.Update(testKeyCtrl('c'))
next = updated.(model)
if len(tool.writes) != 1 || len(tool.writes[0]) != 1 || tool.writes[0][0] != 0x03 {
t.Fatalf("writes = %#v, want [0x03]", tool.writes)
}
if next.terminalAttach == nil {
t.Fatal("Ctrl+C detached the session — it should go to the process")
}
if next.exitConfirmActive {
t.Fatal("Ctrl+C armed the exit confirmation while attached")
}
}
func TestTerminalAttachForwardsPaste(t *testing.T) {
m, tool := attachedModel(t)
next, _ := m.openTerminalAttach(7)
updated, _ := next.Update(testPaste("pw"))
next = updated.(model)
if next.terminalAttach == nil {
t.Fatal("paste should stay attached")
}
if len(tool.writes) != 1 || string(tool.writes[0]) != "pw" {
t.Fatalf("writes = %#v, want [pw]", tool.writes)
}
}
func TestTerminalAttachPasteKeepsBracketedFraming(t *testing.T) {
m, tool := attachedModel(t)
tool.sessions[0].RecentOutput = "\x1b[?2004h[sudo] password for me: "
next, _ := m.openTerminalAttach(7)
updated, _ := next.Update(testPaste("pw"))
next = updated.(model)
want := "\x1b[200~pw\x1b[201~"
if len(tool.writes) != 1 || string(tool.writes[0]) != want {
t.Fatalf("writes = %q, want [%q]", tool.writes, want)
}
// A disable sequence in later output turns framing back off once the
// refresh tick observes it.
tool.sessions[0].RecentOutput += "\x1b[?2004l"
updated, _ = next.Update(terminalAttachTickMsg{})
next = updated.(model)
_, _ = next.Update(testPaste("pw3"))
if last := string(tool.writes[len(tool.writes)-1]); last != "pw3" {
t.Fatalf("write after disable = %q, want raw pw3", last)
}
}
func TestBracketedPasteMode(t *testing.T) {
cases := []struct {
name string
output string
current bool
want bool
}{
{name: "enable last", output: "\x1b[?2004h", want: true},
{name: "disable last", output: "\x1b[?2004l", current: true, want: false},
{name: "neither keeps current", output: "plain output", current: true, want: true},
{name: "neither stays off", output: "plain output", want: false},
{name: "enable after disable", output: "\x1b[?2004ltext\x1b[?2004h", want: true},
{name: "disable after enable", output: "\x1b[?2004htext\x1b[?2004l", current: true, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := bracketedPasteMode(tc.output, tc.current); got != tc.want {
t.Fatalf("bracketedPasteMode(%q, %v) = %v, want %v", tc.output, tc.current, got, tc.want)
}
})
}
}
func TestTerminalAttachEscDetaches(t *testing.T) {
m, tool := attachedModel(t)
next, _ := m.openTerminalAttach(7)
updated, _ := next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if next.terminalAttach != nil {
t.Fatal("Esc should detach")
}
if len(tool.writes) != 0 {
t.Fatalf("Esc forwarded bytes to the process: %#v", tool.writes)
}
if !strings.Contains(next.transientNotice.text, "Detached from session 7") {
t.Fatalf("notice = %q", next.transientNotice.text)
}
}
func TestTerminalAttachTickAutoClosesOnExit(t *testing.T) {
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{{
ID: 7, TTY: true, Status: "running", Command: "sudo apt install x",
StartedAt: time.Unix(100, 0),
}},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
next, _ := m.openTerminalAttach(7)
exitCode := 1
tool.sessions[0].Status = "exited"
tool.sessions[0].ExitCode = &exitCode
updated, _ := next.Update(terminalAttachTickMsg{})
next = updated.(model)
if next.terminalAttach != nil {
t.Fatal("overlay should close itself when the session exits")
}
if !strings.Contains(next.transientNotice.text, "Terminal session 7 finished (exit 1).") {
t.Fatalf("notice = %q", next.transientNotice.text)
}
}
func TestTerminalAttachTickAutoClosesOnMissing(t *testing.T) {
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{{
ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0),
}},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
next, _ := m.openTerminalAttach(7)
tool.sessions = nil
updated, _ := next.Update(terminalAttachTickMsg{})
next = updated.(model)
if next.terminalAttach != nil {
t.Fatal("overlay should close itself when the session disappears")
}
if !strings.Contains(next.transientNotice.text, "Terminal session 7 ended.") {
t.Fatalf("notice = %q", next.transientNotice.text)
}
}
func TestTerminalAttachOverlay(t *testing.T) {
m, _ := attachedModel(t)
next, _ := m.openTerminalAttach(7)
overlay := next.terminalAttachOverlay(80)
for _, want := range []string{"Terminal", "sudo apt install x", "[running]", "[sudo] password for me:", "Esc detach", "/stop 7"} {
if !strings.Contains(overlay, want) {
t.Fatalf("overlay missing %q:\n%s", want, overlay)
}
}
}
func TestTerminalAttachOverlayFillsViewport(t *testing.T) {
m, _ := attachedModel(t)
m.width, m.height = 120, 40
next, _ := m.openTerminalAttach(7)
overlay := next.terminalAttachOverlay(120)
lines := strings.Split(overlay, "\n")
frame := next.scrollableTranscriptFrame(next.pinnedTitleBar(120), next.footerView(120))
if len(lines) != frame.bodyRect.height {
t.Fatalf("overlay lines = %d, want viewport body height %d", len(lines), frame.bodyRect.height)
}
for index, line := range lines {
if width := lipgloss.Width(line); width != 120 {
t.Fatalf("overlay line %d width = %d, want 120: %q", index, width, line)
}
}
plain := ansi.Strip(overlay)
if !strings.Contains(plain, "Terminal") || !strings.Contains(plain, "[running]") {
t.Fatalf("overlay missing border title: %q", lines[0])
}
if !strings.HasPrefix(plain, "╭") || !strings.Contains(lines[len(lines)-1], "╰") {
t.Fatal("overlay should be a bordered box")
}
}
func TestFooterViewWhileAttachedDropsComposer(t *testing.T) {
m, _ := attachedModel(t)
next, _ := m.openTerminalAttach(7)
footer := ansi.Strip(next.footerView(100))
if strings.Contains(footer, "describe a task") {
t.Fatalf("attached footer still shows the composer: %q", footer)
}
if strings.TrimSpace(footer) == "" {
t.Fatal("attached footer should still render the status line")
}
m.input.SetValue("typed")
if plain := ansi.Strip(m.footerView(100)); !strings.Contains(plain, "typed") {
t.Fatalf("detached footer lost the composer: %q", plain)
}
}
func TestTerminalAttachResizesPTYOnOpenAndResize(t *testing.T) {
m, tool := attachedModel(t)
// A resize while nothing is attached must not reach the controller.
if updated, _ := m.Update(tea.WindowSizeMsg{Width: 110, Height: 35}); updated != nil {
m = updated.(model)
}
if tool.resizeCalls != 0 {
t.Fatalf("WindowSizeMsg while detached resized: %d calls", tool.resizeCalls)
}
next, _ := m.openTerminalAttach(7)
wantCols := chatWidth(m.width) - 5
wantRows := next.terminalAttachViewportRows(chatWidth(m.width))
if tool.resizeCalls != 1 || tool.resizeCols != wantCols || tool.resizeRows != wantRows {
t.Fatalf("open resize = %d calls %dx%d, want 1 call %dx%d", tool.resizeCalls, tool.resizeCols, tool.resizeRows, wantCols, wantRows)
}
updated, _ := next.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
next = updated.(model)
wantCols, wantRows = 115, next.terminalAttachViewportRows(120)
if tool.resizeCalls != 2 || tool.resizeCols != wantCols || tool.resizeRows != wantRows {
t.Fatalf("resize = %d calls %dx%d, want 2 calls %dx%d", tool.resizeCalls, tool.resizeCols, tool.resizeRows, wantCols, wantRows)
}
// A no-change resize is not forwarded again.
_, _ = next.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
if tool.resizeCalls != 2 {
t.Fatalf("unchanged size resized again: %d calls", tool.resizeCalls)
}
}
func TestExecCallWantsTTY(t *testing.T) {
cases := []struct {
name string
args string
want bool
}{
{"tty true", `{"cmd":"x","tty":true}`, true},
{"tty false", `{"cmd":"x","tty":false}`, false},
{"tty absent", `{"cmd":"x"}`, false},
{"garbage", `not json`, false},
{"empty", ``, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := execCallWantsTTY(tc.args); got != tc.want {
t.Fatalf("execCallWantsTTY(%q) = %v, want %v", tc.args, got, tc.want)
}
})
}
}
func TestInteractiveExecStartAutoAttaches(t *testing.T) {
tool := &fakeExecSessionTool{}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
m.activeRunID = 3
m.pending = true
updated, cmd := m.Update(interactiveExecStartMsg{runID: 3})
next := updated.(model)
if next.terminalAutoAttach == nil || cmd == nil {
t.Fatal("interactiveExecStartMsg should arm the watcher and a tick")
}
tool.sessions = append(tool.sessions, tools.ExecSessionSnapshot{
ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0),
})
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
t.Fatalf("auto-attach did not open on the new session: %#v", next.terminalAttach)
}
if !next.terminalAttachSeen[7] {
t.Fatal("session 7 should be marked seen")
}
if next.terminalAutoAttach != nil {
t.Fatal("watcher should stop once it attached")
}
}
func TestTerminalAutoAttachSkipsKnownSession(t *testing.T) {
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 5, TTY: true, Status: "running", Command: "top", StartedAt: time.Unix(100, 0)},
},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
m.activeRunID = 3
m.pending = true
// Session 5 predates the tool call — it is not this call's session.
updated, _ := m.Update(interactiveExecStartMsg{runID: 3, known: map[int]bool{5: true}})
next := updated.(model)
updated, cmd := next.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach != nil {
t.Fatal("watcher must not attach to a session that was already running")
}
if cmd == nil || next.terminalAutoAttach == nil {
t.Fatal("watcher should keep ticking while nothing new appears")
}
tool.sessions = append(tool.sessions, tools.ExecSessionSnapshot{
ID: 6, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(200, 0),
})
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach == nil || next.terminalAttach.sessionID != 6 {
t.Fatalf("watcher should attach to the new session 6: %#v", next.terminalAttach)
}
}
func TestInteractiveExecStartBaselinePredatesSessionRegistration(t *testing.T) {
// The baseline is captured when OnToolCall fires, before the tool's Run
// registers the session — a snapshot taken at Update time would already
// contain it and wrongly exclude it as pre-existing.
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 5, TTY: true, Status: "running", Command: "top", StartedAt: time.Unix(100, 0)},
},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
m.activeRunID = 3
m.pending = true
msg := interactiveExecStartMsg{runID: 3, known: map[int]bool{5: true}}
tool.sessions = append(tool.sessions, tools.ExecSessionSnapshot{
ID: 6, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(200, 0),
})
updated, _ := m.Update(msg)
next := updated.(model)
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach == nil || next.terminalAttach.sessionID != 6 {
t.Fatalf("watcher should attach to the session registered after the baseline: %#v", next.terminalAttach)
}
}
func TestTerminalAutoAttachDoesNotReattachSeen(t *testing.T) {
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0)},
},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
m.activeRunID = 3
m.pending = true
m.terminalAttachSeen = map[int]bool{7: true} // user already attached and detached
updated, _ := m.Update(interactiveExecStartMsg{runID: 3})
next := updated.(model)
updated, cmd := next.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach != nil {
t.Fatal("watcher must not re-open a session the user detached")
}
if cmd == nil {
t.Fatal("watcher should keep ticking")
}
}
func TestTerminalAutoAttachTickIgnoresStaleRun(t *testing.T) {
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0)},
},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
m.activeRunID = 3
m.pending = true
m.terminalAutoAttach = &terminalAutoAttachState{
runID: 3, known: map[int]bool{}, deadline: m.now().Add(terminalAutoAttachTimeout),
}
updated, cmd := m.Update(terminalAutoAttachTickMsg{runID: 9})
next := updated.(model)
if next.terminalAttach != nil {
t.Fatal("a stale tick must not attach")
}
if next.terminalAutoAttach == nil || cmd != nil {
t.Fatal("a stale tick should be dropped without touching the watcher")
}
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
t.Fatalf("the live tick should still attach: %#v", next.terminalAttach)
}
}
func TestBTWRoutesAutoAttachTickToHiddenParent(t *testing.T) {
tool := &fakeExecSessionTool{}
m := newBTWTestModel(t)
registry := tools.NewRegistry()
registry.Register(tool)
m.registry = registry
m.pending = true
m.runID = 7
m.activeRunID = 7
side, _ := m.handleBTWCommand("")
routed, _, ok := side.routeBTWParentMessage(interactiveExecStartMsg{runID: 7})
if !ok {
t.Fatal("interactiveExecStartMsg was not routed to the parent")
}
side = routed
if side.btw.parent == nil || side.btw.parent.terminalAutoAttach == nil {
t.Fatal("watcher was not armed on the hidden parent")
}
// A tick tagged for the side run must not reach the parent watcher.
if routed, _, ok := side.routeBTWParentMessage(terminalAutoAttachTickMsg{runID: side.btw.sideRunIDBase}); ok || routed.btw.parent.terminalAttach != nil {
t.Fatal("a side-run tick must not reach the parent watcher")
}
tool.sessions = append(tool.sessions, tools.ExecSessionSnapshot{
ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0),
})
routed, _, ok = side.routeBTWParentMessage(terminalAutoAttachTickMsg{runID: 7})
if !ok {
t.Fatal("parent-run tick was not routed to the parent")
}
side = routed
if side.btw.parent == nil || side.btw.parent.terminalAttach == nil || side.btw.parent.terminalAttach.sessionID != 7 {
t.Fatalf("parent overlay did not open: %#v", side.btw.parent)
}
if side.terminalAttach != nil {
t.Fatal("attach state leaked onto the side model")
}
}
func TestTerminalAutoAttachDefersToOtherViews(t *testing.T) {
newWatchingModel := func(t *testing.T) (model, *fakeExecSessionTool) {
t.Helper()
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0)},
},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
m.activeRunID = 3
m.pending = true
m.terminalAutoAttach = &terminalAutoAttachState{
runID: 3, known: map[int]bool{}, deadline: m.now().Add(terminalAutoAttachTimeout),
}
return m, tool
}
m, _ := newWatchingModel(t)
m.helpOverlay = true
updated, cmd := m.Update(terminalAutoAttachTickMsg{runID: 3})
next := updated.(model)
if next.terminalAttach != nil {
t.Fatal("overlay must not open over the help overlay")
}
if cmd == nil || next.terminalAutoAttach == nil {
t.Fatal("watcher should keep ticking while help owns the viewport")
}
next.helpOverlay = false
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach == nil {
t.Fatal("overlay should open once help closes")
}
m, _ = newWatchingModel(t)
m.subchat.active = true
updated, cmd = m.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach != nil {
t.Fatal("overlay must not open inside the subchat view")
}
if cmd == nil || next.terminalAutoAttach == nil {
t.Fatal("watcher should keep ticking while subchat owns the viewport")
}
}
func TestAttachCommandRefusesWhileSubchatActive(t *testing.T) {
m, _ := attachedModel(t)
m.subchat.active = true
next, _ := m.attachTerminalCommand("7")
if next.terminalAttach != nil {
t.Fatal("/attach must not open the overlay inside the subchat view")
}
if !strings.Contains(next.transientNotice.text, "Leave the current view first") {
t.Fatalf("expected the leave-view notice, got %q", next.transientNotice.text)
}
next.subchat.active = false
next, _ = next.attachTerminalCommand("7")
if next.terminalAttach == nil {
t.Fatal("/attach should open once the subchat view is left")
}
}
func TestTerminalAutoAttachIgnoresWrongRun(t *testing.T) {
m, _ := attachedModel(t)
m.activeRunID = 3
m.pending = true
updated, cmd := m.Update(interactiveExecStartMsg{runID: 9})
next := updated.(model)
if next.terminalAutoAttach != nil || cmd != nil {
t.Fatal("a msg for another run must not arm the watcher")
}
}
func TestTerminalAutoAttachWaitsForPermissionPrompt(t *testing.T) {
tool := &fakeExecSessionTool{
sessions: []tools.ExecSessionSnapshot{
{ID: 7, TTY: true, Status: "running", Command: "cat", StartedAt: time.Unix(100, 0)},
},
}
m := modelWithFakeExecSessions(tool, time.Unix(200, 0))
m.activeRunID = 3
m.pending = true
m.pendingPermission = &pendingPermissionPrompt{
request: agent.PermissionRequest{ToolName: "exec_command"},
decide: func(agent.PermissionDecision) {},
}
// The watcher is armed before the session registers? Session already here —
// simplest equivalent: arm it directly and confirm the modal blocks opening.
m.terminalAutoAttach = &terminalAutoAttachState{
runID: 3, known: map[int]bool{}, deadline: m.now().Add(terminalAutoAttachTimeout),
}
updated, cmd := m.Update(terminalAutoAttachTickMsg{runID: 3})
next := updated.(model)
if next.terminalAttach != nil {
t.Fatal("overlay must not open over a permission prompt")
}
if cmd == nil {
t.Fatal("watcher should keep ticking while the modal is up")
}
next.pendingPermission = nil
updated, _ = next.Update(terminalAutoAttachTickMsg{runID: 3})
next = updated.(model)
if next.terminalAttach == nil || next.terminalAttach.sessionID != 7 {
t.Fatalf("overlay should open once the modal clears: %#v", next.terminalAttach)
}
}
// The user's keystrokes go to the PTY and nowhere else: not the transcript,
// not the composer, not a notice.
func TestTerminalAttachNeverEchoesInput(t *testing.T) {
m, tool := attachedModel(t)
next, _ := m.openTerminalAttach(7)
for _, msg := range []tea.Msg{testKeyText("s"), testKeyText("3"), testKeyText("c"), testKeyText("r"), testKeyText("e"), testKeyText("t"), testPaste("pw")} {
updated, _ := next.Update(msg)
next = updated.(model)
}
if len(tool.writes) == 0 {
t.Fatal("nothing was forwarded")
}
for _, row := range next.transcript {
if strings.Contains(row.text, "s3cret") || strings.Contains(row.text, "pw") {
t.Fatalf("typed bytes leaked into transcript row: %q", row.text)
}
}
if strings.Contains(next.transientNotice.text, "s3cret") || strings.Contains(next.transientNotice.text, "pw") {
t.Fatalf("typed bytes leaked into the notice: %q", next.transientNotice.text)
}
if strings.Contains(next.composerValue(), "s3cret") {
t.Fatal("typed bytes leaked into the composer")
}
}
func TestTerminalAttachSwallowsMouse(t *testing.T) {
m, _ := attachedModel(t)
next, _ := m.openTerminalAttach(7)
updated, _ := next.Update(testMouseClick(tea.MouseLeft, 5, 5))
next = updated.(model)
if next.terminalAttach == nil {
t.Fatal("mouse click detached the session")
}
}
func TestPtyInputBytes(t *testing.T) {
cases := []struct {
name string
msg tea.KeyMsg
want string
ok bool
}{
{"printable", testKeyText("a"), "a", true},
{"space", testKey(tea.KeySpace), " ", true},
{"enter", testKey(tea.KeyEnter), "\r", true},
{"tab", testKey(tea.KeyTab), "\t", true},
{"backspace", testKey(tea.KeyBackspace), "\x7f", true},
{"delete", testKey(tea.KeyDelete), "\x1b[3~", true},
{"up", testKey(tea.KeyUp), "\x1b[A", true},
{"down", testKey(tea.KeyDown), "\x1b[B", true},
{"right", testKey(tea.KeyRight), "\x1b[C", true},
{"left", testKey(tea.KeyLeft), "\x1b[D", true},
{"home", testKey(tea.KeyHome), "\x1b[H", true},
{"end", testKey(tea.KeyEnd), "\x1b[F", true},
{"pgup", testKey(tea.KeyPgUp), "\x1b[5~", true},
{"pgdn", testKey(tea.KeyPgDown), "\x1b[6~", true},
{"ctrl+c", testKeyCtrl('c'), "\x03", true},
{"ctrl+d", testKeyCtrl('d'), "\x04", true},
{"ctrl+a", testKeyCtrl('a'), "\x01", true},
{"alt+x", testKeyAltText("x"), "\x1bx", true},
{"esc unmapped", testKey(tea.KeyEsc), "", false},
{"ctrl+1 unmapped", testKeyCtrl('1'), "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := ptyInputBytes(tc.msg)
if ok != tc.ok || string(got) != tc.want {
t.Fatalf("ptyInputBytes = %q, %v; want %q, %v", got, ok, tc.want, tc.ok)
}
})
}
}
func TestRenderTerminalTail(t *testing.T) {
cases := []struct {
name string
raw string
width int
rows int
want []string
}{
{
name: "tail and padding",
raw: "one\ntwo\nthree",
width: 10, rows: 4,
want: []string{"one", "two", "three", ""},
},
{
name: "keeps last rows",
raw: "a\nb\nc\nd",
width: 10, rows: 2,
want: []string{"c", "d"},
},
{
name: "carriage return overwrites",
raw: "progress 10%\rprogress 90%",
width: 20, rows: 1,
want: []string{"progress 90%"},
},
{
name: "shorter overwrite keeps remainder",
raw: "downloading...\r100%",
width: 20, rows: 1,
want: []string{"100%loading..."},
},
{
name: "crlf normalized",
raw: "line1\r\nline2",
width: 10, rows: 2,
want: []string{"line1", "line2"},
},
{
name: "ansi stripped",
raw: "\x1b[31mred\x1b[0m plain",
width: 20, rows: 1,
want: []string{"red plain"},
},
{
name: "control runes dropped, tab expanded",
raw: "a\x07b\tc",
width: 20, rows: 1,
want: []string{"ab c"},
},
{
name: "width truncation",
raw: "abcdefghijklmnop",
width: 5, rows: 1,
want: []string{"abcd…"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := renderTerminalTail(tc.raw, tc.width, tc.rows)
if len(got) != len(tc.want) {
t.Fatalf("renderTerminalTail = %#v, want %#v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("renderTerminalTail[%d] = %q, want %q (all: %#v)", i, got[i], tc.want[i], got)
}
}
})
}
}
+1 -1
View File
@@ -1160,7 +1160,7 @@ func (m model) transcriptHitTestSource() (header string, items []transcriptBodyI
// outright — a modal/overlay is up, or there's no alt-screen viewport at all.
func (m model) transcriptHitTestBlocked() bool {
return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.terminalAttach != nil || m.suggestionsActive()
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
}
// transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs,