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