diff --git a/internal/agents/terminal.go b/internal/agents/terminal.go index 90fd635..8550da6 100644 --- a/internal/agents/terminal.go +++ b/internal/agents/terminal.go @@ -20,16 +20,34 @@ const terminalArg = `"terminal": {"type": "string", "description": "The id of on type terminalReadArgs struct { Terminal string `json:"terminal"` + Offset *int `json:"offset"` + Limit int `json:"limit"` } func (m *Manager) terminalReadTool(l *live) mcp.Tool { return mcp.Tool{ Name: "terminal_read", Description: "Read one of your task's terminals: the shells in this task's worktree that the human uses from the Terminal tab, and that terminal_type types into. " + - "Returns its recent output as plain text, escape sequences removed, at most the last 64 KiB, then the task's terminals by id and name. " + + "Returns the last 100 lines by default, as plain text with escape sequences removed, followed by the task's terminals by id and name. Use offset and limit to select a range from the retained output (up to 256 KiB of raw output); older output is discarded. Line numbers are relative to that retained output and shift as older output is discarded. " + "Full-screen programs (vim, top, less) come out garbled, since this is the output stream, not a picture of the screen.", - InputSchema: json.RawMessage(`{"type": "object", "properties": {` + terminalArg + `}}`), + InputSchema: json.RawMessage(`{"type": "object", "properties": {` + terminalArg + `, + "offset": {"type": "integer", "description": "First line to return: 1 is the start of retained output, -1 is its last line, -100 starts 100 lines from the end. Defaults to minus limit."}, + "limit": {"type": "integer", "minimum": 1, "description": "Maximum lines to return, starting at offset. Defaults to 100."} + }}`), Handler: mcp.Typed(func(ctx context.Context, args terminalReadArgs) (string, error) { + if args.Limit < 0 { + return "", fmt.Errorf("limit must be positive") + } + if args.Limit == 0 { + args.Limit = 100 + } + offset := -args.Limit + if args.Offset != nil { + offset = *args.Offset + } + if offset == 0 { + return "", fmt.Errorf("offset must be positive (from start) or negative (from end)") + } term := m.terminals.First(l.taskID) if args.Terminal != "" { term = m.terminals.Get(l.taskID, args.Terminal) @@ -40,7 +58,7 @@ func (m *Manager) terminalReadTool(l *live) mcp.Tool { if term == nil { return "No shell is running in this task's terminal.", nil } - out := terminal.Text(term.Backlog()) + out := terminal.Lines(term.Backlog(), offset, args.Limit) exit, exited := term.ExitStatus() if exited { out += "\n[shell ended: " + exit + "]" diff --git a/internal/terminal/text.go b/internal/terminal/text.go index 90b87d3..dcf4ff4 100644 --- a/internal/terminal/text.go +++ b/internal/terminal/text.go @@ -1,6 +1,7 @@ package terminal import ( + "fmt" "strings" "unicode/utf8" ) @@ -8,6 +9,33 @@ import ( const textLimit = 64 << 10 func Text(b []byte) string { + s := plainText(b) + if len(s) > textLimit { + s = s[len(s)-textLimit:] + s = s[strings.IndexByte(s, '\n')+1:] + } + return s +} + +// Lines reads a range of the retained output. Positive offsets are one-based; +// negative offsets count back from the last line. +func Lines(b []byte, offset, limit int) string { + lines := strings.Split(strings.TrimSuffix(plainText(b), "\n"), "\n") + start := 0 + if offset < 0 { + start = len(lines) + offset + } else { + start = offset - 1 + } + start = min(max(0, start), len(lines)) + end := start + min(limit, len(lines)-start) + if start == end { + return fmt.Sprintf("[no lines in range; %d retained lines]", len(lines)) + } + return fmt.Sprintf("[lines %d–%d of %d retained lines]\n", min(start+1, end), end, len(lines)) + strings.Join(lines[start:end], "\n") +} + +func plainText(b []byte) string { out := strings.Builder{} line := []rune{} col := 0 @@ -49,12 +77,7 @@ func Text(b []byte) string { } i++ } - s := out.String() + strings.TrimRight(string(line), " ") - if len(s) > textLimit { - s = s[len(s)-textLimit:] - s = s[strings.IndexByte(s, '\n')+1:] - } - return s + return out.String() + strings.TrimRight(string(line), " ") } func skipEscape(b []byte, i int) (int, byte) {