@elefunc/send 0.1.7 → 0.1.9

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +75 -50
  3. package/src/tui/app.ts +221 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -37,11 +37,45 @@ const waitPeerTimeout = (value: unknown) => {
37
37
  if (!Number.isFinite(parsed) || parsed < 0) throw new ExitError("--wait-peer must be a finite non-negative number of milliseconds", 1)
38
38
  return parsed
39
39
  }
40
+ const BINARY_OPTION_FLAGS = {
41
+ clean: "--clean",
42
+ accept: "--accept",
43
+ offer: "--offer",
44
+ save: "--save",
45
+ } as const
46
+ type BinaryOptionKey = keyof typeof BINARY_OPTION_FLAGS
47
+ const binaryOption = (value: unknown, flag: string) => {
48
+ if (value == null) return undefined
49
+ if (value === 1 || value === "1") return true
50
+ if (value === 0 || value === "0") return false
51
+ throw new ExitError(`${flag} must be 0 or 1`, 1)
52
+ }
53
+ const parseBinaryOptions = <K extends BinaryOptionKey>(options: Record<string, unknown>, keys: readonly K[]) =>
54
+ Object.fromEntries(keys.map(key => [key, binaryOption(options[key], BINARY_OPTION_FLAGS[key])])) as Record<K, boolean | undefined>
40
55
 
41
56
  const SELF_ID_LENGTH = 8
42
57
  const SELF_ID_PATTERN = new RegExp(`^[a-z0-9]{${SELF_ID_LENGTH}}$`)
43
58
  const SELF_HELP_TEXT = "self identity: name, name-ID, or -ID (use --self=-ID)"
44
59
  const INVALID_SELF_ID_MESSAGE = `--self ID suffix must be exactly ${SELF_ID_LENGTH} lowercase alphanumeric characters`
60
+ type CliCommand = ReturnType<CAC["command"]>
61
+ const ROOM_SELF_OPTIONS = [
62
+ ["--room <room>", "room id; omit to create a random room"],
63
+ ["--self <self>", SELF_HELP_TEXT],
64
+ ] as const
65
+ const TURN_OPTIONS = [
66
+ ["--turn-url <url>", "custom TURN url, repeat or comma-separate"],
67
+ ["--turn-username <value>", "custom TURN username"],
68
+ ["--turn-credential <value>", "custom TURN credential"],
69
+ ] as const
70
+ const SAVE_DIR_OPTION = ["--save-dir <dir>", "save directory"] as const
71
+ const TUI_TOGGLE_OPTIONS = [
72
+ ["--clean <0|1>", "show only active peers when 1; show terminal peers too when 0"],
73
+ ["--accept <0|1>", "auto-accept incoming offers: 1 on, 0 off"],
74
+ ["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off"],
75
+ ["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off"],
76
+ ] as const
77
+ const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
78
+ definitions.reduce((next, [flag, description]) => next.option(flag, description), command)
45
79
 
46
80
  const requireSelfId = (value: string) => {
47
81
  if (!SELF_ID_PATTERN.test(value)) throw new ExitError(INVALID_SELF_ID_MESSAGE, 1)
@@ -61,12 +95,13 @@ const parseSelfOption = (value: unknown): Pick<SessionConfig, "name" | "localId"
61
95
  export const sessionConfigFrom = (options: Record<string, unknown>, defaults: { autoAcceptIncoming?: boolean; autoSaveIncoming?: boolean }): SessionConfig & { room: string } => {
62
96
  const room = cleanRoom(firstNonEmptyText(options.room, process.env.SEND_ROOM))
63
97
  const self = parseSelfOption(options.self ?? process.env.SEND_SELF)
98
+ const { accept, save } = parseBinaryOptions(options, ["accept", "save"] as const)
64
99
  return {
65
100
  room,
66
101
  ...self,
67
102
  saveDir: resolve(`${options.saveDir ?? process.env.SEND_SAVE_DIR ?? "downloads"}`),
68
- autoAcceptIncoming: defaults.autoAcceptIncoming ?? false,
69
- autoSaveIncoming: defaults.autoSaveIncoming ?? false,
103
+ autoAcceptIncoming: accept ?? defaults.autoAcceptIncoming ?? false,
104
+ autoSaveIncoming: save ?? defaults.autoSaveIncoming ?? false,
70
105
  turnUrls: splitList(options.turnUrl ?? process.env.SEND_TURN_URL),
71
106
  turnUsername: `${options.turnUsername ?? process.env.SEND_TURN_USERNAME ?? ""}`.trim() || undefined,
72
107
  turnCredential: `${options.turnCredential ?? process.env.SEND_TURN_CREDENTIAL ?? ""}`.trim() || undefined,
@@ -218,8 +253,13 @@ const acceptCommand = async (options: Record<string, unknown>) => {
218
253
 
219
254
  const tuiCommand = async (options: Record<string, unknown>) => {
220
255
  const initialConfig = sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true })
256
+ const { clean, offer } = parseBinaryOptions(options, ["clean", "offer"] as const)
221
257
  const { startTui } = await loadTuiRuntime()
222
- await startTui(initialConfig, !!options.events)
258
+ await startTui(initialConfig, {
259
+ events: !!options.events,
260
+ clean: clean ?? true,
261
+ offer: offer ?? true,
262
+ })
223
263
  }
224
264
 
225
265
  type CliHandlers = {
@@ -240,53 +280,38 @@ export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
240
280
  const cli = cac("send")
241
281
  cli.usage("[command] [options]")
242
282
 
243
- cli
244
- .command("peers", "list discovered peers")
245
- .option("--room <room>", "room id; omit to create a random room")
246
- .option("--self <self>", SELF_HELP_TEXT)
247
- .option("--wait <ms>", "discovery wait in milliseconds")
248
- .option("--json", "print a json snapshot")
249
- .option("--save-dir <dir>", "save directory")
250
- .option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
251
- .option("--turn-username <value>", "custom TURN username")
252
- .option("--turn-credential <value>", "custom TURN credential")
253
- .action(handlers.peers)
254
-
255
- cli
256
- .command("offer [...files]", "offer files to browser-compatible peers")
257
- .option("--room <room>", "room id; omit to create a random room")
258
- .option("--self <self>", SELF_HELP_TEXT)
259
- .option("--to <peer>", "target peer id or name-suffix, or `.` for all ready peers; default: `.`")
260
- .option("--wait-peer <ms>", "wait for eligible peers in milliseconds; omit to wait indefinitely")
261
- .option("--json", "emit ndjson events")
262
- .option("--save-dir <dir>", "save directory")
263
- .option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
264
- .option("--turn-username <value>", "custom TURN username")
265
- .option("--turn-credential <value>", "custom TURN credential")
266
- .action(handlers.offer)
267
-
268
- cli
269
- .command("accept", "receive and save files")
270
- .option("--room <room>", "room id; omit to create a random room")
271
- .option("--self <self>", SELF_HELP_TEXT)
272
- .option("--save-dir <dir>", "save directory")
273
- .option("--once", "exit after the first saved incoming transfer")
274
- .option("--json", "emit ndjson events")
275
- .option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
276
- .option("--turn-username <value>", "custom TURN username")
277
- .option("--turn-credential <value>", "custom TURN credential")
278
- .action(handlers.accept)
279
-
280
- cli
281
- .command("tui", "launch the interactive terminal UI")
282
- .option("--room <room>", "room id; omit to create a random room")
283
- .option("--self <self>", SELF_HELP_TEXT)
284
- .option("--events", "show the event log pane")
285
- .option("--save-dir <dir>", "save directory")
286
- .option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
287
- .option("--turn-username <value>", "custom TURN username")
288
- .option("--turn-credential <value>", "custom TURN credential")
289
- .action(handlers.tui)
283
+ addOptions(cli.command("peers", "list discovered peers"), [
284
+ ...ROOM_SELF_OPTIONS,
285
+ ["--wait <ms>", "discovery wait in milliseconds"],
286
+ ["--json", "print a json snapshot"],
287
+ SAVE_DIR_OPTION,
288
+ ...TURN_OPTIONS,
289
+ ]).action(handlers.peers)
290
+
291
+ addOptions(cli.command("offer [...files]", "offer files to browser-compatible peers"), [
292
+ ...ROOM_SELF_OPTIONS,
293
+ ["--to <peer>", "target peer id or name-suffix, or `.` for all ready peers; default: `.`"],
294
+ ["--wait-peer <ms>", "wait for eligible peers in milliseconds; omit to wait indefinitely"],
295
+ ["--json", "emit ndjson events"],
296
+ SAVE_DIR_OPTION,
297
+ ...TURN_OPTIONS,
298
+ ]).action(handlers.offer)
299
+
300
+ addOptions(cli.command("accept", "receive and save files"), [
301
+ ...ROOM_SELF_OPTIONS,
302
+ SAVE_DIR_OPTION,
303
+ ["--once", "exit after the first saved incoming transfer"],
304
+ ["--json", "emit ndjson events"],
305
+ ...TURN_OPTIONS,
306
+ ]).action(handlers.accept)
307
+
308
+ addOptions(cli.command("tui", "launch the interactive terminal UI"), [
309
+ ...ROOM_SELF_OPTIONS,
310
+ ...TUI_TOGGLE_OPTIONS,
311
+ ["--events", "show the event log pane"],
312
+ SAVE_DIR_OPTION,
313
+ ...TURN_OPTIONS,
314
+ ]).action(handlers.tui)
290
315
 
291
316
  cli.help(sections => {
292
317
  const usage = sections.find(section => section.title === "Usage:")
package/src/tui/app.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path"
1
2
  import { rgb, ui, type BadgeVariant, type VNode } from "@rezi-ui/core"
2
3
  import { createNodeApp } from "@rezi-ui/node"
3
4
  import { inspectLocalFile } from "../core/files"
@@ -37,6 +38,7 @@ type DenseSectionOptions = {
37
38
  border?: "rounded" | "single" | "none"
38
39
  flex?: number
39
40
  }
41
+ type TuiLaunchOptions = { events?: boolean; clean?: boolean; offer?: boolean }
40
42
 
41
43
  export type VisiblePane = "peers" | "transfers" | "logs"
42
44
 
@@ -45,6 +47,7 @@ export interface TuiState {
45
47
  sessionSeed: SessionSeed
46
48
  peerSelectionByRoom: Map<string, Map<string, boolean>>
47
49
  snapshot: SessionSnapshot
50
+ aboutOpen: boolean
48
51
  peerSearch: string
49
52
  focusedId: string | null
50
53
  roomInput: string
@@ -67,6 +70,8 @@ export interface TuiState {
67
70
 
68
71
  export interface TuiActions {
69
72
  toggleEvents: TuiAction
73
+ openAbout: TuiAction
74
+ closeAbout: TuiAction
70
75
  jumpToRandomRoom: TuiAction
71
76
  commitRoom: TuiAction
72
77
  setRoomInput: (value: string) => void
@@ -101,11 +106,20 @@ const ROOM_INPUT_ID = "room-input"
101
106
  const NAME_INPUT_ID = "name-input"
102
107
  const PEER_SEARCH_INPUT_ID = "peer-search-input"
103
108
  const DRAFT_INPUT_ID = "draft-input"
109
+ const ABOUT_TRIGGER_ID = "open-about"
104
110
  const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
105
111
  const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
106
112
  const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
107
113
  const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
108
114
  const DEFAULT_WEB_URL = "https://send.rt.ht/"
115
+ const DEFAULT_SAVE_DIR = resolve(process.cwd(), "downloads")
116
+ const ABOUT_ELEFUNC_URL = "https://elefunc.com"
117
+ const ABOUT_TITLE = "About Send"
118
+ const ABOUT_INTRO = "Peer-to-Peer Transfers for the Web and CLI"
119
+ const ABOUT_SUMMARY = "Join the same room, see who is there, and offer files directly to selected peers."
120
+ const ABOUT_RUNTIME = "Send uses lightweight signaling to discover peers and negotiate WebRTC. Files move over WebRTC data channels, using direct paths when possible and TURN relay when needed."
121
+ const ABOUT_CLI_LABEL = "bunx @elefunc/send@latest"
122
+ const ABOUT_WEB_LINK_LABEL = "Web"
109
123
  const TRANSFER_DIRECTION_ARROW = {
110
124
  out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
111
125
  in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
@@ -119,8 +133,50 @@ const pluralRules = new Intl.PluralRules()
119
133
  export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ? ["peers", "transfers", "logs"] : ["peers", "transfers"]
120
134
 
121
135
  const noop = () => {}
136
+ export const isEditableFocusId = (focusedId: string | null) =>
137
+ focusedId === ROOM_INPUT_ID || focusedId === NAME_INPUT_ID || focusedId === PEER_SEARCH_INPUT_ID || focusedId === DRAFT_INPUT_ID
138
+ export const shouldSwallowQQuit = (focusedId: string | null) => !isEditableFocusId(focusedId)
122
139
 
123
140
  const hashBool = (value: boolean) => value ? "1" : "0"
141
+ const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
142
+ const TUI_QUIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
143
+ type TuiQuitSignal = (typeof TUI_QUIT_SIGNALS)[number]
144
+ type ProcessSignalLike = {
145
+ on?: (signal: TuiQuitSignal, handler: () => void) => unknown
146
+ off?: (signal: TuiQuitSignal, handler: () => void) => unknown
147
+ removeListener?: (signal: TuiQuitSignal, handler: () => void) => unknown
148
+ }
149
+ type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">
150
+ type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
151
+ const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
152
+ const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
153
+ const text = `${value ?? ""}`.trim()
154
+ if (!text) return
155
+ args.push(flag, shellQuote(text))
156
+ }
157
+ const SHARE_TOGGLE_FLAGS = [
158
+ ["clean", "hideTerminalPeers", "--clean"],
159
+ ["accept", "autoAcceptIncoming", "--accept"],
160
+ ["offer", "autoOfferOutgoing", "--offer"],
161
+ ["save", "autoSaveIncoming", "--save"],
162
+ ] as const satisfies readonly (readonly [string, keyof ShareUrlState, string])[]
163
+ const appendToggleCliFlags = (args: string[], state: ShareUrlState) => {
164
+ for (const [, stateKey, flag] of SHARE_TOGGLE_FLAGS) if (!state[stateKey]) appendCliFlag(args, flag, "0")
165
+ }
166
+ const buildHashParams = (state: ShareUrlState, omitDefaults = false) => {
167
+ const params = new URLSearchParams({ room: cleanRoom(state.snapshot.room) })
168
+ for (const [key, stateKey] of SHARE_TOGGLE_FLAGS) if (!omitDefaults || !state[stateKey]) params.set(key, hashBool(state[stateKey]))
169
+ return params
170
+ }
171
+ const shareTurnCliArgs = (sessionSeed: SessionSeed) => {
172
+ const turnUrls = [...new Set((sessionSeed.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
173
+ if (!turnUrls.length) return []
174
+ const args: string[] = []
175
+ for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
176
+ appendCliFlag(args, "--turn-username", sessionSeed.turnUsername)
177
+ appendCliFlag(args, "--turn-credential", sessionSeed.turnCredential)
178
+ return args
179
+ }
124
180
 
125
181
  export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
126
182
  const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
@@ -131,23 +187,75 @@ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
131
187
  }
132
188
  }
133
189
 
134
- export const webInviteUrl = (
135
- state: Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">,
136
- baseUrl = resolveWebUrlBase(),
137
- ) => {
190
+ const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
138
191
  const url = new URL(baseUrl)
139
- url.hash = new URLSearchParams({
140
- room: cleanRoom(state.snapshot.room),
141
- clean: hashBool(state.hideTerminalPeers),
142
- accept: hashBool(state.autoAcceptIncoming),
143
- offer: hashBool(state.autoOfferOutgoing),
144
- save: hashBool(state.autoSaveIncoming),
145
- }).toString()
192
+ url.hash = buildHashParams(state, omitDefaults).toString()
146
193
  return url.toString()
147
194
  }
195
+ const renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
196
+ const args = includePrefix ? ["bunx", "@elefunc/send@latest"] : []
197
+ appendCliFlag(args, "--room", state.snapshot.room)
198
+ if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
199
+ appendToggleCliFlags(args, state)
200
+ if (state.eventsExpanded) args.push("--events")
201
+ if (resolve(state.snapshot.saveDir) !== DEFAULT_SAVE_DIR) appendCliFlag(args, "--save-dir", state.snapshot.saveDir)
202
+ args.push(...shareTurnCliArgs(state.sessionSeed))
203
+ return args.join(" ")
204
+ }
205
+
206
+ export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => {
207
+ return renderWebUrl(state, baseUrl)
208
+ }
209
+
210
+ export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
211
+
212
+ export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => aboutWebUrl(state, baseUrl).replace(/^https:\/\//, "")
213
+
214
+ export const aboutCliCommand = (state: ShareCliState) => renderCliCommand(state)
215
+
216
+ export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
217
+
218
+ export const resumeCliCommand = (state: ShareCliState) => renderCliCommand(state, { includeSelf: true, includePrefix: true })
219
+
220
+ export const resumeOutputLines = (state: ShareCliState) => [
221
+ "Rejoin with:",
222
+ "",
223
+ "Web",
224
+ resumeWebUrl(state),
225
+ "",
226
+ "CLI",
227
+ resumeCliCommand(state),
228
+ "",
229
+ ]
230
+
231
+ export const createQuitController = (processLike: ProcessSignalLike | null = process) => {
232
+ let settled = false
233
+ let resolvePromise = () => {}
234
+ const promise = new Promise<void>(resolve => { resolvePromise = resolve })
235
+ const requestStop = () => {
236
+ if (settled) return false
237
+ settled = true
238
+ resolvePromise()
239
+ return true
240
+ }
241
+ const handler = () => { requestStop() }
242
+ for (const signal of TUI_QUIT_SIGNALS) processLike?.on?.(signal, handler)
243
+ return {
244
+ promise,
245
+ requestStop,
246
+ detach: () => {
247
+ for (const signal of TUI_QUIT_SIGNALS) {
248
+ processLike?.off?.(signal, handler)
249
+ processLike?.removeListener?.(signal, handler)
250
+ }
251
+ },
252
+ }
253
+ }
148
254
 
149
255
  export const createNoopTuiActions = (): TuiActions => ({
150
256
  toggleEvents: noop,
257
+ openAbout: noop,
258
+ closeAbout: noop,
151
259
  jumpToRandomRoom: noop,
152
260
  commitRoom: noop,
153
261
  setRoomInput: noop,
@@ -481,7 +589,7 @@ export const groupTransfersByPeer = (transfers: TransferSnapshot[], peers: PeerS
481
589
  return [...groups.values()]
482
590
  }
483
591
 
484
- export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false): TuiState => {
592
+ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false, launchOptions: Pick<TuiLaunchOptions, "clean" | "offer"> = {}): TuiState => {
485
593
  const sessionSeed = normalizeSessionSeed(initialConfig)
486
594
  const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
487
595
  const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
@@ -493,6 +601,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
493
601
  sessionSeed,
494
602
  peerSelectionByRoom,
495
603
  snapshot: session.snapshot(),
604
+ aboutOpen: false,
496
605
  peerSearch: "",
497
606
  focusedId: null,
498
607
  roomInput: sessionSeed.room,
@@ -502,10 +611,10 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
502
611
  draftInputKeyVersion: 0,
503
612
  filePreview: emptyFilePreviewState(),
504
613
  drafts: [],
505
- autoOfferOutgoing: true,
614
+ autoOfferOutgoing: launchOptions.offer ?? true,
506
615
  autoAcceptIncoming,
507
616
  autoSaveIncoming,
508
- hideTerminalPeers: true,
617
+ hideTerminalPeers: launchOptions.clean ?? true,
509
618
  eventsExpanded: showEvents,
510
619
  offeringDrafts: false,
511
620
  notice: { text: "Tab focus", variant: "info" },
@@ -594,9 +703,64 @@ const renderHeaderBrand = () => ui.row({ id: "brand-title", gap: 1, items: "cent
594
703
  const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
595
704
  id: "header-shell",
596
705
  titleNode: renderHeaderBrand(),
597
- actions: [toggleButton("toggle-events", "Events", state.eventsExpanded, actions.toggleEvents)],
706
+ actions: [
707
+ toggleButton("toggle-events", "Events", state.eventsExpanded, actions.toggleEvents),
708
+ actionButton(ABOUT_TRIGGER_ID, "About", actions.openAbout),
709
+ ],
598
710
  }, [])
599
711
 
712
+ const renderAboutModal = (state: TuiState, actions: TuiActions) => {
713
+ const cliCommand = aboutCliCommand(state)
714
+ const cliCopyText = `${ABOUT_CLI_LABEL} ${cliCommand}`
715
+ const cliCopyUrl = `https://copy.rt.ht/#${new URLSearchParams({ text: cliCopyText })}`
716
+ const currentWebUrl = aboutWebUrl(state)
717
+ const currentWebLabel = aboutWebLabel(state)
718
+ return ui.modal({
719
+ id: "about-modal",
720
+ title: ABOUT_TITLE,
721
+ content: ui.column({ gap: 1 }, [
722
+ ui.text(ABOUT_INTRO, { id: "about-intro", variant: "heading", wrap: true }),
723
+ ui.text(ABOUT_SUMMARY, { id: "about-summary", wrap: true }),
724
+ ui.text(ABOUT_RUNTIME, { id: "about-runtime", wrap: true }),
725
+ ui.column({ gap: 0 }, [
726
+ ui.text(ABOUT_CLI_LABEL, { id: "about-cli-label", variant: "caption", wrap: true }),
727
+ ui.link({
728
+ id: "about-current-cli",
729
+ label: cliCommand,
730
+ accessibleLabel: "Copy current CLI command",
731
+ url: cliCopyUrl,
732
+ }),
733
+ ]),
734
+ ui.column({ gap: 0 }, [
735
+ ui.text(ABOUT_WEB_LINK_LABEL, { id: "about-web-link-label", variant: "caption", wrap: true }),
736
+ ui.link({
737
+ id: "about-current-web-link",
738
+ label: currentWebLabel,
739
+ accessibleLabel: "Open current web link",
740
+ url: currentWebUrl,
741
+ }),
742
+ ]),
743
+ ]),
744
+ actions: [
745
+ ui.link({
746
+ id: "about-elefunc-link",
747
+ label: "elefunc.com",
748
+ accessibleLabel: "Open Elefunc website",
749
+ url: ABOUT_ELEFUNC_URL,
750
+ }),
751
+ actionButton("close-about", "Close", actions.closeAbout, "primary"),
752
+ ],
753
+ width: 72,
754
+ maxWidth: 84,
755
+ minWidth: 54,
756
+ frameStyle: { background: rgb(0, 0, 0) },
757
+ backdrop: { variant: "none" },
758
+ initialFocus: "close-about",
759
+ returnFocusTo: ABOUT_TRIGGER_ID,
760
+ onClose: actions.closeAbout,
761
+ })
762
+ }
763
+
600
764
  const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
601
765
  id: "room-card",
602
766
  }, [
@@ -630,6 +794,14 @@ const renderSelfMetric = (label: string, value: string) => ui.box({ flex: 1, min
630
794
  ])
631
795
 
632
796
  const renderSelfProfileLine = (value: string) => ui.text(value || "—")
797
+ const ipLookupUrl = (value: string) => value ? `https://gi.rt.ht/:${encodeURIComponent(value)}` : null
798
+ const renderIpProfileLine = (value: string) => {
799
+ const ip = value || ""
800
+ const url = ipLookupUrl(ip)
801
+ return url
802
+ ? ui.link({ label: ip, url, accessibleLabel: `Open IP lookup for ${ip}` })
803
+ : ui.text("—")
804
+ }
633
805
  const formatPeerRtt = (value: number) => Number.isFinite(value) ? `${countFormat.format(Math.round(value))}ms` : "—"
634
806
  const renderPeerMetric = (label: string, value: string, asTag = false) => ui.box({ flex: 1, minWidth: 10, border: "single", borderStyle: METRIC_BORDER_STYLE }, [
635
807
  ui.column({ gap: 0 }, [
@@ -663,7 +835,7 @@ const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
663
835
  renderSelfProfileLine(geoSummary(state.snapshot.profile)),
664
836
  renderSelfProfileLine(netSummary(state.snapshot.profile)),
665
837
  renderSelfProfileLine(uaSummary(state.snapshot.profile)),
666
- renderSelfProfileLine(profileIp(state.snapshot.profile)),
838
+ renderIpProfileLine(profileIp(state.snapshot.profile)),
667
839
  ]),
668
840
  ])
669
841
 
@@ -708,7 +880,7 @@ const renderPeerRow = (peer: PeerSnapshot, turnShareEnabled: boolean, actions: T
708
880
  renderSelfProfileLine(geoSummary(peer.profile)),
709
881
  renderSelfProfileLine(netSummary(peer.profile)),
710
882
  renderSelfProfileLine(uaSummary(peer.profile)),
711
- renderSelfProfileLine(profileIp(peer.profile)),
883
+ renderIpProfileLine(profileIp(peer.profile)),
712
884
  ]),
713
885
  peer.lastError ? ui.callout(peer.lastError, { variant: "error" }) : null,
714
886
  ]),
@@ -1021,7 +1193,7 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
1021
1193
  gap: 0,
1022
1194
  })
1023
1195
 
1024
- return state.pendingFocusTarget
1196
+ const basePage = state.pendingFocusTarget
1025
1197
  ? ui.focusTrap({
1026
1198
  id: `focus-request-${state.focusRequestEpoch}`,
1027
1199
  key: `focus-request-${state.focusRequestEpoch}`,
@@ -1029,6 +1201,10 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
1029
1201
  initialFocus: state.pendingFocusTarget,
1030
1202
  }, [page])
1031
1203
  : page
1204
+
1205
+ return state.aboutOpen
1206
+ ? ui.layers([basePage, renderAboutModal(state, actions)])
1207
+ : basePage
1032
1208
  }
1033
1209
 
1034
1210
  const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
@@ -1043,10 +1219,11 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
1043
1219
  focusRequestEpoch: state.focusRequestEpoch + 1,
1044
1220
  }, notice)
1045
1221
 
1046
- export const startTui = async (initialConfig: SessionConfig, showEvents = false) => {
1222
+ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiLaunchOptions = {}) => {
1047
1223
  await installCheckboxClickPatch()
1048
- const initialState = createInitialTuiState(initialConfig, showEvents)
1224
+ const initialState = createInitialTuiState(initialConfig, !!launchOptions.events, launchOptions)
1049
1225
  const app = createNodeApp<TuiState>({ initialState })
1226
+ const quitController = createQuitController()
1050
1227
  let state = initialState
1051
1228
  let unsubscribe = () => {}
1052
1229
  let stopping = false
@@ -1086,11 +1263,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1086
1263
  const requestStop = () => {
1087
1264
  if (stopping) return
1088
1265
  stopping = true
1089
- try {
1090
- process.kill(process.pid, "SIGINT")
1091
- } catch {
1092
- void app.stop()
1093
- }
1266
+ quitController.requestStop()
1094
1267
  }
1095
1268
 
1096
1269
  const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
@@ -1368,6 +1541,8 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1368
1541
 
1369
1542
  const actions: TuiActions = {
1370
1543
  toggleEvents: () => commit(current => ({ ...withNotice(current, { text: current.eventsExpanded ? "Events hidden." : "Events shown.", variant: "info" }), eventsExpanded: !current.eventsExpanded })),
1544
+ openAbout: () => commit(current => ({ ...current, aboutOpen: true })),
1545
+ closeAbout: () => commit(current => ({ ...current, aboutOpen: false })),
1371
1546
  jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
1372
1547
  commitRoom,
1373
1548
  setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
@@ -1523,6 +1698,11 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1523
1698
  })
1524
1699
  app.keys({
1525
1700
  "ctrl+c": { description: "Quit", handler: requestStop },
1701
+ q: {
1702
+ description: "no-op",
1703
+ when: ctx => shouldSwallowQQuit(ctx.focusedId),
1704
+ handler: noop,
1705
+ },
1526
1706
  tab: {
1527
1707
  description: "Accept focused preview row",
1528
1708
  when: ctx => ctx.focusedId === DRAFT_INPUT_ID && !!selectedFilePreviewMatch(state) && filePreviewVisible(state),
@@ -1593,17 +1773,31 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1593
1773
  stopping = true
1594
1774
  unsubscribe()
1595
1775
  stopPreviewSession()
1776
+ try {
1777
+ await app.stop()
1778
+ } catch {}
1596
1779
  await state.session.close()
1597
1780
  }
1598
1781
 
1782
+ const printResumeOutput = async () => {
1783
+ const output = `${resumeOutputLines(state).join("\n")}\n`
1784
+ await new Promise<void>(resolve => process.stdout.write(output, () => resolve()))
1785
+ }
1786
+
1599
1787
  try {
1600
1788
  bindSession(state.session)
1601
- await app.run()
1789
+ await app.start()
1790
+ await quitController.promise
1602
1791
  } finally {
1603
1792
  try {
1604
1793
  await stop()
1605
1794
  } finally {
1606
- app.dispose()
1795
+ try {
1796
+ app.dispose()
1797
+ } finally {
1798
+ await printResumeOutput()
1799
+ quitController.detach()
1800
+ }
1607
1801
  }
1608
1802
  }
1609
1803
  }