project-graph-mcp 2.3.0 → 2.3.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-graph-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for AI agents — project graph, code quality analysis, visual web explorer. JS, TS, Python, Go.",
6
6
  "main": "src/network/server.js",
@@ -10,7 +10,6 @@
10
10
  "publishConfig": {
11
11
  "access": "public"
12
12
  },
13
-
14
13
  "scripts": {
15
14
  "start": "node src/network/server.js",
16
15
  "test": "node --test tests/*.test.js"
@@ -40,7 +39,6 @@
40
39
  "license": "MIT",
41
40
  "dependencies": {
42
41
  "@symbiotejs/symbiote": "^3.2.1",
43
- "symbiote-node": "file:vendor/symbiote-node",
44
42
  "ws": "^8.20.0"
45
43
  },
46
44
  "engines": {
Binary file
@@ -1,6 +1,6 @@
1
1
  // @ctx .context/src/network/web-server.ctx
2
2
  import e from"node:http";import t from"node:fs";import o from"node:path";import n from"node:crypto";import{fileURLToPath as a}from"node:url";import{createRequire as _createRequire}from"node:module";import{WebSocketServer as s}from"ws";import{createServer as i}from"../mcp/mcp-server.js";import c from"../core/event-bus.js";import{registerService as r}from"./local-gateway.js";import{compressFile as _cf}from"../compact/compress.js";import{expandFile as _ef}from"../compact/expand.js";import{setRoots as _setRoots}from"../core/workspace.js";
3
- const d=o.dirname(a(import.meta.url)),p=o.join(d,"..","..");const _rq=_createRequire(import.meta.url);let _pkgVersion="0.0.0";try{_pkgVersion=JSON.parse(t.readFileSync(o.join(p,"package.json"),"utf8")).version}catch{}function _rv(k){try{const r=_rq.resolve(k);const marker=o.sep+"node_modules"+o.sep+k.replace(/\//g,o.sep);const idx=r.lastIndexOf(marker);if(idx>=0)return r.substring(0,idx+marker.length);return o.dirname(r)}catch{return o.join(p,"node_modules",...k.split("/"))}}const m=o.join(p,"web"),h={"symbiote-node":_rv("symbiote-node"),symbiote:_rv("@symbiotejs/symbiote")},f={".html":"text/html",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".woff2":"font/woff2"};
3
+ const d=o.dirname(a(import.meta.url)),p=o.join(d,"..","..");const _rq=_createRequire(import.meta.url);let _pkgVersion="0.0.0";try{_pkgVersion=JSON.parse(t.readFileSync(o.join(p,"package.json"),"utf8")).version}catch{}function _rv(k){try{const r=_rq.resolve(k);const marker=o.sep+"node_modules"+o.sep+k.replace(/\//g,o.sep);const idx=r.lastIndexOf(marker);if(idx>=0)return r.substring(0,idx+marker.length);return o.dirname(r)}catch{return o.join(p,"node_modules",...k.split("/"))}}const m=o.join(p,"web"),_symNodeVendor=o.join(p,"vendor","symbiote-node"),h={"symbiote-node":t.existsSync(_symNodeVendor)?_symNodeVendor:_rv("symbiote-node"),symbiote:_rv("@symbiotejs/symbiote")},f={".html":"text/html",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".woff2":"font/woff2"};
4
4
  function g(e,n){const a=o.normalize(e).replace(/^(\.\.[/\\])+/,""),s=a.match(/^[/\\]?vendor[/\\]([^/\\]+)[/\\]?(.*)/);let i,c;if(s&&h[s[1]]?(c=h[s[1]],i=o.join(c,s[2]||"index.js")):(c=m,i=o.join(m,"/"===a?"index.html":a)),!i.startsWith(c))return n.writeHead(403),void n.end("Forbidden");if(t.existsSync(i)&&t.statSync(i).isDirectory()&&(i=o.join(i,"index.html")),!t.existsSync(i))return n.writeHead(404),void n.end("Not Found");const r=o.extname(i),l=f[r]||"application/octet-stream",d=t.readFileSync(i);n.writeHead(200,{"Content-Type":l,"Cache-Control":"no-cache, no-store, must-revalidate"}),n.end(d)}
5
5
  function u(e){return n.createHash("sha1").update(e+"258EAFA5-E914-47DA-95CA-5AB5ADF35C70").digest("base64")}
6
6
  function y(e){const t=Buffer.from(e,"utf8"),o=t.length;let n;return o<126?(n=Buffer.alloc(2),n[0]=129,n[1]=o):o<65536?(n=Buffer.alloc(4),n[0]=129,n[1]=126,n.writeUInt16BE(o,2)):(n=Buffer.alloc(10),n[0]=129,n[1]=127,n.writeBigUInt64BE(BigInt(o),2)),Buffer.concat([n,t])}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiote-node",
3
- "version": "0.3.0-alpha.0",
3
+ "version": "0.3.0-alpha.1",
4
4
  "type": "module",
5
5
  "description": "Visual Node Graph Editor + Execution Engine — extensible, themeable graph editor and runtime built on Symbiote.js",
6
6
  "main": "index.js",
@@ -56,4 +56,4 @@
56
56
  "engines": {
57
57
  "node": ">=18.0.0"
58
58
  }
59
- }
59
+ }
package/web/app.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // @ctx .context/web/app.ctx
2
2
  import{Layout as e,LayoutTree as t,applyTheme as n,CARBON as o}from"symbiote-node";
3
+ import{followController}from"./follow-controller.js";
4
+ import"./components/follow-ribbon.js";
3
5
  import{state as a,subscribe as s,onEvent as i,call as r,connect as c}from"./state.js";
4
6
  import"./panels/file-tree.js";
5
7
  import"./panels/code-viewer.js";
@@ -16,9 +18,10 @@ export const baseUrl=new URL(".",import.meta.url).href;const l=baseUrl;
16
18
  export async function api(e,t={}){if(a.connected&&e.startsWith("/api/")){const n=await async function(e,t){const n={"/api/skeleton":{name:"get_skeleton",args:e=>({path:e.path})},"/api/file":{name:"compact",args:e=>({action:"compact_file",path:e.path,beautify:!0})},"/api/analysis":{name:"analyze",args:e=>({action:"full_analysis",path:e.path})},"/api/analysis-summary":{name:"analyze",args:e=>({action:"analysis_summary",path:e.path})},"/api/deps":{name:"navigate",args:e=>({action:"deps",symbol:e.symbol})},"/api/usages":{name:"navigate",args:e=>({action:"usages",symbol:e.symbol})},"/api/expand":{name:"navigate",args:e=>({action:"expand",symbol:e.symbol})},"/api/chain":{name:"navigate",args:e=>({action:"call_chain",from:e.from,to:e.to})}}[e];return n?r(n.name,n.args(t)):null}(e,t);if(null!==n)return n}const n=new URLSearchParams(t).toString(),o=e.replace(/^\//, ""),s=n?`${l}${o}?${n}`:`${l}${o}`,i=await fetch(s);if(!i.ok)throw new Error(`API error: ${i.status}`);return i.json()}
17
19
  export const events=new EventTarget;
18
20
  export function emit(e,t={}){events.dispatchEvent(new CustomEvent(e,{detail:t}))}
19
- const p={"file-tree":{title:"Files",icon:"folder",component:"pg-file-tree"},"code-viewer":{title:"Code",icon:"code",component:"pg-code-viewer"},"ctx-panel":{title:"Documentation",icon:"description",component:"pg-ctx-panel"},"dep-graph":{title:"Dependencies",icon:"account_tree",component:"pg-dep-graph"},health:{title:"Health",icon:"analytics",component:"pg-health-panel"},monitor:{title:"Live Monitor",icon:"monitor_heart",component:"pg-live-monitor"},settings:{title:"Settings",icon:"settings",component:"pg-settings-panel"}},m=[{id:"explorer",icon:"folder_open",label:"Explorer"},{id:"graph",icon:"developer_board",label:"Graph"},{id:"analysis",icon:"analytics",label:"Analysis"},{id:"monitor",icon:"monitor_heart",label:"Monitor"},{id:"settings",icon:"settings",label:"Settings"}],d={explorer:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createSplit("horizontal",t.createPanel("code-viewer"),t.createPanel("ctx-panel"),.65),.2),graph:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createPanel("dep-graph"),.18),analysis:()=>t.createPanel("health"),monitor:()=>t.createPanel("monitor"),settings:()=>t.createPanel("settings")};
21
+ const p={"file-tree":{title:"Files",icon:"folder",component:"pg-file-tree"},"code-viewer":{title:"Code",icon:"code",component:"pg-code-viewer"},"ctx-panel":{title:"Documentation",icon:"description",component:"pg-ctx-panel"},"dep-graph":{title:"Dependencies",icon:"account_tree",component:"pg-dep-graph"},health:{title:"Health",icon:"analytics",component:"pg-health-panel"},monitor:{title:"Live Monitor",icon:"monitor_heart",component:"pg-live-monitor"},settings:{title:"Settings",icon:"settings",component:"pg-settings-panel"}},m=[{id:"explorer",icon:"folder_open",label:"Explorer"},{id:"graph",icon:"developer_board",label:"Graph"},{id:"follow",icon:"smart_toy",label:"Follow"},{id:"analysis",icon:"analytics",label:"Analysis"},{id:"monitor",icon:"monitor_heart",label:"Monitor"},{id:"settings",icon:"settings",label:"Settings"}],d={explorer:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createSplit("horizontal",t.createPanel("code-viewer"),t.createPanel("ctx-panel"),.65),.2),graph:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createPanel("dep-graph"),.18),follow:()=>t.createSplit("horizontal",t.createPanel("file-tree"),t.createSplit("vertical",t.createSplit("horizontal",t.createPanel("dep-graph"),t.createPanel("code-viewer"),.65),t.createPanel("monitor"),.72),.12),analysis:()=>t.createPanel("health"),monitor:()=>t.createPanel("monitor"),settings:()=>t.createPanel("settings")};
20
22
  async function u(){(function(){n(document.documentElement,o);const e=document.querySelector(".app-workspace"),t=document.createElement("layout-sidebar");e.prepend(t);const a=e.querySelector(".app-content"),s=document.createElement("panel-layout");s.setAttribute("storage-key","pg-explorer-layout"),s.setAttribute("min-panel-size","150"),s.id="main-layout",a.appendChild(s),requestAnimationFrame(()=>{for(const[e,t]of Object.entries(p))s.registerPanelType(e,t);let lastSection="";function e(){const e=location.hash.replace("#","")||"explorer",t=e.indexOf("?"),n=t>=0?e.substring(0,t):e,o=n.indexOf("/"),a=o>=0?n.substring(0,o):n,i=o>=0?n.substring(o+1):"";if(d[a]&&a!==lastSection){lastSection=a;s.setLayout(d[a]())}"explorer"===a&&i&&requestAnimationFrame(()=>{state.activeFile=i,emit("file-selected",{path:i,fromRoute:!0})})}t.setSections(m),window.addEventListener("hashchange",e),events.addEventListener("file-selected",e=>{if(e.detail.fromRoute)return;if(e.detail.source==="canvas")return;const t=e.detail.path;const _sec=(location.hash.replace("#","").split("?")[0].split("/")[0])||"explorer";if(t&&_sec==="explorer")history.replaceState(null,"",`#explorer/${t}`)}),localStorage.getItem("pg-explorer-layout")||s.setLayout(d.explorer()),location.hash&&"#"!==location.hash?e():location.hash="explorer"})})(),s("project",e=>{e&&(document.title=`${e.name} — Project Graph`,document.getElementById("project-name").textContent=e.name,document.documentElement.style.setProperty("--project-accent",e.color),g(e.agents))}),events.addEventListener("skeleton-loaded",e=>{const t=e.detail;if(!t)return;state.skeleton=t;const n=new Set;for(const e of Object.values(t.n||{}))e.f&&n.add(e.f);for(const e of Object.keys(t.X||{}))n.add(e);for(const[e,o]of Object.entries(t.f||{}))for(const t of o)n.add("./"===e?t:`${e}${t}`);for(const[e,o]of Object.entries(t.a||{}))for(const t of o)n.add("./"===e?t:`${e}${t}`);const o=document.getElementById("project-files");o&&(o.textContent=`${n.size} files`)}),s("skeleton",e=>{if(!e)return;state.skeleton=e;emit("skeleton-loaded",e)}),s("connected",e=>{const t=document.getElementById("status-indicator");t&&(t.className=e?"status connected":"status disconnected")}),i(e=>{if("agent_connect"===e.type||"agent_disconnect"===e.type)return g(e.agents),void emit("agent-event",e);state.monitorEvents.push(e),state.monitorEvents.length>500&&state.monitorEvents.shift(),emit("tool-event",e)}),c()}
21
23
  function g(e){let t=document.getElementById("agent-badge");if(!t){const e=document.querySelector(".app-topbar");if(!e)return;t=document.createElement("span"),t.id="agent-badge",t.className="agent-badge",e.appendChild(t)}t.textContent=e>0?`● ${e} agent${1!==e?"s":""}`:"",t.style.display=e>0?"":"none"}
22
24
  function f(){document.querySelector("pg-quick-open")||document.body.appendChild(document.createElement("pg-quick-open"))}
23
- function h(){const btn=document.getElementById("follow-btn");if(!btn)return;let active=false;btn.addEventListener("click",()=>{active=!active;if(active){btn.setAttribute("data-active","");btn.classList.add("active")}else{btn.removeAttribute("data-active");btn.classList.remove("active")}events.dispatchEvent(new CustomEvent("follow-mode-changed",{detail:{enabled:active}}))})}
24
- "loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{u(),f(),h()}):(u(),f(),h());
25
+ function h(){const btn=document.getElementById("follow-btn");if(!btn)return;let active=false;btn.addEventListener("click",()=>{active=!active;if(active){btn.setAttribute("data-active","");btn.classList.add("active");followController.enable();location.hash="follow"}else{btn.removeAttribute("data-active");btn.classList.remove("active");const prev=followController.getPreviousHash();followController.disable();if(prev&&prev!=="#follow")location.hash=prev.replace(/^#/,"")}events.dispatchEvent(new CustomEvent("follow-mode-changed",{detail:{enabled:active}}))});events.addEventListener("follow-state-changed",e=>{const en=e.detail?.enabled;if(en&&!active){active=true;btn.setAttribute("data-active","");btn.classList.add("active")}else if(!en&&active){active=false;btn.removeAttribute("data-active");btn.classList.remove("active")}});window.addEventListener("hashchange",()=>{const sec=(location.hash.replace("#","").split("?")[0].split("/")[0])||"explorer";if(sec==="follow"&&!active){active=true;btn.setAttribute("data-active","");btn.classList.add("active");followController.enable()}else if(sec!=="follow"&&active){active=false;btn.removeAttribute("data-active");btn.classList.remove("active");followController.disable()}})}
26
+ function _initRibbon(){if(!document.querySelector("follow-ribbon"))document.body.appendChild(document.createElement("follow-ribbon"))}
27
+ "loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{u(),f(),followController.init(events,emit),h(),_initRibbon()}):(u(),f(),followController.init(events,emit),h(),_initRibbon());
@@ -16,12 +16,18 @@ const HIT_RADIUS = 14;
16
16
  function getNodeRadius(node, conns, opts = {}) {
17
17
  const hubScale = 1 + Math.min(conns, 8) * 0.1;
18
18
  const aScale = opts.scale ?? (node.aScale || 1);
19
- let r = DOT_RADIUS * hubScale * aScale;
19
+ let baseR = DOT_RADIUS * hubScale * aScale;
20
20
  if (node.isGroup) {
21
- const childCount = node.children?.length || 1;
22
- r *= 1.0 + Math.sqrt(Math.max(1, Math.min(childCount, 25))) * 0.5;
21
+ const childCount = Math.max(2, Math.min(12, node.children?.length || 3));
22
+ const innerR = baseR * Math.max(0.1, 0.18 - (childCount - 3) * 0.008);
23
+ const spacing = innerR * 2.5;
24
+ const orbitR = spacing / (2 * Math.sin(Math.PI / childCount));
25
+ // r = orbitR + innerR + ringW + padding
26
+ // ringW = r * 0.12
27
+ // r = (orbitR + innerR + 2) / 0.88
28
+ return (orbitR + innerR + 2) / 0.88;
23
29
  }
24
- return r;
30
+ return baseR;
25
31
  }
26
32
 
27
33
  const NODE_TYPES = ['data', 'action', 'output', 'config', 'external', 'style', 'docs', 'asset'];
@@ -280,6 +286,17 @@ export class CanvasGraph extends Symbiote {
280
286
  this._wakeLoop();
281
287
  }
282
288
 
289
+ pulseNode(nodeId, durationMs = 1500) {
290
+ this._pulses = this._pulses || [];
291
+ this._pulses.push({
292
+ id: nodeId,
293
+ startTime: performance.now(),
294
+ duration: durationMs
295
+ });
296
+ this.needsDraw = true;
297
+ this._wakeLoop();
298
+ }
299
+
283
300
  flyToNode(nodeId, options = {}) {
284
301
  const node = this.graphDB?.nodes.get(nodeId);
285
302
  if (node && node.parentId) {
@@ -617,8 +634,8 @@ export class CanvasGraph extends Symbiote {
617
634
  nodeWidth: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 160,
618
635
  nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
619
636
  mode: 'continuous',
620
- activeGroupId: groupId,
621
- boundaryRadius: groupId ? this.graphDB.nodes.get(groupId).w / 2 : null,
637
+ activeGroupId: this.currentGroupId,
638
+ boundaryRadius: this.currentGroupId ? this.graphDB.nodes.get(this.currentGroupId).w / 2 : null,
622
639
  attractors: null,
623
640
  };
624
641
 
@@ -1063,15 +1080,13 @@ export class CanvasGraph extends Symbiote {
1063
1080
  currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity);
1064
1081
  currentCtx.fill();
1065
1082
 
1083
+ let baseR = DOT_RADIUS * hubScale * (node.aScale || 1);
1066
1084
  const childCount = Math.max(2, Math.min(12, node.children?.length || 3));
1067
- const innerR = r * Math.max(0.1, 0.18 - (childCount - 3) * 0.008);
1085
+ const innerR = baseR * Math.max(0.1, 0.18 - (childCount - 3) * 0.008);
1068
1086
 
1069
1087
  // Calculate perfect orbit radius to maintain consistent spacing between dots
1070
1088
  const spacing = innerR * 2.5; // Gap between dots
1071
- const idealOrbitR = spacing / (2 * Math.sin(Math.PI / childCount));
1072
- // Ensure they never spill out of the golden ring
1073
- const maxOrbitR = Math.max(0, r - ringW - innerR - 2);
1074
- const orbitR = Math.min(idealOrbitR, maxOrbitR);
1089
+ const orbitR = spacing / (2 * Math.sin(Math.PI / childCount));
1075
1090
  const isHovered = this.hoverNode && this.hoverNode.id === node.id;
1076
1091
  node.aRotSpeed = node.aRotSpeed || 0;
1077
1092
  const targetRotSpeed = (isActive || isHovered) ? 0.025 : 0;
@@ -1130,6 +1145,29 @@ export class CanvasGraph extends Symbiote {
1130
1145
  mainCtx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
1131
1146
  }
1132
1147
  drawDepth(0, mainCtx);
1148
+
1149
+ if (this._pulses && this._pulses.length > 0) {
1150
+ const now = performance.now();
1151
+ this._pulses = this._pulses.filter(p => {
1152
+ const elapsed = now - p.startTime;
1153
+ if (elapsed > p.duration) return false;
1154
+ const pos = this.getSmooth(p.id) || this.nodePositions.get(p.id);
1155
+ if (!pos) return false;
1156
+ const progress = elapsed / p.duration;
1157
+ const pulsePhase = (progress * 3) % 1;
1158
+ const r = 20 + (pulsePhase * 80);
1159
+ const opacity = 1 - pulsePhase;
1160
+ mainCtx.beginPath();
1161
+ mainCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1162
+ mainCtx.fillStyle = `rgba(76, 139, 245, ${opacity * 0.4})`;
1163
+ mainCtx.fill();
1164
+ mainCtx.lineWidth = 2;
1165
+ mainCtx.strokeStyle = `rgba(76, 139, 245, ${opacity * 0.8})`;
1166
+ mainCtx.stroke();
1167
+ this.needsDraw = true;
1168
+ return true;
1169
+ });
1170
+ }
1133
1171
  }
1134
1172
 
1135
1173
  const showMenu = this.activeNode && !this.dragNode && !this.deactivating;
@@ -1557,6 +1595,7 @@ export class CanvasGraph extends Symbiote {
1557
1595
  });
1558
1596
 
1559
1597
  this.canvas.addEventListener('pointerup', (e) => {
1598
+ const draggedNode = this.dragNode;
1560
1599
  if (this.dragNode) {
1561
1600
  this.worker.postMessage({ type: 'unpin', id: this.dragNode.id });
1562
1601
  this.dragNode = null;
@@ -29,6 +29,6 @@ this.$.highlighted=hl;
29
29
  const e=o.split("\n").length,t=[];for(let o=1;o<=e;o++)t.push(o);this.$.lineNums=t.join("\n");
30
30
  }
31
31
  }),this.sub("isMarkdown",v=>{this.toggleAttribute("mode-markdown",v)}),this.sub("isImage",v=>{this.toggleAttribute("mode-image",v)})
32
- }setBasePath(p){this._basePath=p}}
32
+ }setBasePath(p){this._basePath=p}scrollToLine(line){const pre=this.querySelector('.cb-pre');const scroll=this.querySelector('.cb-scroll');if(!pre||!scroll)return;const lineHeight=parseFloat(window.getComputedStyle(pre).lineHeight)||19.2;scroll.scrollTo({top:Math.max(0,(line-1)*lineHeight-scroll.clientHeight/2+lineHeight/2),behavior:'smooth'})}}
33
33
  CodeBlock.template='\n <div class="cb-scroll">\n <pre class="cb-gutter" bind="textContent: lineNums"></pre>\n <pre class="cb-pre"><code bind="innerHTML: highlighted"></code></pre>\n <div class="cb-md" bind="innerHTML: highlighted"></div>\n <div class="cb-img-wrap"><img class="cb-img" bind="src: imageSrc"></div>\n </div>\n';
34
34
  CodeBlock.rootStyles="\n code-block {\n display: block;\n height: 100%;\n overflow: hidden;\n }\n code-block .cb-scroll {\n display: flex;\n height: 100%;\n overflow: auto;\n align-items: stretch;\n }\n code-block .cb-gutter {\n position: sticky;\n left: 0;\n z-index: 1;\n margin: 0;\n padding: 12px 8px 12px 12px;\n font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;\n font-size: 12px;\n line-height: 1.6;\n text-align: right;\n color: var(--sn-text-dim, hsl(30, 10%, 55%));\n opacity: 0.45;\n background: var(--sn-bg, hsl(37, 30%, 96%));\n border-right: 1px solid var(--sn-node-border, hsl(35, 18%, 88%));\n user-select: none;\n white-space: pre;\n min-width: 32px;\n flex-shrink: 0;\n }\n code-block .cb-pre {\n margin: 0;\n padding: 12px;\n flex: 1;\n min-width: 0;\n font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;\n font-size: 12px;\n line-height: 1.6;\n color: var(--sn-text, hsl(30, 15%, 18%));\n tab-size: 2;\n white-space: pre;\n box-sizing: border-box;\n }\n /* Markdown container — hidden by default */\n code-block .cb-md {\n display: none;\n padding: 20px 28px;\n flex: 1;\n min-width: 0;\n overflow-wrap: break-word;\n word-wrap: break-word;\n line-height: 1.7;\n color: var(--sn-text, hsl(30, 15%, 18%));\n font-family: var(--sn-font, 'Inter', -apple-system, sans-serif);\n font-size: 14px;\n }\n /* Image container — hidden by default */\n code-block .cb-img-wrap {\n display: none;\n flex: 1;\n padding: 20px;\n justify-content: center;\n align-items: center;\n background: repeating-conic-gradient(hsl(30, 10%, 88%) 0% 25%, hsl(30, 10%, 94%) 0% 50%) 0 0 / 16px 16px;\n }\n code-block .cb-img {\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n border-radius: 4px;\n box-shadow: 0 2px 12px rgba(0,0,0,0.12);\n }\n /* In markdown mode: hide code, show md */\n code-block[mode-markdown] .cb-gutter { display: none; }\n code-block[mode-markdown] .cb-pre { display: none; }\n code-block[mode-markdown] .cb-md { display: block; }\n /* In image mode: hide code, show image */\n code-block[mode-image] .cb-gutter { display: none; }\n code-block[mode-image] .cb-pre { display: none; }\n code-block[mode-image] .cb-img-wrap { display: flex; }\n\n /* Markdown styles */\n code-block .md-h { margin: 20px 0 8px; color: var(--sn-text, #222); font-weight: 700; }\n code-block h1.md-h { font-size: 24px; border-bottom: 2px solid var(--sn-node-border, #ddd); padding-bottom: 8px; }\n code-block h2.md-h { font-size: 20px; border-bottom: 1px solid var(--sn-node-border, #ddd); padding-bottom: 6px; }\n code-block h3.md-h { font-size: 16px; }\n code-block h4.md-h { font-size: 14px; }\n code-block .md-p { margin: 8px 0; }\n code-block .md-quote {\n margin: 8px 0;\n padding: 8px 16px;\n border-left: 4px solid var(--sn-cat-server, hsl(210, 45%, 55%));\n background: hsla(210, 40%, 55%, 0.08);\n border-radius: 0 4px 4px 0;\n font-style: italic;\n }\n code-block .md-list {\n margin: 8px 0;\n padding-left: 24px;\n }\n code-block .md-list li {\n margin: 3px 0;\n }\n code-block .md-code-block {\n margin: 12px 0;\n padding: 12px 16px;\n background: var(--sn-bg, hsl(37, 30%, 94%));\n border: 1px solid var(--sn-node-border, hsl(35, 18%, 85%));\n border-radius: 6px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n font-size: 12px;\n line-height: 1.6;\n overflow-x: auto;\n white-space: pre;\n }\n code-block .md-inline-code {\n padding: 1px 5px;\n background: hsla(30, 20%, 50%, 0.12);\n border-radius: 3px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n font-size: 0.9em;\n }\n code-block .md-link {\n color: var(--sn-cat-server, hsl(210, 55%, 50%));\n text-decoration: underline;\n text-decoration-style: dotted;\n }\n code-block .md-link:hover { text-decoration-style: solid; }\n code-block .md-img {\n max-width: 100%;\n height: auto;\n border-radius: 6px;\n margin: 8px 0;\n border: 1px solid var(--sn-node-border, hsl(35, 18%, 85%));\n box-shadow: 0 2px 8px rgba(0,0,0,0.08);\n }\n code-block .md-hr {\n border: none;\n border-top: 1px solid var(--sn-node-border, hsl(35, 18%, 85%));\n margin: 16px 0;\n }\n code-block .md-table {\n width: 100%;\n border-collapse: collapse;\n margin: 12px 0;\n font-size: 13px;\n }\n code-block .md-table th,\n code-block .md-table td {\n padding: 6px 12px;\n border: 1px solid var(--sn-node-border, hsl(35, 18%, 85%));\n text-align: left;\n }\n code-block .md-table th {\n background: hsla(30, 15%, 50%, 0.08);\n font-weight: 600;\n }\n code-block .md-table tr:hover td {\n background: hsla(30, 15%, 50%, 0.04);\n }\n /* Token colors */\n code-block .t-kw { color: rgb(254, 165, 176); }\n code-block .t-str { color: rgb(251, 182, 79); }\n code-block .t-cm { color: rgb(149, 149, 149); font-style: italic; }\n code-block .t-fn { color: rgb(180, 243, 255); }\n code-block .t-num { color: rgb(251, 182, 79); }\n code-block .t-bi { color: rgb(180, 243, 255); }\n code-block .t-prop { color: rgb(238, 131, 252); }\n code-block .t-lit { color: rgb(254, 165, 176); }\n /* JSDoc */\n code-block .t-jd { color: rgb(130, 155, 130); font-style: italic; }\n code-block .t-jd-tag { color: rgb(180, 220, 140); font-style: normal; font-weight: 500; }\n code-block .t-jd-type { color: rgb(130, 210, 240); font-style: normal; }\n",CodeBlock.reg("code-block");
@@ -20,36 +20,126 @@ export class MiniGraphWidget extends Symbiote {
20
20
  }
21
21
 
22
22
  _renderSVG(data) {
23
- // Basic SVG renderer for mini graphs to avoid WebGL context explosion
24
- // Extracts nodes and links if present in the data
25
23
  const nodes = data.nodes || (data.n ? Object.keys(data.n).map(id => ({ id, ...data.n[id] })) : []);
26
- const links = data.links || [];
24
+ const rawLinks = data.links || data.e || [];
25
+
26
+ // Some formats use {from, to}, some use {source, target}
27
+ const links = rawLinks.map(l => ({
28
+ from: l.from || l.source,
29
+ to: l.to || l.target
30
+ }));
27
31
 
28
32
  if (!nodes.length) {
29
33
  this.$.svgContent = '<text x="10" y="20" fill="var(--sn-text-dim)">No graph data</text>';
30
34
  return;
31
35
  }
32
36
 
33
- // Simple circle layout for demonstration
34
37
  const width = 300;
35
38
  const height = 150;
36
- const cx = width / 2;
37
- const cy = height / 2;
38
- const radius = 50;
39
+
40
+ // Initial deterministic positions (circle)
41
+ nodes.forEach((n, i) => {
42
+ const angle = (i / nodes.length) * Math.PI * 2;
43
+ n.x = width / 2 + Math.cos(angle) * (Math.min(width, height) / 4);
44
+ n.y = height / 2 + Math.sin(angle) * (Math.min(width, height) / 4);
45
+ n.vx = 0; n.vy = 0;
46
+ });
47
+
48
+ const K = 0.05; // Spring
49
+ const L = 40; // Ideal length
50
+ const REP = 300; // Repulsion
51
+ const DAMP = 0.8;
39
52
 
53
+ // Run 100 iterations
54
+ for (let i = 0; i < 100; i++) {
55
+ for (let j = 0; j < nodes.length; j++) {
56
+ for (let k = j + 1; k < nodes.length; k++) {
57
+ const a = nodes[j], b = nodes[k];
58
+ let dx = a.x - b.x, dy = a.y - b.y;
59
+ let dist = Math.sqrt(dx*dx + dy*dy) || 0.1;
60
+ let f = REP / (dist * dist);
61
+ const fx = (dx / dist) * f;
62
+ const fy = (dy / dist) * f;
63
+ a.vx += fx; a.vy += fy;
64
+ b.vx -= fx; b.vy -= fy;
65
+ }
66
+ }
67
+ links.forEach(link => {
68
+ const a = nodes.find(n => n.id === link.from);
69
+ const b = nodes.find(n => n.id === link.to);
70
+ if (!a || !b) return;
71
+ let dx = b.x - a.x, dy = b.y - a.y;
72
+ let dist = Math.sqrt(dx*dx + dy*dy) || 0.1;
73
+ let f = (dist - L) * K;
74
+ const fx = (dx / dist) * f;
75
+ const fy = (dy / dist) * f;
76
+ a.vx += fx; a.vy += fy;
77
+ b.vx -= fx; b.vy -= fy;
78
+ });
79
+ nodes.forEach(n => {
80
+ n.vx += (width/2 - n.x) * 0.015;
81
+ n.vy += (height/2 - n.y) * 0.015;
82
+ n.vx *= DAMP; n.vy *= DAMP;
83
+ n.x += n.vx; n.y += n.vy;
84
+ });
85
+ }
86
+
87
+ // Now build SVG
40
88
  let svg = '';
41
89
 
42
- // Draw links
43
- // (Needs actual layout logic, this is a placeholder circle layout)
90
+ // Bounds and scaling to fit within 300x150
91
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
92
+ nodes.forEach(n => {
93
+ minX = Math.min(minX, n.x);
94
+ minY = Math.min(minY, n.y);
95
+ maxX = Math.max(maxX, n.x);
96
+ maxY = Math.max(maxY, n.y);
97
+ });
44
98
 
45
- // Draw nodes
46
- nodes.forEach((n, i) => {
47
- const angle = (i / nodes.length) * Math.PI * 2;
48
- const x = cx + Math.cos(angle) * radius;
49
- const y = cy + Math.sin(angle) * radius;
50
- svg += `<circle cx="${x}" cy="${y}" r="4" fill="var(--sn-node-selected, #4c8bf5)"></circle>`;
99
+ const pad = 20;
100
+ const gW = Math.max(1, maxX - minX);
101
+ const gH = Math.max(1, maxY - minY);
102
+ const scale = Math.min((width - pad*2) / gW, (height - pad*2) / gH, 1.2);
103
+ const cx = (minX + maxX) / 2;
104
+ const cy = (minY + maxY) / 2;
105
+ const offX = width / 2 - cx * scale;
106
+ const offY = height / 2 - cy * scale;
107
+
108
+ // Draw Links
109
+ svg += '<g stroke="var(--sn-edge, #666)" stroke-width="1.5" opacity="0.5">';
110
+ links.forEach(link => {
111
+ const a = nodes.find(n => n.id === link.from);
112
+ const b = nodes.find(n => n.id === link.to);
113
+ if (!a || !b) return;
114
+ const x1 = a.x * scale + offX;
115
+ const y1 = a.y * scale + offY;
116
+ const x2 = b.x * scale + offX;
117
+ const y2 = b.y * scale + offY;
118
+
119
+ // Curved paths
120
+ const dx = x2 - x1, dy = y2 - y1;
121
+ const cx1 = x1 + dx * 0.3 - dy * 0.1;
122
+ const cy1 = y1 + dy * 0.3 + dx * 0.1;
123
+ const cx2 = x1 + dx * 0.7 - dy * 0.1;
124
+ const cy2 = y1 + dy * 0.7 + dx * 0.1;
125
+
126
+ svg += `<path d="M${x1},${y1} C${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}" fill="none" />`;
127
+ });
128
+ svg += '</g>';
129
+
130
+ // Draw Nodes
131
+ svg += '<g>';
132
+ nodes.forEach(n => {
133
+ const x = n.x * scale + offX;
134
+ const y = n.y * scale + offY;
135
+ const tc = n.type === 'action' ? '#ff968c' :
136
+ n.type === 'output' ? '#78d2aa' :
137
+ n.type === 'config' ? '#ffc878' : '#78b4ff';
138
+
139
+ svg += `<circle cx="${x}" cy="${y}" r="4" fill="${tc}"></circle>`;
51
140
  svg += `<text x="${x + 6}" y="${y + 3}" fill="var(--sn-text)" font-size="10">${this._esc(n.id || n.name || 'node')}</text>`;
52
141
  });
142
+ svg += '</g>';
53
143
 
54
144
  this.$.svgContent = svg;
55
145
  }
@@ -0,0 +1,134 @@
1
+ // @ctx .context/web/components/follow-ribbon.ctx
2
+ /**
3
+ * FollowRibbon — Floating status bar that shows current agent action.
4
+ * Appears at the bottom of the screen during Follow Mode.
5
+ * Auto-fades after 4 seconds of inactivity.
6
+ */
7
+ import Symbiote from '@symbiotejs/symbiote';
8
+ import { events } from '../app.js';
9
+
10
+ export class FollowRibbon extends Symbiote {
11
+ init$ = {
12
+ statusText: '',
13
+ visible: false,
14
+ };
15
+
16
+ _fadeTimer = null;
17
+
18
+ initCallback() {
19
+ // Event subscriptions are in renderCallback (after template mount)
20
+ }
21
+
22
+ renderCallback() {
23
+ this.sub('visible', (v) => {
24
+ this.toggleAttribute('visible', v);
25
+ });
26
+
27
+ events.addEventListener('follow-status-changed', (e) => {
28
+ const text = e.detail?.text || '';
29
+ if (!text) {
30
+ this.$.visible = false;
31
+ return;
32
+ }
33
+ this.$.statusText = text;
34
+ this.$.visible = true;
35
+
36
+ // Auto-fade after 4 seconds
37
+ if (this._fadeTimer) clearTimeout(this._fadeTimer);
38
+ this._fadeTimer = setTimeout(() => {
39
+ this.$.visible = false;
40
+ }, 4000);
41
+ });
42
+
43
+ events.addEventListener('follow-state-changed', (e) => {
44
+ if (!e.detail?.enabled) {
45
+ this.$.visible = false;
46
+ this.$.statusText = '';
47
+ if (this._fadeTimer) {
48
+ clearTimeout(this._fadeTimer);
49
+ this._fadeTimer = null;
50
+ }
51
+ }
52
+ });
53
+ }
54
+ }
55
+
56
+ FollowRibbon.template = `
57
+ <div class="fr-inner">
58
+ <span class="fr-icon">smart_toy</span>
59
+ <span class="fr-text" bind="textContent: statusText"></span>
60
+ <span class="fr-dots"></span>
61
+ </div>
62
+ `;
63
+
64
+ FollowRibbon.rootStyles = `
65
+ follow-ribbon {
66
+ position: fixed;
67
+ bottom: 20px;
68
+ left: 50%;
69
+ transform: translateX(-50%) translateY(20px);
70
+ z-index: 9999;
71
+ pointer-events: none;
72
+ opacity: 0;
73
+ transition: opacity 0.4s ease, transform 0.4s ease;
74
+ }
75
+
76
+ follow-ribbon[visible] {
77
+ opacity: 1;
78
+ transform: translateX(-50%) translateY(0);
79
+ }
80
+
81
+ .fr-inner {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 10px;
85
+ padding: 8px 20px;
86
+ border-radius: 24px;
87
+ background: rgba(20, 20, 25, 0.85);
88
+ backdrop-filter: blur(16px);
89
+ -webkit-backdrop-filter: blur(16px);
90
+ border: 1px solid rgba(76, 139, 245, 0.25);
91
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px rgba(76, 139, 245, 0.1);
92
+ font-family: 'Inter', -apple-system, sans-serif;
93
+ font-size: 12px;
94
+ font-weight: 500;
95
+ color: rgba(255, 255, 255, 0.9);
96
+ white-space: nowrap;
97
+ max-width: 500px;
98
+ }
99
+
100
+ .fr-icon {
101
+ font-family: 'Material Symbols Outlined';
102
+ font-size: 16px;
103
+ color: #4c8bf5;
104
+ animation: fr-pulse 2s ease-in-out infinite;
105
+ }
106
+
107
+ .fr-text {
108
+ overflow: hidden;
109
+ text-overflow: ellipsis;
110
+ }
111
+
112
+ .fr-dots::after {
113
+ content: '...';
114
+ animation: fr-dots 1.5s steps(3) infinite;
115
+ display: inline-block;
116
+ width: 16px;
117
+ text-align: left;
118
+ color: rgba(255, 255, 255, 0.4);
119
+ }
120
+
121
+ @keyframes fr-pulse {
122
+ 0%, 100% { opacity: 1; }
123
+ 50% { opacity: 0.5; }
124
+ }
125
+
126
+ @keyframes fr-dots {
127
+ 0% { content: ''; }
128
+ 33% { content: '.'; }
129
+ 66% { content: '..'; }
130
+ 100% { content: '...'; }
131
+ }
132
+ `;
133
+
134
+ FollowRibbon.reg('follow-ribbon');
@@ -0,0 +1,241 @@
1
+ // @ctx .context/web/follow-controller.ctx
2
+ /**
3
+ * FollowController — Central orchestrator for Follow Mode.
4
+ *
5
+ * Classifies incoming tool-events and dispatches debounced focus-change
6
+ * signals to subscribed panels (graph, code-viewer, monitor).
7
+ * Also manages the status ribbon text shown during active follow.
8
+ *
9
+ * NOTE: Does NOT import from app.js to avoid circular dependency.
10
+ * Call init(events, emit) before enable().
11
+ */
12
+
13
+ /** Debounce delay for heavy visual updates (camera, code loading) */
14
+ const HEAVY_DEBOUNCE = 800;
15
+
16
+ class FollowController {
17
+ /** @type {boolean} */
18
+ enabled = false;
19
+ /** @type {{type: string, target: any, action?: string, meta?: object}|null} */
20
+ currentFocus = null;
21
+ /** @type {string} */
22
+ statusText = '';
23
+ /** @type {number|null} */
24
+ _debounceTimer = null;
25
+ /** @type {string|null} Previous hash before entering follow mode */
26
+ _previousHash = null;
27
+ /** @type {Function|null} */
28
+ _boundHandler = null;
29
+ /** @type {EventTarget|null} */
30
+ _events = null;
31
+ /** @type {Function|null} */
32
+ _emit = null;
33
+
34
+ /**
35
+ * Late-bind events bus and emit function (breaks circular import).
36
+ * Must be called once before enable().
37
+ * @param {EventTarget} events
38
+ * @param {Function} emit
39
+ */
40
+ init(events, emit) {
41
+ this._events = events;
42
+ this._emit = emit;
43
+ }
44
+
45
+ enable() {
46
+ if (this.enabled) return;
47
+ this.enabled = true;
48
+
49
+ // Save current location for restoring later
50
+ this._previousHash = location.hash;
51
+
52
+ // Bind tool-event listener
53
+ this._boundHandler = (e) => this._onToolEvent(e.detail);
54
+ this._events.addEventListener('tool-event', this._boundHandler);
55
+
56
+ this._emit('follow-state-changed', { enabled: true });
57
+ }
58
+
59
+ disable() {
60
+ if (!this.enabled) return;
61
+ this.enabled = false;
62
+
63
+ // Clean up
64
+ if (this._boundHandler) {
65
+ this._events.removeEventListener('tool-event', this._boundHandler);
66
+ this._boundHandler = null;
67
+ }
68
+ if (this._debounceTimer) {
69
+ clearTimeout(this._debounceTimer);
70
+ this._debounceTimer = null;
71
+ }
72
+
73
+ this.currentFocus = null;
74
+ this._emitStatus('');
75
+
76
+ this._emit('follow-state-changed', { enabled: false });
77
+ }
78
+
79
+ /** @returns {string|null} */
80
+ getPreviousHash() {
81
+ return this._previousHash;
82
+ }
83
+
84
+ /**
85
+ * Main tool-event dispatcher. Classifies the event and routes to appropriate action.
86
+ * @param {object} event - Tool event from WebSocket
87
+ */
88
+ _onToolEvent(event) {
89
+ if (!this.enabled) return;
90
+
91
+ const toolName = event.tool || event.name || '';
92
+ const args = event.args || {};
93
+ const isCall = event.type === 'tool_call';
94
+ const isResult = event.type === 'tool_result';
95
+
96
+ // Extract short tool name (strip prefixes like 'default_api:', 'mcp_project-graph_')
97
+ const shortName = this._shortName(toolName);
98
+
99
+ // Status ribbon — update immediately on call
100
+ if (isCall) {
101
+ const statusText = this._buildStatusText(shortName, args);
102
+ if (statusText) this._emitStatus(statusText);
103
+ }
104
+
105
+ // Visual focus — classify and dispatch (debounced for heavy ops)
106
+ const action = this._classify(shortName, args, isCall, isResult, event);
107
+ if (action) {
108
+ if (action.immediate) {
109
+ this._emitFocusNow(action.focus);
110
+ } else {
111
+ this._emitFocusDebounced(action.focus, action.debounce || HEAVY_DEBOUNCE);
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Classify tool event into a visual action.
118
+ * Only handles tools emitted by our MCP server (navigate, get_skeleton, etc.).
119
+ * IDE-local tools (view_file, grep_search) never arrive over WebSocket.
120
+ * @returns {{focus: object, debounce?: number, immediate?: boolean}|null}
121
+ */
122
+ _classify(tool, args, isCall, isResult, raw) {
123
+ if (!isCall) return null;
124
+
125
+ // === Graph navigation ===
126
+ if (tool === 'navigate') {
127
+ if (args.action === 'expand' && args.symbol) {
128
+ return { focus: { type: 'graph', target: args.symbol, action: 'focus' }, debounce: HEAVY_DEBOUNCE };
129
+ }
130
+ if (args.action === 'deps' && args.symbol) {
131
+ return { focus: { type: 'graph', target: args.symbol, action: 'deps' }, debounce: HEAVY_DEBOUNCE };
132
+ }
133
+ if (args.action === 'usages' && args.symbol) {
134
+ return { focus: { type: 'graph', target: args.symbol, action: 'deps' }, debounce: HEAVY_DEBOUNCE };
135
+ }
136
+ if (args.action === 'call_chain' && args.from && args.to) {
137
+ return { focus: { type: 'graph', target: { from: args.from, to: args.to }, action: 'chain' }, immediate: true };
138
+ }
139
+ }
140
+
141
+ // === Skeleton / Overview ===
142
+ if (tool === 'get_skeleton' || tool === 'get_ai_context') {
143
+ return { focus: { type: 'graph', action: 'fit' }, immediate: true };
144
+ }
145
+
146
+ // === Code compaction (compact_file action has a file path) ===
147
+ if (tool === 'compact' && args.path) {
148
+ return { focus: { type: 'file', target: args.path }, debounce: HEAVY_DEBOUNCE };
149
+ }
150
+
151
+ // === Analysis ===
152
+ if (tool === 'analyze') {
153
+ return { focus: { type: 'analysis' }, immediate: true };
154
+ }
155
+
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Build human-readable status text for the ribbon.
161
+ * Only MCP-server tools arrive here (navigate, get_skeleton, compact, analyze, docs, etc.).
162
+ * @param {string} tool
163
+ * @param {object} args
164
+ * @returns {string}
165
+ */
166
+ _buildStatusText(tool, args) {
167
+ const file = args.path || '';
168
+ const short = file ? file.split('/').slice(-2).join('/') : '';
169
+
170
+ switch (tool) {
171
+ case 'navigate': {
172
+ if (args.action === 'expand') return `Expanding ${args.symbol}`;
173
+ if (args.action === 'deps') return `Tracing deps of ${args.symbol}`;
174
+ if (args.action === 'usages') return `Finding usages of ${args.symbol}`;
175
+ if (args.action === 'call_chain') return `Tracing ${args.from} → ${args.to}`;
176
+ if (args.action === 'sub_projects') return `Scanning sub-projects`;
177
+ return `Navigating graph`;
178
+ }
179
+ case 'get_skeleton': return `Scanning project structure`;
180
+ case 'get_ai_context': return `Loading AI context`;
181
+ case 'compact': return `Compacting ${short}`;
182
+ case 'analyze': return `Analyzing: ${args.action || ''}`;
183
+ case 'docs': return `Documentation: ${args.action || ''}`;
184
+ case 'jsdoc': return `JSDoc: ${args.action || ''}`;
185
+ case 'db': return `Database: ${args.action || ''}`;
186
+ case 'testing': return `Tests: ${args.action || ''}`;
187
+ case 'filters': return `Filters: ${args.action || ''}`;
188
+ default: return tool ? `Running ${tool}` : '';
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Extract short tool name from full prefixed name.
194
+ * 'default_api:view_file' → 'view_file'
195
+ * 'mcp_project-graph_navigate' → 'navigate'
196
+ */
197
+ _shortName(full) {
198
+ // Strip 'default_api:' prefix
199
+ let name = full.replace(/^default_api:/, '');
200
+ // Strip 'mcp_project-graph_' prefix
201
+ name = name.replace(/^mcp_project-graph_/, '');
202
+ return name;
203
+ }
204
+
205
+ /**
206
+ * Emit focus change immediately (for urgent actions like call_chain).
207
+ */
208
+ _emitFocusNow(focus) {
209
+ if (this._debounceTimer) {
210
+ clearTimeout(this._debounceTimer);
211
+ this._debounceTimer = null;
212
+ }
213
+ this.currentFocus = focus;
214
+ this._emit('follow-focus-changed', focus);
215
+ }
216
+
217
+ /**
218
+ * Emit focus change with debounce (for rapid file reads, etc.).
219
+ */
220
+ _emitFocusDebounced(focus, delay) {
221
+ if (this._debounceTimer) {
222
+ clearTimeout(this._debounceTimer);
223
+ }
224
+ this._debounceTimer = setTimeout(() => {
225
+ this._debounceTimer = null;
226
+ this.currentFocus = focus;
227
+ this._emit('follow-focus-changed', focus);
228
+ }, delay);
229
+ }
230
+
231
+ /**
232
+ * Emit status text for the ribbon.
233
+ */
234
+ _emitStatus(text) {
235
+ this.statusText = text;
236
+ this._emit('follow-status-changed', { text });
237
+ }
238
+ }
239
+
240
+ /** Singleton instance */
241
+ export const followController = new FollowController();
@@ -29,7 +29,7 @@ export class CodeViewer extends e{init$={filename:"Select a file",hasFile:!1,vie
29
29
  // Toggle between source and the transformation
30
30
  this.$.viewMode=this.$.viewMode==="source"?"transformed":"source";
31
31
  this._showCurrentMode();
32
- }};_fileData=null;_isReadable=!1;_transformCache=null;_loadingTransform=!1;_currentPath=null;initCallback(){t.addEventListener("file-selected",e=>this._loadFile(e.detail.path));if(o.activeFile)requestAnimationFrame(()=>this._loadFile(o.activeFile))}renderCallback(){this.sub("hasFile",e=>{this.toggleAttribute("has-file",e)}),this.sub("viewMode",e=>{
32
+ }};_fileData=null;_isReadable=!1;_transformCache=null;_loadingTransform=!1;_currentPath=null;initCallback(){t.addEventListener("file-selected",e=>this._loadFile(e.detail.path));t.addEventListener("follow-focus-changed",e=>{const d=e.detail;if(d.type==="file"&&d.target){this._loadFile(d.target);if(d.meta?.startLine){setTimeout(()=>{const c=this._getCodeBlock();if(c&&c.scrollToLine)c.scrollToLine(d.meta.startLine)},200)}}});if(o.activeFile)requestAnimationFrame(()=>this._loadFile(o.activeFile))}renderCallback(){this.sub("hasFile",e=>{this.toggleAttribute("has-file",e)}),this.sub("viewMode",e=>{
33
33
  const lang=_getLang(this._currentPath);
34
34
  this.toggleAttribute("mode-raw","source"!==e);
35
35
  if(lang==='md'){
@@ -687,8 +687,6 @@ export class DepGraph extends Symbiote {
687
687
  _wasDragged = false;
688
688
  /** @type {Map<string, string>} */
689
689
  _fileMap = new Map();
690
- /** @type {boolean} */
691
- _autopilot = false;
692
690
  /** @type {HTMLElement|null} */
693
691
  _canvas = null;
694
692
  /** @type {object|null} Skeleton data for resolving pin names */
@@ -851,11 +849,6 @@ export class DepGraph extends Symbiote {
851
849
  this._restoreFlatFocus();
852
850
  }
853
851
  });
854
-
855
- // Follow mode: listen for global state (set from topbar)
856
- events.addEventListener('follow-mode-changed', (e) => {
857
- this._autopilot = e.detail.enabled;
858
- });
859
852
 
860
853
  // Label Mode controls
861
854
  const labelBtns = this.querySelectorAll('.label-mode-btn');
@@ -983,17 +976,14 @@ export class DepGraph extends Symbiote {
983
976
  ro.observe(this);
984
977
  this._resizeObserver = ro;
985
978
 
986
- // Bind and save global listener functions for clean up
987
979
  this._onSkeletonLoaded = (e) => {
988
980
  if (this._graphBuilt || this.style.display === 'none' || this.offsetWidth === 0) return;
989
981
  requestAnimationFrame(() => this._buildGraph(e.detail));
990
982
  };
991
983
 
992
- this._onToolEvent = (e) => {
984
+ this._onFollowFocusChanged = (e) => {
993
985
  if (this.style.display === 'none' || this.offsetWidth === 0) return;
994
- if (this._autopilot) {
995
- this._handleAutopilot(e.detail);
996
- }
986
+ this._handleFollowFocus(e.detail);
997
987
  };
998
988
 
999
989
  this._onFileSelected = (e) => {
@@ -1026,8 +1016,8 @@ export class DepGraph extends Symbiote {
1026
1016
  }).catch(() => {});
1027
1017
  }
1028
1018
 
1029
- // Autopilot: listen for agent tool events
1030
- events.addEventListener('tool-event', this._onToolEvent);
1019
+ // Autopilot: listen for orchestrator events
1020
+ events.addEventListener('follow-focus-changed', this._onFollowFocusChanged);
1031
1021
 
1032
1022
  // Update route within graph section
1033
1023
  // On node click → save file path (just focusing)
@@ -1165,7 +1155,7 @@ export class DepGraph extends Symbiote {
1165
1155
  disconnectedCallback() {
1166
1156
  super.disconnectedCallback?.();
1167
1157
  if (this._onSkeletonLoaded) events.removeEventListener('skeleton-loaded', this._onSkeletonLoaded);
1168
- if (this._onToolEvent) events.removeEventListener('tool-event', this._onToolEvent);
1158
+ if (this._onFollowFocusChanged) events.removeEventListener('follow-focus-changed', this._onFollowFocusChanged);
1169
1159
  if (this._onFileSelected) events.removeEventListener('file-selected', this._onFileSelected);
1170
1160
  if (this._onHashChange) window.removeEventListener('hashchange', this._onHashChange);
1171
1161
  if (this._resizeObserver) {
@@ -2498,37 +2488,26 @@ export class DepGraph extends Symbiote {
2498
2488
  }
2499
2489
 
2500
2490
  /**
2501
- * Handle agent tool events for autopilot mode
2502
- * @param {object} event
2491
+ * Handle orchestrated visual focus from FollowController
2492
+ * @param {object} detail
2503
2493
  */
2504
- _handleAutopilot(event) {
2494
+ _handleFollowFocus({ type, target, action }) {
2505
2495
  if (!this._editor || !this._canvas) return;
2506
-
2507
- const toolName = event.tool || event.name || '';
2508
- const args = event.args || {};
2509
-
2510
- // tool:call events
2511
- if (event.phase === 'call' || event.type === 'tool:call') {
2512
- if (toolName === 'navigate' && args.action === 'expand' && args.symbol) {
2513
- this._focusSymbol(args.symbol);
2514
- } else if (toolName === 'navigate' && args.action === 'deps' && args.symbol) {
2515
- this._highlightDeps(args.symbol);
2516
- } else if (toolName === 'navigate' && args.action === 'call_chain') {
2517
- // Phase 4: animate call chain when agent traces a path
2518
- if (args.from && args.to) {
2519
- this._highlightCallChain(args.from, args.to);
2520
- }
2521
- } else if (toolName === 'navigate' && args.action === 'usages' && args.symbol) {
2522
- this._highlightDeps(args.symbol);
2523
- } else if (toolName === 'get_skeleton') {
2496
+ if (type !== 'graph' && type !== 'file') return; // React to graph and file actions
2497
+
2498
+ if (type === 'graph') {
2499
+ if (action === 'focus' && target) {
2500
+ this._focusSymbol(target);
2501
+ } else if (action === 'deps' && target) {
2502
+ this._highlightDeps(target);
2503
+ } else if (action === 'chain' && target.from && target.to) {
2504
+ this._highlightCallChain(target.from, target.to);
2505
+ } else if (action === 'fit') {
2524
2506
  this._canvas.fitView();
2525
- } else if (toolName === 'compact' && args.path) {
2526
- this._pulseFile(args.path);
2527
- } else if (toolName === 'view_file' && args.path) {
2528
- // Agent opened a file — focus it on the board
2529
- this._focusFile(args.path);
2530
- this._pulseFile(args.path);
2531
2507
  }
2508
+ } else if (type === 'file' && target) {
2509
+ this._focusFile(target);
2510
+ this._pulseFile(target);
2532
2511
  }
2533
2512
  }
2534
2513
 
package/web/style.css CHANGED
@@ -123,6 +123,12 @@ html, body {
123
123
  background: rgba(76, 139, 245, 0.15);
124
124
  color: #4c8bf5;
125
125
  border-color: rgba(76, 139, 245, 0.3);
126
+ animation: follow-btn-glow 2s ease-in-out infinite;
127
+ }
128
+
129
+ @keyframes follow-btn-glow {
130
+ 0%, 100% { box-shadow: 0 0 4px rgba(76, 139, 245, 0.1); }
131
+ 50% { box-shadow: 0 0 12px rgba(76, 139, 245, 0.4); }
126
132
  }
127
133
 
128
134
  /* Agent badge */