prompt_objects 0.3.1 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/CLAUDE.md +112 -44
  4. data/Gemfile.lock +31 -29
  5. data/README.md +5 -0
  6. data/frontend/index.html +5 -1
  7. data/frontend/package-lock.json +123 -0
  8. data/frontend/package.json +4 -0
  9. data/frontend/src/App.tsx +70 -71
  10. data/frontend/src/canvas/CanvasView.tsx +113 -0
  11. data/frontend/src/canvas/ForceLayout.ts +115 -0
  12. data/frontend/src/canvas/SceneManager.ts +587 -0
  13. data/frontend/src/canvas/canvasStore.ts +47 -0
  14. data/frontend/src/canvas/constants.ts +95 -0
  15. data/frontend/src/canvas/controls/CameraControls.ts +98 -0
  16. data/frontend/src/canvas/edges/MessageArc.ts +149 -0
  17. data/frontend/src/canvas/inspector/InspectorPanel.tsx +31 -0
  18. data/frontend/src/canvas/inspector/POInspector.tsx +262 -0
  19. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +67 -0
  20. data/frontend/src/canvas/nodes/PONode.ts +249 -0
  21. data/frontend/src/canvas/nodes/ToolCallNode.ts +116 -0
  22. data/frontend/src/canvas/types.ts +24 -0
  23. data/frontend/src/components/ContextMenu.tsx +5 -4
  24. data/frontend/src/components/Inspector.tsx +232 -0
  25. data/frontend/src/components/MarkdownMessage.tsx +22 -20
  26. data/frontend/src/components/MethodList.tsx +90 -0
  27. data/frontend/src/components/ModelSelector.tsx +13 -14
  28. data/frontend/src/components/NotificationPanel.tsx +29 -33
  29. data/frontend/src/components/ObjectList.tsx +78 -0
  30. data/frontend/src/components/PaneSlot.tsx +30 -0
  31. data/frontend/src/components/SourcePane.tsx +202 -0
  32. data/frontend/src/components/SystemBar.tsx +74 -0
  33. data/frontend/src/components/Transcript.tsx +76 -0
  34. data/frontend/src/components/UsagePanel.tsx +27 -27
  35. data/frontend/src/components/Workspace.tsx +260 -0
  36. data/frontend/src/components/index.ts +10 -9
  37. data/frontend/src/hooks/useResize.ts +55 -0
  38. data/frontend/src/hooks/useWebSocket.ts +274 -189
  39. data/frontend/src/index.css +69 -3
  40. data/frontend/src/store/index.ts +23 -0
  41. data/frontend/src/types/index.ts +5 -0
  42. data/frontend/tailwind.config.js +28 -9
  43. data/lib/prompt_objects/capability.rb +23 -1
  44. data/lib/prompt_objects/connectors/mcp.rb +5 -22
  45. data/lib/prompt_objects/environment.rb +8 -0
  46. data/lib/prompt_objects/llm/openai_adapter.rb +22 -0
  47. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +1 -2
  48. data/lib/prompt_objects/mcp/tools/inspect_po.rb +1 -31
  49. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +2 -8
  50. data/lib/prompt_objects/primitives/list_files.rb +1 -2
  51. data/lib/prompt_objects/prompt_object.rb +150 -6
  52. data/lib/prompt_objects/server/api/routes.rb +3 -48
  53. data/lib/prompt_objects/server/app.rb +9 -0
  54. data/lib/prompt_objects/server/public/assets/index-D1myxE0l.js +4345 -0
  55. data/lib/prompt_objects/server/public/assets/index-DdCcwC-Z.css +1 -0
  56. data/lib/prompt_objects/server/public/index.html +7 -3
  57. data/lib/prompt_objects/server/websocket_handler.rb +23 -100
  58. data/lib/prompt_objects/server.rb +6 -62
  59. data/prompt_objects.gemspec +1 -1
  60. data/templates/arc-agi-1/primitives/find_objects.rb +1 -1
  61. data/templates/arc-agi-1/primitives/grid_diff.rb +2 -2
  62. data/templates/arc-agi-1/primitives/grid_info.rb +1 -1
  63. data/templates/arc-agi-1/primitives/grid_transform.rb +1 -1
  64. data/templates/arc-agi-1/primitives/render_grid.rb +1 -0
  65. data/templates/arc-agi-1/primitives/test_solution.rb +3 -0
  66. metadata +26 -14
  67. data/frontend/src/components/CapabilitiesPanel.tsx +0 -141
  68. data/frontend/src/components/ChatPanel.tsx +0 -288
  69. data/frontend/src/components/Dashboard.tsx +0 -83
  70. data/frontend/src/components/Header.tsx +0 -141
  71. data/frontend/src/components/MessageBus.tsx +0 -56
  72. data/frontend/src/components/POCard.tsx +0 -56
  73. data/frontend/src/components/PODetail.tsx +0 -124
  74. data/frontend/src/components/PromptPanel.tsx +0 -156
  75. data/frontend/src/components/SessionsPanel.tsx +0 -174
  76. data/frontend/src/components/ThreadsSidebar.tsx +0 -163
  77. data/lib/prompt_objects/server/public/assets/index-Bkme6COu.css +0 -1
  78. data/lib/prompt_objects/server/public/assets/index-CQ7lVDF_.js +0 -77
@@ -0,0 +1,95 @@
1
+ // Canvas visualization constants
2
+
3
+ // Colors (hex values matching warm po-* palette)
4
+ export const COLORS = {
5
+ // Node colors
6
+ background: 0x1a1918,
7
+ surface: 0x222120,
8
+ border: 0x3d3a37,
9
+ accent: 0xd4952a,
10
+ accentHover: 0xe0a940,
11
+ success: 0x3b9a6e,
12
+ warning: 0xd4952a,
13
+ error: 0xc45c4a,
14
+
15
+ // Status colors
16
+ statusIdle: 0x78726a,
17
+ statusThinking: 0xd4952a,
18
+ statusCallingTool: 0x3b9a6e,
19
+
20
+ // Canvas-specific
21
+ nodeFill: 0x222120,
22
+ nodeGlow: 0xd4952a,
23
+ toolCallFill: 0x3b9a6e,
24
+ arcColor: 0xd4952a,
25
+ particleColor: 0xe0a940,
26
+ gridColor: 0x222120,
27
+ } as const
28
+
29
+ // CSS color strings (for CSS2DRenderer elements)
30
+ export const CSS_COLORS = {
31
+ accent: '#d4952a',
32
+ accentHover: '#e0a940',
33
+ warning: '#d4952a',
34
+ success: '#3b9a6e',
35
+ error: '#c45c4a',
36
+ textPrimary: '#e8e2da',
37
+ textSecondary: '#a8a29a',
38
+ textMuted: '#78726a',
39
+ surface: '#222120',
40
+ border: '#3d3a37',
41
+ statusIdle: '#78726a',
42
+ statusThinking: '#d4952a',
43
+ statusCallingTool: '#3b9a6e',
44
+ } as const
45
+
46
+ // Node dimensions
47
+ export const NODE = {
48
+ poRadius: 40,
49
+ poSides: 6, // hexagon
50
+ toolCallRadius: 18,
51
+ labelOffsetY: 55,
52
+ badgeOffsetX: 30,
53
+ badgeOffsetY: -30,
54
+ } as const
55
+
56
+ // Camera
57
+ export const CAMERA = {
58
+ zoomMin: 0.1,
59
+ zoomMax: 5,
60
+ zoomSpeed: 0.1,
61
+ fitPadding: 1.3, // 30% padding
62
+ } as const
63
+
64
+ // Animation
65
+ export const ANIMATION = {
66
+ positionLerpFactor: 0.1,
67
+ toolCallFadeInDuration: 0.4,
68
+ toolCallActiveDuration: 8,
69
+ toolCallFadeOutDuration: 1.5,
70
+ arcLifetime: 6,
71
+ arcFadeDuration: 2,
72
+ particleSpeed: 0.4,
73
+ particleCount: 5,
74
+ pulseSpeed: 2,
75
+ } as const
76
+
77
+ // Force simulation
78
+ export const FORCE = {
79
+ chargeStrength: -300,
80
+ centerStrength: 0.05,
81
+ collisionRadius: 60, // radius + 20
82
+ linkDistance: 200,
83
+ alphaDecay: 0.02,
84
+ velocityDecay: 0.4,
85
+ } as const
86
+
87
+ // Bloom
88
+ export const BLOOM = {
89
+ strength: 0.8,
90
+ radius: 0.4,
91
+ threshold: 0.6,
92
+ } as const
93
+
94
+ // Sync throttling
95
+ export const SYNC_THROTTLE_MS = 100
@@ -0,0 +1,98 @@
1
+ import * as THREE from 'three'
2
+ import { CAMERA } from '../constants'
3
+
4
+ export class CameraControls {
5
+ private camera: THREE.OrthographicCamera
6
+ private domElement: HTMLElement
7
+ private isPanning = false
8
+ private panStart = new THREE.Vector2()
9
+
10
+ private onWheel: (e: WheelEvent) => void
11
+ private onPointerDown: (e: PointerEvent) => void
12
+ private onPointerMove: (e: PointerEvent) => void
13
+ private onPointerUp: (e: PointerEvent) => void
14
+
15
+ constructor(camera: THREE.OrthographicCamera, domElement: HTMLElement) {
16
+ this.camera = camera
17
+ this.domElement = domElement
18
+
19
+ this.onWheel = this.handleWheel.bind(this)
20
+ this.onPointerDown = this.handlePointerDown.bind(this)
21
+ this.onPointerMove = this.handlePointerMove.bind(this)
22
+ this.onPointerUp = this.handlePointerUp.bind(this)
23
+
24
+ domElement.addEventListener('wheel', this.onWheel, { passive: false })
25
+ domElement.addEventListener('pointerdown', this.onPointerDown)
26
+ domElement.addEventListener('pointermove', this.onPointerMove)
27
+ domElement.addEventListener('pointerup', this.onPointerUp)
28
+ }
29
+
30
+ private handleWheel(e: WheelEvent): void {
31
+ e.preventDefault()
32
+ const delta = e.deltaY > 0 ? 1 - CAMERA.zoomSpeed : 1 + CAMERA.zoomSpeed
33
+ const newZoom = this.camera.zoom * delta
34
+ this.camera.zoom = THREE.MathUtils.clamp(newZoom, CAMERA.zoomMin, CAMERA.zoomMax)
35
+ this.camera.updateProjectionMatrix()
36
+ }
37
+
38
+ private handlePointerDown(e: PointerEvent): void {
39
+ // Shift+left click or middle mouse button for panning
40
+ if ((e.button === 0 && e.shiftKey) || e.button === 1) {
41
+ this.isPanning = true
42
+ this.panStart.set(e.clientX, e.clientY)
43
+ this.domElement.setPointerCapture(e.pointerId)
44
+ }
45
+ }
46
+
47
+ private handlePointerMove(e: PointerEvent): void {
48
+ if (!this.isPanning) return
49
+
50
+ const dx = e.clientX - this.panStart.x
51
+ const dy = e.clientY - this.panStart.y
52
+
53
+ // Convert screen pixels to world units based on zoom
54
+ const worldDx = -dx / this.camera.zoom
55
+ const worldDy = dy / this.camera.zoom
56
+
57
+ this.camera.position.x += worldDx
58
+ this.camera.position.y += worldDy
59
+
60
+ this.panStart.set(e.clientX, e.clientY)
61
+ }
62
+
63
+ private handlePointerUp(e: PointerEvent): void {
64
+ if (this.isPanning) {
65
+ this.isPanning = false
66
+ this.domElement.releasePointerCapture(e.pointerId)
67
+ }
68
+ }
69
+
70
+ fitAll(bounds: THREE.Box3): void {
71
+ if (bounds.isEmpty()) return
72
+
73
+ const center = new THREE.Vector3()
74
+ bounds.getCenter(center)
75
+ const size = new THREE.Vector3()
76
+ bounds.getSize(size)
77
+
78
+ this.camera.position.x = center.x
79
+ this.camera.position.y = center.y
80
+
81
+ const viewWidth = this.camera.right - this.camera.left
82
+ const viewHeight = this.camera.top - this.camera.bottom
83
+
84
+ const scaleX = viewWidth / (size.x * CAMERA.fitPadding)
85
+ const scaleY = viewHeight / (size.y * CAMERA.fitPadding)
86
+
87
+ this.camera.zoom = Math.min(scaleX, scaleY, CAMERA.zoomMax)
88
+ this.camera.zoom = Math.max(this.camera.zoom, CAMERA.zoomMin)
89
+ this.camera.updateProjectionMatrix()
90
+ }
91
+
92
+ dispose(): void {
93
+ this.domElement.removeEventListener('wheel', this.onWheel)
94
+ this.domElement.removeEventListener('pointerdown', this.onPointerDown)
95
+ this.domElement.removeEventListener('pointermove', this.onPointerMove)
96
+ this.domElement.removeEventListener('pointerup', this.onPointerUp)
97
+ }
98
+ }
@@ -0,0 +1,149 @@
1
+ import * as THREE from 'three'
2
+ import { COLORS, ANIMATION } from '../constants'
3
+
4
+ export class MessageArc {
5
+ readonly id: string
6
+ readonly from: string
7
+ readonly to: string
8
+ readonly group: THREE.Group
9
+
10
+ private curve: THREE.QuadraticBezierCurve3
11
+ private line: THREE.Line
12
+ private lineMaterial: THREE.LineBasicMaterial
13
+ private particles: THREE.Points
14
+ private particleMaterial: THREE.PointsMaterial
15
+ private particlePositions: Float32Array
16
+ private particleTs: number[] // parameter t for each particle on the curve
17
+
18
+ private startPoint = new THREE.Vector3()
19
+ private endPoint = new THREE.Vector3()
20
+ private controlPoint = new THREE.Vector3()
21
+ private age = 0
22
+ private expired = false
23
+
24
+ constructor(id: string, from: string, to: string, startPos: THREE.Vector3, endPos: THREE.Vector3) {
25
+ this.id = id
26
+ this.from = from
27
+ this.to = to
28
+ this.group = new THREE.Group()
29
+
30
+ this.startPoint.copy(startPos)
31
+ this.endPoint.copy(endPos)
32
+ this.computeControlPoint()
33
+
34
+ this.curve = new THREE.QuadraticBezierCurve3(
35
+ this.startPoint,
36
+ this.controlPoint,
37
+ this.endPoint
38
+ )
39
+
40
+ // Arc line
41
+ const linePoints = this.curve.getPoints(50)
42
+ const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints)
43
+ this.lineMaterial = new THREE.LineBasicMaterial({
44
+ color: COLORS.arcColor,
45
+ transparent: true,
46
+ opacity: 0.4,
47
+ })
48
+ this.line = new THREE.Line(lineGeometry, this.lineMaterial)
49
+ this.group.add(this.line)
50
+
51
+ // Particles traveling along the curve
52
+ const count = ANIMATION.particleCount
53
+ this.particlePositions = new Float32Array(count * 3)
54
+ this.particleTs = []
55
+
56
+ for (let i = 0; i < count; i++) {
57
+ this.particleTs.push(i / count)
58
+ }
59
+
60
+ const particleGeometry = new THREE.BufferGeometry()
61
+ particleGeometry.setAttribute(
62
+ 'position',
63
+ new THREE.BufferAttribute(this.particlePositions, 3)
64
+ )
65
+
66
+ this.particleMaterial = new THREE.PointsMaterial({
67
+ color: COLORS.particleColor,
68
+ size: 4,
69
+ transparent: true,
70
+ opacity: 0.8,
71
+ sizeAttenuation: false,
72
+ })
73
+
74
+ this.particles = new THREE.Points(particleGeometry, this.particleMaterial)
75
+ this.group.add(this.particles)
76
+
77
+ this.updateParticlePositions()
78
+ }
79
+
80
+ private computeControlPoint(): void {
81
+ // Perpendicular offset at midpoint
82
+ const mid = new THREE.Vector3().addVectors(this.startPoint, this.endPoint).multiplyScalar(0.5)
83
+ const dir = new THREE.Vector3().subVectors(this.endPoint, this.startPoint)
84
+ const dist = dir.length()
85
+ // Perpendicular in 2D: rotate 90 degrees
86
+ const perp = new THREE.Vector3(-dir.y, dir.x, 0).normalize()
87
+ this.controlPoint.copy(mid).addScaledVector(perp, dist * 0.3)
88
+ }
89
+
90
+ updateEndpoints(startPos: THREE.Vector3, endPos: THREE.Vector3): void {
91
+ this.startPoint.copy(startPos)
92
+ this.endPoint.copy(endPos)
93
+ this.computeControlPoint()
94
+
95
+ this.curve.v0.copy(this.startPoint)
96
+ this.curve.v1.copy(this.controlPoint)
97
+ this.curve.v2.copy(this.endPoint)
98
+
99
+ // Rebuild line geometry
100
+ const linePoints = this.curve.getPoints(50)
101
+ this.line.geometry.dispose()
102
+ this.line.geometry = new THREE.BufferGeometry().setFromPoints(linePoints)
103
+ }
104
+
105
+ private updateParticlePositions(): void {
106
+ for (let i = 0; i < this.particleTs.length; i++) {
107
+ const point = this.curve.getPoint(this.particleTs[i])
108
+ this.particlePositions[i * 3] = point.x
109
+ this.particlePositions[i * 3 + 1] = point.y
110
+ this.particlePositions[i * 3 + 2] = point.z
111
+ }
112
+ this.particles.geometry.attributes.position.needsUpdate = true
113
+ }
114
+
115
+ update(delta: number): void {
116
+ this.age += delta
117
+
118
+ // Advance particles
119
+ for (let i = 0; i < this.particleTs.length; i++) {
120
+ this.particleTs[i] = (this.particleTs[i] + delta * ANIMATION.particleSpeed) % 1
121
+ }
122
+ this.updateParticlePositions()
123
+
124
+ // Fade out in last 2 seconds of lifetime
125
+ const fadeStart = ANIMATION.arcLifetime - ANIMATION.arcFadeDuration
126
+ if (this.age > fadeStart) {
127
+ const fadeProgress = (this.age - fadeStart) / ANIMATION.arcFadeDuration
128
+ const alpha = Math.max(1 - fadeProgress, 0)
129
+ this.lineMaterial.opacity = 0.4 * alpha
130
+ this.particleMaterial.opacity = 0.8 * alpha
131
+ }
132
+
133
+ if (this.age >= ANIMATION.arcLifetime) {
134
+ this.expired = true
135
+ }
136
+ }
137
+
138
+ isExpired(): boolean {
139
+ return this.expired
140
+ }
141
+
142
+ dispose(): void {
143
+ this.line.geometry.dispose()
144
+ this.lineMaterial.dispose()
145
+ this.particles.geometry.dispose()
146
+ this.particleMaterial.dispose()
147
+ this.group.parent?.remove(this.group)
148
+ }
149
+ }
@@ -0,0 +1,31 @@
1
+ import { useCanvasStore } from '../canvasStore'
2
+ import { POInspector } from './POInspector'
3
+ import { ToolCallInspector } from './ToolCallInspector'
4
+
5
+ export function InspectorPanel() {
6
+ const selectedNode = useCanvasStore((s) => s.selectedNode)
7
+
8
+ if (!selectedNode) return null
9
+
10
+ return (
11
+ <aside className="w-80 border-l border-po-border bg-po-surface overflow-hidden flex flex-col">
12
+ <div className="h-8 bg-po-surface-2 border-b border-po-border flex items-center px-3">
13
+ <span className="text-2xs font-medium text-po-text-ghost uppercase tracking-wider flex-1">Inspector</span>
14
+ <button
15
+ onClick={() => useCanvasStore.getState().selectNode(null)}
16
+ className="text-2xs text-po-text-ghost hover:text-po-text-secondary transition-colors duration-150"
17
+ title="Close inspector"
18
+ >
19
+ {'\u2715'}
20
+ </button>
21
+ </div>
22
+ <div className="flex-1 overflow-auto">
23
+ {selectedNode.type === 'po' ? (
24
+ <POInspector poName={selectedNode.id} />
25
+ ) : (
26
+ <ToolCallInspector toolCallId={selectedNode.id} />
27
+ )}
28
+ </div>
29
+ </aside>
30
+ )
31
+ }
@@ -0,0 +1,262 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
+ import { useStore, usePONotifications } from '../../store'
3
+ import { useWebSocket } from '../../hooks/useWebSocket'
4
+
5
+ interface Props {
6
+ poName: string
7
+ }
8
+
9
+ const statusColors: Record<string, string> = {
10
+ idle: 'bg-po-status-idle',
11
+ thinking: 'bg-po-status-active animate-pulse',
12
+ calling_tool: 'bg-po-status-calling animate-pulse',
13
+ }
14
+
15
+ export function POInspector({ poName }: Props) {
16
+ const po = useStore((s) => s.promptObjects[poName])
17
+ const notifications = usePONotifications(poName)
18
+ const { updatePrompt, respondToNotification } = useWebSocket()
19
+ const { selectPO, setCurrentView } = useStore()
20
+
21
+ if (!po) {
22
+ return (
23
+ <div className="p-4 text-po-text-ghost text-xs font-mono">
24
+ Prompt Object "{poName}" not found.
25
+ </div>
26
+ )
27
+ }
28
+
29
+ return (
30
+ <div className="p-4 space-y-5">
31
+ {/* Header */}
32
+ <div>
33
+ <div className="flex items-center gap-2 mb-1">
34
+ <h3 className="text-sm font-mono font-medium text-po-text-primary">{po.name}</h3>
35
+ <div className={`w-2 h-2 rounded-full ${statusColors[po.status] || statusColors.idle}`} />
36
+ </div>
37
+ <p className="text-xs text-po-text-secondary">{po.description}</p>
38
+ <span className="inline-block mt-1 text-2xs text-po-text-ghost bg-po-surface-2 px-1.5 py-0.5 rounded font-mono">
39
+ {po.status}
40
+ </span>
41
+ </div>
42
+
43
+ {/* Capabilities */}
44
+ <div>
45
+ <h4 className="text-2xs font-medium text-po-text-ghost uppercase tracking-wider mb-2">
46
+ Capabilities ({po.capabilities?.length || 0})
47
+ </h4>
48
+ <div className="space-y-1">
49
+ {(po.capabilities || []).map((cap) => (
50
+ <CapabilityItem key={cap.name} name={cap.name} description={cap.description} />
51
+ ))}
52
+ </div>
53
+ </div>
54
+
55
+ {/* Prompt */}
56
+ <PromptSection
57
+ prompt={po.prompt || ''}
58
+ onSave={(prompt) => updatePrompt(poName, prompt)}
59
+ />
60
+
61
+ {/* Sessions */}
62
+ <div>
63
+ <h4 className="text-2xs font-medium text-po-text-ghost uppercase tracking-wider mb-2">
64
+ Sessions ({po.sessions?.length || 0})
65
+ </h4>
66
+ {po.current_session && (
67
+ <div className="text-xs text-po-text-tertiary">
68
+ Current: <span className="text-po-text-secondary font-mono">{po.current_session.id.slice(0, 8)}...</span>
69
+ <span className="ml-2">({po.current_session.messages.length} messages)</span>
70
+ </div>
71
+ )}
72
+ </div>
73
+
74
+ {/* Notifications */}
75
+ {notifications.length > 0 && (
76
+ <div>
77
+ <h4 className="text-2xs font-medium text-po-warning uppercase tracking-wider mb-2">
78
+ Pending Requests ({notifications.length})
79
+ </h4>
80
+ <div className="space-y-2">
81
+ {notifications.map((n) => (
82
+ <NotificationCard
83
+ key={n.id}
84
+ notification={n}
85
+ onRespond={(response) => respondToNotification(n.id, response)}
86
+ />
87
+ ))}
88
+ </div>
89
+ </div>
90
+ )}
91
+
92
+ {/* Link to full detail */}
93
+ <button
94
+ onClick={() => {
95
+ selectPO(poName)
96
+ setCurrentView('dashboard')
97
+ }}
98
+ className="text-xs text-po-accent hover:underline font-mono transition-colors duration-150"
99
+ >
100
+ Open in browser view
101
+ </button>
102
+ </div>
103
+ )
104
+ }
105
+
106
+ function CapabilityItem({ name, description }: { name: string; description: string }) {
107
+ const [expanded, setExpanded] = useState(false)
108
+
109
+ return (
110
+ <div className="bg-po-surface-2 border border-po-border rounded overflow-hidden">
111
+ <button
112
+ onClick={() => setExpanded(!expanded)}
113
+ className="w-full px-2.5 py-1.5 flex items-center justify-between hover:bg-po-surface-3 transition-colors duration-150"
114
+ >
115
+ <span className="font-mono text-xs text-po-accent">{name}</span>
116
+ <span className="text-po-text-ghost text-xs">{expanded ? '\u25BC' : '\u25B8'}</span>
117
+ </button>
118
+ {expanded && (
119
+ <div className="px-2.5 py-2 border-t border-po-border bg-po-surface">
120
+ <p className="text-xs text-po-text-secondary">{description}</p>
121
+ </div>
122
+ )}
123
+ </div>
124
+ )
125
+ }
126
+
127
+ function PromptSection({ prompt, onSave }: { prompt: string; onSave: (p: string) => void }) {
128
+ const [isEditing, setIsEditing] = useState(false)
129
+ const [edited, setEdited] = useState(prompt)
130
+ const saveTimeoutRef = useRef<number | null>(null)
131
+
132
+ useEffect(() => {
133
+ if (!isEditing) setEdited(prompt)
134
+ }, [prompt, isEditing])
135
+
136
+ const debouncedSave = useCallback(
137
+ (value: string) => {
138
+ if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
139
+ saveTimeoutRef.current = window.setTimeout(() => {
140
+ if (value !== prompt) onSave(value)
141
+ }, 1000)
142
+ },
143
+ [onSave, prompt]
144
+ )
145
+
146
+ useEffect(() => {
147
+ return () => {
148
+ if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
149
+ }
150
+ }, [])
151
+
152
+ return (
153
+ <div>
154
+ <div className="flex items-center justify-between mb-2">
155
+ <h4 className="text-2xs font-medium text-po-text-ghost uppercase tracking-wider">Prompt</h4>
156
+ <button
157
+ onClick={() => {
158
+ if (isEditing && edited !== prompt) onSave(edited)
159
+ setIsEditing(!isEditing)
160
+ }}
161
+ className={`text-2xs px-1.5 py-0.5 rounded transition-colors duration-150 ${
162
+ isEditing
163
+ ? 'bg-po-accent text-po-bg'
164
+ : 'text-po-text-tertiary hover:text-po-text-primary hover:bg-po-surface-2'
165
+ }`}
166
+ >
167
+ {isEditing ? 'Done' : 'Edit'}
168
+ </button>
169
+ </div>
170
+ {isEditing ? (
171
+ <textarea
172
+ value={edited}
173
+ onChange={(e) => {
174
+ setEdited(e.target.value)
175
+ debouncedSave(e.target.value)
176
+ }}
177
+ className="w-full h-40 bg-po-bg border border-po-border rounded p-2 text-xs text-po-text-primary font-mono resize-none focus:outline-none focus:border-po-accent"
178
+ spellCheck={false}
179
+ />
180
+ ) : (
181
+ <div className="bg-po-surface-2 border border-po-border rounded p-2 max-h-32 overflow-auto">
182
+ <pre className="text-xs text-po-text-secondary font-mono whitespace-pre-wrap">
183
+ {prompt || '(no prompt)'}
184
+ </pre>
185
+ </div>
186
+ )}
187
+ </div>
188
+ )
189
+ }
190
+
191
+ function NotificationCard({
192
+ notification,
193
+ onRespond,
194
+ }: {
195
+ notification: { id: string; type: string; message: string; options: string[] }
196
+ onRespond: (response: string) => void
197
+ }) {
198
+ const [customInput, setCustomInput] = useState('')
199
+ const [showCustom, setShowCustom] = useState(false)
200
+
201
+ return (
202
+ <div className="bg-po-surface-2 border border-po-border rounded p-2">
203
+ <span className="text-2xs font-mono bg-po-warning text-po-bg px-1.5 py-0.5 rounded font-bold">
204
+ {notification.type}
205
+ </span>
206
+ <p className="text-xs text-po-text-primary mt-1.5 mb-2">{notification.message}</p>
207
+
208
+ {notification.options.length > 0 && (
209
+ <div className="flex flex-wrap gap-1.5 mb-1.5">
210
+ {notification.options.map((opt, i) => (
211
+ <button
212
+ key={i}
213
+ onClick={() => onRespond(opt)}
214
+ className="px-2 py-0.5 text-xs bg-po-surface border border-po-border rounded hover:border-po-accent hover:text-po-accent transition-colors duration-150 text-po-text-secondary"
215
+ >
216
+ {opt}
217
+ </button>
218
+ ))}
219
+ </div>
220
+ )}
221
+
222
+ {showCustom ? (
223
+ <div className="flex gap-1.5 mt-1.5">
224
+ <input
225
+ type="text"
226
+ value={customInput}
227
+ onChange={(e) => setCustomInput(e.target.value)}
228
+ placeholder="Custom response..."
229
+ className="flex-1 bg-po-bg border border-po-border rounded px-2 py-1 text-xs text-po-text-primary placeholder-po-text-ghost focus:outline-none focus:border-po-accent"
230
+ onKeyDown={(e) => {
231
+ if (e.key === 'Enter' && customInput.trim()) {
232
+ onRespond(customInput.trim())
233
+ setCustomInput('')
234
+ setShowCustom(false)
235
+ }
236
+ }}
237
+ autoFocus
238
+ />
239
+ <button
240
+ onClick={() => {
241
+ if (customInput.trim()) {
242
+ onRespond(customInput.trim())
243
+ setCustomInput('')
244
+ setShowCustom(false)
245
+ }
246
+ }}
247
+ className="px-2 py-1 text-xs bg-po-accent text-po-bg rounded font-medium"
248
+ >
249
+ Send
250
+ </button>
251
+ </div>
252
+ ) : (
253
+ <button
254
+ onClick={() => setShowCustom(true)}
255
+ className="text-2xs text-po-text-ghost hover:text-po-text-secondary transition-colors duration-150 mt-1"
256
+ >
257
+ + Custom
258
+ </button>
259
+ )}
260
+ </div>
261
+ )
262
+ }
@@ -0,0 +1,67 @@
1
+ import { useCanvasStore } from '../canvasStore'
2
+
3
+ interface Props {
4
+ toolCallId: string
5
+ }
6
+
7
+ const statusColors: Record<string, string> = {
8
+ active: 'text-po-accent',
9
+ completed: 'text-po-success',
10
+ error: 'text-po-error',
11
+ }
12
+
13
+ export function ToolCallInspector({ toolCallId }: Props) {
14
+ const toolCall = useCanvasStore((s) => s.activeToolCalls.get(toolCallId))
15
+
16
+ if (!toolCall) {
17
+ return (
18
+ <div className="p-4 text-po-text-ghost text-xs font-mono">
19
+ Tool call not found or has expired.
20
+ </div>
21
+ )
22
+ }
23
+
24
+ const duration = toolCall.completedAt
25
+ ? ((toolCall.completedAt - toolCall.startedAt) / 1000).toFixed(1) + 's'
26
+ : ((Date.now() - toolCall.startedAt) / 1000).toFixed(1) + 's (running)'
27
+
28
+ return (
29
+ <div className="p-4 space-y-4">
30
+ {/* Header */}
31
+ <div>
32
+ <h3 className="text-sm font-mono font-medium text-po-text-primary">{toolCall.toolName}</h3>
33
+ <div className="flex items-center gap-2 mt-1">
34
+ <span className={`text-xs font-mono font-medium ${statusColors[toolCall.status] || ''}`}>
35
+ {toolCall.status}
36
+ </span>
37
+ <span className="text-2xs text-po-text-ghost font-mono">{duration}</span>
38
+ </div>
39
+ <div className="text-xs text-po-text-tertiary mt-1">
40
+ Called by: <span className="text-po-accent font-mono">{toolCall.callerPO}</span>
41
+ </div>
42
+ </div>
43
+
44
+ {/* Parameters */}
45
+ <div>
46
+ <h4 className="text-2xs font-medium text-po-text-ghost uppercase tracking-wider mb-2">Parameters</h4>
47
+ <div className="bg-po-surface-2 border border-po-border rounded p-2.5 overflow-auto max-h-48">
48
+ <pre className="text-xs text-po-text-secondary font-mono whitespace-pre-wrap">
49
+ {JSON.stringify(toolCall.params, null, 2)}
50
+ </pre>
51
+ </div>
52
+ </div>
53
+
54
+ {/* Result */}
55
+ {toolCall.result && (
56
+ <div>
57
+ <h4 className="text-2xs font-medium text-po-text-ghost uppercase tracking-wider mb-2">Result</h4>
58
+ <div className="bg-po-surface-2 border border-po-border rounded p-2.5 overflow-auto max-h-64">
59
+ <pre className="text-xs text-po-text-secondary font-mono whitespace-pre-wrap">
60
+ {toolCall.result}
61
+ </pre>
62
+ </div>
63
+ </div>
64
+ )}
65
+ </div>
66
+ )
67
+ }