@pennyfarthing/cyclist 10.0.3 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/api/agent-load.d.ts +3 -0
  2. package/dist/api/agent-load.d.ts.map +1 -0
  3. package/dist/api/agent-load.js +124 -0
  4. package/dist/api/agent-load.js.map +1 -0
  5. package/dist/api/code-markers.d.ts +9 -0
  6. package/dist/api/code-markers.d.ts.map +1 -0
  7. package/dist/api/code-markers.js +62 -0
  8. package/dist/api/code-markers.js.map +1 -0
  9. package/dist/api/complexity.d.ts +3 -0
  10. package/dist/api/complexity.d.ts.map +1 -0
  11. package/dist/api/complexity.js +47 -0
  12. package/dist/api/complexity.js.map +1 -0
  13. package/dist/api/dead-code.d.ts +3 -0
  14. package/dist/api/dead-code.d.ts.map +1 -0
  15. package/dist/api/dead-code.js +70 -0
  16. package/dist/api/dead-code.js.map +1 -0
  17. package/dist/api/dependencies.d.ts +3 -0
  18. package/dist/api/dependencies.d.ts.map +1 -0
  19. package/dist/api/dependencies.js +43 -0
  20. package/dist/api/dependencies.js.map +1 -0
  21. package/dist/api/git.d.ts +3 -2
  22. package/dist/api/git.d.ts.map +1 -1
  23. package/dist/api/git.js +11 -6
  24. package/dist/api/git.js.map +1 -1
  25. package/dist/api/health-score.d.ts +3 -0
  26. package/dist/api/health-score.d.ts.map +1 -0
  27. package/dist/api/health-score.js +47 -0
  28. package/dist/api/health-score.js.map +1 -0
  29. package/dist/api/hotspots.d.ts.map +1 -1
  30. package/dist/api/hotspots.js +9 -1
  31. package/dist/api/hotspots.js.map +1 -1
  32. package/dist/api/index.d.ts +7 -1
  33. package/dist/api/index.d.ts.map +1 -1
  34. package/dist/api/index.js +12 -2
  35. package/dist/api/index.js.map +1 -1
  36. package/dist/api/persona.d.ts +2 -0
  37. package/dist/api/persona.d.ts.map +1 -1
  38. package/dist/api/persona.js +19 -1
  39. package/dist/api/persona.js.map +1 -1
  40. package/dist/api/settings.js +1 -1
  41. package/dist/api/settings.js.map +1 -1
  42. package/dist/claude-service.d.ts +8 -2
  43. package/dist/claude-service.d.ts.map +1 -1
  44. package/dist/claude-service.js +21 -2
  45. package/dist/claude-service.js.map +1 -1
  46. package/dist/git-diff.d.ts.map +1 -1
  47. package/dist/git-diff.js +6 -5
  48. package/dist/git-diff.js.map +1 -1
  49. package/dist/main.d.ts.map +1 -1
  50. package/dist/main.js +11 -2
  51. package/dist/main.js.map +1 -1
  52. package/dist/plugin-loader.d.ts +49 -0
  53. package/dist/plugin-loader.d.ts.map +1 -0
  54. package/dist/plugin-loader.js +92 -0
  55. package/dist/plugin-loader.js.map +1 -0
  56. package/dist/preload.js +12 -1
  57. package/dist/preload.js.map +1 -1
  58. package/dist/prime.d.ts +3 -2
  59. package/dist/prime.d.ts.map +1 -1
  60. package/dist/prime.js +25 -8
  61. package/dist/prime.js.map +1 -1
  62. package/dist/public/css/react.css +1 -1
  63. package/dist/public/js/react/react.js +50 -39
  64. package/dist/server.d.ts.map +1 -1
  65. package/dist/server.js +19 -16
  66. package/dist/server.js.map +1 -1
  67. package/dist/sprint-data.d.ts +6 -0
  68. package/dist/sprint-data.d.ts.map +1 -1
  69. package/dist/sprint-data.js +118 -67
  70. package/dist/sprint-data.js.map +1 -1
  71. package/dist/story-parser.js +1 -1
  72. package/dist/story-parser.js.map +1 -1
  73. package/dist/theme-metadata.js +2 -2
  74. package/dist/theme-metadata.js.map +1 -1
  75. package/dist/websocket.d.ts +0 -6
  76. package/dist/websocket.d.ts.map +1 -1
  77. package/dist/websocket.js +36 -40
  78. package/dist/websocket.js.map +1 -1
  79. package/package.json +2 -1
  80. package/portraits/fifth-element/large/cornelius-54343.png +0 -0
  81. package/portraits/fifth-element/large/diva-53453.png +0 -0
  82. package/portraits/fifth-element/large/korben-34232.png +0 -0
  83. package/portraits/fifth-element/large/leeloo-54333.png +0 -0
  84. package/portraits/fifth-element/large/lindberg-34432.png +0 -0
  85. package/portraits/fifth-element/large/mondoshawan-55131.png +0 -0
  86. package/portraits/fifth-element/large/munro-25321.png +0 -0
  87. package/portraits/fifth-element/large/pacoli-45232.png +0 -0
  88. package/portraits/fifth-element/large/ruby-53544.png +0 -0
  89. package/portraits/fifth-element/large/zorg-45312.png +0 -0
  90. package/portraits/fifth-element/medium/cornelius-54343.png +0 -0
  91. package/portraits/fifth-element/medium/diva-53453.png +0 -0
  92. package/portraits/fifth-element/medium/korben-34232.png +0 -0
  93. package/portraits/fifth-element/medium/leeloo-54333.png +0 -0
  94. package/portraits/fifth-element/medium/lindberg-34432.png +0 -0
  95. package/portraits/fifth-element/medium/mondoshawan-55131.png +0 -0
  96. package/portraits/fifth-element/medium/munro-25321.png +0 -0
  97. package/portraits/fifth-element/medium/pacoli-45232.png +0 -0
  98. package/portraits/fifth-element/medium/ruby-53544.png +0 -0
  99. package/portraits/fifth-element/medium/zorg-45312.png +0 -0
  100. package/src/public/App.tsx +0 -2
  101. package/src/public/components/AgentLoadDialog.tsx +202 -0
  102. package/src/public/components/AgentPopup.tsx +3 -5
  103. package/src/public/components/ContextSparkline.tsx +56 -0
  104. package/src/public/components/ControlBar.tsx +140 -6
  105. package/src/public/components/DeadCodeDialog.tsx +169 -0
  106. package/src/public/components/DockviewWorkspace.tsx +0 -3
  107. package/src/public/components/FullFileTree.tsx +18 -4
  108. package/src/public/components/HealthGauge.tsx +181 -0
  109. package/src/public/components/MessageView.tsx +23 -6
  110. package/src/public/components/PersonaHeader.tsx +46 -3
  111. package/src/public/components/TandemPortrait.tsx +71 -0
  112. package/src/public/components/ToolCallBlock.tsx +21 -6
  113. package/src/public/components/dialogs/CodeMarkersDialog.tsx +169 -0
  114. package/src/public/components/dialogs/ComplexityDialog.tsx +163 -0
  115. package/src/public/components/dialogs/DependenciesDialog.tsx +120 -0
  116. package/src/public/components/dialogs/HotspotsDialog.tsx +451 -0
  117. package/src/public/components/dialogs/ToolDialog.tsx +43 -0
  118. package/src/public/components/panels/ACPanel.tsx +1 -1
  119. package/src/public/components/panels/AcceptanceCriteriaPanel.tsx +15 -30
  120. package/src/public/components/panels/DebugPanel.tsx +79 -3
  121. package/src/public/components/panels/GitPanel.tsx +25 -30
  122. package/src/public/components/panels/MessagePanel.tsx +44 -2
  123. package/src/public/components/panels/SettingsPanel.tsx +4 -4
  124. package/src/public/components/panels/SprintPanel.tsx +247 -123
  125. package/src/public/components/panels/index.ts +0 -1
  126. package/src/public/components/ui/dialog.tsx +3 -3
  127. package/src/public/css/theme-system.css +98 -11
  128. package/src/public/hooks/index.ts +4 -0
  129. package/src/public/hooks/useAgentLoad.ts +105 -0
  130. package/src/public/hooks/useCodeMarkers.ts +101 -0
  131. package/src/public/hooks/useColorScheme.ts +25 -10
  132. package/src/public/hooks/useComplexity.ts +80 -0
  133. package/src/public/hooks/useDeadCode.ts +99 -0
  134. package/src/public/hooks/useDependencies.ts +82 -0
  135. package/src/public/hooks/useHealthScore.ts +69 -0
  136. package/src/public/hooks/useHotspots.ts +11 -1
  137. package/src/public/hooks/usePersona.ts +26 -3
  138. package/src/public/hooks/useSprint.ts +7 -1
  139. package/src/public/styles/tailwind.css +389 -83
  140. package/src/public/utils/messageFilters.ts +77 -6
  141. package/src/public/utils/slash-commands.ts +3 -35
  142. package/dist/hooks/cyclist-pretooluse-hook.d.ts +0 -60
  143. package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +0 -1
  144. package/dist/hooks/cyclist-pretooluse-hook.js +0 -57
  145. package/dist/hooks/cyclist-pretooluse-hook.js.map +0 -1
  146. package/dist/hooks/pretooluse-hook.d.ts +0 -89
  147. package/dist/hooks/pretooluse-hook.d.ts.map +0 -1
  148. package/dist/hooks/pretooluse-hook.js +0 -235
  149. package/dist/hooks/pretooluse-hook.js.map +0 -1
  150. package/dist/notification-sound.d.ts +0 -59
  151. package/dist/notification-sound.d.ts.map +0 -1
  152. package/dist/notification-sound.js +0 -219
  153. package/dist/notification-sound.js.map +0 -1
  154. package/src/public/types/electron.d.ts +0 -18
@@ -0,0 +1,202 @@
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { Progress } from '@/components/ui/progress';
4
+ import { Skeleton } from '@/components/ui/skeleton';
5
+ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
6
+ import { ToolDialog } from './dialogs/ToolDialog';
7
+ import { ConfirmDialog, useConfirmDialog } from './ConfirmDialog';
8
+ import { useAgentLoad } from '../hooks/useAgentLoad.js';
9
+ import type { AgentLoadEntry } from '../hooks/useAgentLoad.js';
10
+ import { formatComponentName } from './panels/DebugPanel';
11
+
12
+ const SIDECAR_FILES = ['patterns.md', 'gotchas.md', 'decisions.md'] as const;
13
+
14
+ export interface AgentLoadDialogProps {
15
+ isOpen: boolean;
16
+ onClose: () => void;
17
+ }
18
+
19
+ export function AgentLoadDialog({ isOpen, onClose }: AgentLoadDialogProps): React.ReactElement {
20
+ const { data, isLoading, error, refresh, pruneSidecar, pruneResult } = useAgentLoad();
21
+ const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
22
+ const [pendingPrune, setPendingPrune] = useState<{ agent: string; file: string } | null>(null);
23
+
24
+ const { confirm, dialogProps } = useConfirmDialog({
25
+ title: 'Clear Sidecar',
26
+ message: pendingPrune
27
+ ? `Reset ${pendingPrune.file} for ${pendingPrune.agent}? This will replace the sidecar with its default template.`
28
+ : '',
29
+ confirmLabel: 'Confirm',
30
+ isDanger: true,
31
+ });
32
+
33
+ useEffect(() => {
34
+ if (isOpen) {
35
+ refresh();
36
+ }
37
+ }, [isOpen, refresh]);
38
+
39
+ const sortedAgents = useMemo(() => {
40
+ if (!data) return [];
41
+ return [...data.agents].sort((a, b) => (b.totalTokens ?? 0) - (a.totalTokens ?? 0));
42
+ }, [data]);
43
+
44
+ const maxTokens = useMemo(() => {
45
+ if (!sortedAgents.length) return 0;
46
+ return sortedAgents[0]?.totalTokens ?? 0;
47
+ }, [sortedAgents]);
48
+
49
+ const handleRowClick = (agent: string) => {
50
+ setExpandedAgent((prev) => (prev === agent ? null : agent));
51
+ };
52
+
53
+ const handleClear = async (agent: string, file: string) => {
54
+ setPendingPrune({ agent, file });
55
+ const confirmed = await confirm();
56
+ if (confirmed) {
57
+ await pruneSidecar(agent, file);
58
+ }
59
+ setPendingPrune(null);
60
+ };
61
+
62
+ const renderAgentRow = (entry: AgentLoadEntry) => {
63
+ const tokens = entry.totalTokens ?? 0;
64
+ const progressValue = maxTokens > 0 ? (tokens / maxTokens) * 100 : 0;
65
+ const isExpanded = expandedAgent === entry.agent;
66
+ // Color by token threshold: <3k green, <5k orange, >=5k red
67
+ const barColor = tokens >= 5000
68
+ ? 'bg-[var(--status-error,#f14c4c)]'
69
+ : tokens >= 3000
70
+ ? 'bg-[var(--status-warning,#cca700)]'
71
+ : 'bg-[var(--status-success,#4ec9b0)]';
72
+
73
+ return (
74
+ <Collapsible
75
+ key={entry.agent}
76
+ open={isExpanded}
77
+ onOpenChange={() => handleRowClick(entry.agent)}
78
+ >
79
+ <CollapsibleTrigger asChild>
80
+ <div
81
+ className="px-4 py-2 cursor-pointer hover:bg-muted/50 rounded-md transition-colors"
82
+ data-testid={`agent-row-${entry.agent}`}
83
+ >
84
+ <div className="flex items-center justify-between mb-1">
85
+ <span className="font-mono text-sm font-medium">{entry.agent}</span>
86
+ <span className="font-mono text-sm tabular-nums text-text-secondary">
87
+ {tokens.toLocaleString()}
88
+ </span>
89
+ </div>
90
+ <Progress
91
+ value={progressValue}
92
+ className="h-2.5 bg-[var(--border)]"
93
+ indicatorClassName={barColor}
94
+ />
95
+ </div>
96
+ </CollapsibleTrigger>
97
+ <CollapsibleContent>
98
+ <div className="mx-4 mb-3 rounded-md bg-muted/30 p-3 space-y-1">
99
+ {entry.components && entry.components.length > 0 ? (
100
+ entry.components.map((comp) => (
101
+ <div key={comp.name} className="flex items-center justify-between text-xs text-text-secondary">
102
+ <span>{formatComponentName(comp.name)}</span>
103
+ <span className="tabular-nums">{comp.tokens.toLocaleString()}</span>
104
+ </div>
105
+ ))
106
+ ) : (
107
+ <div className="text-xs text-text-secondary">No component breakdown available</div>
108
+ )}
109
+ <div className="pt-2 border-t border-muted/50 mt-2">
110
+ <div className="text-xs font-medium mb-1.5 text-text-secondary">Sidecars</div>
111
+ <div className="flex gap-2">
112
+ {SIDECAR_FILES.map((file) => (
113
+ <Button
114
+ key={file}
115
+ variant="ghost"
116
+ size="sm"
117
+ className="text-xs h-6"
118
+ onClick={(e) => {
119
+ e.stopPropagation();
120
+ handleClear(entry.agent, file);
121
+ }}
122
+ >
123
+ Clear {file}
124
+ </Button>
125
+ ))}
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </CollapsibleContent>
130
+ </Collapsible>
131
+ );
132
+ };
133
+
134
+ const renderContent = () => {
135
+ if (isLoading) {
136
+ return (
137
+ <div className="space-y-3 p-4">
138
+ {Array.from({ length: 5 }).map((_, i) => (
139
+ <div key={i} data-testid={`skeleton-${i}`} className="space-y-1.5 px-4">
140
+ <div className="flex justify-between">
141
+ <Skeleton className="h-4 w-24" />
142
+ <Skeleton className="h-4 w-16" />
143
+ </div>
144
+ <Skeleton className="h-2.5 w-full rounded-full" />
145
+ </div>
146
+ ))}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ if (error) {
152
+ return (
153
+ <div className="p-4 space-y-3">
154
+ <div className="p-4 rounded border border-[var(--status-error)]/20 bg-[var(--status-error)]/5 text-[var(--status-error)] text-sm">
155
+ {error.message}
156
+ </div>
157
+ <div className="text-center">
158
+ <Button variant="outline" size="sm" onClick={refresh}>
159
+ Retry
160
+ </Button>
161
+ </div>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ if (!data) return null;
167
+
168
+ return (
169
+ <div className="space-y-0.5">
170
+ {pruneResult?.success && pruneResult.tokensFreed != null && (
171
+ <div className="text-xs text-green-600 px-4 py-1">
172
+ Freed {pruneResult.tokensFreed.toLocaleString()} tokens from {pruneResult.agent}/{pruneResult.file}
173
+ </div>
174
+ )}
175
+ <div className="flex items-center justify-between px-4 py-2">
176
+ <span data-testid="cached-at" className="text-xs text-[var(--text-muted)]">
177
+ Cached: {new Date(data.cachedAt).toLocaleString()}
178
+ </span>
179
+ <Button variant="ghost" size="sm" className="text-xs h-6 text-[var(--text-muted)] hover:text-[var(--text-primary)]" onClick={refresh}>
180
+ Refresh
181
+ </Button>
182
+ </div>
183
+ {sortedAgents.map((entry) => renderAgentRow(entry))}
184
+ </div>
185
+ );
186
+ };
187
+
188
+ return (
189
+ <>
190
+ <ToolDialog
191
+ open={isOpen}
192
+ onOpenChange={(open) => { if (!open) onClose(); }}
193
+ title="Agent Load Analysis"
194
+ description="Token usage breakdown for all agents"
195
+ className="max-w-2xl"
196
+ >
197
+ {renderContent()}
198
+ </ToolDialog>
199
+ <ConfirmDialog {...dialogProps} />
200
+ </>
201
+ );
202
+ }
@@ -183,11 +183,9 @@ export function AgentPopup({ isOpen, onClose, currentRole, currentTheme }: Agent
183
183
  <div className="agent-popup-header">
184
184
  <h2 id="agent-popup-title" className="agent-popup-theme">
185
185
  {themeData.themeName}
186
- {themeData.tier && (
187
- <Badge variant="secondary" className={`tier-badge tier-${themeData.tier.toLowerCase()}`}>
188
- {themeData.tier}
189
- </Badge>
190
- )}
186
+ <Badge variant="secondary" className={`tier-badge ${themeData.tier ? `tier-${themeData.tier.toLowerCase()}` : 'tier-unranked'}`}>
187
+ {themeData.tier || 'Unranked'}
188
+ </Badge>
191
189
  </h2>
192
190
  <Button
193
191
  variant="ghost"
@@ -0,0 +1,56 @@
1
+ import React from 'react';
2
+
3
+ export interface SparklinePoint {
4
+ percent: number;
5
+ tokens: number;
6
+ timestamp: number;
7
+ }
8
+
9
+ export interface ContextSparklineProps {
10
+ history: SparklinePoint[];
11
+ }
12
+
13
+ function getBarColor(tokens: number): string {
14
+ if (tokens >= 50000) return 'var(--color-danger, #ef4444)';
15
+ if (tokens >= 5000) return 'var(--color-warning, #f59e0b)';
16
+ return 'var(--color-success, #22c55e)';
17
+ }
18
+
19
+ export function ContextSparkline({ history }: ContextSparklineProps): React.ReactElement | null {
20
+ if (history.length < 2) return null;
21
+
22
+ const W = 200;
23
+ const H = 32;
24
+ const PAD = 2;
25
+ const usable = H - PAD * 2;
26
+ const latest = history[history.length - 1];
27
+ const barWidth = W / history.length;
28
+
29
+ return (
30
+ <div className="context-sparkline" data-testid="context-sparkline">
31
+ <svg
32
+ viewBox={`0 0 ${W} ${H}`}
33
+ role="img"
34
+ aria-label={`Context usage trend: currently ${latest.percent}%`}
35
+ >
36
+ {history.map((p, i) => {
37
+ const barHeight = Math.max(1, (p.percent / 100) * usable);
38
+ const x = i * barWidth;
39
+ const y = PAD + usable - barHeight;
40
+ return (
41
+ <rect
42
+ key={i}
43
+ x={x}
44
+ y={y}
45
+ width={barWidth}
46
+ height={barHeight}
47
+ fill={getBarColor(p.tokens)}
48
+ opacity={0.8}
49
+ rx={0.5}
50
+ />
51
+ );
52
+ })}
53
+ </svg>
54
+ </div>
55
+ );
56
+ }
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * ControlBar Component
3
3
  *
4
- * Provides Stop, Reset, Bell Mode, and Relay Mode controls for Claude sessions.
4
+ * Provides Stop, Reset, Bell Mode, Relay Mode, and Agent Quick Picker controls.
5
5
  * Story MSSCI-12729 - Stop/Reset Controls and Escape Key
6
6
  * Story MSSCI-12275 - Bell Mode toggle
7
7
  * Story MSSCI-12395 - Relay Mode toggle
8
+ * Story MSSCI-14762 - Quick agent picker in control bar
8
9
  *
9
10
  * Features:
10
11
  * - Stop button visible only when Claude is running
11
12
  * - Reset button always visible
13
+ * - Agent quick picker (lightweight dropdown for rapid agent switching)
12
14
  * - Bell mode toggle (inject queued messages via PostToolUse hook)
13
15
  * - Relay mode toggle (auto-handoff to next agent)
14
16
  * - Escape key handler for stopping (single press = interrupt, double = force kill)
@@ -16,6 +18,7 @@
16
18
  */
17
19
 
18
20
  import React, { useEffect, useRef, useCallback, useState, FocusEvent } from 'react';
21
+ import { BellRing, Zap, RotateCcw, UserCog } from 'lucide-react';
19
22
  import { Button } from '@/components/ui/button';
20
23
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
21
24
  import { useClaudeContext } from '../contexts/ClaudeContext';
@@ -40,6 +43,123 @@ function useFocusTracking() {
40
43
  return { handleFocus, handleBlur, isFocused };
41
44
  }
42
45
 
46
+ // =============================================================================
47
+ // Agent Quick Picker
48
+ // =============================================================================
49
+
50
+ interface ThemeAgent {
51
+ role: string;
52
+ character: string;
53
+ slug: string;
54
+ }
55
+
56
+ interface ThemeData {
57
+ agents: ThemeAgent[];
58
+ }
59
+
60
+ function AgentQuickPicker({ currentAgent, onAgentSwitch }: { currentAgent: string | null; onAgentSwitch?: (role: string) => void }): React.ReactElement {
61
+ const [isOpen, setIsOpen] = useState(false);
62
+ const [agents, setAgents] = useState<ThemeAgent[]>([]);
63
+ const pickerRef = useRef<HTMLDivElement>(null);
64
+
65
+ // Fetch agent list on mount
66
+ useEffect(() => {
67
+ fetch('/api/theme-agents/full')
68
+ .then(res => res.ok ? res.json() : null)
69
+ .then((data: ThemeData | null) => {
70
+ if (data?.agents) {
71
+ setAgents(data.agents);
72
+ }
73
+ })
74
+ .catch(() => {});
75
+ }, []);
76
+
77
+ // Close on outside click
78
+ useEffect(() => {
79
+ if (!isOpen) return;
80
+
81
+ const handleMouseDown = (e: MouseEvent) => {
82
+ if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
83
+ setIsOpen(false);
84
+ }
85
+ };
86
+
87
+ document.addEventListener('mousedown', handleMouseDown);
88
+ return () => document.removeEventListener('mousedown', handleMouseDown);
89
+ }, [isOpen]);
90
+
91
+ // Close on Escape
92
+ useEffect(() => {
93
+ if (!isOpen) return;
94
+
95
+ const handleKeyDown = (e: KeyboardEvent) => {
96
+ if (e.key === 'Escape') {
97
+ e.stopPropagation();
98
+ setIsOpen(false);
99
+ }
100
+ };
101
+
102
+ document.addEventListener('keydown', handleKeyDown, true);
103
+ return () => document.removeEventListener('keydown', handleKeyDown, true);
104
+ }, [isOpen]);
105
+
106
+ const handleAgentClick = useCallback((agent: ThemeAgent) => {
107
+ if (agent.role === currentAgent) return;
108
+ onAgentSwitch?.(agent.role);
109
+ setIsOpen(false);
110
+ }, [currentAgent, onAgentSwitch]);
111
+
112
+ return (
113
+ <div className="agent-quick-picker-wrapper" ref={pickerRef}>
114
+ <Tooltip>
115
+ <TooltipTrigger asChild>
116
+ <Button
117
+ variant="ghost"
118
+ size="icon"
119
+ type="button"
120
+ className={`btn-toggle agent-picker-toggle ${isOpen ? 'active' : ''}`}
121
+ data-testid="agent-quick-picker"
122
+ onClick={() => setIsOpen(prev => !prev)}
123
+ aria-label="Switch agent"
124
+ aria-expanded={isOpen}
125
+ aria-haspopup="listbox"
126
+ >
127
+ <UserCog className="h-4 w-4" />
128
+ </Button>
129
+ </TooltipTrigger>
130
+ <TooltipContent>Switch Agent</TooltipContent>
131
+ </Tooltip>
132
+
133
+ {isOpen && (
134
+ <div
135
+ className="agent-quick-picker-dropdown"
136
+ data-testid="agent-quick-picker-dropdown"
137
+ role="listbox"
138
+ aria-label="Available agents"
139
+ >
140
+ {agents.map(agent => {
141
+ const isCurrent = agent.role === currentAgent;
142
+ return (
143
+ <div
144
+ key={agent.role}
145
+ className={`agent-quick-picker-option ${isCurrent ? 'current' : ''}`}
146
+ data-testid={`agent-option-${agent.role}`}
147
+ role="option"
148
+ aria-selected={isCurrent}
149
+ aria-label={`${agent.role} (${agent.character})`}
150
+ title={agent.character}
151
+ onClick={() => handleAgentClick(agent)}
152
+ >
153
+ <span className="agent-option-role">{agent.role}</span>
154
+ </div>
155
+ );
156
+ })}
157
+ </div>
158
+ )}
159
+ </div>
160
+ );
161
+ }
162
+
43
163
  // =============================================================================
44
164
  // Types
45
165
  // =============================================================================
@@ -69,6 +189,8 @@ export interface ControlBarProps {
69
189
  currentAgent?: string | null;
70
190
  /** Called when TirePump button clicked */
71
191
  onTirePump?: () => void;
192
+ /** Called when agent is selected from quick picker */
193
+ onAgentSwitch?: (role: string) => void;
72
194
  }
73
195
 
74
196
  // =============================================================================
@@ -88,6 +210,7 @@ export function ControlBar({
88
210
  contextPercent = 0,
89
211
  currentAgent = null,
90
212
  onTirePump,
213
+ onAgentSwitch,
91
214
  }: ControlBarProps): React.ReactElement {
92
215
  const lastEscapeTime = useRef<number>(0);
93
216
  const DOUBLE_PRESS_THRESHOLD = 500; // ms
@@ -134,8 +257,11 @@ export function ControlBar({
134
257
  return (
135
258
  <TooltipProvider delayDuration={300}>
136
259
  <div className="control-bar" data-testid="control-bar">
137
- {/* Mode toggles - Bell and Relay */}
260
+ {/* Mode toggles - Agent Picker, Bell, and Relay */}
138
261
  <div className="control-bar-toggles">
262
+ {/* Agent Quick Picker */}
263
+ <AgentQuickPicker currentAgent={currentAgent} onAgentSwitch={onAgentSwitch} />
264
+
139
265
  {/* Bell Mode Toggle */}
140
266
  <Tooltip>
141
267
  <TooltipTrigger asChild>
@@ -149,7 +275,7 @@ export function ControlBar({
149
275
  aria-pressed={bellMode}
150
276
  aria-label="Bell mode - inject queued messages via hook"
151
277
  >
152
- <span className="toggle-icon">🔔</span>
278
+ <BellRing className="h-4 w-4" />
153
279
  </Button>
154
280
  </TooltipTrigger>
155
281
  <TooltipContent>Bell Mode: Inject queued messages during tool use (Cmd+B)</TooltipContent>
@@ -168,7 +294,7 @@ export function ControlBar({
168
294
  aria-pressed={relayMode}
169
295
  aria-label="Relay mode - auto-handoff to next agent"
170
296
  >
171
- <span className="toggle-icon">✋</span>
297
+ <Zap className="h-4 w-4" />
172
298
  </Button>
173
299
  </TooltipTrigger>
174
300
  <TooltipContent>Relay Mode: Auto-handoff to next agent (Cmd+4)</TooltipContent>
@@ -187,7 +313,7 @@ export function ControlBar({
187
313
  disabled={!currentAgent}
188
314
  aria-label="TirePump: Clear context and reload agent"
189
315
  >
190
- <span className="toggle-icon">⬆️</span>
316
+ <RotateCcw className="h-4 w-4" />
191
317
  </Button>
192
318
  </TooltipTrigger>
193
319
  <TooltipContent>{currentAgent ? `TirePump: Clear context (${contextPercent}%) and reload ${currentAgent}` : 'TirePump: No agent loaded'}</TooltipContent>
@@ -265,6 +391,8 @@ interface UseControlBarResult {
265
391
  handleRelayModeChange: (enabled: boolean) => void;
266
392
  /** Handle TirePump action */
267
393
  handleTirePump: () => void;
394
+ /** Handle agent switch from quick picker */
395
+ handleAgentSwitch: (role: string) => void;
268
396
  }
269
397
 
270
398
  export function useControlBar(): UseControlBarResult {
@@ -276,7 +404,7 @@ export function useControlBar(): UseControlBarResult {
276
404
  const [currentAgent, setCurrentAgent] = useState<string | null>(null);
277
405
 
278
406
  // Claude context for WebSocket communication
279
- const { abort, clear, clearAndReload, onMessage, onComplete, onError, isConnected } = useClaudeContext();
407
+ const { abort, clear, clearAndReload, send, onMessage, onComplete, onError, isConnected } = useClaudeContext();
280
408
 
281
409
  // Load initial settings and listen for changes (using REST/WebSocket, not IPC)
282
410
  useEffect(() => {
@@ -483,6 +611,11 @@ export function useControlBar(): UseControlBarResult {
483
611
  }
484
612
  }, [currentAgent, clearAndReload]);
485
613
 
614
+ // Agent quick picker: send /{role} command
615
+ const handleAgentSwitch = useCallback((role: string) => {
616
+ send(`/${role}`);
617
+ }, [send]);
618
+
486
619
  return {
487
620
  isRunning,
488
621
  isStopping,
@@ -496,6 +629,7 @@ export function useControlBar(): UseControlBarResult {
496
629
  handleBellModeChange,
497
630
  handleRelayModeChange,
498
631
  handleTirePump,
632
+ handleAgentSwitch,
499
633
  };
500
634
  }
501
635
 
@@ -0,0 +1,169 @@
1
+ import React, { useState, useMemo, useCallback, useEffect } from 'react';
2
+ import {
3
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
4
+ } from '@/components/ui/dialog';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { ScrollArea } from '@/components/ui/scroll-area';
7
+ import { cn } from '@/lib/utils';
8
+ import { useDeadCode, type StaleFile, type UnusedExport } from '@/hooks/useDeadCode';
9
+
10
+ export interface DeadCodeDialogProps {
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
+ days?: number;
14
+ repo?: string;
15
+ }
16
+
17
+ type TabId = 'stale' | 'exports';
18
+ type SortDir = 'asc' | 'desc';
19
+
20
+ export function DeadCodeDialog({ isOpen, onClose, days = 180, repo }: DeadCodeDialogProps) {
21
+ const { data, isLoading, error, refresh } = useDeadCode({ days, repo, layer: 'all' });
22
+ const [activeTab, setActiveTab] = useState<TabId>('stale');
23
+ const [staleSortField, setStaleSortField] = useState<keyof StaleFile>('days_since_last_commit');
24
+ const [staleSortDir, setStaleSortDir] = useState<SortDir>('desc');
25
+ const [exportSortField, setExportSortField] = useState<keyof UnusedExport>('file');
26
+ const [exportSortDir, setExportSortDir] = useState<SortDir>('asc');
27
+
28
+ useEffect(() => {
29
+ if (isOpen) refresh();
30
+ }, [isOpen, refresh]);
31
+
32
+ const handleStaleSort = useCallback((field: keyof StaleFile) => {
33
+ setStaleSortDir(prev => staleSortField === field && prev === 'desc' ? 'asc' : 'desc');
34
+ setStaleSortField(field);
35
+ }, [staleSortField]);
36
+
37
+ const handleExportSort = useCallback((field: keyof UnusedExport) => {
38
+ setExportSortDir(prev => exportSortField === field && prev === 'desc' ? 'asc' : 'desc');
39
+ setExportSortField(field);
40
+ }, [exportSortField]);
41
+
42
+ const staleCount = data?.stale_file_count ?? data?.stale_files?.length ?? 0;
43
+ const exportCount = data?.unused_export_count ?? data?.unused_exports?.length ?? 0;
44
+
45
+ const sortedStaleFiles = useMemo(() => {
46
+ const files = [...(data?.stale_files || [])];
47
+ files.sort((a, b) => {
48
+ const aVal = a[staleSortField];
49
+ const bVal = b[staleSortField];
50
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
51
+ return staleSortDir === 'asc' ? aVal - bVal : bVal - aVal;
52
+ }
53
+ const aStr = String(aVal);
54
+ const bStr = String(bVal);
55
+ return staleSortDir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
56
+ });
57
+ return files;
58
+ }, [data?.stale_files, staleSortField, staleSortDir]);
59
+
60
+ const sortedExports = useMemo(() => {
61
+ const exports = [...(data?.unused_exports || [])];
62
+ exports.sort((a, b) => {
63
+ const aVal = a[exportSortField];
64
+ const bVal = b[exportSortField];
65
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
66
+ return exportSortDir === 'asc' ? aVal - bVal : bVal - aVal;
67
+ }
68
+ const aStr = String(aVal);
69
+ const bStr = String(bVal);
70
+ return exportSortDir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
71
+ });
72
+ return exports;
73
+ }, [data?.unused_exports, exportSortField, exportSortDir]);
74
+
75
+ return (
76
+ <Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
77
+ <DialogContent className="max-w-4xl max-h-[80vh]">
78
+ <DialogHeader>
79
+ <DialogTitle>Dead Code Analysis</DialogTitle>
80
+ <DialogDescription>
81
+ Diagnostic report — files and exports with no recent activity.
82
+ </DialogDescription>
83
+ </DialogHeader>
84
+
85
+ {/* Tab bar */}
86
+ <div className="flex gap-4 border-b border-[var(--border)]">
87
+ <button
88
+ onClick={() => setActiveTab('stale')}
89
+ className={cn('pb-2 text-sm', activeTab === 'stale' ? 'border-b-2 border-[var(--accent)] text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]')}
90
+ >
91
+ Stale Files <Badge variant="secondary" className="ml-1">{staleCount}</Badge>
92
+ </button>
93
+ <button
94
+ onClick={() => setActiveTab('exports')}
95
+ className={cn('pb-2 text-sm', activeTab === 'exports' ? 'border-b-2 border-[var(--accent)] text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]')}
96
+ >
97
+ Unused Exports <Badge variant="secondary" className="ml-1">{exportCount}</Badge>
98
+ </button>
99
+ </div>
100
+
101
+ {/* Content area */}
102
+ <ScrollArea className="h-[50vh]">
103
+ {isLoading && <div className="text-center py-12 text-[var(--text-muted)]">Analyzing...</div>}
104
+ {error && (
105
+ <div className="m-4 p-4 rounded border border-[var(--status-error)]/20 bg-[var(--status-error)]/5 text-[var(--status-error)] text-sm">
106
+ {error.message}
107
+ </div>
108
+ )}
109
+ {data && activeTab === 'stale' && (
110
+ sortedStaleFiles.length === 0 ? (
111
+ <div className="text-center py-12 text-[var(--text-muted)]">No stale files found</div>
112
+ ) : (
113
+ <table className="w-full text-sm">
114
+ <thead>
115
+ <tr role="row" className="border-b border-[var(--border)]">
116
+ <th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('path')}>File</th>
117
+ <th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('days_since_last_commit')}>Days Stale</th>
118
+ <th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('size_bytes')}>Size</th>
119
+ <th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('last_commit_date')}>Last Commit</th>
120
+ </tr>
121
+ </thead>
122
+ <tbody>
123
+ {sortedStaleFiles.map((file) => (
124
+ <tr key={file.path} role="row" className="text-[var(--text-primary)]">
125
+ <td className="py-1.5 font-mono text-xs">{file.path}</td>
126
+ <td className="py-1.5 text-right tabular-nums font-mono">{file.days_since_last_commit}</td>
127
+ <td className="py-1.5 text-right tabular-nums font-mono">{formatBytes(file.size_bytes)}</td>
128
+ <td className="py-1.5">{file.last_commit_date ? new Date(file.last_commit_date).toLocaleDateString() : '—'}</td>
129
+ </tr>
130
+ ))}
131
+ </tbody>
132
+ </table>
133
+ )
134
+ )}
135
+ {data && activeTab === 'exports' && (
136
+ sortedExports.length === 0 ? (
137
+ <div className="text-center py-12 text-[var(--text-muted)]">No unused exports found</div>
138
+ ) : (
139
+ <table className="w-full text-sm">
140
+ <thead>
141
+ <tr role="row" className="border-b border-[var(--border)]">
142
+ <th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('file')}>File</th>
143
+ <th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('symbol')}>Export</th>
144
+ <th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('line')}>Line</th>
145
+ </tr>
146
+ </thead>
147
+ <tbody>
148
+ {sortedExports.map((exp) => (
149
+ <tr key={`${exp.file}:${exp.symbol}`} role="row" className="text-[var(--text-primary)]">
150
+ <td className="py-1.5 font-mono text-xs">{exp.file}</td>
151
+ <td className="py-1.5">{exp.symbol}</td>
152
+ <td className="py-1.5 text-right tabular-nums font-mono">{exp.line}</td>
153
+ </tr>
154
+ ))}
155
+ </tbody>
156
+ </table>
157
+ )
158
+ )}
159
+ </ScrollArea>
160
+ </DialogContent>
161
+ </Dialog>
162
+ );
163
+ }
164
+
165
+ function formatBytes(bytes: number): string {
166
+ if (bytes < 1024) return `${bytes} B`;
167
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
168
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
169
+ }