From 5633025dd1f403f1afb0f43f163f60c4415b7e44 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Fri, 27 Mar 2026 08:25:04 -0700 Subject: [PATCH] fix: resolve linting errors and remove unused calculateCost function Remove unused calculateCost and its test from opencode adapter, remove unused sessionIndex fields, fix alignment and formatting issues across 93 files flagged by linters. Nightshift-Task: lint-fix Nightshift-Ref: https://github.com/marcus/nightshift Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/adapter/amp/adapter.go | 2 +- internal/adapter/amp/adapter_test.go | 8 +- internal/adapter/claudecode/parser_test.go | 1 - internal/adapter/claudecode/stats.go | 20 +- internal/adapter/claudecode/types.go | 14 +- internal/adapter/codex/adapter.go | 12 +- internal/adapter/kiro/adapter.go | 2 - internal/adapter/kiro/kiro_test.go | 1 - internal/adapter/kiro/types.go | 20 +- internal/adapter/opencode/adapter.go | 39 ---- internal/adapter/opencode/adapter_test.go | 35 +-- internal/adapter/opencode/types.go | 10 +- internal/adapter/pi/adapter.go | 2 +- internal/adapter/pi/adapter_test.go | 16 +- internal/adapter/pi/types.go | 30 +-- internal/adapter/piagent/adapter_test.go | 2 +- internal/adapter/pricing/pricing.go | 14 +- internal/adapter/search_utils_test.go | 6 +- internal/adapter/testutil/fixtures.go | 20 +- internal/adapter/warp/types.go | 40 ++-- internal/app/intro.go | 24 +- internal/app/intro_test.go | 8 +- internal/app/model.go | 94 ++++---- internal/app/open_in_modal.go | 4 +- internal/app/open_in_modal_test.go | 4 +- internal/app/update_test.go | 6 +- internal/app/worktree_switcher_modal.go | 1 - internal/app/worktree_switcher_test.go | 98 ++++----- internal/community/colorutil_test.go | 34 +-- internal/config/config.go | 2 +- internal/config/loader.go | 2 +- internal/config/saver.go | 2 +- internal/event/event.go | 6 +- internal/image/image.go | 1 - internal/keymap/registry.go | 4 +- internal/modal/list.go | 1 - internal/mouse/mouse.go | 2 +- internal/plugin/context.go | 12 +- internal/plugin/registry_test.go | 30 +-- .../plugins/conversations/clipboard_test.go | 2 - .../plugins/conversations/coalescer_test.go | 18 +- .../plugins/conversations/content_search.go | 8 +- .../conversations/content_search_exec_test.go | 32 +-- .../conversations/content_search_test.go | 24 +- .../conversations/content_search_view.go | 2 - .../plugins/conversations/markdown_test.go | 2 +- internal/plugins/filebrowser/inline_edit.go | 1 - .../plugins/filebrowser/project_search.go | 24 +- .../filebrowser/project_search_test.go | 26 +-- internal/plugins/filebrowser/tabs_test.go | 4 +- internal/plugins/filebrowser/view_blame.go | 8 +- internal/plugins/filebrowser/watcher.go | 14 +- .../plugins/gitstatus/commit_list_test.go | 6 +- internal/plugins/gitstatus/data_loaders.go | 2 - internal/plugins/gitstatus/diff_parser.go | 10 +- internal/plugins/gitstatus/diff_renderer.go | 2 +- .../plugins/gitstatus/diff_renderer_test.go | 14 +- internal/plugins/gitstatus/graph_test.go | 10 +- .../plugins/gitstatus/history_search_test.go | 50 ++--- .../plugins/gitstatus/preview_selection.go | 1 - internal/plugins/gitstatus/push.go | 12 +- internal/plugins/gitstatus/scroll_layout.go | 2 - internal/plugins/gitstatus/trunc_cache.go | 2 +- internal/plugins/gitstatus/update_handlers.go | 1 - internal/plugins/gitstatus/view.go | 1 - internal/plugins/tdmonitor/plugin.go | 2 +- internal/plugins/workspace/agent.go | 30 +-- internal/plugins/workspace/agent_session.go | 1 - internal/plugins/workspace/agent_test.go | 13 +- internal/plugins/workspace/commands.go | 4 +- internal/plugins/workspace/create_modal.go | 22 +- internal/plugins/workspace/diff_test.go | 50 ++--- .../workspace/interactive_selection_test.go | 10 +- .../plugins/workspace/interactive_test.go | 8 +- internal/plugins/workspace/keys.go | 8 +- internal/plugins/workspace/merge.go | 206 +++++++++--------- internal/plugins/workspace/messages.go | 42 ++-- internal/plugins/workspace/mouse.go | 6 +- internal/plugins/workspace/plugin.go | 80 +++---- internal/plugins/workspace/prompts_test.go | 8 +- internal/plugins/workspace/setup.go | 15 +- internal/plugins/workspace/shell.go | 1 + internal/plugins/workspace/types.go | 43 ++-- internal/plugins/workspace/types_test.go | 8 +- internal/plugins/workspace/view_kanban.go | 2 +- internal/plugins/workspace/view_modals.go | 12 +- internal/plugins/workspace/worktree_test.go | 13 +- internal/state/state_test.go | 6 +- internal/styles/borders_test.go | 6 +- internal/tty/output_buffer_test.go | 10 +- internal/ui/selection_test.go | 8 +- internal/ui/text.go | 116 +++++----- internal/version/checker_test.go | 1 - 93 files changed, 775 insertions(+), 863 deletions(-) diff --git a/internal/adapter/amp/adapter.go b/internal/adapter/amp/adapter.go index 09fe7dbe..c6b688c4 100644 --- a/internal/adapter/amp/adapter.go +++ b/internal/adapter/amp/adapter.go @@ -28,7 +28,7 @@ const ( type Adapter struct { threadsDir string sessionIndex map[string]string // threadID -> file path - mu sync.RWMutex // guards sessionIndex + mu sync.RWMutex // guards sessionIndex metaCache map[string]metaCacheEntry metaMu sync.RWMutex // guards metaCache msgCache *cache.Cache[msgCacheEntry] diff --git a/internal/adapter/amp/adapter_test.go b/internal/adapter/amp/adapter_test.go index 05190449..8752b772 100644 --- a/internal/adapter/amp/adapter_test.go +++ b/internal/adapter/amp/adapter_test.go @@ -100,10 +100,10 @@ func fixtureSimpleThread(projectDir string) Thread { {Type: "text", Text: "Of course! I'd be happy to help."}, }, Usage: &Usage{ - Model: "claude-opus-4-6", - InputTokens: 80, - OutputTokens: 50, - TotalInputTokens: 100, + Model: "claude-opus-4-6", + InputTokens: 80, + OutputTokens: 50, + TotalInputTokens: 100, CacheReadInputTokens: 10, CacheCreationInputTokens: 5, }, diff --git a/internal/adapter/claudecode/parser_test.go b/internal/adapter/claudecode/parser_test.go index afabebea..3547c74f 100644 --- a/internal/adapter/claudecode/parser_test.go +++ b/internal/adapter/claudecode/parser_test.go @@ -929,4 +929,3 @@ func TestTwoPassToolLinking_InterleavedTextAndTools(t *testing.T) { t.Error("blocks[2].ToolOutput should be non-empty") } } - diff --git a/internal/adapter/claudecode/stats.go b/internal/adapter/claudecode/stats.go index 4929b624..0003aea0 100644 --- a/internal/adapter/claudecode/stats.go +++ b/internal/adapter/claudecode/stats.go @@ -12,16 +12,16 @@ import ( // StatsCache represents the aggregated usage stats from stats-cache.json. type StatsCache struct { - Version int `json:"version"` - LastComputedDate string `json:"lastComputedDate"` - TotalSessions int `json:"totalSessions"` - TotalMessages int `json:"totalMessages"` - FirstSessionDate time.Time `json:"firstSessionDate"` - DailyActivity []DailyActivity `json:"dailyActivity"` - DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"` - ModelUsage map[string]ModelUsage `json:"modelUsage"` - HourCounts map[string]int `json:"hourCounts"` - LongestSession LongestSession `json:"longestSession"` + Version int `json:"version"` + LastComputedDate string `json:"lastComputedDate"` + TotalSessions int `json:"totalSessions"` + TotalMessages int `json:"totalMessages"` + FirstSessionDate time.Time `json:"firstSessionDate"` + DailyActivity []DailyActivity `json:"dailyActivity"` + DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"` + ModelUsage map[string]ModelUsage `json:"modelUsage"` + HourCounts map[string]int `json:"hourCounts"` + LongestSession LongestSession `json:"longestSession"` } // DailyActivity tracks activity for a single day. diff --git a/internal/adapter/claudecode/types.go b/internal/adapter/claudecode/types.go index 8cf1da50..8f5bbb1c 100644 --- a/internal/adapter/claudecode/types.go +++ b/internal/adapter/claudecode/types.go @@ -30,10 +30,10 @@ type MessageContent struct { // Usage tracks token usage for a message. type Usage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CacheCreationInputTokens int `json:"cache_creation_input_tokens"` - CacheReadInputTokens int `json:"cache_read_input_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` CacheCreation *CacheCreation `json:"cache_creation,omitempty"` } @@ -48,9 +48,9 @@ type ContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` Thinking string `json:"thinking,omitempty"` - ID string `json:"id,omitempty"` // tool_use ID - Name string `json:"name,omitempty"` // tool name - Input any `json:"input,omitempty"` // tool input + ID string `json:"id,omitempty"` // tool_use ID + Name string `json:"name,omitempty"` // tool name + Input any `json:"input,omitempty"` // tool input ToolUseID string `json:"tool_use_id,omitempty"` // for tool_result linking Content any `json:"content,omitempty"` // tool_result content (string or array) IsError bool `json:"is_error,omitempty"` // tool_result error flag diff --git a/internal/adapter/codex/adapter.go b/internal/adapter/codex/adapter.go index 80df9ed6..b3bdbeb8 100644 --- a/internal/adapter/codex/adapter.go +++ b/internal/adapter/codex/adapter.go @@ -20,7 +20,7 @@ const ( adapterID = "codex" adapterName = "Codex" metaCacheMaxEntries = 2048 - msgCacheMaxEntries = 128 // fewer entries since messages are larger + msgCacheMaxEntries = 128 // fewer entries since messages are larger dirCacheTTL = 500 * time.Millisecond // TTL for directory listing cache (td-c9ff3aac) // Two-pass parsing thresholds (td-a2c1dd41) metaParseSmallFileThreshold = 16 * 1024 // Files smaller than 16KB use full scan @@ -37,12 +37,12 @@ type dirCacheEntry struct { // Adapter implements the adapter.Adapter interface for Codex CLI sessions. type Adapter struct { sessionsDir string - sessionIndex map[string]string // sessionID -> file path cache - totalUsageCache map[string]*TokenUsage // sessionID -> total usage (populated by Messages) - mu sync.RWMutex // guards sessionIndex and totalUsageCache + sessionIndex map[string]string // sessionID -> file path cache + totalUsageCache map[string]*TokenUsage // sessionID -> total usage (populated by Messages) + mu sync.RWMutex // guards sessionIndex and totalUsageCache metaCache map[string]sessionMetaCacheEntry - metaMu sync.RWMutex // guards metaCache - msgCache *cache.Cache[messageCacheEntry] // path -> cached messages + metaMu sync.RWMutex // guards metaCache + msgCache *cache.Cache[messageCacheEntry] // path -> cached messages dirCache *dirCacheEntry dirCacheMu sync.RWMutex // guards dirCache } diff --git a/internal/adapter/kiro/adapter.go b/internal/adapter/kiro/adapter.go index 1b1d419a..1de2bb6d 100644 --- a/internal/adapter/kiro/adapter.go +++ b/internal/adapter/kiro/adapter.go @@ -409,7 +409,6 @@ func resolveProjectPath(projectRoot string) string { return filepath.Clean(abs) } - // firstPromptText returns the text of the first Prompt entry in the history. func firstPromptText(history []HistoryEntry) string { for _, entry := range history { @@ -573,4 +572,3 @@ func truncateOutput(s string, maxLen int) string { } return s[:maxLen-3] + "..." } - diff --git a/internal/adapter/kiro/kiro_test.go b/internal/adapter/kiro/kiro_test.go index a57029ba..a83c710a 100644 --- a/internal/adapter/kiro/kiro_test.go +++ b/internal/adapter/kiro/kiro_test.go @@ -140,7 +140,6 @@ func TestShortConversationID(t *testing.T) { } } - func TestIsPromptEntry(t *testing.T) { prompt := HistoryEntry{ User: &UserMessage{ diff --git a/internal/adapter/kiro/types.go b/internal/adapter/kiro/types.go index c863eb6e..20ee7708 100644 --- a/internal/adapter/kiro/types.go +++ b/internal/adapter/kiro/types.go @@ -29,8 +29,8 @@ type HistoryEntry struct { // UserMessage is the user side of a history entry. type UserMessage struct { - Content json.RawMessage `json:"content"` // Prompt or ToolUseResults, discriminated - Timestamp string `json:"timestamp"` // RFC3339 format + Content json.RawMessage `json:"content"` // Prompt or ToolUseResults, discriminated + Timestamp string `json:"timestamp"` // RFC3339 format EnvContext *EnvContext `json:"env_context"` } @@ -67,9 +67,9 @@ type ToolUseResultsData struct { // ToolUseResult is a single tool result. type ToolUseResult struct { - ToolUseID string `json:"tool_use_id"` + ToolUseID string `json:"tool_use_id"` Content []ToolResultContent `json:"content"` - Status string `json:"status"` + Status string `json:"status"` } // ToolResultContent is a content block in a tool result. @@ -102,9 +102,9 @@ type AssistantToolUse struct { // ToolUseData contains the tool use info. type ToolUseData struct { - MessageID string `json:"message_id"` - Content string `json:"content"` - ToolUses []ToolUseEntry `json:"tool_uses"` + MessageID string `json:"message_id"` + Content string `json:"content"` + ToolUses []ToolUseEntry `json:"tool_uses"` } // ToolUseEntry is a single tool invocation. @@ -116,9 +116,9 @@ type ToolUseEntry struct { // RequestMetadata holds timing and context info for a request. type RequestMetadata struct { - ContextUsagePercentage float64 `json:"context_usage_percentage"` - RequestStartTimestampMs int64 `json:"request_start_timestamp_ms"` - StreamEndTimestampMs int64 `json:"stream_end_timestamp_ms"` + ContextUsagePercentage float64 `json:"context_usage_percentage"` + RequestStartTimestampMs int64 `json:"request_start_timestamp_ms"` + StreamEndTimestampMs int64 `json:"stream_end_timestamp_ms"` } // ModelInfo holds model configuration. diff --git a/internal/adapter/opencode/adapter.go b/internal/adapter/opencode/adapter.go index 0e7a942f..4c9d22e7 100644 --- a/internal/adapter/opencode/adapter.go +++ b/internal/adapter/opencode/adapter.go @@ -706,45 +706,6 @@ func (a *Adapter) batchLoadAllParts(messageIDs []string) map[string]parsedParts return result } -// calculateCost estimates cost based on model and token usage. -func calculateCost(model string, inputTokens, outputTokens, cacheRead int) float64 { - var inRate, outRate float64 - model = strings.ToLower(model) - - switch { - case strings.Contains(model, "opus"): - inRate, outRate = 15.0, 75.0 - case strings.Contains(model, "sonnet"): - inRate, outRate = 3.0, 15.0 - case strings.Contains(model, "haiku"): - inRate, outRate = 0.25, 1.25 - case strings.Contains(model, "gpt-4o"): - inRate, outRate = 2.5, 10.0 - case strings.Contains(model, "gpt-4"): - inRate, outRate = 10.0, 30.0 - case strings.Contains(model, "o1"): - inRate, outRate = 15.0, 60.0 - case strings.Contains(model, "gemini"): - inRate, outRate = 1.25, 5.0 - case strings.Contains(model, "deepseek"): - inRate, outRate = 0.14, 0.28 - default: - // Default to sonnet rates - inRate, outRate = 3.0, 15.0 - } - - // Cache reads get 90% discount - regularIn := inputTokens - cacheRead - if regularIn < 0 { - regularIn = 0 - } - cacheInCost := float64(cacheRead) * inRate * 0.1 / 1_000_000 - regularInCost := float64(regularIn) * inRate / 1_000_000 - outCost := float64(outputTokens) * outRate / 1_000_000 - - return cacheInCost + regularInCost + outCost -} - // shortID returns the first 12 characters of an ID, or the full ID if shorter. func shortID(id string) string { if len(id) >= 12 { diff --git a/internal/adapter/opencode/adapter_test.go b/internal/adapter/opencode/adapter_test.go index e55982a1..23477906 100644 --- a/internal/adapter/opencode/adapter_test.go +++ b/internal/adapter/opencode/adapter_test.go @@ -146,8 +146,7 @@ func TestFindProjectID_WithSandboxPaths(t *testing.T) { a := &Adapter{ storageDir: tmpDir, projectIndex: make(map[string]*Project), - sessionIndex: make(map[string]string), - metaCache: make(map[string]sessionMetaCacheEntry), + metaCache: make(map[string]sessionMetaCacheEntry), } // Test 1: Find project by worktree path @@ -179,7 +178,6 @@ func TestFindProjectID_WithSandboxPaths(t *testing.T) { } } - func TestFindProjectID_SubdirectoryMatch(t *testing.T) { tmpDir := t.TempDir() projectDir := filepath.Join(tmpDir, "project") @@ -208,8 +206,7 @@ func TestFindProjectID_SubdirectoryMatch(t *testing.T) { a := &Adapter{ storageDir: tmpDir, projectIndex: make(map[string]*Project), - sessionIndex: make(map[string]string), - metaCache: make(map[string]sessionMetaCacheEntry), + metaCache: make(map[string]sessionMetaCacheEntry), } // Test 1: .bare subdirectory should match via subdirectory fallback @@ -266,8 +263,7 @@ func TestFindProjectID_SandboxNotDuplicated(t *testing.T) { a := &Adapter{ storageDir: tmpDir, projectIndex: make(map[string]*Project), - sessionIndex: make(map[string]string), - metaCache: make(map[string]sessionMetaCacheEntry), + metaCache: make(map[string]sessionMetaCacheEntry), } // Should find project by worktree path @@ -458,31 +454,6 @@ func TestShortID(t *testing.T) { } } -func TestCalculateCost(t *testing.T) { - tests := []struct { - model string - input int - output int - cache int - minCost float64 - maxCost float64 - }{ - {"claude-opus-4", 1000, 500, 0, 0.05, 0.06}, - {"claude-sonnet-4", 1000, 500, 0, 0.01, 0.02}, - {"claude-haiku", 1000, 500, 0, 0.0005, 0.001}, - {"gpt-4o", 1000, 500, 0, 0.005, 0.01}, - {"deepseek", 1000, 500, 0, 0.0001, 0.0005}, - } - - for _, tt := range tests { - cost := calculateCost(tt.model, tt.input, tt.output, tt.cache) - if cost < tt.minCost || cost > tt.maxCost { - t.Errorf("calculateCost(%q, %d, %d, %d) = %f, want between %f and %f", - tt.model, tt.input, tt.output, tt.cache, cost, tt.minCost, tt.maxCost) - } - } -} - func TestTimeInfo(t *testing.T) { ti := TimeInfo{ Created: 1767050000000, diff --git a/internal/adapter/opencode/types.go b/internal/adapter/opencode/types.go index 3af35343..99dc2caf 100644 --- a/internal/adapter/opencode/types.go +++ b/internal/adapter/opencode/types.go @@ -7,12 +7,12 @@ import ( // Project represents an OpenCode project from storage/project/{id}.json. type Project struct { - ID string `json:"id"` - Worktree string `json:"worktree"` - VCS string `json:"vcs,omitempty"` + ID string `json:"id"` + Worktree string `json:"worktree"` + VCS string `json:"vcs,omitempty"` Sandboxes []string `json:"sandboxes,omitempty"` - Time TimeInfo `json:"time"` - Icon *Icon `json:"icon,omitempty"` + Time TimeInfo `json:"time"` + Icon *Icon `json:"icon,omitempty"` } // Icon holds project icon settings. diff --git a/internal/adapter/pi/adapter.go b/internal/adapter/pi/adapter.go index a98eea23..e9fbb185 100644 --- a/internal/adapter/pi/adapter.go +++ b/internal/adapter/pi/adapter.go @@ -5,9 +5,9 @@ import ( "encoding/json" "fmt" "io" + "maps" "os" "path/filepath" - "maps" "sort" "strings" "sync" diff --git a/internal/adapter/pi/adapter_test.go b/internal/adapter/pi/adapter_test.go index c7eefbad..1e45015f 100644 --- a/internal/adapter/pi/adapter_test.go +++ b/internal/adapter/pi/adapter_test.go @@ -737,11 +737,11 @@ func TestDetectSourceChannel(t *testing.T) { func TestExtractSessionMetadata(t *testing.T) { tests := []struct { - name string - msg string - wantCat string - wantCron string - wantChannel string + name string + msg string + wantCat string + wantCron string + wantChannel string }{ { "cron session", @@ -839,9 +839,9 @@ func TestDirectSessionMetadata(t *testing.T) { func TestStripMessagePrefix(t *testing.T) { tests := []struct { - name string - input string - want string + name string + input string + want string }{ { "telegram prefix", diff --git a/internal/adapter/pi/types.go b/internal/adapter/pi/types.go index 99227d66..9a2aaaf6 100644 --- a/internal/adapter/pi/types.go +++ b/internal/adapter/pi/types.go @@ -8,11 +8,11 @@ import ( // RawLine represents any JSONL line from a Pi session file. // Fields are a superset; only relevant fields are populated per line type. type RawLine struct { - Type string `json:"type"` // "session", "message", "model_change", "thinking_level_change", "custom" - ID string `json:"id"` // line identifier - ParentID *string `json:"parentId"` // parent line reference (nullable) - Timestamp time.Time `json:"timestamp"` // line timestamp - Message *MessageContent `json:"message,omitempty"` // populated for type="message" + Type string `json:"type"` // "session", "message", "model_change", "thinking_level_change", "custom" + ID string `json:"id"` // line identifier + ParentID *string `json:"parentId"` // parent line reference (nullable) + Timestamp time.Time `json:"timestamp"` // line timestamp + Message *MessageContent `json:"message,omitempty"` // populated for type="message" // Session header fields (type="session") Version int `json:"version,omitempty"` // session format version @@ -32,16 +32,16 @@ type RawLine struct { // MessageContent holds the message payload for type="message" lines. type MessageContent struct { - Role string `json:"role"` // "user", "assistant", "toolResult" - Content json.RawMessage `json:"content"` // array of ContentBlock - Model string `json:"model,omitempty"` // model ID for assistant messages - Provider string `json:"provider,omitempty"` // e.g. "anthropic" - API string `json:"api,omitempty"` // e.g. "anthropic-messages" - Usage *Usage `json:"usage,omitempty"` // token usage for assistant messages - StopReason string `json:"stopReason,omitempty"` // e.g. "end_turn" - ToolCallID string `json:"toolCallId,omitempty"` // for toolResult: links to toolCall block ID - ToolName string `json:"toolName,omitempty"` // for toolResult: name of the tool - Details *Details `json:"details,omitempty"` // for toolResult: extra info (e.g. diff) + Role string `json:"role"` // "user", "assistant", "toolResult" + Content json.RawMessage `json:"content"` // array of ContentBlock + Model string `json:"model,omitempty"` // model ID for assistant messages + Provider string `json:"provider,omitempty"` // e.g. "anthropic" + API string `json:"api,omitempty"` // e.g. "anthropic-messages" + Usage *Usage `json:"usage,omitempty"` // token usage for assistant messages + StopReason string `json:"stopReason,omitempty"` // e.g. "end_turn" + ToolCallID string `json:"toolCallId,omitempty"` // for toolResult: links to toolCall block ID + ToolName string `json:"toolName,omitempty"` // for toolResult: name of the tool + Details *Details `json:"details,omitempty"` // for toolResult: extra info (e.g. diff) } // Usage tracks token counts and cost for an assistant message. diff --git a/internal/adapter/piagent/adapter_test.go b/internal/adapter/piagent/adapter_test.go index 33d6c077..3c9b5e07 100644 --- a/internal/adapter/piagent/adapter_test.go +++ b/internal/adapter/piagent/adapter_test.go @@ -27,7 +27,7 @@ func TestProjectDirPath(t *testing.T) { a := New() tests := []struct { - input string + input string wantSuffix string }{ {"/home/user/project", "--home-user-project--"}, diff --git a/internal/adapter/pricing/pricing.go b/internal/adapter/pricing/pricing.go index 5c6c3484..41005a50 100644 --- a/internal/adapter/pricing/pricing.go +++ b/internal/adapter/pricing/pricing.go @@ -21,13 +21,13 @@ type modelTier struct { var ( // Version-aware tiers. - tierOpusNew = modelTier{5.0, 25.0} // Opus 4.5+ - tierOpusOld = modelTier{15.0, 75.0} // Opus 3/4/4.1 - tierSonnet = modelTier{3.0, 15.0} // All Sonnet versions - tierHaikuNew = modelTier{1.0, 5.0} // Haiku 4.5+ - tierHaiku35 = modelTier{0.80, 4.0} // Haiku 3.5 - tierHaikuOld = modelTier{0.25, 1.25} // Haiku 3 - tierDefault = tierSonnet // Unknown models + tierOpusNew = modelTier{5.0, 25.0} // Opus 4.5+ + tierOpusOld = modelTier{15.0, 75.0} // Opus 3/4/4.1 + tierSonnet = modelTier{3.0, 15.0} // All Sonnet versions + tierHaikuNew = modelTier{1.0, 5.0} // Haiku 4.5+ + tierHaiku35 = modelTier{0.80, 4.0} // Haiku 3.5 + tierHaikuOld = modelTier{0.25, 1.25} // Haiku 3 + tierDefault = tierSonnet // Unknown models ) // ModelCost calculates cost in dollars for the given model and usage. diff --git a/internal/adapter/search_utils_test.go b/internal/adapter/search_utils_test.go index f22486e4..b81e845d 100644 --- a/internal/adapter/search_utils_test.go +++ b/internal/adapter/search_utils_test.go @@ -243,9 +243,9 @@ func TestSearchMessagesSlice(t *testing.T) { func TestSearchMessagesSlice_MaxResultsAcrossMessages(t *testing.T) { messages := []Message{ - {ID: "m1", Content: "match here match"}, // 2 matches - {ID: "m2", Content: "another match here"}, // 1 match - {ID: "m3", Content: "match match match"}, // 3 matches + {ID: "m1", Content: "match here match"}, // 2 matches + {ID: "m2", Content: "another match here"}, // 1 match + {ID: "m3", Content: "match match match"}, // 3 matches } // Limit to 3 total matches diff --git a/internal/adapter/testutil/fixtures.go b/internal/adapter/testutil/fixtures.go index 016a2edd..4a260ef0 100644 --- a/internal/adapter/testutil/fixtures.go +++ b/internal/adapter/testutil/fixtures.go @@ -9,20 +9,20 @@ import ( // ClaudeCodeMessage represents a JSONL message in Claude Code format. type ClaudeCodeMessage struct { - Type string `json:"type"` - UUID string `json:"uuid"` - SessionID string `json:"sessionId"` - Timestamp time.Time `json:"timestamp"` - Message *ClaudeCodeMsgContent `json:"message,omitempty"` - CWD string `json:"cwd,omitempty"` - Version string `json:"version,omitempty"` + Type string `json:"type"` + UUID string `json:"uuid"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"timestamp"` + Message *ClaudeCodeMsgContent `json:"message,omitempty"` + CWD string `json:"cwd,omitempty"` + Version string `json:"version,omitempty"` } // ClaudeCodeMsgContent holds the actual message content. type ClaudeCodeMsgContent struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - Model string `json:"model,omitempty"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Model string `json:"model,omitempty"` Usage *ClaudeCodeUsage `json:"usage,omitempty"` } diff --git a/internal/adapter/warp/types.go b/internal/adapter/warp/types.go index 1dd2d7f9..cd7bccc0 100644 --- a/internal/adapter/warp/types.go +++ b/internal/adapter/warp/types.go @@ -23,8 +23,8 @@ type AIQueryRow struct { // Format: [{"Query": {...}}] type QueryInput struct { Query struct { - Text string `json:"text"` - Context []QueryContext `json:"context"` + Text string `json:"text"` + Context []QueryContext `json:"context"` ReferencedAttachments json.RawMessage `json:"referenced_attachments"` } `json:"Query"` } @@ -94,30 +94,30 @@ type ConversationData struct { // ConversationUsageMetadata contains usage statistics. type ConversationUsageMetadata struct { - WasSummarized bool `json:"was_summarized"` - ContextWindowUsage float64 `json:"context_window_usage"` - CreditsSpent float64 `json:"credits_spent"` - CreditsSpentLastBlock float64 `json:"credits_spent_for_last_block"` - TokenUsage []TokenUsageItem `json:"token_usage"` - ToolUsageMetadata *ToolUsageMetadata `json:"tool_usage_metadata"` + WasSummarized bool `json:"was_summarized"` + ContextWindowUsage float64 `json:"context_window_usage"` + CreditsSpent float64 `json:"credits_spent"` + CreditsSpentLastBlock float64 `json:"credits_spent_for_last_block"` + TokenUsage []TokenUsageItem `json:"token_usage"` + ToolUsageMetadata *ToolUsageMetadata `json:"tool_usage_metadata"` } // TokenUsageItem contains token usage for a specific model. type TokenUsageItem struct { - ModelID string `json:"model_id"` - WarpTokens int `json:"warp_tokens"` - BYOKTokens int `json:"byok_tokens"` + ModelID string `json:"model_id"` + WarpTokens int `json:"warp_tokens"` + BYOKTokens int `json:"byok_tokens"` WarpTokensByCategory map[string]int `json:"warp_token_usage_by_category"` } // ToolUsageMetadata contains tool call statistics. type ToolUsageMetadata struct { - RunCommand *ToolStats `json:"run_command_stats"` - ReadFiles *ToolStats `json:"read_files_stats"` - Grep *ToolStats `json:"grep_stats"` - FileGlob *ToolStats `json:"file_glob_stats"` - ApplyFileDiff *DiffStats `json:"apply_file_diff_stats"` - ReadShellOutput *ToolStats `json:"read_shell_command_output_stats"` + RunCommand *ToolStats `json:"run_command_stats"` + ReadFiles *ToolStats `json:"read_files_stats"` + Grep *ToolStats `json:"grep_stats"` + FileGlob *ToolStats `json:"file_glob_stats"` + ApplyFileDiff *DiffStats `json:"apply_file_diff_stats"` + ReadShellOutput *ToolStats `json:"read_shell_command_output_stats"` } // ToolStats contains basic tool call counts. @@ -148,9 +148,9 @@ type BlockRow struct { // BlockAIMetadata represents the parsed ai_metadata JSON from blocks. type BlockAIMetadata struct { - ActionID string `json:"action_id"` - ConversationID string `json:"conversation_id"` - ConversationPhase json.RawMessage `json:"conversation_phase"` + ActionID string `json:"action_id"` + ConversationID string `json:"conversation_id"` + ConversationPhase json.RawMessage `json:"conversation_phase"` } // ModelDisplayNames maps Warp model IDs to display names. diff --git a/internal/app/intro.go b/internal/app/intro.go index 37395840..84d6a8bc 100644 --- a/internal/app/intro.go +++ b/internal/app/intro.go @@ -72,13 +72,13 @@ func NewIntroModel(repoName string) IntroModel { // Use theme colors for the varied start colors startColors := []string{ - theme.Colors.Error, // Red - theme.Colors.Secondary, // Blue/Cyan - theme.Colors.Success, // Green - theme.Colors.Primary, // Purple + theme.Colors.Error, // Red + theme.Colors.Secondary, // Blue/Cyan + theme.Colors.Success, // Green + theme.Colors.Primary, // Purple theme.Colors.ButtonHover, // Pink - theme.Colors.Info, // Cyan/Blue - theme.Colors.Accent, // Orange/Amber + theme.Colors.Info, // Cyan/Blue + theme.Colors.Accent, // Orange/Amber } for i, char := range text { @@ -138,8 +138,8 @@ func (m *IntroModel) Update(dt time.Duration) { if !l.ReachedTarget { target = l.OvershootMax speed = 30.0 - - if l.CurrentX >= l.OvershootMax - 0.1 { + + if l.CurrentX >= l.OvershootMax-0.1 { l.ReachedTarget = true } } else { @@ -154,7 +154,7 @@ func (m *IntroModel) Update(dt time.Duration) { if math.Abs(move) > math.Abs(dist) { move = dist } - + // Ensure minimum movement if far away minMove := speed * dt.Seconds() if math.Abs(dist) > 0.1 && math.Abs(move) < minMove { @@ -175,9 +175,9 @@ func (m *IntroModel) Update(dt time.Duration) { l.CurrentColor.B += (l.EndColor.B - l.CurrentColor.B) * colorSpeed // Check if settled - if l.ReachedTarget && - math.Abs(l.TargetX-l.CurrentX) < 0.1 && - math.Abs(l.EndColor.R-l.CurrentColor.R) < 1.0 { + if l.ReachedTarget && + math.Abs(l.TargetX-l.CurrentX) < 0.1 && + math.Abs(l.EndColor.R-l.CurrentColor.R) < 1.0 { // Settled } else { allSettled = false diff --git a/internal/app/intro_test.go b/internal/app/intro_test.go index 54b41293..c6573b22 100644 --- a/internal/app/intro_test.go +++ b/internal/app/intro_test.go @@ -15,11 +15,11 @@ func TestIntroModel_Update(t *testing.T) { // Simulate running for a few seconds // Total duration depends on last letter delay + travel time // Max delay ~ 0.6s. Travel time ~ 1-2s? - + const dt = 16 * time.Millisecond timeout := 5 * time.Second start := time.Now() - + for !m.Done { m.Update(dt) if time.Since(start) > timeout { @@ -30,10 +30,10 @@ func TestIntroModel_Update(t *testing.T) { if !m.Done { t.Error("IntroModel should be done after simulation") } - + // Verify final state // Letters should be at target positions (0, 1, 2...) - + for i, l := range m.Letters { targetX := float64(i) if l.CurrentX < targetX-0.1 || l.CurrentX > targetX+0.1 { diff --git a/internal/app/model.go b/internal/app/model.go index 9747008c..bf05d1dd 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -104,21 +104,21 @@ type Model struct { activeContext string // UI state - width, height int - showHelp bool - helpModal *modal.Modal - helpModalWidth int - helpMouseHandler *mouse.Handler + width, height int + showHelp bool + helpModal *modal.Modal + helpModalWidth int + helpMouseHandler *mouse.Handler showDiagnostics bool diagnosticsModal *modal.Modal diagnosticsModalWidth int diagnosticsMouseHandler *mouse.Handler showClock bool - showPalette bool - showQuitConfirm bool - quitModal *modal.Modal - quitMouseHandler *mouse.Handler - palette palette.Model + showPalette bool + showQuitConfirm bool + quitModal *modal.Modal + quitMouseHandler *mouse.Handler + palette palette.Model // Project switcher modal showProjectSwitcher bool @@ -174,15 +174,15 @@ type Model struct { openInMouseHandler *mouse.Handler // Theme switcher modal - showThemeSwitcher bool - themeSwitcherModal *modal.Modal - themeSwitcherModalWidth int - themeSwitcherMouseHandler *mouse.Handler - themeSwitcherSelectedIdx int - themeSwitcherInput textinput.Model - themeSwitcherFiltered []themeEntry - themeSwitcherOriginal themeEntry // original theme to restore on cancel - themeSwitcherScope string // "global" or "project" + showThemeSwitcher bool + themeSwitcherModal *modal.Modal + themeSwitcherModalWidth int + themeSwitcherMouseHandler *mouse.Handler + themeSwitcherSelectedIdx int + themeSwitcherInput textinput.Model + themeSwitcherFiltered []themeEntry + themeSwitcherOriginal themeEntry // original theme to restore on cancel + themeSwitcherScope string // "global" or "project" // Issue preview - input phase showIssueInput bool @@ -192,11 +192,11 @@ type Model struct { issueInputMouseHandler *mouse.Handler // Issue input auto-complete - issueSearchResults []IssueSearchResult - issueSearchQuery string // last query sent to td search - issueSearchLoading bool - issueSearchCursor int // selected result index (-1 = none/input focused) - issueSearchScrollOffset int // viewport scroll offset for search results + issueSearchResults []IssueSearchResult + issueSearchQuery string // last query sent to td search + issueSearchLoading bool + issueSearchCursor int // selected result index (-1 = none/input focused) + issueSearchScrollOffset int // viewport scroll offset for search results issueSearchIncludeClosed bool // whether to include closed issues in search // Issue preview - preview phase @@ -246,20 +246,20 @@ type Model struct { changelogScrollState *changelogViewState // Shared state for modal closure // Update modal (declarative) - updatePreviewModal *modal.Modal - updatePreviewModalWidth int - updatePreviewMouseHandler *mouse.Handler - updateCompleteModal *modal.Modal - updateCompleteModalWidth int + updatePreviewModal *modal.Modal + updatePreviewModalWidth int + updatePreviewMouseHandler *mouse.Handler + updateCompleteModal *modal.Modal + updateCompleteModalWidth int updateCompleteMouseHandler *mouse.Handler - updateErrorModal *modal.Modal - updateErrorModalWidth int - updateErrorMouseHandler *mouse.Handler - changelogModal *modal.Modal - changelogModalWidth int - changelogMouseHandler *mouse.Handler - changelogRenderedLines []string // Cached rendered changelog lines - changelogMaxVisibleLines int // Max lines visible in viewport + updateErrorModal *modal.Modal + updateErrorModalWidth int + updateErrorMouseHandler *mouse.Handler + changelogModal *modal.Modal + changelogModalWidth int + changelogMouseHandler *mouse.Handler + changelogRenderedLines []string // Cached rendered changelog lines + changelogMaxVisibleLines int // Max lines visible in viewport // Intro animation intro IntroModel @@ -285,16 +285,16 @@ func New(reg *plugin.Registry, km *keymap.Registry, cfg *config.Config, currentV } return Model{ - cfg: cfg, - registry: reg, - keymap: km, - activePlugin: activeIdx, - activeContext: "global", - showClock: cfg.UI.ShowClock, - palette: palette.New(), - ui: ui, - ready: false, - intro: NewIntroModel(repoName), + cfg: cfg, + registry: reg, + keymap: km, + activePlugin: activeIdx, + activeContext: "global", + showClock: cfg.UI.ShowClock, + palette: palette.New(), + ui: ui, + ready: false, + intro: NewIntroModel(repoName), currentVersion: currentVersion, updatePhaseStatus: make(map[UpdatePhase]string), } diff --git a/internal/app/open_in_modal.go b/internal/app/open_in_modal.go index c29272ca..18a99b95 100644 --- a/internal/app/open_in_modal.go +++ b/internal/app/open_in_modal.go @@ -18,8 +18,8 @@ import ( ) const ( - openInItemPrefix = "open-in-item-" - openInMaxVisible = 10 + openInItemPrefix = "open-in-item-" + openInMaxVisible = 10 ) // openInApp represents a known IDE/app that can open the project directory. diff --git a/internal/app/open_in_modal_test.go b/internal/app/open_in_modal_test.go index 79a5755d..f3a01105 100644 --- a/internal/app/open_in_modal_test.go +++ b/internal/app/open_in_modal_test.go @@ -123,8 +123,8 @@ func TestFindLastUsedIndex(t *testing.T) { {"vscode", 0}, {"goland", 1}, {"finder", 2}, - {"unknown", 0}, // fallback to 0 - {"", 0}, // empty string fallback + {"unknown", 0}, // fallback to 0 + {"", 0}, // empty string fallback } for _, tt := range tests { diff --git a/internal/app/update_test.go b/internal/app/update_test.go index e027738f..e7b62e1e 100644 --- a/internal/app/update_test.go +++ b/internal/app/update_test.go @@ -21,9 +21,9 @@ func TestIsGlobalRefreshContext(t *testing.T) { // Contexts where 'r' should be forwarded to plugin // (text input or plugin-specific 'r' binding) - {"td-monitor", false}, // 'r' for mark-review - {"file-browser-tree", false}, // 'r' for rename - {"file-browser-search", false}, // text input + {"td-monitor", false}, // 'r' for mark-review + {"file-browser-tree", false}, // 'r' for rename + {"file-browser-search", false}, // text input {"file-browser-content-search", false}, // text input {"file-browser-quick-open", false}, // text input {"file-browser-file-op", false}, // text input diff --git a/internal/app/worktree_switcher_modal.go b/internal/app/worktree_switcher_modal.go index f7d82e4f..f3dd93f8 100644 --- a/internal/app/worktree_switcher_modal.go +++ b/internal/app/worktree_switcher_modal.go @@ -410,7 +410,6 @@ func (m *Model) switchWorktree(worktreePath string) tea.Cmd { return m.switchProject(worktreePath) } - // refreshWorktreeCache calls GetWorktrees and caches the result for the current WorkDir. func (m *Model) refreshWorktreeCache() { worktrees := GetWorktrees(m.ui.WorkDir) diff --git a/internal/app/worktree_switcher_test.go b/internal/app/worktree_switcher_test.go index eace81e8..1b4c0587 100644 --- a/internal/app/worktree_switcher_test.go +++ b/internal/app/worktree_switcher_test.go @@ -142,74 +142,74 @@ func TestWorktreeStatePersistence(t *testing.T) { // This tests the core decision logic without needing a full Model tests := []struct { - name string - oldWorkDir string - projectPath string - mainRepoPath string // What GetMainWorktreePath would return - savedWorktree string // Previously saved worktree for this repo + name string + oldWorkDir string + projectPath string + mainRepoPath string // What GetMainWorktreePath would return + savedWorktree string // Previously saved worktree for this repo savedWorktreeExists bool - expectedTarget string - description string + expectedTarget string + description string }{ { - name: "switch from worktree to main - should go to main", - oldWorkDir: "/repo/worktrees/feature-a", - projectPath: "/repo/main", - mainRepoPath: "/repo/main", - savedWorktree: "/repo/worktrees/feature-a", // Same as oldWorkDir + name: "switch from worktree to main - should go to main", + oldWorkDir: "/repo/worktrees/feature-a", + projectPath: "/repo/main", + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/feature-a", // Same as oldWorkDir savedWorktreeExists: true, - expectedTarget: "/repo/main", // Should NOT restore back to feature-a - description: "When leaving a worktree to go to main, don't restore back to that worktree", + expectedTarget: "/repo/main", // Should NOT restore back to feature-a + description: "When leaving a worktree to go to main, don't restore back to that worktree", }, { - name: "switch from different project - should restore saved worktree", - oldWorkDir: "/other-project", - projectPath: "/repo/main", - mainRepoPath: "/repo/main", - savedWorktree: "/repo/worktrees/feature-b", + name: "switch from different project - should restore saved worktree", + oldWorkDir: "/other-project", + projectPath: "/repo/main", + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/feature-b", savedWorktreeExists: true, - expectedTarget: "/repo/worktrees/feature-b", // Should restore - description: "When coming from a different project, restore the last worktree", + expectedTarget: "/repo/worktrees/feature-b", // Should restore + description: "When coming from a different project, restore the last worktree", }, { - name: "switch to main with no saved worktree", - oldWorkDir: "/other-project", - projectPath: "/repo/main", - mainRepoPath: "/repo/main", - savedWorktree: "", + name: "switch to main with no saved worktree", + oldWorkDir: "/other-project", + projectPath: "/repo/main", + mainRepoPath: "/repo/main", + savedWorktree: "", savedWorktreeExists: false, - expectedTarget: "/repo/main", - description: "No saved worktree means stay on main", + expectedTarget: "/repo/main", + description: "No saved worktree means stay on main", }, { - name: "switch to main with stale saved worktree", - oldWorkDir: "/other-project", - projectPath: "/repo/main", - mainRepoPath: "/repo/main", - savedWorktree: "/repo/worktrees/deleted-feature", + name: "switch to main with stale saved worktree", + oldWorkDir: "/other-project", + projectPath: "/repo/main", + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/deleted-feature", savedWorktreeExists: false, // Worktree was deleted - expectedTarget: "/repo/main", - description: "Stale worktree entry should be ignored", + expectedTarget: "/repo/main", + description: "Stale worktree entry should be ignored", }, { - name: "explicit worktree selection - should not restore", - oldWorkDir: "/repo/main", - projectPath: "/repo/worktrees/feature-c", // User explicitly chose this - mainRepoPath: "/repo/main", - savedWorktree: "/repo/worktrees/feature-d", // Different saved worktree + name: "explicit worktree selection - should not restore", + oldWorkDir: "/repo/main", + projectPath: "/repo/worktrees/feature-c", // User explicitly chose this + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/feature-d", // Different saved worktree savedWorktreeExists: true, - expectedTarget: "/repo/worktrees/feature-c", // User's explicit choice - description: "When user explicitly selects a worktree, don't redirect to saved one", + expectedTarget: "/repo/worktrees/feature-c", // User's explicit choice + description: "When user explicitly selects a worktree, don't redirect to saved one", }, { - name: "switch between worktrees in same repo", - oldWorkDir: "/repo/worktrees/feature-a", - projectPath: "/repo/worktrees/feature-b", // User explicitly chose this - mainRepoPath: "/repo/main", - savedWorktree: "/repo/worktrees/feature-a", + name: "switch between worktrees in same repo", + oldWorkDir: "/repo/worktrees/feature-a", + projectPath: "/repo/worktrees/feature-b", // User explicitly chose this + mainRepoPath: "/repo/main", + savedWorktree: "/repo/worktrees/feature-a", savedWorktreeExists: true, - expectedTarget: "/repo/worktrees/feature-b", // User's explicit choice - description: "Switching between worktrees should respect explicit selection", + expectedTarget: "/repo/worktrees/feature-b", // User's explicit choice + description: "Switching between worktrees should respect explicit selection", }, } diff --git a/internal/community/colorutil_test.go b/internal/community/colorutil_test.go index 414ff8dc..f4b92d66 100644 --- a/internal/community/colorutil_test.go +++ b/internal/community/colorutil_test.go @@ -7,19 +7,19 @@ import ( func TestHexToHSL(t *testing.T) { tests := []struct { - hex string - wantH float64 - wantS float64 - wantL float64 - tolerance float64 + hex string + wantH float64 + wantS float64 + wantL float64 + tolerance float64 }{ - {"#ff0000", 0, 1.0, 0.5, 1.0}, // pure red - {"#00ff00", 120, 1.0, 0.5, 1.0}, // pure green - {"#0000ff", 240, 1.0, 0.5, 1.0}, // pure blue - {"#ffffff", 0, 0, 1.0, 0.01}, // white - {"#000000", 0, 0, 0, 0.01}, // black - {"#808080", 0, 0, 0.502, 0.01}, // gray - {"#ff8000", 30, 1.0, 0.5, 1.0}, // orange + {"#ff0000", 0, 1.0, 0.5, 1.0}, // pure red + {"#00ff00", 120, 1.0, 0.5, 1.0}, // pure green + {"#0000ff", 240, 1.0, 0.5, 1.0}, // pure blue + {"#ffffff", 0, 0, 1.0, 0.01}, // white + {"#000000", 0, 0, 0, 0.01}, // black + {"#808080", 0, 0, 0.502, 0.01}, // gray + {"#ff8000", 30, 1.0, 0.5, 1.0}, // orange } for _, tt := range tests { @@ -41,11 +41,11 @@ func TestHSLToHex(t *testing.T) { h, s, l float64 want string }{ - {0, 1.0, 0.5, "#ff0000"}, // red - {120, 1.0, 0.5, "#00ff00"}, // green - {240, 1.0, 0.5, "#0000ff"}, // blue - {0, 0, 1.0, "#ffffff"}, // white - {0, 0, 0, "#000000"}, // black + {0, 1.0, 0.5, "#ff0000"}, // red + {120, 1.0, 0.5, "#00ff00"}, // green + {240, 1.0, 0.5, "#0000ff"}, // blue + {0, 0, 1.0, "#ffffff"}, // white + {0, 0, 0, "#000000"}, // black } for _, tt := range tests { diff --git a/internal/config/config.go b/internal/config/config.go index 815fba91..757be733 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -140,7 +140,7 @@ func Default() *Config { Overrides: make(map[string]string), }, UI: UIConfig{ - ShowClock: true, + ShowClock: true, Theme: ThemeConfig{ Name: "default", Overrides: make(map[string]interface{}), diff --git a/internal/config/loader.go b/internal/config/loader.go index dd6bac8e..02bfb7b7 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -74,7 +74,7 @@ type rawPluginsConfig struct { GitStatus rawGitStatusConfig `json:"git-status"` TDMonitor rawTDMonitorConfig `json:"td-monitor"` Conversations rawConversationsConfig `json:"conversations"` - Workspace rawWorkspaceConfig `json:"workspace"` + Workspace rawWorkspaceConfig `json:"workspace"` } type rawWorkspaceConfig struct { diff --git a/internal/config/saver.go b/internal/config/saver.go index 50e12caa..1d69f065 100644 --- a/internal/config/saver.go +++ b/internal/config/saver.go @@ -27,7 +27,7 @@ type savePluginsConfig struct { GitStatus saveGitStatusConfig `json:"git-status,omitempty"` TDMonitor saveTDMonitorConfig `json:"td-monitor,omitempty"` Conversations saveConversationsConfig `json:"conversations,omitempty"` - Workspace saveWorkspaceConfig `json:"workspace,omitempty"` + Workspace saveWorkspaceConfig `json:"workspace,omitempty"` } type saveGitStatusConfig struct { diff --git a/internal/event/event.go b/internal/event/event.go index 0299b8e1..f653c5c5 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -15,9 +15,9 @@ type Type string const ( // File change events - TypeFileChanged Type = "file_changed" - TypeGitChanged Type = "git_changed" - TypeSessionFile Type = "session_file" + TypeFileChanged Type = "file_changed" + TypeGitChanged Type = "git_changed" + TypeSessionFile Type = "session_file" // Data update events TypeTDUpdate Type = "td_update" diff --git a/internal/image/image.go b/internal/image/image.go index a3d9ee35..d0148f1a 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -211,4 +211,3 @@ func (r *Renderer) CacheStats() (entries int, maxEntries int) { func SupportedTerminals() string { return "all terminals with Unicode and true color support" } - diff --git a/internal/keymap/registry.go b/internal/keymap/registry.go index d1ae8b2c..e1be5d69 100644 --- a/internal/keymap/registry.go +++ b/internal/keymap/registry.go @@ -27,9 +27,9 @@ type Binding struct { // Registry manages key bindings and command dispatch. type Registry struct { - commands map[string]Command // ID -> Command + commands map[string]Command // ID -> Command bindings map[string][]Binding // context -> bindings - userOverrides map[string]string // key -> command ID + userOverrides map[string]string // key -> command ID pendingKey string pendingTime time.Time mu sync.RWMutex diff --git a/internal/modal/list.go b/internal/modal/list.go index 2a1efedf..59cae772 100644 --- a/internal/modal/list.go +++ b/internal/modal/list.go @@ -240,4 +240,3 @@ func (s *listSection) Update(msg tea.Msg, focusID string) (string, tea.Cmd) { return "", nil } - diff --git a/internal/mouse/mouse.go b/internal/mouse/mouse.go index 7863bb2f..b90c261a 100644 --- a/internal/mouse/mouse.go +++ b/internal/mouse/mouse.go @@ -97,7 +97,7 @@ func NewHandler() *Handler { // ClickResult represents the result of processing a click event. type ClickResult struct { - Region *Region + Region *Region IsDoubleClick bool } diff --git a/internal/plugin/context.go b/internal/plugin/context.go index f789b33e..c2a09fe4 100644 --- a/internal/plugin/context.go +++ b/internal/plugin/context.go @@ -19,10 +19,10 @@ type Context struct { WorkDir string // Actual working directory (worktree path for linked worktrees) ProjectRoot string // Main repo root for shared state (same as WorkDir for non-worktrees) ConfigDir string - Config *config.Config - Adapters map[string]adapter.Adapter - EventBus *event.Dispatcher - Logger *slog.Logger - Keymap BindingRegistrar // For plugins to register dynamic bindings - Epoch uint64 // Incremented on project switch to invalidate stale async messages + Config *config.Config + Adapters map[string]adapter.Adapter + EventBus *event.Dispatcher + Logger *slog.Logger + Keymap BindingRegistrar // For plugins to register dynamic bindings + Epoch uint64 // Incremented on project switch to invalidate stale async messages } diff --git a/internal/plugin/registry_test.go b/internal/plugin/registry_test.go index 2e10e5e7..9969f4df 100644 --- a/internal/plugin/registry_test.go +++ b/internal/plugin/registry_test.go @@ -9,23 +9,23 @@ import ( // mockPlugin implements Plugin for testing. type mockPlugin struct { - id string - initErr error - initPanic bool - startPanic bool - stopPanic bool - started bool - stopped bool + id string + initErr error + initPanic bool + startPanic bool + stopPanic bool + started bool + stopped bool } -func (m *mockPlugin) ID() string { return m.id } -func (m *mockPlugin) Name() string { return m.id } -func (m *mockPlugin) Icon() string { return "πŸ“¦" } -func (m *mockPlugin) IsFocused() bool { return false } -func (m *mockPlugin) SetFocused(bool) {} -func (m *mockPlugin) Commands() []Command { return nil } -func (m *mockPlugin) FocusContext() string { return m.id } -func (m *mockPlugin) View(w, h int) string { return "" } +func (m *mockPlugin) ID() string { return m.id } +func (m *mockPlugin) Name() string { return m.id } +func (m *mockPlugin) Icon() string { return "πŸ“¦" } +func (m *mockPlugin) IsFocused() bool { return false } +func (m *mockPlugin) SetFocused(bool) {} +func (m *mockPlugin) Commands() []Command { return nil } +func (m *mockPlugin) FocusContext() string { return m.id } +func (m *mockPlugin) View(w, h int) string { return "" } func (m *mockPlugin) Update(msg tea.Msg) (Plugin, tea.Cmd) { return m, nil } func (m *mockPlugin) Init(ctx *Context) error { diff --git a/internal/plugins/conversations/clipboard_test.go b/internal/plugins/conversations/clipboard_test.go index 431ca3ca..02c9bdee 100644 --- a/internal/plugins/conversations/clipboard_test.go +++ b/internal/plugins/conversations/clipboard_test.go @@ -142,5 +142,3 @@ func TestFormatTurnAsMarkdown_WithTokens(t *testing.T) { t.Error("should contain output token count") } } - - diff --git a/internal/plugins/conversations/coalescer_test.go b/internal/plugins/conversations/coalescer_test.go index 0f66d40c..08808c55 100644 --- a/internal/plugins/conversations/coalescer_test.go +++ b/internal/plugins/conversations/coalescer_test.go @@ -263,12 +263,12 @@ func TestEventCoalescer_DynamicWindow(t *testing.T) { expected time.Duration }{ {"0 bytes", 0, 250 * time.Millisecond}, - {"50MB", 50 * 1024 * 1024, 250 * time.Millisecond}, // below 100MB threshold - {"100MB", 100 * 1024 * 1024, 750 * time.Millisecond}, // 250 + 500 - {"200MB", 200 * 1024 * 1024, 1250 * time.Millisecond}, // 250 + 1000 - {"500MB", 500 * 1024 * 1024, 2750 * time.Millisecond}, // 250 + 2500 - {"1GB", 1024 * 1024 * 1024, 5 * time.Second}, // capped at max - {"2GB", 2 * 1024 * 1024 * 1024, 5 * time.Second}, // capped at max + {"50MB", 50 * 1024 * 1024, 250 * time.Millisecond}, // below 100MB threshold + {"100MB", 100 * 1024 * 1024, 750 * time.Millisecond}, // 250 + 500 + {"200MB", 200 * 1024 * 1024, 1250 * time.Millisecond}, // 250 + 1000 + {"500MB", 500 * 1024 * 1024, 2750 * time.Millisecond}, // 250 + 2500 + {"1GB", 1024 * 1024 * 1024, 5 * time.Second}, // capped at max + {"2GB", 2 * 1024 * 1024 * 1024, 5 * time.Second}, // capped at max } for _, tc := range testCases { @@ -287,9 +287,9 @@ func TestEventCoalescer_DynamicWindow(t *testing.T) { c := NewEventCoalescer(250*time.Millisecond, nil) // Mix of session sizes - c.UpdateSessionSize("small", 10*1024*1024) // 0 scale - c.UpdateSessionSize("medium", 150*1024*1024) // 1x scale = +500ms - c.UpdateSessionSize("large", 350*1024*1024) // 3x scale = +1500ms + c.UpdateSessionSize("small", 10*1024*1024) // 0 scale + c.UpdateSessionSize("medium", 150*1024*1024) // 1x scale = +500ms + c.UpdateSessionSize("large", 350*1024*1024) // 3x scale = +1500ms c.pendingIDs["small"] = struct{}{} c.pendingIDs["medium"] = struct{}{} diff --git a/internal/plugins/conversations/content_search.go b/internal/plugins/conversations/content_search.go index f3018125..8d6b08d9 100644 --- a/internal/plugins/conversations/content_search.go +++ b/internal/plugins/conversations/content_search.go @@ -25,9 +25,9 @@ type ContentSearchState struct { // SessionSearchResult represents a session with matching messages. type SessionSearchResult struct { - Session adapter.Session // The session containing matches - Messages []adapter.MessageMatch // Messages with matches (from adapter search) - Collapsed bool // True if session is collapsed in view + Session adapter.Session // The session containing matches + Messages []adapter.MessageMatch // Messages with matches (from adapter search) + Collapsed bool // True if session is collapsed in view } // ContentSearchDebounceMsg is sent after debounce delay to trigger search. @@ -97,7 +97,7 @@ func (s *ContentSearchState) FlatLen() int { count++ // Session row if !sr.Collapsed { for _, mm := range sr.Messages { - count++ // Message row + count++ // Message row count += len(mm.Matches) // Match rows } } diff --git a/internal/plugins/conversations/content_search_exec_test.go b/internal/plugins/conversations/content_search_exec_test.go index 59f378d0..8c041164 100644 --- a/internal/plugins/conversations/content_search_exec_test.go +++ b/internal/plugins/conversations/content_search_exec_test.go @@ -21,14 +21,14 @@ type mockSearchAdapter struct { err error } -func (m *mockSearchAdapter) ID() string { return m.id } -func (m *mockSearchAdapter) Name() string { return "Mock" } -func (m *mockSearchAdapter) Icon() string { return "M" } -func (m *mockSearchAdapter) Detect(string) (bool, error) { return true, nil } -func (m *mockSearchAdapter) Capabilities() adapter.CapabilitySet { return nil } -func (m *mockSearchAdapter) Sessions(string) ([]adapter.Session, error) { return nil, nil } -func (m *mockSearchAdapter) Messages(string) ([]adapter.Message, error) { return nil, nil } -func (m *mockSearchAdapter) Usage(string) (*adapter.UsageStats, error) { return nil, nil } +func (m *mockSearchAdapter) ID() string { return m.id } +func (m *mockSearchAdapter) Name() string { return "Mock" } +func (m *mockSearchAdapter) Icon() string { return "M" } +func (m *mockSearchAdapter) Detect(string) (bool, error) { return true, nil } +func (m *mockSearchAdapter) Capabilities() adapter.CapabilitySet { return nil } +func (m *mockSearchAdapter) Sessions(string) ([]adapter.Session, error) { return nil, nil } +func (m *mockSearchAdapter) Messages(string) ([]adapter.Message, error) { return nil, nil } +func (m *mockSearchAdapter) Usage(string) (*adapter.UsageStats, error) { return nil, nil } func (m *mockSearchAdapter) Watch(string) (<-chan adapter.Event, io.Closer, error) { return nil, nopCloser{}, nil } @@ -48,14 +48,14 @@ type mockNonSearchAdapter struct { id string } -func (m *mockNonSearchAdapter) ID() string { return m.id } -func (m *mockNonSearchAdapter) Name() string { return "NonSearch" } -func (m *mockNonSearchAdapter) Icon() string { return "N" } -func (m *mockNonSearchAdapter) Detect(string) (bool, error) { return true, nil } -func (m *mockNonSearchAdapter) Capabilities() adapter.CapabilitySet { return nil } -func (m *mockNonSearchAdapter) Sessions(string) ([]adapter.Session, error) { return nil, nil } -func (m *mockNonSearchAdapter) Messages(string) ([]adapter.Message, error) { return nil, nil } -func (m *mockNonSearchAdapter) Usage(string) (*adapter.UsageStats, error) { return nil, nil } +func (m *mockNonSearchAdapter) ID() string { return m.id } +func (m *mockNonSearchAdapter) Name() string { return "NonSearch" } +func (m *mockNonSearchAdapter) Icon() string { return "N" } +func (m *mockNonSearchAdapter) Detect(string) (bool, error) { return true, nil } +func (m *mockNonSearchAdapter) Capabilities() adapter.CapabilitySet { return nil } +func (m *mockNonSearchAdapter) Sessions(string) ([]adapter.Session, error) { return nil, nil } +func (m *mockNonSearchAdapter) Messages(string) ([]adapter.Message, error) { return nil, nil } +func (m *mockNonSearchAdapter) Usage(string) (*adapter.UsageStats, error) { return nil, nil } func (m *mockNonSearchAdapter) Watch(string) (<-chan adapter.Event, io.Closer, error) { return nil, nopCloser{}, nil } diff --git a/internal/plugins/conversations/content_search_test.go b/internal/plugins/conversations/content_search_test.go index acaba789..b5feaf7a 100644 --- a/internal/plugins/conversations/content_search_test.go +++ b/internal/plugins/conversations/content_search_test.go @@ -138,10 +138,10 @@ func TestFlatItemWithCollapsed(t *testing.T) { isSess bool isMsg bool }{ - {0, 0, -1, -1, true, false}, // session1 - {1, 1, -1, -1, true, false}, // session2 - {2, 1, 0, -1, false, true}, // session2/msg3 - {3, 1, 0, 0, false, false}, // session2/msg3/match0 + {0, 0, -1, -1, true, false}, // session1 + {1, 1, -1, -1, true, false}, // session2 + {2, 1, 0, -1, false, true}, // session2/msg3 + {3, 1, 0, 0, false, false}, // session2/msg3/match0 {4, -1, -1, -1, false, false}, // out of range } @@ -164,12 +164,12 @@ func TestNextMatchIndex(t *testing.T) { want int desc string }{ - {0, 2, "from session1 to first match"}, // session -> match - {1, 2, "from msg1 to first match"}, // msg -> match - {2, 3, "from match0 to match1"}, // match -> next match - {3, 5, "from match1 to msg2/match0"}, // skip msg header + {0, 2, "from session1 to first match"}, // session -> match + {1, 2, "from msg1 to first match"}, // msg -> match + {2, 3, "from match0 to match1"}, // match -> next match + {3, 5, "from match1 to msg2/match0"}, // skip msg header {5, 8, "from msg2/match0 to session2 match"}, // skip session and msg headers - {8, 2, "wrap around from last match"}, // wrap to first match + {8, 2, "wrap around from last match"}, // wrap to first match } for _, tc := range tests { @@ -559,9 +559,9 @@ func TestRenderSessionHeader(t *testing.T) { func TestRenderMessageHeader(t *testing.T) { msg := adapter.MessageMatch{ - MessageID: "msg1", - Role: "user", - Timestamp: time.Now(), + MessageID: "msg1", + Role: "user", + Timestamp: time.Now(), Matches: []adapter.ContentMatch{ {LineText: "This is a test message"}, }, diff --git a/internal/plugins/conversations/content_search_view.go b/internal/plugins/conversations/content_search_view.go index 44638245..9b7faafb 100644 --- a/internal/plugins/conversations/content_search_view.go +++ b/internal/plugins/conversations/content_search_view.go @@ -15,7 +15,6 @@ import ( "github.com/marcus/sidecar/internal/styles" ) - // renderContentSearchModal renders the content search modal. // This creates a modal with search input, options, results, and stats sections. func renderContentSearchModal(state *ContentSearchState, width, height int) string { @@ -692,4 +691,3 @@ func byteToRuneIndex(s string, byteIdx int) int { } return utf8.RuneCountInString(s[:byteIdx]) } - diff --git a/internal/plugins/conversations/markdown_test.go b/internal/plugins/conversations/markdown_test.go index 2943f835..8d641670 100644 --- a/internal/plugins/conversations/markdown_test.go +++ b/internal/plugins/conversations/markdown_test.go @@ -179,7 +179,7 @@ func TestGlamourRenderer_Concurrent(t *testing.T) { go func(idx int) { defer wg.Done() content := contents[idx%len(contents)] - width := 60 + (idx % 3) * 20 // 60, 80, or 100 + width := 60 + (idx%3)*20 // 60, 80, or 100 lines := r.RenderContent(content, width) if len(lines) == 0 { t.Errorf("Concurrent render %d returned empty", idx) diff --git a/internal/plugins/filebrowser/inline_edit.go b/internal/plugins/filebrowser/inline_edit.go index 67d1278f..eea28c40 100644 --- a/internal/plugins/filebrowser/inline_edit.go +++ b/internal/plugins/filebrowser/inline_edit.go @@ -740,4 +740,3 @@ func (p *Plugin) enterInlineEditModeAtCurrentLine(path string) tea.Cmd { lineNo := p.getCurrentPreviewLine() return p.enterInlineEditMode(path, lineNo) } - diff --git a/internal/plugins/filebrowser/project_search.go b/internal/plugins/filebrowser/project_search.go index 1d766a87..581ebebc 100644 --- a/internal/plugins/filebrowser/project_search.go +++ b/internal/plugins/filebrowser/project_search.go @@ -12,9 +12,9 @@ import ( ) const ( - projectSearchMaxResults = 1000 // Max total matches to display - projectSearchTimeout = 30 * time.Second // Max time for search - projectSearchDebounce = 200 * time.Millisecond // Debounce delay before searching + projectSearchMaxResults = 1000 // Max total matches to display + projectSearchTimeout = 30 * time.Second // Max time for search + projectSearchDebounce = 200 * time.Millisecond // Debounce delay before searching ) // ProjectSearchState holds the state for project-wide search. @@ -55,10 +55,10 @@ type SearchFileResult struct { // SearchMatch represents a single match within a file. type SearchMatch struct { - LineNo int // 1-indexed line number - LineText string // Full line content - ColStart int // Match start column (0-indexed) - ColEnd int // Match end column (0-indexed) + LineNo int // 1-indexed line number + LineText string // Full line content + ColStart int // Match start column (0-indexed) + ColEnd int // Match end column (0-indexed) } // ProjectSearchResultsMsg contains results from a search. @@ -282,11 +282,11 @@ func RunProjectSearch(workDir string, state *ProjectSearchState, epoch uint64) t // buildRipgrepArgs constructs the ripgrep command arguments. func buildRipgrepArgs(state *ProjectSearchState) []string { args := []string{ - "--line-number", // Include line numbers - "--column", // Include column numbers for match position - "--no-heading", // Don't group by file (simpler parsing) - "--with-filename", // Always include filename - "--max-count=100", // Limit matches per file + "--line-number", // Include line numbers + "--column", // Include column numbers for match position + "--no-heading", // Don't group by file (simpler parsing) + "--with-filename", // Always include filename + "--max-count=100", // Limit matches per file "--max-filesize=1M", // Skip very large files } diff --git a/internal/plugins/filebrowser/project_search_test.go b/internal/plugins/filebrowser/project_search_test.go index 5804b18f..5b886351 100644 --- a/internal/plugins/filebrowser/project_search_test.go +++ b/internal/plugins/filebrowser/project_search_test.go @@ -100,16 +100,16 @@ func TestProjectSearchState_FlatItem(t *testing.T) { } tests := []struct { - idx int - wantFileIdx int + idx int + wantFileIdx int wantMatchIdx int - wantIsFile bool + wantIsFile bool }{ - {idx: 0, wantFileIdx: 0, wantMatchIdx: -1, wantIsFile: true}, // a.go header - {idx: 1, wantFileIdx: 0, wantMatchIdx: 0, wantIsFile: false}, // a.go match 1 - {idx: 2, wantFileIdx: 0, wantMatchIdx: 1, wantIsFile: false}, // a.go match 2 - {idx: 3, wantFileIdx: 1, wantMatchIdx: -1, wantIsFile: true}, // b.go header - {idx: 4, wantFileIdx: 1, wantMatchIdx: 0, wantIsFile: false}, // b.go match 1 + {idx: 0, wantFileIdx: 0, wantMatchIdx: -1, wantIsFile: true}, // a.go header + {idx: 1, wantFileIdx: 0, wantMatchIdx: 0, wantIsFile: false}, // a.go match 1 + {idx: 2, wantFileIdx: 0, wantMatchIdx: 1, wantIsFile: false}, // a.go match 2 + {idx: 3, wantFileIdx: 1, wantMatchIdx: -1, wantIsFile: true}, // b.go header + {idx: 4, wantFileIdx: 1, wantMatchIdx: 0, wantIsFile: false}, // b.go match 1 } for _, tc := range tests { @@ -167,11 +167,11 @@ func TestProjectSearchState_GetSelectedFile(t *testing.T) { wantPath string wantLine int }{ - {cursor: 0, wantPath: "a.go", wantLine: 0}, // file header - {cursor: 1, wantPath: "a.go", wantLine: 10}, // first match - {cursor: 2, wantPath: "a.go", wantLine: 20}, // second match - {cursor: 3, wantPath: "b.go", wantLine: 0}, // file header - {cursor: 4, wantPath: "b.go", wantLine: 5}, // match + {cursor: 0, wantPath: "a.go", wantLine: 0}, // file header + {cursor: 1, wantPath: "a.go", wantLine: 10}, // first match + {cursor: 2, wantPath: "a.go", wantLine: 20}, // second match + {cursor: 3, wantPath: "b.go", wantLine: 0}, // file header + {cursor: 4, wantPath: "b.go", wantLine: 5}, // match } for _, tc := range tests { diff --git a/internal/plugins/filebrowser/tabs_test.go b/internal/plugins/filebrowser/tabs_test.go index a8e6884c..84daf12f 100644 --- a/internal/plugins/filebrowser/tabs_test.go +++ b/internal/plugins/filebrowser/tabs_test.go @@ -25,9 +25,9 @@ func createTabTestPlugin(t *testing.T, tmpDir string) *Plugin { files := map[string]string{ "main.go": "package main", "README.md": "# Test", - "src/main.go": "package src", // duplicate filename + "src/main.go": "package src", // duplicate filename "src/helper.go": "package src", - "lib/helper.go": "package lib", // duplicate filename + "lib/helper.go": "package lib", // duplicate filename "pkg/util/util.go": "package util", } for path, content := range files { diff --git a/internal/plugins/filebrowser/view_blame.go b/internal/plugins/filebrowser/view_blame.go index fcfdc0f1..25716b9f 100644 --- a/internal/plugins/filebrowser/view_blame.go +++ b/internal/plugins/filebrowser/view_blame.go @@ -79,10 +79,14 @@ func (p *Plugin) ensureBlameModal() { modal.WithHints(false), ). AddSection(p.blameHeaderSection()). - AddSection(modal.When(func() bool { return !p.blameState.IsLoading && p.blameState.Error == nil && len(p.blameState.Lines) > 0 }, p.blameContentSection(resultsHeight))). + AddSection(modal.When(func() bool { + return !p.blameState.IsLoading && p.blameState.Error == nil && len(p.blameState.Lines) > 0 + }, p.blameContentSection(resultsHeight))). AddSection(modal.When(func() bool { return p.blameState.IsLoading }, p.blameLoadingSection())). AddSection(modal.When(func() bool { return p.blameState.Error != nil }, p.blameErrorSection())). - AddSection(modal.When(func() bool { return !p.blameState.IsLoading && p.blameState.Error == nil && len(p.blameState.Lines) == 0 }, p.blameEmptySection())) + AddSection(modal.When(func() bool { + return !p.blameState.IsLoading && p.blameState.Error == nil && len(p.blameState.Lines) == 0 + }, p.blameEmptySection())) } // blameHeaderSection is intentionally empty - title is in modal header diff --git a/internal/plugins/filebrowser/watcher.go b/internal/plugins/filebrowser/watcher.go index 9e2e9299..90c43fa5 100644 --- a/internal/plugins/filebrowser/watcher.go +++ b/internal/plugins/filebrowser/watcher.go @@ -11,13 +11,13 @@ import ( // Watcher monitors a single file for changes. // Only watches the currently previewed file, not the entire directory tree. type Watcher struct { - fsWatcher *fsnotify.Watcher - watchedFile string // Currently watched file (absolute path) - events chan struct{} - stop chan struct{} - debounce *time.Timer - mu sync.Mutex - closed bool + fsWatcher *fsnotify.Watcher + watchedFile string // Currently watched file (absolute path) + events chan struct{} + stop chan struct{} + debounce *time.Timer + mu sync.Mutex + closed bool } // NewWatcher creates a file watcher. Does not start watching anything until WatchFile is called. diff --git a/internal/plugins/gitstatus/commit_list_test.go b/internal/plugins/gitstatus/commit_list_test.go index 69c25472..d1661c36 100644 --- a/internal/plugins/gitstatus/commit_list_test.go +++ b/internal/plugins/gitstatus/commit_list_test.go @@ -51,9 +51,9 @@ func TestMergeRecentCommitsAddsNewHead(t *testing.T) { func TestClampCommitScroll(t *testing.T) { p := &Plugin{ - tree: &FileTree{}, - height: 10, - recentCommits: makeCommits("c", 5), + tree: &FileTree{}, + height: 10, + recentCommits: makeCommits("c", 5), commitScrollOff: 10, } diff --git a/internal/plugins/gitstatus/data_loaders.go b/internal/plugins/gitstatus/data_loaders.go index e72d41cd..e8d2c247 100644 --- a/internal/plugins/gitstatus/data_loaders.go +++ b/internal/plugins/gitstatus/data_loaders.go @@ -80,7 +80,6 @@ func (p *Plugin) loadMoreCommits() tea.Cmd { } } - // loadFilteredCommits fetches commits with current filter options. func (p *Plugin) loadFilteredCommits() tea.Cmd { epoch := p.ctx.Epoch @@ -145,7 +144,6 @@ func (p *Plugin) loadCommitFileDiff(hash, path, parentHash string) tea.Cmd { } } - // loadCommitDetailForPreview loads commit detail for inline preview. func (p *Plugin) loadCommitDetailForPreview(hash string) tea.Cmd { epoch := p.ctx.Epoch diff --git a/internal/plugins/gitstatus/diff_parser.go b/internal/plugins/gitstatus/diff_parser.go index 02944246..ca854f2c 100644 --- a/internal/plugins/gitstatus/diff_parser.go +++ b/internal/plugins/gitstatus/diff_parser.go @@ -50,11 +50,11 @@ type ParsedDiff struct { // FileDiffInfo holds a parsed diff with rendering position info. type FileDiffInfo struct { - Diff *ParsedDiff - StartLine int // Line position where this file starts in rendered output - EndLine int // Line position where this file ends - Additions int // Number of added lines - Deletions int // Number of deleted lines + Diff *ParsedDiff + StartLine int // Line position where this file starts in rendered output + EndLine int // Line position where this file ends + Additions int // Number of added lines + Deletions int // Number of deleted lines } // MultiFileDiff holds multiple file diffs with navigation info. diff --git a/internal/plugins/gitstatus/diff_renderer.go b/internal/plugins/gitstatus/diff_renderer.go index ba31e68e..a38ece03 100644 --- a/internal/plugins/gitstatus/diff_renderer.go +++ b/internal/plugins/gitstatus/diff_renderer.go @@ -12,7 +12,7 @@ import ( type DiffViewMode int const ( - DiffViewUnified DiffViewMode = iota // Line-by-line unified view + DiffViewUnified DiffViewMode = iota // Line-by-line unified view DiffViewSideBySide // Side-by-side split view ) diff --git a/internal/plugins/gitstatus/diff_renderer_test.go b/internal/plugins/gitstatus/diff_renderer_test.go index 5c65779d..754ee1cc 100644 --- a/internal/plugins/gitstatus/diff_renderer_test.go +++ b/internal/plugins/gitstatus/diff_renderer_test.go @@ -439,11 +439,11 @@ func TestRenderLineDiff_WithWrapEnabled(t *testing.T) { // Without wrap - should truncate resultNoWrap := RenderLineDiff(diff, 80, 0, 20, 0, nil, false) linesNoWrap := strings.Split(strings.TrimSpace(resultNoWrap), "\n") - + // With wrap - should create multiple lines resultWrap := RenderLineDiff(diff, 80, 0, 20, 0, nil, true) linesWrap := strings.Split(strings.TrimSpace(resultWrap), "\n") - + // Wrapped version should have more lines if len(linesWrap) <= len(linesNoWrap) { t.Errorf("wrapped output should have more lines: got %d vs %d", len(linesWrap), len(linesNoWrap)) @@ -473,7 +473,7 @@ func TestRenderLineDiff_WrapWithEmptyLines(t *testing.T) { if result == "" { t.Error("expected non-empty result with wrap enabled") } - + // Should handle empty lines gracefully if !strings.Contains(result, "short") { t.Error("should contain short line") @@ -483,7 +483,7 @@ func TestRenderLineDiff_WrapWithEmptyLines(t *testing.T) { func TestRenderSideBySide_WithWrapEnabled(t *testing.T) { longOld := strings.Repeat("a", 150) longNew := strings.Repeat("b", 150) - + diff := &ParsedDiff{ OldFile: "test.go", NewFile: "test.go", @@ -504,11 +504,11 @@ func TestRenderSideBySide_WithWrapEnabled(t *testing.T) { // Without wrap resultNoWrap := RenderSideBySide(diff, 120, 0, 20, 0, nil, false) linesNoWrap := strings.Split(strings.TrimSpace(resultNoWrap), "\n") - + // With wrap resultWrap := RenderSideBySide(diff, 120, 0, 20, 0, nil, true) linesWrap := strings.Split(strings.TrimSpace(resultWrap), "\n") - + // Wrapped version should have more lines if len(linesWrap) <= len(linesNoWrap) { t.Errorf("wrapped side-by-side should have more lines: got %d vs %d", len(linesWrap), len(linesNoWrap)) @@ -536,7 +536,7 @@ func TestRenderLineDiff_WrapVeryLongLine(t *testing.T) { result := RenderLineDiff(diff, 80, 0, 50, 0, nil, true) lines := strings.Split(strings.TrimSpace(result), "\n") - + // Should wrap into many lines if len(lines) < 10 { t.Errorf("expected at least 10 wrapped lines for 1500 char content, got %d", len(lines)) diff --git a/internal/plugins/gitstatus/graph_test.go b/internal/plugins/gitstatus/graph_test.go index b1b8c046..5a397bf0 100644 --- a/internal/plugins/gitstatus/graph_test.go +++ b/internal/plugins/gitstatus/graph_test.go @@ -183,12 +183,12 @@ func TestComputeGraph_MergeShowsBranch(t *testing.T) { // // Linear commits -> merge -> two branches commits := []*Commit{ - {Hash: "c1", ParentHashes: []string{"c2"}}, // linear - {Hash: "c2", ParentHashes: []string{"merge"}}, // linear + {Hash: "c1", ParentHashes: []string{"c2"}}, // linear + {Hash: "c2", ParentHashes: []string{"merge"}}, // linear {Hash: "merge", ParentHashes: []string{"p1", "p2"}, IsMerge: true}, // merge with 2 parents - {Hash: "p1", ParentHashes: []string{"base"}}, // first parent branch - {Hash: "p2", ParentHashes: []string{"base"}}, // second parent branch - {Hash: "base", ParentHashes: []string{}}, // root + {Hash: "p1", ParentHashes: []string{"base"}}, // first parent branch + {Hash: "p2", ParentHashes: []string{"base"}}, // second parent branch + {Hash: "base", ParentHashes: []string{}}, // root } lines := ComputeGraphForCommits(commits) diff --git a/internal/plugins/gitstatus/history_search_test.go b/internal/plugins/gitstatus/history_search_test.go index 71a1b14a..7c01269f 100644 --- a/internal/plugins/gitstatus/history_search_test.go +++ b/internal/plugins/gitstatus/history_search_test.go @@ -29,21 +29,21 @@ func TestSearchCommits(t *testing.T) { wantSubject string // Check first match subject if count > 0 }{ { - name: "Empty query", - query: "", - wantCount: 0, + name: "Empty query", + query: "", + wantCount: 0, }, { - name: "Simple match subject", - query: "feature", - wantCount: 1, - wantSubject: "Add new feature", + name: "Simple match subject", + query: "feature", + wantCount: 1, + wantSubject: "Add new feature", }, { - name: "Simple match author", - query: "Charlie", - wantCount: 1, - wantSubject: "Update documentation", + name: "Simple match author", + query: "Charlie", + wantCount: 1, + wantSubject: "Update documentation", }, { name: "Case insensitive match", @@ -66,24 +66,24 @@ func TestSearchCommits(t *testing.T) { wantSubject: "Fix bug in parser", }, { - name: "Regex match", - query: "Fix.*", - useRegex: true, - wantCount: 2, - wantSubject: "Fix bug in parser", + name: "Regex match", + query: "Fix.*", + useRegex: true, + wantCount: 2, + wantSubject: "Fix bug in parser", }, { - name: "Regex match author", - query: "^Bob$", - useRegex: true, - wantCount: 2, - wantSubject: "Add new feature", + name: "Regex match author", + query: "^Bob$", + useRegex: true, + wantCount: 2, + wantSubject: "Add new feature", }, { - name: "Invalid regex", - query: "[", - useRegex: true, - wantCount: 0, + name: "Invalid regex", + query: "[", + useRegex: true, + wantCount: 0, }, } diff --git a/internal/plugins/gitstatus/preview_selection.go b/internal/plugins/gitstatus/preview_selection.go index c795ebcf..280ba0b9 100644 --- a/internal/plugins/gitstatus/preview_selection.go +++ b/internal/plugins/gitstatus/preview_selection.go @@ -47,7 +47,6 @@ func (p *Plugin) selectedCommitIndex() int { return p.cursor - len(entries) } - // autoLoadDiff triggers loading the diff for the currently selected file or folder. func (p *Plugin) autoLoadDiff() tea.Cmd { entries := p.tree.AllEntries() diff --git a/internal/plugins/gitstatus/push.go b/internal/plugins/gitstatus/push.go index 925c6e65..9582536a 100644 --- a/internal/plugins/gitstatus/push.go +++ b/internal/plugins/gitstatus/push.go @@ -10,13 +10,13 @@ import ( // PushStatus represents the push state of the current branch. type PushStatus struct { - HasUpstream bool // Whether an upstream branch is configured - UpstreamBranch string // Name of upstream branch (e.g., "origin/main") - Ahead int // Commits ahead of upstream - Behind int // Commits behind upstream + HasUpstream bool // Whether an upstream branch is configured + UpstreamBranch string // Name of upstream branch (e.g., "origin/main") + Ahead int // Commits ahead of upstream + Behind int // Commits behind upstream UnpushedHashes []string // Hashes of unpushed commits - DetachedHead bool // Whether HEAD is detached - CurrentBranch string // Current branch name (empty if detached) + DetachedHead bool // Whether HEAD is detached + CurrentBranch string // Current branch name (empty if detached) } // GetPushStatus retrieves the push status for the current branch. diff --git a/internal/plugins/gitstatus/scroll_layout.go b/internal/plugins/gitstatus/scroll_layout.go index 635bf676..11750cb6 100644 --- a/internal/plugins/gitstatus/scroll_layout.go +++ b/internal/plugins/gitstatus/scroll_layout.go @@ -4,7 +4,6 @@ import ( tea "github.com/charmbracelet/bubbletea" ) - // ensurePreviewCursorVisible adjusts scroll to keep commit preview cursor visible. func (p *Plugin) ensurePreviewCursorVisible() { // Estimate visible file rows (rough - matches renderCommitPreview calculation) @@ -67,7 +66,6 @@ func (p *Plugin) clampCommitScroll() { } } - func (p *Plugin) ensureCommitListFilled() tea.Cmd { if p.historyFilterActive || p.loadingMoreCommits || !p.moreCommitsAvailable { return nil diff --git a/internal/plugins/gitstatus/trunc_cache.go b/internal/plugins/gitstatus/trunc_cache.go index 81372a5b..309ec572 100644 --- a/internal/plugins/gitstatus/trunc_cache.go +++ b/internal/plugins/gitstatus/trunc_cache.go @@ -25,4 +25,4 @@ func truncateLeftCached(s string, offset int) string { return truncCache.TruncateLeft(s, offset, "") } -func clearTruncCache() { truncCache.Clear() } \ No newline at end of file +func clearTruncCache() { truncCache.Clear() } diff --git a/internal/plugins/gitstatus/update_handlers.go b/internal/plugins/gitstatus/update_handlers.go index 8687ebc6..4b4589e4 100644 --- a/internal/plugins/gitstatus/update_handlers.go +++ b/internal/plugins/gitstatus/update_handlers.go @@ -1013,7 +1013,6 @@ func (p *Plugin) executePushMenuAction(idx int) (plugin.Plugin, tea.Cmd) { return p, nil } - // updateConfirmDiscard handles key events in the confirm discard modal. func (p *Plugin) updateConfirmDiscard(msg tea.KeyMsg) (plugin.Plugin, tea.Cmd) { if p.discardModal == nil { diff --git a/internal/plugins/gitstatus/view.go b/internal/plugins/gitstatus/view.go index 26de99ac..da53db24 100644 --- a/internal/plugins/gitstatus/view.go +++ b/internal/plugins/gitstatus/view.go @@ -8,7 +8,6 @@ import ( "github.com/marcus/sidecar/internal/ui" ) - // renderDiffModal renders the diff modal with panel border. func (p *Plugin) renderDiffModal() string { // Calculate dimensions accounting for panel border (2) + padding (2) diff --git a/internal/plugins/tdmonitor/plugin.go b/internal/plugins/tdmonitor/plugin.go index 5d868a2c..bdd15931 100644 --- a/internal/plugins/tdmonitor/plugin.go +++ b/internal/plugins/tdmonitor/plugin.go @@ -7,12 +7,12 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/marcus/td/pkg/monitor" "github.com/marcus/sidecar/internal/app" "github.com/marcus/sidecar/internal/plugin" "github.com/marcus/sidecar/internal/plugins/workspace" "github.com/marcus/sidecar/internal/styles" "github.com/marcus/sidecar/internal/tdroot" + "github.com/marcus/td/pkg/monitor" ) const ( diff --git a/internal/plugins/workspace/agent.go b/internal/plugins/workspace/agent.go index 7b0e88d7..548f25e5 100644 --- a/internal/plugins/workspace/agent.go +++ b/internal/plugins/workspace/agent.go @@ -236,9 +236,9 @@ const ( // Runaway detection thresholds (td-018f25) // Detect sessions producing continuous output and throttle them to reduce CPU usage. - runawayPollCount = 20 // Number of polls to track - runawayTimeWindow = 3 * time.Second // If 20 polls happen within this window = runaway - runawayResetCount = 3 // Consecutive unchanged polls to reset throttle + runawayPollCount = 20 // Number of polls to track + runawayTimeWindow = 3 * time.Second // If 20 polls happen within this window = runaway + runawayResetCount = 3 // Consecutive unchanged polls to reset throttle ) // AgentStartedMsg signals an agent has been started in a worktree. @@ -258,27 +258,27 @@ func (m AgentStartedMsg) GetEpoch() uint64 { return m.Epoch } // ApproveResultMsg signals the result of an approve action. type ApproveResultMsg struct { WorkspaceName string - Err error + Err error } // RejectResultMsg signals the result of a reject action. type RejectResultMsg struct { WorkspaceName string - Err error + Err error } // SendTextResultMsg signals the result of sending text to an agent. type SendTextResultMsg struct { WorkspaceName string - Text string - Err error + Text string + Err error } // pollAgentMsg triggers output polling for a worktree's agent. // Includes generation for timer leak prevention (td-83dc22). type pollAgentMsg struct { WorkspaceName string - Generation int // Generation at time of scheduling; ignore if stale + Generation int // Generation at time of scheduling; ignore if stale } // reconnectedAgentsMsg delivers reconnected agents from startup. @@ -748,7 +748,7 @@ func (p *Plugin) scheduleInteractivePoll(worktreeName string, delay time.Duratio // AgentPollUnchangedMsg signals content unchanged, schedule next poll. type AgentPollUnchangedMsg struct { - WorkspaceName string + WorkspaceName string CurrentStatus WorktreeStatus // Status including session file re-check WaitingFor string // Prompt text if waiting // Cursor position captured atomically (even when content unchanged) @@ -892,7 +892,7 @@ func (p *Plugin) handlePollAgent(worktreeName string) tea.Cmd { if !outputChanged { return AgentPollUnchangedMsg{ - WorkspaceName: worktreeName, + WorkspaceName: worktreeName, CurrentStatus: status, WaitingFor: waitingFor, CursorRow: cursorRow, @@ -905,7 +905,7 @@ func (p *Plugin) handlePollAgent(worktreeName string) tea.Cmd { } return AgentOutputMsg{ - WorkspaceName: worktreeName, + WorkspaceName: worktreeName, Output: output, Status: status, WaitingFor: waitingFor, @@ -1255,7 +1255,7 @@ func (p *Plugin) Approve(wt *Worktree) tea.Cmd { return ApproveResultMsg{ WorkspaceName: wt.Name, - Err: err, + Err: err, } } } @@ -1272,7 +1272,7 @@ func (p *Plugin) Reject(wt *Worktree) tea.Cmd { return RejectResultMsg{ WorkspaceName: wt.Name, - Err: err, + Err: err, } } } @@ -1312,8 +1312,8 @@ func (p *Plugin) SendText(wt *Worktree, text string) tea.Cmd { return SendTextResultMsg{ WorkspaceName: wt.Name, - Text: text, - Err: err, + Text: text, + Err: err, } } } diff --git a/internal/plugins/workspace/agent_session.go b/internal/plugins/workspace/agent_session.go index 00602ed2..f973fb20 100644 --- a/internal/plugins/workspace/agent_session.go +++ b/internal/plugins/workspace/agent_session.go @@ -941,4 +941,3 @@ func getOpenCodeLastMessageStatus(storageDir, sessionID string) (WorktreeStatus, return 0, false } } - diff --git a/internal/plugins/workspace/agent_test.go b/internal/plugins/workspace/agent_test.go index 7807c308..b6edca36 100644 --- a/internal/plugins/workspace/agent_test.go +++ b/internal/plugins/workspace/agent_test.go @@ -314,7 +314,6 @@ func TestExtractPrompt(t *testing.T) { } } - func TestDetectStatusPriorityOrder(t *testing.T) { // Waiting should take priority over error when both patterns present output := "Error occurred\nRetry? [y/n]" @@ -474,12 +473,12 @@ func TestShouldShowSkipPermissions(t *testing.T) { func TestBuildAgentCommand(t *testing.T) { tests := []struct { - name string - agentType AgentType - skipPerms bool - taskID string - wantFlag string // Expected skip-perms flag in output - wantPrompt bool // Whether prompt should be included + name string + agentType AgentType + skipPerms bool + taskID string + wantFlag string // Expected skip-perms flag in output + wantPrompt bool // Whether prompt should be included }{ // Claude tests { diff --git a/internal/plugins/workspace/commands.go b/internal/plugins/workspace/commands.go index 13acc62c..14cad072 100644 --- a/internal/plugins/workspace/commands.go +++ b/internal/plugins/workspace/commands.go @@ -177,13 +177,13 @@ func (p *Plugin) Commands() []plugin.Command { if shell == nil || shell.Agent == nil { cmds = append(cmds, plugin.Command{ID: "attach-shell", Name: "Attach", Description: "Create and attach to shell", Context: "workspace-list", Priority: 10}, - plugin.Command{ID: "rename-shell", Name: "Rename", Description: "Rename shell", Context: "workspace-list", Priority: 11}, + plugin.Command{ID: "rename-shell", Name: "Rename", Description: "Rename shell", Context: "workspace-list", Priority: 11}, ) } else { cmds = append(cmds, plugin.Command{ID: "attach-shell", Name: "Attach", Description: "Attach to shell", Context: "workspace-list", Priority: 10}, plugin.Command{ID: "kill-shell", Name: "Kill", Description: "Kill shell session", Context: "workspace-list", Priority: 11}, - plugin.Command{ID: "rename-shell", Name: "Rename", Description: "Rename shell", Context: "workspace-list", Priority: 12}, + plugin.Command{ID: "rename-shell", Name: "Rename", Description: "Rename shell", Context: "workspace-list", Priority: 12}, ) } return cmds diff --git a/internal/plugins/workspace/create_modal.go b/internal/plugins/workspace/create_modal.go index 41d1b4d0..8fec0b43 100644 --- a/internal/plugins/workspace/create_modal.go +++ b/internal/plugins/workspace/create_modal.go @@ -12,17 +12,17 @@ import ( ) const ( - createNameFieldID = "create-name" - createBaseFieldID = "create-base" - createPromptFieldID = "create-prompt" - createTaskFieldID = "create-task" - createAgentListID = "create-agent-list" - createSkipPermissionsID = "create-skip-permissions" - createSubmitID = "create-submit" - createCancelID = "create-cancel" - createBranchItemPrefix = "create-branch-" - createTaskItemPrefix = "create-task-item-" - createAgentItemPrefix = "create-agent-" + createNameFieldID = "create-name" + createBaseFieldID = "create-base" + createPromptFieldID = "create-prompt" + createTaskFieldID = "create-task" + createAgentListID = "create-agent-list" + createSkipPermissionsID = "create-skip-permissions" + createSubmitID = "create-submit" + createCancelID = "create-cancel" + createBranchItemPrefix = "create-branch-" + createTaskItemPrefix = "create-task-item-" + createAgentItemPrefix = "create-agent-" ) func createIndexedID(prefix string, idx int) string { diff --git a/internal/plugins/workspace/diff_test.go b/internal/plugins/workspace/diff_test.go index 37412423..88f4c57e 100644 --- a/internal/plugins/workspace/diff_test.go +++ b/internal/plugins/workspace/diff_test.go @@ -11,9 +11,9 @@ import ( func TestMergeBaseHashValidation(t *testing.T) { // Test the hash validation logic used in getDiffFromBase tests := []struct { - name string - mbOutput string - shouldUse bool // Should use merge-base hash + name string + mbOutput string + shouldUse bool // Should use merge-base hash }{ { name: "valid sha", @@ -101,7 +101,7 @@ func TestGetUnpushedCommits_InvalidRemote(t *testing.T) { func TestGetUnpushedCommits_Integration(t *testing.T) { tmpDir := t.TempDir() - + // Initialize git repo run := func(args ...string) { cmd := exec.Command("git", args...) @@ -110,11 +110,11 @@ func TestGetUnpushedCommits_Integration(t *testing.T) { t.Fatalf("git %v failed: %v", args, err) } } - + run("init") run("config", "user.email", "test@test.com") run("config", "user.name", "Test") - + // Create initial commit testFile := filepath.Join(tmpDir, "test.txt") if err := os.WriteFile(testFile, []byte("initial"), 0644); err != nil { @@ -122,10 +122,10 @@ func TestGetUnpushedCommits_Integration(t *testing.T) { } run("add", "test.txt") run("commit", "-m", "initial") - + // Create a "remote" branch pointing to current commit run("branch", "origin/main") - + // Create unpushed commits for i := 1; i <= 3; i++ { content := []byte(strings.Repeat("x", i)) @@ -135,7 +135,7 @@ func TestGetUnpushedCommits_Integration(t *testing.T) { run("add", "test.txt") run("commit", "-m", "commit") } - + // Get unpushed commits unpushed := getUnpushedCommits(tmpDir, "origin/main") if unpushed == nil { @@ -148,25 +148,25 @@ func TestGetUnpushedCommits_Integration(t *testing.T) { func TestGetUnpushedCommits_AllPushed(t *testing.T) { tmpDir := t.TempDir() - + run := func(args ...string) { cmd := exec.Command("git", args...) cmd.Dir = tmpDir _ = cmd.Run() } - + run("init") run("config", "user.email", "test@test.com") run("config", "user.name", "Test") - + testFile := filepath.Join(tmpDir, "test.txt") _ = os.WriteFile(testFile, []byte("content"), 0644) run("add", "test.txt") run("commit", "-m", "commit") - + // Remote branch points to HEAD (all pushed) run("branch", "origin/main") - + unpushed := getUnpushedCommits(tmpDir, "origin/main") if unpushed == nil { t.Fatal("expected empty map, got nil") @@ -178,7 +178,7 @@ func TestGetUnpushedCommits_AllPushed(t *testing.T) { func TestGetWorktreeCommits_Integration(t *testing.T) { tmpDir := t.TempDir() - + run := func(args ...string) error { cmd := exec.Command("git", args...) cmd.Dir = tmpDir @@ -205,17 +205,17 @@ func TestGetWorktreeCommits_Integration(t *testing.T) { _ = run("add", "test.txt") _ = run("commit", "-m", "feature commit") } - + // Test: get commits comparing to main commits, err := getWorktreeCommits(tmpDir, "main") if err != nil { t.Fatalf("getWorktreeCommits failed: %v", err) } - + if len(commits) != 2 { t.Errorf("expected 2 commits, got %d", len(commits)) } - + // All commits should be marked as not pushed (no remote tracking) for _, c := range commits { if c.Pushed { @@ -226,17 +226,17 @@ func TestGetWorktreeCommits_Integration(t *testing.T) { func TestGetWorktreeCommits_WithRemoteTracking(t *testing.T) { tmpDir := t.TempDir() - + run := func(args ...string) { cmd := exec.Command("git", args...) cmd.Dir = tmpDir _ = cmd.Run() } - + run("init") run("config", "user.email", "test@test.com") run("config", "user.name", "Test") - + testFile := filepath.Join(tmpDir, "test.txt") _ = os.WriteFile(testFile, []byte("initial"), 0644) run("add", "test.txt") @@ -244,7 +244,7 @@ func TestGetWorktreeCommits_WithRemoteTracking(t *testing.T) { run("branch", "-M", "main") run("checkout", "-b", "feature") - + // Create commits _ = os.WriteFile(testFile, []byte("x"), 0644) run("add", "test.txt") @@ -253,16 +253,16 @@ func TestGetWorktreeCommits_WithRemoteTracking(t *testing.T) { _ = os.WriteFile(testFile, []byte("xx"), 0644) run("add", "test.txt") run("commit", "-m", "commit2") - + commits, err := getWorktreeCommits(tmpDir, "main") if err != nil { t.Fatalf("getWorktreeCommits failed: %v", err) } - + if len(commits) != 2 { t.Errorf("expected 2 commits, got %d", len(commits)) } - + // Without remote tracking, all commits should be marked as not pushed for _, c := range commits { if c.Pushed { diff --git a/internal/plugins/workspace/interactive_selection_test.go b/internal/plugins/workspace/interactive_selection_test.go index c3600bc1..ee37ae43 100644 --- a/internal/plugins/workspace/interactive_selection_test.go +++ b/internal/plugins/workspace/interactive_selection_test.go @@ -349,11 +349,11 @@ func TestGetLineSelectionCols(t *testing.T) { p := newSelectionTestPlugin() tests := []struct { - name string - start, end ui.SelectionPoint - lineIdx int - expectStart int - expectEnd int + name string + start, end ui.SelectionPoint + lineIdx int + expectStart int + expectEnd int }{ { "line before selection", diff --git a/internal/plugins/workspace/interactive_test.go b/internal/plugins/workspace/interactive_test.go index b27994e0..6445d5cd 100644 --- a/internal/plugins/workspace/interactive_test.go +++ b/internal/plugins/workspace/interactive_test.go @@ -1046,10 +1046,10 @@ func TestHandleInteractiveKeys_CancelsPendingEscapeForMouseSequence(t *testing.T p := &Plugin{ viewMode: ViewModeInteractive, interactiveState: &InteractiveState{ - Active: true, - TargetSession: "test-session", - EscapePressed: true, // ESC arrived first (split-read) - EscapeTime: time.Now(), + Active: true, + TargetSession: "test-session", + EscapePressed: true, // ESC arrived first (split-read) + EscapeTime: time.Now(), }, } diff --git a/internal/plugins/workspace/keys.go b/internal/plugins/workspace/keys.go index 5009cc38..bdf504a4 100644 --- a/internal/plugins/workspace/keys.go +++ b/internal/plugins/workspace/keys.go @@ -493,7 +493,7 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { if p.previewOffset > 0 { p.previewOffset-- if p.previewOffset == 0 { - p.autoScrollOutput = true // Resume auto-scroll when at bottom + p.autoScrollOutput = true // Resume auto-scroll when at bottom p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot } } @@ -538,7 +538,7 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { // Go to top (oldest content) - pause auto-scroll p.autoScrollOutput = false p.captureScrollBaseLineCount() // td-f7c8be: prevent bounce on poll - p.previewOffset = math.MaxInt // Will be clamped in render + p.previewOffset = math.MaxInt // Will be clamped in render case "G": if p.viewMode == ViewModeKanban { // Kanban mode: jump cursor to bottom of current column @@ -576,8 +576,8 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { p.typeSelectorNameInput.Prompt = "" p.typeSelectorNameInput.Width = 30 p.typeSelectorNameInput.CharLimit = 50 - p.typeSelectorModal = nil // Force rebuild - p.typeSelectorModalWidth = 0 // Force rebuild + p.typeSelectorModal = nil // Force rebuild + p.typeSelectorModalWidth = 0 // Force rebuild return nil case "D": // Check if deleting a shell session diff --git a/internal/plugins/workspace/merge.go b/internal/plugins/workspace/merge.go index 04d4eb85..09fd5491 100644 --- a/internal/plugins/workspace/merge.go +++ b/internal/plugins/workspace/merge.go @@ -18,14 +18,14 @@ import ( type MergeWorkflowStep int const ( - MergeStepReviewDiff MergeWorkflowStep = iota - MergeStepTargetBranch // Choose target branch for merge/PR - MergeStepMergeMethod // Choose: PR workflow or direct merge + MergeStepReviewDiff MergeWorkflowStep = iota + MergeStepTargetBranch // Choose target branch for merge/PR + MergeStepMergeMethod // Choose: PR workflow or direct merge MergeStepPush MergeStepCreatePR MergeStepWaitingMerge - MergeStepDirectMerge // Performing direct merge (no PR) - MergeStepPostMergeConfirmation // User confirms cleanup options after PR merge + MergeStepDirectMerge // Performing direct merge (no PR) + MergeStepPostMergeConfirmation // User confirms cleanup options after PR merge MergeStepCleanup MergeStepDone MergeStepError // Error display step (strategy-agnostic) @@ -69,11 +69,11 @@ type MergeWorkflowState struct { PRTitle string PRBody string PRURL string - ExistingPR bool // True if using an existing PR (vs newly created) + ExistingPR bool // True if using an existing PR (vs newly created) Error error - ErrorTitle string // Short title for error display (e.g. "Direct Merge Failed") - ErrorDetail string // Full error text for display and clipboard copy - ErrorFromStep MergeWorkflowStep // Which step produced the error + ErrorTitle string // Short title for error display (e.g. "Direct Merge Failed") + ErrorDetail string // Full error text for display and clipboard copy + ErrorFromStep MergeWorkflowStep // Which step produced the error StepStatus map[MergeWorkflowStep]string // "pending", "running", "done", "error", "skipped" DeleteAfterMerge bool // true = delete worktree after merge (default) @@ -87,17 +87,17 @@ type MergeWorkflowState struct { MergeMethodOption int // 0 = Create PR (default), 1 = Direct merge // Post-merge confirmation options - DeleteLocalWorktree bool // Checkbox: delete local worktree (default: true) - DeleteLocalBranch bool // Checkbox: delete local branch (default: true) - DeleteRemoteBranch bool // Checkbox: delete remote branch (default: false) - PullAfterMerge bool // Checkbox: pull changes to current branch after merge + DeleteLocalWorktree bool // Checkbox: delete local worktree (default: true) + DeleteLocalBranch bool // Checkbox: delete local branch (default: true) + DeleteRemoteBranch bool // Checkbox: delete remote branch (default: false) + PullAfterMerge bool // Checkbox: pull changes to current branch after merge CurrentBranch string // Branch user was on before merge (for pull) - ConfirmationFocus int // 0-3=checkboxes, 4=confirm btn, 5=skip btn - ConfirmationHover int // Mouse hover state + ConfirmationFocus int // 0-3=checkboxes, 4=confirm btn, 5=skip btn + ConfirmationHover int // Mouse hover state // Cleanup results for summary display - CleanupResults *CleanupResults - PendingCleanupOps int // Counter for parallel cleanup operations in flight + CleanupResults *CleanupResults + PendingCleanupOps int // Counter for parallel cleanup operations in flight } // CleanupResults holds the results of cleanup operations for display in summary. @@ -120,7 +120,7 @@ type CleanupResults struct { // MergeStepCompleteMsg signals a merge workflow step completed. type MergeStepCompleteMsg struct { - WorkspaceName string + WorkspaceName string Step MergeWorkflowStep Data string // Step-specific data (e.g., PR URL) Err error @@ -130,25 +130,25 @@ type MergeStepCompleteMsg struct { // CheckPRMergedMsg signals the result of checking if a PR was merged. type CheckPRMergedMsg struct { WorkspaceName string - Merged bool - Err error + Merged bool + Err error } // UncommittedChangesCheckMsg signals the result of checking for uncommitted changes. type UncommittedChangesCheckMsg struct { - WorkspaceName string - HasChanges bool - StagedCount int - ModifiedCount int - UntrackedCount int - Err error + WorkspaceName string + HasChanges bool + StagedCount int + ModifiedCount int + UntrackedCount int + Err error } // MergeCommitDoneMsg signals that the commit before merge completed. type MergeCommitDoneMsg struct { WorkspaceName string - CommitHash string - Err error + CommitHash string + Err error } // MergeCommitState holds state for the commit-before-merge modal. @@ -164,29 +164,29 @@ type MergeCommitState struct { // RemoteBranchDeleteMsg signals the result of deleting a remote branch. type RemoteBranchDeleteMsg struct { WorkspaceName string - BranchName string - Err error + BranchName string + Err error } // CleanupDoneMsg signals that cleanup operations completed. type CleanupDoneMsg struct { WorkspaceName string - Results *CleanupResults + Results *CleanupResults } // DirectMergeDoneMsg signals that direct merge completed. type DirectMergeDoneMsg struct { WorkspaceName string - BaseBranch string - Err error + BaseBranch string + Err error } // PullAfterMergeMsg signals that pull after merge completed. type PullAfterMergeMsg struct { WorkspaceName string - Branch string - Success bool - Err error + Branch string + Success bool + Err error } // checkUncommittedChanges checks if a worktree has uncommitted changes. @@ -196,8 +196,8 @@ func (p *Plugin) checkUncommittedChanges(wt *Worktree) tea.Cmd { if err := tree.Refresh(); err != nil { return UncommittedChangesCheckMsg{ WorkspaceName: wt.Name, - HasChanges: false, - Err: err, + HasChanges: false, + Err: err, } } @@ -207,7 +207,7 @@ func (p *Plugin) checkUncommittedChanges(wt *Worktree) tea.Cmd { hasChanges := stagedCount > 0 || modifiedCount > 0 || untrackedCount > 0 return UncommittedChangesCheckMsg{ - WorkspaceName: wt.Name, + WorkspaceName: wt.Name, HasChanges: hasChanges, StagedCount: stagedCount, ModifiedCount: modifiedCount, @@ -223,7 +223,7 @@ func (p *Plugin) stageAllAndCommit(wt *Worktree, message string) tea.Cmd { if tree == nil { return MergeCommitDoneMsg{ WorkspaceName: wt.Name, - Err: fmt.Errorf("failed to initialize git tree for %s", wt.Path), + Err: fmt.Errorf("failed to initialize git tree for %s", wt.Path), } } @@ -231,7 +231,7 @@ func (p *Plugin) stageAllAndCommit(wt *Worktree, message string) tea.Cmd { if err := tree.StageAll(); err != nil { return MergeCommitDoneMsg{ WorkspaceName: wt.Name, - Err: fmt.Errorf("failed to stage: %w", err), + Err: fmt.Errorf("failed to stage: %w", err), } } @@ -240,13 +240,13 @@ func (p *Plugin) stageAllAndCommit(wt *Worktree, message string) tea.Cmd { if err != nil { return MergeCommitDoneMsg{ WorkspaceName: wt.Name, - Err: err, + Err: err, } } return MergeCommitDoneMsg{ WorkspaceName: wt.Name, - CommitHash: hash, + CommitHash: hash, } } } @@ -295,16 +295,16 @@ func (p *Plugin) loadMergeDiff(wt *Worktree) tea.Cmd { if err != nil { return MergeStepCompleteMsg{ WorkspaceName: wt.Name, - Step: MergeStepReviewDiff, - Data: "", - Err: err, + Step: MergeStepReviewDiff, + Data: "", + Err: err, } } return MergeStepCompleteMsg{ WorkspaceName: wt.Name, - Step: MergeStepReviewDiff, - Data: stat, + Step: MergeStepReviewDiff, + Data: stat, } } } @@ -325,8 +325,8 @@ func (p *Plugin) pushForMerge(wt *Worktree) tea.Cmd { err := doPush(wt.Path, wt.Branch, false, true) return MergeStepCompleteMsg{ WorkspaceName: wt.Name, - Step: MergeStepPush, - Err: err, + Step: MergeStepPush, + Err: err, } } } @@ -385,7 +385,7 @@ func (p *Plugin) createPR(wt *Worktree, title, body, targetBranch string) tea.Cm outputStr := string(output) if existingURL, found := parseExistingPRURL(outputStr); found { return MergeStepCompleteMsg{ - WorkspaceName: wt.Name, + WorkspaceName: wt.Name, Step: MergeStepCreatePR, Data: existingURL, ExistingPRFound: true, @@ -393,8 +393,8 @@ func (p *Plugin) createPR(wt *Worktree, title, body, targetBranch string) tea.Cm } return MergeStepCompleteMsg{ WorkspaceName: wt.Name, - Step: MergeStepCreatePR, - Err: fmt.Errorf("gh pr create: %s: %w", strings.TrimSpace(outputStr), err), + Step: MergeStepCreatePR, + Err: fmt.Errorf("gh pr create: %s: %w", strings.TrimSpace(outputStr), err), } } @@ -403,8 +403,8 @@ func (p *Plugin) createPR(wt *Worktree, title, body, targetBranch string) tea.Cm return MergeStepCompleteMsg{ WorkspaceName: wt.Name, - Step: MergeStepCreatePR, - Data: prURL, + Step: MergeStepCreatePR, + Data: prURL, } } } @@ -420,8 +420,8 @@ func (p *Plugin) checkPRMerged(wt *Worktree) tea.Cmd { if err != nil { return CheckPRMergedMsg{ WorkspaceName: wt.Name, - Merged: false, - Err: err, + Merged: false, + Err: err, } } @@ -438,7 +438,7 @@ func (p *Plugin) checkPRMerged(wt *Worktree) tea.Cmd { return CheckPRMergedMsg{ WorkspaceName: wt.Name, - Merged: merged, + Merged: merged, } } } @@ -456,8 +456,8 @@ func (p *Plugin) performDirectMerge(wt *Worktree, targetBranch string) tea.Cmd { if output, err := fetchCmd.CombinedOutput(); err != nil { return DirectMergeDoneMsg{ WorkspaceName: wt.Name, - BaseBranch: baseBranch, - Err: fmt.Errorf("fetch origin: %s: %w", strings.TrimSpace(string(output)), err), + BaseBranch: baseBranch, + Err: fmt.Errorf("fetch origin: %s: %w", strings.TrimSpace(string(output)), err), } } @@ -467,8 +467,8 @@ func (p *Plugin) performDirectMerge(wt *Worktree, targetBranch string) tea.Cmd { if output, err := checkoutCmd.CombinedOutput(); err != nil { return DirectMergeDoneMsg{ WorkspaceName: wt.Name, - BaseBranch: baseBranch, - Err: fmt.Errorf("checkout %s: %s: %w", baseBranch, strings.TrimSpace(string(output)), err), + BaseBranch: baseBranch, + Err: fmt.Errorf("checkout %s: %s: %w", baseBranch, strings.TrimSpace(string(output)), err), } } @@ -478,8 +478,8 @@ func (p *Plugin) performDirectMerge(wt *Worktree, targetBranch string) tea.Cmd { if output, err := pullCmd.CombinedOutput(); err != nil { return DirectMergeDoneMsg{ WorkspaceName: wt.Name, - BaseBranch: baseBranch, - Err: fmt.Errorf("pull origin %s: %s: %w", baseBranch, strings.TrimSpace(string(output)), err), + BaseBranch: baseBranch, + Err: fmt.Errorf("pull origin %s: %s: %w", baseBranch, strings.TrimSpace(string(output)), err), } } @@ -490,8 +490,8 @@ func (p *Plugin) performDirectMerge(wt *Worktree, targetBranch string) tea.Cmd { if output, err := mergeCmd.CombinedOutput(); err != nil { return DirectMergeDoneMsg{ WorkspaceName: wt.Name, - BaseBranch: baseBranch, - Err: fmt.Errorf("merge %s: %s: %w", branch, strings.TrimSpace(string(output)), err), + BaseBranch: baseBranch, + Err: fmt.Errorf("merge %s: %s: %w", branch, strings.TrimSpace(string(output)), err), } } @@ -501,14 +501,14 @@ func (p *Plugin) performDirectMerge(wt *Worktree, targetBranch string) tea.Cmd { if output, err := pushCmd.CombinedOutput(); err != nil { return DirectMergeDoneMsg{ WorkspaceName: wt.Name, - BaseBranch: baseBranch, - Err: fmt.Errorf("push origin %s: %s: %w", baseBranch, strings.TrimSpace(string(output)), err), + BaseBranch: baseBranch, + Err: fmt.Errorf("push origin %s: %s: %w", baseBranch, strings.TrimSpace(string(output)), err), } } return DirectMergeDoneMsg{ WorkspaceName: wt.Name, - BaseBranch: baseBranch, + BaseBranch: baseBranch, } } } @@ -536,9 +536,9 @@ func (p *Plugin) pullAfterMerge(wt *Worktree, branch string, currentBranch strin if output, err := pullCmd.CombinedOutput(); err != nil { return PullAfterMergeMsg{ WorkspaceName: wt.Name, - Branch: branch, - Success: false, - Err: fmt.Errorf("pull: %s: %w", strings.TrimSpace(string(output)), err), + Branch: branch, + Success: false, + Err: fmt.Errorf("pull: %s: %w", strings.TrimSpace(string(output)), err), } } } else { @@ -548,9 +548,9 @@ func (p *Plugin) pullAfterMerge(wt *Worktree, branch string, currentBranch strin if output, err := fetchCmd.CombinedOutput(); err != nil { return PullAfterMergeMsg{ WorkspaceName: wt.Name, - Branch: branch, - Success: false, - Err: fmt.Errorf("fetch: %s: %w", strings.TrimSpace(string(output)), err), + Branch: branch, + Success: false, + Err: fmt.Errorf("fetch: %s: %w", strings.TrimSpace(string(output)), err), } } @@ -559,17 +559,17 @@ func (p *Plugin) pullAfterMerge(wt *Worktree, branch string, currentBranch strin if output, err := updateCmd.CombinedOutput(); err != nil { return PullAfterMergeMsg{ WorkspaceName: wt.Name, - Branch: branch, - Success: false, - Err: fmt.Errorf("update-ref: %s: %w", strings.TrimSpace(string(output)), err), + Branch: branch, + Success: false, + Err: fmt.Errorf("update-ref: %s: %w", strings.TrimSpace(string(output)), err), } } } return PullAfterMergeMsg{ WorkspaceName: wt.Name, - Branch: branch, - Success: true, + Branch: branch, + Success: true, } } } @@ -652,17 +652,17 @@ func summarizeGitError(err error) (string, string, bool) { // RebaseResolutionMsg signals result of rebase resolution attempt. type RebaseResolutionMsg struct { WorkspaceName string - Branch string - Success bool - Err error + Branch string + Success bool + Err error } // MergeResolutionMsg signals result of merge resolution attempt. type MergeResolutionMsg struct { WorkspaceName string - Branch string - Success bool - Err error + Branch string + Success bool + Err error } // executeRebaseResolution performs git pull --rebase to resolve diverged branches. @@ -683,16 +683,16 @@ func (p *Plugin) executeRebaseResolution() tea.Cmd { if err != nil { return RebaseResolutionMsg{ WorkspaceName: wtName, - Branch: branch, - Success: false, - Err: fmt.Errorf("rebase failed: %s", strings.TrimSpace(string(output))), + Branch: branch, + Success: false, + Err: fmt.Errorf("rebase failed: %s", strings.TrimSpace(string(output))), } } return RebaseResolutionMsg{ WorkspaceName: wtName, - Branch: branch, - Success: true, + Branch: branch, + Success: true, } } } @@ -715,16 +715,16 @@ func (p *Plugin) executeMergeResolution() tea.Cmd { if err != nil { return MergeResolutionMsg{ WorkspaceName: wtName, - Branch: branch, - Success: false, - Err: fmt.Errorf("merge failed: %s", strings.TrimSpace(string(output))), + Branch: branch, + Success: false, + Err: fmt.Errorf("merge failed: %s", strings.TrimSpace(string(output))), } } return MergeResolutionMsg{ WorkspaceName: wtName, - Branch: branch, - Success: true, + Branch: branch, + Success: true, } } } @@ -749,19 +749,19 @@ func (p *Plugin) deleteRemoteBranch(wt *Worktree) tea.Cmd { // Not an error - branch already gone return RemoteBranchDeleteMsg{ WorkspaceName: name, - BranchName: branch, + BranchName: branch, } } return RemoteBranchDeleteMsg{ WorkspaceName: name, - BranchName: branch, - Err: fmt.Errorf("delete remote branch: %s", strings.TrimSpace(outputStr)), + BranchName: branch, + Err: fmt.Errorf("delete remote branch: %s", strings.TrimSpace(outputStr)), } } return RemoteBranchDeleteMsg{ WorkspaceName: name, - BranchName: branch, + BranchName: branch, } } } @@ -944,9 +944,9 @@ func (p *Plugin) advanceMergeStep() tea.Cmd { p.mergeState.StepStatus[MergeStepPostMergeConfirmation] = "running" // Initialize default checkbox values - p.mergeState.DeleteLocalWorktree = true // Default: checked - p.mergeState.DeleteLocalBranch = true // Default: checked - p.mergeState.DeleteRemoteBranch = false // Default: unchecked (safer) + p.mergeState.DeleteLocalWorktree = true // Default: checked + p.mergeState.DeleteLocalBranch = true // Default: checked + p.mergeState.DeleteRemoteBranch = false // Default: unchecked (safer) // Pull option: default checked if current branch matches base branch p.mergeState.PullAfterMerge = p.mergeState.CurrentBranch == p.mergeState.TargetBranch p.mergeState.ConfirmationFocus = 0 diff --git a/internal/plugins/workspace/messages.go b/internal/plugins/workspace/messages.go index 7b42e771..e15255bf 100644 --- a/internal/plugins/workspace/messages.go +++ b/internal/plugins/workspace/messages.go @@ -29,9 +29,9 @@ type WatcherErrorMsg struct { // AgentOutputMsg delivers new agent output. type AgentOutputMsg struct { WorkspaceName string - Output string - Status WorktreeStatus - WaitingFor string + Output string + Status WorktreeStatus + WaitingFor string // Cursor position captured atomically with output (only set in interactive mode) CursorRow int CursorCol int @@ -44,13 +44,13 @@ type AgentOutputMsg struct { // AgentStoppedMsg signals an agent has stopped. type AgentStoppedMsg struct { WorkspaceName string - Err error + Err error } // TmuxAttachFinishedMsg signals return from tmux attach. type TmuxAttachFinishedMsg struct { WorkspaceName string - Err error + Err error } // DiffLoadedMsg delivers diff content for a worktree. @@ -67,7 +67,7 @@ func (m DiffLoadedMsg) GetEpoch() uint64 { return m.Epoch } // DiffErrorMsg signals diff loading failed. type DiffErrorMsg struct { WorkspaceName string - Err error + Err error } // StatsLoadedMsg delivers git stats for a worktree. @@ -83,7 +83,7 @@ func (m StatsLoadedMsg) GetEpoch() uint64 { return m.Epoch } // StatsErrorMsg signals stats loading failed. type StatsErrorMsg struct { WorkspaceName string - Err error + Err error } // CreateWorktreeMsg requests worktree creation. @@ -118,21 +118,21 @@ type DeleteDoneMsg struct { // RemoteCheckDoneMsg signals remote branch existence check completed. type RemoteCheckDoneMsg struct { WorkspaceName string - Branch string - Exists bool + Branch string + Exists bool } // PushMsg requests pushing a worktree branch. type PushMsg struct { WorkspaceName string - Force bool - SetUpstream bool + Force bool + SetUpstream bool } // PushDoneMsg signals push operation completed. type PushDoneMsg struct { WorkspaceName string - Err error + Err error } // TaskSearchResultsMsg delivers task search results. @@ -150,8 +150,8 @@ type BranchListMsg struct { // TaskLinkedMsg signals a task was linked to a worktree. type TaskLinkedMsg struct { WorkspaceName string - TaskID string - Err error + TaskID string + Err error } // Task represents a TD task for linking. @@ -248,13 +248,13 @@ type FetchPRDoneMsg struct { // PRListItem represents an open pull request for the fetch modal. type PRListItem struct { - Number int `json:"number"` - Title string `json:"title"` - Branch string `json:"headRefName"` - Author prAuthor `json:"author"` - URL string `json:"url"` - CreatedAt string `json:"createdAt"` - IsDraft bool `json:"isDraft"` + Number int `json:"number"` + Title string `json:"title"` + Branch string `json:"headRefName"` + Author prAuthor `json:"author"` + URL string `json:"url"` + CreatedAt string `json:"createdAt"` + IsDraft bool `json:"isDraft"` } // prAuthor represents the author field from gh pr list --json. diff --git a/internal/plugins/workspace/mouse.go b/internal/plugins/workspace/mouse.go index 5f50b235..1415d20d 100644 --- a/internal/plugins/workspace/mouse.go +++ b/internal/plugins/workspace/mouse.go @@ -523,7 +523,7 @@ func (p *Plugin) handleMouseClick(action mouse.MouseAction) tea.Cmd { p.previewOffset = 0 p.autoScrollOutput = true p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot for new selection - p.taskLoading = false // Reset task loading on selection change (td-3668584f) + p.taskLoading = false // Reset task loading on selection change (td-3668584f) // Exit interactive mode when switching selection (td-fc758e88) p.exitInteractiveMode() p.saveSelectionState() @@ -539,7 +539,7 @@ func (p *Plugin) handleMouseClick(action mouse.MouseAction) tea.Cmd { p.previewOffset = 0 p.autoScrollOutput = true p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot for new selection - p.taskLoading = false // Reset task loading on selection change (td-3668584f) + p.taskLoading = false // Reset task loading on selection change (td-3668584f) // Exit interactive mode when switching selection (td-fc758e88) p.exitInteractiveMode() p.saveSelectionState() @@ -870,7 +870,7 @@ func (p *Plugin) scrollPreview(delta int) tea.Cmd { if p.previewOffset > 0 { p.previewOffset-- if p.previewOffset == 0 { - p.autoScrollOutput = true // Resume auto-scroll when at bottom + p.autoScrollOutput = true // Resume auto-scroll when at bottom p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot } } diff --git a/internal/plugins/workspace/plugin.go b/internal/plugins/workspace/plugin.go index e22f6067..a7349029 100644 --- a/internal/plugins/workspace/plugin.go +++ b/internal/plugins/workspace/plugin.go @@ -13,10 +13,10 @@ import ( "github.com/marcus/sidecar/internal/modal" "github.com/marcus/sidecar/internal/mouse" "github.com/marcus/sidecar/internal/plugin" - "github.com/marcus/sidecar/internal/projectdir" - "github.com/marcus/sidecar/internal/ui" "github.com/marcus/sidecar/internal/plugins/gitstatus" + "github.com/marcus/sidecar/internal/projectdir" "github.com/marcus/sidecar/internal/state" + "github.com/marcus/sidecar/internal/ui" ) const ( @@ -35,11 +35,11 @@ const ( flashDuration = 1500 * time.Millisecond // Hit region IDs - regionSidebar = "sidebar" - regionPreviewPane = "preview-pane" - regionPaneDivider = "pane-divider" - regionWorktreeItem = "workspace-item" - regionPreviewTab = "preview-tab" + regionSidebar = "sidebar" + regionPreviewPane = "preview-pane" + regionPaneDivider = "pane-divider" + regionWorktreeItem = "workspace-item" + regionPreviewTab = "preview-tab" // Agent choice modal IDs (modal library) agentChoiceListID = "agent-choice-list" agentChoiceConfirmID = "agent-choice-confirm" @@ -91,9 +91,9 @@ const ( typeSelectorInputID = "type-selector-name-input" typeSelectorConfirmID = "type-selector-confirm" typeSelectorCancelID = "type-selector-cancel" - typeSelectorAgentListID = "type-selector-agent-list" // td-a902fe - typeSelectorSkipPermsID = "type-selector-skip-perms" // td-a902fe - typeSelectorAgentItemPfx = "ts-agent-" // td-a902fe: prefix for agent items + typeSelectorAgentListID = "type-selector-agent-list" // td-a902fe + typeSelectorSkipPermsID = "type-selector-skip-perms" // td-a902fe + typeSelectorAgentItemPfx = "ts-agent-" // td-a902fe: prefix for agent items // Shell delete confirmation modal regions ) @@ -114,20 +114,20 @@ type Plugin struct { managedSessions map[string]bool // View state - viewMode ViewMode - activePane FocusPane - previewTab PreviewTab - selectedIdx int - scrollOffset int // Sidebar list scroll offset - visibleCount int // Number of visible list items + viewMode ViewMode + activePane FocusPane + previewTab PreviewTab + selectedIdx int + scrollOffset int // Sidebar list scroll offset + visibleCount int // Number of visible list items previewOffset int - autoScrollOutput bool // Auto-scroll output to follow agent (paused when user scrolls up) - scrollBaseLineCount int // Snapshot of lineCount when scroll started (td-f7c8be: prevents bounce on poll) - sidebarWidth int // Persisted sidebar width - sidebarVisible bool // Whether sidebar is visible (toggled with \) - flashPreviewTime time.Time // When preview flash was triggered - toastMessage string // Temporary toast message to display - toastTime time.Time // When toast was triggered + autoScrollOutput bool // Auto-scroll output to follow agent (paused when user scrolls up) + scrollBaseLineCount int // Snapshot of lineCount when scroll started (td-f7c8be: prevents bounce on poll) + sidebarWidth int // Persisted sidebar width + sidebarVisible bool // Whether sidebar is visible (toggled with \) + flashPreviewTime time.Time // When preview flash was triggered + toastMessage string // Temporary toast message to display + toastTime time.Time // When toast was triggered // Interactive selection state (preview pane) selection ui.SelectionState @@ -229,21 +229,21 @@ type Plugin struct { // Merge workflow state mergeState *MergeWorkflowState - mergeModal *modal.Modal // Modal instance for merge workflow - mergeModalWidth int // Cached width for rebuild detection + mergeModal *modal.Modal // Modal instance for merge workflow + mergeModalWidth int // Cached width for rebuild detection mergeModalStep MergeWorkflowStep // Cached step for rebuild detection // Commit-before-merge state - mergeCommitState *MergeCommitState - mergeCommitMessageInput textinput.Model - commitForMergeModal *modal.Modal // Modal instance - commitForMergeModalWidth int // Cached width for rebuild detection + mergeCommitState *MergeCommitState + mergeCommitMessageInput textinput.Model + commitForMergeModal *modal.Modal // Modal instance + commitForMergeModalWidth int // Cached width for rebuild detection // Agent choice modal state (attach vs restart) - agentChoiceWorktree *Worktree - agentChoiceIdx int // 0=attach, 1=restart - agentChoiceModal *modal.Modal // Modal instance - agentChoiceModalWidth int // Cached width for rebuild detection + agentChoiceWorktree *Worktree + agentChoiceIdx int // 0=attach, 1=restart + agentChoiceModal *modal.Modal // Modal instance + agentChoiceModalWidth int // Cached width for rebuild detection // Delete confirmation modal state deleteConfirmWorktree *Worktree // Worktree pending deletion @@ -291,10 +291,10 @@ type Plugin struct { shellSelected bool // True when any shell is selected (vs a worktree) // Type selector modal state (shell vs worktree) - typeSelectorIdx int // 0=Shell, 1=Worktree - typeSelectorNameInput textinput.Model // Optional shell name input - typeSelectorModal *modal.Modal // Modal instance - typeSelectorModalWidth int // Cached width for rebuild detection + typeSelectorIdx int // 0=Shell, 1=Worktree + typeSelectorNameInput textinput.Model // Optional shell name input + typeSelectorModal *modal.Modal // Modal instance + typeSelectorModalWidth int // Cached width for rebuild detection // Type selector modal - shell agent selection (td-2bb232) typeSelectorAgentIdx int // Selected index in agent list (0 = None) @@ -302,7 +302,7 @@ type Plugin struct { typeSelectorSkipPerms bool // Whether skip permissions is checked typeSelectorFocusField int // Focus: 0=name, 1=agent, 2=skipPerms, 3=buttons -// Resume conversation state (td-aa4136) + // Resume conversation state (td-aa4136) pendingResumeCmd string // Resume command to inject after shell creation pendingResumeWorktree string // Worktree name to enter interactive mode after agent starts @@ -915,7 +915,7 @@ func (p *Plugin) moveCursor(delta int) { p.previewOffset = 0 p.autoScrollOutput = true p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot for new selection - p.taskLoading = false // Reset task loading state for new selection (td-3668584f) + p.taskLoading = false // Reset task loading state for new selection (td-3668584f) // Exit interactive mode when switching selection (td-fc758e88) p.exitInteractiveMode() // Persist selection to disk @@ -952,7 +952,7 @@ func (p *Plugin) cyclePreviewTab(delta int) tea.Cmd { prevTab := p.previewTab p.previewTab = PreviewTab((int(p.previewTab) + delta + 3) % 3) p.previewOffset = 0 - p.autoScrollOutput = true // Reset auto-scroll when switching tabs + p.autoScrollOutput = true // Reset auto-scroll when switching tabs p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot when switching tabs if prevTab == PreviewTabOutput && p.previewTab != PreviewTabOutput { diff --git a/internal/plugins/workspace/prompts_test.go b/internal/plugins/workspace/prompts_test.go index fc74281d..9b728ec7 100644 --- a/internal/plugins/workspace/prompts_test.go +++ b/internal/plugins/workspace/prompts_test.go @@ -345,11 +345,11 @@ func TestDefaultPrompts(t *testing.T) { // Verify expected prompt names exist expectedNames := map[string]bool{ - "Begin Work on Ticket": false, - "Code Review Ticket": false, - "Plan to Epic (No Impl)": false, + "Begin Work on Ticket": false, + "Code Review Ticket": false, + "Plan to Epic (No Impl)": false, "Plan to Epic + Implement": false, - "TD Review Session": false, + "TD Review Session": false, } for _, p := range prompts { diff --git a/internal/plugins/workspace/setup.go b/internal/plugins/workspace/setup.go index ef0ba80f..a57f8043 100644 --- a/internal/plugins/workspace/setup.go +++ b/internal/plugins/workspace/setup.go @@ -19,18 +19,18 @@ var ( // SetupConfig holds worktree setup configuration. type SetupConfig struct { - CopyEnv bool // Whether to copy env files (default: true) - EnvFiles []string // List of env files to copy - SymlinkDirs []string // Directories to symlink (default: empty, opt-in) - RunSetupScript bool // Whether to run .worktree-setup.sh (default: true) + CopyEnv bool // Whether to copy env files (default: true) + EnvFiles []string // List of env files to copy + SymlinkDirs []string // Directories to symlink (default: empty, opt-in) + RunSetupScript bool // Whether to run .worktree-setup.sh (default: true) } // DefaultSetupConfig returns the default setup configuration. func DefaultSetupConfig() *SetupConfig { return &SetupConfig{ - CopyEnv: true, - EnvFiles: defaultEnvFiles, - SymlinkDirs: nil, // Opt-in, not enabled by default + CopyEnv: true, + EnvFiles: defaultEnvFiles, + SymlinkDirs: nil, // Opt-in, not enabled by default RunSetupScript: true, } } @@ -206,4 +206,3 @@ func (p *Plugin) runSetupScript(worktreePath, branchName string) error { return nil } - diff --git a/internal/plugins/workspace/shell.go b/internal/plugins/workspace/shell.go index 3ea4bf4a..f80f7dce 100644 --- a/internal/plugins/workspace/shell.go +++ b/internal/plugins/workspace/shell.go @@ -462,6 +462,7 @@ func (p *Plugin) restoreShellDisplayNames() { _ = state.SetWorkspaceState(p.ctx.ProjectRoot, wtState) } } + // nextShellIndex returns the next available shell index based on existing sessions. func (p *Plugin) nextShellIndex() int { projectName := filepath.Base(p.ctx.WorkDir) diff --git a/internal/plugins/workspace/types.go b/internal/plugins/workspace/types.go index 89d35c50..232dd31b 100644 --- a/internal/plugins/workspace/types.go +++ b/internal/plugins/workspace/types.go @@ -23,21 +23,21 @@ var partialMouseEscapeRegex = regexp.MustCompile(`\[<\d+;\d+;\d+[Mm]?`) type ViewMode int const ( - ViewModeList ViewMode = iota // List view (default) - ViewModeKanban // Kanban board view - ViewModeCreate // New worktree modal - ViewModeTaskLink // Task link modal (for existing worktrees) - ViewModeMerge // Merge workflow modal - ViewModeAgentChoice // Agent action choice modal (attach/restart) - ViewModeConfirmDelete // Delete confirmation modal - ViewModeConfirmDeleteShell // Shell delete confirmation modal - ViewModeCommitForMerge // Commit modal before merge workflow - ViewModePromptPicker // Prompt template picker modal - ViewModeTypeSelector // Type selector modal (shell vs worktree) - ViewModeRenameShell // Rename shell modal - ViewModeFilePicker // Diff file picker modal - ViewModeInteractive // Interactive mode (tmux input passthrough) - ViewModeFetchPR // Fetch remote PR modal + ViewModeList ViewMode = iota // List view (default) + ViewModeKanban // Kanban board view + ViewModeCreate // New worktree modal + ViewModeTaskLink // Task link modal (for existing worktrees) + ViewModeMerge // Merge workflow modal + ViewModeAgentChoice // Agent action choice modal (attach/restart) + ViewModeConfirmDelete // Delete confirmation modal + ViewModeConfirmDeleteShell // Shell delete confirmation modal + ViewModeCommitForMerge // Commit modal before merge workflow + ViewModePromptPicker // Prompt template picker modal + ViewModeTypeSelector // Type selector modal (shell vs worktree) + ViewModeRenameShell // Rename shell modal + ViewModeFilePicker // Diff file picker modal + ViewModeInteractive // Interactive mode (tmux input passthrough) + ViewModeFetchPR // Fetch remote PR modal ) // FocusPane represents which pane is active in the split view. @@ -235,9 +235,9 @@ type Worktree struct { // ShellSession represents a tmux shell session (not tied to a git worktree). type ShellSession struct { - Name string // Display name (e.g., "Shell 1") - TmuxName string // tmux session name (e.g., "sidecar-sh-project-1") - Agent *Agent // Reuses Agent struct for tmux state + Name string // Display name (e.g., "Shell 1") + TmuxName string // tmux session name (e.g., "sidecar-sh-project-1") + Agent *Agent // Reuses Agent struct for tmux state CreatedAt time.Time ChosenAgent AgentType // td-317b64: Agent type selected at creation (AgentNone for plain shell) SkipPerms bool // td-317b64: Whether skip permissions was enabled @@ -516,9 +516,9 @@ type validateManagedSessionsResultMsg struct { // Used to avoid blocking the UI thread on tmux subprocess calls (td-c2961e). type AsyncCaptureResultMsg struct { WorkspaceName string // Worktree this capture is for - SessionName string // tmux session name - Output string // Captured output (empty on error) - Err error // Non-nil if capture failed + SessionName string // tmux session name + Output string // Captured output (empty on error) + Err error // Non-nil if capture failed } // AsyncShellCaptureResultMsg delivers async shell capture results. @@ -569,4 +569,3 @@ func (c *paneIDCache) set(sessionName, paneID string) { cachedAt: time.Now(), } } - diff --git a/internal/plugins/workspace/types_test.go b/internal/plugins/workspace/types_test.go index e8994e3c..70365419 100644 --- a/internal/plugins/workspace/types_test.go +++ b/internal/plugins/workspace/types_test.go @@ -204,10 +204,10 @@ func TestIsDefaultShellName(t *testing.T) { {"Shell 123", true}, {"Backend", false}, {"Frontend", false}, - {"shell 1", false}, // case sensitive - {"Shell1", false}, // no space - {"Shell", false}, // no number - {"Shell X", false}, // not a digit + {"shell 1", false}, // case sensitive + {"Shell1", false}, // no space + {"Shell", false}, // no number + {"Shell X", false}, // not a digit {"My Shell 1", false}, {"", false}, } diff --git a/internal/plugins/workspace/view_kanban.go b/internal/plugins/workspace/view_kanban.go index 8dfd7d8b..1afd90ec 100644 --- a/internal/plugins/workspace/view_kanban.go +++ b/internal/plugins/workspace/view_kanban.go @@ -57,7 +57,7 @@ func (p *Plugin) renderKanbanView(width, height int) string { } columnColors := map[WorktreeStatus]lipgloss.Color{ StatusActive: styles.StatusCompleted.GetForeground().(lipgloss.Color), // Green - StatusThinking: styles.Primary, // Purple + StatusThinking: styles.Primary, // Purple StatusWaiting: styles.StatusModified.GetForeground().(lipgloss.Color), // Yellow StatusDone: styles.Secondary, // Cyan/Blue StatusPaused: styles.TextMuted, // Gray diff --git a/internal/plugins/workspace/view_modals.go b/internal/plugins/workspace/view_modals.go index 02e3ff9b..b96d704e 100644 --- a/internal/plugins/workspace/view_modals.go +++ b/internal/plugins/workspace/view_modals.go @@ -303,10 +303,10 @@ const ( ) const ( - commitForMergeInputID = "commit-for-merge-input" - commitForMergeCommitID = "commit-for-merge-commit" - commitForMergeCancelID = "commit-for-merge-cancel" - commitForMergeActionID = "commit-for-merge-action" + commitForMergeInputID = "commit-for-merge-input" + commitForMergeCommitID = "commit-for-merge-commit" + commitForMergeCancelID = "commit-for-merge-cancel" + commitForMergeActionID = "commit-for-merge-action" ) // renderConfirmDeleteShellModal renders the shell delete confirmation modal. @@ -669,9 +669,9 @@ func (p *Plugin) ensureMergeModal() { m.AddSection(modal.Text(dimText("Select what to clean up:"))) m.AddSection(modal.Spacer()) m.AddSection(modal.Checkbox(mergeConfirmWorktreeID, "Delete local worktree", &p.mergeState.DeleteLocalWorktree)) - m.AddSection(modal.Text(dimText(" Removes "+p.mergeState.Worktree.Path))) + m.AddSection(modal.Text(dimText(" Removes " + p.mergeState.Worktree.Path))) m.AddSection(modal.Checkbox(mergeConfirmBranchID, "Delete local branch", &p.mergeState.DeleteLocalBranch)) - m.AddSection(modal.Text(dimText(" Removes '"+p.mergeState.Worktree.Branch+"' locally"))) + m.AddSection(modal.Text(dimText(" Removes '" + p.mergeState.Worktree.Branch + "' locally"))) m.AddSection(modal.Checkbox(mergeConfirmRemoteID, "Delete remote branch", &p.mergeState.DeleteRemoteBranch)) m.AddSection(modal.Text(dimText(" Removes from GitHub (often auto-deleted)"))) m.AddSection(modal.Spacer()) diff --git a/internal/plugins/workspace/worktree_test.go b/internal/plugins/workspace/worktree_test.go index 726f498f..42095470 100644 --- a/internal/plugins/workspace/worktree_test.go +++ b/internal/plugins/workspace/worktree_test.go @@ -94,12 +94,12 @@ func TestSanitizeBranchName(t *testing.T) { func TestParseWorktreeList(t *testing.T) { tests := []struct { - name string - output string - mainWorkdir string - wantCount int - wantNames []string - wantBranch []string + name string + output string + mainWorkdir string + wantCount int + wantNames []string + wantBranch []string wantIsMain []bool // Track which worktrees should be marked as main wantIsMissing []bool // Track which worktrees should be marked as missing }{ @@ -372,4 +372,3 @@ func TestFilterTasks(t *testing.T) { } }) } - diff --git a/internal/state/state_test.go b/internal/state/state_test.go index f7c6473f..8dd4df80 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -425,7 +425,7 @@ func TestGetWorkspaceState_Found(t *testing.T) { current = &State{ Workspace: map[string]WorkspaceState{ "/path/to/project": { - WorkspaceName: "feature-branch", + WorkspaceName: "feature-branch", ShellTmuxName: "sidecar-sh-project-1", ShellDisplayNames: map[string]string{ "sidecar-sh-project-1": "Backend", @@ -459,7 +459,7 @@ func TestSetWorkspaceState(t *testing.T) { current = &State{} wtState := WorkspaceState{ - WorkspaceName: "my-workspace", + WorkspaceName: "my-workspace", ShellTmuxName: "", ShellDisplayNames: map[string]string{ "sidecar-sh-project-1": "Backend", @@ -506,7 +506,7 @@ func TestSetWorkspaceState_ShellSelection(t *testing.T) { // Save shell selection wtState := WorkspaceState{ - WorkspaceName: "", + WorkspaceName: "", ShellTmuxName: "sidecar-sh-project-2", } diff --git a/internal/styles/borders_test.go b/internal/styles/borders_test.go index 129c3287..21ca4ffb 100644 --- a/internal/styles/borders_test.go +++ b/internal/styles/borders_test.go @@ -144,7 +144,7 @@ func TestDecodeRune(t *testing.T) { {"3-byte UTF-8", "δΈ­", 'δΈ­', 3}, {"4-byte UTF-8", "πŸ˜€", 'πŸ˜€', 4}, // Invalid continuation byte tests - should fallback to single byte - {"invalid 2-byte continuation", "\xC0\x00", 0xC0, 1}, // \x00 not valid continuation + {"invalid 2-byte continuation", "\xC0\x00", 0xC0, 1}, // \x00 not valid continuation {"invalid 3-byte continuation", "\xE0\x80\x00", 0xE0, 1}, // \x00 not valid continuation {"invalid 4-byte continuation", "\xF0\x80\x80\x00", 0xF0, 1}, } @@ -170,11 +170,11 @@ func TestRuneWidth(t *testing.T) { {'δΈ­', 2}, // CJK {'ν•œ', 2}, // Hangul {'a', 2}, // Fullwidth Latin - {'Γ©', 1}, // Latin extended + {'Γ©', 1}, // Latin extended // Emoji tests - most render as width 2 in terminals {'πŸ˜€', 2}, // Emoticons (U+1F600) {'🌍', 2}, // Misc Symbols (U+1F30D) - {'β˜€', 2}, // Misc Symbols (U+2600) + {'β˜€', 2}, // Misc Symbols (U+2600) {'βœ…', 2}, // Dingbats (U+2705) } diff --git a/internal/tty/output_buffer_test.go b/internal/tty/output_buffer_test.go index 60d1240a..fee733de 100644 --- a/internal/tty/output_buffer_test.go +++ b/internal/tty/output_buffer_test.go @@ -131,11 +131,11 @@ func TestPartialMouseSeqRegex(t *testing.T) { input string match bool }{ - {"[<65;83;33M", true}, // scroll down - {"[<64;10;5M", true}, // scroll up - {"[<0;50;20m", true}, // release - {"hello", false}, // normal text - {"[notmouse]", false}, // not a mouse sequence + {"[<65;83;33M", true}, // scroll down + {"[<64;10;5M", true}, // scroll up + {"[<0;50;20m", true}, // release + {"hello", false}, // normal text + {"[notmouse]", false}, // not a mouse sequence {"[ width { - return string(runes[:width]) - } - return s - } - - if runewidth.StringWidth(s) <= width { - return s - } - - targetWidth := width - 3 - - currentWidth := 0 - runes := []rune(s) - for i, r := range runes { - w := runewidth.RuneWidth(r) - if currentWidth + w > targetWidth { - return string(runes[:i]) + "..." - } - currentWidth += w - } - - return s + if width < 3 { + // Fallback for very small width + runes := []rune(s) + if len(runes) > width { + return string(runes[:width]) + } + return s + } + + if runewidth.StringWidth(s) <= width { + return s + } + + targetWidth := width - 3 + + currentWidth := 0 + runes := []rune(s) + for i, r := range runes { + w := runewidth.RuneWidth(r) + if currentWidth+w > targetWidth { + return string(runes[:i]) + "..." + } + currentWidth += w + } + + return s } // SafeByteSlice extracts a substring using byte positions, ensuring @@ -171,36 +171,36 @@ func BytePosToRunePos(s string, bytePos int) int { // TruncateStart truncates the start of the string if it exceeds width. // "..." + suffix func TruncateStart(s string, width int) string { - if width < 3 { - runes := []rune(s) - if len(runes) > width { - return string(runes[len(runes)-width:]) - } - return s - } - - if runewidth.StringWidth(s) <= width { - return s - } - - targetWidth := width - 3 - runes := []rune(s) - - // Calculate total width first - totalWidth := 0 - for _, r := range runes { - totalWidth += runewidth.RuneWidth(r) - } - - // Scan from end - currentWidth := 0 - for i := len(runes) - 1; i >= 0; i-- { - w := runewidth.RuneWidth(runes[i]) - if currentWidth + w > targetWidth { - return "..." + string(runes[i+1:]) - } - currentWidth += w - } - - return s + if width < 3 { + runes := []rune(s) + if len(runes) > width { + return string(runes[len(runes)-width:]) + } + return s + } + + if runewidth.StringWidth(s) <= width { + return s + } + + targetWidth := width - 3 + runes := []rune(s) + + // Calculate total width first + totalWidth := 0 + for _, r := range runes { + totalWidth += runewidth.RuneWidth(r) + } + + // Scan from end + currentWidth := 0 + for i := len(runes) - 1; i >= 0; i-- { + w := runewidth.RuneWidth(runes[i]) + if currentWidth+w > targetWidth { + return "..." + string(runes[i+1:]) + } + currentWidth += w + } + + return s } diff --git a/internal/version/checker_test.go b/internal/version/checker_test.go index 72bdf73f..51b8f5cd 100644 --- a/internal/version/checker_test.go +++ b/internal/version/checker_test.go @@ -53,7 +53,6 @@ func TestUpdateCommand(t *testing.T) { } } - func TestCheck_DevelopmentVersion(t *testing.T) { // Development versions should return empty result without making HTTP calls devVersions := []string{"", "unknown", "devel", "devel+abc123"}