Skip to content

Commit 1284dc4

Browse files
authored
Merge pull request #47 from marcus/perf
perf
2 parents 6157eba + a413eef commit 1284dc4

File tree

6 files changed

+73
-35
lines changed

6 files changed

+73
-35
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,5 @@ func (p *Plugin) openInFileBrowser(path string) tea.Cmd {
7777
)
7878
}
7979
```
80+
81+
Worktree tmux preview capture cap is configurable via `plugins.worktree.tmuxCaptureMaxBytes` in `~/.config/sidecar/config.json`.

internal/config/config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ type WorktreePluginConfig struct {
4848
// DirPrefix prefixes worktree directory names with the repo name (e.g., 'myrepo-feature-auth')
4949
// This helps associate conversations with the repo after worktree deletion. Default: true.
5050
DirPrefix bool `json:"dirPrefix"`
51+
// TmuxCaptureMaxBytes caps tmux pane capture size for the preview pane. Default: 2MB.
52+
TmuxCaptureMaxBytes int `json:"tmuxCaptureMaxBytes"`
5153
}
5254

5355
// KeymapConfig holds key binding overrides.
@@ -90,7 +92,8 @@ func Default() *Config {
9092
ClaudeDataDir: "~/.claude",
9193
},
9294
Worktree: WorktreePluginConfig{
93-
DirPrefix: true,
95+
DirPrefix: true,
96+
TmuxCaptureMaxBytes: 2 * 1024 * 1024,
9497
},
9598
},
9699
Keymap: KeymapConfig{
@@ -115,5 +118,8 @@ func (c *Config) Validate() error {
115118
if c.Plugins.TDMonitor.RefreshInterval < 0 {
116119
c.Plugins.TDMonitor.RefreshInterval = 2 * time.Second
117120
}
121+
if c.Plugins.Worktree.TmuxCaptureMaxBytes <= 0 {
122+
c.Plugins.Worktree.TmuxCaptureMaxBytes = 2 * 1024 * 1024
123+
}
118124
return nil
119125
}

internal/config/loader.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ type rawPluginsConfig struct {
3434
}
3535

3636
type rawWorktreeConfig struct {
37-
DirPrefix *bool `json:"dirPrefix"`
37+
DirPrefix *bool `json:"dirPrefix"`
38+
TmuxCaptureMaxBytes *int `json:"tmuxCaptureMaxBytes"`
3839
}
3940

4041
type rawGitStatusConfig struct {
@@ -143,6 +144,9 @@ func mergeConfig(cfg *Config, raw *rawConfig) {
143144
if raw.Plugins.Worktree.DirPrefix != nil {
144145
cfg.Plugins.Worktree.DirPrefix = *raw.Plugins.Worktree.DirPrefix
145146
}
147+
if raw.Plugins.Worktree.TmuxCaptureMaxBytes != nil {
148+
cfg.Plugins.Worktree.TmuxCaptureMaxBytes = *raw.Plugins.Worktree.TmuxCaptureMaxBytes
149+
}
146150

147151
// Keymap
148152
if raw.Keymap.Overrides != nil {

internal/plugins/worktree/agent.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ const (
123123
// We only need recent output for status detection and display
124124
captureLineCount = 600
125125

126+
// Hard cap on captured output size to avoid runaway memory for TUI-heavy panes.
127+
defaultTmuxCaptureMaxBytes = 2 * 1024 * 1024
128+
126129
// Timeout for tmux capture commands to avoid blocking on hung sessions
127130
tmuxCaptureTimeout = 2 * time.Second
128131
tmuxBatchCaptureTimeout = 3 * time.Second
@@ -567,6 +570,8 @@ func (p *Plugin) handlePollAgent(worktreeName string) tea.Cmd {
567570
return pollAgentMsg{WorktreeName: worktreeName}
568571
}
569572

573+
output = trimCapturedOutput(output, p.tmuxCaptureMaxBytes)
574+
570575
// Use hash-based change detection to skip processing if content unchanged
571576
if wt.Agent.OutputBuf != nil && !wt.Agent.OutputBuf.Update(output) {
572577
// Content unchanged - signal to schedule next poll with delay
@@ -704,6 +709,17 @@ done
704709
return results, nil
705710
}
706711

712+
func trimCapturedOutput(output string, maxBytes int) string {
713+
if maxBytes <= 0 || len(output) <= maxBytes {
714+
return output
715+
}
716+
trimmed := tailUTF8Safe(output, maxBytes)
717+
if nl := strings.Index(trimmed, "\n"); nl >= 0 && nl+1 < len(trimmed) {
718+
return trimmed[nl+1:]
719+
}
720+
return trimmed
721+
}
722+
707723
// tailUTF8Safe returns the last n bytes of s, adjusted to not split UTF-8 chars.
708724
// If the slice would split a multi-byte character, it advances to the next valid
709725
// UTF-8 boundary (returning slightly fewer than n bytes).

internal/plugins/worktree/plugin.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ type Plugin struct {
106106
kanbanRow int // Current row within the column
107107

108108
// Agent state
109-
attachedSession string // Name of worktree we're attached to (pauses polling)
109+
attachedSession string // Name of worktree we're attached to (pauses polling)
110+
tmuxCaptureMaxBytes int // Cap for tmux capture output (bytes)
110111

111112
// Mouse support
112113
mouseHandler *mouse.Handler
@@ -212,18 +213,19 @@ func New() *Plugin {
212213
mdRenderer, _ := markdown.NewRenderer()
213214

214215
return &Plugin{
215-
worktrees: make([]*Worktree, 0),
216-
agents: make(map[string]*Agent),
217-
managedSessions: make(map[string]bool),
218-
viewMode: ViewModeList,
219-
activePane: PaneSidebar,
220-
previewTab: PreviewTabOutput,
221-
mouseHandler: mouse.NewHandler(),
222-
sidebarWidth: 40, // Default 40% sidebar
223-
sidebarVisible: true, // Sidebar visible by default
224-
autoScrollOutput: true, // Auto-scroll to follow agent output
225-
markdownRenderer: mdRenderer,
226-
taskMarkdownMode: true, // Default to rendered mode
216+
worktrees: make([]*Worktree, 0),
217+
agents: make(map[string]*Agent),
218+
managedSessions: make(map[string]bool),
219+
viewMode: ViewModeList,
220+
activePane: PaneSidebar,
221+
previewTab: PreviewTabOutput,
222+
mouseHandler: mouse.NewHandler(),
223+
sidebarWidth: 40, // Default 40% sidebar
224+
sidebarVisible: true, // Sidebar visible by default
225+
autoScrollOutput: true, // Auto-scroll to follow agent output
226+
tmuxCaptureMaxBytes: defaultTmuxCaptureMaxBytes,
227+
markdownRenderer: mdRenderer,
228+
taskMarkdownMode: true, // Default to rendered mode
227229
}
228230
}
229231

@@ -245,6 +247,9 @@ func (p *Plugin) SetFocused(f bool) { p.focused = f }
245247
// Init initializes the plugin with context.
246248
func (p *Plugin) Init(ctx *plugin.Context) error {
247249
p.ctx = ctx
250+
if ctx.Config != nil && ctx.Config.Plugins.Worktree.TmuxCaptureMaxBytes > 0 {
251+
p.tmuxCaptureMaxBytes = ctx.Config.Plugins.Worktree.TmuxCaptureMaxBytes
252+
}
248253

249254
// Register dynamic keybindings
250255
if ctx.Keymap != nil {

internal/plugins/worktree/types.go

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package worktree
22

33
import (
4-
"crypto/sha256"
4+
"hash/maphash"
55
"strings"
66
"sync"
77
"time"
@@ -11,15 +11,15 @@ import (
1111
type ViewMode int
1212

1313
const (
14-
ViewModeList ViewMode = iota // List view (default)
15-
ViewModeKanban // Kanban board view
16-
ViewModeCreate // New worktree modal
17-
ViewModeTaskLink // Task link modal (for existing worktrees)
18-
ViewModeMerge // Merge workflow modal
19-
ViewModeAgentChoice // Agent action choice modal (attach/restart)
20-
ViewModeConfirmDelete // Delete confirmation modal
21-
ViewModeCommitForMerge // Commit modal before merge workflow
22-
ViewModePromptPicker // Prompt template picker modal
14+
ViewModeList ViewMode = iota // List view (default)
15+
ViewModeKanban // Kanban board view
16+
ViewModeCreate // New worktree modal
17+
ViewModeTaskLink // Task link modal (for existing worktrees)
18+
ViewModeMerge // Merge workflow modal
19+
ViewModeAgentChoice // Agent action choice modal (attach/restart)
20+
ViewModeConfirmDelete // Delete confirmation modal
21+
ViewModeCommitForMerge // Commit modal before merge workflow
22+
ViewModePromptPicker // Prompt template picker modal
2323
)
2424

2525
// FocusPane represents which pane is active in the split view.
@@ -179,10 +179,10 @@ type Worktree struct {
179179

180180
// Agent represents an AI coding agent process.
181181
type Agent struct {
182-
Type AgentType // claude, codex, aider, gemini
183-
TmuxSession string // tmux session name
184-
TmuxPane string // Pane identifier
185-
PID int // Process ID (if available)
182+
Type AgentType // claude, codex, aider, gemini
183+
TmuxSession string // tmux session name
184+
TmuxPane string // Pane identifier
185+
PID int // Process ID (if available)
186186
StartedAt time.Time
187187
LastOutput time.Time // Last time output was detected
188188
OutputBuf *OutputBuffer // Last N lines of output
@@ -224,14 +224,17 @@ type OutputBuffer struct {
224224
mu sync.Mutex
225225
lines []string
226226
cap int
227-
lastHash [32]byte // SHA256 of last content for change detection
227+
lastHash uint64 // Hash of last content for change detection
228+
lastLen int // Length of last content (collision guard)
229+
hashSeed maphash.Seed // Seed for stable hashing
228230
}
229231

230232
// NewOutputBuffer creates a new output buffer with the given capacity.
231233
func NewOutputBuffer(capacity int) *OutputBuffer {
232234
return &OutputBuffer{
233-
lines: make([]string, 0, capacity),
234-
cap: capacity,
235+
lines: make([]string, 0, capacity),
236+
cap: capacity,
237+
hashSeed: maphash.MakeSeed(),
235238
}
236239
}
237240

@@ -242,13 +245,14 @@ func (b *OutputBuffer) Update(content string) bool {
242245
defer b.mu.Unlock()
243246

244247
// Compute hash of new content
245-
hash := sha256.Sum256([]byte(content))
246-
if hash == b.lastHash {
248+
hash := maphash.String(b.hashSeed, content)
249+
if hash == b.lastHash && len(content) == b.lastLen {
247250
return false // Content unchanged
248251
}
249252

250253
// Content changed - update hash and replace lines
251254
b.lastHash = hash
255+
b.lastLen = len(content)
252256
b.lines = strings.Split(content, "\n")
253257

254258
// Trim to capacity (keep most recent lines)
@@ -319,7 +323,8 @@ func (b *OutputBuffer) Clear() {
319323
b.mu.Lock()
320324
defer b.mu.Unlock()
321325
b.lines = b.lines[:0]
322-
b.lastHash = [32]byte{} // Reset hash
326+
b.lastHash = 0
327+
b.lastLen = 0
323328
}
324329

325330
// Len returns the number of lines in the buffer.

0 commit comments

Comments
 (0)