prompt_objects 0.1.0 → 0.3.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +2 -2
  5. data/exe/prompt_objects +387 -1
  6. data/frontend/src/App.tsx +12 -3
  7. data/frontend/src/components/CapabilitiesPanel.tsx +122 -25
  8. data/frontend/src/components/ChatPanel.tsx +43 -6
  9. data/frontend/src/components/ContextMenu.tsx +67 -0
  10. data/frontend/src/components/MessageBus.tsx +4 -3
  11. data/frontend/src/components/ModelSelector.tsx +5 -1
  12. data/frontend/src/components/PODetail.tsx +8 -1
  13. data/frontend/src/components/PromptPanel.tsx +124 -19
  14. data/frontend/src/components/ThreadsSidebar.tsx +46 -2
  15. data/frontend/src/components/UsagePanel.tsx +105 -0
  16. data/frontend/src/hooks/useWebSocket.ts +69 -0
  17. data/frontend/src/store/index.ts +10 -0
  18. data/frontend/src/types/index.ts +16 -2
  19. data/lib/prompt_objects/cli.rb +1 -0
  20. data/lib/prompt_objects/connectors/mcp.rb +1 -0
  21. data/lib/prompt_objects/environment.rb +35 -1
  22. data/lib/prompt_objects/llm/anthropic_adapter.rb +15 -1
  23. data/lib/prompt_objects/llm/factory.rb +93 -6
  24. data/lib/prompt_objects/llm/gemini_adapter.rb +13 -1
  25. data/lib/prompt_objects/llm/openai_adapter.rb +21 -4
  26. data/lib/prompt_objects/llm/pricing.rb +49 -0
  27. data/lib/prompt_objects/llm/response.rb +3 -2
  28. data/lib/prompt_objects/mcp/server.rb +1 -0
  29. data/lib/prompt_objects/message_bus.rb +27 -8
  30. data/lib/prompt_objects/prompt_object.rb +15 -3
  31. data/lib/prompt_objects/server/api/routes.rb +186 -29
  32. data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +1 -0
  33. data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +77 -0
  34. data/lib/prompt_objects/server/public/index.html +2 -2
  35. data/lib/prompt_objects/server/websocket_handler.rb +160 -12
  36. data/lib/prompt_objects/server.rb +67 -0
  37. data/lib/prompt_objects/session/store.rb +399 -4
  38. data/lib/prompt_objects/universal/add_capability.rb +6 -1
  39. data/lib/prompt_objects/universal/add_primitive.rb +6 -1
  40. data/lib/prompt_objects/universal/create_capability.rb +4 -0
  41. data/lib/prompt_objects/universal/create_primitive.rb +4 -0
  42. data/lib/prompt_objects/universal/delete_primitive.rb +77 -0
  43. data/lib/prompt_objects/universal/modify_prompt.rb +164 -0
  44. data/lib/prompt_objects/universal/remove_capability.rb +73 -0
  45. data/lib/prompt_objects.rb +5 -1
  46. data/prompt_objects.gemspec +1 -1
  47. data/templates/arc-agi-1/manifest.yml +22 -0
  48. data/templates/arc-agi-1/objects/data_manager.md +42 -0
  49. data/templates/arc-agi-1/objects/observer.md +100 -0
  50. data/templates/arc-agi-1/objects/solver.md +118 -0
  51. data/templates/arc-agi-1/objects/verifier.md +79 -0
  52. data/templates/arc-agi-1/primitives/check_arc_data.rb +53 -0
  53. data/templates/arc-agi-1/primitives/find_objects.rb +72 -0
  54. data/templates/arc-agi-1/primitives/grid_diff.rb +70 -0
  55. data/templates/arc-agi-1/primitives/grid_info.rb +42 -0
  56. data/templates/arc-agi-1/primitives/grid_transform.rb +50 -0
  57. data/templates/arc-agi-1/primitives/load_arc_task.rb +68 -0
  58. data/templates/arc-agi-1/primitives/render_grid.rb +78 -0
  59. data/templates/arc-agi-1/primitives/test_solution.rb +131 -0
  60. metadata +23 -3
  61. data/lib/prompt_objects/server/public/assets/index-2acS2FYZ.js +0 -77
  62. data/lib/prompt_objects/server/public/assets/index-DXU5uRXQ.css +0 -1
@@ -1,4 +1,5 @@
1
- import type { PromptObject } from '../types'
1
+ import { useState } from 'react'
2
+ import type { PromptObject, CapabilityInfo } from '../types'
2
3
 
3
4
  interface CapabilitiesPanelProps {
4
5
  po: PromptObject
@@ -6,38 +7,134 @@ interface CapabilitiesPanelProps {
6
7
 
7
8
  export function CapabilitiesPanel({ po }: CapabilitiesPanelProps) {
8
9
  const capabilities = po.capabilities || []
10
+ const universalCapabilities = po.universal_capabilities || []
9
11
 
10
12
  return (
11
13
  <div className="h-full overflow-auto p-4">
12
- <h3 className="text-lg font-medium text-white mb-4">Capabilities</h3>
14
+ {/* Declared Capabilities */}
15
+ <div className="mb-6">
16
+ <h3 className="text-lg font-medium text-white mb-3">
17
+ Declared Capabilities
18
+ <span className="ml-2 text-sm text-gray-500">({capabilities.length})</span>
19
+ </h3>
13
20
 
14
- {capabilities.length === 0 ? (
15
- <div className="text-gray-500 text-center py-8">
16
- No capabilities defined.
17
- </div>
18
- ) : (
19
- <div className="space-y-2">
20
- {capabilities.map((cap, index) => (
21
- <div
22
- key={index}
23
- className="bg-po-surface border border-po-border rounded-lg p-3"
24
- >
25
- <div className="font-mono text-sm text-po-accent">{cap}</div>
26
- </div>
21
+ {capabilities.length === 0 ? (
22
+ <div className="text-gray-500 text-sm py-4 px-3 bg-po-bg rounded-lg border border-po-border">
23
+ No capabilities declared. This PO can only use universal capabilities.
24
+ </div>
25
+ ) : (
26
+ <div className="space-y-1">
27
+ {capabilities.map((cap) => (
28
+ <CapabilityItem key={cap.name} capability={cap} accent />
29
+ ))}
30
+ </div>
31
+ )}
32
+ </div>
33
+
34
+ {/* Universal Capabilities */}
35
+ <div>
36
+ <h3 className="text-lg font-medium text-white mb-3">
37
+ Universal Capabilities
38
+ <span className="ml-2 text-sm text-gray-500">({universalCapabilities.length})</span>
39
+ </h3>
40
+ <p className="text-xs text-gray-500 mb-3">
41
+ Available to all Prompt Objects automatically.
42
+ </p>
43
+
44
+ <div className="space-y-1">
45
+ {universalCapabilities.map((cap) => (
46
+ <CapabilityItem key={cap.name} capability={cap} />
27
47
  ))}
28
48
  </div>
49
+ </div>
50
+ </div>
51
+ )
52
+ }
53
+
54
+ interface CapabilityItemProps {
55
+ capability: CapabilityInfo
56
+ accent?: boolean // Use accent color for name (for declared caps)
57
+ }
58
+
59
+ function CapabilityItem({ capability, accent }: CapabilityItemProps) {
60
+ const [expanded, setExpanded] = useState(false)
61
+
62
+ return (
63
+ <div className="bg-po-bg border border-po-border rounded-lg overflow-hidden">
64
+ <button
65
+ onClick={() => setExpanded(!expanded)}
66
+ className="w-full px-3 py-2 flex items-center justify-between hover:bg-po-surface transition-colors"
67
+ >
68
+ <span className={`font-mono text-sm ${accent ? 'text-po-accent' : 'text-gray-300'}`}>
69
+ {capability.name}
70
+ </span>
71
+ <span className="text-gray-500 text-xs">{expanded ? '▼' : '▶'}</span>
72
+ </button>
73
+ {expanded && (
74
+ <div className="px-3 py-2 border-t border-po-border bg-po-surface space-y-3">
75
+ <p className="text-xs text-gray-400">{capability.description}</p>
76
+
77
+ {capability.parameters && (
78
+ <ParametersDisplay parameters={capability.parameters} />
79
+ )}
80
+ </div>
29
81
  )}
82
+ </div>
83
+ )
84
+ }
30
85
 
31
- <div className="mt-6 p-4 bg-po-bg rounded-lg border border-po-border">
32
- <h4 className="text-sm font-medium text-gray-400 mb-2">
33
- Universal Capabilities
34
- </h4>
35
- <p className="text-xs text-gray-500">
36
- All Prompt Objects automatically have access to universal capabilities
37
- like <code className="text-po-accent">ask_human</code>,{' '}
38
- <code className="text-po-accent">think</code>, and{' '}
39
- <code className="text-po-accent">request_capability</code>.
40
- </p>
86
+ interface ParametersDisplayProps {
87
+ parameters: Record<string, unknown>
88
+ }
89
+
90
+ function ParametersDisplay({ parameters }: ParametersDisplayProps) {
91
+ const properties = (parameters.properties as Record<string, unknown>) || {}
92
+ const required = (parameters.required as string[]) || []
93
+
94
+ const propertyNames = Object.keys(properties)
95
+
96
+ if (propertyNames.length === 0) {
97
+ return null
98
+ }
99
+
100
+ return (
101
+ <div>
102
+ <div className="text-xs text-gray-500 mb-2 font-medium">Parameters</div>
103
+ <div className="space-y-2">
104
+ {propertyNames.map((propName) => {
105
+ const prop = properties[propName] as Record<string, unknown>
106
+ const isRequired = required.includes(propName)
107
+
108
+ const propType = prop.type ? String(prop.type) : null
109
+ const propDescription = prop.description ? String(prop.description) : null
110
+ const propEnum = prop.enum as string[] | undefined
111
+
112
+ return (
113
+ <div key={propName} className="bg-po-bg rounded p-2">
114
+ <div className="flex items-center gap-2">
115
+ <span className="font-mono text-xs text-po-accent">{propName}</span>
116
+ {propType && (
117
+ <span className="text-xs text-gray-600">({propType})</span>
118
+ )}
119
+ {isRequired && (
120
+ <span className="text-xs text-red-400">required</span>
121
+ )}
122
+ </div>
123
+ {propDescription && (
124
+ <p className="text-xs text-gray-500 mt-1">{propDescription}</p>
125
+ )}
126
+ {propEnum && propEnum.length > 0 && (
127
+ <div className="mt-1 flex flex-wrap gap-1">
128
+ {propEnum.map((val) => (
129
+ <span key={val} className="text-xs bg-po-surface px-1.5 py-0.5 rounded text-gray-400">
130
+ {val}
131
+ </span>
132
+ ))}
133
+ </div>
134
+ )}
135
+ </div>
136
+ )
137
+ })}
41
138
  </div>
42
139
  </div>
43
140
  )
@@ -133,13 +133,15 @@ function MessageBubble({ message }: { message: Message }) {
133
133
  const isTool = message.role === 'tool'
134
134
 
135
135
  if (isTool) {
136
+ // Tool messages contain results from tool calls
137
+ const results = message.results || []
138
+ if (results.length === 0) return null
139
+
136
140
  return (
137
- <div className="text-xs text-gray-500 bg-po-bg rounded p-2 font-mono">
138
- <div className="text-gray-400 mb-1">Tool Result:</div>
139
- <div className="whitespace-pre-wrap break-all">
140
- {message.content?.slice(0, 500)}
141
- {message.content && message.content.length > 500 && '...'}
142
- </div>
141
+ <div className="space-y-2 ml-11">
142
+ {results.map((result, idx) => (
143
+ <ToolResultDisplay key={result.tool_call_id || idx} result={result} />
144
+ ))}
143
145
  </div>
144
146
  )
145
147
  }
@@ -249,3 +251,38 @@ function ToolCallDisplay({ toolCall }: { toolCall: ToolCall }) {
249
251
  </div>
250
252
  )
251
253
  }
254
+
255
+ function ToolResultDisplay({ result }: { result: { tool_call_id: string; name?: string; content: string } }) {
256
+ const [expanded, setExpanded] = useState(false)
257
+ const content = result.content || ''
258
+ const truncatedContent = content.slice(0, 200)
259
+ const isTruncated = content.length > 200
260
+
261
+ return (
262
+ <div className="text-xs bg-po-surface/50 border border-po-border/30 rounded overflow-hidden">
263
+ <button
264
+ onClick={() => setExpanded(!expanded)}
265
+ className="w-full px-2 py-1 text-left font-mono flex items-center gap-1 hover:bg-po-surface transition-colors"
266
+ >
267
+ <span className="text-gray-500">{expanded ? '▼' : '▶'}</span>
268
+ <span className="text-gray-400">↳ Result</span>
269
+ {result.name && <span className="text-po-accent/70">from {result.name}</span>}
270
+ </button>
271
+ {expanded && (
272
+ <div className="px-2 pb-2 border-t border-po-border/30">
273
+ <pre className="text-gray-400 whitespace-pre-wrap break-all mt-1 text-[10px] leading-relaxed">
274
+ {content}
275
+ </pre>
276
+ </div>
277
+ )}
278
+ {!expanded && (
279
+ <div className="px-2 pb-1 text-gray-500">
280
+ <span className="whitespace-pre-wrap break-all">
281
+ {truncatedContent}
282
+ {isTruncated && '...'}
283
+ </span>
284
+ </div>
285
+ )}
286
+ </div>
287
+ )
288
+ }
@@ -0,0 +1,67 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ interface ContextMenuItem {
4
+ label: string
5
+ onClick: () => void
6
+ icon?: string
7
+ danger?: boolean
8
+ }
9
+
10
+ interface ContextMenuProps {
11
+ x: number
12
+ y: number
13
+ items: ContextMenuItem[]
14
+ onClose: () => void
15
+ }
16
+
17
+ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
18
+ const menuRef = useRef<HTMLDivElement>(null)
19
+
20
+ useEffect(() => {
21
+ const handleClickOutside = (e: MouseEvent) => {
22
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
23
+ onClose()
24
+ }
25
+ }
26
+ const handleEscape = (e: KeyboardEvent) => {
27
+ if (e.key === 'Escape') onClose()
28
+ }
29
+
30
+ document.addEventListener('mousedown', handleClickOutside)
31
+ document.addEventListener('keydown', handleEscape)
32
+ return () => {
33
+ document.removeEventListener('mousedown', handleClickOutside)
34
+ document.removeEventListener('keydown', handleEscape)
35
+ }
36
+ }, [onClose])
37
+
38
+ // Adjust position to stay within viewport
39
+ const adjustedStyle = {
40
+ top: y,
41
+ left: x,
42
+ }
43
+
44
+ return (
45
+ <div
46
+ ref={menuRef}
47
+ className="fixed z-50 bg-po-surface border border-po-border rounded-lg shadow-xl py-1 min-w-[160px]"
48
+ style={adjustedStyle}
49
+ >
50
+ {items.map((item, idx) => (
51
+ <button
52
+ key={idx}
53
+ onClick={() => {
54
+ item.onClick()
55
+ onClose()
56
+ }}
57
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-po-bg transition-colors flex items-center gap-2 ${
58
+ item.danger ? 'text-red-400 hover:text-red-300' : 'text-gray-300 hover:text-white'
59
+ }`}
60
+ >
61
+ {item.icon && <span>{item.icon}</span>}
62
+ {item.label}
63
+ </button>
64
+ ))}
65
+ </div>
66
+ )
67
+ }
@@ -41,9 +41,10 @@ export function MessageBus() {
41
41
  </span>
42
42
  </div>
43
43
  <div className="text-gray-300 break-words">
44
- {msg.content.length > 200
45
- ? msg.content.slice(0, 200) + '...'
46
- : msg.content}
44
+ {(() => {
45
+ const text = msg.summary || (typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content))
46
+ return text.length > 200 ? text.slice(0, 200) + '...' : text
47
+ })()}
47
48
  </div>
48
49
  </div>
49
50
  ))
@@ -33,6 +33,8 @@ export function ModelSelector({ switchLLM }: Props) {
33
33
  openai: 'OpenAI',
34
34
  anthropic: 'Anthropic',
35
35
  gemini: 'Gemini',
36
+ ollama: 'Ollama',
37
+ openrouter: 'OpenRouter',
36
38
  }
37
39
 
38
40
  return (
@@ -62,7 +64,9 @@ export function ModelSelector({ switchLLM }: Props) {
62
64
  <div className="px-3 py-2 bg-po-bg text-xs font-medium text-gray-400 uppercase tracking-wide flex items-center justify-between">
63
65
  <span>{providerNames[provider.name] || provider.name}</span>
64
66
  {!provider.available && (
65
- <span className="text-red-400 text-[10px] normal-case">No API Key</span>
67
+ <span className="text-red-400 text-[10px] normal-case">
68
+ {provider.name === 'ollama' ? 'Not Running' : 'No API Key'}
69
+ </span>
66
70
  )}
67
71
  </div>
68
72
  {provider.models.map((model) => {
@@ -9,6 +9,7 @@ interface PODetailProps {
9
9
  createSession: (target: string, name?: string) => void
10
10
  switchSession: (target: string, sessionId: string) => void
11
11
  createThread: (target: string, name?: string) => void
12
+ updatePrompt: (target: string, prompt: string) => void
12
13
  }
13
14
 
14
15
  export function PODetail({
@@ -16,6 +17,7 @@ export function PODetail({
16
17
  createSession,
17
18
  switchSession,
18
19
  createThread,
20
+ updatePrompt,
19
21
  }: PODetailProps) {
20
22
  const { activeTab, setActiveTab, selectPO } = useStore()
21
23
  const po = useSelectedPO()
@@ -95,7 +97,12 @@ export function PODetail({
95
97
  />
96
98
  )}
97
99
  {activeTab === 'capabilities' && <CapabilitiesPanel po={po} />}
98
- {activeTab === 'prompt' && <PromptPanel po={po} />}
100
+ {activeTab === 'prompt' && (
101
+ <PromptPanel
102
+ po={po}
103
+ onSave={(prompt) => updatePrompt(po.name, prompt)}
104
+ />
105
+ )}
99
106
  </div>
100
107
  </div>
101
108
  )
@@ -1,13 +1,78 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
1
2
  import type { PromptObject } from '../types'
2
3
  import { MarkdownMessage } from './MarkdownMessage'
3
4
 
4
5
  interface PromptPanelProps {
5
6
  po: PromptObject
7
+ onSave?: (prompt: string) => void
6
8
  }
7
9
 
8
- export function PromptPanel({ po }: PromptPanelProps) {
10
+ export function PromptPanel({ po, onSave }: PromptPanelProps) {
9
11
  const prompt = po.prompt || ''
10
12
  const config = po.config || {}
13
+ const [isEditing, setIsEditing] = useState(false)
14
+ const [editedPrompt, setEditedPrompt] = useState(prompt)
15
+ const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
16
+ const saveTimeoutRef = useRef<number | null>(null)
17
+
18
+ // Sync editedPrompt when po.prompt changes from server
19
+ useEffect(() => {
20
+ if (!isEditing) {
21
+ setEditedPrompt(prompt)
22
+ }
23
+ }, [prompt, isEditing])
24
+
25
+ // Debounced auto-save
26
+ const debouncedSave = useCallback((newPrompt: string) => {
27
+ if (saveTimeoutRef.current) {
28
+ clearTimeout(saveTimeoutRef.current)
29
+ }
30
+
31
+ setSaveStatus('unsaved')
32
+
33
+ saveTimeoutRef.current = window.setTimeout(() => {
34
+ if (onSave && newPrompt !== prompt) {
35
+ setSaveStatus('saving')
36
+ onSave(newPrompt)
37
+ // Assume save succeeded - server will broadcast update
38
+ setTimeout(() => setSaveStatus('saved'), 500)
39
+ } else {
40
+ setSaveStatus('saved')
41
+ }
42
+ }, 1000) // 1 second debounce
43
+ }, [onSave, prompt])
44
+
45
+ const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
46
+ const newPrompt = e.target.value
47
+ setEditedPrompt(newPrompt)
48
+ debouncedSave(newPrompt)
49
+ }
50
+
51
+ const handleStartEditing = () => {
52
+ setEditedPrompt(prompt)
53
+ setIsEditing(true)
54
+ }
55
+
56
+ const handleStopEditing = () => {
57
+ // Save any pending changes
58
+ if (saveTimeoutRef.current) {
59
+ clearTimeout(saveTimeoutRef.current)
60
+ }
61
+ if (editedPrompt !== prompt && onSave) {
62
+ onSave(editedPrompt)
63
+ }
64
+ setIsEditing(false)
65
+ setSaveStatus('saved')
66
+ }
67
+
68
+ // Cleanup timeout on unmount
69
+ useEffect(() => {
70
+ return () => {
71
+ if (saveTimeoutRef.current) {
72
+ clearTimeout(saveTimeoutRef.current)
73
+ }
74
+ }
75
+ }, [])
11
76
 
12
77
  return (
13
78
  <div className="h-full overflow-auto p-4">
@@ -23,29 +88,69 @@ export function PromptPanel({ po }: PromptPanelProps) {
23
88
 
24
89
  {/* Prompt/Body */}
25
90
  <div>
26
- <h3 className="text-lg font-medium text-white mb-3">Prompt</h3>
27
- <div className="bg-po-bg rounded-lg border border-po-border p-4">
28
- {prompt ? (
29
- <div className="prose prose-invert max-w-none">
30
- <MarkdownMessage content={prompt} />
31
- </div>
91
+ <div className="flex items-center justify-between mb-3">
92
+ <h3 className="text-lg font-medium text-white">Prompt</h3>
93
+ <div className="flex items-center gap-3">
94
+ {isEditing && (
95
+ <span className={`text-xs ${
96
+ saveStatus === 'saved' ? 'text-green-400' :
97
+ saveStatus === 'saving' ? 'text-yellow-400' :
98
+ 'text-gray-400'
99
+ }`}>
100
+ {saveStatus === 'saved' ? 'Saved' :
101
+ saveStatus === 'saving' ? 'Saving...' :
102
+ 'Unsaved changes'}
103
+ </span>
104
+ )}
105
+ <button
106
+ onClick={isEditing ? handleStopEditing : handleStartEditing}
107
+ className={`px-3 py-1 text-sm rounded transition-colors ${
108
+ isEditing
109
+ ? 'bg-po-accent text-black hover:bg-po-accent/80'
110
+ : 'bg-po-surface border border-po-border text-gray-300 hover:text-white hover:border-po-accent'
111
+ }`}
112
+ >
113
+ {isEditing ? 'Done' : 'Edit'}
114
+ </button>
115
+ </div>
116
+ </div>
117
+
118
+ <div className="bg-po-bg rounded-lg border border-po-border">
119
+ {isEditing ? (
120
+ <textarea
121
+ value={editedPrompt}
122
+ onChange={handlePromptChange}
123
+ className="w-full h-96 p-4 bg-transparent text-gray-200 font-mono text-sm resize-none focus:outline-none focus:ring-1 focus:ring-po-accent rounded-lg"
124
+ placeholder="Enter prompt markdown..."
125
+ spellCheck={false}
126
+ />
32
127
  ) : (
33
- <p className="text-gray-500 italic">No prompt defined</p>
128
+ <div className="p-4">
129
+ {prompt ? (
130
+ <div className="prose prose-invert max-w-none">
131
+ <MarkdownMessage content={prompt} />
132
+ </div>
133
+ ) : (
134
+ <p className="text-gray-500 italic">No prompt defined. Click Edit to add one.</p>
135
+ )}
136
+ </div>
34
137
  )}
35
138
  </div>
36
139
  </div>
37
140
 
38
- {/* Raw source toggle */}
39
- <details className="mt-6">
40
- <summary className="text-sm text-gray-400 cursor-pointer hover:text-white">
41
- View raw source
42
- </summary>
43
- <div className="mt-2 bg-po-bg rounded-lg border border-po-border p-4">
44
- <pre className="text-xs text-gray-400 font-mono whitespace-pre-wrap overflow-x-auto">
45
- {prompt || '(empty)'}
46
- </pre>
47
- </div>
48
- </details>
141
+ {/* Raw source toggle (only in view mode) */}
142
+ {!isEditing && (
143
+ <details className="mt-6">
144
+ <summary className="text-sm text-gray-400 cursor-pointer hover:text-white">
145
+ View raw source
146
+ </summary>
147
+ <div className="mt-2 bg-po-bg rounded-lg border border-po-border p-4">
148
+ <pre className="text-xs text-gray-400 font-mono whitespace-pre-wrap overflow-x-auto">
149
+ {prompt || '(empty)'}
150
+ </pre>
151
+ </div>
152
+ </details>
153
+ )}
49
154
  </div>
50
155
  )
51
156
  }
@@ -1,10 +1,13 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, useState } from 'react'
2
+ import { ContextMenu } from './ContextMenu'
2
3
  import type { PromptObject, Session, ThreadType } from '../types'
3
4
 
4
5
  interface ThreadsSidebarProps {
5
6
  po: PromptObject
6
7
  switchSession: (target: string, sessionId: string) => void
7
8
  createThread: (target: string) => void
9
+ requestUsage?: (sessionId: string, includeTree?: boolean) => void
10
+ exportThread?: (sessionId: string, format?: string) => void
8
11
  }
9
12
 
10
13
  // Build a flat list with depth info
@@ -59,12 +62,18 @@ function ThreadTypeIcon({ type }: { type: ThreadType }) {
59
62
  }
60
63
  }
61
64
 
62
- export function ThreadsSidebar({ po, switchSession, createThread }: ThreadsSidebarProps) {
65
+ export function ThreadsSidebar({ po, switchSession, createThread, requestUsage, exportThread }: ThreadsSidebarProps) {
63
66
  const sessions = po.sessions || []
64
67
  const currentSessionId = po.current_session?.id
68
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; sessionId: string } | null>(null)
65
69
 
66
70
  const threadList = useMemo(() => buildThreadList(sessions), [sessions])
67
71
 
72
+ const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
73
+ e.preventDefault()
74
+ setContextMenu({ x: e.clientX, y: e.clientY, sessionId })
75
+ }
76
+
68
77
  return (
69
78
  <div className="h-full flex flex-col">
70
79
  <div className="p-3 border-b border-po-border flex items-center justify-between">
@@ -88,6 +97,7 @@ export function ThreadsSidebar({ po, switchSession, createThread }: ThreadsSideb
88
97
  <button
89
98
  key={session.id}
90
99
  onClick={() => switchSession(po.name, session.id)}
100
+ onContextMenu={(e) => handleContextMenu(e, session.id)}
91
101
  className={`w-full text-left p-2 rounded text-xs transition-colors ${
92
102
  session.id === currentSessionId
93
103
  ? 'bg-po-accent/20 border border-po-accent'
@@ -114,6 +124,40 @@ export function ThreadsSidebar({ po, switchSession, createThread }: ThreadsSideb
114
124
  ))
115
125
  )}
116
126
  </div>
127
+
128
+ {contextMenu && (
129
+ <ContextMenu
130
+ x={contextMenu.x}
131
+ y={contextMenu.y}
132
+ onClose={() => setContextMenu(null)}
133
+ items={[
134
+ ...(requestUsage ? [
135
+ {
136
+ label: 'View Usage',
137
+ icon: '📊',
138
+ onClick: () => requestUsage(contextMenu.sessionId),
139
+ },
140
+ {
141
+ label: 'View Tree Usage',
142
+ icon: '🌳',
143
+ onClick: () => requestUsage(contextMenu.sessionId, true),
144
+ },
145
+ ] : []),
146
+ ...(exportThread ? [
147
+ {
148
+ label: 'Export as Markdown',
149
+ icon: '📄',
150
+ onClick: () => exportThread(contextMenu.sessionId, 'markdown'),
151
+ },
152
+ {
153
+ label: 'Export as JSON',
154
+ icon: '📋',
155
+ onClick: () => exportThread(contextMenu.sessionId, 'json'),
156
+ },
157
+ ] : []),
158
+ ]}
159
+ />
160
+ )}
117
161
  </div>
118
162
  )
119
163
  }