Normalize whitespace in grid get_process_output to save tokens

Grid snapshots pad every row to the full terminal width and leave the
bottom of the screen blank, so MCP grid reads carried a lot of dead
whitespace. Add normalizeGridText (CRLF/lone-CR to LF, right-trim each
line, collapse blank runs to a single blank, drop leading/trailing
blanks) and apply it to the grid branch of GetProcessOutput only.
Stream output, raw output, and WaitForPattern matching are untouched.

Resolves the terminal-read newline/token-waste TODO item.
This commit is contained in:
2026-05-25 12:33:59 +01:00
parent 50fd7be70d
commit 53f06b604f
4 changed files with 72 additions and 2 deletions

View File

@@ -10,6 +10,11 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- MCP clients can now call `scratchpad_delete` with a scratchpad name - MCP clients can now call `scratchpad_delete` with a scratchpad name
to remove a shared project scratchpad. to remove a shared project scratchpad.
### Changed
- Grid-mode `get_process_output` now returns whitespace-normalized
text to avoid sending padded terminal rows and repeated blank lines
over MCP.
### Fixed ### Fixed
- Closing an agent now escalates from SIGTERM to SIGKILL when needed, - Closing an agent now escalates from SIGTERM to SIGKILL when needed,
so agents that ignore SIGTERM disappear from the running tab bar so agents that ignore SIGTERM disappear from the running tab bar

View File

@@ -1,4 +1,3 @@
- [ ] We should deduplicate /r/n newlines or /n newlines to save tokens on mcp responses for terminal reads.
- [ ] Codex idle detection seems to trigger too soon, see below [CODEX IDLE] - [ ] Codex idle detection seems to trigger too soon, see below [CODEX IDLE]
- [ ] Issue with mcp timing out [MCP TIMEOUT] - [ ] Issue with mcp timing out [MCP TIMEOUT]
- [ ] When opening a codex sub agent, the message gets input to the field, but the message is never submitted. - [ ] When opening a codex sub agent, the message gets input to the field, but the message is never submitted.

View File

@@ -7,6 +7,7 @@ import (
"sync" "sync"
"syscall" "syscall"
"time" "time"
"unicode"
"github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
@@ -398,7 +399,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
if c.Kind == KindAgent { if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef)) txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef))
} }
out.Content = txt out.Content = normalizeGridText(txt)
return out, nil return out, nil
case "stream": case "stream":
b, end := c.StreamRead(sinceOffset) b, end := c.StreamRead(sinceOffset)
@@ -1018,6 +1019,30 @@ func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "") return ansiRegexp.ReplaceAllString(s, "")
} }
func normalizeGridText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
pendingBlank := false
for _, line := range lines {
line = strings.TrimRightFunc(line, unicode.IsSpace)
if line == "" {
if len(out) > 0 {
pendingBlank = true
}
continue
}
if pendingBlank {
out = append(out, "")
pendingBlank = false
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the // stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will // string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a // itself walk the result line-by-line (SearchOutput) or feed it to a

View File

@@ -104,3 +104,44 @@ func TestStripANSIBytesEquivalence(t *testing.T) {
} }
} }
} }
func TestNormalizeGridText(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "line endings",
in: "one\r\ntwo\rthree",
want: "one\ntwo\nthree",
},
{
name: "trailing whitespace",
in: "one \ntwo\t\t\nthree",
want: "one\ntwo\nthree",
},
{
name: "collapse blank runs",
in: "one\n\n\n two\n \n\t\nthree",
want: "one\n\n two\n\nthree",
},
{
name: "trim leading and trailing blanks",
in: "\n \n\t\none\n\n",
want: "one",
},
{
name: "already clean",
in: "one\n\ntwo\nthree",
want: "one\n\ntwo\nthree",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeGridText(tc.in); got != tc.want {
t.Fatalf("normalizeGridText(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}