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:
@@ -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
|
||||||
|
|||||||
1
TODO.md
1
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]
|
- [ ] 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user