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