Compare commits

..
24 changed files with 1785 additions and 119 deletions

No files matched your search

-23
View File
@@ -49,14 +49,6 @@ 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
@@ -328,7 +320,6 @@ 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...))
@@ -1231,20 +1222,6 @@ 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,23 +1975,3 @@ 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,7 +166,6 @@ 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,
@@ -235,7 +234,6 @@ 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)
@@ -296,7 +294,6 @@ 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)
@@ -609,7 +606,6 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
n = MaxTurnsCeiling
}
cfg.MaxTurns = n
cfg.maxTurnsSet = true
}
}
@@ -730,7 +726,6 @@ 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,45 +2252,3 @@ 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)
}
}
}
+4 -13
View File
@@ -364,10 +364,6 @@ 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:"-"`
@@ -465,15 +461,10 @@ type Overrides struct {
}
type ResolvedConfig struct {
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
ActiveProvider string
Providers []ProviderProfile
Provider ProviderProfile
MaxTurns int
MCP MCPConfig
Sandbox SandboxConfig
Notify NotifyConfig
+43
View File
@@ -224,6 +224,49 @@ 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,6 +2,7 @@ package execution
import (
"context"
"errors"
"io"
"os"
"os/exec"
@@ -85,6 +86,126 @@ 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,3 +11,7 @@ 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,6 +48,9 @@ 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()
@@ -80,3 +83,16 @@ 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),
})
}
+32 -5
View File
@@ -47,8 +47,11 @@ 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 {
@@ -95,7 +98,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.", Default: false},
"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},
},
Required: []string{"cmd"},
AdditionalProperties: false,
@@ -131,6 +134,10 @@ 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)
}
@@ -139,6 +146,14 @@ 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 {
@@ -172,8 +187,14 @@ 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)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
return interactiveBlockResult(interactive)
// 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
}
}
absoluteCwd, relativeCwd, err := resolveScopedPath(tool.workspaceRoot, tool.scope, workdir)
if err != nil {
@@ -453,7 +474,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)
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted, input.tty)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
meta["shell_issue"] = issue.Kind
@@ -575,7 +596,7 @@ func executionChangeSummaries(changes []execution.Change) []execution.Change {
return summaries
}
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool) string {
func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode int, interrupted bool, tty bool) string {
output = strings.TrimRight(output, "\r\n")
parts := []string{}
if output != "" {
@@ -593,12 +614,18 @@ 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,3 +894,56 @@ 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.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
+94 -4
View File
@@ -6,6 +6,8 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -30,15 +32,21 @@ func (m model) backgroundTerminalSessions() []tools.ExecSessionSnapshot {
}
func (m model) backgroundTerminalSummary() string {
count := len(m.backgroundTerminalSessions())
if count == 0 {
sessions := m.backgroundTerminalSessions()
if len(sessions) == 0 {
return ""
}
plural := ""
if count != 1 {
if len(sessions) != 1 {
plural = "s"
}
return fmt.Sprintf("%d background terminal%s running · /ps to view · /stop to close", count, plural)
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
}
func (m model) stopAllBackgroundTerminalSessions() []int {
@@ -71,6 +79,12 @@ 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)
}
@@ -112,6 +126,82 @@ 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,6 +6,7 @@ import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -13,6 +14,11 @@ 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 }
@@ -35,6 +41,15 @@ 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 {
@@ -54,6 +69,35 @@ 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)
@@ -127,6 +171,103 @@ 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,6 +306,10 @@ 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,6 +17,7 @@ const (
commandPermissions
commandPS
commandStop
commandAttach
commandSandboxSetup
commandProvider
commandModel
@@ -140,6 +141,13 @@ 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,6 +977,8 @@ 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)
+63 -1
View File
@@ -253,6 +253,9 @@ 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
@@ -1245,7 +1248,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.sttKeyPrompt == nil && m.renamePrompt == nil && m.terminalAttach == nil
}
func (m model) quit() (tea.Model, tea.Cmd) {
@@ -1447,6 +1450,11 @@ 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)
}
@@ -1533,6 +1541,11 @@ 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 {
@@ -1565,6 +1578,20 @@ 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
@@ -1604,6 +1631,11 @@ 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)
}
@@ -2457,6 +2489,9 @@ 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 {
@@ -2632,6 +2667,7 @@ 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;
@@ -3135,8 +3171,11 @@ 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 != "":
@@ -3204,6 +3243,13 @@ 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")
@@ -4739,6 +4785,8 @@ 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:
@@ -5240,6 +5288,7 @@ 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
@@ -5397,6 +5446,7 @@ 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
@@ -5760,6 +5810,18 @@ 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.suggestionsActive())
return m.altScreen && (m.setupWantsMouseCapture() || m.chatWantsMouseCapture() || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.terminalAttach != 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.sttKeyPrompt != nil || m.renamePrompt != nil || m.terminalAttach != 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:
case toolCallStreamStartMsg, toolCallStreamDeltaMsg, interactiveExecStartMsg:
continue
}
received++
@@ -407,7 +407,7 @@ func TestPermissionWaitDoesNotCountTowardTurnElapsed(t *testing.T) {
updated, _ = next.Update(runtimeMsg)
next = updated.(model)
switch runtimeMsg.(type) {
case toolCallStreamStartMsg, toolCallStreamDeltaMsg:
case toolCallStreamStartMsg, toolCallStreamDeltaMsg, interactiveExecStartMsg:
continue
case permissionRequestMsg:
clockNanos.Store(base.Add(92 * time.Second).UnixNano())
+446
View File
@@ -0,0 +1,446 @@
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
@@ -0,0 +1,748 @@
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.suggestionsActive()
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.terminalAttach != nil || m.suggestionsActive()
}
// transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs,