@interactive-inc/claude-funnel 0.30.0 → 0.31.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.
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-RzDvrNE5.js";
1
+ import { i as FunnelDiscordAdapter, n as FunnelDiscordListener, t as discordConnectorSchema } from "./discord-connector-schema-BeThExJp.js";
2
2
  import { n as FunnelConnectorListener, t as FunnelLogger } from "./logger-Czli2OKh.js";
3
3
  import { a as FunnelProcessRunner, i as NodeFunnelProcessRunner, n as FunnelGhListener, r as FunnelGhAdapter, t as ghConnectorSchema } from "./gh-connector-schema-eYE4g77K.js";
4
4
  import { a as ScheduleStateStore, i as FunnelScheduleListener, n as scheduleConnectorSchema, o as NodeFunnelFileSystem, r as scheduleEntrySchema, s as FunnelFileSystem, t as scheduleCatchupPolicySchema } from "./schedule-connector-schema-CM-sRkac.js";
5
- import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-CHbRJHGp.js";
5
+ import { i as FunnelSlackAdapter, n as FunnelSlackListener, r as FunnelSlackEventProcessor, t as slackConnectorSchema } from "./slack-connector-schema-DDbSGPZn.js";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { z } from "zod";
@@ -18,10 +18,6 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
19
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
20
20
  import { stringify } from "yaml";
21
- import { TextAttributes, createCliRenderer } from "@opentui/core";
22
- import { createRoot, useKeyboard, useRenderer } from "@opentui/react";
23
- import { createContext, useContext, useEffect, useId, useState } from "react";
24
- import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
25
21
  //#region lib/engine/id/id-generator.ts
26
22
  /**
27
23
  * ID generator boundary. Default NodeFunnelIdGenerator wraps `crypto.randomUUID()`;
@@ -1807,7 +1803,7 @@ const resolveDaemonScript = () => {
1807
1803
  const DEFAULT_PORT$1 = 9742;
1808
1804
  const STARTUP_TIMEOUT_MS = 5e3;
1809
1805
  const SIGTERM_TIMEOUT_MS = 2e3;
1810
- const POLL_INTERVAL_MS$1 = 100;
1806
+ const POLL_INTERVAL_MS = 100;
1811
1807
  const SIGKILL_GRACE_MS = 200;
1812
1808
  const defaultProcess$1 = new NodeFunnelProcessRunner();
1813
1809
  const defaultFs$1 = new NodeFunnelFileSystem();
@@ -1869,7 +1865,7 @@ var FunnelGateway = class {
1869
1865
  const deadline = this.clock.millis() + STARTUP_TIMEOUT_MS;
1870
1866
  while (this.clock.millis() < deadline) {
1871
1867
  if (this.isRunning()) return true;
1872
- await this.sleep(POLL_INTERVAL_MS$1);
1868
+ await this.sleep(POLL_INTERVAL_MS);
1873
1869
  }
1874
1870
  return this.isRunning();
1875
1871
  }
@@ -1906,7 +1902,7 @@ var FunnelGateway = class {
1906
1902
  this.removePid();
1907
1903
  return true;
1908
1904
  }
1909
- await this.sleep(POLL_INTERVAL_MS$1);
1905
+ await this.sleep(POLL_INTERVAL_MS);
1910
1906
  }
1911
1907
  try {
1912
1908
  this.process.kill(pid, "SIGKILL");
@@ -2558,7 +2554,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2558
2554
  record(record) {
2559
2555
  const event = {
2560
2556
  type: record.meta?.event_type ?? "unknown",
2561
- content: truncate$1(record.content),
2557
+ content: truncate(record.content),
2562
2558
  channel_id: record.channelId,
2563
2559
  connector_id: record.connectorId,
2564
2560
  meta: record.meta
@@ -2615,7 +2611,7 @@ var SqliteFunnelEventLog = class extends FunnelEventLog {
2615
2611
  this.sink.close();
2616
2612
  }
2617
2613
  };
2618
- function truncate$1(content) {
2614
+ function truncate(content) {
2619
2615
  if (content.length <= MAX_CONTENT_CHARS) return content;
2620
2616
  return `${content.slice(0, MAX_CONTENT_CHARS)}...`;
2621
2617
  }
@@ -3519,7 +3515,7 @@ const noopOnError = () => {};
3519
3515
  /**
3520
3516
  * Facade exposing every funnel facet as a getter.
3521
3517
  *
3522
- * The same `Funnel` is used by the CLI, the TUI, and as a programmable library.
3518
+ * The same `Funnel` is used by the CLI and as a programmable library.
3523
3519
  * All side-effecting boundaries (filesystem, process, logger, clock, id, paths) are
3524
3520
  * injectable via `Props` — passing memory implementations gives a fully sandboxed
3525
3521
  * Funnel that touches no real disk, processes, or wall-clock time.
@@ -4449,18 +4445,18 @@ var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4449
4445
  });
4450
4446
  }
4451
4447
  queryRaw(query) {
4452
- return takeRecent(this.raws.filter((event) => matches$1(event, query)), query.limit);
4448
+ return takeRecent(this.raws.filter((event) => matches(event, query)), query.limit);
4453
4449
  }
4454
4450
  queryProcessed(query) {
4455
4451
  return takeRecent(this.processeds.filter((event) => {
4456
- if (!matches$1(event, query)) return false;
4452
+ if (!matches(event, query)) return false;
4457
4453
  if (query.outcome !== void 0 && event.outcome !== query.outcome) return false;
4458
4454
  return true;
4459
4455
  }), query.limit);
4460
4456
  }
4461
4457
  queryConnection(query) {
4462
4458
  return takeRecent(this.connections.filter((event) => {
4463
- if (!matches$1(event, query)) return false;
4459
+ if (!matches(event, query)) return false;
4464
4460
  if (query.status !== void 0 && event.status !== query.status) return false;
4465
4461
  return true;
4466
4462
  }), query.limit);
@@ -4472,7 +4468,7 @@ var MemoryConnectorDiagnosticLog = class extends ConnectorDiagnosticLog {
4472
4468
  }
4473
4469
  close() {}
4474
4470
  };
4475
- const matches$1 = (event, query) => {
4471
+ const matches = (event, query) => {
4476
4472
  if (query.type !== void 0 && event.type !== query.type) return false;
4477
4473
  if (query.connectorId !== void 0 && event.connectorId !== query.connectorId) return false;
4478
4474
  if (query.channelId !== void 0 && event.channelId !== query.channelId) return false;
@@ -5209,7 +5205,7 @@ examples:
5209
5205
  funnel gateway logs
5210
5206
  funnel gateway logs -n 100`;
5211
5207
  const logger = new NodeFunnelLogger();
5212
- const tryParseJson$1 = (line) => {
5208
+ const tryParseJson = (line) => {
5213
5209
  try {
5214
5210
  return JSON.parse(line);
5215
5211
  } catch {
@@ -5255,7 +5251,7 @@ const gatewayLogsHandler = factory.createHandlers(zValidator$1("query", z.object
5255
5251
  buffer = lines.pop() ?? "";
5256
5252
  for (const line of lines) {
5257
5253
  if (!line.trim()) continue;
5258
- const parsed = tryParseJson$1(line);
5254
+ const parsed = tryParseJson(line);
5259
5255
  if (!isLogEntry(parsed)) {
5260
5256
  process.stdout.write(`${line}\n`);
5261
5257
  continue;
@@ -5749,2209 +5745,4 @@ const createCliApp = (funnel) => {
5749
5745
  /** CLI Hono app wired to a default `new Funnel()`. For embedding with a custom Funnel use `createCliApp`. */
5750
5746
  const app = createCliApp(new Funnel());
5751
5747
  //#endregion
5752
- //#region lib/tui/utils/hascii/tokens.json
5753
- var tokens_default = { theme: { "extend": {
5754
- "colors": {
5755
- "background": "#09090b",
5756
- "foreground": "#fafafa",
5757
- "primary": "#fafafa",
5758
- "primary-foreground": "#09090b",
5759
- "primary-hover": "#e4e4e7",
5760
- "primary-active": "#d4d4d8",
5761
- "secondary": "#27272a",
5762
- "secondary-foreground": "#fafafa",
5763
- "secondary-hover": "#3f3f46",
5764
- "secondary-active": "#52525b",
5765
- "card": "#27272a",
5766
- "card-foreground": "#fafafa",
5767
- "popover": "#3f3f46",
5768
- "popover-foreground": "#fafafa",
5769
- "muted": "#27272a",
5770
- "muted-foreground": "#a1a1aa",
5771
- "accent": "#27272a",
5772
- "accent-foreground": "#fafafa",
5773
- "accent-hover": "#18181b",
5774
- "accent-active": "#3f3f46",
5775
- "destructive": "#7f1d1d",
5776
- "destructive-foreground": "#fef2f2",
5777
- "destructive-hover": "#991b1b",
5778
- "destructive-active": "#b91c1c",
5779
- "border": "#3f3f46",
5780
- "input": "#3f3f46",
5781
- "ring": "#d4d4d8",
5782
- "hover-active": "#71717a"
5783
- },
5784
- "fontFamily": {},
5785
- "fontSize": {},
5786
- "borderRadius": {
5787
- "sm": "0px",
5788
- "md": "0px",
5789
- "lg": "0px"
5790
- },
5791
- "spacing": {
5792
- "xs": "1px",
5793
- "sm": "2px",
5794
- "md": "4px",
5795
- "lg": "8px"
5796
- }
5797
- } } };
5798
- //#endregion
5799
- //#region lib/tui/utils/hascii/theme.ts
5800
- const hasciiTw = { colors: {
5801
- neutral: {
5802
- 50: "#fafafa",
5803
- 100: "#f5f5f5",
5804
- 200: "#e5e5e5",
5805
- 300: "#d4d4d4",
5806
- 400: "#a3a3a3",
5807
- 500: "#737373",
5808
- 600: "#525252",
5809
- 700: "#404040",
5810
- 800: "#262626",
5811
- 900: "#171717",
5812
- 950: "#0a0a0a"
5813
- },
5814
- zinc: {
5815
- 50: "#fafafa",
5816
- 100: "#f4f4f5",
5817
- 200: "#e4e4e7",
5818
- 300: "#d4d4d8",
5819
- 400: "#a1a1aa",
5820
- 500: "#71717a",
5821
- 600: "#52525b",
5822
- 700: "#3f3f46",
5823
- 800: "#27272a",
5824
- 900: "#18181b",
5825
- 950: "#09090b"
5826
- },
5827
- red: {
5828
- 50: "#fef2f2",
5829
- 100: "#fee2e2",
5830
- 200: "#fecaca",
5831
- 300: "#fca5a5",
5832
- 400: "#f87171",
5833
- 500: "#ef4444",
5834
- 600: "#dc2626",
5835
- 700: "#b91c1c",
5836
- 800: "#991b1b",
5837
- 900: "#7f1d1d",
5838
- 950: "#450a0a"
5839
- }
5840
- } };
5841
- const HEX = /^#[0-9a-fA-F]{6}$/;
5842
- const c = z.object({ theme: z.object({ extend: z.object({ colors: z.object({
5843
- background: z.string().regex(HEX),
5844
- foreground: z.string().regex(HEX),
5845
- primary: z.string().regex(HEX),
5846
- "primary-foreground": z.string().regex(HEX),
5847
- "primary-hover": z.string().regex(HEX),
5848
- "primary-active": z.string().regex(HEX),
5849
- secondary: z.string().regex(HEX),
5850
- "secondary-foreground": z.string().regex(HEX),
5851
- "secondary-hover": z.string().regex(HEX),
5852
- "secondary-active": z.string().regex(HEX),
5853
- card: z.string().regex(HEX),
5854
- "card-foreground": z.string().regex(HEX),
5855
- popover: z.string().regex(HEX),
5856
- "popover-foreground": z.string().regex(HEX),
5857
- muted: z.string().regex(HEX),
5858
- "muted-foreground": z.string().regex(HEX),
5859
- accent: z.string().regex(HEX),
5860
- "accent-foreground": z.string().regex(HEX),
5861
- "accent-hover": z.string().regex(HEX),
5862
- "accent-active": z.string().regex(HEX),
5863
- destructive: z.string().regex(HEX),
5864
- "destructive-foreground": z.string().regex(HEX),
5865
- "destructive-hover": z.string().regex(HEX),
5866
- "destructive-active": z.string().regex(HEX),
5867
- border: z.string().regex(HEX),
5868
- input: z.string().regex(HEX),
5869
- ring: z.string().regex(HEX),
5870
- "hover-active": z.string().regex(HEX)
5871
- }) }) }) }).parse(tokens_default).theme.extend.colors;
5872
- /** Default dark theme. Tokens are loaded from tokens.json (generated from DESIGN.md by `make tokens`) and validated with zod. */
5873
- const hasciiTheme = { color: {
5874
- background: c.background,
5875
- foreground: c.foreground,
5876
- primary: c.primary,
5877
- primaryForeground: c["primary-foreground"],
5878
- primaryHover: c["primary-hover"],
5879
- primaryActive: c["primary-active"],
5880
- secondary: c.secondary,
5881
- secondaryForeground: c["secondary-foreground"],
5882
- secondaryHover: c["secondary-hover"],
5883
- secondaryActive: c["secondary-active"],
5884
- card: c.card,
5885
- cardForeground: c["card-foreground"],
5886
- popover: c.popover,
5887
- popoverForeground: c["popover-foreground"],
5888
- muted: c.muted,
5889
- mutedForeground: c["muted-foreground"],
5890
- accent: c.accent,
5891
- accentForeground: c["accent-foreground"],
5892
- accentHover: c["accent-hover"],
5893
- accentActive: c["accent-active"],
5894
- destructive: c.destructive,
5895
- destructiveForeground: c["destructive-foreground"],
5896
- destructiveHover: c["destructive-hover"],
5897
- destructiveActive: c["destructive-active"],
5898
- border: c.border,
5899
- input: c.input,
5900
- ring: c.ring,
5901
- hoverActive: c["hover-active"]
5902
- } };
5903
- //#endregion
5904
- //#region lib/tui/theme.ts
5905
- /**
5906
- * Funnel-specific TUI tokens that hascii does not cover.
5907
- *
5908
- * Generic colors (background / foreground / primary / muted / etc.)
5909
- * come from hascii via `useHasciiTheme()` — components that need them
5910
- * read the theme there. Only the funnel-only concerns live here:
5911
- *
5912
- * - status accents (`alive` / `dead` / `warn`) and the selection
5913
- * accent (`primary` blue) — semantic colors not in hascii's palette
5914
- * - the in-between background tier `surface` (zinc[900]) sitting
5915
- * between hascii's `background` and `muted`
5916
- * - the deeper text tier `faint` (zinc[600]) below `mutedForeground`
5917
- * - layout constants (paddingX / paddingY / gap / sidebarWidth / ...)
5918
- */
5919
- const funnel = {
5920
- alive: "#86efac",
5921
- dead: "#fca5a5",
5922
- warn: "#fcd34d",
5923
- primary: "#3b82f6",
5924
- surface: hasciiTw.colors.zinc[900],
5925
- faint: hasciiTw.colors.zinc[600],
5926
- paddingX: 2,
5927
- paddingY: 1,
5928
- gap: 1,
5929
- sidebarWidth: 24,
5930
- modalTop: 4,
5931
- modalInset: "20%",
5932
- barHeight: 1,
5933
- detailPanelHeight: 14
5934
- };
5935
- //#endregion
5936
- //#region lib/tui/utils/hascii/theme-context.tsx
5937
- /** @jsxImportSource @opentui/react */
5938
- const HasciiThemeContext = createContext(hasciiTheme);
5939
- /** Read the active theme from context. Falls back to the default theme outside a provider. */
5940
- function useHasciiTheme() {
5941
- return useContext(HasciiThemeContext);
5942
- }
5943
- /** Wraps a subtree with a HasciiTheme so descendants can read tokens via useHasciiTheme. */
5944
- function HasciiThemeProvider(props) {
5945
- return /* @__PURE__ */ jsx(HasciiThemeContext.Provider, {
5946
- value: props.theme ?? hasciiTheme,
5947
- children: props.children
5948
- });
5949
- }
5950
- //#endregion
5951
- //#region lib/tui/filter-input.tsx
5952
- /** @jsxImportSource @opentui/react */
5953
- /** Inline filter overlay shown when the user presses `/`. */
5954
- function FilterInput(props) {
5955
- const theme = useHasciiTheme();
5956
- if (!props.active) return null;
5957
- return /* @__PURE__ */ jsx("box", {
5958
- style: {
5959
- height: funnel.barHeight,
5960
- backgroundColor: theme.color.muted,
5961
- paddingLeft: funnel.paddingX,
5962
- paddingRight: funnel.paddingX
5963
- },
5964
- children: /* @__PURE__ */ jsxs("text", { children: [
5965
- /* @__PURE__ */ jsx("span", {
5966
- fg: theme.color.foreground,
5967
- children: "/"
5968
- }),
5969
- /* @__PURE__ */ jsx("span", {
5970
- fg: theme.color.foreground,
5971
- children: props.value
5972
- }),
5973
- /* @__PURE__ */ jsx("span", {
5974
- fg: theme.color.foreground,
5975
- children: "█"
5976
- }),
5977
- /* @__PURE__ */ jsx("span", {
5978
- fg: theme.color.mutedForeground,
5979
- children: " · Enter to apply · Esc to cancel"
5980
- })
5981
- ] })
5982
- });
5983
- }
5984
- //#endregion
5985
- //#region lib/tui/components/ui/hascii/separator.tsx
5986
- /** Hairline divider drawn with box-drawing characters. Length is the run in the chosen orientation. */
5987
- function HasciiSeparator(props) {
5988
- const orientation = props.orientation ?? "horizontal";
5989
- const length = props.length ?? (orientation === "horizontal" ? 32 : 8);
5990
- const theme = useHasciiTheme();
5991
- const color = props.color ?? theme.color.border;
5992
- if (orientation === "vertical") {
5993
- const lines = [];
5994
- for (let index = 0; index < length; index++) lines.push("│");
5995
- return /* @__PURE__ */ jsx("box", {
5996
- width: 1,
5997
- height: length,
5998
- flexShrink: 0,
5999
- children: /* @__PURE__ */ jsx("text", {
6000
- fg: color,
6001
- children: lines.join("\n")
6002
- })
6003
- });
6004
- }
6005
- return /* @__PURE__ */ jsx("box", {
6006
- width: length,
6007
- height: 1,
6008
- flexShrink: 0,
6009
- children: /* @__PURE__ */ jsx("text", {
6010
- fg: color,
6011
- children: "─".repeat(length)
6012
- })
6013
- });
6014
- }
6015
- //#endregion
6016
- //#region lib/tui/components/empty-state.tsx
6017
- /** @jsxImportSource @opentui/react */
6018
- /** Faint placeholder shown when a list has no items. */
6019
- function EmptyState(props) {
6020
- return /* @__PURE__ */ jsx("text", {
6021
- fg: funnel.faint,
6022
- children: props.message
6023
- });
6024
- }
6025
- //#endregion
6026
- //#region lib/tui/profile-launcher.tsx
6027
- /** @jsxImportSource @opentui/react */
6028
- /**
6029
- * Modal-style overlay: pick a profile and launch Claude Code via the same
6030
- * code path as `fnl claude --profile`. The launcher exits the TUI before
6031
- * exec'ing so Claude takes over the terminal.
6032
- */
6033
- function ProfileLauncher(props) {
6034
- const theme = useHasciiTheme();
6035
- if (!props.active) return null;
6036
- return /* @__PURE__ */ jsxs("box", {
6037
- style: {
6038
- flexDirection: "column",
6039
- backgroundColor: funnel.surface,
6040
- paddingLeft: funnel.paddingX,
6041
- paddingRight: funnel.paddingX,
6042
- paddingTop: funnel.paddingY,
6043
- paddingBottom: funnel.paddingY,
6044
- gap: funnel.gap,
6045
- position: "absolute",
6046
- top: funnel.modalTop,
6047
- left: funnel.modalInset,
6048
- right: funnel.modalInset
6049
- },
6050
- children: [
6051
- /* @__PURE__ */ jsx("text", {
6052
- fg: theme.color.foreground,
6053
- children: "launch claude with profile"
6054
- }),
6055
- /* @__PURE__ */ jsx(HasciiSeparator, {}),
6056
- props.profiles.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { message: "(no profiles — `fnl profiles add` first)" }) : props.profiles.map((profile, index) => {
6057
- return /* @__PURE__ */ jsxs("text", {
6058
- bg: index === props.selectedIndex ? theme.color.muted : void 0,
6059
- children: [/* @__PURE__ */ jsx("span", {
6060
- fg: theme.color.foreground,
6061
- children: profile.name
6062
- }), /* @__PURE__ */ jsx("span", {
6063
- fg: funnel.faint,
6064
- children: ` → channel ${profile.channelId} · path ${profile.path}`
6065
- })]
6066
- }, profile.name);
6067
- })
6068
- ]
6069
- });
6070
- }
6071
- //#endregion
6072
- //#region lib/tui/components/brand.tsx
6073
- /** @jsxImportSource @opentui/react */
6074
- /**
6075
- * Brand mark rendered at the top of the sidebar. Carries its own paddingX
6076
- * so it aligns with menu items (which need paddingX-less parents so their
6077
- * active highlight spans edge-to-edge).
6078
- */
6079
- function Brand() {
6080
- const theme = useHasciiTheme();
6081
- return /* @__PURE__ */ jsx("box", {
6082
- style: {
6083
- flexDirection: "row",
6084
- paddingLeft: funnel.paddingX,
6085
- paddingRight: funnel.paddingX
6086
- },
6087
- children: /* @__PURE__ */ jsx("text", {
6088
- fg: theme.color.foreground,
6089
- attributes: TextAttributes.BOLD,
6090
- children: "funnel"
6091
- })
6092
- });
6093
- }
6094
- //#endregion
6095
- //#region lib/tui/components/ui/hascii/focus-group.tsx
6096
- const HasciiFocusContext = createContext(null);
6097
- /** Returns whether the provided focusId matches the surrounding HasciiFocusGroup's current focus. */
6098
- function useHasciiFocus(focusId) {
6099
- const ctx = useContext(HasciiFocusContext);
6100
- if (!ctx || focusId === void 0) return false;
6101
- return ctx.currentId === focusId;
6102
- }
6103
- //#endregion
6104
- //#region lib/tui/hooks/hascii/use-pressable.ts
6105
- /** Tracks hover and press state for a focusable element and exposes mouse handlers ready for spread. */
6106
- function usePressable(options) {
6107
- const isDisabled = options?.isDisabled ?? false;
6108
- const onPress = options?.onPress;
6109
- const hoveredState = useState(false);
6110
- const isHovered = hoveredState[0];
6111
- const setHovered = hoveredState[1];
6112
- const pressedState = useState(false);
6113
- const isPressed = pressedState[0];
6114
- const setPressed = pressedState[1];
6115
- return {
6116
- isHovered,
6117
- isPressed,
6118
- bind: {
6119
- onMouseOver: () => {
6120
- if (!isDisabled) setHovered(true);
6121
- },
6122
- onMouseOut: () => {
6123
- setHovered(false);
6124
- setPressed(false);
6125
- },
6126
- onMouseDown: () => {
6127
- if (!isDisabled) setPressed(true);
6128
- },
6129
- onMouseUp: () => {
6130
- if (isDisabled) return;
6131
- if (isPressed) onPress?.();
6132
- setPressed(false);
6133
- }
6134
- }
6135
- };
6136
- }
6137
- //#endregion
6138
- //#region lib/tui/components/ui/hascii/button.tsx
6139
- const sizeDims = {
6140
- sm: {
6141
- paddingX: 1,
6142
- height: 1
6143
- },
6144
- md: {
6145
- paddingX: 2,
6146
- height: 1
6147
- },
6148
- lg: {
6149
- paddingX: 2,
6150
- height: 3
6151
- }
6152
- };
6153
- const resolveSize = (size) => size === "default" ? "md" : size;
6154
- const pickBg = (rest, hover, active, isHover, isActive) => {
6155
- if (isActive) return active;
6156
- if (isHover) return hover;
6157
- return rest;
6158
- };
6159
- /** A focusable terminal button. Background cycles through rest, hover, and active states. */
6160
- function HasciiButton(props) {
6161
- const variant = props.variant ?? "default";
6162
- const size = props.size ?? "default";
6163
- const groupFocused = useHasciiFocus(props.focusId);
6164
- const isFocused = props.isFocused ?? groupFocused;
6165
- const isDisabled = props.isDisabled ?? false;
6166
- const theme = useHasciiTheme();
6167
- const resolvedSize = resolveSize(size);
6168
- const dims = sizeDims[resolvedSize];
6169
- const press = usePressable({
6170
- isDisabled,
6171
- onPress: props.onPress
6172
- });
6173
- const flashState = useState(false);
6174
- const flashed = flashState[0];
6175
- const setFlashed = flashState[1];
6176
- const isHover = press.isHovered && !press.isPressed && !flashed;
6177
- const isActive = press.isPressed || flashed;
6178
- useKeyboard((key) => {
6179
- if (!isFocused || isDisabled) return;
6180
- if (key.name === "return" || key.name === "space") {
6181
- setFlashed(true);
6182
- props.onPress?.();
6183
- setTimeout(() => setFlashed(false), 120);
6184
- }
6185
- });
6186
- if (variant === "outline") {
6187
- const tone = isDisabled ? theme.color.border : isActive ? theme.color.primaryActive : isHover ? theme.color.primaryHover : theme.color.primary;
6188
- const outlinePaddingX = resolvedSize === "sm" ? 0 : resolvedSize === "md" ? 1 : 2;
6189
- const outlineHeight = 3;
6190
- return /* @__PURE__ */ jsx("box", {
6191
- paddingLeft: outlinePaddingX,
6192
- paddingRight: outlinePaddingX,
6193
- height: outlineHeight,
6194
- border: outlineHeight >= 3,
6195
- borderStyle: "rounded",
6196
- borderColor: tone,
6197
- alignItems: "center",
6198
- justifyContent: "center",
6199
- ...press.bind,
6200
- children: /* @__PURE__ */ jsx("text", {
6201
- fg: tone,
6202
- children: props.children
6203
- })
6204
- });
6205
- }
6206
- if (variant === "ghost") {
6207
- const fg = isDisabled ? theme.color.mutedForeground : theme.color.foreground;
6208
- const bg = pickBg(void 0, theme.color.accentHover, theme.color.accent, isHover, isActive);
6209
- return /* @__PURE__ */ jsx("box", {
6210
- paddingLeft: dims.paddingX,
6211
- paddingRight: dims.paddingX,
6212
- height: dims.height,
6213
- backgroundColor: bg,
6214
- alignItems: "center",
6215
- justifyContent: "center",
6216
- ...press.bind,
6217
- children: /* @__PURE__ */ jsx("text", {
6218
- fg,
6219
- children: props.children
6220
- })
6221
- });
6222
- }
6223
- if (variant === "secondary") {
6224
- const fg = isDisabled ? theme.color.mutedForeground : theme.color.secondaryForeground;
6225
- const bg = pickBg(theme.color.secondary, theme.color.secondaryHover, theme.color.secondaryActive, isHover, isActive);
6226
- return /* @__PURE__ */ jsx("box", {
6227
- paddingLeft: dims.paddingX,
6228
- paddingRight: dims.paddingX,
6229
- height: dims.height,
6230
- backgroundColor: bg,
6231
- alignItems: "center",
6232
- justifyContent: "center",
6233
- ...press.bind,
6234
- children: /* @__PURE__ */ jsx("text", {
6235
- fg,
6236
- children: props.children
6237
- })
6238
- });
6239
- }
6240
- if (variant === "destructive") {
6241
- const fg = isDisabled ? theme.color.mutedForeground : theme.color.destructiveForeground;
6242
- const bg = pickBg(theme.color.destructive, theme.color.destructiveHover, theme.color.destructiveActive, isHover, isActive);
6243
- return /* @__PURE__ */ jsx("box", {
6244
- paddingLeft: dims.paddingX,
6245
- paddingRight: dims.paddingX,
6246
- height: dims.height,
6247
- backgroundColor: bg,
6248
- alignItems: "center",
6249
- justifyContent: "center",
6250
- ...press.bind,
6251
- children: /* @__PURE__ */ jsx("text", {
6252
- fg,
6253
- children: props.children
6254
- })
6255
- });
6256
- }
6257
- const fg = isDisabled ? theme.color.mutedForeground : theme.color.primaryForeground;
6258
- const bg = pickBg(theme.color.primary, theme.color.primaryHover, theme.color.primaryActive, isHover, isActive);
6259
- return /* @__PURE__ */ jsx("box", {
6260
- paddingLeft: dims.paddingX,
6261
- paddingRight: dims.paddingX,
6262
- height: dims.height,
6263
- backgroundColor: bg,
6264
- alignItems: "center",
6265
- justifyContent: "center",
6266
- ...press.bind,
6267
- children: /* @__PURE__ */ jsx("text", {
6268
- fg,
6269
- children: props.children
6270
- })
6271
- });
6272
- }
6273
- //#endregion
6274
- //#region lib/tui/components/gateway-status.tsx
6275
- /** @jsxImportSource @opentui/react */
6276
- /**
6277
- * Compact running/stopped indicator with pid and port for the sidebar.
6278
- *
6279
- * The "gateway" label lives inside the same elevated block as the status
6280
- * so the heading reads as part of the card, not a floating sidebar
6281
- * separator on the surrounding surface tier.
6282
- *
6283
- * The toggle is rendered as a neutral-white `Button` with the same
6284
- * paddingX=2 / paddingY=1 as the rest of the form chrome — no leading
6285
- * glyph, just the verb. `busy` disables the button while a toggle is
6286
- * in flight so rapid clicks don't stack daemon spawns.
6287
- */
6288
- function GatewayStatus(props) {
6289
- const theme = useHasciiTheme();
6290
- return /* @__PURE__ */ jsxs("box", {
6291
- style: {
6292
- flexDirection: "column",
6293
- backgroundColor: theme.color.muted,
6294
- paddingLeft: funnel.paddingX,
6295
- paddingRight: funnel.paddingX,
6296
- paddingTop: funnel.paddingY,
6297
- paddingBottom: funnel.paddingY,
6298
- gap: funnel.gap
6299
- },
6300
- children: [/* @__PURE__ */ jsx("text", {
6301
- fg: funnel.faint,
6302
- children: "gateway"
6303
- }), props.gateway.running ? /* @__PURE__ */ jsxs(Fragment, { children: [
6304
- /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
6305
- fg: funnel.alive,
6306
- children: "●"
6307
- }), /* @__PURE__ */ jsx("span", {
6308
- fg: theme.color.foreground,
6309
- children: ` running`
6310
- })] }),
6311
- /* @__PURE__ */ jsx("text", {
6312
- fg: funnel.faint,
6313
- children: `pid ${props.gateway.pid} · :${props.gateway.port}`
6314
- }),
6315
- /* @__PURE__ */ jsx(HasciiButton, {
6316
- onPress: props.onToggle,
6317
- isDisabled: props.busy,
6318
- children: props.busy ? "stopping…" : "stop"
6319
- })
6320
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
6321
- fg: theme.color.mutedForeground,
6322
- children: "○"
6323
- }), /* @__PURE__ */ jsx("span", {
6324
- fg: theme.color.mutedForeground,
6325
- children: ` stopped`
6326
- })] }), /* @__PURE__ */ jsx(HasciiButton, {
6327
- onPress: props.onToggle,
6328
- isDisabled: props.busy,
6329
- children: props.busy ? "starting…" : "start"
6330
- })] })]
6331
- });
6332
- }
6333
- //#endregion
6334
- //#region lib/tui/components/menu-item.tsx
6335
- /** @jsxImportSource @opentui/react */
6336
- const ROW_HEIGHT = funnel.paddingY * 2 + 1;
6337
- /**
6338
- * One row in the sidebar nav.
6339
- *
6340
- * Active state: a thin `▏` (U+258F LEFT ONE EIGHTH BLOCK) rule painted
6341
- * in the primary color, stacked to fill the row height. The rule is
6342
- * narrower than a full character cell so it reads as a delicate accent
6343
- * instead of a heavy block. The sidebar's own background is `muted`,
6344
- * so hover/active lift the row to `secondaryHover` / `secondaryActive`
6345
- * to read against it.
6346
- */
6347
- function MenuItem(props) {
6348
- const theme = useHasciiTheme();
6349
- const press = usePressable({ onPress: props.onSelect });
6350
- const bg = press.isPressed ? theme.color.secondaryActive : props.active || press.isHovered ? theme.color.secondaryHover : void 0;
6351
- const isLifted = props.active || press.isHovered || press.isPressed;
6352
- const fg = isLifted ? theme.color.foreground : theme.color.foreground;
6353
- const countFg = isLifted ? theme.color.foreground : funnel.faint;
6354
- return /* @__PURE__ */ jsxs("box", {
6355
- ...press.bind,
6356
- style: {
6357
- flexDirection: "row",
6358
- justifyContent: "space-between",
6359
- backgroundColor: bg,
6360
- paddingLeft: funnel.paddingX,
6361
- paddingRight: funnel.paddingX,
6362
- paddingTop: funnel.paddingY,
6363
- paddingBottom: funnel.paddingY
6364
- },
6365
- children: [
6366
- props.active ? /* @__PURE__ */ jsx("box", {
6367
- style: {
6368
- position: "absolute",
6369
- left: 0,
6370
- top: 0,
6371
- bottom: 0,
6372
- flexDirection: "column"
6373
- },
6374
- children: Array.from({ length: ROW_HEIGHT }, (_, index) => /* @__PURE__ */ jsx("text", {
6375
- fg: funnel.primary,
6376
- children: "▏"
6377
- }, index))
6378
- }) : null,
6379
- /* @__PURE__ */ jsx("text", {
6380
- fg,
6381
- children: props.label
6382
- }),
6383
- props.count !== void 0 ? /* @__PURE__ */ jsx("text", {
6384
- fg: countFg,
6385
- children: String(props.count)
6386
- }) : null
6387
- ]
6388
- });
6389
- }
6390
- //#endregion
6391
- //#region lib/tui/components/menu.tsx
6392
- /** @jsxImportSource @opentui/react */
6393
- /** Vertical list of clickable nav rows. Each row is a `MenuItem`. */
6394
- function Menu(props) {
6395
- return /* @__PURE__ */ jsx("box", {
6396
- style: { flexDirection: "column" },
6397
- children: props.items.map((item) => /* @__PURE__ */ jsx(MenuItem, {
6398
- label: item.label,
6399
- active: item.view === props.active,
6400
- count: item.count,
6401
- onSelect: () => props.onSelect(item.view)
6402
- }, item.view))
6403
- });
6404
- }
6405
- //#endregion
6406
- //#region lib/tui/components/section-header.tsx
6407
- /** @jsxImportSource @opentui/react */
6408
- /**
6409
- * Tiny faint label rendered above each sidebar section. Wrapped in a box
6410
- * because OpenTUI ignores padding on `<text>`; only boxes lay out with
6411
- * padding.
6412
- */
6413
- function SectionHeader(props) {
6414
- return /* @__PURE__ */ jsx("box", {
6415
- style: {
6416
- flexDirection: "row",
6417
- paddingLeft: funnel.paddingX,
6418
- paddingRight: funnel.paddingX
6419
- },
6420
- children: /* @__PURE__ */ jsx("text", {
6421
- fg: funnel.faint,
6422
- children: props.label
6423
- })
6424
- });
6425
- }
6426
- //#endregion
6427
- //#region lib/tui/components/session-item.tsx
6428
- /** @jsxImportSource @opentui/react */
6429
- /** One connected WebSocket session — channel name + connector summary. */
6430
- function SessionItem(props) {
6431
- const theme = useHasciiTheme();
6432
- const { session } = props;
6433
- const summary = session.connectors.length === 0 ? "(no connectors)" : session.connectors.length === 1 ? session.connectors[0] : `${session.connectors.length} connectors`;
6434
- return /* @__PURE__ */ jsxs("box", {
6435
- style: {
6436
- flexDirection: "column",
6437
- paddingLeft: funnel.paddingX,
6438
- paddingRight: funnel.paddingX
6439
- },
6440
- children: [/* @__PURE__ */ jsx("text", {
6441
- fg: theme.color.foreground,
6442
- children: session.channel || "(unnamed)"
6443
- }), /* @__PURE__ */ jsx("text", {
6444
- fg: funnel.faint,
6445
- children: summary
6446
- })]
6447
- });
6448
- }
6449
- //#endregion
6450
- //#region lib/tui/components/session-list.tsx
6451
- /** @jsxImportSource @opentui/react */
6452
- /** Vertical list of connected sessions (Claude MCP clients) for the sidebar. */
6453
- function SessionList(props) {
6454
- if (props.sessions.length === 0) return /* @__PURE__ */ jsx("box", {
6455
- style: {
6456
- flexDirection: "row",
6457
- paddingLeft: funnel.paddingX,
6458
- paddingRight: funnel.paddingX
6459
- },
6460
- children: /* @__PURE__ */ jsx("text", {
6461
- fg: funnel.faint,
6462
- children: "(none)"
6463
- })
6464
- });
6465
- return /* @__PURE__ */ jsx("box", {
6466
- style: { flexDirection: "column" },
6467
- children: props.sessions.map((session, index) => /* @__PURE__ */ jsx(SessionItem, { session }, `${session.channel}-${index}`))
6468
- });
6469
- }
6470
- //#endregion
6471
- //#region lib/tui/components/ui/hascii/sidebar.tsx
6472
- /** Fixed-width vertical sidebar. Compose with HasciiSidebarHeader, HasciiSidebarContent, HasciiSidebarMenuItem. */
6473
- function HasciiSidebar(props) {
6474
- const theme = useHasciiTheme();
6475
- return /* @__PURE__ */ jsx("box", {
6476
- flexDirection: "column",
6477
- width: props.width ?? 24,
6478
- backgroundColor: theme.color.muted,
6479
- paddingTop: 1,
6480
- gap: 0,
6481
- children: props.children
6482
- });
6483
- }
6484
- //#endregion
6485
- //#region lib/tui/components/ui/hascii/sidebar-header.tsx
6486
- /** Top section of a HasciiSidebar. Renders children at the sidebar's text padding. */
6487
- function HasciiSidebarHeader(props) {
6488
- return /* @__PURE__ */ jsx("box", {
6489
- flexDirection: "column",
6490
- paddingLeft: 2,
6491
- paddingRight: 2,
6492
- paddingBottom: 1,
6493
- gap: 0,
6494
- children: props.children
6495
- });
6496
- }
6497
- //#endregion
6498
- //#region lib/tui/sidebar.tsx
6499
- /** @jsxImportSource @opentui/react */
6500
- /**
6501
- * Left rail built on hascii's HasciiSidebar shell.
6502
- *
6503
- * Sections (top → bottom): brand (header slot), gateway card, sessions,
6504
- * navigation menu. The funnel `Menu` items keep the left-edge `▏`
6505
- * primary accent and right-aligned counts that hascii's stock sidebar
6506
- * menu does not provide.
6507
- */
6508
- function Sidebar(props) {
6509
- return /* @__PURE__ */ jsxs(HasciiSidebar, {
6510
- width: funnel.sidebarWidth,
6511
- children: [
6512
- /* @__PURE__ */ jsx(HasciiSidebarHeader, { children: /* @__PURE__ */ jsx(Brand, {}) }),
6513
- /* @__PURE__ */ jsx(GatewayStatus, {
6514
- gateway: props.snapshot.gateway,
6515
- busy: props.busy,
6516
- onToggle: props.onToggleGateway
6517
- }),
6518
- /* @__PURE__ */ jsxs("box", {
6519
- style: { flexDirection: "column" },
6520
- children: [/* @__PURE__ */ jsx(SectionHeader, { label: "sessions" }), /* @__PURE__ */ jsx(SessionList, { sessions: props.snapshot.sessions })]
6521
- }),
6522
- /* @__PURE__ */ jsx(Menu, {
6523
- items: props.menuItems,
6524
- active: props.view,
6525
- onSelect: props.onSelect
6526
- })
6527
- ]
6528
- });
6529
- }
6530
- //#endregion
6531
- //#region lib/tui/use-event-stream.ts
6532
- const MAX_BUFFER = 200;
6533
- const RECONNECT_BASE_MS = 500;
6534
- const RECONNECT_MAX_MS = 8e3;
6535
- const eventPayloadSchema = z.object({
6536
- content: z.string(),
6537
- meta: z.record(z.string(), z.string()).optional(),
6538
- offset: z.number().int().nonnegative().optional()
6539
- });
6540
- /**
6541
- * Opens a `tap=all` WebSocket against the gateway daemon and accumulates
6542
- * received events in a ring buffer. Reconnects with capped exponential
6543
- * backoff. Returns `disabled` status until the daemon comes online.
6544
- *
6545
- * `token` is appended as `?token=` so the gateway accepts the upgrade.
6546
- */
6547
- const useEventStream = (port, daemonReachable, token) => {
6548
- const [events, setEvents] = useState([]);
6549
- const [status, setStatus] = useState("disabled");
6550
- const [resetTick, setResetTick] = useState(0);
6551
- useEffect(() => {
6552
- if (!daemonReachable) {
6553
- setStatus("disabled");
6554
- return;
6555
- }
6556
- let cancelled = false;
6557
- let socket = null;
6558
- let reconnectTimer = null;
6559
- let attempt = 0;
6560
- let nextId = events.length > 0 ? Math.max(...events.map((e) => e.id)) + 1 : 1;
6561
- let lastOffset = 0;
6562
- const connect = () => {
6563
- if (cancelled) return;
6564
- setStatus("connecting");
6565
- const sinceQuery = lastOffset > 0 ? `&since=${lastOffset}` : "";
6566
- const protocols = token ? [`funnel.token.${token}`] : void 0;
6567
- socket = new WebSocket(`ws://localhost:${port}/ws?tap=all${sinceQuery}`, protocols);
6568
- socket.addEventListener("open", () => {
6569
- if (cancelled) return;
6570
- attempt = 0;
6571
- setStatus("open");
6572
- });
6573
- socket.addEventListener("message", (event) => {
6574
- if (cancelled) return;
6575
- const raw = (() => {
6576
- try {
6577
- return JSON.parse(String(event.data));
6578
- } catch {
6579
- return null;
6580
- }
6581
- })();
6582
- const parsed = eventPayloadSchema.safeParse(raw);
6583
- if (!parsed.success) return;
6584
- if (typeof parsed.data.offset === "number") lastOffset = parsed.data.offset;
6585
- const next = {
6586
- id: nextId,
6587
- receivedAt: Date.now(),
6588
- content: parsed.data.content,
6589
- meta: parsed.data.meta ?? {}
6590
- };
6591
- nextId += 1;
6592
- setEvents((prev) => {
6593
- const merged = [next, ...prev];
6594
- return merged.length > MAX_BUFFER ? merged.slice(0, MAX_BUFFER) : merged;
6595
- });
6596
- });
6597
- socket.addEventListener("close", () => {
6598
- if (cancelled) return;
6599
- setStatus("closed");
6600
- attempt += 1;
6601
- const delay = Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
6602
- reconnectTimer = setTimeout(connect, delay);
6603
- });
6604
- socket.addEventListener("error", () => {});
6605
- };
6606
- connect();
6607
- return () => {
6608
- cancelled = true;
6609
- if (reconnectTimer) clearTimeout(reconnectTimer);
6610
- if (socket) socket.close();
6611
- };
6612
- }, [
6613
- port,
6614
- daemonReachable,
6615
- resetTick,
6616
- token
6617
- ]);
6618
- return {
6619
- events,
6620
- status,
6621
- reset: () => {
6622
- setEvents([]);
6623
- setResetTick((value) => value + 1);
6624
- }
6625
- };
6626
- };
6627
- //#endregion
6628
- //#region lib/tui/use-snapshot.ts
6629
- const POLL_INTERVAL_MS = 3e3;
6630
- const sessionSchema = z.object({
6631
- channel: z.string(),
6632
- connectors: z.array(z.string())
6633
- });
6634
- const statusResponseSchema = z.object({ clients: z.array(sessionSchema) });
6635
- const emptySnapshot = {
6636
- connectors: [],
6637
- channels: [],
6638
- profiles: [],
6639
- gateway: {
6640
- running: false,
6641
- pid: null,
6642
- port: 9742
6643
- },
6644
- listeners: [],
6645
- sessions: [],
6646
- daemonReachable: false,
6647
- refreshedAt: 0
6648
- };
6649
- const fetchSessions = async (port, daemonRunning, token) => {
6650
- if (!daemonRunning) return [];
6651
- try {
6652
- const headers = token ? { authorization: `Bearer ${token}` } : {};
6653
- const response = await fetch(`http://localhost:${port}/status`, { headers });
6654
- if (!response.ok) return [];
6655
- const parsed = statusResponseSchema.safeParse(await response.json());
6656
- if (!parsed.success) return [];
6657
- return parsed.data.clients.filter((client) => client.channel !== "*tap*");
6658
- } catch {
6659
- return [];
6660
- }
6661
- };
6662
- /**
6663
- * Polls Funnel state every few seconds. The returned `refresh` callback forces
6664
- * an immediate refetch — used by the manual `r` key and by listener-action
6665
- * keystrokes that change daemon state.
6666
- */
6667
- const useSnapshot = (funnel) => {
6668
- const [snapshot, setSnapshot] = useState(emptySnapshot);
6669
- const [tick, setTick] = useState(0);
6670
- useEffect(() => {
6671
- let cancelled = false;
6672
- const load = async () => {
6673
- const gateway = funnel.gateway.getStatus();
6674
- const token = funnel.gatewayToken.read();
6675
- const [listenersResult, sessions] = await Promise.all([funnel.listeners.list(), fetchSessions(gateway.port, gateway.running, token)]);
6676
- if (cancelled) return;
6677
- setSnapshot({
6678
- connectors: funnel.channels.listAllConnectors(),
6679
- channels: funnel.channels.list(),
6680
- profiles: funnel.profiles.list(),
6681
- gateway,
6682
- listeners: listenersResult.state === "ok" ? listenersResult.listeners : [],
6683
- sessions,
6684
- daemonReachable: listenersResult.state === "ok",
6685
- refreshedAt: Date.now()
6686
- });
6687
- };
6688
- load();
6689
- const timer = setInterval(() => void load(), POLL_INTERVAL_MS);
6690
- return () => {
6691
- cancelled = true;
6692
- clearInterval(timer);
6693
- };
6694
- }, [tick, funnel]);
6695
- return {
6696
- snapshot,
6697
- refresh: () => setTick((value) => value + 1)
6698
- };
6699
- };
6700
- //#endregion
6701
- //#region lib/tui/components/add-row.tsx
6702
- /** @jsxImportSource @opentui/react */
6703
- /**
6704
- * "+ add …" row used at the foot of an entity list.
6705
- *
6706
- * Bound to hascii's primary `HasciiButton` so AddRows match the gateway's
6707
- * start / stop affordance — same neutral-white CTA chip with darken-
6708
- * on-hover feel.
6709
- */
6710
- function AddRow(props) {
6711
- return /* @__PURE__ */ jsx(HasciiButton, {
6712
- onPress: props.onClick,
6713
- children: `+ ${props.label}`
6714
- });
6715
- }
6716
- //#endregion
6717
- //#region lib/tui/components/ui/hascii/card.tsx
6718
- /** Filled container that frames its children. Use with HasciiCardHeader, HasciiCardContent, and HasciiCardFooter. */
6719
- function HasciiCard(props) {
6720
- return /* @__PURE__ */ jsx("box", {
6721
- backgroundColor: useHasciiTheme().color.card,
6722
- flexDirection: "column",
6723
- paddingTop: 1,
6724
- paddingBottom: 1,
6725
- paddingLeft: 2,
6726
- paddingRight: 2,
6727
- gap: 0,
6728
- width: props.width,
6729
- children: props.children
6730
- });
6731
- }
6732
- //#endregion
6733
- //#region lib/tui/components/ui/hascii/card-footer.tsx
6734
- /** Horizontal footer region for action rows inside a HasciiCard. */
6735
- function HasciiCardFooter(props) {
6736
- return /* @__PURE__ */ jsx("box", {
6737
- flexDirection: "row",
6738
- justifyContent: "flex-end",
6739
- gap: 1,
6740
- children: props.children
6741
- });
6742
- }
6743
- //#endregion
6744
- //#region lib/tui/components/ui/hascii/card-header.tsx
6745
- /** Vertical heading group typically holding HasciiCardTitle and HasciiCardDescription. */
6746
- function HasciiCardHeader(props) {
6747
- return /* @__PURE__ */ jsx("box", {
6748
- flexDirection: "column",
6749
- gap: 0,
6750
- children: props.children
6751
- });
6752
- }
6753
- //#endregion
6754
- //#region lib/tui/components/ui/hascii/card-title.tsx
6755
- /** Primary title text inside a card header. */
6756
- function HasciiCardTitle(props) {
6757
- return /* @__PURE__ */ jsx("text", {
6758
- fg: useHasciiTheme().color.foreground,
6759
- children: props.children
6760
- });
6761
- }
6762
- //#endregion
6763
- //#region lib/tui/components/selection-accent.tsx
6764
- /** @jsxImportSource @opentui/react */
6765
- const RULE_LENGTH = 20;
6766
- /**
6767
- * A thin `▏` (U+258F) primary-colour rule pinned to the left edge of
6768
- * the parent box. Stacked tall enough to span any reasonable card or
6769
- * field-group height; rows past the parent's bottom edge are clipped.
6770
- *
6771
- * Use inside a `position: relative` parent to mark it as the "current"
6772
- * selection — same look as the sidebar `MenuItem` active accent.
6773
- */
6774
- function SelectionAccent() {
6775
- return /* @__PURE__ */ jsx("box", {
6776
- style: {
6777
- position: "absolute",
6778
- left: 0,
6779
- top: 0,
6780
- bottom: 0,
6781
- flexDirection: "column"
6782
- },
6783
- children: Array.from({ length: RULE_LENGTH }, (_, index) => /* @__PURE__ */ jsx("text", {
6784
- fg: funnel.primary,
6785
- children: "▏"
6786
- }, index))
6787
- });
6788
- }
6789
- //#endregion
6790
- //#region lib/tui/components/card.tsx
6791
- /**
6792
- * Per-entity form wrapper used inside the connectors / channels /
6793
- * profiles / listeners views.
6794
- *
6795
- * Composed from hascii's `HasciiCard` family — header + body + footer.
6796
- * `selected` overlays a `SelectionAccent` (▏ primary rule) at the left
6797
- * edge for cursor-driven views.
6798
- */
6799
- function Card(props) {
6800
- return /* @__PURE__ */ jsxs(HasciiCard, { children: [
6801
- props.selected ? /* @__PURE__ */ jsx(SelectionAccent, {}) : null,
6802
- /* @__PURE__ */ jsx(HasciiCardHeader, { children: /* @__PURE__ */ jsx(HasciiCardTitle, { children: props.title }) }),
6803
- props.children,
6804
- props.onDelete ? /* @__PURE__ */ jsx(HasciiCardFooter, { children: /* @__PURE__ */ jsx(HasciiButton, {
6805
- variant: "destructive",
6806
- size: "sm",
6807
- onPress: props.onDelete,
6808
- children: "delete"
6809
- }) }) : null
6810
- ] });
6811
- }
6812
- //#endregion
6813
- //#region lib/tui/utils/hascii/form-item-context.tsx
6814
- const FormItemContext = createContext(null);
6815
- /** Read the surrounding HasciiFormItem context. Returns null when called outside one. */
6816
- function useHasciiFormItem() {
6817
- return useContext(FormItemContext);
6818
- }
6819
- /** Provider used by HasciiFormItem to share the row's focus id with the wrapped HasciiInput. */
6820
- function HasciiFormItemProvider(props) {
6821
- return /* @__PURE__ */ jsx(FormItemContext.Provider, {
6822
- value: props.value,
6823
- children: props.children
6824
- });
6825
- }
6826
- //#endregion
6827
- //#region lib/tui/utils/hascii/input-focus-context.tsx
6828
- /** @jsxImportSource @opentui/react */
6829
- const InputFocusContext = createContext(null);
6830
- /** Returns the surrounding HasciiInputFocusProvider's API. Null when no provider is mounted. */
6831
- function useHasciiInputFocus() {
6832
- return useContext(InputFocusContext);
6833
- }
6834
- //#endregion
6835
- //#region lib/tui/components/ui/hascii/form-item.tsx
6836
- /** Horizontal form row: a fixed-width label on the left, the field on the right. The label background brightens while the wrapped HasciiInput is focused (requires HasciiInputFocusProvider in the tree). */
6837
- function HasciiFormItem(props) {
6838
- const labelWidth = props.labelWidth ?? 12;
6839
- const theme = useHasciiTheme();
6840
- const id = useId();
6841
- const isInputFocused = useHasciiInputFocus()?.focusedId === id;
6842
- const labelBg = isInputFocused ? theme.color.secondaryActive : theme.color.popover;
6843
- const labelFg = isInputFocused ? theme.color.foreground : theme.color.mutedForeground;
6844
- return /* @__PURE__ */ jsx(HasciiFormItemProvider, {
6845
- value: { focusId: id },
6846
- children: /* @__PURE__ */ jsxs("box", {
6847
- flexDirection: "row",
6848
- alignItems: "center",
6849
- children: [/* @__PURE__ */ jsx("box", {
6850
- width: labelWidth,
6851
- height: 3,
6852
- paddingLeft: 2,
6853
- paddingRight: 2,
6854
- alignItems: "flex-start",
6855
- justifyContent: "center",
6856
- backgroundColor: labelBg,
6857
- children: /* @__PURE__ */ jsx("text", {
6858
- fg: labelFg,
6859
- children: props.label
6860
- })
6861
- }), props.children]
6862
- })
6863
- });
6864
- }
6865
- //#endregion
6866
- //#region lib/tui/components/ui/hascii/input.tsx
6867
- /** Single-line text input. Click to focus, Esc / outside click to blur (requires HasciiInputFocusProvider for outside click). */
6868
- function HasciiInput(props) {
6869
- const variant = props.variant ?? "default";
6870
- const width = props.width ?? 32;
6871
- const placeholder = props.placeholder ?? "";
6872
- const fallbackId = useId();
6873
- const id = useHasciiFormItem()?.focusId ?? fallbackId;
6874
- const focusCtx = useHasciiInputFocus();
6875
- const fallbackState = useState(props.defaultFocused ?? false);
6876
- const isFocused = focusCtx ? focusCtx.focusedId === id : fallbackState[0];
6877
- const focus = () => {
6878
- if (focusCtx) focusCtx.setFocusedId(id);
6879
- else fallbackState[1](true);
6880
- };
6881
- const blur = () => {
6882
- if (focusCtx) focusCtx.setFocusedId(null);
6883
- else fallbackState[1](false);
6884
- };
6885
- const theme = useHasciiTheme();
6886
- const press = usePressable();
6887
- useKeyboard((key) => {
6888
- if (!isFocused) return;
6889
- if (key.name === "escape") blur();
6890
- });
6891
- if (variant === "outline") return /* @__PURE__ */ jsx("box", {
6892
- border: true,
6893
- borderStyle: "rounded",
6894
- borderColor: press.isPressed ? theme.color.foreground : isFocused ? theme.color.ring : press.isHovered ? theme.color.mutedForeground : theme.color.input,
6895
- height: 3,
6896
- width,
6897
- paddingLeft: 1,
6898
- paddingRight: 1,
6899
- backgroundColor: theme.color.background,
6900
- justifyContent: "center",
6901
- ...press.bind,
6902
- onMouseDown: (event) => {
6903
- event.stopPropagation();
6904
- press.bind.onMouseDown();
6905
- focus();
6906
- },
6907
- children: /* @__PURE__ */ jsx("input", {
6908
- focused: isFocused,
6909
- placeholder,
6910
- value: props.value,
6911
- textColor: theme.color.foreground,
6912
- placeholderColor: theme.color.mutedForeground,
6913
- cursorColor: theme.color.foreground,
6914
- onInput: props.onInput,
6915
- onChange: props.onChange
6916
- })
6917
- });
6918
- return /* @__PURE__ */ jsxs("box", {
6919
- height: 3,
6920
- width,
6921
- paddingLeft: 2,
6922
- paddingRight: 2,
6923
- backgroundColor: press.isPressed ? theme.color.secondaryActive : isFocused || press.isHovered ? theme.color.secondaryHover : theme.color.muted,
6924
- justifyContent: "center",
6925
- ...press.bind,
6926
- onMouseDown: (event) => {
6927
- event.stopPropagation();
6928
- press.bind.onMouseDown();
6929
- focus();
6930
- },
6931
- children: [/* @__PURE__ */ jsx("input", {
6932
- focused: isFocused,
6933
- placeholder,
6934
- value: props.value,
6935
- textColor: theme.color.foreground,
6936
- placeholderColor: theme.color.mutedForeground,
6937
- cursorColor: theme.color.foreground,
6938
- onInput: props.onInput,
6939
- onChange: props.onChange
6940
- }), isFocused ? /* @__PURE__ */ jsx("box", {
6941
- position: "absolute",
6942
- bottom: 0,
6943
- left: 0,
6944
- right: 0,
6945
- children: /* @__PURE__ */ jsx("text", {
6946
- fg: theme.color.primary,
6947
- children: "▁".repeat(width)
6948
- })
6949
- }) : null]
6950
- });
6951
- }
6952
- //#endregion
6953
- //#region lib/tui/components/editable-field.tsx
6954
- /**
6955
- * Inline label + input row built on hascii primitives. The hascii Input fires
6956
- * `onChange` on every keystroke; we forward that to `onCommit` so callers
6957
- * persist live (re-keying by `focused` still forces a remount on blur so the
6958
- * input snaps back when the user clicks away without typing).
6959
- */
6960
- function EditableField(props) {
6961
- return /* @__PURE__ */ jsx("box", {
6962
- style: { flexDirection: "row" },
6963
- onMouseDown: props.onFocus,
6964
- children: /* @__PURE__ */ jsx(HasciiFormItem, {
6965
- label: props.label,
6966
- labelWidth: 12,
6967
- children: /* @__PURE__ */ jsx(HasciiInput, {
6968
- value: props.initialValue,
6969
- placeholder: props.placeholder,
6970
- defaultFocused: props.focused,
6971
- onChange: props.onCommit
6972
- }, props.focused ? "focused" : "blurred")
6973
- })
6974
- });
6975
- }
6976
- //#endregion
6977
- //#region lib/tui/components/panel-header.tsx
6978
- /** @jsxImportSource @opentui/react */
6979
- /** Dim section label rendered at the top of every Panel. */
6980
- function PanelHeader(props) {
6981
- const theme = useHasciiTheme();
6982
- const text = props.count !== void 0 ? `${props.label} (${props.count})` : props.label;
6983
- return /* @__PURE__ */ jsxs("text", {
6984
- fg: theme.color.mutedForeground,
6985
- children: [text, props.hint ? /* @__PURE__ */ jsx("span", {
6986
- fg: funnel.faint,
6987
- children: ` · ${props.hint}`
6988
- }) : null]
6989
- });
6990
- }
6991
- //#endregion
6992
- //#region lib/tui/components/readonly-field.tsx
6993
- /** Static label + value row that mirrors the EditableField layout. */
6994
- function ReadonlyField(props) {
6995
- const theme = useHasciiTheme();
6996
- return /* @__PURE__ */ jsx(HasciiFormItem, {
6997
- label: props.label,
6998
- labelWidth: 12,
6999
- children: /* @__PURE__ */ jsx("text", {
7000
- fg: theme.color.foreground,
7001
- children: props.value
7002
- })
7003
- });
7004
- }
7005
- //#endregion
7006
- //#region lib/tui/scrollbar-options.ts
7007
- /**
7008
- * Shared OpenTUI scrollbar styling. Used by `ViewShell` and `DetailBar`
7009
- * so both scroll containers paint the track with the surrounding
7010
- * `surface` tone and the thumb in `mutedForeground` instead of OpenTUI's
7011
- * default electric blue.
7012
- *
7013
- * Takes the active hascii theme since scrollbar coloring depends on
7014
- * the same palette as everything else.
7015
- */
7016
- const verticalScrollbarOptions = (theme) => ({ trackOptions: {
7017
- backgroundColor: funnel.surface,
7018
- foregroundColor: theme.color.mutedForeground
7019
- } });
7020
- //#endregion
7021
- //#region lib/tui/components/view-shell.tsx
7022
- /**
7023
- * Outer wrapper every view renders into.
7024
- *
7025
- * Renders a vertical `<scrollbox>` so content that overflows the visible
7026
- * area scrolls instead of clipping the layout. Padding follows the
7027
- * uniform `funnel.paddingX/Y` rule and lives on the inner content
7028
- * container (via `contentOptions`); the outer scrollbox itself is
7029
- * transparent so multi-block views (`events` events list + DetailBar
7030
- * sibling) still stack cleanly.
7031
- *
7032
- * The vertical scrollbar's track and thumb pull from the theme so the
7033
- * widget reads as part of the surface palette instead of OpenTUI's
7034
- * default electric blue.
7035
- */
7036
- function ViewShell(props) {
7037
- const theme = useHasciiTheme();
7038
- return /* @__PURE__ */ jsx("scrollbox", {
7039
- style: { flexGrow: 1 },
7040
- contentOptions: {
7041
- flexDirection: "column",
7042
- paddingLeft: funnel.paddingX,
7043
- paddingRight: funnel.paddingX,
7044
- paddingTop: funnel.paddingY,
7045
- paddingBottom: funnel.paddingY,
7046
- gap: funnel.gap
7047
- },
7048
- verticalScrollbarOptions: verticalScrollbarOptions(theme),
7049
- children: props.children
7050
- });
7051
- }
7052
- //#endregion
7053
- //#region lib/tui/unique-name.ts
7054
- /**
7055
- * Pick the first `${prefix}-N` (N = 1, 2, …) that doesn't appear in the
7056
- * `existing` list.
7057
- *
7058
- * Used by the connectors / channels / profiles views to mint a unique
7059
- * default name when the user clicks "+ add". Stops at 10 000 to avoid a
7060
- * runaway loop if `existing` is full of placeholder names — falls back
7061
- * to `${prefix}-${Date.now()}` so the caller still gets a legal name.
7062
- */
7063
- function uniqueName(existing, prefix) {
7064
- for (let i = 1; i < 1e4; i += 1) {
7065
- const candidate = `${prefix}-${i}`;
7066
- if (!existing.includes(candidate)) return candidate;
7067
- }
7068
- return `${prefix}-${Date.now()}`;
7069
- }
7070
- //#endregion
7071
- //#region lib/tui/views/channels-view.tsx
7072
- /** @jsxImportSource @opentui/react */
7073
- const fieldKey$1 = (name, field) => `channels::${name}::${field}`;
7074
- /**
7075
- * Channel inspector — one Card per channel. Connectors live nested inside the
7076
- * channel and are managed in the connectors view; here only the channel's
7077
- * name and id (read-only) are shown along with a count of nested connectors.
7078
- */
7079
- function ChannelsView(props) {
7080
- const channels = props.snapshot.channels;
7081
- const commit = (channel, field, raw) => {
7082
- try {
7083
- if (field === "name") {
7084
- const next = raw.trim();
7085
- if (next && next !== channel.name) props.funnel.channels.rename(channel.name, next);
7086
- }
7087
- } catch (error) {
7088
- props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
7089
- }
7090
- props.setFocusedKey(null);
7091
- props.refresh();
7092
- };
7093
- const removeChannel = (name) => {
7094
- try {
7095
- props.funnel.channels.remove(name);
7096
- } catch (error) {
7097
- props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
7098
- }
7099
- props.setFocusedKey(null);
7100
- props.refresh();
7101
- };
7102
- const addChannel = () => {
7103
- const name = uniqueName(channels.map((c) => c.name), "channel");
7104
- try {
7105
- const created = props.funnel.channels.add({ name });
7106
- props.setFocusedKey(fieldKey$1(created.name, "name"));
7107
- } catch (error) {
7108
- props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
7109
- }
7110
- props.refresh();
7111
- };
7112
- return /* @__PURE__ */ jsxs(ViewShell, { children: [
7113
- /* @__PURE__ */ jsx(PanelHeader, {
7114
- label: "channels",
7115
- count: channels.length
7116
- }),
7117
- channels.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { message: "(none — use the button below to add one)" }) : channels.map((channel) => /* @__PURE__ */ jsxs(Card, {
7118
- title: channel.name,
7119
- onDelete: () => removeChannel(channel.name),
7120
- children: [
7121
- /* @__PURE__ */ jsx(EditableField, {
7122
- label: "name",
7123
- initialValue: channel.name,
7124
- focused: props.focusedKey === fieldKey$1(channel.name, "name"),
7125
- onFocus: () => props.setFocusedKey(fieldKey$1(channel.name, "name")),
7126
- onCommit: (raw) => commit(channel, "name", raw)
7127
- }),
7128
- /* @__PURE__ */ jsx(ReadonlyField, {
7129
- label: "id",
7130
- value: channel.id
7131
- }),
7132
- /* @__PURE__ */ jsx(ReadonlyField, {
7133
- label: "delivery",
7134
- value: channel.delivery
7135
- }),
7136
- /* @__PURE__ */ jsx(ReadonlyField, {
7137
- label: "connectors",
7138
- value: channel.connectors.length > 0 ? channel.connectors.map((c) => `${c.name}:${c.type}`).join(", ") : "(none)"
7139
- })
7140
- ]
7141
- }, channel.id)),
7142
- /* @__PURE__ */ jsx(AddRow, {
7143
- label: "add channel",
7144
- onClick: addChannel
7145
- })
7146
- ] });
7147
- }
7148
- //#endregion
7149
- //#region lib/tui/views/connectors-view.tsx
7150
- const tokenDisplay = (literal, envVar) => {
7151
- if (literal !== void 0 && literal !== "") return literal;
7152
- if (envVar !== void 0 && envVar !== "") return `env:${envVar}`;
7153
- return "—";
7154
- };
7155
- const formatTimestamp = (iso) => {
7156
- if (!iso) return "—";
7157
- const d = new Date(iso);
7158
- if (Number.isNaN(d.getTime())) return "—";
7159
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
7160
- };
7161
- /**
7162
- * Channel-scoped connector inspector. Reads `funnel.channels.listAllConnectors()`
7163
- * (already flattened with channelName / channelId tags) and lets the user delete
7164
- * each connector or quickly add a new one to the first available channel via the
7165
- * AddRow buttons. Editing values is intentionally read-only — token / pollInterval
7166
- * mutation belongs to `fnl channels <ch> connectors set <conn> ...` because the
7167
- * same connector name can exist in multiple channels and inline edits would have
7168
- * to disambiguate.
7169
- */
7170
- function ConnectorsView(props) {
7171
- const connectors = props.snapshot.connectors;
7172
- const targetChannel = props.snapshot.channels[0] ?? null;
7173
- const logError = (error) => {
7174
- props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
7175
- };
7176
- const removeConnector = (connector) => {
7177
- props.funnel.listeners.stop(connector.channelName, connector.name).catch(logError);
7178
- try {
7179
- props.funnel.channels.removeConnector(connector.channelName, connector.name);
7180
- } catch (error) {
7181
- logError(error);
7182
- }
7183
- props.refresh();
7184
- };
7185
- const addConnector = (type) => {
7186
- if (!targetChannel) {
7187
- logError(/* @__PURE__ */ new Error("add a channel first before creating a connector"));
7188
- return;
7189
- }
7190
- const name = uniqueName(connectors.filter((c) => c.channelId === targetChannel.id).map((c) => c.name), type);
7191
- try {
7192
- if (type === "slack") props.funnel.channels.addConnector(targetChannel.name, {
7193
- type: "slack",
7194
- name,
7195
- botToken: "xoxb-PLACEHOLDER",
7196
- appToken: "xapp-PLACEHOLDER"
7197
- });
7198
- else if (type === "gh") props.funnel.channels.addConnector(targetChannel.name, {
7199
- type: "gh",
7200
- name
7201
- });
7202
- else if (type === "discord") props.funnel.channels.addConnector(targetChannel.name, {
7203
- type: "discord",
7204
- name,
7205
- botToken: "PLACEHOLDER-PLACEHOLDER"
7206
- });
7207
- else props.funnel.channels.addConnector(targetChannel.name, {
7208
- type: "schedule",
7209
- name
7210
- });
7211
- props.funnel.listeners.start(targetChannel.name, name).catch(logError);
7212
- } catch (error) {
7213
- logError(error);
7214
- }
7215
- props.refresh();
7216
- };
7217
- return /* @__PURE__ */ jsxs(ViewShell, { children: [
7218
- /* @__PURE__ */ jsx(PanelHeader, {
7219
- label: "connectors",
7220
- count: connectors.length
7221
- }),
7222
- connectors.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { message: "(none — add via the buttons below or `fnl channels <ch> connectors add ...`)" }) : connectors.map((connector) => /* @__PURE__ */ jsxs(Card, {
7223
- title: connector.name,
7224
- children: [
7225
- /* @__PURE__ */ jsx(ReadonlyField, {
7226
- label: "channel",
7227
- value: connector.channelName
7228
- }),
7229
- /* @__PURE__ */ jsx(ReadonlyField, {
7230
- label: "type",
7231
- value: connector.type
7232
- }),
7233
- /* @__PURE__ */ jsx(ReadonlyField, {
7234
- label: "id",
7235
- value: connector.id
7236
- }),
7237
- connector.type === "slack" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ReadonlyField, {
7238
- label: "bot-token",
7239
- value: tokenDisplay(connector.botToken, connector.botTokenEnv)
7240
- }), /* @__PURE__ */ jsx(ReadonlyField, {
7241
- label: "app-token",
7242
- value: tokenDisplay(connector.appToken, connector.appTokenEnv)
7243
- })] }) : null,
7244
- connector.type === "gh" ? /* @__PURE__ */ jsx(ReadonlyField, {
7245
- label: "poll",
7246
- value: String(connector.pollInterval ?? 60)
7247
- }) : null,
7248
- connector.type === "discord" ? /* @__PURE__ */ jsx(ReadonlyField, {
7249
- label: "bot-token",
7250
- value: tokenDisplay(connector.botToken, connector.botTokenEnv)
7251
- }) : null,
7252
- connector.type === "schedule" ? /* @__PURE__ */ jsx(ReadonlyField, {
7253
- label: "entries",
7254
- value: String(connector.entries.length)
7255
- }) : null,
7256
- /* @__PURE__ */ jsx("text", {
7257
- fg: funnel.faint,
7258
- children: `created ${formatTimestamp(connector.createdAt)}`
7259
- }),
7260
- /* @__PURE__ */ jsxs("box", {
7261
- style: {
7262
- flexDirection: "row",
7263
- justifyContent: "space-between"
7264
- },
7265
- children: [/* @__PURE__ */ jsx("text", {
7266
- fg: funnel.faint,
7267
- children: `updated ${formatTimestamp(connector.updatedAt)}`
7268
- }), /* @__PURE__ */ jsx(HasciiButton, {
7269
- variant: "destructive",
7270
- size: "sm",
7271
- onPress: () => removeConnector(connector),
7272
- children: "delete"
7273
- })]
7274
- })
7275
- ]
7276
- }, `${connector.channelId}::${connector.id}`)),
7277
- targetChannel ? /* @__PURE__ */ jsx("text", {
7278
- fg: funnel.faint,
7279
- children: `add target channel: ${targetChannel.name}`
7280
- }) : /* @__PURE__ */ jsx("text", {
7281
- fg: funnel.warn,
7282
- children: "add a channel first to enable the buttons below"
7283
- }),
7284
- /* @__PURE__ */ jsx(AddRow, {
7285
- label: "add slack",
7286
- onClick: () => addConnector("slack")
7287
- }),
7288
- /* @__PURE__ */ jsx(AddRow, {
7289
- label: "add gh",
7290
- onClick: () => addConnector("gh")
7291
- }),
7292
- /* @__PURE__ */ jsx(AddRow, {
7293
- label: "add discord",
7294
- onClick: () => addConnector("discord")
7295
- }),
7296
- /* @__PURE__ */ jsx(AddRow, {
7297
- label: "add schedule",
7298
- onClick: () => addConnector("schedule")
7299
- })
7300
- ] });
7301
- }
7302
- //#endregion
7303
- //#region lib/tui/components/detail-bar.tsx
7304
- /**
7305
- * Bottom inspector strip rendered at the foot of a view.
7306
- *
7307
- * Sits as a sibling of `ViewShell` (not inside it) so its background
7308
- * stretches edge-to-edge across the main column and butts against the
7309
- * sidebar with no horizontal gap. Background tier is `elevated` —
7310
- * one step brighter than the sidebar — to read as a separate stratum
7311
- * without needing a border.
7312
- *
7313
- * The strip is itself a `<scrollbox>` so long content (e.g., JSON for
7314
- * the selected event) scrolls within the fixed `funnel.detailPanelHeight`
7315
- * frame instead of pushing other UI off-screen.
7316
- */
7317
- function DetailBar(props) {
7318
- const theme = useHasciiTheme();
7319
- return /* @__PURE__ */ jsx("scrollbox", {
7320
- style: {
7321
- height: funnel.detailPanelHeight,
7322
- backgroundColor: theme.color.muted
7323
- },
7324
- contentOptions: {
7325
- flexDirection: "column",
7326
- paddingLeft: funnel.paddingX,
7327
- paddingRight: funnel.paddingX,
7328
- paddingTop: funnel.paddingY,
7329
- paddingBottom: funnel.paddingY,
7330
- gap: funnel.gap
7331
- },
7332
- verticalScrollbarOptions: verticalScrollbarOptions(theme),
7333
- children: props.children
7334
- });
7335
- }
7336
- //#endregion
7337
- //#region lib/tui/components/keymap.tsx
7338
- /** @jsxImportSource @opentui/react */
7339
- /** Inline keymap row — used at the bottom of each interactive view. */
7340
- function Keymap(props) {
7341
- return /* @__PURE__ */ jsx("text", {
7342
- fg: useHasciiTheme().color.mutedForeground,
7343
- children: props.hints.map((hint, index) => /* @__PURE__ */ jsxs("span", { children: [
7344
- index > 0 ? /* @__PURE__ */ jsx("span", {
7345
- fg: funnel.faint,
7346
- children: " · "
7347
- }) : null,
7348
- /* @__PURE__ */ jsxs("span", {
7349
- fg: funnel.faint,
7350
- children: [hint.key, " "]
7351
- }),
7352
- hint.label
7353
- ] }, hint.key))
7354
- });
7355
- }
7356
- //#endregion
7357
- //#region lib/tui/views/events-view.tsx
7358
- /** @jsxImportSource @opentui/react */
7359
- const streamLabel = (status) => {
7360
- if (status === "open") return "live";
7361
- if (status === "connecting") return "connecting…";
7362
- if (status === "closed") return "reconnecting…";
7363
- return "offline";
7364
- };
7365
- const formatTime = (ms) => {
7366
- const date = new Date(ms);
7367
- return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
7368
- };
7369
- const truncate = (value, max) => {
7370
- const flat = value.replace(/\s+/g, " ").trim();
7371
- if (flat.length <= max) return flat;
7372
- return `${flat.slice(0, max - 1)}…`;
7373
- };
7374
- const matches = (event, filter) => {
7375
- if (!filter) return true;
7376
- const needle = filter.toLowerCase();
7377
- return [
7378
- event.content,
7379
- event.meta.connector ?? "",
7380
- event.meta.event_type ?? "",
7381
- event.meta.channel ?? ""
7382
- ].join(" ").toLowerCase().includes(needle);
7383
- };
7384
- const tryParseJson = (value) => {
7385
- try {
7386
- return JSON.parse(value);
7387
- } catch {
7388
- return value;
7389
- }
7390
- };
7391
- const formatJson = (value) => {
7392
- try {
7393
- return JSON.stringify(value, null, 2);
7394
- } catch {
7395
- return String(value);
7396
- }
7397
- };
7398
- /**
7399
- * Live event stream + detail of the selected event.
7400
- *
7401
- * The events list lives inside `ViewShell` (padded canvas) while the
7402
- * detail strip is a sibling `DetailBar` so its background spans the
7403
- * full main column edge-to-edge and reads as a distinct elevated
7404
- * stratum below the list.
7405
- */
7406
- function EventsView(props) {
7407
- const theme = useHasciiTheme();
7408
- const visible = props.events.filter((event) => matches(event, props.filter));
7409
- const selected = visible[props.selectedIndex] ?? null;
7410
- return /* @__PURE__ */ jsxs("box", {
7411
- style: {
7412
- flexDirection: "column",
7413
- flexGrow: 1
7414
- },
7415
- children: [/* @__PURE__ */ jsxs(ViewShell, { children: [
7416
- /* @__PURE__ */ jsx(PanelHeader, {
7417
- label: "events",
7418
- count: visible.length,
7419
- hint: [
7420
- streamLabel(props.streamStatus),
7421
- `${props.events.length} total`,
7422
- props.filter ? `/${props.filter}/` : null
7423
- ].filter((part) => part !== null).join(" · ")
7424
- }),
7425
- visible.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { message: "(no events yet — waiting for the first one)" }) : visible.map((event, index) => {
7426
- const isSelected = index === props.selectedIndex;
7427
- const connector = event.meta.connector ?? "system";
7428
- const eventType = event.meta.event_type ?? "?";
7429
- return /* @__PURE__ */ jsxs("text", {
7430
- bg: isSelected ? theme.color.muted : void 0,
7431
- children: [
7432
- /* @__PURE__ */ jsx("span", {
7433
- fg: theme.color.mutedForeground,
7434
- children: formatTime(event.receivedAt)
7435
- }),
7436
- /* @__PURE__ */ jsx("span", {
7437
- fg: funnel.faint,
7438
- children: " "
7439
- }),
7440
- /* @__PURE__ */ jsx("span", {
7441
- fg: theme.color.mutedForeground,
7442
- children: eventType.padEnd(8)
7443
- }),
7444
- /* @__PURE__ */ jsx("span", {
7445
- fg: funnel.faint,
7446
- children: " · "
7447
- }),
7448
- /* @__PURE__ */ jsx("span", {
7449
- fg: isSelected ? theme.color.foreground : theme.color.foreground,
7450
- children: connector.padEnd(14)
7451
- }),
7452
- /* @__PURE__ */ jsx("span", {
7453
- fg: funnel.faint,
7454
- children: " "
7455
- }),
7456
- /* @__PURE__ */ jsx("span", {
7457
- fg: isSelected ? theme.color.foreground : theme.color.mutedForeground,
7458
- children: truncate(event.content, 80)
7459
- })
7460
- ]
7461
- }, event.id);
7462
- }),
7463
- /* @__PURE__ */ jsx(Keymap, { hints: [{
7464
- key: "j/k",
7465
- label: "select"
7466
- }, {
7467
- key: "/",
7468
- label: "filter"
7469
- }] })
7470
- ] }), /* @__PURE__ */ jsxs(DetailBar, { children: [/* @__PURE__ */ jsx(PanelHeader, { label: "detail" }), !selected ? /* @__PURE__ */ jsx(EmptyState, { message: "(select an event with j/k to inspect)" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
7471
- /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
7472
- fg: theme.color.mutedForeground,
7473
- children: "meta: "
7474
- }), /* @__PURE__ */ jsx("span", {
7475
- fg: theme.color.foreground,
7476
- children: Object.entries(selected.meta).map(([key, value]) => `${key}=${value}`).join(" ")
7477
- })] }),
7478
- /* @__PURE__ */ jsx(HasciiSeparator, {}),
7479
- /* @__PURE__ */ jsx("text", {
7480
- fg: theme.color.foreground,
7481
- children: formatJson(tryParseJson(selected.content))
7482
- })
7483
- ] })] })]
7484
- });
7485
- }
7486
- //#endregion
7487
- //#region lib/tui/views/listeners-view.tsx
7488
- /** @jsxImportSource @opentui/react */
7489
- const eventCountBy = (events, connectorName) => {
7490
- let count = 0;
7491
- for (const event of events) if (event.meta.connector === connectorName) count += 1;
7492
- return count;
7493
- };
7494
- /**
7495
- * Listener registry — one Card per listener. The Card title shows the
7496
- * listener's name; inside, a single status line carries the alive
7497
- * dot, the connector type, and the event count. Cursor selection is
7498
- * shown via the Card's `selected` accent. Listeners are runtime
7499
- * entities derived from connectors, so there is no add path here —
7500
- * register / remove a connector instead.
7501
- */
7502
- function ListenersView(props) {
7503
- const theme = useHasciiTheme();
7504
- const listeners = props.snapshot.listeners;
7505
- return /* @__PURE__ */ jsxs(ViewShell, { children: [
7506
- /* @__PURE__ */ jsx(PanelHeader, {
7507
- label: "listeners",
7508
- count: listeners.length,
7509
- hint: props.busy ? "working…" : void 0
7510
- }),
7511
- !props.snapshot.daemonReachable ? /* @__PURE__ */ jsx(EmptyState, { message: "(gateway daemon offline — press G to start it)" }) : listeners.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { message: "(no listeners — register a connector first)" }) : listeners.map((entry, index) => {
7512
- const aliveColor = entry.alive ? funnel.alive : funnel.dead;
7513
- const count = eventCountBy(props.events, entry.name);
7514
- return /* @__PURE__ */ jsx(Card, {
7515
- title: entry.name,
7516
- selected: index === props.selectedIndex,
7517
- children: /* @__PURE__ */ jsxs("text", { children: [
7518
- /* @__PURE__ */ jsx("span", {
7519
- fg: aliveColor,
7520
- children: entry.alive ? "●" : "○"
7521
- }),
7522
- /* @__PURE__ */ jsx("span", {
7523
- fg: funnel.faint,
7524
- children: " "
7525
- }),
7526
- /* @__PURE__ */ jsx("span", {
7527
- fg: theme.color.mutedForeground,
7528
- children: entry.type
7529
- }),
7530
- count > 0 ? /* @__PURE__ */ jsx("span", {
7531
- fg: theme.color.mutedForeground,
7532
- children: ` ${count}↓`
7533
- }) : null
7534
- ] })
7535
- }, entry.name);
7536
- }),
7537
- /* @__PURE__ */ jsx(Keymap, { hints: [
7538
- {
7539
- key: "j/k",
7540
- label: "select"
7541
- },
7542
- {
7543
- key: "s",
7544
- label: "start"
7545
- },
7546
- {
7547
- key: "x",
7548
- label: "stop"
7549
- },
7550
- {
7551
- key: "R",
7552
- label: "restart"
7553
- }
7554
- ] })
7555
- ] });
7556
- }
7557
- //#endregion
7558
- //#region lib/tui/views/profiles-view.tsx
7559
- /** @jsxImportSource @opentui/react */
7560
- const fieldKey = (name, field) => `profiles::${name}::${field}`;
7561
- /**
7562
- * Profile list — one Card per profile. Selection (j/k cursor) shows the
7563
- * `▏` primary rule via the Card's `selected` prop; pressing `c`
7564
- * launches Claude Code with the selected profile.
7565
- *
7566
- * `+ add profile` at the foot creates a new profile pointed at the
7567
- * first existing channel (or an empty string if there are none, which
7568
- * the user must then edit before launching).
7569
- */
7570
- function ProfilesView(props) {
7571
- const profiles = props.snapshot.profiles;
7572
- const channels = props.snapshot.channels;
7573
- const commit = (profile, field, raw) => {
7574
- try {
7575
- if (field === "name") {
7576
- const next = raw.trim();
7577
- if (next && next !== profile.name) props.funnel.profiles.rename(profile.name, next);
7578
- } else if (field === "channel") {
7579
- const next = raw.trim();
7580
- if (next) props.funnel.profiles.update(profile.name, { channelId: next });
7581
- } else if (field === "path") {
7582
- const next = raw.trim();
7583
- if (next) props.funnel.profiles.update(profile.name, { path: next });
7584
- }
7585
- } catch (error) {
7586
- props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
7587
- }
7588
- props.setFocusedKey(null);
7589
- props.refresh();
7590
- };
7591
- const removeProfile = (name) => {
7592
- try {
7593
- props.funnel.profiles.remove(name);
7594
- } catch (error) {
7595
- props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
7596
- }
7597
- props.setFocusedKey(null);
7598
- props.refresh();
7599
- };
7600
- const addProfile = () => {
7601
- const name = uniqueName(profiles.map((p) => p.name), "profile");
7602
- const channelId = channels[0]?.name ?? "";
7603
- try {
7604
- props.funnel.profiles.add({
7605
- name,
7606
- path: "",
7607
- channelId
7608
- });
7609
- props.setFocusedKey(fieldKey(name, "name"));
7610
- } catch (error) {
7611
- props.funnel.logger?.error(error instanceof Error ? error.message : String(error));
7612
- }
7613
- props.refresh();
7614
- };
7615
- return /* @__PURE__ */ jsxs(ViewShell, { children: [
7616
- /* @__PURE__ */ jsx(PanelHeader, {
7617
- label: "profiles",
7618
- count: profiles.length
7619
- }),
7620
- profiles.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { message: "(none — use the button below to add one)" }) : profiles.map((profile, index) => /* @__PURE__ */ jsxs(Card, {
7621
- title: profile.name,
7622
- selected: index === props.selectedIndex,
7623
- onDelete: () => removeProfile(profile.name),
7624
- children: [
7625
- /* @__PURE__ */ jsx(EditableField, {
7626
- label: "name",
7627
- initialValue: profile.name,
7628
- focused: props.focusedKey === fieldKey(profile.name, "name"),
7629
- onFocus: () => props.setFocusedKey(fieldKey(profile.name, "name")),
7630
- onCommit: (raw) => commit(profile, "name", raw)
7631
- }),
7632
- /* @__PURE__ */ jsx(EditableField, {
7633
- label: "path",
7634
- initialValue: profile.path,
7635
- focused: props.focusedKey === fieldKey(profile.name, "path"),
7636
- onFocus: () => props.setFocusedKey(fieldKey(profile.name, "path")),
7637
- onCommit: (raw) => commit(profile, "path", raw),
7638
- placeholder: "repository path"
7639
- }),
7640
- /* @__PURE__ */ jsx(EditableField, {
7641
- label: "channel",
7642
- initialValue: profile.channelId,
7643
- focused: props.focusedKey === fieldKey(profile.name, "channel"),
7644
- onFocus: () => props.setFocusedKey(fieldKey(profile.name, "channel")),
7645
- onCommit: (raw) => commit(profile, "channel", raw)
7646
- })
7647
- ]
7648
- }, profile.name)),
7649
- /* @__PURE__ */ jsx(AddRow, {
7650
- label: "add profile",
7651
- onClick: addProfile
7652
- }),
7653
- /* @__PURE__ */ jsx(Keymap, { hints: [{
7654
- key: "j/k",
7655
- label: "select"
7656
- }, {
7657
- key: "c",
7658
- label: "launch"
7659
- }] })
7660
- ] });
7661
- }
7662
- //#endregion
7663
- //#region lib/tui/app.tsx
7664
- /** @jsxImportSource @opentui/react */
7665
- const VIEW_KEYS = [
7666
- "events",
7667
- "connectors",
7668
- "channels",
7669
- "profiles",
7670
- "listeners"
7671
- ];
7672
- const clamp = (value, length) => {
7673
- if (length === 0) return 0;
7674
- if (value < 0) return 0;
7675
- if (value >= length) return length - 1;
7676
- return value;
7677
- };
7678
- const matchesEventFilter = (event, filter) => {
7679
- if (!filter) return true;
7680
- const needle = filter.toLowerCase();
7681
- return [
7682
- event.content,
7683
- event.meta.connector ?? "",
7684
- event.meta.event_type ?? "",
7685
- event.meta.channel ?? ""
7686
- ].join(" ").toLowerCase().includes(needle);
7687
- };
7688
- /**
7689
- * Funnel TUI: side-rail navigation + main content panel.
7690
- *
7691
- * Default landing view is the live `events` log. The sidebar shows the
7692
- * gateway state, currently connected sessions, and a navigation menu
7693
- * where each row carries the live count of its underlying entity.
7694
- */
7695
- function App(props) {
7696
- const theme = useHasciiTheme();
7697
- const renderer = useRenderer();
7698
- const { snapshot, refresh } = useSnapshot(props.funnel);
7699
- const { events, status: streamStatus } = useEventStream(snapshot.gateway.port, snapshot.daemonReachable, props.funnel.gatewayToken.read());
7700
- const [view, setViewState] = useState("events");
7701
- const [mode, setMode] = useState("browse");
7702
- const [listenerCursor, setListenerCursor] = useState(0);
7703
- const [profileCursor, setProfileCursor] = useState(0);
7704
- const [eventCursor, setEventCursor] = useState(0);
7705
- const [filter, setFilter] = useState("");
7706
- const [filterDraft, setFilterDraft] = useState("");
7707
- const [busy, setBusy] = useState(false);
7708
- const [editFocus, setEditFocus] = useState(null);
7709
- const isEditing = editFocus !== null;
7710
- const setView = (next) => {
7711
- setViewState(next);
7712
- setEditFocus(null);
7713
- };
7714
- const visibleEventCount = events.filter((event) => matchesEventFilter(event, filter)).length;
7715
- const menuItems = [
7716
- {
7717
- view: "events",
7718
- label: "events",
7719
- count: events.length
7720
- },
7721
- {
7722
- view: "connectors",
7723
- label: "connectors",
7724
- count: snapshot.connectors.length
7725
- },
7726
- {
7727
- view: "channels",
7728
- label: "channels",
7729
- count: snapshot.channels.length
7730
- },
7731
- {
7732
- view: "profiles",
7733
- label: "profiles",
7734
- count: snapshot.profiles.length
7735
- },
7736
- {
7737
- view: "listeners",
7738
- label: "listeners",
7739
- count: snapshot.listeners.length
7740
- }
7741
- ];
7742
- const moveCursor = (delta) => {
7743
- if (view === "listeners") setListenerCursor((prev) => clamp(prev + delta, snapshot.listeners.length));
7744
- else if (view === "profiles") setProfileCursor((prev) => clamp(prev + delta, snapshot.profiles.length));
7745
- else if (view === "events") setEventCursor((prev) => clamp(prev + delta, visibleEventCount));
7746
- };
7747
- const runListenerAction = async (action) => {
7748
- const entry = snapshot.listeners[listenerCursor];
7749
- if (!entry || busy) return;
7750
- setBusy(true);
7751
- if (action === "start") await props.funnel.listeners.start(entry.channelName, entry.name);
7752
- if (action === "stop") await props.funnel.listeners.stop(entry.channelName, entry.name);
7753
- if (action === "restart") await props.funnel.listeners.restart(entry.channelName, entry.name);
7754
- setBusy(false);
7755
- refresh();
7756
- };
7757
- const launchProfile = async () => {
7758
- const profile = snapshot.profiles[profileCursor];
7759
- if (!profile) return;
7760
- renderer.destroy();
7761
- try {
7762
- await props.funnel.claude.launch({
7763
- channel: profile.channelId,
7764
- cwd: profile.path,
7765
- profileId: profile.id,
7766
- options: profile.options,
7767
- env: profile.env,
7768
- resume: profile.resume
7769
- });
7770
- } catch (error) {
7771
- process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`);
7772
- process.exit(1);
7773
- }
7774
- process.exit(0);
7775
- };
7776
- const toggleGateway = async () => {
7777
- if (busy) return;
7778
- setBusy(true);
7779
- if (snapshot.gateway.running) await props.funnel.gateway.stop();
7780
- else await props.funnel.gateway.start();
7781
- setBusy(false);
7782
- refresh();
7783
- };
7784
- useKeyboard((key) => {
7785
- if (isEditing) {
7786
- if (key.name === "escape") setEditFocus(null);
7787
- return;
7788
- }
7789
- if (mode === "filter") {
7790
- if (key.name === "return") {
7791
- setFilter(filterDraft);
7792
- setEventCursor(0);
7793
- setMode("browse");
7794
- return;
7795
- }
7796
- if (key.name === "escape") {
7797
- setFilterDraft(filter);
7798
- setMode("browse");
7799
- return;
7800
- }
7801
- if (key.name === "backspace") {
7802
- setFilterDraft((prev) => prev.slice(0, -1));
7803
- return;
7804
- }
7805
- if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
7806
- const ch = key.sequence;
7807
- if (ch >= " " && ch <= "~") setFilterDraft((prev) => prev + ch);
7808
- }
7809
- return;
7810
- }
7811
- if (mode === "profile-launcher") {
7812
- if (key.name === "escape" || key.ctrl && key.name === "c") {
7813
- setMode("browse");
7814
- return;
7815
- }
7816
- if (key.name === "up" || key.name === "k") {
7817
- setProfileCursor((prev) => clamp(prev - 1, snapshot.profiles.length));
7818
- return;
7819
- }
7820
- if (key.name === "down" || key.name === "j") {
7821
- setProfileCursor((prev) => clamp(prev + 1, snapshot.profiles.length));
7822
- return;
7823
- }
7824
- if (key.name === "return") launchProfile();
7825
- return;
7826
- }
7827
- if (key.name === "q" || key.name === "escape" || key.ctrl && key.name === "c") {
7828
- renderer.destroy();
7829
- return;
7830
- }
7831
- const numericIndex = Number.parseInt(key.name ?? "", 10);
7832
- const target = Number.isFinite(numericIndex) ? VIEW_KEYS[numericIndex - 1] : void 0;
7833
- if (target) {
7834
- setView(target);
7835
- return;
7836
- }
7837
- if (key.name === "j" || key.name === "down") {
7838
- moveCursor(1);
7839
- return;
7840
- }
7841
- if (key.name === "k" || key.name === "up") {
7842
- moveCursor(-1);
7843
- return;
7844
- }
7845
- if (key.name === "r") {
7846
- refresh();
7847
- return;
7848
- }
7849
- if (key.name === "/" && view === "events") {
7850
- setFilterDraft(filter);
7851
- setMode("filter");
7852
- return;
7853
- }
7854
- if (key.name === "c") {
7855
- if (snapshot.profiles.length === 0) return;
7856
- setProfileCursor((prev) => Math.min(prev, Math.max(0, snapshot.profiles.length - 1)));
7857
- setMode("profile-launcher");
7858
- return;
7859
- }
7860
- if (view === "listeners") {
7861
- if (key.name === "s") {
7862
- runListenerAction("start");
7863
- return;
7864
- }
7865
- if (key.name === "x") {
7866
- runListenerAction("stop");
7867
- return;
7868
- }
7869
- if (key.name === "R" || key.shift && key.name === "r") {
7870
- runListenerAction("restart");
7871
- return;
7872
- }
7873
- }
7874
- if (key.name === "G" || key.shift && key.name === "g") toggleGateway();
7875
- });
7876
- return /* @__PURE__ */ jsxs("box", {
7877
- style: {
7878
- width: "100%",
7879
- height: "100%",
7880
- backgroundColor: theme.color.background,
7881
- flexDirection: "column"
7882
- },
7883
- children: [
7884
- /* @__PURE__ */ jsxs("box", {
7885
- style: {
7886
- flexDirection: "row",
7887
- flexGrow: 1
7888
- },
7889
- children: [/* @__PURE__ */ jsx(Sidebar, {
7890
- snapshot,
7891
- menuItems,
7892
- view,
7893
- onSelect: setView,
7894
- busy,
7895
- onToggleGateway: () => void toggleGateway()
7896
- }), /* @__PURE__ */ jsx("box", {
7897
- style: {
7898
- flexDirection: "column",
7899
- flexGrow: 1,
7900
- backgroundColor: theme.color.background
7901
- },
7902
- children: view === "events" ? /* @__PURE__ */ jsx(EventsView, {
7903
- events,
7904
- filter,
7905
- selectedIndex: eventCursor,
7906
- streamStatus
7907
- }) : view === "connectors" ? /* @__PURE__ */ jsx(ConnectorsView, {
7908
- snapshot,
7909
- funnel: props.funnel,
7910
- refresh,
7911
- focusedKey: editFocus,
7912
- setFocusedKey: setEditFocus
7913
- }) : view === "channels" ? /* @__PURE__ */ jsx(ChannelsView, {
7914
- snapshot,
7915
- funnel: props.funnel,
7916
- refresh,
7917
- focusedKey: editFocus,
7918
- setFocusedKey: setEditFocus
7919
- }) : view === "profiles" ? /* @__PURE__ */ jsx(ProfilesView, {
7920
- snapshot,
7921
- selectedIndex: profileCursor,
7922
- funnel: props.funnel,
7923
- refresh,
7924
- focusedKey: editFocus,
7925
- setFocusedKey: setEditFocus
7926
- }) : /* @__PURE__ */ jsx(ListenersView, {
7927
- snapshot,
7928
- events,
7929
- selectedIndex: listenerCursor,
7930
- busy
7931
- })
7932
- })]
7933
- }),
7934
- /* @__PURE__ */ jsx(FilterInput, {
7935
- value: filterDraft,
7936
- active: mode === "filter"
7937
- }),
7938
- /* @__PURE__ */ jsx(ProfileLauncher, {
7939
- active: mode === "profile-launcher",
7940
- profiles: snapshot.profiles,
7941
- selectedIndex: profileCursor
7942
- })
7943
- ]
7944
- });
7945
- }
7946
- //#endregion
7947
- //#region lib/tui/tui.tsx
7948
- /** @jsxImportSource @opentui/react */
7949
- async function launchTui(funnel) {
7950
- const renderer = await createCliRenderer();
7951
- createRoot(renderer).render(/* @__PURE__ */ jsx(HasciiThemeProvider, { children: /* @__PURE__ */ jsx(App, { funnel }) }));
7952
- await new Promise((resolve) => {
7953
- renderer.once("destroy", () => resolve());
7954
- });
7955
- }
7956
- //#endregion
7957
- export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, launchTui, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };
5748
+ export { CONNECTOR_CONNECTION_STATUSES, ConnectorDiagnosticLog, ConnectorDiagnosticSqlReader, DEFAULT_GATEWAY_TOKEN_PATH, FUNNEL_DIR, FUNNEL_MCP_COMMAND, FUNNEL_MCP_NAME, Funnel, FunnelBroadcaster, FunnelChannelPublisher, FunnelChannels, FunnelClaude, FunnelClock, FunnelConnectorFactory, FunnelConnectorListener, FunnelEventLog, FunnelFileSystem, FunnelGateway, FunnelGatewayServer, FunnelGatewayToken, FunnelIdGenerator, FunnelListenerSupervisor, FunnelListenersClient, FunnelLocalConfig, FunnelLocalConfigSync, FunnelLocalConfigWriter, FunnelLogger, FunnelMcp, FunnelProcessRunner, FunnelProfiles, FunnelSettingsReader, FunnelSettingsStore, FunnelSlackEventProcessor, FunnelTokenPrompter, LOCAL_CONFIG_FILENAME, MemoryConnectorDiagnosticLog, MemoryFunnelClock, MemoryFunnelEventLog, MemoryFunnelFileSystem, MemoryFunnelIdGenerator, MemoryFunnelLogger, MemoryFunnelProcessRunner, MemoryFunnelTokenPrompter, MockFunnelSettingsReader, NodeFunnelClock, NodeFunnelFileSystem, NodeFunnelIdGenerator, NodeFunnelLogger, NodeFunnelProcessRunner, NodeFunnelTokenPrompter, NoopFunnelLogger, SETTINGS_PATH, SETTINGS_VERSION, SqliteConnectorDiagnosticLog, SqliteFunnelEventLog, channelConfigSchema, channelDeliveryModeSchema, channelSpecSchema, app as cliApp, connectorConfigSchema, connectorConnectionEventSchema, connectorProcessedEventSchema, connectorRawEventSchema, connectorSpecSchema, createCliApp, createSettings, discordConnectorSchema, factory, funnelEventSchema, funnelJsonSchema, ghConnectorSchema, localConfigSchema, profileConfigSchema, profileSpecSchema, publishRequestSchema, publishResponseSchema, queryToCliArgs, resolveFunnelDir, scheduleCatchupPolicySchema, scheduleConnectorSchema, scheduleEntrySchema, settingsSchema, slackConnectorSchema, startChannelServer, toRequest };