@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 @@
1
+ {"version":3,"file":"workflow-presets.js","sourceRoot":"","sources":["../src/workflow-presets.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAyC1D;;GAEG;AACH,MAAM,UAAU,4BAA4B,CAC1C,WAAmC;IAEnC,OAAO,WAAW,CAAC,WAAW,IAAI,EAAE,CAAC;AACvC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAClC,OAAmC;IAEnC,MAAM,aAAa,GAAG,SAAS,EAAE,CAAC;IAClC,MAAM,OAAO,GAA+B,EAAE,CAAC;IAC/C,MAAM,OAAO,GAA+B,EAAE,CAAC;IAE/C,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,MAAM,QAAQ,GAAG,aAAa,CAAC,IAAI,CACjC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,CAAC,KAAK,KAAK,MAAM,CAAC,KAAK,CAC1D,CAAC;QACF,IAAI,QAAQ,EAAE,CAAC;YACb,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACvB,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACvB,CAAC;IACH,CAAC;IAED,OAAO;QACL,UAAU,EAAE,OAAO,CAAC,MAAM,KAAK,CAAC;QAChC,OAAO;QACP,OAAO;KACR,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,+BAA+B,CAC7C,WAAuC,EACvC,OAAkE;IAElE,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC;QAC7B,IAAI,EAAE,0BAA0B;QAChC,WAAW;KACZ,CAAC,CAAC;IACH,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,UAAU,KAAK,CAAC,EAAE,CAAC;YAC5B,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACvB,CAAC;IACH,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,kBAAkB,CAChC,WAAuC,EACvC,YAAoB;IAEpB,OAAO;QACL,IAAI,EAAE,0BAA0B;QAChC,YAAY;QACZ,WAAW;KACZ,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,mBAAmB,CACjC,WAAuC,EACvC,UAAyC;IAEzC,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;QAC/B,QAAQ,CAAC;YACP,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,UAAU,EAAE,UAAU;YACtB,UAAU,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SACrC,CAAC,CAAC;IACL,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB;IAClC,OAAO;QACL,QAAQ,EAAE,IAAI;QACd,MAAM,EAAE,yCAAyC;KAClD,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,2BAA2B,CACzC,OAAe;IAEf,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACjC,OAAO;QACL,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,UAAU,EAAE,IAAI,CAAC,UAAU;KAC5B,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pennyfarthing/cyclist",
3
- "version": "9.3.0",
3
+ "version": "10.0.0",
4
4
  "description": "Visual terminal interface for Claude Code",
5
5
  "author": "1898andCo",
6
6
  "type": "module",
@@ -115,7 +115,7 @@
115
115
  "build": {
116
116
  "appId": "com.cyclist.app",
117
117
  "productName": "Cyclist",
118
- "copyright": "Copyright © 2026",
118
+ "copyright": "Copyright \u00a9 2026",
119
119
  "asar": true,
120
120
  "directories": {
121
121
  "output": "release",
@@ -9,7 +9,7 @@
9
9
  * Persists layout changes to config.local.yaml.
10
10
  */
11
11
 
12
- import React, { useEffect, useCallback } from 'react';
12
+ import React, { useEffect, useCallback, useState } from 'react';
13
13
  import {
14
14
  DockviewWorkspace,
15
15
  registerPanelComponent,
@@ -20,7 +20,11 @@ import { ClaudeProvider } from './contexts/ClaudeContext';
20
20
  import { MessageQueueProvider } from './contexts/MessageQueueContext';
21
21
  import { useLayoutPersistence } from './hooks/useLayoutPersistence';
22
22
  import { loadFontSettings, applyFontSettings } from './utils/font-presets';
23
+ import { loadPresetFromProject, applyPreset } from './utils/color-presets';
23
24
  import { ErrorBoundary } from './components/ErrorBoundary';
25
+ import ApprovalModal, { useApprovalModal } from './components/ApprovalModal';
26
+ import { subscribeToPermissionRequests, sendPermissionResponse, createApprovalResponse } from './components/ApprovalModal';
27
+ import type { ApprovalRequest, GrantScope } from './components/ApprovalModal';
24
28
 
25
29
  // Import all panel components
26
30
  // Note: ProgressPanel split into Workflow/AC/Todo panels (MSSCI-14188)
@@ -38,6 +42,7 @@ import {
38
42
  SettingsPanel,
39
43
  AuditLogPanel,
40
44
  TTYPanel,
45
+ HotspotsPanel,
41
46
  } from './components/panels';
42
47
 
43
48
  // =============================================================================
@@ -63,6 +68,7 @@ registerPanelComponent(PANEL_INVENTORY.AC, ACPanel);
63
68
  registerPanelComponent(PANEL_INVENTORY.TODO, TodoPanel);
64
69
  registerPanelComponent(PANEL_INVENTORY.BACKGROUND, BackgroundPanel);
65
70
  registerPanelComponent(PANEL_INVENTORY.GIT, GitPanel);
71
+ registerPanelComponent(PANEL_INVENTORY.HOTSPOTS, HotspotsPanel);
66
72
  registerPanelComponent(PANEL_INVENTORY.SETTINGS, SettingsPanel);
67
73
 
68
74
  // =============================================================================
@@ -206,6 +212,50 @@ export default function App(): React.ReactElement {
206
212
  });
207
213
  }, []);
208
214
 
215
+ // Load and apply color preset on startup
216
+ useEffect(() => {
217
+ loadPresetFromProject().then(presetId => {
218
+ applyPreset(presetId);
219
+ });
220
+ }, []);
221
+
222
+ // ApprovalModal state management (MSSCI-14322)
223
+ const [requestQueue, setRequestQueue] = useState<ApprovalRequest[]>([]);
224
+ const { request, isOpen, show, hide } = useApprovalModal();
225
+
226
+ // Subscribe to permission requests via WebSocket
227
+ useEffect(() => {
228
+ const unsub = subscribeToPermissionRequests((incoming: ApprovalRequest) => {
229
+ setRequestQueue((prev) => [...prev, incoming]);
230
+ });
231
+ return unsub;
232
+ }, []);
233
+
234
+ // Show the next queued request when the current one is resolved
235
+ useEffect(() => {
236
+ if (!isOpen && requestQueue.length > 0) {
237
+ const [next, ...rest] = requestQueue;
238
+ setRequestQueue(rest);
239
+ show(next);
240
+ }
241
+ }, [isOpen, requestQueue, show]);
242
+
243
+ const handleApprove = useCallback((grantScope: GrantScope) => {
244
+ if (request) {
245
+ const response = createApprovalResponse(request.toolId, true, grantScope);
246
+ sendPermissionResponse(response);
247
+ }
248
+ hide();
249
+ }, [request, hide]);
250
+
251
+ const handleReject = useCallback(() => {
252
+ if (request) {
253
+ const response = createApprovalResponse(request.toolId, false);
254
+ sendPermissionResponse(response);
255
+ }
256
+ hide();
257
+ }, [request, hide]);
258
+
209
259
  return (
210
260
  <ErrorBoundary fallback={<RootErrorFallback />} panelName="App">
211
261
  <ClaudeProvider>
@@ -240,6 +290,16 @@ export default function App(): React.ReactElement {
240
290
  {/* Message input target (for skip link) */}
241
291
  <div id="message-input" tabIndex={-1} style={{ display: 'contents' }} aria-hidden="true" />
242
292
  </div>
293
+
294
+ {/* ApprovalModal - renders via Radix Portal outside the React tree (MSSCI-14322) */}
295
+ <ApprovalModal
296
+ isOpen={isOpen}
297
+ toolName={request?.toolName ?? ''}
298
+ toolId={request?.toolId ?? ''}
299
+ input={request?.input ?? {}}
300
+ onApprove={handleApprove}
301
+ onReject={handleReject}
302
+ />
243
303
  </CommandPaletteProvider>
244
304
  </MessageQueueProvider>
245
305
  </ClaudeProvider>
@@ -40,6 +40,7 @@ export const TOOL_NAME_TESTID = 'tool-name';
40
40
  export const APPROVE_BUTTON_TESTID = 'approve-button';
41
41
  export const REJECT_BUTTON_TESTID = 'reject-button';
42
42
  export const ALWAYS_ALLOW_TESTID = 'always-allow-checkbox';
43
+ export const WARNING_TESTID = 'approval-modal-warning';
43
44
 
44
45
  // ============================================================================
45
46
  // Constants - Keyboard Shortcuts
@@ -120,6 +121,9 @@ export interface ApprovalRequest {
120
121
  toolName: string;
121
122
  input: ToolInput;
122
123
  reason?: string;
124
+ severity?: ActionSeverity;
125
+ warning?: string;
126
+ agent?: string;
123
127
  }
124
128
 
125
129
  export interface ApprovalResponse {
@@ -145,6 +149,12 @@ export interface ApprovalModalProps {
145
149
  onDismiss?: () => void;
146
150
  /** Additional CSS class name */
147
151
  className?: string;
152
+ /** Server-provided severity classification (MSSCI-14323) */
153
+ severity?: ActionSeverity;
154
+ /** Server-provided warning text for destructive operations (MSSCI-14323) */
155
+ warning?: string;
156
+ /** Agent name requesting permission (MSSCI-14392) */
157
+ agent?: string;
148
158
  }
149
159
 
150
160
  interface UseApprovalModalResult {
@@ -361,6 +371,9 @@ interface HookRequestMessage {
361
371
  toolId: string;
362
372
  toolName: string;
363
373
  input: Record<string, unknown>;
374
+ severity?: 'safe' | 'normal' | 'destructive';
375
+ warning?: string;
376
+ agent?: string;
364
377
  context?: {
365
378
  percentage: number;
366
379
  isHigh: boolean;
@@ -417,6 +430,9 @@ export function subscribeToPermissionRequests(
417
430
  toolId: msg.toolId,
418
431
  toolName: msg.toolName,
419
432
  input: msg.input as ToolInput,
433
+ severity: msg.severity as ActionSeverity | undefined,
434
+ warning: msg.warning,
435
+ agent: msg.agent,
420
436
  });
421
437
  }
422
438
  } catch (err) {
@@ -487,6 +503,9 @@ export default function ApprovalModal({
487
503
  onReject,
488
504
  onDismiss,
489
505
  className = '',
506
+ severity: serverSeverity,
507
+ warning,
508
+ agent,
490
509
  }: ApprovalModalProps): React.ReactElement {
491
510
  const [alwaysAllow, setAlwaysAllow] = useState(false);
492
511
 
@@ -506,7 +525,7 @@ export default function ApprovalModal({
506
525
  return () => document.removeEventListener('keydown', handleKey);
507
526
  }, [isOpen, alwaysAllow, onApprove]);
508
527
 
509
- const severity = classifyActionSeverity(toolName, input);
528
+ const severity = serverSeverity ?? classifyActionSeverity(toolName, input);
510
529
  const severityClass = SEVERITY_CLASSNAMES[severity];
511
530
  const preview = formatCommandPreview(toolName, input);
512
531
  const icon = getToolIcon(toolName);
@@ -546,6 +565,8 @@ export default function ApprovalModal({
546
565
  <div>
547
566
  <div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
548
567
  <span className="approval-modal__icon" data-icon={icon} />
568
+ {agent && <span data-testid="agent-name" className="font-medium">{agent}</span>}
569
+ {agent && <span className="text-muted-foreground/50">/</span>}
549
570
  <span data-testid={TOOL_NAME_TESTID}>{toolName}</span>
550
571
  </div>
551
572
 
@@ -564,6 +585,15 @@ export default function ApprovalModal({
564
585
  </DialogDescription>
565
586
  </DialogHeader>
566
587
 
588
+ {warning && (
589
+ <div
590
+ data-testid={WARNING_TESTID}
591
+ className="text-sm text-destructive font-medium"
592
+ >
593
+ {warning}
594
+ </div>
595
+ )}
596
+
567
597
  <div className="flex items-center gap-2 text-sm text-muted-foreground">
568
598
  <Checkbox
569
599
  id="always-allow"
@@ -168,31 +168,30 @@ export function ControlBar({
168
168
  aria-pressed={relayMode}
169
169
  aria-label="Relay mode - auto-handoff to next agent"
170
170
  >
171
- <span className="toggle-icon">🚲</span>
171
+ <span className="toggle-icon">✋</span>
172
172
  </Button>
173
173
  </TooltipTrigger>
174
174
  <TooltipContent>Relay Mode: Auto-handoff to next agent (Cmd+4)</TooltipContent>
175
175
  </Tooltip>
176
176
 
177
- {/* TirePump Button - visible at 50%+ context, warning at 70%+ */}
178
- {contextPercent >= 50 && currentAgent && (
179
- <Tooltip>
180
- <TooltipTrigger asChild>
181
- <Button
182
- variant="ghost"
183
- size="icon"
184
- type="button"
185
- className={`btn-toggle pump-toggle ${contextPercent >= 70 ? 'warning' : ''}`}
186
- data-testid="pump-toggle"
187
- onClick={onTirePump}
188
- aria-label="TirePump: Clear context and reload agent"
189
- >
190
- <span className="toggle-icon">🫧</span>
191
- </Button>
192
- </TooltipTrigger>
193
- <TooltipContent>{`TirePump: Clear context (${contextPercent}%) and reload ${currentAgent}`}</TooltipContent>
194
- </Tooltip>
195
- )}
177
+ {/* TirePump Button - always visible, warning style at 70%+ */}
178
+ <Tooltip>
179
+ <TooltipTrigger asChild>
180
+ <Button
181
+ variant="ghost"
182
+ size="icon"
183
+ type="button"
184
+ className={`btn-toggle pump-toggle ${contextPercent >= 70 ? 'warning' : ''}`}
185
+ data-testid="pump-toggle"
186
+ onClick={onTirePump}
187
+ disabled={!currentAgent}
188
+ aria-label="TirePump: Clear context and reload agent"
189
+ >
190
+ <span className="toggle-icon">⬆️</span>
191
+ </Button>
192
+ </TooltipTrigger>
193
+ <TooltipContent>{currentAgent ? `TirePump: Clear context (${contextPercent}%) and reload ${currentAgent}` : 'TirePump: No agent loaded'}</TooltipContent>
194
+ </Tooltip>
196
195
  </div>
197
196
 
198
197
  {/* Stop button - always visible, disabled when not running */}
@@ -49,6 +49,7 @@ export const PANEL_INVENTORY = {
49
49
  TODO: 'todo',
50
50
  BACKGROUND: 'background',
51
51
  GIT: 'git',
52
+ HOTSPOTS: 'hotspots',
52
53
  SETTINGS: 'settings',
53
54
  } as const;
54
55
 
@@ -90,6 +91,7 @@ export const RIGHT_SIDEBAR_PANELS = [
90
91
  PANEL_INVENTORY.TODO,
91
92
  PANEL_INVENTORY.BACKGROUND,
92
93
  PANEL_INVENTORY.GIT,
94
+ PANEL_INVENTORY.HOTSPOTS,
93
95
  PANEL_INVENTORY.SETTINGS,
94
96
  ] as const;
95
97
 
@@ -105,8 +107,9 @@ const PANEL_TITLES: Record<string, string> = {
105
107
  workflow: 'Workflow',
106
108
  ac: 'AC',
107
109
  todo: 'Todo',
108
- background: 'Background',
110
+ background: 'Subagents',
109
111
  git: 'Git',
112
+ hotspots: 'Hotspots',
110
113
  settings: 'Settings',
111
114
  };
112
115
 
@@ -254,6 +257,7 @@ export function createDefaultDockviewLayout(): SerializedDockview {
254
257
  views: [PANEL_INVENTORY.MESSAGE],
255
258
  activeView: PANEL_INVENTORY.MESSAGE,
256
259
  id: 'center',
260
+ hideHeader: true,
257
261
  },
258
262
  size: 600, // Center takes remaining space
259
263
  },
@@ -430,10 +434,11 @@ export function DockviewWorkspace({
430
434
  try {
431
435
  api.fromJSON(initialLayout);
432
436
 
433
- // After restoring, lock the message panel's group
437
+ // After restoring, lock the message panel's group and hide its tab bar
434
438
  const messagePanel = api.getPanel(PANEL_INVENTORY.MESSAGE);
435
439
  if (messagePanel?.group) {
436
440
  messagePanel.group.locked = 'no-drop-target';
441
+ messagePanel.group.model.header.hidden = true;
437
442
  }
438
443
 
439
444
  setIsReady(true);
@@ -500,8 +505,10 @@ export function DockviewWorkspace({
500
505
  }
501
506
 
502
507
  // Lock the center group - MessagePanel cannot be closed or moved
508
+ // Hide the tab bar so users can't accidentally close the message tab
503
509
  if (messagePanel?.group) {
504
510
  messagePanel.group.locked = 'no-drop-target';
511
+ messagePanel.group.model.header.hidden = true;
505
512
  }
506
513
 
507
514
  // Set initial sidebar sizes
@@ -552,9 +559,9 @@ export function DockviewWorkspace({
552
559
  handleLayoutChange();
553
560
  }),
554
561
  api.onDidRemovePanel((e) => {
555
- // Track closed panels (except message which can't be closed)
562
+ // Track closed panels for restoration
556
563
  const panelId = e?.panel?.id;
557
- if (panelId && panelId !== PANEL_INVENTORY.MESSAGE) {
564
+ if (panelId) {
558
565
  closedPanels.add(panelId);
559
566
  updateClosedPanelsList();
560
567
  }
@@ -619,6 +626,32 @@ export function DockviewWorkspace({
619
626
  };
620
627
  }, []);
621
628
 
629
+ // Listen for panel toggle commands via WebSocket (View menu integration)
630
+ useEffect(() => {
631
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
632
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws/settings`);
633
+
634
+ ws.onmessage = (event) => {
635
+ try {
636
+ const data = JSON.parse(event.data);
637
+ if (data.type === 'panel:toggle' && data.panelId) {
638
+ const api = dockviewApiRef;
639
+ if (!api) return;
640
+ const existing = api.getPanel(data.panelId);
641
+ if (existing) {
642
+ existing.api.close();
643
+ } else {
644
+ restorePanel(data.panelId);
645
+ }
646
+ }
647
+ } catch {
648
+ // ignore parse errors
649
+ }
650
+ };
651
+
652
+ return () => ws.close();
653
+ }, []);
654
+
622
655
  // Component map for Dockview
623
656
  const components = {
624
657
  PanelAdapter,
@@ -635,8 +668,9 @@ export function DockviewWorkspace({
635
668
  workflow: 'Workflow',
636
669
  ac: 'AC',
637
670
  todo: 'Todo',
638
- background: 'Background',
671
+ background: 'Subagents',
639
672
  git: 'Git',
673
+ hotspots: 'Hotspots',
640
674
  settings: 'Settings',
641
675
  };
642
676
 
@@ -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>