@elefunc/send 0.1.7 → 0.1.8

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 +194 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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 = "Room-based peer-to-peer file transfers for the web and terminal"
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 } },
@@ -121,6 +135,45 @@ export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ?
121
135
  const noop = () => {}
122
136
 
123
137
  const hashBool = (value: boolean) => value ? "1" : "0"
138
+ const safeShellArgPattern = /^[A-Za-z0-9._/:?=&,+@%-]+$/
139
+ const TUI_QUIT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
140
+ type TuiQuitSignal = (typeof TUI_QUIT_SIGNALS)[number]
141
+ type ProcessSignalLike = {
142
+ on?: (signal: TuiQuitSignal, handler: () => void) => unknown
143
+ off?: (signal: TuiQuitSignal, handler: () => void) => unknown
144
+ removeListener?: (signal: TuiQuitSignal, handler: () => void) => unknown
145
+ }
146
+ type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">
147
+ type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
148
+ const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
149
+ const appendCliFlag = (args: string[], flag: string, value?: string | null) => {
150
+ const text = `${value ?? ""}`.trim()
151
+ if (!text) return
152
+ args.push(flag, shellQuote(text))
153
+ }
154
+ const SHARE_TOGGLE_FLAGS = [
155
+ ["clean", "hideTerminalPeers", "--clean"],
156
+ ["accept", "autoAcceptIncoming", "--accept"],
157
+ ["offer", "autoOfferOutgoing", "--offer"],
158
+ ["save", "autoSaveIncoming", "--save"],
159
+ ] as const satisfies readonly (readonly [string, keyof ShareUrlState, string])[]
160
+ const appendToggleCliFlags = (args: string[], state: ShareUrlState) => {
161
+ for (const [, stateKey, flag] of SHARE_TOGGLE_FLAGS) if (!state[stateKey]) appendCliFlag(args, flag, "0")
162
+ }
163
+ const buildHashParams = (state: ShareUrlState, omitDefaults = false) => {
164
+ const params = new URLSearchParams({ room: cleanRoom(state.snapshot.room) })
165
+ for (const [key, stateKey] of SHARE_TOGGLE_FLAGS) if (!omitDefaults || !state[stateKey]) params.set(key, hashBool(state[stateKey]))
166
+ return params
167
+ }
168
+ const shareTurnCliArgs = (sessionSeed: SessionSeed) => {
169
+ const turnUrls = [...new Set((sessionSeed.turnUrls ?? []).map(url => `${url ?? ""}`.trim()).filter(Boolean))]
170
+ if (!turnUrls.length) return []
171
+ const args: string[] = []
172
+ for (const turnUrl of turnUrls) appendCliFlag(args, "--turn-url", turnUrl)
173
+ appendCliFlag(args, "--turn-username", sessionSeed.turnUsername)
174
+ appendCliFlag(args, "--turn-credential", sessionSeed.turnCredential)
175
+ return args
176
+ }
124
177
 
125
178
  export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
126
179
  const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
@@ -131,23 +184,75 @@ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
131
184
  }
132
185
  }
133
186
 
134
- export const webInviteUrl = (
135
- state: Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">,
136
- baseUrl = resolveWebUrlBase(),
137
- ) => {
187
+ const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
138
188
  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()
189
+ url.hash = buildHashParams(state, omitDefaults).toString()
146
190
  return url.toString()
147
191
  }
192
+ const renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
193
+ const args = includePrefix ? ["bunx", "@elefunc/send@latest"] : []
194
+ appendCliFlag(args, "--room", state.snapshot.room)
195
+ if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
196
+ appendToggleCliFlags(args, state)
197
+ if (state.eventsExpanded) args.push("--events")
198
+ if (resolve(state.snapshot.saveDir) !== DEFAULT_SAVE_DIR) appendCliFlag(args, "--save-dir", state.snapshot.saveDir)
199
+ args.push(...shareTurnCliArgs(state.sessionSeed))
200
+ return args.join(" ")
201
+ }
202
+
203
+ export const webInviteUrl = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => {
204
+ return renderWebUrl(state, baseUrl, false)
205
+ }
206
+
207
+ export const aboutWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
208
+
209
+ export const aboutWebLabel = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => aboutWebUrl(state, baseUrl).replace(/^https:\/\//, "")
210
+
211
+ export const aboutCliCommand = (state: ShareCliState) => renderCliCommand(state)
212
+
213
+ export const resumeWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL) => renderWebUrl(state, baseUrl)
214
+
215
+ export const resumeCliCommand = (state: ShareCliState) => renderCliCommand(state, { includeSelf: true, includePrefix: true })
216
+
217
+ export const resumeOutputLines = (state: ShareCliState) => [
218
+ "Rejoin with:",
219
+ "",
220
+ "Web",
221
+ resumeWebUrl(state),
222
+ "",
223
+ "CLI",
224
+ resumeCliCommand(state),
225
+ "",
226
+ ]
227
+
228
+ export const createQuitController = (processLike: ProcessSignalLike | null = process) => {
229
+ let settled = false
230
+ let resolvePromise = () => {}
231
+ const promise = new Promise<void>(resolve => { resolvePromise = resolve })
232
+ const requestStop = () => {
233
+ if (settled) return false
234
+ settled = true
235
+ resolvePromise()
236
+ return true
237
+ }
238
+ const handler = () => { requestStop() }
239
+ for (const signal of TUI_QUIT_SIGNALS) processLike?.on?.(signal, handler)
240
+ return {
241
+ promise,
242
+ requestStop,
243
+ detach: () => {
244
+ for (const signal of TUI_QUIT_SIGNALS) {
245
+ processLike?.off?.(signal, handler)
246
+ processLike?.removeListener?.(signal, handler)
247
+ }
248
+ },
249
+ }
250
+ }
148
251
 
149
252
  export const createNoopTuiActions = (): TuiActions => ({
150
253
  toggleEvents: noop,
254
+ openAbout: noop,
255
+ closeAbout: noop,
151
256
  jumpToRandomRoom: noop,
152
257
  commitRoom: noop,
153
258
  setRoomInput: noop,
@@ -481,7 +586,7 @@ export const groupTransfersByPeer = (transfers: TransferSnapshot[], peers: PeerS
481
586
  return [...groups.values()]
482
587
  }
483
588
 
484
- export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false): TuiState => {
589
+ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false, launchOptions: Pick<TuiLaunchOptions, "clean" | "offer"> = {}): TuiState => {
485
590
  const sessionSeed = normalizeSessionSeed(initialConfig)
486
591
  const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
487
592
  const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
@@ -493,6 +598,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
493
598
  sessionSeed,
494
599
  peerSelectionByRoom,
495
600
  snapshot: session.snapshot(),
601
+ aboutOpen: false,
496
602
  peerSearch: "",
497
603
  focusedId: null,
498
604
  roomInput: sessionSeed.room,
@@ -502,10 +608,10 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
502
608
  draftInputKeyVersion: 0,
503
609
  filePreview: emptyFilePreviewState(),
504
610
  drafts: [],
505
- autoOfferOutgoing: true,
611
+ autoOfferOutgoing: launchOptions.offer ?? true,
506
612
  autoAcceptIncoming,
507
613
  autoSaveIncoming,
508
- hideTerminalPeers: true,
614
+ hideTerminalPeers: launchOptions.clean ?? true,
509
615
  eventsExpanded: showEvents,
510
616
  offeringDrafts: false,
511
617
  notice: { text: "Tab focus", variant: "info" },
@@ -594,9 +700,55 @@ const renderHeaderBrand = () => ui.row({ id: "brand-title", gap: 1, items: "cent
594
700
  const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
595
701
  id: "header-shell",
596
702
  titleNode: renderHeaderBrand(),
597
- actions: [toggleButton("toggle-events", "Events", state.eventsExpanded, actions.toggleEvents)],
703
+ actions: [
704
+ toggleButton("toggle-events", "Events", state.eventsExpanded, actions.toggleEvents),
705
+ actionButton(ABOUT_TRIGGER_ID, "About", actions.openAbout),
706
+ ],
598
707
  }, [])
599
708
 
709
+ const renderAboutModal = (state: TuiState, actions: TuiActions) => {
710
+ const cliCommand = aboutCliCommand(state)
711
+ const currentWebUrl = aboutWebUrl(state)
712
+ const currentWebLabel = aboutWebLabel(state)
713
+ return ui.modal({
714
+ id: "about-modal",
715
+ title: ABOUT_TITLE,
716
+ content: ui.column({ gap: 1 }, [
717
+ ui.text(ABOUT_INTRO, { id: "about-intro", variant: "heading" }),
718
+ ui.text(ABOUT_SUMMARY, { id: "about-summary" }),
719
+ ui.text(ABOUT_RUNTIME, { id: "about-runtime" }),
720
+ ui.column({ gap: 0 }, [
721
+ ui.text(ABOUT_CLI_LABEL, { id: "about-cli-label", variant: "caption" }),
722
+ ui.text(cliCommand, { id: "about-current-cli" }),
723
+ ]),
724
+ ui.column({ gap: 0 }, [
725
+ ui.text(ABOUT_WEB_LINK_LABEL, { id: "about-web-link-label", variant: "caption" }),
726
+ ui.link({
727
+ id: "about-current-web-link",
728
+ label: currentWebLabel,
729
+ accessibleLabel: "Open current web link",
730
+ url: currentWebUrl,
731
+ }),
732
+ ]),
733
+ ]),
734
+ actions: [
735
+ ui.link({
736
+ id: "about-elefunc-link",
737
+ label: "elefunc.com",
738
+ accessibleLabel: "Open Elefunc website",
739
+ url: ABOUT_ELEFUNC_URL,
740
+ }),
741
+ actionButton("close-about", "Close", actions.closeAbout, "primary"),
742
+ ],
743
+ width: 72,
744
+ maxWidth: 84,
745
+ minWidth: 54,
746
+ initialFocus: "close-about",
747
+ returnFocusTo: ABOUT_TRIGGER_ID,
748
+ onClose: actions.closeAbout,
749
+ })
750
+ }
751
+
600
752
  const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
601
753
  id: "room-card",
602
754
  }, [
@@ -1021,7 +1173,7 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
1021
1173
  gap: 0,
1022
1174
  })
1023
1175
 
1024
- return state.pendingFocusTarget
1176
+ const basePage = state.pendingFocusTarget
1025
1177
  ? ui.focusTrap({
1026
1178
  id: `focus-request-${state.focusRequestEpoch}`,
1027
1179
  key: `focus-request-${state.focusRequestEpoch}`,
@@ -1029,6 +1181,10 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
1029
1181
  initialFocus: state.pendingFocusTarget,
1030
1182
  }, [page])
1031
1183
  : page
1184
+
1185
+ return state.aboutOpen
1186
+ ? ui.layers([basePage, renderAboutModal(state, actions)])
1187
+ : basePage
1032
1188
  }
1033
1189
 
1034
1190
  const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
@@ -1043,10 +1199,11 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
1043
1199
  focusRequestEpoch: state.focusRequestEpoch + 1,
1044
1200
  }, notice)
1045
1201
 
1046
- export const startTui = async (initialConfig: SessionConfig, showEvents = false) => {
1202
+ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiLaunchOptions = {}) => {
1047
1203
  await installCheckboxClickPatch()
1048
- const initialState = createInitialTuiState(initialConfig, showEvents)
1204
+ const initialState = createInitialTuiState(initialConfig, !!launchOptions.events, launchOptions)
1049
1205
  const app = createNodeApp<TuiState>({ initialState })
1206
+ const quitController = createQuitController()
1050
1207
  let state = initialState
1051
1208
  let unsubscribe = () => {}
1052
1209
  let stopping = false
@@ -1086,11 +1243,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1086
1243
  const requestStop = () => {
1087
1244
  if (stopping) return
1088
1245
  stopping = true
1089
- try {
1090
- process.kill(process.pid, "SIGINT")
1091
- } catch {
1092
- void app.stop()
1093
- }
1246
+ quitController.requestStop()
1094
1247
  }
1095
1248
 
1096
1249
  const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
@@ -1368,6 +1521,8 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1368
1521
 
1369
1522
  const actions: TuiActions = {
1370
1523
  toggleEvents: () => commit(current => ({ ...withNotice(current, { text: current.eventsExpanded ? "Events hidden." : "Events shown.", variant: "info" }), eventsExpanded: !current.eventsExpanded })),
1524
+ openAbout: () => commit(current => ({ ...current, aboutOpen: true })),
1525
+ closeAbout: () => commit(current => ({ ...current, aboutOpen: false })),
1371
1526
  jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
1372
1527
  commitRoom,
1373
1528
  setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
@@ -1593,17 +1748,31 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1593
1748
  stopping = true
1594
1749
  unsubscribe()
1595
1750
  stopPreviewSession()
1751
+ try {
1752
+ await app.stop()
1753
+ } catch {}
1596
1754
  await state.session.close()
1597
1755
  }
1598
1756
 
1757
+ const printResumeOutput = async () => {
1758
+ const output = `${resumeOutputLines(state).join("\n")}\n`
1759
+ await new Promise<void>(resolve => process.stdout.write(output, () => resolve()))
1760
+ }
1761
+
1599
1762
  try {
1600
1763
  bindSession(state.session)
1601
- await app.run()
1764
+ await app.start()
1765
+ await quitController.promise
1602
1766
  } finally {
1603
1767
  try {
1604
1768
  await stop()
1605
1769
  } finally {
1606
- app.dispose()
1770
+ try {
1771
+ app.dispose()
1772
+ } finally {
1773
+ await printResumeOutput()
1774
+ quitController.detach()
1775
+ }
1607
1776
  }
1608
1777
  }
1609
1778
  }