Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/httpapi/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
15 changes: 1 addition & 14 deletions lib/screentracker/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 18 additions & 7 deletions lib/termexec/termexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,26 @@ 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
checkAnimatedContent bool
agentType msgfmt.AgentType
}

type StartProcessConfig struct {
Program string
Args []string
TerminalWidth uint16
TerminalHeight uint16
AgentType msgfmt.AgentType
}

func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error) {
Expand All @@ -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, checkAnimatedContent: true, agentType: args.AgentType}

go func() {
// HACK: Working around xpty concurrency limitations
Expand Down Expand Up @@ -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.checkAnimatedContent {
state, p.checkAnimatedContent = removeAnimatedContent(state, p.agentType)
}
return state
}
p.screenUpdateLock.RUnlock()
time.Sleep(16 * time.Millisecond)
}
return p.xp.State.String()
if p.checkAnimatedContent {
state, p.checkAnimatedContent = removeAnimatedContent(p.xp.State.String(), p.agentType)
}
return state
}

// Write sends input to the process via the pseudo terminal.
Expand Down
59 changes: 59 additions & 0 deletions lib/termexec/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package termexec

import (
"strings"

"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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add some tests for this?

}

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:
//
// ┃ # Getting Started with Claude CLI ┃
// ┃ /share to create a shareable link 12.6K/6% ($0.05) ┃
if len(lines) >= 2 {
return 2, true
}
return -1, true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is largely the same, but the continue removing variable seems new, perhaps we should ensure we've covered the new behavior with tests?

}

func removeAnimatedContent(screen string, agentType msgfmt.AgentType) (string, bool) {
lines := strings.Split(screen, "\n")
animatedContentEnd := -1
var continueRemoving bool
if agentType == msgfmt.AgentTypeAmp {
animatedContentEnd, continueRemoving = calcAmpAnimatedContent(lines)
} else if agentType == msgfmt.AgentTypeOpencode {
animatedContentEnd, continueRemoving = calcOpencodeAnimatedContent(lines)
} else {
continueRemoving = false
}
return strings.Join(lines[animatedContentEnd+1:], "\n"), continueRemoving
}