Skip to content

Commit 3d3c7f8

Browse files
committed
feat(termexec): enhance dynamic header (animation) handling for different agent types
1 parent 0811a14 commit 3d3c7f8

File tree

4 files changed

+79
-21
lines changed

4 files changed

+79
-21
lines changed

lib/httpapi/setup.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func SetupProcess(ctx context.Context, config SetupProcessConfig) (*termexec.Pro
3232
Args: config.ProgramArgs,
3333
TerminalWidth: config.TerminalWidth,
3434
TerminalHeight: config.TerminalHeight,
35+
AgentType: config.AgentType,
3536
})
3637
if err != nil {
3738
logger.Error(fmt.Sprintf("Error starting process: %v", err))

lib/screentracker/conversation.go

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -145,24 +145,11 @@ func FindNewMessage(oldScreen, newScreen string, agentType msgfmt.AgentType) str
145145
newLines := strings.Split(newScreen, "\n")
146146
oldLinesMap := make(map[string]bool)
147147

148-
// -1 indicates no header
149-
dynamicHeaderEnd := -1
150-
151-
// Skip header lines for Opencode agent type to avoid false positives
152-
// The header contains dynamic content (token count, context percentage, cost)
153-
// that changes between screens, causing line comparison mismatches:
154-
//
155-
// ┃ # Getting Started with Claude CLI ┃
156-
// ┃ /share to create a shareable link 12.6K/6% ($0.05) ┃
157-
if len(newLines) >= 2 && agentType == msgfmt.AgentTypeOpencode {
158-
dynamicHeaderEnd = 2
159-
}
160-
161148
for _, line := range oldLines {
162149
oldLinesMap[line] = true
163150
}
164151
firstNonMatchingLine := len(newLines)
165-
for i, line := range newLines[dynamicHeaderEnd+1:] {
152+
for i, line := range newLines {
166153
if !oldLinesMap[line] {
167154
firstNonMatchingLine = i
168155
break

lib/termexec/termexec.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,26 @@ import (
1313

1414
"github.com/ActiveState/termtest/xpty"
1515
"github.com/coder/agentapi/lib/logctx"
16+
"github.com/coder/agentapi/lib/msgfmt"
1617
"github.com/coder/agentapi/lib/util"
1718
"golang.org/x/xerrors"
1819
)
1920

2021
type Process struct {
21-
xp *xpty.Xpty
22-
execCmd *exec.Cmd
23-
screenUpdateLock sync.RWMutex
24-
lastScreenUpdate time.Time
22+
xp *xpty.Xpty
23+
execCmd *exec.Cmd
24+
screenUpdateLock sync.RWMutex
25+
lastScreenUpdate time.Time
26+
checkDynamicHeader bool
27+
agentType msgfmt.AgentType
2528
}
2629

2730
type StartProcessConfig struct {
2831
Program string
2932
Args []string
3033
TerminalWidth uint16
3134
TerminalHeight uint16
35+
AgentType msgfmt.AgentType
3236
}
3337

3438
func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error) {
@@ -46,7 +50,7 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
4650
return nil, err
4751
}
4852

49-
process := &Process{xp: xp, execCmd: execCmd}
53+
process := &Process{xp: xp, execCmd: execCmd, checkDynamicHeader: true, agentType: args.AgentType}
5054

5155
go func() {
5256
// HACK: Working around xpty concurrency limitations
@@ -112,17 +116,24 @@ func (p *Process) Signal(sig os.Signal) error {
112116
// result in a malformed agent message being returned to the
113117
// user.
114118
func (p *Process) ReadScreen() string {
119+
var state string
115120
for range 3 {
116121
p.screenUpdateLock.RLock()
117122
if time.Since(p.lastScreenUpdate) >= 16*time.Millisecond {
118-
state := p.xp.State.String()
123+
state = p.xp.State.String()
119124
p.screenUpdateLock.RUnlock()
125+
if p.checkDynamicHeader {
126+
state, p.checkDynamicHeader = removeDynamicHeader(state, p.agentType)
127+
}
120128
return state
121129
}
122130
p.screenUpdateLock.RUnlock()
123131
time.Sleep(16 * time.Millisecond)
124132
}
125-
return p.xp.State.String()
133+
if p.checkDynamicHeader {
134+
state, p.checkDynamicHeader = removeDynamicHeader(p.xp.State.String(), p.agentType)
135+
}
136+
return state
126137
}
127138

128139
// Write sends input to the process via the pseudo terminal.

lib/termexec/utils.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package termexec
2+
3+
import (
4+
"strings"
5+
6+
"github.com/coder/agentapi/lib/msgfmt"
7+
)
8+
9+
func calcAmpDynamicHeader(newLines []string) (int, bool) {
10+
dynamicHeaderEnd := -1
11+
firstTextEncountered := false
12+
continueRemoving := true
13+
14+
// search for the first 3 consecutive empty lines after the first text encountered.
15+
if len(newLines) > 3 {
16+
for i := 0; i < len(newLines)-3; i++ {
17+
if !firstTextEncountered && len(strings.Trim(newLines[i], " \n")) != 0 {
18+
if strings.HasPrefix(strings.TrimSpace(newLines[i]), "┃") {
19+
continueRemoving = false
20+
}
21+
firstTextEncountered = true
22+
}
23+
if firstTextEncountered && len(strings.Trim(newLines[i], " \n")) == 0 && len(strings.Trim(newLines[i+1], " \n")) == 0 &&
24+
len(strings.Trim(newLines[i+2], " \n")) == 0 {
25+
dynamicHeaderEnd = i
26+
break
27+
28+
}
29+
}
30+
}
31+
return dynamicHeaderEnd, continueRemoving
32+
}
33+
34+
func calcOpencodeDynamicHeader(newLines []string) (int, bool) {
35+
// Skip header lines for Opencode agent type to avoid false positives
36+
// The header contains dynamic content (token count, context percentage, cost)
37+
// that changes between screens, causing line comparison mismatches:
38+
//
39+
// ┃ # Getting Started with Claude CLI ┃
40+
// ┃ /share to create a shareable link 12.6K/6% ($0.05) ┃
41+
if len(newLines) >= 2 {
42+
return 2, true
43+
}
44+
return -1, true
45+
}
46+
47+
func removeDynamicHeader(screen string, agentType msgfmt.AgentType) (string, bool) {
48+
lines := strings.Split(screen, "\n")
49+
dynamicHeaderEnd := -1
50+
continueRemoving := true
51+
if agentType == msgfmt.AgentTypeAmp {
52+
dynamicHeaderEnd, continueRemoving = calcAmpDynamicHeader(lines)
53+
} else if agentType == msgfmt.AgentTypeOpencode {
54+
dynamicHeaderEnd, continueRemoving = calcOpencodeDynamicHeader(lines)
55+
} else {
56+
continueRemoving = false
57+
}
58+
return strings.Join(lines[dynamicHeaderEnd+1:], "\n"), continueRemoving
59+
}

0 commit comments

Comments
 (0)