@elefunc/send 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui/app.ts ADDED
@@ -0,0 +1,1442 @@
1
+ import { rgb, ui, type BadgeVariant, type VNode } from "@rezi-ui/core"
2
+ import { createNodeApp } from "@rezi-ui/node"
3
+ import { inspectLocalFile } from "../core/files"
4
+ import { SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
5
+ import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
6
+ import { FILE_SEARCH_VISIBLE_ROWS, type FileSearchEvent, type FileSearchMatch, type FileSearchRequest } from "./file-search-protocol"
7
+ import { deriveFileSearchScope, formatFileSearchDisplayPath, normalizeSearchQuery, offsetFileSearchMatchIndices } from "./file-search"
8
+ import { installCheckboxClickPatch } from "./rezi-checkbox-click"
9
+
10
+ type Notice = { text: string; variant: "info" | "success" | "warning" | "error" }
11
+ type DraftItem = { id: string; path: string; name: string; size: number; createdAt: number }
12
+ type SessionSeed = Omit<SessionConfig, "autoAcceptIncoming" | "autoSaveIncoming"> & { localId: string; name: string; room: string }
13
+ type TuiAction = () => void
14
+ type TransferSection = { title: string; items: TransferSnapshot[]; clearAction?: "completed" | "failed" }
15
+ type TransferSummaryStat = { state: string; label?: string; count: number; size: number; countText?: string; sizeText?: string }
16
+ type TransferGroup = { key: string; name: string; items: TransferSnapshot[] }
17
+ type DenseSectionChild = VNode | false | null | undefined
18
+ type FilePreviewState = {
19
+ dismissedQuery: string | null
20
+ workspaceRoot: string | null
21
+ displayPrefix: string
22
+ displayQuery: string | null
23
+ pendingQuery: string | null
24
+ waiting: boolean
25
+ error: string | null
26
+ results: FileSearchMatch[]
27
+ selectedIndex: number | null
28
+ scrollTop: number
29
+ }
30
+ type DenseSectionOptions = {
31
+ id?: string
32
+ key?: string
33
+ title?: string
34
+ titleNode?: VNode
35
+ subtitle?: string
36
+ actions?: readonly VNode[]
37
+ border?: "rounded" | "single" | "none"
38
+ flex?: number
39
+ }
40
+
41
+ export type VisiblePane = "peers" | "transfers" | "logs"
42
+
43
+ export interface TuiState {
44
+ session: SendSession
45
+ sessionSeed: SessionSeed
46
+ snapshot: SessionSnapshot
47
+ focusedId: string | null
48
+ roomInput: string
49
+ nameInput: string
50
+ pendingFocusTarget: string | null
51
+ focusRequestEpoch: number
52
+ bootNameJumpPending: boolean
53
+ draftInput: string
54
+ draftInputKeyVersion: number
55
+ filePreview: FilePreviewState
56
+ drafts: DraftItem[]
57
+ autoOfferOutgoing: boolean
58
+ autoAcceptIncoming: boolean
59
+ autoSaveIncoming: boolean
60
+ hideTerminalPeers: boolean
61
+ eventsExpanded: boolean
62
+ offeringDrafts: boolean
63
+ notice: Notice
64
+ }
65
+
66
+ export interface TuiActions {
67
+ toggleEvents: TuiAction
68
+ jumpToRandomRoom: TuiAction
69
+ commitRoom: TuiAction
70
+ setRoomInput: (value: string) => void
71
+ jumpToNewSelf: TuiAction
72
+ commitName: TuiAction
73
+ setNameInput: (value: string) => void
74
+ toggleSelectReadyPeers: TuiAction
75
+ clearPeerSelection: TuiAction
76
+ toggleHideTerminalPeers: TuiAction
77
+ togglePeer: (peerId: string) => void
78
+ toggleAutoOffer: TuiAction
79
+ toggleAutoAccept: TuiAction
80
+ toggleAutoSave: TuiAction
81
+ setDraftInput: (value: string) => void
82
+ addDrafts: TuiAction
83
+ removeDraft: (draftId: string) => void
84
+ clearDrafts: TuiAction
85
+ cancelPendingOffers: TuiAction
86
+ acceptTransfer: (transferId: string) => void
87
+ rejectTransfer: (transferId: string) => void
88
+ cancelTransfer: (transferId: string) => void
89
+ saveTransfer: (transferId: string) => void
90
+ clearCompleted: TuiAction
91
+ clearFailed: TuiAction
92
+ clearLogs: TuiAction
93
+ }
94
+
95
+ const ROOM_INPUT_ID = "room-input"
96
+ const NAME_INPUT_ID = "name-input"
97
+ const DRAFT_INPUT_ID = "draft-input"
98
+ const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
99
+ const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
100
+
101
+ const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
102
+ const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
103
+ const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
104
+ const pluralRules = new Intl.PluralRules()
105
+
106
+ export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ? ["peers", "transfers", "logs"] : ["peers", "transfers"]
107
+
108
+ const noop = () => {}
109
+
110
+ export const createNoopTuiActions = (): TuiActions => ({
111
+ toggleEvents: noop,
112
+ jumpToRandomRoom: noop,
113
+ commitRoom: noop,
114
+ setRoomInput: noop,
115
+ jumpToNewSelf: noop,
116
+ commitName: noop,
117
+ setNameInput: noop,
118
+ toggleSelectReadyPeers: noop,
119
+ clearPeerSelection: noop,
120
+ toggleHideTerminalPeers: noop,
121
+ togglePeer: noop,
122
+ toggleAutoOffer: noop,
123
+ toggleAutoAccept: noop,
124
+ toggleAutoSave: noop,
125
+ setDraftInput: noop,
126
+ addDrafts: noop,
127
+ removeDraft: noop,
128
+ clearDrafts: noop,
129
+ cancelPendingOffers: noop,
130
+ acceptTransfer: noop,
131
+ rejectTransfer: noop,
132
+ cancelTransfer: noop,
133
+ saveTransfer: noop,
134
+ clearCompleted: noop,
135
+ clearFailed: noop,
136
+ clearLogs: noop,
137
+ })
138
+
139
+ const plural = (count: number, noun: string) => `${countFormat.format(count)} ${noun}${pluralRules.select(count) === "one" ? "" : "s"}`
140
+ const shortText = (value: unknown, max = 88) => {
141
+ const text = typeof value === "string" ? value : JSON.stringify(value)
142
+ return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
143
+ }
144
+ const toggleIntent = (active: boolean) => active ? "success" : "secondary"
145
+ const statusKind = (socketState: SessionSnapshot["socketState"]) => socketState === "open" ? "online" : socketState === "connecting" ? "busy" : socketState === "error" ? "offline" : "away"
146
+ const peerConnectionStatusKind = (status: string) => ({
147
+ connected: "online",
148
+ connecting: "busy",
149
+ disconnected: "away",
150
+ left: "away",
151
+ failed: "offline",
152
+ closed: "offline",
153
+ idle: "unknown",
154
+ new: "unknown",
155
+ }[status] || "unknown") as "online" | "offline" | "away" | "busy" | "unknown"
156
+ const visiblePeers = (peers: PeerSnapshot[], hideTerminalPeers: boolean) => hideTerminalPeers ? peers.filter(peer => peer.presence === "active") : peers
157
+ const transferProgress = (transfer: TransferSnapshot) => Math.max(0, Math.min(1, transfer.progress / 100))
158
+ const isPendingOffer = (transfer: TransferSnapshot) => transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered")
159
+ const statusVariant = (status: TransferSnapshot["status"]): BadgeVariant => ({
160
+ draft: "default",
161
+ complete: "success",
162
+ sending: "info",
163
+ receiving: "info",
164
+ accepted: "warning",
165
+ queued: "warning",
166
+ offered: "warning",
167
+ pending: "warning",
168
+ rejected: "error",
169
+ cancelled: "error",
170
+ error: "error",
171
+ cancelling: "warning",
172
+ "awaiting-done": "info",
173
+ }[status] || "default") as BadgeVariant
174
+ const statusToneVariant = (value: string): BadgeVariant => ({
175
+ open: "success",
176
+ connected: "success",
177
+ complete: "success",
178
+ accepted: "success",
179
+ receiving: "success",
180
+ sending: "success",
181
+ available: "success",
182
+ used: "success",
183
+ idle: "info",
184
+ connecting: "info",
185
+ offered: "info",
186
+ "awaiting-done": "info",
187
+ queued: "info",
188
+ retrying: "info",
189
+ pending: "warning",
190
+ cancelling: "warning",
191
+ checking: "warning",
192
+ disconnected: "warning",
193
+ left: "warning",
194
+ rejected: "warning",
195
+ cancelled: "warning",
196
+ absent: "warning",
197
+ none: "warning",
198
+ error: "error",
199
+ failed: "error",
200
+ closed: "error",
201
+ }[value] || "default") as BadgeVariant
202
+ const joinSummary = (parts: Array<string | undefined>, glue = ", ") => parts.filter(Boolean).join(glue)
203
+ const geoSummary = (profile?: PeerProfile) => joinSummary([profile?.geo?.city, profile?.geo?.region, profile?.geo?.country]) || "—"
204
+ const netSummary = (profile?: PeerProfile) => joinSummary([profile?.network?.asOrganization, profile?.network?.colo], " · ") || "—"
205
+ const uaSummary = (profile?: PeerProfile) => joinSummary([profile?.ua?.browser, profile?.ua?.os, profile?.ua?.device && profile.ua.device !== "desktop" ? profile.ua.device : ""] , " · ") || "—"
206
+ const profileIp = (profile?: PeerProfile) => profile?.network?.ip || "—"
207
+ const peerDefaultsVariant = (profile?: PeerProfile): BadgeVariant => {
208
+ const token = peerDefaultsToken(profile)
209
+ return token === "AS" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
210
+ }
211
+ const TIGHT_TAG_COLORS = {
212
+ default: rgb(89, 194, 255),
213
+ success: rgb(170, 217, 76),
214
+ warning: rgb(242, 169, 59),
215
+ error: rgb(240, 113, 120),
216
+ info: rgb(89, 194, 255),
217
+ } as const
218
+ const tightTag = (text: string, props: { key?: string; variant?: BadgeVariant; bare?: boolean } = {}) => ui.text(props.bare ? text : `(${text})`, {
219
+ ...(props.key === undefined ? {} : { key: props.key }),
220
+ style: {
221
+ fg: TIGHT_TAG_COLORS[props.variant ?? "default"],
222
+ bold: true,
223
+ },
224
+ })
225
+ const emptyFilePreviewState = (): FilePreviewState => ({
226
+ dismissedQuery: null,
227
+ workspaceRoot: null,
228
+ displayPrefix: "",
229
+ displayQuery: null,
230
+ pendingQuery: null,
231
+ waiting: false,
232
+ error: null,
233
+ results: [],
234
+ selectedIndex: null,
235
+ scrollTop: 0,
236
+ })
237
+
238
+ type FocusControllerState = Pick<TuiState, "pendingFocusTarget" | "focusRequestEpoch" | "bootNameJumpPending">
239
+
240
+ export const deriveBootFocusState = (name: string, focusRequestEpoch = 0): FocusControllerState => {
241
+ const normalizedName = cleanName(name)
242
+ const customSelfName = normalizedName !== fallbackName
243
+ return {
244
+ pendingFocusTarget: customSelfName ? DRAFT_INPUT_ID : NAME_INPUT_ID,
245
+ focusRequestEpoch,
246
+ bootNameJumpPending: !customSelfName,
247
+ }
248
+ }
249
+
250
+ export const consumeSatisfiedFocusRequest = <T extends FocusControllerState>(state: T, focusedId: string | null): T =>
251
+ state.pendingFocusTarget !== null && state.pendingFocusTarget === focusedId
252
+ ? { ...state, pendingFocusTarget: null }
253
+ : state
254
+
255
+ export const scheduleBootNameJump = <T extends FocusControllerState>(state: T): T =>
256
+ state.bootNameJumpPending
257
+ ? {
258
+ ...state,
259
+ pendingFocusTarget: DRAFT_INPUT_ID,
260
+ focusRequestEpoch: state.focusRequestEpoch + 1,
261
+ bootNameJumpPending: false,
262
+ }
263
+ : state
264
+
265
+ const visibleNameInput = (name: string) => cleanName(name) === fallbackName ? "" : cleanName(name)
266
+
267
+ const normalizeSessionSeed = (config: SessionConfig): SessionSeed => ({
268
+ ...config,
269
+ localId: cleanLocalId(config.localId ?? uid(8)),
270
+ name: cleanName(config.name ?? fallbackName),
271
+ room: cleanRoom(config.room),
272
+ })
273
+
274
+ const makeSession = (seed: SessionSeed, autoAcceptIncoming: boolean, autoSaveIncoming: boolean) => new SendSession({
275
+ ...seed,
276
+ autoAcceptIncoming,
277
+ autoSaveIncoming,
278
+ })
279
+
280
+ const transferSections = (snapshot: SessionSnapshot): TransferSection[] => {
281
+ const pending = snapshot.transfers.filter(transfer => transfer.direction === "in" && transfer.status === "pending" || transfer.direction === "out" && (transfer.status === "queued" || transfer.status === "offered"))
282
+ const active = snapshot.transfers.filter(transfer => !["pending", "queued", "offered", "complete", "rejected", "cancelled", "error"].includes(transfer.status))
283
+ const completed = snapshot.transfers.filter(transfer => transfer.status === "complete")
284
+ const failed = snapshot.transfers.filter(transfer => ["rejected", "cancelled", "error"].includes(transfer.status))
285
+ return [
286
+ { title: "Pending", items: pending },
287
+ { title: "Transfers", items: active },
288
+ { title: "Completed", items: completed, clearAction: "completed" },
289
+ { title: "Failed", items: failed, clearAction: "failed" },
290
+ ]
291
+ }
292
+
293
+ const formatClockTime = (value: number) => value ? timeFormat.format(new Date(value)) : "—"
294
+
295
+ export const formatDuration = (value: number) => {
296
+ const ms = Number(value) || 0
297
+ const total = Math.round(ms / 1000)
298
+ if (!Number.isFinite(ms) || ms <= 0) return "—"
299
+ if (ms < 1000) return "<1s"
300
+ if (total < 60) return `${total}s`
301
+ const hours = Math.floor(total / 3600)
302
+ const minutes = Math.floor(total % 3600 / 60)
303
+ const seconds = total % 60
304
+ return hours ? `${hours}h ${`${minutes}`.padStart(2, "0")}m` : `${minutes}m ${`${seconds}`.padStart(2, "0")}s`
305
+ }
306
+
307
+ export const transferActualDurationMs = (transfer: TransferSnapshot, now = Date.now()) => transfer.startedAt ? Math.max(0, (transfer.endedAt || transfer.updatedAt || now) - transfer.startedAt) : Number.NaN
308
+ export const transferWaitDurationMs = (transfer: TransferSnapshot, now = Date.now()) => Math.max(0, (transfer.startedAt || now) - transfer.createdAt)
309
+ export const filePreviewVisible = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">) =>
310
+ state.focusedId === DRAFT_INPUT_ID && !!normalizeSearchQuery(state.draftInput) && state.filePreview.dismissedQuery !== state.draftInput
311
+ export const clampFilePreviewSelectedIndex = (selectedIndex: number | null, resultCount: number) =>
312
+ !resultCount ? null : selectedIndex == null ? 0 : Math.max(0, Math.min(resultCount - 1, selectedIndex))
313
+ export const ensureFilePreviewScrollTop = (selectedIndex: number | null, scrollTop: number, resultCount: number, visibleRows = FILE_SEARCH_VISIBLE_ROWS) => {
314
+ if (!resultCount || selectedIndex == null) return 0
315
+ const maxScrollTop = Math.max(0, resultCount - visibleRows)
316
+ if (selectedIndex < scrollTop) return selectedIndex
317
+ if (selectedIndex >= scrollTop + visibleRows) return Math.min(maxScrollTop, selectedIndex - visibleRows + 1)
318
+ return Math.max(0, Math.min(maxScrollTop, scrollTop))
319
+ }
320
+ export const moveFilePreviewSelection = (preview: FilePreviewState, direction: -1 | 1) => {
321
+ if (!preview.results.length) return preview
322
+ const nextIndex = preview.selectedIndex == null
323
+ ? direction > 0 ? 0 : preview.results.length - 1
324
+ : (preview.selectedIndex + direction + preview.results.length) % preview.results.length
325
+ return {
326
+ ...preview,
327
+ selectedIndex: nextIndex,
328
+ scrollTop: ensureFilePreviewScrollTop(nextIndex, preview.scrollTop, preview.results.length),
329
+ }
330
+ }
331
+
332
+ const visibleFilePreviewResults = (preview: FilePreviewState) => preview.results.slice(preview.scrollTop, preview.scrollTop + FILE_SEARCH_VISIBLE_ROWS)
333
+ const selectedFilePreviewMatch = (state: TuiState) => {
334
+ const index = state.filePreview.selectedIndex
335
+ return index == null ? null : state.filePreview.results[index] ?? null
336
+ }
337
+ const highlightedSegments = (value: string, indices: number[]) => {
338
+ const marks = new Set(indices)
339
+ const chars = Array.from(value)
340
+ const segments: Array<{ text: string; highlighted: boolean }> = []
341
+ let current = ""
342
+ let highlighted = false
343
+ for (let index = 0; index < chars.length; index += 1) {
344
+ const nextHighlighted = marks.has(index)
345
+ if (current && nextHighlighted !== highlighted) {
346
+ segments.push({ text: current, highlighted })
347
+ current = ""
348
+ }
349
+ current += chars[index]
350
+ highlighted = nextHighlighted
351
+ }
352
+ if (current) segments.push({ text: current, highlighted })
353
+ return segments
354
+ }
355
+
356
+ export const summarizeStates = <T,>(items: T[], stateOf: (item: T) => string = item => `${(item as { status?: string }).status ?? "idle"}`, sizeOf: (item: T) => number = item => Number((item as { size?: number }).size) || 0, defaults: string[] = []): TransferSummaryStat[] => {
357
+ const order = ["draft", "pending", "queued", "offered", "accepted", "receiving", "sending", "awaiting-done", "cancelling", "complete", "rejected", "cancelled", "error"]
358
+ const buckets = new Map(defaults.map(state => [state, { state, count: 0, size: 0 } satisfies TransferSummaryStat]))
359
+ for (const item of items) {
360
+ const state = stateOf(item) || "idle"
361
+ const size = Number(sizeOf(item)) || 0
362
+ if (!buckets.has(state)) buckets.set(state, { state, count: 0, size: 0 })
363
+ const bucket = buckets.get(state)!
364
+ bucket.count += 1
365
+ bucket.size += size
366
+ }
367
+ return [...buckets.values()].sort((left, right) => {
368
+ const leftIndex = order.indexOf(left.state)
369
+ const rightIndex = order.indexOf(right.state)
370
+ return (leftIndex < 0 ? order.length : leftIndex) - (rightIndex < 0 ? order.length : rightIndex) || left.state.localeCompare(right.state)
371
+ })
372
+ }
373
+
374
+ export const transferSummaryStats = (items: TransferSnapshot[], now = Date.now()): TransferSummaryStat[] => {
375
+ let totalDuration = 0
376
+ let durationCount = 0
377
+ for (const transfer of items) {
378
+ const duration = transferActualDurationMs(transfer, now)
379
+ if (!Number.isFinite(duration)) continue
380
+ totalDuration += duration
381
+ durationCount += 1
382
+ }
383
+ return [
384
+ ...summarizeStates(items),
385
+ { state: "duration", label: "duration", count: durationCount, size: 0, countText: durationCount ? formatDuration(totalDuration) : "—", sizeText: "" },
386
+ ]
387
+ }
388
+
389
+ export const groupTransfersByPeer = (transfers: TransferSnapshot[], peers: PeerSnapshot[]): TransferGroup[] => {
390
+ const peersById = new Map(peers.map(peer => [peer.id, peer] as const))
391
+ const groups = new Map<string, TransferGroup>()
392
+ for (const transfer of transfers) {
393
+ const key = transfer.peerId || `${transfer.direction}:${transfer.peerName || transfer.id}`
394
+ if (!groups.has(key)) {
395
+ const peer = peersById.get(transfer.peerId)
396
+ groups.set(key, {
397
+ key,
398
+ name: peer?.displayName ?? displayPeerName(transfer.peerName || fallbackName, transfer.peerId),
399
+ items: [],
400
+ })
401
+ }
402
+ groups.get(key)!.items.push(transfer)
403
+ }
404
+ return [...groups.values()]
405
+ }
406
+
407
+ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents = false): TuiState => {
408
+ const sessionSeed = normalizeSessionSeed(initialConfig)
409
+ const autoAcceptIncoming = initialConfig.autoAcceptIncoming ?? true
410
+ const autoSaveIncoming = initialConfig.autoSaveIncoming ?? true
411
+ const session = makeSession(sessionSeed, autoAcceptIncoming, autoSaveIncoming)
412
+ const focusState = deriveBootFocusState(sessionSeed.name)
413
+ return {
414
+ session,
415
+ sessionSeed,
416
+ snapshot: session.snapshot(),
417
+ focusedId: null,
418
+ roomInput: sessionSeed.room,
419
+ nameInput: visibleNameInput(sessionSeed.name),
420
+ ...focusState,
421
+ draftInput: "",
422
+ draftInputKeyVersion: 0,
423
+ filePreview: emptyFilePreviewState(),
424
+ drafts: [],
425
+ autoOfferOutgoing: true,
426
+ autoAcceptIncoming,
427
+ autoSaveIncoming,
428
+ hideTerminalPeers: true,
429
+ eventsExpanded: showEvents,
430
+ offeringDrafts: false,
431
+ notice: { text: "Tab focus", variant: "info" },
432
+ }
433
+ }
434
+
435
+ const toggleButton = (id: string, label: string, active: boolean, onPress: TuiAction, disabled = false) => ui.button({
436
+ id,
437
+ label,
438
+ disabled,
439
+ onPress,
440
+ intent: toggleIntent(active),
441
+ dsVariant: active ? "solid" : "outline",
442
+ })
443
+
444
+ const actionButton = (id: string, label: string, onPress: TuiAction, intent: "secondary" | "warning" | "danger" | "success" | "primary" | "link" = "secondary", disabled = false) => ui.button({
445
+ id,
446
+ label,
447
+ disabled,
448
+ onPress,
449
+ intent,
450
+ dsVariant: intent === "secondary" ? "outline" : "soft",
451
+ })
452
+
453
+ const ghostButton = (id: string, label: string, onPress?: TuiAction, options: { disabled?: boolean; focusable?: boolean } = {}) => ui.button({
454
+ id,
455
+ label,
456
+ ...(options.disabled === undefined ? {} : { disabled: options.disabled }),
457
+ ...(options.focusable === undefined ? {} : { focusable: options.focusable }),
458
+ ...(onPress === undefined ? {} : { onPress }),
459
+ intent: "secondary",
460
+ dsVariant: "ghost",
461
+ })
462
+
463
+ const denseSection = (options: DenseSectionOptions, children: readonly DenseSectionChild[]) => ui.box({
464
+ ...(options.id === undefined ? {} : { id: options.id }),
465
+ ...(options.key === undefined ? {} : { key: options.key }),
466
+ ...(options.flex === undefined ? {} : { flex: options.flex }),
467
+ border: options.border ?? "rounded",
468
+ p: 0,
469
+ }, [
470
+ ui.column({ gap: 0, ...(options.flex === undefined ? {} : { height: "full" as const }) }, [
471
+ options.title !== undefined || options.titleNode !== undefined || (options.actions?.length ?? 0) > 0
472
+ ? ui.row({ gap: 0, items: "center", wrap: true }, [
473
+ options.titleNode ?? (options.title !== undefined ? ui.text(options.title, { variant: "heading" }) : null),
474
+ (options.actions?.length ?? 0) > 0 ? ui.spacer({ flex: 1 }) : null,
475
+ ...(options.actions ?? []),
476
+ ])
477
+ : null,
478
+ options.subtitle !== undefined ? ui.text(options.subtitle, { dim: true }) : null,
479
+ ...children,
480
+ ]),
481
+ ])
482
+
483
+ const renderHeaderBrand = () => ui.row({ id: "brand-title", gap: 1, items: "center" }, [
484
+ ghostButton("brand-icon", "📤", undefined, { focusable: false }),
485
+ ui.text("Send", { id: "brand-label", variant: "heading" }),
486
+ ])
487
+
488
+ const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
489
+ id: "header-shell",
490
+ titleNode: renderHeaderBrand(),
491
+ actions: [toggleButton("toggle-events", "Events", state.eventsExpanded, actions.toggleEvents)],
492
+ }, [])
493
+
494
+ const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
495
+ id: "room-card",
496
+ }, [
497
+ ui.row({ gap: 0, items: "center" }, [
498
+ ghostButton("new-room", "🏠", actions.jumpToRandomRoom),
499
+ ui.box({ flex: 1 }, [
500
+ ui.input({
501
+ id: ROOM_INPUT_ID,
502
+ value: state.roomInput,
503
+ placeholder: "room",
504
+ onInput: value => actions.setRoomInput(value),
505
+ onBlur: actions.commitRoom,
506
+ }),
507
+ ]),
508
+ ]),
509
+ ])
510
+
511
+ const renderSelfMetric = (label: string, value: string) => ui.box({ flex: 1, minWidth: 12, border: "single", borderStyle: METRIC_BORDER_STYLE }, [
512
+ ui.column({ gap: 0 }, [
513
+ ui.text(label, { variant: "caption" }),
514
+ tightTag(value || "—", { variant: statusToneVariant(value), bare: true }),
515
+ ]),
516
+ ])
517
+
518
+ const renderSelfProfileLine = (value: string) => ui.text(value || "—")
519
+ const formatPeerRtt = (value: number) => Number.isFinite(value) ? `${countFormat.format(Math.round(value))}ms` : "—"
520
+ const renderPeerMetric = (label: string, value: string, asTag = false) => ui.box({ flex: 1, minWidth: 10, border: "single", borderStyle: METRIC_BORDER_STYLE }, [
521
+ ui.column({ gap: 0 }, [
522
+ ui.text(label, { variant: "caption" }),
523
+ asTag ? tightTag(value || "—", { variant: statusToneVariant(value), bare: true }) : ui.text(value || "—"),
524
+ ]),
525
+ ])
526
+
527
+ const renderSelfCard = (state: TuiState, actions: TuiActions) => denseSection({
528
+ id: "self-card",
529
+ }, [
530
+ ui.row({ gap: 0, items: "center" }, [
531
+ ghostButton("new-self", "🙂", actions.jumpToNewSelf),
532
+ ui.box({ flex: 1 }, [
533
+ ui.input({
534
+ id: NAME_INPUT_ID,
535
+ value: state.nameInput,
536
+ placeholder: fallbackName,
537
+ onInput: value => actions.setNameInput(value),
538
+ onBlur: actions.commitName,
539
+ }),
540
+ ]),
541
+ ui.text(`-${state.snapshot.localId}`),
542
+ ]),
543
+ ui.row({ gap: 0, wrap: true }, [
544
+ renderSelfMetric("Signaling", state.snapshot.socketState),
545
+ renderSelfMetric("Pulse", state.snapshot.pulse.state),
546
+ renderSelfMetric("TURN", state.snapshot.turnState),
547
+ ]),
548
+ ui.column({ gap: 0 }, [
549
+ renderSelfProfileLine(geoSummary(state.snapshot.profile)),
550
+ renderSelfProfileLine(netSummary(state.snapshot.profile)),
551
+ renderSelfProfileLine(uaSummary(state.snapshot.profile)),
552
+ renderSelfProfileLine(profileIp(state.snapshot.profile)),
553
+ ]),
554
+ ])
555
+
556
+ const renderPeerRow = (peer: PeerSnapshot, actions: TuiActions) => denseSection({
557
+ id: `peer-row-${peer.id}`,
558
+ key: peer.id,
559
+ }, [
560
+ ui.column({ gap: 0 }, [
561
+ ui.row({ id: `peer-head-${peer.id}`, gap: 0, items: "center" }, [
562
+ ui.box({ id: `peer-toggle-slot-${peer.id}`, width: 7, pl: 1, border: "single", borderStyle: TRANSPARENT_BORDER_STYLE }, [
563
+ ui.checkbox({
564
+ id: `peer-toggle-${peer.id}`,
565
+ checked: peer.selectable && peer.selected,
566
+ disabled: !peer.selectable,
567
+ accessibleLabel: `select ${peer.displayName}`,
568
+ focusConfig: { indicator: "none", showHint: false },
569
+ onChange: checked => {
570
+ if (checked !== peer.selected) actions.togglePeer(peer.id)
571
+ },
572
+ }),
573
+ ]),
574
+ ui.box({ id: `peer-name-slot-${peer.id}`, flex: 1, minWidth: 0, border: "none" }, [
575
+ ui.text(peer.displayName, {
576
+ id: `peer-name-text-${peer.id}`,
577
+ textOverflow: "ellipsis",
578
+ }),
579
+ ]),
580
+ ui.row({ id: `peer-status-cluster-${peer.id}`, gap: 1, items: "center" }, [
581
+ ui.status(peerConnectionStatusKind(peer.status), { label: peer.status || "unknown", showLabel: true }),
582
+ tightTag(peerDefaultsToken(peer.profile), { variant: peerDefaultsVariant(peer.profile), bare: true }),
583
+ ]),
584
+ ]),
585
+ ui.row({ gap: 0 }, [
586
+ renderPeerMetric("RTT", formatPeerRtt(peer.rttMs)),
587
+ renderPeerMetric("Data", peer.dataState, true),
588
+ ]),
589
+ ui.row({ gap: 0 }, [
590
+ renderPeerMetric("TURN", peer.turnState, true),
591
+ renderPeerMetric("Path", peer.pathLabel || "—"),
592
+ ]),
593
+ ui.column({ gap: 0 }, [
594
+ renderSelfProfileLine(geoSummary(peer.profile)),
595
+ renderSelfProfileLine(netSummary(peer.profile)),
596
+ renderSelfProfileLine(uaSummary(peer.profile)),
597
+ renderSelfProfileLine(profileIp(peer.profile)),
598
+ ]),
599
+ peer.lastError ? ui.callout(peer.lastError, { variant: "error" }) : null,
600
+ ]),
601
+ ])
602
+
603
+ const renderPeersCard = (state: TuiState, actions: TuiActions) => {
604
+ const peers = visiblePeers(state.snapshot.peers, state.hideTerminalPeers)
605
+ const activeCount = state.snapshot.peers.filter(peer => peer.presence === "active").length
606
+ const selectedCount = state.snapshot.peers.filter(peer => peer.selectable && peer.selected).length
607
+ return denseSection({
608
+ id: "peers-card",
609
+ title: `Peers ${selectedCount}/${activeCount}`,
610
+ flex: 1,
611
+ actions: [
612
+ actionButton("select-ready-peers", "All", actions.toggleSelectReadyPeers),
613
+ actionButton("clear-peer-selection", "None", actions.clearPeerSelection),
614
+ toggleButton("toggle-clean-peers", "Clean", state.hideTerminalPeers, actions.toggleHideTerminalPeers),
615
+ ],
616
+ }, [
617
+ ui.box({ id: "peers-list", flex: 1, minHeight: 0, overflow: "scroll", border: "none" }, [
618
+ peers.length
619
+ ? ui.column({ gap: 0 }, peers.map(peer => renderPeerRow(peer, actions)))
620
+ : ui.empty(`Waiting for peers in ${state.snapshot.room}...`),
621
+ ]),
622
+ ])
623
+ }
624
+
625
+ const renderDraftRow = (draft: DraftItem, actions: TuiActions) => ui.row({ key: draft.id, gap: 0, items: "center" }, [
626
+ ui.box({ flex: 1 }, [
627
+ ui.column({ gap: 0 }, [
628
+ ui.text(draft.name),
629
+ ui.text(formatBytes(draft.size), { style: { dim: true } }),
630
+ ]),
631
+ ]),
632
+ actionButton(`remove-draft-${draft.id}`, "✕", () => actions.removeDraft(draft.id), "warning"),
633
+ ])
634
+
635
+ const renderDraftSummary = (drafts: DraftItem[]) => denseSection({
636
+ id: "drafts-summary",
637
+ title: "Total",
638
+ border: "single",
639
+ }, [
640
+ ui.row({ gap: 1, wrap: true }, summarizeStates(drafts, () => "draft", draft => draft.size, ["draft"]).map(renderSummaryStat)),
641
+ ])
642
+
643
+ const renderHighlightedPreviewPath = (value: string, indices: number[], options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
644
+ gap: 0,
645
+ wrap: true,
646
+ ...(options.id === undefined ? {} : { id: options.id }),
647
+ ...(options.key === undefined ? {} : { key: options.key }),
648
+ ...(options.flex === undefined ? {} : { flex: options.flex }),
649
+ }, highlightedSegments(value, indices).map((segment, index) =>
650
+ ui.text(segment.text, { key: `segment-${index}`, ...(segment.highlighted ? { style: { bold: true } } : {}) }),
651
+ ))
652
+
653
+ const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: boolean, displayPrefix: string) => ui.row({
654
+ id: `file-preview-row-${index}`,
655
+ key: `${match.kind}:${match.relativePath}`,
656
+ gap: 1,
657
+ wrap: true,
658
+ }, [
659
+ ui.text(selected ? ">" : " "),
660
+ renderHighlightedPreviewPath(
661
+ formatFileSearchDisplayPath(displayPrefix, match.relativePath),
662
+ offsetFileSearchMatchIndices(displayPrefix, match.indices),
663
+ { id: `file-preview-path-${index}`, flex: 1 },
664
+ ),
665
+ match.kind === "directory" ? tightTag("dir", { variant: "info", bare: true }) : null,
666
+ ])
667
+
668
+ const renderFilePreview = (state: TuiState) => {
669
+ if (!filePreviewVisible(state)) return null
670
+ const preview = state.filePreview
671
+ const rows = visibleFilePreviewResults(preview)
672
+ const matchCountText = `${countFormat.format(preview.results.length)} ${preview.results.length === 1 ? "match" : "matches"}`
673
+ const statusText = preview.waiting
674
+ ? "searching..."
675
+ : preview.results.length
676
+ ? matchCountText
677
+ : "no matches"
678
+ return denseSection({
679
+ id: "draft-preview",
680
+ border: "single",
681
+ }, [
682
+ ui.text(statusText, { id: "draft-preview-status", style: { dim: true } }),
683
+ ui.text(preview.error || " ", { id: "draft-preview-error", style: { dim: true } }),
684
+ rows.length ? ui.column({ gap: 0 }, rows.map((match, offset) => renderFilePreviewRow(match, preview.scrollTop + offset, preview.selectedIndex === preview.scrollTop + offset, preview.displayPrefix))) : null,
685
+ ])
686
+ }
687
+
688
+ const renderSummaryStat = (stat: TransferSummaryStat) => {
689
+ const countText = stat.countText ?? countFormat.format(stat.count)
690
+ const sizeText = stat.sizeText ?? (stat.size ? formatBytes(stat.size) : "")
691
+ const text = `${stat.label || stat.state} ${countText}${sizeText ? ` ${sizeText}` : ""}`
692
+ const variant = stat.state === "duration" ? "default" : statusVariant(stat.state as TransferSnapshot["status"])
693
+ return tightTag(text, { variant, bare: true })
694
+ }
695
+
696
+ const transferActionButtons = (transfer: TransferSnapshot, actions: TuiActions): VNode[] => {
697
+ if (transfer.status === "pending") {
698
+ return [
699
+ actionButton(`reject-${transfer.id}`, "Reject", () => actions.rejectTransfer(transfer.id), "warning"),
700
+ actionButton(`accept-${transfer.id}`, "Accept", () => actions.acceptTransfer(transfer.id), "success"),
701
+ ]
702
+ }
703
+ if (!["complete", "rejected", "cancelled", "error"].includes(transfer.status)) {
704
+ return [actionButton(`cancel-${transfer.id}`, "Cancel", () => actions.cancelTransfer(transfer.id), "warning")]
705
+ }
706
+ if (transfer.direction === "in" && transfer.status === "complete" && !transfer.savedAt) {
707
+ return [actionButton(`save-${transfer.id}`, "Save", () => actions.saveTransfer(transfer.id), "success")]
708
+ }
709
+ return []
710
+ }
711
+
712
+ const renderTransferFact = (label: string, value: string) => ui.box({ minWidth: 12 }, [
713
+ ui.column({ gap: 0 }, [
714
+ ui.text(label, { variant: "caption" }),
715
+ ui.text(value),
716
+ ]),
717
+ ])
718
+
719
+ const transferPathLabel = (transfer: TransferSnapshot, peersById: Map<string, PeerSnapshot>) => peersById.get(transfer.peerId)?.pathLabel || "—"
720
+
721
+ const renderTransferRow = (transfer: TransferSnapshot, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => {
722
+ const hasStarted = !!transfer.startedAt
723
+ const facts = [
724
+ renderTransferFact("Size", formatBytes(transfer.size)),
725
+ renderTransferFact("Path", transferPathLabel(transfer, peersById)),
726
+ renderTransferFact("Created", formatClockTime(transfer.createdAt)),
727
+ !hasStarted ? renderTransferFact("Waiting", formatDuration(transferWaitDurationMs(transfer, now))) : null,
728
+ hasStarted ? renderTransferFact("Start", formatClockTime(transfer.startedAt)) : null,
729
+ hasStarted ? renderTransferFact("End", formatClockTime(transfer.endedAt)) : null,
730
+ hasStarted ? renderTransferFact("Duration", formatDuration(transferActualDurationMs(transfer, now))) : null,
731
+ ].filter(Boolean) as VNode[]
732
+
733
+ return denseSection({
734
+ key: transfer.id,
735
+ title: `${transfer.direction === "out" ? "→" : "←"} ${transfer.name}`,
736
+ actions: transferActionButtons(transfer, actions),
737
+ }, [
738
+ ui.row({ gap: 1, wrap: true }, [
739
+ tightTag(transfer.status, { variant: statusVariant(transfer.status), bare: true }),
740
+ transfer.error ? tightTag("error", { variant: "error", bare: true }) : null,
741
+ ]),
742
+ ui.row({ gap: 0, wrap: true }, facts),
743
+ ui.progress(transferProgress(transfer), { showPercent: true, label: `${percentFormat.format(transfer.progress)}%` }),
744
+ ui.row({ gap: 0, wrap: true }, [
745
+ renderTransferFact("Speed", hasStarted ? transfer.speedText : "—"),
746
+ renderTransferFact("ETA", transfer.status === "sending" || transfer.status === "receiving" ? transfer.etaText : "—"),
747
+ ]),
748
+ transfer.error ? ui.callout(transfer.error, { variant: "error" }) : null,
749
+ ])
750
+ }
751
+
752
+ const renderTransferGroup = (group: TransferGroup, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => denseSection({
753
+ key: `group-${group.key}`,
754
+ title: group.name,
755
+ }, [
756
+ ui.row({ gap: 1, wrap: true }, transferSummaryStats(group.items, now).map(renderSummaryStat)),
757
+ ui.column({ gap: 0 }, group.items.map(transfer => renderTransferRow(transfer, peersById, actions, now))),
758
+ ])
759
+
760
+ const renderTransferSection = (section: TransferSection, peersById: Map<string, PeerSnapshot>, actions: TuiActions, now = Date.now()) => {
761
+ if (!section.items.length) return null
762
+ const groups = groupTransfersByPeer(section.items, [...peersById.values()])
763
+ const pendingOfferCount = section.title === "Pending" ? section.items.filter(isPendingOffer).length : 0
764
+ const children: VNode[] = []
765
+ if (groups.length > 1) {
766
+ children.push(denseSection({ key: `${section.title}-total`, title: "Total" }, [
767
+ ui.row({ gap: 1, wrap: true }, transferSummaryStats(section.items, now).map(renderSummaryStat)),
768
+ ]))
769
+ }
770
+ children.push(...groups.map(group => renderTransferGroup(group, peersById, actions, now)))
771
+ return denseSection({
772
+ id: `${section.title.toLowerCase()}-card`,
773
+ title: section.title,
774
+ actions: section.title === "Pending"
775
+ ? [actionButton("cancel-pending", "Cancel", actions.cancelPendingOffers, "warning", pendingOfferCount === 0)]
776
+ : section.clearAction === "completed"
777
+ ? [actionButton("clear-completed", "Clear", actions.clearCompleted, "warning")]
778
+ : section.clearAction === "failed"
779
+ ? [actionButton("clear-failed", "Clear", actions.clearFailed, "warning")]
780
+ : [],
781
+ }, children)
782
+ }
783
+
784
+ const renderFilesCard = (state: TuiState, actions: TuiActions) => denseSection({
785
+ id: "files-card",
786
+ title: "Files",
787
+ actions: [
788
+ ui.row({ id: "files-actions", gap: 1, items: "center", wrap: true }, [
789
+ toggleButton("toggle-offer", "Offer", state.autoOfferOutgoing, actions.toggleAutoOffer),
790
+ ui.row({ id: "files-mode-actions", gap: 0, items: "center" }, [
791
+ toggleButton("toggle-accept", "Accept", state.autoAcceptIncoming, actions.toggleAutoAccept),
792
+ toggleButton("toggle-save", "Save", state.autoSaveIncoming, actions.toggleAutoSave),
793
+ ]),
794
+ actionButton("clear-drafts", "Clear", actions.clearDrafts, "warning"),
795
+ ]),
796
+ ],
797
+ }, [
798
+ ui.row({ id: "files-input-row", gap: 0, items: "center" }, [
799
+ ui.box({ flex: 1 }, [
800
+ ui.input({
801
+ id: DRAFT_INPUT_ID,
802
+ key: `draft-input-${state.draftInputKeyVersion}`,
803
+ value: state.draftInput,
804
+ placeholder: "path/to/file.txt",
805
+ onInput: value => actions.setDraftInput(value),
806
+ }),
807
+ ]),
808
+ actionButton("add-drafts", "Add", actions.addDrafts, "primary", !state.draftInput.trim()),
809
+ ]),
810
+ renderFilePreview(state),
811
+ state.offeringDrafts ? ui.row({ gap: 0, items: "center" }, [ui.spinner({ label: "Offering drafts..." })]) : null,
812
+ state.drafts.length > 1 ? renderDraftSummary(state.drafts) : null,
813
+ state.drafts.length
814
+ ? ui.box({ id: "drafts-view", maxHeight: 10, overflow: "scroll" }, [
815
+ ui.column({ gap: 0 }, state.drafts.map(draft => renderDraftRow(draft, actions))),
816
+ ])
817
+ : null,
818
+ ])
819
+
820
+ const renderLogRow = (log: LogEntry) => denseSection({
821
+ key: log.id,
822
+ title: log.kind,
823
+ subtitle: timeFormat.format(log.at),
824
+ }, [
825
+ ui.text(shortText(log.payload), { style: { dim: true } }),
826
+ ])
827
+
828
+ const renderEventsCard = (state: TuiState, actions: TuiActions) => denseSection({
829
+ id: "events-card",
830
+ title: "Events",
831
+ actions: [
832
+ actionButton("clear-events", "Clear", actions.clearLogs, "warning", !state.snapshot.logs.length),
833
+ actionButton("hide-events", "Hide", actions.toggleEvents),
834
+ ],
835
+ }, [
836
+ ui.box({ maxHeight: 24, overflow: "scroll" }, [
837
+ state.snapshot.logs.length
838
+ ? ui.column({ gap: 0 }, state.snapshot.logs.slice(0, 20).map(renderLogRow))
839
+ : ui.empty("No events"),
840
+ ]),
841
+ ])
842
+
843
+ const renderFooterHint = (id: string, keycap: string, label: string) => ui.row({ id, gap: 0, items: "center" }, [
844
+ ui.kbd(keycap),
845
+ ui.text(` ${label}`, { style: { dim: true } }),
846
+ ])
847
+
848
+ const renderFooter = (state: TuiState) => ui.statusBar({
849
+ id: "footer-shell",
850
+ left: [ui.callout(state.notice.text, { variant: state.notice.variant })],
851
+ right: [
852
+ ui.toolbar({ id: "footer-hints", gap: 3 }, [
853
+ renderFooterHint("footer-hint-tab", "tab", "focus/accept"),
854
+ renderFooterHint("footer-hint-enter", "enter", "accept/add"),
855
+ renderFooterHint("footer-hint-esc", "esc", "hide/reset"),
856
+ renderFooterHint("footer-hint-ctrlc", "ctrl+c", "quit"),
857
+ ]),
858
+ ],
859
+ })
860
+
861
+ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
862
+ const peers = visiblePeers(state.snapshot.peers, state.hideTerminalPeers)
863
+ const peersById = new Map(peers.map(peer => [peer.id, peer] as const))
864
+ const now = Date.now()
865
+ const transferCards = transferSections(state.snapshot)
866
+ .map(section => renderTransferSection(section, peersById, actions, now))
867
+ .filter((section): section is VNode => !!section)
868
+
869
+ const page = ui.page({
870
+ header: renderHeader(state, actions),
871
+ body: ui.row({ id: "body-shell", gap: 1, items: "stretch", flex: 1, minHeight: 0 }, [
872
+ ui.column({ id: "sidebar", width: 45, minWidth: 36, maxWidth: 51, gap: 0, minHeight: 0 }, [
873
+ renderRoomCard(state, actions),
874
+ renderSelfCard(state, actions),
875
+ renderPeersCard(state, actions),
876
+ ]),
877
+ ui.box({ id: "main-scroll", flex: 1, minHeight: 0, overflow: "scroll", border: "none" }, [
878
+ ui.column({ gap: 0 }, [
879
+ renderFilesCard(state, actions),
880
+ ...transferCards,
881
+ ]),
882
+ ]),
883
+ state.eventsExpanded ? ui.box({ id: "events-shell", width: 28, minHeight: 0 }, [renderEventsCard(state, actions)]) : null,
884
+ ]),
885
+ footer: renderFooter(state),
886
+ p: 0,
887
+ gap: 0,
888
+ })
889
+
890
+ return state.pendingFocusTarget
891
+ ? ui.focusTrap({
892
+ id: `focus-request-${state.focusRequestEpoch}`,
893
+ key: `focus-request-${state.focusRequestEpoch}`,
894
+ active: true,
895
+ initialFocus: state.pendingFocusTarget,
896
+ }, [page])
897
+ : page
898
+ }
899
+
900
+ const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
901
+
902
+ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, filePreview: FilePreviewState, notice: Notice): TuiState =>
903
+ withNotice({
904
+ ...state,
905
+ draftInput,
906
+ draftInputKeyVersion: state.draftInputKeyVersion + 1,
907
+ filePreview,
908
+ pendingFocusTarget: DRAFT_INPUT_ID,
909
+ focusRequestEpoch: state.focusRequestEpoch + 1,
910
+ }, notice)
911
+
912
+ export const startTui = async (initialConfig: SessionConfig, showEvents = false) => {
913
+ installCheckboxClickPatch()
914
+ const initialState = createInitialTuiState(initialConfig, showEvents)
915
+ const app = createNodeApp<TuiState>({ initialState })
916
+ let state = initialState
917
+ let unsubscribe = () => {}
918
+ let stopping = false
919
+ let cleanedUp = false
920
+ let updateQueued = false
921
+ const previewBaseRoot = process.cwd()
922
+ let previewWorker: Worker | null = null
923
+ let previewSessionId: string | null = null
924
+ let previewSessionRoot: string | null = null
925
+
926
+ const flushUpdate = () => {
927
+ if (updateQueued || stopping || cleanedUp) return
928
+ updateQueued = true
929
+ queueMicrotask(() => {
930
+ updateQueued = false
931
+ if (stopping || cleanedUp) return
932
+ try {
933
+ app.update(state)
934
+ } catch (error) {
935
+ const message = error instanceof Error ? error.message : `${error}`
936
+ if (message.includes("lifecycle operation already in flight")) {
937
+ setTimeout(flushUpdate, 0)
938
+ return
939
+ }
940
+ if (message.includes("app is Disposed")) return
941
+ throw error
942
+ }
943
+ })
944
+ }
945
+
946
+ const commit = (updater: TuiState | ((prev: Readonly<TuiState>) => TuiState)) => {
947
+ state = typeof updater === "function" ? updater(state) : updater
948
+ flushUpdate()
949
+ return state
950
+ }
951
+
952
+ const requestStop = () => {
953
+ if (stopping) return
954
+ stopping = true
955
+ void app.stop()
956
+ }
957
+
958
+ const resetFilePreview = (overrides: Partial<FilePreviewState> = {}): FilePreviewState => ({
959
+ ...emptyFilePreviewState(),
960
+ ...overrides,
961
+ })
962
+
963
+ const ensurePreviewSession = (workspaceRoot: string) => {
964
+ if (previewWorker && previewSessionId && previewSessionRoot === workspaceRoot) return
965
+ if (previewWorker || previewSessionId) stopPreviewSession()
966
+ previewSessionId = uid(8)
967
+ previewSessionRoot = workspaceRoot
968
+ previewWorker = new Worker(new URL("./file-search.worker.ts", import.meta.url).href, { type: "module" })
969
+ previewWorker.onmessage = ({ data }: MessageEvent<FileSearchEvent>) => {
970
+ if (!previewSessionId || data.sessionId !== previewSessionId) return
971
+ if (data.type === "update") {
972
+ commit(current => {
973
+ if (current.filePreview.pendingQuery !== data.query || current.filePreview.workspaceRoot !== previewSessionRoot) return current
974
+ const selectedIndex = clampFilePreviewSelectedIndex(current.filePreview.selectedIndex, data.matches.length)
975
+ return {
976
+ ...current,
977
+ filePreview: {
978
+ ...current.filePreview,
979
+ displayQuery: data.query,
980
+ pendingQuery: data.query,
981
+ waiting: !data.walkComplete,
982
+ error: null,
983
+ results: data.matches,
984
+ selectedIndex,
985
+ scrollTop: ensureFilePreviewScrollTop(selectedIndex, current.filePreview.scrollTop, data.matches.length),
986
+ },
987
+ }
988
+ })
989
+ return
990
+ }
991
+ if (data.type === "complete") {
992
+ commit(current => {
993
+ if (current.filePreview.pendingQuery !== data.query || current.filePreview.workspaceRoot !== previewSessionRoot) return current
994
+ return {
995
+ ...current,
996
+ filePreview: {
997
+ ...current.filePreview,
998
+ displayQuery: data.query,
999
+ waiting: false,
1000
+ error: null,
1001
+ },
1002
+ }
1003
+ })
1004
+ return
1005
+ }
1006
+ commit(current => {
1007
+ if (current.filePreview.pendingQuery !== data.query || current.filePreview.workspaceRoot !== previewSessionRoot) return current
1008
+ return {
1009
+ ...current,
1010
+ filePreview: {
1011
+ ...current.filePreview,
1012
+ waiting: false,
1013
+ error: data.message,
1014
+ displayQuery: data.query,
1015
+ },
1016
+ }
1017
+ })
1018
+ }
1019
+ previewWorker.onerror = event => {
1020
+ commit(current => ({
1021
+ ...current,
1022
+ filePreview: {
1023
+ ...current.filePreview,
1024
+ waiting: false,
1025
+ error: event.message || "File preview worker failed.",
1026
+ },
1027
+ }))
1028
+ }
1029
+ previewWorker.postMessage({
1030
+ type: "create-session",
1031
+ sessionId: previewSessionId,
1032
+ workspaceRoot,
1033
+ } satisfies FileSearchRequest)
1034
+ }
1035
+
1036
+ const stopPreviewSession = () => {
1037
+ if (!previewWorker || !previewSessionId) return
1038
+ previewWorker.postMessage({ type: "dispose-session", sessionId: previewSessionId } satisfies FileSearchRequest)
1039
+ previewWorker.terminate()
1040
+ previewWorker = null
1041
+ previewSessionId = null
1042
+ previewSessionRoot = null
1043
+ }
1044
+
1045
+ const requestFilePreview = (value: string) => {
1046
+ const scope = deriveFileSearchScope(value, previewBaseRoot)
1047
+ if (!scope) {
1048
+ stopPreviewSession()
1049
+ return null
1050
+ }
1051
+ ensurePreviewSession(scope.workspaceRoot)
1052
+ if (!previewWorker || !previewSessionId) return
1053
+ previewWorker.postMessage({ type: "update-query", sessionId: previewSessionId, query: scope.query } satisfies FileSearchRequest)
1054
+ return scope
1055
+ }
1056
+
1057
+ const updateDraftInput = (value: string) => {
1058
+ const scope = deriveFileSearchScope(value, previewBaseRoot)
1059
+ const shouldDispose = !scope || state.filePreview.dismissedQuery === value
1060
+ commit(current => {
1061
+ if (!scope) return { ...current, draftInput: value, filePreview: resetFilePreview() }
1062
+ const shouldDismiss = current.filePreview.dismissedQuery === value
1063
+ const rootChanged = current.filePreview.workspaceRoot !== scope.workspaceRoot
1064
+ const basePreview = rootChanged ? resetFilePreview() : current.filePreview
1065
+ return {
1066
+ ...current,
1067
+ draftInput: value,
1068
+ filePreview: {
1069
+ ...basePreview,
1070
+ workspaceRoot: scope.workspaceRoot,
1071
+ displayPrefix: scope.displayPrefix,
1072
+ dismissedQuery: shouldDismiss ? current.filePreview.dismissedQuery : null,
1073
+ pendingQuery: shouldDismiss ? current.filePreview.pendingQuery : scope.query,
1074
+ waiting: shouldDismiss ? false : true,
1075
+ error: null,
1076
+ },
1077
+ }
1078
+ })
1079
+ if (shouldDispose) {
1080
+ stopPreviewSession()
1081
+ return
1082
+ }
1083
+ requestFilePreview(value)
1084
+ }
1085
+
1086
+ const acceptSelectedFilePreview = () => {
1087
+ const match = selectedFilePreviewMatch(state)
1088
+ if (!match || !filePreviewVisible(state)) return false
1089
+ const displayPath = formatFileSearchDisplayPath(state.filePreview.displayPrefix, match.relativePath)
1090
+ if (match.kind === "directory") {
1091
+ const nextValue = `${displayPath}/`
1092
+ const nextScope = deriveFileSearchScope(nextValue, previewBaseRoot)
1093
+ if (!nextScope) return false
1094
+ commit(current => withAcceptedDraftInput(current, nextValue, {
1095
+ ...resetFilePreview(),
1096
+ workspaceRoot: nextScope.workspaceRoot,
1097
+ displayPrefix: nextScope.displayPrefix,
1098
+ dismissedQuery: null,
1099
+ pendingQuery: nextScope.query,
1100
+ waiting: true,
1101
+ error: null,
1102
+ selectedIndex: null,
1103
+ scrollTop: 0,
1104
+ }, { text: `Browsing ${nextValue}`, variant: "info" }))
1105
+ requestFilePreview(nextValue)
1106
+ return true
1107
+ }
1108
+ commit(current => withAcceptedDraftInput(
1109
+ current,
1110
+ displayPath,
1111
+ resetFilePreview({ dismissedQuery: displayPath }),
1112
+ { text: `Selected ${displayPath}.`, variant: "success" },
1113
+ ))
1114
+ stopPreviewSession()
1115
+ return true
1116
+ }
1117
+
1118
+ const maybeOfferDrafts = () => {
1119
+ if (!state.autoOfferOutgoing || !state.drafts.length || state.offeringDrafts) return
1120
+ if (!state.snapshot.peers.some(peer => peer.presence === "active" && peer.ready && peer.selected)) return
1121
+ const session = state.session
1122
+ const pendingDrafts = [...state.drafts]
1123
+ commit(current => ({ ...current, offeringDrafts: true }))
1124
+ void session.offerToSelectedPeers(pendingDrafts.map(draft => draft.path)).then(
1125
+ ids => {
1126
+ if (state.session !== session) return
1127
+ const offeredIds = new Set(pendingDrafts.map(draft => draft.id))
1128
+ commit(current => withNotice({
1129
+ ...current,
1130
+ drafts: current.drafts.filter(draft => !offeredIds.has(draft.id)),
1131
+ offeringDrafts: false,
1132
+ }, { text: `Queued ${plural(ids.length, "transfer")}.`, variant: "success" }))
1133
+ },
1134
+ error => {
1135
+ if (state.session !== session) return
1136
+ commit(current => withNotice({ ...current, offeringDrafts: false }, { text: `${error}`, variant: "error" }))
1137
+ },
1138
+ )
1139
+ }
1140
+
1141
+ const bindSession = (session: SendSession) => {
1142
+ unsubscribe()
1143
+ unsubscribe = session.subscribe(() => {
1144
+ commit(current => current.session === session ? { ...current, snapshot: session.snapshot() } : current)
1145
+ maybeOfferDrafts()
1146
+ })
1147
+ commit(current => current.session === session ? { ...current, snapshot: session.snapshot() } : current)
1148
+ void session.connect().catch(error => {
1149
+ if (state.session !== session) return
1150
+ commit(current => withNotice(current, { text: `${error}`, variant: "error" }))
1151
+ })
1152
+ }
1153
+
1154
+ const replaceSession = (nextSeed: SessionSeed, text: string, options: { reseedBootFocus?: boolean } = {}) => {
1155
+ const previousSession = state.session
1156
+ const nextSession = makeSession(nextSeed, state.autoAcceptIncoming, state.autoSaveIncoming)
1157
+ stopPreviewSession()
1158
+ commit(current => withNotice({
1159
+ ...current,
1160
+ session: nextSession,
1161
+ sessionSeed: nextSeed,
1162
+ snapshot: nextSession.snapshot(),
1163
+ roomInput: nextSeed.room,
1164
+ nameInput: visibleNameInput(nextSeed.name),
1165
+ draftInput: "",
1166
+ filePreview: resetFilePreview(),
1167
+ drafts: [],
1168
+ offeringDrafts: false,
1169
+ ...(options.reseedBootFocus
1170
+ ? deriveBootFocusState(nextSeed.name, current.focusRequestEpoch + 1)
1171
+ : {
1172
+ pendingFocusTarget: current.pendingFocusTarget,
1173
+ focusRequestEpoch: current.focusRequestEpoch,
1174
+ bootNameJumpPending: current.bootNameJumpPending,
1175
+ }),
1176
+ }, { text, variant: "success" }))
1177
+ bindSession(nextSession)
1178
+ void previousSession.close()
1179
+ }
1180
+
1181
+ const commitRoom = () => {
1182
+ const nextRoom = cleanRoom(state.roomInput)
1183
+ if (nextRoom === state.sessionSeed.room) {
1184
+ commit(current => current.roomInput === nextRoom ? current : withNotice({ ...current, roomInput: nextRoom }, { text: `Room ${nextRoom}.`, variant: "info" }))
1185
+ return
1186
+ }
1187
+ replaceSession({ ...state.sessionSeed, room: nextRoom }, `Joined room ${nextRoom}.`)
1188
+ }
1189
+
1190
+ const commitName = () => {
1191
+ const nextName = state.session.setName(state.nameInput)
1192
+ commit(current => scheduleBootNameJump(withNotice({
1193
+ ...current,
1194
+ nameInput: visibleNameInput(nextName),
1195
+ sessionSeed: { ...current.sessionSeed, name: nextName },
1196
+ snapshot: current.session.snapshot(),
1197
+ }, { text: `Self name is ${nextName}.`, variant: "success" })))
1198
+ }
1199
+
1200
+ const addDrafts = () => {
1201
+ const submittedInput = state.draftInput
1202
+ if (!normalizeSearchQuery(submittedInput)) {
1203
+ commit(current => withNotice(current, { text: "No file paths entered.", variant: "warning" }))
1204
+ return
1205
+ }
1206
+ void inspectLocalFile(submittedInput).then(file => {
1207
+ const shouldDispose = state.draftInput === submittedInput
1208
+ const created = {
1209
+ id: uid(10),
1210
+ path: file.path,
1211
+ name: file.name,
1212
+ size: file.size,
1213
+ createdAt: Date.now(),
1214
+ }
1215
+ commit(current => withNotice({
1216
+ ...current,
1217
+ draftInput: current.draftInput === submittedInput ? "" : current.draftInput,
1218
+ filePreview: current.draftInput === submittedInput ? resetFilePreview() : current.filePreview,
1219
+ drafts: [...current.drafts, created],
1220
+ }, { text: `Added ${plural(1, "draft file")}.`, variant: "success" }))
1221
+ if (shouldDispose) stopPreviewSession()
1222
+ maybeOfferDrafts()
1223
+ }, error => {
1224
+ commit(current => withNotice(current, { text: `${error}`, variant: "error" }))
1225
+ })
1226
+ }
1227
+
1228
+ const actions: TuiActions = {
1229
+ toggleEvents: () => commit(current => ({ ...withNotice(current, { text: current.eventsExpanded ? "Events hidden." : "Events shown.", variant: "info" }), eventsExpanded: !current.eventsExpanded })),
1230
+ jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
1231
+ commitRoom,
1232
+ setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
1233
+ jumpToNewSelf: () => replaceSession({ ...state.sessionSeed, localId: cleanLocalId(uid(8)) }, "Started a fresh self ID.", { reseedBootFocus: true }),
1234
+ commitName,
1235
+ setNameInput: value => commit(current => ({ ...current, nameInput: value })),
1236
+ toggleSelectReadyPeers: () => {
1237
+ let changed = 0
1238
+ for (const peer of state.snapshot.peers) if (peer.presence === "active" && state.session.setPeerSelected(peer.id, peer.ready)) changed += 1
1239
+ commit(current => withNotice(current, { text: changed ? "Selected ready peers." : "No ready peers to select.", variant: changed ? "success" : "info" }))
1240
+ maybeOfferDrafts()
1241
+ },
1242
+ clearPeerSelection: () => {
1243
+ let changed = 0
1244
+ for (const peer of state.snapshot.peers) if (state.session.setPeerSelected(peer.id, false)) changed += 1
1245
+ commit(current => withNotice(current, { text: changed ? `Cleared ${plural(changed, "peer selection")}.` : "No peer selections to clear.", variant: changed ? "warning" : "info" }))
1246
+ },
1247
+ toggleHideTerminalPeers: () => commit(current => withNotice({ ...current, hideTerminalPeers: !current.hideTerminalPeers }, { text: current.hideTerminalPeers ? "Terminal peers shown." : "Terminal peers hidden.", variant: "info" })),
1248
+ togglePeer: peerId => {
1249
+ state.session.togglePeerSelection(peerId)
1250
+ maybeOfferDrafts()
1251
+ },
1252
+ toggleAutoOffer: () => {
1253
+ commit(current => withNotice({ ...current, autoOfferOutgoing: !current.autoOfferOutgoing }, { text: !state.autoOfferOutgoing ? "Auto-offer on." : "Auto-offer off.", variant: !state.autoOfferOutgoing ? "success" : "warning" }))
1254
+ maybeOfferDrafts()
1255
+ },
1256
+ toggleAutoAccept: () => {
1257
+ const next = !state.autoAcceptIncoming
1258
+ commit(current => ({ ...current, autoAcceptIncoming: next }))
1259
+ void state.session.setAutoAcceptIncoming(next).then(
1260
+ count => commit(current => withNotice(current, { text: next ? `Auto-accept on${count ? ` · accepted ${plural(count, "transfer")}` : ""}.` : "Auto-accept off.", variant: next ? "success" : "warning" })),
1261
+ error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
1262
+ )
1263
+ },
1264
+ toggleAutoSave: () => {
1265
+ const next = !state.autoSaveIncoming
1266
+ commit(current => ({ ...current, autoSaveIncoming: next }))
1267
+ void state.session.setAutoSaveIncoming(next).then(
1268
+ count => commit(current => withNotice(current, { text: next ? `Auto-save on${count ? ` · saved ${plural(count, "transfer")}` : ""}.` : "Auto-save off.", variant: next ? "success" : "warning" })),
1269
+ error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
1270
+ )
1271
+ },
1272
+ setDraftInput: value => updateDraftInput(value),
1273
+ addDrafts,
1274
+ removeDraft: draftId => commit(current => withNotice({ ...current, drafts: current.drafts.filter(draft => draft.id !== draftId) }, { text: "Draft removed.", variant: "warning" })),
1275
+ clearDrafts: () => commit(current => withNotice({ ...current, drafts: [] }, { text: current.drafts.length ? `Cleared ${plural(current.drafts.length, "draft file")}.` : "No drafts to clear.", variant: current.drafts.length ? "warning" : "info" })),
1276
+ cancelPendingOffers: () => {
1277
+ const cancelled = state.session.cancelPendingOffers()
1278
+ commit(current => withNotice(current, {
1279
+ text: cancelled ? `Cancelled ${plural(cancelled, "pending offer")}.` : "No pending offers to cancel.",
1280
+ variant: cancelled ? "warning" : "info",
1281
+ }))
1282
+ },
1283
+ acceptTransfer: transferId => {
1284
+ const transfer = state.snapshot.transfers.find(item => item.id === transferId)
1285
+ void state.session.acceptTransfer(transferId).then(ok => {
1286
+ commit(current => withNotice(current, { text: ok ? `Accepted ${transfer?.name ?? "transfer"}.` : `Unable to accept ${transfer?.name ?? "transfer"}.`, variant: ok ? "success" : "error" }))
1287
+ })
1288
+ },
1289
+ rejectTransfer: transferId => {
1290
+ const transfer = state.snapshot.transfers.find(item => item.id === transferId)
1291
+ const ok = state.session.rejectTransfer(transferId)
1292
+ commit(current => withNotice(current, { text: ok ? `Rejected ${transfer?.name ?? "transfer"}.` : `Unable to reject ${transfer?.name ?? "transfer"}.`, variant: ok ? "warning" : "error" }))
1293
+ },
1294
+ cancelTransfer: transferId => {
1295
+ const transfer = state.snapshot.transfers.find(item => item.id === transferId)
1296
+ const ok = state.session.cancelTransfer(transferId)
1297
+ commit(current => withNotice(current, { text: ok ? `Cancelled ${transfer?.name ?? "transfer"}.` : `Unable to cancel ${transfer?.name ?? "transfer"}.`, variant: ok ? "warning" : "error" }))
1298
+ },
1299
+ saveTransfer: transferId => {
1300
+ const transfer = state.snapshot.transfers.find(item => item.id === transferId)
1301
+ void state.session.saveTransfer(transferId).then(path => {
1302
+ commit(current => withNotice(current, { text: path ? `Saved ${transfer?.name ?? "transfer"} to ${path}.` : `Unable to save ${transfer?.name ?? "transfer"}.`, variant: path ? "success" : "error" }))
1303
+ })
1304
+ },
1305
+ clearCompleted: () => {
1306
+ const cleared = state.session.clearCompletedTransfers()
1307
+ commit(current => withNotice(current, { text: cleared ? `Cleared ${plural(cleared, "completed transfer")}.` : "No completed transfers to clear.", variant: cleared ? "warning" : "info" }))
1308
+ },
1309
+ clearFailed: () => {
1310
+ const cleared = state.session.clearFailedTransfers()
1311
+ commit(current => withNotice(current, { text: cleared ? `Cleared ${plural(cleared, "failed transfer")}.` : "No failed transfers to clear.", variant: cleared ? "warning" : "info" }))
1312
+ },
1313
+ clearLogs: () => {
1314
+ state.session.clearLogs()
1315
+ commit(current => withNotice(current, { text: "Events cleared.", variant: "warning" }))
1316
+ },
1317
+ }
1318
+
1319
+ app.view(model => renderTuiView(model, actions))
1320
+ app.onFocusChange(info => {
1321
+ const previousFocusedId = state.focusedId
1322
+ commit(current => {
1323
+ const next = { ...current, focusedId: info.id }
1324
+ return info.id === current.pendingFocusTarget ? consumeSatisfiedFocusRequest(next, info.id) : next
1325
+ })
1326
+ if (previousFocusedId === DRAFT_INPUT_ID && info.id !== DRAFT_INPUT_ID) {
1327
+ stopPreviewSession()
1328
+ commit(current => ({
1329
+ ...current,
1330
+ filePreview: resetFilePreview({
1331
+ dismissedQuery: current.filePreview.dismissedQuery === current.draftInput ? current.draftInput : null,
1332
+ }),
1333
+ }))
1334
+ return
1335
+ }
1336
+ if (info.id === DRAFT_INPUT_ID) {
1337
+ const scope = deriveFileSearchScope(state.draftInput, previewBaseRoot)
1338
+ if (scope && state.filePreview.dismissedQuery !== state.draftInput && (state.filePreview.pendingQuery !== scope.query || state.filePreview.workspaceRoot !== scope.workspaceRoot || state.filePreview.displayPrefix !== scope.displayPrefix)) {
1339
+ commit(current => ({
1340
+ ...current,
1341
+ filePreview: {
1342
+ ...(current.filePreview.workspaceRoot === scope.workspaceRoot ? current.filePreview : resetFilePreview()),
1343
+ workspaceRoot: scope.workspaceRoot,
1344
+ displayPrefix: scope.displayPrefix,
1345
+ pendingQuery: scope.query,
1346
+ waiting: true,
1347
+ error: null,
1348
+ },
1349
+ }))
1350
+ requestFilePreview(state.draftInput)
1351
+ }
1352
+ }
1353
+ })
1354
+ app.keys({
1355
+ "ctrl+c": { description: "Quit", handler: requestStop },
1356
+ tab: {
1357
+ description: "Accept focused preview row",
1358
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && !!selectedFilePreviewMatch(state) && filePreviewVisible(state),
1359
+ handler: () => {
1360
+ acceptSelectedFilePreview()
1361
+ },
1362
+ },
1363
+ up: {
1364
+ description: "Move file preview selection up",
1365
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
1366
+ handler: () => {
1367
+ commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, -1) }))
1368
+ },
1369
+ },
1370
+ down: {
1371
+ description: "Move file preview selection down",
1372
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
1373
+ handler: () => {
1374
+ commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
1375
+ },
1376
+ },
1377
+ "ctrl+p": {
1378
+ description: "Move file preview selection up",
1379
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
1380
+ handler: () => {
1381
+ commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, -1) }))
1382
+ },
1383
+ },
1384
+ "ctrl+n": {
1385
+ description: "Move file preview selection down",
1386
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
1387
+ handler: () => {
1388
+ commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
1389
+ },
1390
+ },
1391
+ enter: {
1392
+ description: "Commit focused input",
1393
+ when: ctx => ctx.focusedId === ROOM_INPUT_ID || ctx.focusedId === NAME_INPUT_ID || ctx.focusedId === DRAFT_INPUT_ID,
1394
+ handler: ctx => {
1395
+ if (ctx.focusedId === ROOM_INPUT_ID) commitRoom()
1396
+ if (ctx.focusedId === NAME_INPUT_ID) commitName()
1397
+ if (ctx.focusedId === DRAFT_INPUT_ID && !acceptSelectedFilePreview()) addDrafts()
1398
+ },
1399
+ },
1400
+ escape: {
1401
+ description: "Reset focused input",
1402
+ when: ctx => ctx.focusedId === ROOM_INPUT_ID || ctx.focusedId === NAME_INPUT_ID || ctx.focusedId === DRAFT_INPUT_ID,
1403
+ handler: ctx => {
1404
+ if (ctx.focusedId === ROOM_INPUT_ID) commit(current => withNotice({ ...current, roomInput: current.sessionSeed.room }, { text: "Room input reset.", variant: "warning" }))
1405
+ if (ctx.focusedId === NAME_INPUT_ID) commit(current => withNotice({ ...current, nameInput: visibleNameInput(current.snapshot.name) }, { text: "Name input reset.", variant: "warning" }))
1406
+ if (ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state)) {
1407
+ stopPreviewSession()
1408
+ commit(current => withNotice({
1409
+ ...current,
1410
+ filePreview: resetFilePreview({ dismissedQuery: current.draftInput }),
1411
+ }, { text: "File preview hidden.", variant: "warning" }))
1412
+ } else if (ctx.focusedId === DRAFT_INPUT_ID) {
1413
+ stopPreviewSession()
1414
+ commit(current => withNotice({ ...current, draftInput: "", filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
1415
+ }
1416
+ },
1417
+ },
1418
+ })
1419
+
1420
+ const stop = async () => {
1421
+ if (cleanedUp) return
1422
+ cleanedUp = true
1423
+ stopping = true
1424
+ unsubscribe()
1425
+ stopPreviewSession()
1426
+ await state.session.close()
1427
+ }
1428
+
1429
+ const onSignal = () => requestStop()
1430
+ process.once("SIGINT", onSignal)
1431
+ process.once("SIGTERM", onSignal)
1432
+
1433
+ try {
1434
+ bindSession(state.session)
1435
+ await app.run()
1436
+ } finally {
1437
+ process.off("SIGINT", onSignal)
1438
+ process.off("SIGTERM", onSignal)
1439
+ await stop()
1440
+ app.dispose()
1441
+ }
1442
+ }