From 3d3c7f8df9fc29654ba101d494cddd70dfb1f838 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 17 Nov 2025 21:16:56 +0530 Subject: [PATCH 1/6] feat(termexec): enhance dynamic header (animation) handling for different agent types --- lib/httpapi/setup.go | 1 + lib/screentracker/conversation.go | 15 +------- lib/termexec/termexec.go | 25 +++++++++---- lib/termexec/utils.go | 59 +++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 21 deletions(-) create mode 100644 lib/termexec/utils.go diff --git a/lib/httpapi/setup.go b/lib/httpapi/setup.go index 1620304..00d0b45 100644 --- a/lib/httpapi/setup.go +++ b/lib/httpapi/setup.go @@ -32,6 +32,7 @@ func SetupProcess(ctx context.Context, config SetupProcessConfig) (*termexec.Pro Args: config.ProgramArgs, TerminalWidth: config.TerminalWidth, TerminalHeight: config.TerminalHeight, + AgentType: config.AgentType, }) if err != nil { logger.Error(fmt.Sprintf("Error starting process: %v", err)) diff --git a/lib/screentracker/conversation.go b/lib/screentracker/conversation.go index 4617e8e..5247fd8 100644 --- a/lib/screentracker/conversation.go +++ b/lib/screentracker/conversation.go @@ -145,24 +145,11 @@ func FindNewMessage(oldScreen, newScreen string, agentType msgfmt.AgentType) str newLines := strings.Split(newScreen, "\n") oldLinesMap := make(map[string]bool) - // -1 indicates no header - dynamicHeaderEnd := -1 - - // Skip header lines for Opencode agent type to avoid false positives - // The header contains dynamic content (token count, context percentage, cost) - // that changes between screens, causing line comparison mismatches: - // - // ┃ # Getting Started with Claude CLI ┃ - // ┃ /share to create a shareable link 12.6K/6% ($0.05) ┃ - if len(newLines) >= 2 && agentType == msgfmt.AgentTypeOpencode { - dynamicHeaderEnd = 2 - } - for _, line := range oldLines { oldLinesMap[line] = true } firstNonMatchingLine := len(newLines) - for i, line := range newLines[dynamicHeaderEnd+1:] { + for i, line := range newLines { if !oldLinesMap[line] { firstNonMatchingLine = i break diff --git a/lib/termexec/termexec.go b/lib/termexec/termexec.go index fd2c04b..266e24a 100644 --- a/lib/termexec/termexec.go +++ b/lib/termexec/termexec.go @@ -13,15 +13,18 @@ import ( "github.com/ActiveState/termtest/xpty" "github.com/coder/agentapi/lib/logctx" + "github.com/coder/agentapi/lib/msgfmt" "github.com/coder/agentapi/lib/util" "golang.org/x/xerrors" ) type Process struct { - xp *xpty.Xpty - execCmd *exec.Cmd - screenUpdateLock sync.RWMutex - lastScreenUpdate time.Time + xp *xpty.Xpty + execCmd *exec.Cmd + screenUpdateLock sync.RWMutex + lastScreenUpdate time.Time + checkDynamicHeader bool + agentType msgfmt.AgentType } type StartProcessConfig struct { @@ -29,6 +32,7 @@ type StartProcessConfig struct { Args []string TerminalWidth uint16 TerminalHeight uint16 + AgentType msgfmt.AgentType } func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error) { @@ -46,7 +50,7 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error return nil, err } - process := &Process{xp: xp, execCmd: execCmd} + process := &Process{xp: xp, execCmd: execCmd, checkDynamicHeader: true, agentType: args.AgentType} go func() { // HACK: Working around xpty concurrency limitations @@ -112,17 +116,24 @@ func (p *Process) Signal(sig os.Signal) error { // result in a malformed agent message being returned to the // user. func (p *Process) ReadScreen() string { + var state string for range 3 { p.screenUpdateLock.RLock() if time.Since(p.lastScreenUpdate) >= 16*time.Millisecond { - state := p.xp.State.String() + state = p.xp.State.String() p.screenUpdateLock.RUnlock() + if p.checkDynamicHeader { + state, p.checkDynamicHeader = removeDynamicHeader(state, p.agentType) + } return state } p.screenUpdateLock.RUnlock() time.Sleep(16 * time.Millisecond) } - return p.xp.State.String() + if p.checkDynamicHeader { + state, p.checkDynamicHeader = removeDynamicHeader(p.xp.State.String(), p.agentType) + } + return state } // Write sends input to the process via the pseudo terminal. diff --git a/lib/termexec/utils.go b/lib/termexec/utils.go new file mode 100644 index 0000000..f681d5f --- /dev/null +++ b/lib/termexec/utils.go @@ -0,0 +1,59 @@ +package termexec + +import ( + "strings" + + "github.com/coder/agentapi/lib/msgfmt" +) + +func calcAmpDynamicHeader(newLines []string) (int, bool) { + dynamicHeaderEnd := -1 + firstTextEncountered := false + continueRemoving := true + + // search for the first 3 consecutive empty lines after the first text encountered. + if len(newLines) > 3 { + for i := 0; i < len(newLines)-3; i++ { + if !firstTextEncountered && len(strings.Trim(newLines[i], " \n")) != 0 { + if strings.HasPrefix(strings.TrimSpace(newLines[i]), "┃") { + continueRemoving = false + } + firstTextEncountered = true + } + if firstTextEncountered && len(strings.Trim(newLines[i], " \n")) == 0 && len(strings.Trim(newLines[i+1], " \n")) == 0 && + len(strings.Trim(newLines[i+2], " \n")) == 0 { + dynamicHeaderEnd = i + break + + } + } + } + return dynamicHeaderEnd, continueRemoving +} + +func calcOpencodeDynamicHeader(newLines []string) (int, bool) { + // Skip header lines for Opencode agent type to avoid false positives + // The header contains dynamic content (token count, context percentage, cost) + // that changes between screens, causing line comparison mismatches: + // + // ┃ # Getting Started with Claude CLI ┃ + // ┃ /share to create a shareable link 12.6K/6% ($0.05) ┃ + if len(newLines) >= 2 { + return 2, true + } + return -1, true +} + +func removeDynamicHeader(screen string, agentType msgfmt.AgentType) (string, bool) { + lines := strings.Split(screen, "\n") + dynamicHeaderEnd := -1 + continueRemoving := true + if agentType == msgfmt.AgentTypeAmp { + dynamicHeaderEnd, continueRemoving = calcAmpDynamicHeader(lines) + } else if agentType == msgfmt.AgentTypeOpencode { + dynamicHeaderEnd, continueRemoving = calcOpencodeDynamicHeader(lines) + } else { + continueRemoving = false + } + return strings.Join(lines[dynamicHeaderEnd+1:], "\n"), continueRemoving +} From f9036c123a8f07ccac628ac7a8419791560fecc9 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 17 Nov 2025 21:27:18 +0530 Subject: [PATCH 2/6] chore: fix lint --- lib/termexec/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/termexec/utils.go b/lib/termexec/utils.go index f681d5f..65a413f 100644 --- a/lib/termexec/utils.go +++ b/lib/termexec/utils.go @@ -47,7 +47,7 @@ func calcOpencodeDynamicHeader(newLines []string) (int, bool) { func removeDynamicHeader(screen string, agentType msgfmt.AgentType) (string, bool) { lines := strings.Split(screen, "\n") dynamicHeaderEnd := -1 - continueRemoving := true + var continueRemoving bool if agentType == msgfmt.AgentTypeAmp { dynamicHeaderEnd, continueRemoving = calcAmpDynamicHeader(lines) } else if agentType == msgfmt.AgentTypeOpencode { From dbbc9ca1934f3eb74ddb9dd04d52d909c7de0946 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 17 Nov 2025 21:56:31 +0530 Subject: [PATCH 3/6] chore: refactor names --- lib/termexec/utils.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/termexec/utils.go b/lib/termexec/utils.go index 65a413f..fd49a27 100644 --- a/lib/termexec/utils.go +++ b/lib/termexec/utils.go @@ -6,22 +6,22 @@ import ( "github.com/coder/agentapi/lib/msgfmt" ) -func calcAmpDynamicHeader(newLines []string) (int, bool) { +func calcAmpDynamicHeader(lines []string) (int, bool) { dynamicHeaderEnd := -1 firstTextEncountered := false continueRemoving := true // search for the first 3 consecutive empty lines after the first text encountered. - if len(newLines) > 3 { - for i := 0; i < len(newLines)-3; i++ { - if !firstTextEncountered && len(strings.Trim(newLines[i], " \n")) != 0 { - if strings.HasPrefix(strings.TrimSpace(newLines[i]), "┃") { + if len(lines) > 3 { + for i := 0; i < len(lines)-3; i++ { + if !firstTextEncountered && len(strings.Trim(lines[i], " \n")) != 0 { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "┃") { continueRemoving = false } firstTextEncountered = true } - if firstTextEncountered && len(strings.Trim(newLines[i], " \n")) == 0 && len(strings.Trim(newLines[i+1], " \n")) == 0 && - len(strings.Trim(newLines[i+2], " \n")) == 0 { + if firstTextEncountered && len(strings.Trim(lines[i], " \n")) == 0 && len(strings.Trim(lines[i+1], " \n")) == 0 && + len(strings.Trim(lines[i+2], " \n")) == 0 { dynamicHeaderEnd = i break @@ -31,14 +31,14 @@ func calcAmpDynamicHeader(newLines []string) (int, bool) { return dynamicHeaderEnd, continueRemoving } -func calcOpencodeDynamicHeader(newLines []string) (int, bool) { +func calcOpencodeDynamicHeader(lines []string) (int, bool) { // Skip header lines for Opencode agent type to avoid false positives // The header contains dynamic content (token count, context percentage, cost) // that changes between screens, causing line comparison mismatches: // // ┃ # Getting Started with Claude CLI ┃ // ┃ /share to create a shareable link 12.6K/6% ($0.05) ┃ - if len(newLines) >= 2 { + if len(lines) >= 2 { return 2, true } return -1, true From 8fc86c66a882d4e85bab078e3f8b6a94a3e5996d Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Tue, 18 Nov 2025 16:40:24 +0530 Subject: [PATCH 4/6] chore: rename variables --- lib/termexec/termexec.go | 22 +++++++++++----------- lib/termexec/utils.go | 20 ++++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/termexec/termexec.go b/lib/termexec/termexec.go index 266e24a..e30b1ff 100644 --- a/lib/termexec/termexec.go +++ b/lib/termexec/termexec.go @@ -19,12 +19,12 @@ import ( ) type Process struct { - xp *xpty.Xpty - execCmd *exec.Cmd - screenUpdateLock sync.RWMutex - lastScreenUpdate time.Time - checkDynamicHeader bool - agentType msgfmt.AgentType + xp *xpty.Xpty + execCmd *exec.Cmd + screenUpdateLock sync.RWMutex + lastScreenUpdate time.Time + checkAnimatedContent bool + agentType msgfmt.AgentType } type StartProcessConfig struct { @@ -50,7 +50,7 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error return nil, err } - process := &Process{xp: xp, execCmd: execCmd, checkDynamicHeader: true, agentType: args.AgentType} + process := &Process{xp: xp, execCmd: execCmd, checkAnimatedContent: true, agentType: args.AgentType} go func() { // HACK: Working around xpty concurrency limitations @@ -122,16 +122,16 @@ func (p *Process) ReadScreen() string { if time.Since(p.lastScreenUpdate) >= 16*time.Millisecond { state = p.xp.State.String() p.screenUpdateLock.RUnlock() - if p.checkDynamicHeader { - state, p.checkDynamicHeader = removeDynamicHeader(state, p.agentType) + if p.checkAnimatedContent { + state, p.checkAnimatedContent = removeAnimatedContent(state, p.agentType) } return state } p.screenUpdateLock.RUnlock() time.Sleep(16 * time.Millisecond) } - if p.checkDynamicHeader { - state, p.checkDynamicHeader = removeDynamicHeader(p.xp.State.String(), p.agentType) + if p.checkAnimatedContent { + state, p.checkAnimatedContent = removeAnimatedContent(p.xp.State.String(), p.agentType) } return state } diff --git a/lib/termexec/utils.go b/lib/termexec/utils.go index fd49a27..b584f93 100644 --- a/lib/termexec/utils.go +++ b/lib/termexec/utils.go @@ -6,8 +6,8 @@ import ( "github.com/coder/agentapi/lib/msgfmt" ) -func calcAmpDynamicHeader(lines []string) (int, bool) { - dynamicHeaderEnd := -1 +func calcAmpAnimatedContent(lines []string) (int, bool) { + animatedContentEnd := -1 firstTextEncountered := false continueRemoving := true @@ -22,16 +22,16 @@ func calcAmpDynamicHeader(lines []string) (int, bool) { } if firstTextEncountered && len(strings.Trim(lines[i], " \n")) == 0 && len(strings.Trim(lines[i+1], " \n")) == 0 && len(strings.Trim(lines[i+2], " \n")) == 0 { - dynamicHeaderEnd = i + animatedContentEnd = i break } } } - return dynamicHeaderEnd, continueRemoving + return animatedContentEnd, continueRemoving } -func calcOpencodeDynamicHeader(lines []string) (int, bool) { +func calcOpencodeAnimatedContent(lines []string) (int, bool) { // Skip header lines for Opencode agent type to avoid false positives // The header contains dynamic content (token count, context percentage, cost) // that changes between screens, causing line comparison mismatches: @@ -44,16 +44,16 @@ func calcOpencodeDynamicHeader(lines []string) (int, bool) { return -1, true } -func removeDynamicHeader(screen string, agentType msgfmt.AgentType) (string, bool) { +func removeAnimatedContent(screen string, agentType msgfmt.AgentType) (string, bool) { lines := strings.Split(screen, "\n") - dynamicHeaderEnd := -1 + animatedContentEnd := -1 var continueRemoving bool if agentType == msgfmt.AgentTypeAmp { - dynamicHeaderEnd, continueRemoving = calcAmpDynamicHeader(lines) + animatedContentEnd, continueRemoving = calcAmpAnimatedContent(lines) } else if agentType == msgfmt.AgentTypeOpencode { - dynamicHeaderEnd, continueRemoving = calcOpencodeDynamicHeader(lines) + animatedContentEnd, continueRemoving = calcOpencodeAnimatedContent(lines) } else { continueRemoving = false } - return strings.Join(lines[dynamicHeaderEnd+1:], "\n"), continueRemoving + return strings.Join(lines[animatedContentEnd+1:], "\n"), continueRemoving } From ba9f6db097edb3939f59a28d1dbbf62d66841e04 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 19 Nov 2025 23:26:52 +0530 Subject: [PATCH 5/6] chore: add note and remove amp animation parsing logic --- README.md | 4 ++++ lib/termexec/utils.go | 34 ++++------------------------------ 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index dd39485..8d92c50 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ agentapi server -- goose > [!NOTE] > When using Codex, Opencode, Copilot, Gemini, Amp or CursorCLI, always specify the agent type explicitly (eg: `agentapi server --type=codex -- codex`), or message formatting may break. + +> [!NOTE] +> When using Amp, set `"amp.terminal.animation": false` in `~/.config/amp/settings.json` to disable terminal animations, which can interfere with AgentAPI's message parsing. + An OpenAPI schema is available in [openapi.json](openapi.json). By default, the server runs on port 3284. Additionally, the server exposes the same OpenAPI schema at http://localhost:3284/openapi.json and the available endpoints in a documentation UI at http://localhost:3284/docs. diff --git a/lib/termexec/utils.go b/lib/termexec/utils.go index b584f93..4b7ad4b 100644 --- a/lib/termexec/utils.go +++ b/lib/termexec/utils.go @@ -6,31 +6,6 @@ import ( "github.com/coder/agentapi/lib/msgfmt" ) -func calcAmpAnimatedContent(lines []string) (int, bool) { - animatedContentEnd := -1 - firstTextEncountered := false - continueRemoving := true - - // search for the first 3 consecutive empty lines after the first text encountered. - if len(lines) > 3 { - for i := 0; i < len(lines)-3; i++ { - if !firstTextEncountered && len(strings.Trim(lines[i], " \n")) != 0 { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "┃") { - continueRemoving = false - } - firstTextEncountered = true - } - if firstTextEncountered && len(strings.Trim(lines[i], " \n")) == 0 && len(strings.Trim(lines[i+1], " \n")) == 0 && - len(strings.Trim(lines[i+2], " \n")) == 0 { - animatedContentEnd = i - break - - } - } - } - return animatedContentEnd, continueRemoving -} - func calcOpencodeAnimatedContent(lines []string) (int, bool) { // Skip header lines for Opencode agent type to avoid false positives // The header contains dynamic content (token count, context percentage, cost) @@ -48,12 +23,11 @@ func removeAnimatedContent(screen string, agentType msgfmt.AgentType) (string, b lines := strings.Split(screen, "\n") animatedContentEnd := -1 var continueRemoving bool - if agentType == msgfmt.AgentTypeAmp { - animatedContentEnd, continueRemoving = calcAmpAnimatedContent(lines) - } else if agentType == msgfmt.AgentTypeOpencode { + switch agentType { + case msgfmt.AgentTypeOpencode: animatedContentEnd, continueRemoving = calcOpencodeAnimatedContent(lines) - } else { - continueRemoving = false + default: + return screen, false } return strings.Join(lines[animatedContentEnd+1:], "\n"), continueRemoving } From 33fdf77294bc2b0775f476c2ef712613242454d9 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 19 Nov 2025 23:41:03 +0530 Subject: [PATCH 6/6] fmt --- .golangci.yml | 1 + lib/termexec/utils.go | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index b89afc4..ba46a52 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,6 +4,7 @@ linters: - exhaustive settings: exhaustive: + default-signifies-exhaustive: true check: - "switch" - "map" diff --git a/lib/termexec/utils.go b/lib/termexec/utils.go index 4b7ad4b..6a06468 100644 --- a/lib/termexec/utils.go +++ b/lib/termexec/utils.go @@ -20,14 +20,12 @@ func calcOpencodeAnimatedContent(lines []string) (int, bool) { } func removeAnimatedContent(screen string, agentType msgfmt.AgentType) (string, bool) { - lines := strings.Split(screen, "\n") - animatedContentEnd := -1 - var continueRemoving bool switch agentType { case msgfmt.AgentTypeOpencode: - animatedContentEnd, continueRemoving = calcOpencodeAnimatedContent(lines) + lines := strings.Split(screen, "\n") + animatedContentEnd, continueRemoving := calcOpencodeAnimatedContent(lines) + return strings.Join(lines[animatedContentEnd+1:], "\n"), continueRemoving default: return screen, false } - return strings.Join(lines[animatedContentEnd+1:], "\n"), continueRemoving }