{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"terminal","type":"registry:ui","title":"Terminal","description":"Controlled xterm.js terminal \u2014 agent-bridgeable.","package":"fancy-term","dependencies":[],"registryDependencies":["output-diff","shell-select","clipboard","context-menu","selection-snapshot","types"],"files":[{"path":"components\/fancy\/terminal\/Terminal.tsx","content":"import {\r\n  forwardRef,\r\n  useCallback,\r\n  useEffect,\r\n  useImperativeHandle,\r\n  useRef,\r\n  useState,\r\n} from \"react\";\r\nimport { useTerminal } from \"..\/hooks\/use-terminal\";\r\nimport { ShellSwitcher } from \".\/ShellSwitcher\";\r\nimport { TerminalContextMenu } from \".\/TerminalContextMenu\";\r\nimport { diffOutput } from \"..\/output-diff\";\r\nimport { decideShellSelect } from \"..\/shell-select\";\r\nimport { providerWrite, resolveClipboard } from \"..\/clipboard\";\r\nimport { resolveMenuItems, type TerminalContextMenuContext, type TerminalContextMenuItem } from \"..\/context-menu\";\r\nimport { nextSelectionSnapshot } from \"..\/selection-snapshot\";\r\nimport type { TerminalHandle, TerminalProps } from \"..\/types\";\r\n\r\n\/**\r\n * Human+ `<Terminal>` \u2014 a controlled, themeable xterm.js terminal.\r\n *\r\n * - **Authoring surface:** terse, controlled (`output` + `onData`), JSON-friendly\r\n *   props (rows\/cols, theme tokens, initial buffer), a stable `data-fancy-terminal`\r\n *   handle, and a ref exposing {@link TerminalHandle} (write\/clear\/fit\/getBuffer\/\u2026).\r\n * - **Inhabited surface:** the same handle is what an MCP bridge drives, so an\r\n *   embedded agent reads the buffer + writes input without DOM-scraping.\r\n *\r\n * The parent must have a height \u2014 the terminal fits its container (like any xterm\r\n * surface); a 0-height parent collapses it.\r\n *\r\n * ```tsx\r\n * const [out, setOut] = useState(\"\");\r\n * <div style={{ height: 360 }}>\r\n *   <Terminal output={out} onData={(d) => backend.send(d)} \/>\r\n * <\/div>\r\n * ```\r\n *\/\r\nexport const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(\r\n  {\r\n    output,\r\n    theme,\r\n    rows,\r\n    cols,\r\n    fit = true,\r\n    readOnly,\r\n    cursorBlink,\r\n    cursorStyle,\r\n    fontFamily,\r\n    fontSize,\r\n    scrollback,\r\n    initialOutput,\r\n    onData,\r\n    onResize,\r\n    shells,\r\n    activeShell,\r\n    onShellChange,\r\n    showShellBar = false,\r\n    clipboard,\r\n    osc52,\r\n    copyPaste,\r\n    onReady,\r\n    onPaste,\r\n    contextMenu,\r\n    className,\r\n    style,\r\n    ...rest\r\n  },\r\n  ref,\r\n) {\r\n  const containerRef = useRef<HTMLDivElement>(null);\r\n\r\n  \/\/ Selected shell: controlled by `activeShell` when provided, else internal.\r\n  const isShellControlled = activeShell !== undefined;\r\n  const [internalShell, setInternalShell] = useState<string | undefined>(undefined);\r\n  const shellId = isShellControlled ? activeShell : internalShell;\r\n\r\n  \/\/ Read shell state live so the stable handle's setShell\/getShell never go stale.\r\n  const shellStateRef = useRef({ shells, shellId, isShellControlled, onShellChange });\r\n  shellStateRef.current = { shells, shellId, isShellControlled, onShellChange };\r\n\r\n  const selectShell = useCallback((id: string) => {\r\n    const s = shellStateRef.current;\r\n    const decision = decideShellSelect(s.shells, s.shellId, id);\r\n    if (!decision) return; \/\/ unknown id \u2014 no-op\r\n    if (!s.isShellControlled) setInternalShell(id);\r\n    s.onShellChange?.(id, decision.profile);\r\n  }, []);\r\n\r\n  const handle = useTerminal(containerRef, {\r\n    theme,\r\n    rows,\r\n    cols,\r\n    fit,\r\n    readOnly,\r\n    cursorBlink,\r\n    cursorStyle,\r\n    fontFamily,\r\n    fontSize,\r\n    scrollback,\r\n    \/\/ `initialOutput` is for uncontrolled use; with a controlled `output` the\r\n    \/\/ diffing effect below owns the buffer, so don't double-write.\r\n    initialOutput: output === undefined ? initialOutput : undefined,\r\n    onData,\r\n    onResize,\r\n    clipboard,\r\n    osc52,\r\n    copyPaste,\r\n    onReady,\r\n    onPaste,\r\n  });\r\n\r\n  \/\/ Delegate `xterm` \/ `ready` as live getters rather than spreading `handle`\r\n  \/\/ (object spread would snapshot the getters \u2014 capturing xterm === null before\r\n  \/\/ the container is measured, the exact race consumers hit). Methods are stable.\r\n  useImperativeHandle(\r\n    ref,\r\n    (): TerminalHandle => ({\r\n      get xterm() {\r\n        return handle.xterm;\r\n      },\r\n      get ready() {\r\n        return handle.ready;\r\n      },\r\n      write: handle.write,\r\n      writeln: handle.writeln,\r\n      clear: handle.clear,\r\n      reset: handle.reset,\r\n      fit: handle.fit,\r\n      focus: handle.focus,\r\n      getBuffer: handle.getBuffer,\r\n      getSelection: handle.getSelection,\r\n      copySelection: handle.copySelection,\r\n      paste: handle.paste,\r\n      selectAll: handle.selectAll,\r\n      clearSelection: handle.clearSelection,\r\n      setShell: selectShell,\r\n      getShell: () => shellStateRef.current.shellId,\r\n    }),\r\n    [handle, selectShell],\r\n  );\r\n\r\n  \/\/ Controlled output: write only the appended delta; reset + rewrite if the\r\n  \/\/ value diverges from what we've already written (a wholesale replace).\r\n  const written = useRef(\"\");\r\n  useEffect(() => {\r\n    if (output === undefined) return;\r\n    const change = diffOutput(written.current, output);\r\n    if (!change) return;\r\n    if (change.reset) handle.reset();\r\n    handle.write(change.write);\r\n    written.current = output;\r\n  }, [output, handle]);\r\n\r\n  \/\/ Right-click selection context menu (Copy \/ Paste \/ Select all \/ Clear by\r\n  \/\/ default; customizable via the `contextMenu` prop).\r\n  const [menu, setMenu] = useState<{\r\n    at: { x: number; y: number };\r\n    items: TerminalContextMenuItem[];\r\n    ctx: TerminalContextMenuContext;\r\n  } | null>(null);\r\n\r\n  \/\/ #2 \u2014 over a mouse-reporting TUI (Claude Code, tmux, vim) the app's redraw\r\n  \/\/ asynchronously clears xterm's selection right after a right-click, so both\r\n  \/\/ late reads lose: at `contextmenu` time the selection may already be gone,\r\n  \/\/ and by the time the user clicks Copy it almost always is. Track a snapshot\r\n  \/\/ at pointer time (capture phase \u2014 before xterm \/ the app can react) and use\r\n  \/\/ it as the menu's source of truth whenever the live selection is empty.\r\n  const selectionSnapshot = useRef(\"\");\r\n  const trackSelection = useCallback(\r\n    (e: React.MouseEvent) => {\r\n      selectionSnapshot.current = nextSelectionSnapshot(\r\n        selectionSnapshot.current,\r\n        { type: e.type as \"mousedown\" | \"mouseup\", button: e.button },\r\n        handle.getSelection(),\r\n      );\r\n    },\r\n    [handle],\r\n  );\r\n\r\n  const openMenu = useCallback(\r\n    (e: React.MouseEvent) => {\r\n      if (contextMenu === false) return;\r\n      e.preventDefault();\r\n      const selection = handle.getSelection() || selectionSnapshot.current;\r\n      const ctx: TerminalContextMenuContext = {\r\n        hasSelection: selection.length > 0,\r\n        selection,\r\n        readOnly: !!readOnly,\r\n      };\r\n      const items = resolveMenuItems(contextMenu, ctx, {\r\n        \/\/ Copy writes the SNAPSHOTTED selection through the active clipboard\r\n        \/\/ provider \u2014 never a click-time re-read, which copies \"\" over a TUI\r\n        \/\/ (falling back to the live selection only when the snapshot is empty).\r\n        copy: (c) => {\r\n          const text = c.selection || handle.getSelection();\r\n          if (text) void providerWrite(resolveClipboard(clipboard).provider, text);\r\n        },\r\n        paste: () => void handle.paste(),\r\n        selectAll: () => handle.selectAll(),\r\n        clear: () => handle.clear(),\r\n      });\r\n      if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });\r\n    },\r\n    [contextMenu, handle, readOnly, clipboard],\r\n  );\r\n\r\n  const menuNode = menu ? (\r\n    <TerminalContextMenu at={menu.at} items={menu.items} ctx={menu.ctx} onClose={() => setMenu(null)} \/>\r\n  ) : null;\r\n\r\n  const surface = (\r\n    <div\r\n      ref={containerRef}\r\n      data-fancy-terminal=\"\"\r\n      onMouseDownCapture={trackSelection}\r\n      onMouseUpCapture={trackSelection}\r\n      onContextMenu={openMenu}\r\n      data-readonly={readOnly ? \"\" : undefined}\r\n      data-fancy-terminal-shell={shellId ?? undefined}\r\n      \/\/ When the shell bar is shown the wrapper owns the box; the surface flexes.\r\n      style={\r\n        showShellBar && shells\r\n          ? { width: \"100%\", flex: 1, minHeight: 0 }\r\n          : { width: \"100%\", height: \"100%\", ...style }\r\n      }\r\n      {...(showShellBar && shells ? {} : rest)}\r\n    \/>\r\n  );\r\n\r\n  if (!showShellBar || !shells) {\r\n    return (\r\n      <>\r\n        {surface}\r\n        {menuNode}\r\n      <\/>\r\n    );\r\n  }\r\n\r\n  return (\r\n    <div\r\n      data-fancy-terminal-shell={shellId ?? undefined}\r\n      className={className}\r\n      style={{ display: \"flex\", flexDirection: \"column\", width: \"100%\", height: \"100%\", ...style }}\r\n      {...rest}\r\n    >\r\n      <div\r\n        style={{\r\n          display: \"flex\",\r\n          alignItems: \"center\",\r\n          gap: 8,\r\n          padding: \"4px 6px\",\r\n          background: \"#09090b\",\r\n          borderBottom: \"1px solid #27272a\",\r\n        }}\r\n      >\r\n        <ShellSwitcher\r\n          shells={shells}\r\n          value={shellId}\r\n          onChange={(id) => selectShell(id)}\r\n          disabled={readOnly}\r\n        \/>\r\n      <\/div>\r\n      {surface}\r\n      {menuNode}\r\n    <\/div>\r\n  );\r\n});\r\n","type":"registry:ui","target":"components\/fancy\/terminal\/Terminal.tsx"}]}