From 53f06b604f2b7a6528e9439f1363010c317ae9a9 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Mon, 25 May 2026 12:33:59 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 5 +++++ TODO.md | 1 - internal/app/host.go | 27 +++++++++++++++++++++++++- internal/app/ring_test.go | 41 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4940a5..8fdb4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 - Closing an agent now escalates from SIGTERM to SIGKILL when needed, so agents that ignore SIGTERM disappear from the running tab bar diff --git a/TODO.md b/TODO.md index 761f3b5..e832166 100644 --- a/TODO.md +++ b/TODO.md @@ -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] - [ ] 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. diff --git a/internal/app/host.go b/internal/app/host.go index 370ca31..4f5ad49 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -7,6 +7,7 @@ import ( "sync" "syscall" "time" + "unicode" "github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/preset" @@ -398,7 +399,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse if c.Kind == KindAgent { txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef)) } - out.Content = txt + out.Content = normalizeGridText(txt) return out, nil case "stream": b, end := c.StreamRead(sinceOffset) @@ -1018,6 +1019,30 @@ func stripANSI(s string) string { 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 // string conversion and the regex DFA — useful when the caller will // itself walk the result line-by-line (SearchOutput) or feed it to a diff --git a/internal/app/ring_test.go b/internal/app/ring_test.go index ec2424a..f166f1c 100644 --- a/internal/app/ring_test.go +++ b/internal/app/ring_test.go @@ -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) + } + }) + } +}