@pennyfarthing/cyclist 9.3.0 → 10.0.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 (101) hide show
  1. package/dist/api/hook-request.d.ts +11 -0
  2. package/dist/api/hook-request.d.ts.map +1 -1
  3. package/dist/api/hook-request.js +126 -28
  4. package/dist/api/hook-request.js.map +1 -1
  5. package/dist/api/hotspots.d.ts +3 -0
  6. package/dist/api/hotspots.d.ts.map +1 -0
  7. package/dist/api/hotspots.js +54 -0
  8. package/dist/api/hotspots.js.map +1 -0
  9. package/dist/api/index.d.ts +2 -0
  10. package/dist/api/index.d.ts.map +1 -1
  11. package/dist/api/index.js +3 -0
  12. package/dist/api/index.js.map +1 -1
  13. package/dist/api/permissions.d.ts +16 -0
  14. package/dist/api/permissions.d.ts.map +1 -0
  15. package/dist/api/permissions.js +67 -0
  16. package/dist/api/permissions.js.map +1 -0
  17. package/dist/api/settings.d.ts +1 -1
  18. package/dist/api/settings.d.ts.map +1 -1
  19. package/dist/api/settings.js +44 -17
  20. package/dist/api/settings.js.map +1 -1
  21. package/dist/api/theme-agents.d.ts +4 -0
  22. package/dist/api/theme-agents.d.ts.map +1 -1
  23. package/dist/api/theme-agents.js +3 -0
  24. package/dist/api/theme-agents.js.map +1 -1
  25. package/dist/approval-gate.d.ts +3 -75
  26. package/dist/approval-gate.d.ts.map +1 -1
  27. package/dist/approval-gate.js +4 -121
  28. package/dist/approval-gate.js.map +1 -1
  29. package/dist/hooks/cyclist-pretooluse-hook.d.ts +60 -0
  30. package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +1 -0
  31. package/dist/hooks/cyclist-pretooluse-hook.js +57 -0
  32. package/dist/hooks/cyclist-pretooluse-hook.js.map +1 -0
  33. package/dist/hooks/pretooluse-hook.d.ts +89 -0
  34. package/dist/hooks/pretooluse-hook.d.ts.map +1 -0
  35. package/dist/hooks/pretooluse-hook.js +235 -0
  36. package/dist/hooks/pretooluse-hook.js.map +1 -0
  37. package/dist/main.d.ts +1 -134
  38. package/dist/main.d.ts.map +1 -1
  39. package/dist/main.js +42 -373
  40. package/dist/main.js.map +1 -1
  41. package/dist/menu-builder.d.ts +7 -1
  42. package/dist/menu-builder.d.ts.map +1 -1
  43. package/dist/menu-builder.js +36 -1
  44. package/dist/menu-builder.js.map +1 -1
  45. package/dist/otlp-receiver.d.ts.map +1 -1
  46. package/dist/otlp-receiver.js +6 -0
  47. package/dist/otlp-receiver.js.map +1 -1
  48. package/dist/public/css/react.css +1 -1
  49. package/dist/public/js/react/react.js +42 -42
  50. package/dist/server.d.ts.map +1 -1
  51. package/dist/server.js +16 -3
  52. package/dist/server.js.map +1 -1
  53. package/dist/settings-store.d.ts +3 -1
  54. package/dist/settings-store.d.ts.map +1 -1
  55. package/dist/settings-store.js +18 -9
  56. package/dist/settings-store.js.map +1 -1
  57. package/dist/story-parser.d.ts +17 -0
  58. package/dist/story-parser.d.ts.map +1 -1
  59. package/dist/story-parser.js +183 -13
  60. package/dist/story-parser.js.map +1 -1
  61. package/dist/websocket.d.ts +1 -0
  62. package/dist/websocket.d.ts.map +1 -1
  63. package/dist/websocket.js +48 -5
  64. package/dist/websocket.js.map +1 -1
  65. package/dist/workflow-presets.d.ts +72 -0
  66. package/dist/workflow-presets.d.ts.map +1 -0
  67. package/dist/workflow-presets.js +93 -0
  68. package/dist/workflow-presets.js.map +1 -0
  69. package/package.json +2 -2
  70. package/src/public/App.tsx +61 -1
  71. package/src/public/components/ApprovalModal/index.tsx +31 -1
  72. package/src/public/components/ControlBar.tsx +19 -20
  73. package/src/public/components/DockviewWorkspace.tsx +39 -5
  74. package/src/public/components/FontPicker/index.tsx +118 -33
  75. package/src/public/components/FullFileTree.tsx +223 -0
  76. package/src/public/components/Message.tsx +89 -11
  77. package/src/public/components/MessageView.tsx +206 -93
  78. package/src/public/components/PersonaHeader.tsx +47 -15
  79. package/src/public/components/SubagentSpan.tsx +15 -8
  80. package/src/public/components/panels/BackgroundPanel.tsx +1 -1
  81. package/src/public/components/panels/ChangedPanel.tsx +30 -44
  82. package/src/public/components/panels/HotspotsPanel.tsx +365 -0
  83. package/src/public/components/panels/MessagePanel.tsx +79 -5
  84. package/src/public/components/panels/SettingsPanel.tsx +3 -28
  85. package/src/public/components/panels/WorkflowPanel.tsx +108 -13
  86. package/src/public/components/panels/index.ts +1 -0
  87. package/src/public/contexts/ClaudeContext.tsx +16 -1
  88. package/src/public/css/theme-system.css +46 -38
  89. package/src/public/hooks/useColorScheme.ts +27 -0
  90. package/src/public/hooks/useFileBrowser.ts +71 -0
  91. package/src/public/hooks/useHotspots.ts +113 -0
  92. package/src/public/hooks/usePlanModeExit.ts +105 -0
  93. package/src/public/hooks/useStory.ts +12 -3
  94. package/src/public/images/cyclist-dark.png +0 -0
  95. package/src/public/images/cyclist-light.png +0 -0
  96. package/src/public/styles/dockview-theme.css +31 -33
  97. package/src/public/styles/tailwind.css +417 -58
  98. package/src/public/types/message.ts +6 -1
  99. package/src/public/utils/markdown.ts +2 -2
  100. package/src/public/utils/slash-commands.ts +1 -1
  101. package/src/public/utils/toolStackGrouper.ts +5 -6
@@ -0,0 +1,223 @@
1
+ /**
2
+ * FullFileTree - Complete directory tree with changed file highlighting
3
+ *
4
+ * Displays the full project file tree with lazy-loaded directories.
5
+ * Changed files are highlighted with git status colors (created/modified/deleted).
6
+ * Uses /api/files for directory listing and /ws/git for change status.
7
+ */
8
+
9
+ import React, { useState, useCallback, useEffect } from 'react';
10
+ import { Badge } from '@/components/ui/badge';
11
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
12
+ import { ScrollArea } from '@/components/ui/scroll-area';
13
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
14
+ import { useFileBrowser, DirectoryEntry } from '../hooks/useFileBrowser';
15
+ import type { FileStatus } from './FileTree';
16
+
17
+ // =============================================================================
18
+ // Types
19
+ // =============================================================================
20
+
21
+ export interface FullFileTreeProps {
22
+ /** Map of file path → git status for highlighting */
23
+ changedFiles: Map<string, FileStatus>;
24
+ /** Callback when a file is clicked */
25
+ onFileClick?: (entry: DirectoryEntry, status?: FileStatus) => void;
26
+ }
27
+
28
+ // =============================================================================
29
+ // File Item Component
30
+ // =============================================================================
31
+
32
+ function TreeFileItem({
33
+ entry,
34
+ status,
35
+ depth,
36
+ onFileClick,
37
+ }: {
38
+ entry: DirectoryEntry;
39
+ status?: FileStatus;
40
+ depth: number;
41
+ onFileClick?: (entry: DirectoryEntry, status?: FileStatus) => void;
42
+ }): React.ReactElement {
43
+ const statusIcon = status === 'created' ? '+' : status === 'modified' ? '~' : status === 'deleted' ? '-' : null;
44
+
45
+ return (
46
+ <Tooltip>
47
+ <TooltipTrigger asChild>
48
+ <div
49
+ role="treeitem"
50
+ className={`file-item${status ? ` file-${status}` : ''}`}
51
+ style={{ paddingLeft: `${12 + depth * 16}px` }}
52
+ tabIndex={0}
53
+ aria-label={`${entry.name}${status ? `, ${status}` : ''}`}
54
+ onClick={() => onFileClick?.(entry, status)}
55
+ onKeyDown={(e) => {
56
+ if (e.key === 'Enter' || e.key === ' ') {
57
+ e.preventDefault();
58
+ onFileClick?.(entry, status);
59
+ }
60
+ }}
61
+ >
62
+ {statusIcon && (
63
+ <span className={`status-icon status-${status}`} aria-hidden="true">
64
+ {statusIcon}
65
+ </span>
66
+ )}
67
+ <span className={`file-name${status === 'deleted' ? '' : ''}`}>{entry.name}</span>
68
+ </div>
69
+ </TooltipTrigger>
70
+ <TooltipContent>{entry.path}</TooltipContent>
71
+ </Tooltip>
72
+ );
73
+ }
74
+
75
+ // =============================================================================
76
+ // Directory Node Component (recursive)
77
+ // =============================================================================
78
+
79
+ function TreeDirectoryNode({
80
+ entry,
81
+ depth,
82
+ changedFiles,
83
+ onFileClick,
84
+ fetchDirectory,
85
+ cache,
86
+ loading,
87
+ }: {
88
+ entry: DirectoryEntry;
89
+ depth: number;
90
+ changedFiles: Map<string, FileStatus>;
91
+ onFileClick?: (entry: DirectoryEntry, status?: FileStatus) => void;
92
+ fetchDirectory: (path: string) => Promise<void>;
93
+ cache: Record<string, DirectoryEntry[]>;
94
+ loading: Set<string>;
95
+ }): React.ReactElement {
96
+ const [isOpen, setIsOpen] = useState(false);
97
+ const children = cache[entry.path];
98
+ const isLoading = loading.has(entry.path);
99
+
100
+ // Check if this directory contains any changed files
101
+ const hasChanges = Array.from(changedFiles.keys()).some(
102
+ (filePath) => filePath.startsWith(entry.path + '/')
103
+ );
104
+
105
+ const handleToggle = useCallback(() => {
106
+ const willOpen = !isOpen;
107
+ setIsOpen(willOpen);
108
+ if (willOpen && !children) {
109
+ fetchDirectory(entry.path);
110
+ }
111
+ }, [isOpen, children, entry.path, fetchDirectory]);
112
+
113
+ return (
114
+ <Collapsible open={isOpen} onOpenChange={handleToggle}>
115
+ <CollapsibleTrigger asChild>
116
+ <div
117
+ className={`directory-header${hasChanges ? ' has-changes' : ''}`}
118
+ style={{ paddingLeft: `${4 + depth * 16}px` }}
119
+ >
120
+ <span className="directory-toggle">
121
+ <span className="toggle-icon">{isOpen ? '▼' : '▶'}</span>
122
+ </span>
123
+ <span className="directory-name">{entry.name}</span>
124
+ </div>
125
+ </CollapsibleTrigger>
126
+ <CollapsibleContent>
127
+ {isLoading && (
128
+ <div
129
+ className="tree-loading"
130
+ style={{ paddingLeft: `${12 + (depth + 1) * 16}px` }}
131
+ >
132
+ Loading...
133
+ </div>
134
+ )}
135
+ {children?.map((child) =>
136
+ child.type === 'directory' ? (
137
+ <TreeDirectoryNode
138
+ key={child.path}
139
+ entry={child}
140
+ depth={depth + 1}
141
+ changedFiles={changedFiles}
142
+ onFileClick={onFileClick}
143
+ fetchDirectory={fetchDirectory}
144
+ cache={cache}
145
+ loading={loading}
146
+ />
147
+ ) : (
148
+ <TreeFileItem
149
+ key={child.path}
150
+ entry={child}
151
+ status={changedFiles.get(child.path)}
152
+ depth={depth + 1}
153
+ onFileClick={onFileClick}
154
+ />
155
+ )
156
+ )}
157
+ </CollapsibleContent>
158
+ </Collapsible>
159
+ );
160
+ }
161
+
162
+ // =============================================================================
163
+ // FullFileTree Component
164
+ // =============================================================================
165
+
166
+ export function FullFileTree({ changedFiles, onFileClick }: FullFileTreeProps): React.ReactElement {
167
+ const { cache, loading, error, fetchDirectory } = useFileBrowser();
168
+
169
+ // Load root directory on mount
170
+ useEffect(() => {
171
+ fetchDirectory('');
172
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
173
+
174
+ const rootEntries = cache['__root__'];
175
+ const changedCount = changedFiles.size;
176
+
177
+ return (
178
+ <TooltipProvider delayDuration={300}>
179
+ <div role="tree" aria-label="Project files" className="filetree full-filetree">
180
+ {changedCount > 0 && (
181
+ <Badge
182
+ variant="secondary"
183
+ data-testid="file-count-badge"
184
+ className="file-count-badge"
185
+ aria-label={`${changedCount} files changed`}
186
+ >
187
+ {changedCount}
188
+ </Badge>
189
+ )}
190
+ <ScrollArea className="filetree-scroll">
191
+ {error && <div className="tree-error">{error}</div>}
192
+ {!rootEntries && !error && (
193
+ <div className="tree-loading">Loading project files...</div>
194
+ )}
195
+ {rootEntries?.map((entry) =>
196
+ entry.type === 'directory' ? (
197
+ <TreeDirectoryNode
198
+ key={entry.path}
199
+ entry={entry}
200
+ depth={0}
201
+ changedFiles={changedFiles}
202
+ onFileClick={onFileClick}
203
+ fetchDirectory={fetchDirectory}
204
+ cache={cache}
205
+ loading={loading}
206
+ />
207
+ ) : (
208
+ <TreeFileItem
209
+ key={entry.path}
210
+ entry={entry}
211
+ status={changedFiles.get(entry.path)}
212
+ depth={0}
213
+ onFileClick={onFileClick}
214
+ />
215
+ )
216
+ )}
217
+ </ScrollArea>
218
+ </div>
219
+ </TooltipProvider>
220
+ );
221
+ }
222
+
223
+ export default FullFileTree;
@@ -6,7 +6,7 @@
6
6
  * Story MSSCI-12777 - User Avatar from GitHub
7
7
  */
8
8
 
9
- import React, { useState } from 'react';
9
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
10
10
  import { Badge } from '@/components/ui/badge';
11
11
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
12
12
  import { parseMarkdown } from '../utils/markdown';
@@ -17,25 +17,33 @@ import type { MessageData } from '../types/message';
17
17
 
18
18
  interface MessageProps {
19
19
  message: MessageData;
20
+ isLastAgentMessage?: boolean;
21
+ /** Whether this is the first message in a turn (shows avatar) */
22
+ isFirstInTurn?: boolean;
20
23
  }
21
24
 
22
25
  interface AssistantAvatarProps {
23
26
  isStreaming?: boolean;
27
+ agentSlug?: string;
28
+ agentTheme?: string;
29
+ agentCharacter?: string;
24
30
  }
25
31
 
26
- function AssistantAvatar({ isStreaming }: AssistantAvatarProps): React.ReactElement {
32
+ function AssistantAvatar({ isStreaming, agentSlug, agentTheme, agentCharacter }: AssistantAvatarProps): React.ReactElement {
27
33
  const { persona } = usePersona();
28
34
  const [imageError, setImageError] = useState(false);
29
35
 
30
- const slug = persona?.slug;
31
- const theme = persona?.theme;
36
+ // Use per-message persona if available, fall back to current global persona
37
+ const slug = agentSlug || persona?.slug;
38
+ const theme = agentTheme || persona?.theme;
39
+ const character = agentCharacter || persona?.character;
32
40
  const avatarClass = isStreaming ? 'avatar-portrait avatar-thinking' : 'avatar-portrait';
33
41
 
34
42
  if (slug && theme && !imageError) {
35
43
  return (
36
44
  <img
37
45
  src={`/portraits/${theme}/small/${slug}.png`}
38
- alt={persona?.character || 'Agent'}
46
+ alt={character || 'Agent'}
39
47
  className={avatarClass}
40
48
  onError={() => setImageError(true)}
41
49
  />
@@ -67,9 +75,66 @@ function UserAvatar(): React.ReactElement {
67
75
  return <span className="avatar-emoji">👤</span>;
68
76
  }
69
77
 
70
- export default function Message({ message }: MessageProps): React.ReactElement {
78
+ function useSortableTables(contentRef: React.RefObject<HTMLDivElement | null>) {
79
+ const attachSort = useCallback(() => {
80
+ const el = contentRef.current;
81
+ if (!el) return;
82
+ const headers = el.querySelectorAll<HTMLTableCellElement>('th.sortable-th');
83
+ headers.forEach((th) => {
84
+ if (th.dataset.sortBound) return;
85
+ th.dataset.sortBound = '1';
86
+ th.style.cursor = 'pointer';
87
+ th.addEventListener('click', () => {
88
+ const colIdx = parseInt(th.dataset.col || '0', 10);
89
+ const table = th.closest('table');
90
+ if (!table) return;
91
+ const tbody = table.querySelector('tbody');
92
+ if (!tbody) return;
93
+ const rows = Array.from(tbody.querySelectorAll('tr'));
94
+ const currentDir = th.dataset.sortDir === 'asc' ? 'desc' : 'asc';
95
+
96
+ // Clear all indicators in this table
97
+ table.querySelectorAll<HTMLTableCellElement>('th.sortable-th').forEach((h) => {
98
+ h.dataset.sortDir = '';
99
+ const ind = h.querySelector('.sort-indicator');
100
+ if (ind) ind.textContent = '';
101
+ });
102
+
103
+ th.dataset.sortDir = currentDir;
104
+ const indicator = th.querySelector('.sort-indicator');
105
+ if (indicator) indicator.textContent = currentDir === 'asc' ? ' \u25B2' : ' \u25BC';
106
+
107
+ rows.sort((a, b) => {
108
+ const aText = (a.children[colIdx]?.textContent || '').trim();
109
+ const bText = (b.children[colIdx]?.textContent || '').trim();
110
+ const aNum = parseFloat(aText);
111
+ const bNum = parseFloat(bText);
112
+ // Numeric sort if both parse as numbers
113
+ if (!isNaN(aNum) && !isNaN(bNum)) {
114
+ return currentDir === 'asc' ? aNum - bNum : bNum - aNum;
115
+ }
116
+ const cmp = aText.localeCompare(bText, undefined, { sensitivity: 'base' });
117
+ return currentDir === 'asc' ? cmp : -cmp;
118
+ });
119
+
120
+ for (const row of rows) {
121
+ tbody.appendChild(row);
122
+ }
123
+ });
124
+ });
125
+ }, [contentRef]);
126
+
127
+ useEffect(() => {
128
+ attachSort();
129
+ });
130
+ }
131
+
132
+ export default function Message({ message, isLastAgentMessage, isFirstInTurn = true }: MessageProps): React.ReactElement {
133
+ const contentRef = useRef<HTMLDivElement>(null);
134
+ useSortableTables(contentRef);
71
135
  const roleClass = `message-${message.type}`;
72
136
  const testId = `message-${message.type}`;
137
+ const continuationClass = !isFirstInTurn ? ' continuation' : '';
73
138
 
74
139
  // For bell-injected messages (queued messages injected via PostToolUse hook)
75
140
  // Show with 🔔 indicator so user knows it was sent mid-turn
@@ -77,7 +142,7 @@ export default function Message({ message }: MessageProps): React.ReactElement {
77
142
  const html = message.content ? parseMarkdown(message.content) : '';
78
143
  return (
79
144
  <TooltipProvider delayDuration={300}>
80
- <div data-testid="message-bell-injected" className="message message-user message-bell-injected">
145
+ <div data-testid="message-bell-injected" className={`message message-user message-bell-injected${continuationClass}`}>
81
146
  <div data-testid="avatar" className="message-avatar">
82
147
  <UserAvatar />
83
148
  </div>
@@ -106,11 +171,18 @@ export default function Message({ message }: MessageProps): React.ReactElement {
106
171
  }
107
172
 
108
173
  // For streaming agent messages, use StreamingContent with throbbing avatar
174
+ // Only throb the last agent message's avatar
109
175
  if (message.type === 'agent' && message.isStreaming) {
176
+ const showThrob = isLastAgentMessage !== false;
110
177
  return (
111
- <div data-testid={testId} className={`message ${roleClass}`}>
178
+ <div data-testid={testId} className={`message ${roleClass}${continuationClass}`}>
112
179
  <div data-testid="avatar" className="message-avatar">
113
- <AssistantAvatar isStreaming={true} />
180
+ <AssistantAvatar
181
+ isStreaming={showThrob}
182
+ agentSlug={message.agentSlug}
183
+ agentTheme={message.agentTheme}
184
+ agentCharacter={message.agentCharacter}
185
+ />
114
186
  </div>
115
187
  <div className="message-content">
116
188
  <StreamingContent content={message.content || ''} isStreaming={message.isStreaming ?? false} />
@@ -123,9 +195,15 @@ export default function Message({ message }: MessageProps): React.ReactElement {
123
195
  const html = message.content ? parseMarkdown(message.content) : '';
124
196
 
125
197
  return (
126
- <div data-testid={testId} className={`message ${roleClass}`}>
198
+ <div data-testid={testId} className={`message ${roleClass}${continuationClass}`} ref={contentRef}>
127
199
  <div data-testid="avatar" className="message-avatar">
128
- {message.type === 'user' ? <UserAvatar /> : <AssistantAvatar />}
200
+ {message.type === 'user' ? <UserAvatar /> : (
201
+ <AssistantAvatar
202
+ agentSlug={message.agentSlug}
203
+ agentTheme={message.agentTheme}
204
+ agentCharacter={message.agentCharacter}
205
+ />
206
+ )}
129
207
  </div>
130
208
  <div className="message-content">
131
209
  <div dangerouslySetInnerHTML={{ __html: html }} />