Add bounded ranges to terminal reads

This commit is contained in:
Ian Gulliver
2026-10-23 21:49:06 -07:00
parent 21a30263b6
commit 5d8e3bdea4
2 changed files with 50 additions and 9 deletions
+21 -3
View File
@@ -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 + "]"
+29 -6
View File
@@ -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) {