@elefunc/send 0.1.6 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elefunc/send",
3
- "version": "0.1.6",
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",
@@ -492,6 +492,20 @@ export class SendSession {
492
492
  return sent
493
493
  }
494
494
 
495
+ shareTurnWithPeers(peerIds: string[]) {
496
+ if (!this.extraTurnServers.length) return 0
497
+ const iceServers = this.sharedTurnServers()
498
+ const sentPeers: string[] = []
499
+ for (const peerId of new Set(peerIds.filter(Boolean))) {
500
+ const peer = this.peers.get(peerId)
501
+ if (!peer || peer.presence !== "active") continue
502
+ if (!this.sendSignal({ kind: "turn-share", to: peer.id, iceServers })) continue
503
+ sentPeers.push(peer.id)
504
+ }
505
+ if (sentPeers.length) this.pushLog("turn:share-sent", { peers: sentPeers.length, scope: "filtered", peerIds: sentPeers }, "info")
506
+ return sentPeers.length
507
+ }
508
+
495
509
  shareTurnWithAllPeers() {
496
510
  const count = this.activePeers().length
497
511
  if (!count || !this.extraTurnServers.length) return 0
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,8 @@ export interface TuiState {
45
47
  sessionSeed: SessionSeed
46
48
  peerSelectionByRoom: Map<string, Map<string, boolean>>
47
49
  snapshot: SessionSnapshot
50
+ aboutOpen: boolean
51
+ peerSearch: string
48
52
  focusedId: string | null
49
53
  roomInput: string
50
54
  nameInput: string
@@ -66,12 +70,15 @@ export interface TuiState {
66
70
 
67
71
  export interface TuiActions {
68
72
  toggleEvents: TuiAction
73
+ openAbout: TuiAction
74
+ closeAbout: TuiAction
69
75
  jumpToRandomRoom: TuiAction
70
76
  commitRoom: TuiAction
71
77
  setRoomInput: (value: string) => void
72
78
  jumpToNewSelf: TuiAction
73
79
  commitName: TuiAction
74
80
  setNameInput: (value: string) => void
81
+ setPeerSearch: (value: string) => void
75
82
  toggleSelectReadyPeers: TuiAction
76
83
  clearPeerSelection: TuiAction
77
84
  toggleHideTerminalPeers: TuiAction
@@ -97,12 +104,22 @@ export interface TuiActions {
97
104
 
98
105
  const ROOM_INPUT_ID = "room-input"
99
106
  const NAME_INPUT_ID = "name-input"
107
+ const PEER_SEARCH_INPUT_ID = "peer-search-input"
100
108
  const DRAFT_INPUT_ID = "draft-input"
109
+ const ABOUT_TRIGGER_ID = "open-about"
101
110
  const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
102
111
  const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
103
112
  const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
104
113
  const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
105
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"
106
123
  const TRANSFER_DIRECTION_ARROW = {
107
124
  out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
108
125
  in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
@@ -118,6 +135,45 @@ export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ?
118
135
  const noop = () => {}
119
136
 
120
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
+ }
121
177
 
122
178
  export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
123
179
  const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
@@ -128,29 +184,82 @@ export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
128
184
  }
129
185
  }
130
186
 
131
- export const webInviteUrl = (
132
- state: Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">,
133
- baseUrl = resolveWebUrlBase(),
134
- ) => {
187
+ const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefaults = true) => {
135
188
  const url = new URL(baseUrl)
136
- url.hash = new URLSearchParams({
137
- room: cleanRoom(state.snapshot.room),
138
- clean: hashBool(state.hideTerminalPeers),
139
- accept: hashBool(state.autoAcceptIncoming),
140
- offer: hashBool(state.autoOfferOutgoing),
141
- save: hashBool(state.autoSaveIncoming),
142
- }).toString()
189
+ url.hash = buildHashParams(state, omitDefaults).toString()
143
190
  return url.toString()
144
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
+ }
145
251
 
146
252
  export const createNoopTuiActions = (): TuiActions => ({
147
253
  toggleEvents: noop,
254
+ openAbout: noop,
255
+ closeAbout: noop,
148
256
  jumpToRandomRoom: noop,
149
257
  commitRoom: noop,
150
258
  setRoomInput: noop,
151
259
  jumpToNewSelf: noop,
152
260
  commitName: noop,
153
261
  setNameInput: noop,
262
+ setPeerSearch: noop,
154
263
  toggleSelectReadyPeers: noop,
155
264
  clearPeerSelection: noop,
156
265
  toggleHideTerminalPeers: noop,
@@ -206,6 +315,16 @@ const transferStatusKind = (status: TransferSnapshot["status"]) => ({
206
315
  error: "offline",
207
316
  }[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
208
317
  const visiblePeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean) => hideTerminalPeers ? peers.filter(peer => peer.presence === "active") : peers
318
+ const peerSearchNeedle = (value: string) => `${value ?? ""}`.trim().toLowerCase()
319
+ const peerMatchesSearch = (peer: PeerSnapshot, search: string) => !search || peer.displayName.toLowerCase().includes(search)
320
+ export const renderedPeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean, search: string) => {
321
+ const needle = peerSearchNeedle(search)
322
+ return visiblePeers(peers, hideTerminalPeers)
323
+ .filter(peer => peerMatchesSearch(peer, needle))
324
+ .sort((left, right) => left.id.localeCompare(right.id))
325
+ }
326
+ export const renderedReadySelectedPeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean, search: string) =>
327
+ renderedPeers(peers, hideTerminalPeers, search).filter(peer => peer.selected && peer.ready)
209
328
  const transferProgress = (transfer: TransferSnapshot) => Math.max(0, Math.min(1, transfer.progress / 100))
210
329
  const isPendingOffer = (transfer: TransferSnapshot) => transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered")
211
330
  const statusVariant = (status: TransferSnapshot["status"]): BadgeVariant => ({
@@ -467,7 +586,7 @@ export const groupTransfersByPeer = (transfers: TransferSnapshot[], peers: PeerS
467
586
  return [...groups.values()]
468
587
  }
469
588
 
470
- export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false): TuiState => {
589
+ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false, launchOptions: Pick<TuiLaunchOptions, "clean" | "offer"> = {}): TuiState => {
471
590
  const sessionSeed = normalizeSessionSeed(initialConfig)
472
591
  const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
473
592
  const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
@@ -479,6 +598,8 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
479
598
  sessionSeed,
480
599
  peerSelectionByRoom,
481
600
  snapshot: session.snapshot(),
601
+ aboutOpen: false,
602
+ peerSearch: "",
482
603
  focusedId: null,
483
604
  roomInput: sessionSeed.room,
484
605
  nameInput: visibleNameInput(sessionSeed.name),
@@ -487,10 +608,10 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
487
608
  draftInputKeyVersion: 0,
488
609
  filePreview: emptyFilePreviewState(),
489
610
  drafts: [],
490
- autoOfferOutgoing: true,
611
+ autoOfferOutgoing: launchOptions.offer ?? true,
491
612
  autoAcceptIncoming,
492
613
  autoSaveIncoming,
493
- hideTerminalPeers: true,
614
+ hideTerminalPeers: launchOptions.clean ?? true,
494
615
  eventsExpanded: showEvents,
495
616
  offeringDrafts: false,
496
617
  notice: { text: "Tab focus", variant: "info" },
@@ -579,9 +700,55 @@ const renderHeaderBrand = () => ui.row({ id: "brand-title", gap: 1, items: "cent
579
700
  const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
580
701
  id: "header-shell",
581
702
  titleNode: renderHeaderBrand(),
582
- 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
+ ],
583
707
  }, [])
584
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
+
585
752
  const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
586
753
  id: "room-card",
587
754
  }, [
@@ -700,18 +867,18 @@ const renderPeerRow = (peer: PeerSnapshot, turnShareEnabled: boolean, actions: T
700
867
  ])
701
868
 
702
869
  const renderPeersCard = (state: TuiState, actions: TuiActions) => {
703
- const peers = visiblePeers(state.snapshot.peers, state.hideTerminalPeers)
704
- const activeCount = state.snapshot.peers.filter(peer => peer.presence === "active").length
705
- const selectedCount = state.snapshot.peers.filter(peer => peer.selectable && peer.selected).length
870
+ const peers = renderedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch)
871
+ const activeCount = peers.filter(peer => peer.presence === "active").length
872
+ const selectedCount = peers.filter(peer => peer.selectable && peer.selected).length
706
873
  const canShareTurn = state.session.canShareTurn()
707
874
  return denseSection({
708
875
  id: "peers-card",
709
876
  titleNode: ui.row({ id: "peers-title-row", gap: 1, items: "center" }, [
710
877
  headingTextButton("share-turn-all-peers", "Peers", canShareTurn && !!activeCount ? actions.shareTurnWithAllPeers : undefined, {
711
878
  focusable: canShareTurn && !!activeCount,
712
- accessibleLabel: "share TURN with all active peers",
879
+ accessibleLabel: "share TURN with matching active peers",
713
880
  }),
714
- ui.text(`${selectedCount}/${activeCount}`, { id: "peers-count-text", variant: "heading" }),
881
+ ui.text(`${selectedCount}/${peers.length}`, { id: "peers-count-text", variant: "heading" }),
715
882
  ]),
716
883
  flex: 1,
717
884
  actions: [
@@ -720,10 +887,16 @@ const renderPeersCard = (state: TuiState, actions: TuiActions) => {
720
887
  toggleButton("toggle-clean-peers", "Clean", state.hideTerminalPeers, actions.toggleHideTerminalPeers),
721
888
  ],
722
889
  }, [
890
+ ui.input({
891
+ id: PEER_SEARCH_INPUT_ID,
892
+ value: state.peerSearch,
893
+ placeholder: "filter",
894
+ onInput: value => actions.setPeerSearch(value),
895
+ }),
723
896
  ui.box({ id: "peers-list", flex: 1, minHeight: 0, overflow: "scroll", border: "none" }, [
724
897
  peers.length
725
898
  ? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, canShareTurn, actions)))
726
- : ui.empty(`Waiting for peers in ${state.snapshot.room}...`),
899
+ : ui.empty(state.snapshot.peers.length ? "No peers match current filters." : `Waiting for peers in ${state.snapshot.room}...`),
727
900
  ]),
728
901
  ])
729
902
  }
@@ -768,6 +941,7 @@ const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: b
768
941
  offsetFileSearchMatchIndices(displayPrefix, match.indices),
769
942
  { id: `file-preview-path-${index}`, flex: 1 },
770
943
  ),
944
+ match.kind === "file" && typeof match.size === "number" ? ui.text(formatBytes(match.size), { style: { dim: true } }) : null,
771
945
  match.kind === "directory" ? tightTag("dir", { variant: "info", bare: true }) : null,
772
946
  ])
773
947
 
@@ -999,7 +1173,7 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
999
1173
  gap: 0,
1000
1174
  })
1001
1175
 
1002
- return state.pendingFocusTarget
1176
+ const basePage = state.pendingFocusTarget
1003
1177
  ? ui.focusTrap({
1004
1178
  id: `focus-request-${state.focusRequestEpoch}`,
1005
1179
  key: `focus-request-${state.focusRequestEpoch}`,
@@ -1007,6 +1181,10 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
1007
1181
  initialFocus: state.pendingFocusTarget,
1008
1182
  }, [page])
1009
1183
  : page
1184
+
1185
+ return state.aboutOpen
1186
+ ? ui.layers([basePage, renderAboutModal(state, actions)])
1187
+ : basePage
1010
1188
  }
1011
1189
 
1012
1190
  const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
@@ -1021,10 +1199,11 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
1021
1199
  focusRequestEpoch: state.focusRequestEpoch + 1,
1022
1200
  }, notice)
1023
1201
 
1024
- export const startTui = async (initialConfig: SessionConfig, showEvents = false) => {
1202
+ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiLaunchOptions = {}) => {
1025
1203
  await installCheckboxClickPatch()
1026
- const initialState = createInitialTuiState(initialConfig, showEvents)
1204
+ const initialState = createInitialTuiState(initialConfig, !!launchOptions.events, launchOptions)
1027
1205
  const app = createNodeApp<TuiState>({ initialState })
1206
+ const quitController = createQuitController()
1028
1207
  let state = initialState
1029
1208
  let unsubscribe = () => {}
1030
1209
  let stopping = false
@@ -1064,11 +1243,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1064
1243
  const requestStop = () => {
1065
1244
  if (stopping) return
1066
1245
  stopping = true
1067
- try {
1068
- process.kill(process.pid, "SIGINT")
1069
- } catch {
1070
- void app.stop()
1071
- }
1246
+ quitController.requestStop()
1072
1247
  }
1073
1248
 
1074
1249
  const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
@@ -1233,11 +1408,12 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1233
1408
 
1234
1409
  const maybeOfferDrafts = () => {
1235
1410
  if (!state.autoOfferOutgoing || !state.drafts.length || state.offeringDrafts) return
1236
- if (!state.snapshot.peers.some(peer => peer.presence === "active" && peer.ready && peer.selected)) return
1411
+ const targetPeerIds = renderedReadySelectedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch).map(peer => peer.id)
1412
+ if (!targetPeerIds.length) return
1237
1413
  const session = state.session
1238
1414
  const pendingDrafts = [...state.drafts]
1239
1415
  commit(current => ({ ...current, offeringDrafts: true }))
1240
- void session.offerToSelectedPeers(pendingDrafts.map(draft => draft.path)).then(
1416
+ void session.queueFiles(pendingDrafts.map(draft => draft.path), targetPeerIds).then(
1241
1417
  ids => {
1242
1418
  if (state.session !== session) return
1243
1419
  const offeredIds = new Set(pendingDrafts.map(draft => draft.id))
@@ -1277,6 +1453,7 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1277
1453
  sessionSeed: nextSeed,
1278
1454
  peerSelectionByRoom: current.peerSelectionByRoom,
1279
1455
  snapshot: nextSession.snapshot(),
1456
+ peerSearch: "",
1280
1457
  roomInput: nextSeed.room,
1281
1458
  nameInput: visibleNameInput(nextSeed.name),
1282
1459
  draftInput: "",
@@ -1344,22 +1521,27 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1344
1521
 
1345
1522
  const actions: TuiActions = {
1346
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 })),
1347
1526
  jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
1348
1527
  commitRoom,
1349
1528
  setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
1350
1529
  jumpToNewSelf: () => replaceSession({ ...state.sessionSeed, localId: cleanLocalId(uid(8)) }, "Started a fresh self ID.", { reseedBootFocus: true }),
1351
1530
  commitName,
1352
1531
  setNameInput: value => commit(current => ({ ...current, nameInput: value })),
1532
+ setPeerSearch: value => commit(current => ({ ...current, peerSearch: value })),
1353
1533
  toggleSelectReadyPeers: () => {
1534
+ const peers = renderedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch)
1354
1535
  let changed = 0
1355
- for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, peer.presence === "active" && peer.ready)) changed += 1
1356
- commit(current => withNotice(current, { text: changed ? "Selected ready peers." : "No ready peers to select.", variant: changed ? "success" : "info" }))
1536
+ for (const peer of peers) if (state.session.setPeerSelected(peer.id, peer.presence === "active" && peer.ready)) changed += 1
1537
+ commit(current => withNotice(current, { text: changed ? "Selected matching ready peers." : "No matching ready peers to select.", variant: changed ? "success" : "info" }))
1357
1538
  maybeOfferDrafts()
1358
1539
  },
1359
1540
  clearPeerSelection: () => {
1541
+ const peers = renderedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch)
1360
1542
  let changed = 0
1361
- for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, false)) changed += 1
1362
- commit(current => withNotice(current, { text: changed ? `Cleared ${plural(changed, "peer selection")}.` : "No peer selections to clear.", variant: changed ? "warning" : "info" }))
1543
+ for (const peer of peers) if (state.session.setPeerSelected(peer.id, false)) changed += 1
1544
+ commit(current => withNotice(current, { text: changed ? `Cleared ${plural(changed, "matching peer selection")}.` : "No matching peer selections to clear.", variant: changed ? "warning" : "info" }))
1363
1545
  },
1364
1546
  toggleHideTerminalPeers: () => commit(current => withNotice({ ...current, hideTerminalPeers: !current.hideTerminalPeers }, { text: current.hideTerminalPeers ? "Terminal peers shown." : "Terminal peers hidden.", variant: "info" })),
1365
1547
  togglePeer: peerId => {
@@ -1379,13 +1561,16 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1379
1561
  }))
1380
1562
  },
1381
1563
  shareTurnWithAllPeers: () => {
1382
- const shared = state.session.shareTurnWithAllPeers()
1564
+ const targetPeerIds = renderedPeers(state.snapshot.peers, state.hideTerminalPeers, state.peerSearch)
1565
+ .filter(peer => peer.presence === "active")
1566
+ .map(peer => peer.id)
1567
+ const shared = state.session.shareTurnWithPeers(targetPeerIds)
1383
1568
  commit(current => withNotice(current, {
1384
1569
  text: !state.session.canShareTurn()
1385
1570
  ? "TURN is not configured."
1386
1571
  : shared
1387
- ? `Shared TURN with ${plural(shared, "active peer")}.`
1388
- : "No active peers to share TURN with.",
1572
+ ? `Shared TURN with ${plural(shared, "matching peer")}.`
1573
+ : "No matching active peers to share TURN with.",
1389
1574
  variant: !state.session.canShareTurn() ? "info" : shared ? "success" : "info",
1390
1575
  }))
1391
1576
  },
@@ -1563,17 +1748,31 @@ export const startTui = async (initialConfig: SessionConfig, showEvents = false)
1563
1748
  stopping = true
1564
1749
  unsubscribe()
1565
1750
  stopPreviewSession()
1751
+ try {
1752
+ await app.stop()
1753
+ } catch {}
1566
1754
  await state.session.close()
1567
1755
  }
1568
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
+
1569
1762
  try {
1570
1763
  bindSession(state.session)
1571
- await app.run()
1764
+ await app.start()
1765
+ await quitController.promise
1572
1766
  } finally {
1573
1767
  try {
1574
1768
  await stop()
1575
1769
  } finally {
1576
- app.dispose()
1770
+ try {
1771
+ app.dispose()
1772
+ } finally {
1773
+ await printResumeOutput()
1774
+ quitController.detach()
1775
+ }
1577
1776
  }
1578
1777
  }
1579
1778
  }
@@ -3,6 +3,7 @@ export interface FileSearchMatch {
3
3
  absolutePath: string
4
4
  fileName: string
5
5
  kind: "file" | "directory"
6
+ size?: number
6
7
  score: number
7
8
  indices: number[]
8
9
  }
@@ -8,6 +8,7 @@ export interface IndexedEntry {
8
8
  relativePath: string
9
9
  fileName: string
10
10
  kind: "file" | "directory"
11
+ size?: number
11
12
  }
12
13
 
13
14
  export interface FileSearchScope {
@@ -187,6 +188,7 @@ const browseEntries = (entries: readonly IndexedEntry[], resultLimit: number) =>
187
188
  absolutePath: entry.absolutePath,
188
189
  fileName: entry.fileName,
189
190
  kind: entry.kind,
191
+ size: entry.size,
190
192
  score: 0,
191
193
  indices: [],
192
194
  } satisfies FileSearchMatch))
@@ -203,6 +205,7 @@ export const searchEntries = (entries: readonly IndexedEntry[], query: string, r
203
205
  absolutePath: entry.absolutePath,
204
206
  fileName: entry.fileName,
205
207
  kind: entry.kind,
208
+ size: entry.size,
206
209
  score: match.score,
207
210
  indices: match.indices,
208
211
  })
@@ -272,6 +275,7 @@ export const crawlWorkspaceEntries = async (workspaceRoot: string, onEntry: (ent
272
275
  relativePath: normalizeRelativePath(relative(root, absolutePath)),
273
276
  fileName: child.name,
274
277
  kind: "file",
278
+ size: info.size,
275
279
  })
276
280
  }
277
281
  } catch {