@pennyfarthing/cyclist 9.3.0 → 9.4.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 (46) hide show
  1. package/dist/api/hotspots.d.ts +3 -0
  2. package/dist/api/hotspots.d.ts.map +1 -0
  3. package/dist/api/hotspots.js +54 -0
  4. package/dist/api/hotspots.js.map +1 -0
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.d.ts.map +1 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/index.js.map +1 -1
  9. package/dist/api/settings.d.ts +1 -1
  10. package/dist/api/settings.d.ts.map +1 -1
  11. package/dist/api/settings.js +44 -17
  12. package/dist/api/settings.js.map +1 -1
  13. package/dist/public/css/react.css +1 -1
  14. package/dist/public/js/react/react.js +31 -31
  15. package/dist/server.d.ts.map +1 -1
  16. package/dist/server.js +3 -1
  17. package/dist/server.js.map +1 -1
  18. package/dist/story-parser.d.ts +17 -0
  19. package/dist/story-parser.d.ts.map +1 -1
  20. package/dist/story-parser.js +183 -13
  21. package/dist/story-parser.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/public/App.tsx +2 -0
  24. package/src/public/components/ControlBar.tsx +1 -1
  25. package/src/public/components/DockviewWorkspace.tsx +4 -0
  26. package/src/public/components/FontPicker/index.tsx +118 -33
  27. package/src/public/components/FullFileTree.tsx +223 -0
  28. package/src/public/components/Message.tsx +32 -10
  29. package/src/public/components/MessageView.tsx +177 -92
  30. package/src/public/components/PersonaHeader.tsx +45 -15
  31. package/src/public/components/SubagentSpan.tsx +15 -8
  32. package/src/public/components/panels/ChangedPanel.tsx +30 -44
  33. package/src/public/components/panels/HotspotsPanel.tsx +365 -0
  34. package/src/public/components/panels/MessagePanel.tsx +14 -2
  35. package/src/public/components/panels/WorkflowPanel.tsx +85 -12
  36. package/src/public/components/panels/index.ts +1 -0
  37. package/src/public/css/theme-system.css +46 -38
  38. package/src/public/hooks/useFileBrowser.ts +71 -0
  39. package/src/public/hooks/useHotspots.ts +113 -0
  40. package/src/public/hooks/useStory.ts +12 -3
  41. package/src/public/images/cyclist-dark.png +0 -0
  42. package/src/public/images/cyclist-light.png +0 -0
  43. package/src/public/styles/tailwind.css +236 -58
  44. package/src/public/types/message.ts +4 -0
  45. package/src/public/utils/slash-commands.ts +1 -1
  46. package/src/public/utils/toolStackGrouper.ts +5 -6
@@ -58,41 +58,99 @@ interface SystemFont {
58
58
  }
59
59
 
60
60
  // =============================================================================
61
- // Monospace Detection
61
+ // Monospace Detection via OpenType `post` table
62
62
  // =============================================================================
63
63
 
64
- const monoCache = new Map<string, boolean>();
65
-
66
- function detectMonospace(fontFamily: string): boolean {
67
- if (monoCache.has(fontFamily)) {
68
- return monoCache.get(fontFamily)!;
64
+ /**
65
+ * Parse SFNT table directory to find a table's offset and length.
66
+ * SFNT header: version(4) + numTables(2) + searchRange(2) + entrySelector(2) + rangeShift(2) = 12
67
+ * Each table record: tag(4) + checksum(4) + offset(4) + length(4) = 16
68
+ */
69
+ function findSfntTable(view: DataView, tag: string): { offset: number; length: number } | null {
70
+ const numTables = view.getUint16(4);
71
+ for (let i = 0; i < numTables; i++) {
72
+ const recordOffset = 12 + i * 16;
73
+ const tableTag = String.fromCharCode(
74
+ view.getUint8(recordOffset),
75
+ view.getUint8(recordOffset + 1),
76
+ view.getUint8(recordOffset + 2),
77
+ view.getUint8(recordOffset + 3),
78
+ );
79
+ if (tableTag === tag) {
80
+ return {
81
+ offset: view.getUint32(recordOffset + 8),
82
+ length: view.getUint32(recordOffset + 12),
83
+ };
84
+ }
69
85
  }
86
+ return null;
87
+ }
70
88
 
71
- const canvas = document.createElement('canvas');
72
- const ctx = canvas.getContext('2d');
73
- if (!ctx) {
74
- monoCache.set(fontFamily, false);
89
+ /**
90
+ * Read `isFixedPitch` from the `post` table.
91
+ * post layout: version(4) + italicAngle(4) + underlinePosition(2) + underlineThickness(2) = offset 12
92
+ * isFixedPitch is uint32 at offset 12: 0 = proportional, non-zero = monospace.
93
+ */
94
+ async function detectMonospaceFromBlob(fontData: { blob: () => Promise<Blob> }): Promise<boolean> {
95
+ try {
96
+ const blob = await fontData.blob();
97
+ const buffer = await blob.arrayBuffer();
98
+ const view = new DataView(buffer);
99
+
100
+ const post = findSfntTable(view, 'post');
101
+ if (post) {
102
+ const isFixedPitch = view.getUint32(post.offset + 12);
103
+ return isFixedPitch !== 0;
104
+ }
105
+ return false;
106
+ } catch {
75
107
  return false;
76
108
  }
77
-
78
- ctx.font = `16px "${fontFamily}", monospace`;
79
- const wideChar = ctx.measureText('W').width;
80
- const narrowChar = ctx.measureText('i').width;
81
- const isMono = Math.abs(wideChar - narrowChar) < 1;
82
-
83
- monoCache.set(fontFamily, isMono);
84
- return isMono;
85
109
  }
86
110
 
87
111
  // =============================================================================
88
112
  // System Font Discovery
89
113
  // =============================================================================
90
114
 
91
- let systemFontsCache: SystemFont[] | null = null;
115
+ interface FontDataEntry {
116
+ family: string;
117
+ fullName: string;
118
+ postscriptName: string;
119
+ style: string;
120
+ blob: () => Promise<Blob>;
121
+ }
122
+
123
+ const FONT_CACHE_KEY = 'cyclist-system-fonts';
124
+
125
+ interface FontCacheData {
126
+ fonts: SystemFont[];
127
+ count: number; // number of font families — if it changes, fonts were installed/removed
128
+ }
129
+
130
+ function loadCachedFonts(): SystemFont[] | null {
131
+ try {
132
+ const raw = localStorage.getItem(FONT_CACHE_KEY);
133
+ if (!raw) return null;
134
+ const data: FontCacheData = JSON.parse(raw);
135
+ if (data.fonts?.length > 0) return data.fonts;
136
+ return null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ function saveCachedFonts(fonts: SystemFont[], count: number): void {
143
+ try {
144
+ const data: FontCacheData = { fonts, count };
145
+ localStorage.setItem(FONT_CACHE_KEY, JSON.stringify(data));
146
+ } catch {
147
+ // localStorage full or unavailable — not critical
148
+ }
149
+ }
150
+
92
151
  let systemFontsPromise: Promise<SystemFont[]> | null = null;
93
152
 
94
153
  async function getSystemFonts(): Promise<SystemFont[]> {
95
- if (systemFontsCache) return systemFontsCache;
96
154
  if (systemFontsPromise) return systemFontsPromise;
97
155
 
98
156
  systemFontsPromise = (async () => {
@@ -101,24 +159,48 @@ async function getSystemFonts(): Promise<SystemFont[]> {
101
159
  }
102
160
 
103
161
  try {
104
- const fonts = await (window as unknown as { queryLocalFonts: () => Promise<Array<{ family: string }>> }).queryLocalFonts();
162
+ const fonts: FontDataEntry[] = await (window as unknown as { queryLocalFonts: () => Promise<FontDataEntry[]> }).queryLocalFonts();
105
163
 
106
- // Deduplicate by family name
107
- const families = new Set<string>();
164
+ // Deduplicate by family name, keep one FontData per family for mono detection
165
+ const familyMap = new Map<string, FontDataEntry>();
108
166
  for (const font of fonts) {
109
- families.add(font.family);
167
+ if (!familyMap.has(font.family)) {
168
+ familyMap.set(font.family, font);
169
+ }
110
170
  }
111
171
 
112
- const result: SystemFont[] = [];
113
- for (const family of families) {
114
- result.push({
115
- family,
116
- isMonospace: detectMonospace(family),
117
- });
172
+ const familyCount = familyMap.size;
173
+
174
+ // Check localStorage cache — reuse if font count hasn't changed
175
+ const cached = loadCachedFonts();
176
+ if (cached && cached.length > 0) {
177
+ // Load raw cached data to check count
178
+ try {
179
+ const raw = localStorage.getItem(FONT_CACHE_KEY);
180
+ if (raw) {
181
+ const data: FontCacheData = JSON.parse(raw);
182
+ if (data.count === familyCount) {
183
+ return cached;
184
+ }
185
+ }
186
+ } catch {
187
+ // Fall through to re-detect
188
+ }
118
189
  }
119
190
 
191
+ // Detect monospace via post table in parallel
192
+ const entries = Array.from(familyMap.entries());
193
+ const monoResults = await Promise.all(
194
+ entries.map(([, fontData]) => detectMonospaceFromBlob(fontData))
195
+ );
196
+
197
+ const result: SystemFont[] = entries.map(([family], i) => ({
198
+ family,
199
+ isMonospace: monoResults[i],
200
+ }));
201
+
120
202
  result.sort((a, b) => a.family.localeCompare(b.family));
121
- systemFontsCache = result;
203
+ saveCachedFonts(result, familyCount);
122
204
  return result;
123
205
  } catch {
124
206
  // Permission denied or API error
@@ -161,10 +243,13 @@ export function FontPicker({
161
243
  }
162
244
  }, [fontsLoaded]);
163
245
 
164
- // Filter system fonts for code type (monospace only)
246
+ // Filter system fonts: English-only (Latin names), monospace-only for code
165
247
  const filteredSystemFonts = useMemo(() => {
166
248
  let fonts = systemFonts;
167
249
 
250
+ // Filter to fonts with Latin-script names (excludes CJK, Arabic, Devanagari, etc.)
251
+ fonts = fonts.filter(f => /^[\x20-\x7E\u00C0-\u024F]+$/.test(f.family));
252
+
168
253
  // For code fonts, only show monospace
169
254
  if (type === 'code') {
170
255
  fonts = fonts.filter(f => f.isMonospace);
@@ -237,7 +322,7 @@ export function FontPicker({
237
322
  >
238
323
  <SelectValue placeholder="Select font..." />
239
324
  </SelectTrigger>
240
- <SelectContent>
325
+ <SelectContent className="max-h-[300px]">
241
326
  {/* Presets section */}
242
327
  {displayPresets.length > 0 && (
243
328
  <SelectGroup>
@@ -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;
@@ -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,10 @@ 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
+ export default function Message({ message, isLastAgentMessage, isFirstInTurn = true }: MessageProps): React.ReactElement {
71
79
  const roleClass = `message-${message.type}`;
72
80
  const testId = `message-${message.type}`;
81
+ const continuationClass = !isFirstInTurn ? ' continuation' : '';
73
82
 
74
83
  // For bell-injected messages (queued messages injected via PostToolUse hook)
75
84
  // Show with 🔔 indicator so user knows it was sent mid-turn
@@ -77,7 +86,7 @@ export default function Message({ message }: MessageProps): React.ReactElement {
77
86
  const html = message.content ? parseMarkdown(message.content) : '';
78
87
  return (
79
88
  <TooltipProvider delayDuration={300}>
80
- <div data-testid="message-bell-injected" className="message message-user message-bell-injected">
89
+ <div data-testid="message-bell-injected" className={`message message-user message-bell-injected${continuationClass}`}>
81
90
  <div data-testid="avatar" className="message-avatar">
82
91
  <UserAvatar />
83
92
  </div>
@@ -106,11 +115,18 @@ export default function Message({ message }: MessageProps): React.ReactElement {
106
115
  }
107
116
 
108
117
  // For streaming agent messages, use StreamingContent with throbbing avatar
118
+ // Only throb the last agent message's avatar
109
119
  if (message.type === 'agent' && message.isStreaming) {
120
+ const showThrob = isLastAgentMessage !== false;
110
121
  return (
111
- <div data-testid={testId} className={`message ${roleClass}`}>
122
+ <div data-testid={testId} className={`message ${roleClass}${continuationClass}`}>
112
123
  <div data-testid="avatar" className="message-avatar">
113
- <AssistantAvatar isStreaming={true} />
124
+ <AssistantAvatar
125
+ isStreaming={showThrob}
126
+ agentSlug={message.agentSlug}
127
+ agentTheme={message.agentTheme}
128
+ agentCharacter={message.agentCharacter}
129
+ />
114
130
  </div>
115
131
  <div className="message-content">
116
132
  <StreamingContent content={message.content || ''} isStreaming={message.isStreaming ?? false} />
@@ -123,9 +139,15 @@ export default function Message({ message }: MessageProps): React.ReactElement {
123
139
  const html = message.content ? parseMarkdown(message.content) : '';
124
140
 
125
141
  return (
126
- <div data-testid={testId} className={`message ${roleClass}`}>
142
+ <div data-testid={testId} className={`message ${roleClass}${continuationClass}`}>
127
143
  <div data-testid="avatar" className="message-avatar">
128
- {message.type === 'user' ? <UserAvatar /> : <AssistantAvatar />}
144
+ {message.type === 'user' ? <UserAvatar /> : (
145
+ <AssistantAvatar
146
+ agentSlug={message.agentSlug}
147
+ agentTheme={message.agentTheme}
148
+ agentCharacter={message.agentCharacter}
149
+ />
150
+ )}
129
151
  </div>
130
152
  <div className="message-content">
131
153
  <div dangerouslySetInnerHTML={{ __html: html }} />