@elefunc/send 0.1.14 → 0.1.16

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.14",
3
+ "version": "0.1.16",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,6 +56,7 @@ export interface PeerProfile {
56
56
  autoAcceptIncoming?: boolean
57
57
  autoSaveIncoming?: boolean
58
58
  }
59
+ streamingSaveIncoming?: boolean
59
60
  ready?: boolean
60
61
  error?: string
61
62
  }
@@ -219,13 +220,15 @@ export const signalEpoch = (value: unknown) => Number.isSafeInteger(value) && Nu
219
220
 
220
221
  export const buildCliProfile = (): PeerProfile => ({
221
222
  ua: { browser: "send-cli", os: process.platform, device: "desktop" },
223
+ streamingSaveIncoming: true,
222
224
  ready: true,
223
225
  })
224
226
 
225
227
  export const peerDefaultsToken = (profile?: PeerProfile) => {
226
228
  const autoAcceptIncoming = typeof profile?.defaults?.autoAcceptIncoming === "boolean" ? profile.defaults.autoAcceptIncoming : null
227
229
  const autoSaveIncoming = typeof profile?.defaults?.autoSaveIncoming === "boolean" ? profile.defaults.autoSaveIncoming : null
228
- return autoAcceptIncoming === null || autoSaveIncoming === null ? "??" : `${autoAcceptIncoming ? "A" : "a"}${autoSaveIncoming ? "S" : "s"}`
230
+ if (autoAcceptIncoming === null || autoSaveIncoming === null) return "??"
231
+ return `${autoAcceptIncoming ? "A" : "a"}${!autoSaveIncoming ? "s" : profile?.streamingSaveIncoming === true ? "X" : "S"}`
229
232
  }
230
233
 
231
234
  export const displayPeerName = (name: string, id: string) => `${cleanName(name)}-${id}`
@@ -230,6 +230,7 @@ export const sanitizeProfile = (profile?: PeerProfile): PeerProfile => ({
230
230
  autoAcceptIncoming: typeof profile?.defaults?.autoAcceptIncoming === "boolean" ? profile.defaults.autoAcceptIncoming : undefined,
231
231
  autoSaveIncoming: typeof profile?.defaults?.autoSaveIncoming === "boolean" ? profile.defaults.autoSaveIncoming : undefined,
232
232
  },
233
+ streamingSaveIncoming: typeof profile?.streamingSaveIncoming === "boolean" ? profile.streamingSaveIncoming : undefined,
233
234
  ready: !!profile?.ready,
234
235
  error: cleanText(profile?.error, 120),
235
236
  })
@@ -254,6 +255,7 @@ export const localProfileFromResponse = (data: unknown, error = ""): PeerProfile
254
255
  ip: cleaned(value?.hs?.["cf-connecting-ip"] || value?.hs?.["x-real-ip"], 80),
255
256
  },
256
257
  ua: buildCliProfile().ua,
258
+ streamingSaveIncoming: true,
257
259
  ready: !!value?.cf,
258
260
  error,
259
261
  })
@@ -1175,6 +1177,7 @@ export class SendSession {
1175
1177
  autoAcceptIncoming: this.autoAcceptIncoming,
1176
1178
  autoSaveIncoming: this.autoSaveIncoming,
1177
1179
  },
1180
+ streamingSaveIncoming: true,
1178
1181
  })
1179
1182
  }
1180
1183
 
package/src/tui/app.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolve } from "node:path"
2
- import { rgb, ui, type BadgeVariant, type VNode } from "@rezi-ui/core"
2
+ import { BACKEND_RAW_WRITE_MARKER, rgb, ui, type BackendRawWrite, type BadgeVariant, type TextStyle, type UiEvent, type VNode } from "@rezi-ui/core"
3
3
  import { createNodeApp } from "@rezi-ui/node"
4
+ import { applyInputEditEvent } from "../../node_modules/@rezi-ui/core/dist/runtime/inputEditor.js"
4
5
  import { inspectLocalFile } from "../core/files"
5
6
  import { isSessionAbortedError, SendSession, type PeerSnapshot, type SessionConfig, type SessionSnapshot, type TransferSnapshot } from "../core/session"
6
7
  import { cleanLocalId, cleanName, cleanRoom, displayPeerName, fallbackName, formatBytes, type LogEntry, peerDefaultsToken, type PeerProfile, uid } from "../core/protocol"
@@ -16,6 +17,13 @@ type TransferSection = { title: string; items: TransferSnapshot[]; clearAction?:
16
17
  type TransferSummaryStat = { state: string; label?: string; count: number; size: number; countText?: string; sizeText?: string }
17
18
  type TransferGroup = { key: string; name: string; items: TransferSnapshot[] }
18
19
  type DenseSectionChild = VNode | false | null | undefined
20
+ type PreviewSegmentRole = "prefix" | "path" | "basename"
21
+ type PreviewSegment = { text: string; highlighted: boolean; role: PreviewSegmentRole }
22
+ type DraftHistoryState = {
23
+ entries: string[]
24
+ index: number | null
25
+ baseInput: string | null
26
+ }
19
27
  type FilePreviewState = {
20
28
  dismissedQuery: string | null
21
29
  workspaceRoot: string | null
@@ -48,6 +56,7 @@ export interface TuiState {
48
56
  peerSelectionByRoom: Map<string, Map<string, boolean>>
49
57
  snapshot: SessionSnapshot
50
58
  aboutOpen: boolean
59
+ inviteDropdownOpen: boolean
51
60
  peerSearch: string
52
61
  focusedId: string | null
53
62
  roomInput: string
@@ -57,6 +66,7 @@ export interface TuiState {
57
66
  bootNameJumpPending: boolean
58
67
  draftInput: string
59
68
  draftInputKeyVersion: number
69
+ draftHistory: DraftHistoryState
60
70
  filePreview: FilePreviewState
61
71
  drafts: DraftItem[]
62
72
  autoOfferOutgoing: boolean
@@ -72,6 +82,10 @@ export interface TuiActions {
72
82
  toggleEvents: TuiAction
73
83
  openAbout: TuiAction
74
84
  closeAbout: TuiAction
85
+ toggleInviteDropdown: TuiAction
86
+ closeInviteDropdown: TuiAction
87
+ copyWebInvite: TuiAction
88
+ copyCliInvite: TuiAction
75
89
  jumpToRandomRoom: TuiAction
76
90
  commitRoom: TuiAction
77
91
  setRoomInput: (value: string) => void
@@ -88,7 +102,7 @@ export interface TuiActions {
88
102
  toggleAutoOffer: TuiAction
89
103
  toggleAutoAccept: TuiAction
90
104
  toggleAutoSave: TuiAction
91
- setDraftInput: (value: string) => void
105
+ setDraftInput: (value: string, cursor?: number) => void
92
106
  addDrafts: TuiAction
93
107
  removeDraft: (draftId: string) => void
94
108
  clearDrafts: TuiAction
@@ -106,20 +120,22 @@ const ROOM_INPUT_ID = "room-input"
106
120
  const NAME_INPUT_ID = "name-input"
107
121
  const PEER_SEARCH_INPUT_ID = "peer-search-input"
108
122
  const DRAFT_INPUT_ID = "draft-input"
123
+ const ROOM_INVITE_BUTTON_ID = "room-invite-button"
124
+ const INVITE_DROPDOWN_ID = "room-invite-dropdown"
109
125
  const ABOUT_TRIGGER_ID = "open-about"
110
126
  const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
111
127
  const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
112
128
  const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
113
129
  const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
114
- const DEFAULT_WEB_URL = "https://send.rt.ht/"
130
+ const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
131
+ const DEFAULT_WEB_URL = "https://rtme.sh/"
115
132
  const DEFAULT_SAVE_DIR = resolve(process.cwd())
116
133
  const ABOUT_ELEFUNC_URL = "https://elefunc.com/send"
117
134
  const ABOUT_TITLE = "About Send"
118
135
  const ABOUT_INTRO = "Peer-to-Peer Transfers – Web & CLI"
119
136
  const ABOUT_SUMMARY = "Join the same room, see who is there, and offer files directly to selected peers."
120
137
  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"
138
+ const COPY_SERVICE_URL = "https://copy.rt.ht/"
123
139
  const TRANSFER_DIRECTION_ARROW = {
124
140
  out: { glyph: "↗", style: { fg: rgb(170, 217, 76), bold: true } },
125
141
  in: { glyph: "↙", style: { fg: rgb(240, 113, 120), bold: true } },
@@ -129,6 +145,7 @@ const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0
129
145
  const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
130
146
  const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
131
147
  const pluralRules = new Intl.PluralRules()
148
+ const DRAFT_HISTORY_LIMIT = 50
132
149
 
133
150
  export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ? ["peers", "transfers", "logs"] : ["peers", "transfers"]
134
151
 
@@ -146,6 +163,12 @@ type ProcessSignalLike = {
146
163
  off?: (signal: TuiQuitSignal, handler: () => void) => unknown
147
164
  removeListener?: (signal: TuiQuitSignal, handler: () => void) => unknown
148
165
  }
166
+ type BunSpawn = (cmd: string[], options: {
167
+ stdin?: "pipe" | "inherit" | "ignore"
168
+ stdout?: "pipe" | "inherit" | "ignore"
169
+ stderr?: "pipe" | "inherit" | "ignore"
170
+ }) => { unref?: () => void }
171
+ type BunLike = { spawn?: BunSpawn }
149
172
  type ShareUrlState = Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">
150
173
  type ShareCliState = ShareUrlState & Pick<TuiState, "sessionSeed" | "eventsExpanded">
151
174
  const shellQuote = (value: string) => safeShellArgPattern.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`
@@ -192,8 +215,29 @@ const renderWebUrl = (state: ShareUrlState, baseUrl = DEFAULT_WEB_URL, omitDefau
192
215
  url.hash = buildHashParams(state, omitDefaults).toString()
193
216
  return url.toString()
194
217
  }
218
+ const schemeLessUrlText = (text: string) => text.replace(/^[a-z]+:\/\//, "")
219
+ export const inviteWebLabel = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => schemeLessUrlText(webInviteUrl(state, baseUrl))
220
+ export const inviteCliPackageName = (baseUrl = resolveWebUrlBase()) => new URL(resolveWebUrlBase(baseUrl)).hostname
221
+ export const inviteCliCommand = (state: ShareUrlState) => {
222
+ const args: string[] = []
223
+ appendCliFlag(args, "--room", state.snapshot.room)
224
+ appendToggleCliFlags(args, state)
225
+ return args.join(" ")
226
+ }
227
+ export const inviteCliText = (state: ShareUrlState, baseUrl = resolveWebUrlBase()) => `bunx ${inviteCliPackageName(baseUrl)} ${inviteCliCommand(state)}`
228
+ export const inviteCopyUrl = (text: string) => `${COPY_SERVICE_URL}#${new URLSearchParams({ text })}`
229
+ export const buildOsc52ClipboardSequence = (text: string) => text ? `\u001b]52;c;${Buffer.from(text).toString("base64")}\u0007` : ""
230
+ export const externalOpenCommand = (url: string, platform = process.platform) =>
231
+ platform === "darwin" ? ["open", url]
232
+ : platform === "win32" ? ["cmd.exe", "/c", "start", "", url]
233
+ : ["xdg-open", url]
234
+ const getBackendRawWriter = (backend: unknown): BackendRawWrite | null => {
235
+ const marker = (backend as Record<string, unknown>)[BACKEND_RAW_WRITE_MARKER]
236
+ return typeof marker === "function" ? marker as BackendRawWrite : null
237
+ }
238
+ const getBunRuntime = () => (globalThis as typeof globalThis & { Bun?: BunLike }).Bun ?? null
195
239
  const renderCliCommand = (state: ShareCliState, { includeSelf = false, includePrefix = false } = {}) => {
196
- const args = includePrefix ? ["bunx", "@elefunc/send@latest"] : []
240
+ const args = includePrefix ? ["bunx", "rtme.sh"] : []
197
241
  appendCliFlag(args, "--room", state.snapshot.room)
198
242
  if (includeSelf) appendCliFlag(args, "--self", displayPeerName(state.snapshot.name, state.snapshot.localId))
199
243
  appendToggleCliFlags(args, state)
@@ -256,6 +300,10 @@ export const createNoopTuiActions = (): TuiActions => ({
256
300
  toggleEvents: noop,
257
301
  openAbout: noop,
258
302
  closeAbout: noop,
303
+ toggleInviteDropdown: noop,
304
+ closeInviteDropdown: noop,
305
+ copyWebInvite: noop,
306
+ copyCliInvite: noop,
259
307
  jumpToRandomRoom: noop,
260
308
  commitRoom: noop,
261
309
  setRoomInput: noop,
@@ -380,7 +428,7 @@ const uaSummary = (profile?: PeerProfile) => joinSummary([profile?.ua?.browser,
380
428
  const profileIp = (profile?: PeerProfile) => profile?.network?.ip || "—"
381
429
  const peerDefaultsVariant = (profile?: PeerProfile): BadgeVariant => {
382
430
  const token = peerDefaultsToken(profile)
383
- return token === "AS" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
431
+ return token === "AX" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
384
432
  }
385
433
  const TIGHT_TAG_COLORS = {
386
434
  default: rgb(89, 194, 255),
@@ -389,6 +437,12 @@ const TIGHT_TAG_COLORS = {
389
437
  error: rgb(240, 113, 120),
390
438
  info: rgb(89, 194, 255),
391
439
  } as const
440
+ const PREVIEW_PREFIX_STYLE = { fg: rgb(112, 121, 136), dim: true } as const
441
+ const PREVIEW_PATH_STYLE = { ...MUTED_TEXT_STYLE, dim: true } as const
442
+ const PREVIEW_BASENAME_STYLE = PRIMARY_TEXT_STYLE
443
+ const PREVIEW_HIGHLIGHT_STYLE = { fg: TIGHT_TAG_COLORS.info, bold: true } as const
444
+ const PREVIEW_SELECTED_HIGHLIGHT_STYLE = { fg: TIGHT_TAG_COLORS.success, bold: true } as const
445
+ const PREVIEW_SELECTED_MARKER_STYLE = { fg: TIGHT_TAG_COLORS.info, bold: true } as const
392
446
  const tightTag = (text: string, props: { key?: string; variant?: BadgeVariant; bare?: boolean } = {}) => ui.text(props.bare ? text : `(${text})`, {
393
447
  ...(props.key === undefined ? {} : { key: props.key }),
394
448
  style: {
@@ -408,6 +462,71 @@ const emptyFilePreviewState = (): FilePreviewState => ({
408
462
  selectedIndex: null,
409
463
  scrollTop: 0,
410
464
  })
465
+ const emptyDraftHistoryState = (): DraftHistoryState => ({
466
+ entries: [],
467
+ index: null,
468
+ baseInput: null,
469
+ })
470
+
471
+ const resetDraftHistoryBrowse = (history: DraftHistoryState): DraftHistoryState =>
472
+ history.index == null && history.baseInput == null
473
+ ? history
474
+ : { ...history, index: null, baseInput: null }
475
+
476
+ export const pushDraftHistoryEntry = (history: DraftHistoryState, value: string, limit = DRAFT_HISTORY_LIMIT): DraftHistoryState => {
477
+ const nextValue = normalizeSearchQuery(value)
478
+ if (!nextValue) return resetDraftHistoryBrowse(history)
479
+ return {
480
+ entries: history.entries[0] === nextValue ? history.entries : [nextValue, ...history.entries].slice(0, limit),
481
+ index: null,
482
+ baseInput: null,
483
+ }
484
+ }
485
+
486
+ export const isDraftHistoryEntryPoint = (value: string, cursor: number, cwd = process.cwd()) => {
487
+ if (cursor !== 0) return false
488
+ const normalized = normalizeSearchQuery(value)
489
+ if (!normalized) return true
490
+ return !deriveFileSearchScope(value, cwd)?.query
491
+ }
492
+
493
+ export const canNavigateDraftHistory = (history: DraftHistoryState, value: string, cursor: number, cwd = process.cwd()) =>
494
+ cursor === 0 && (history.index != null || (history.entries.length > 0 && isDraftHistoryEntryPoint(value, cursor, cwd)))
495
+
496
+ export const moveDraftHistory = (history: DraftHistoryState, value: string, direction: -1 | 1) => {
497
+ if (!history.entries.length) return { history, value, changed: false }
498
+ if (history.index == null) {
499
+ if (direction > 0) return { history, value, changed: false }
500
+ const nextIndex = 0
501
+ return {
502
+ history: { ...history, index: nextIndex, baseInput: value },
503
+ value: history.entries[nextIndex]!,
504
+ changed: history.entries[nextIndex] !== value,
505
+ }
506
+ }
507
+ if (direction < 0) {
508
+ const nextIndex = Math.min(history.entries.length - 1, history.index + 1)
509
+ return {
510
+ history: nextIndex === history.index ? history : { ...history, index: nextIndex },
511
+ value: history.entries[nextIndex]!,
512
+ changed: nextIndex !== history.index || history.entries[nextIndex] !== value,
513
+ }
514
+ }
515
+ if (history.index === 0) {
516
+ const nextValue = history.baseInput ?? ""
517
+ return {
518
+ history: resetDraftHistoryBrowse(history),
519
+ value: nextValue,
520
+ changed: nextValue !== value || history.index != null,
521
+ }
522
+ }
523
+ const nextIndex = history.index - 1
524
+ return {
525
+ history: { ...history, index: nextIndex },
526
+ value: history.entries[nextIndex]!,
527
+ changed: history.entries[nextIndex] !== value,
528
+ }
529
+ }
411
530
 
412
531
  type FocusControllerState = Pick<TuiState, "pendingFocusTarget" | "focusRequestEpoch" | "bootNameJumpPending">
413
532
 
@@ -493,6 +612,12 @@ export const transferActualDurationMs = (transfer: TransferSnapshot, now = Date.
493
612
  export const transferWaitDurationMs = (transfer: TransferSnapshot, now = Date.now()) => Math.max(0, (transfer.startedAt || now) - transfer.createdAt)
494
613
  export const filePreviewVisible = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">) =>
495
614
  state.focusedId === DRAFT_INPUT_ID && !!normalizeSearchQuery(state.draftInput) && state.filePreview.dismissedQuery !== state.draftInput
615
+ export const canAcceptFilePreviewWithRight = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">, cursor: number) =>
616
+ state.focusedId === DRAFT_INPUT_ID
617
+ && cursor === state.draftInput.length
618
+ && filePreviewVisible(state)
619
+ && state.filePreview.selectedIndex != null
620
+ && !!state.filePreview.results[state.filePreview.selectedIndex]
496
621
  export const clampFilePreviewSelectedIndex = (selectedIndex: number | null, resultCount: number) =>
497
622
  !resultCount ? null : selectedIndex == null ? 0 : Math.max(0, Math.min(resultCount - 1, selectedIndex))
498
623
  export const ensureFilePreviewScrollTop = (selectedIndex: number | null, scrollTop: number, resultCount: number, visibleRows = FILE_SEARCH_VISIBLE_ROWS) => {
@@ -519,24 +644,36 @@ const selectedFilePreviewMatch = (state: TuiState) => {
519
644
  const index = state.filePreview.selectedIndex
520
645
  return index == null ? null : state.filePreview.results[index] ?? null
521
646
  }
522
- const highlightedSegments = (value: string, indices: number[]) => {
647
+ export const previewPathSegments = (value: string, prefixLength: number, indices: number[]) => {
523
648
  const marks = new Set(indices)
524
649
  const chars = Array.from(value)
525
- const segments: Array<{ text: string; highlighted: boolean }> = []
650
+ const basenameStart = Math.max(prefixLength, value.lastIndexOf("/") + 1)
651
+ const segments: PreviewSegment[] = []
526
652
  let current = ""
527
653
  let highlighted = false
654
+ let role: PreviewSegmentRole = "basename"
528
655
  for (let index = 0; index < chars.length; index += 1) {
529
656
  const nextHighlighted = marks.has(index)
530
- if (current && nextHighlighted !== highlighted) {
531
- segments.push({ text: current, highlighted })
657
+ const nextRole = index < prefixLength ? "prefix" : index < basenameStart ? "path" : "basename"
658
+ if (current && (nextHighlighted !== highlighted || nextRole !== role)) {
659
+ segments.push({ text: current, highlighted, role })
532
660
  current = ""
533
661
  }
534
662
  current += chars[index]
535
663
  highlighted = nextHighlighted
664
+ role = nextRole
536
665
  }
537
- if (current) segments.push({ text: current, highlighted })
666
+ if (current) segments.push({ text: current, highlighted, role })
538
667
  return segments
539
668
  }
669
+ export const previewSegmentStyle = (segment: PreviewSegment, selected: boolean): TextStyle =>
670
+ segment.highlighted
671
+ ? selected ? PREVIEW_SELECTED_HIGHLIGHT_STYLE : PREVIEW_HIGHLIGHT_STYLE
672
+ : segment.role === "prefix"
673
+ ? PREVIEW_PREFIX_STYLE
674
+ : segment.role === "path"
675
+ ? PREVIEW_PATH_STYLE
676
+ : PREVIEW_BASENAME_STYLE
540
677
 
541
678
  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[] => {
542
679
  const order = ["draft", "pending", "queued", "offered", "accepted", "receiving", "sending", "awaiting-done", "cancelling", "complete", "rejected", "cancelled", "error"]
@@ -602,6 +739,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
602
739
  peerSelectionByRoom,
603
740
  snapshot: session.snapshot(),
604
741
  aboutOpen: false,
742
+ inviteDropdownOpen: false,
605
743
  peerSearch: "",
606
744
  focusedId: null,
607
745
  roomInput: sessionSeed.room,
@@ -609,6 +747,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
609
747
  ...focusState,
610
748
  draftInput: "",
611
749
  draftInputKeyVersion: 0,
750
+ draftHistory: emptyDraftHistoryState(),
612
751
  filePreview: emptyFilePreviewState(),
613
752
  drafts: [],
614
753
  autoOfferOutgoing: launchOptions.offer ?? true,
@@ -709,12 +848,7 @@ const renderHeader = (state: TuiState, actions: TuiActions) => denseSection({
709
848
  ],
710
849
  }, [])
711
850
 
712
- const renderAboutModal = (state: TuiState, actions: TuiActions) => {
713
- const cliCommand = aboutCliCommand(state)
714
- const cliCopyText = `${ABOUT_CLI_LABEL} ${cliCommand}`
715
- const cliCopyUrl = `https://copy.rt.ht/#${new URLSearchParams({ text: cliCopyText })}`
716
- const currentWebUrl = aboutWebUrl(state)
717
- const currentWebLabel = aboutWebLabel(state)
851
+ const renderAboutModal = (_state: TuiState, actions: TuiActions) => {
718
852
  return ui.modal({
719
853
  id: "about-modal",
720
854
  title: ABOUT_TITLE,
@@ -722,24 +856,6 @@ const renderAboutModal = (state: TuiState, actions: TuiActions) => {
722
856
  ui.text(ABOUT_INTRO, { id: "about-intro", variant: "heading", wrap: true }),
723
857
  ui.text(ABOUT_SUMMARY, { id: "about-summary", wrap: true }),
724
858
  ui.text(ABOUT_RUNTIME, { id: "about-runtime", wrap: true }),
725
- ui.column({ gap: 0 }, [
726
- ui.text(ABOUT_CLI_LABEL, { id: "about-cli-label", variant: "caption", wrap: true }),
727
- ui.link({
728
- id: "about-current-cli",
729
- label: cliCommand,
730
- accessibleLabel: "Copy current CLI command",
731
- url: cliCopyUrl,
732
- }),
733
- ]),
734
- ui.column({ gap: 0 }, [
735
- ui.text(ABOUT_WEB_LINK_LABEL, { id: "about-web-link-label", variant: "caption", wrap: true }),
736
- ui.link({
737
- id: "about-current-web-link",
738
- label: currentWebLabel,
739
- accessibleLabel: "Open current web link",
740
- url: currentWebUrl,
741
- }),
742
- ]),
743
859
  ]),
744
860
  actions: [
745
861
  ui.link({
@@ -761,6 +877,18 @@ const renderAboutModal = (state: TuiState, actions: TuiActions) => {
761
877
  })
762
878
  }
763
879
 
880
+ const renderInviteDropdown = (state: TuiState, actions: TuiActions) => ui.dropdown({
881
+ id: INVITE_DROPDOWN_ID,
882
+ anchorId: ROOM_INVITE_BUTTON_ID,
883
+ position: "below-end",
884
+ items: [
885
+ { id: "web", label: "WEB", shortcut: inviteWebLabel(state) },
886
+ { id: "cli", label: "CLI", shortcut: inviteCliText(state) },
887
+ ],
888
+ onSelect: item => { if (item.id === "web") actions.copyWebInvite(); if (item.id === "cli") actions.copyCliInvite() },
889
+ onClose: actions.closeInviteDropdown,
890
+ })
891
+
764
892
  const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
765
893
  id: "room-card",
766
894
  }, [
@@ -776,11 +904,13 @@ const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
776
904
  }),
777
905
  ]),
778
906
  ui.row({ id: "room-invite-slot", width: 6, justify: "center", items: "center" }, [
779
- ui.link({
780
- id: "room-invite-link",
907
+ ui.button({
908
+ id: ROOM_INVITE_BUTTON_ID,
781
909
  label: "📋",
782
- accessibleLabel: "Open invite link",
783
- url: webInviteUrl(state),
910
+ accessibleLabel: "Open invite links",
911
+ onPress: actions.toggleInviteDropdown,
912
+ dsVariant: "ghost",
913
+ intent: "secondary",
784
914
  }),
785
915
  ]),
786
916
  ]),
@@ -939,14 +1069,14 @@ const renderDraftSummary = (drafts: DraftItem[]) => denseSection({
939
1069
  ui.row({ gap: 1, wrap: true }, summarizeStates(drafts, () => "draft", draft => draft.size, ["draft"]).map(renderSummaryStat)),
940
1070
  ])
941
1071
 
942
- const renderHighlightedPreviewPath = (value: string, indices: number[], options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
1072
+ const renderHighlightedPreviewPath = (value: string, prefixLength: number, indices: number[], selected: boolean, options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
943
1073
  gap: 0,
944
1074
  wrap: true,
945
1075
  ...(options.id === undefined ? {} : { id: options.id }),
946
1076
  ...(options.key === undefined ? {} : { key: options.key }),
947
1077
  ...(options.flex === undefined ? {} : { flex: options.flex }),
948
- }, highlightedSegments(value, indices).map((segment, index) =>
949
- ui.text(segment.text, { key: `segment-${index}`, ...(segment.highlighted ? { style: { bold: true } } : {}) }),
1078
+ }, previewPathSegments(value, prefixLength, indices).map((segment, index) =>
1079
+ ui.text(segment.text, { key: `segment-${index}`, style: previewSegmentStyle(segment, selected) }),
950
1080
  ))
951
1081
 
952
1082
  const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: boolean, displayPrefix: string) => ui.row({
@@ -955,10 +1085,12 @@ const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: b
955
1085
  gap: 1,
956
1086
  wrap: true,
957
1087
  }, [
958
- ui.text(selected ? ">" : " "),
1088
+ ui.text(selected ? ">" : " ", { style: selected ? PREVIEW_SELECTED_MARKER_STYLE : PREVIEW_PATH_STYLE }),
959
1089
  renderHighlightedPreviewPath(
960
1090
  formatFileSearchDisplayPath(displayPrefix, match.relativePath),
1091
+ Array.from(formatFileSearchDisplayPath(displayPrefix, "")).length,
961
1092
  offsetFileSearchMatchIndices(displayPrefix, match.indices),
1093
+ selected,
962
1094
  { id: `file-preview-path-${index}`, flex: 1 },
963
1095
  ),
964
1096
  match.kind === "file" && typeof match.size === "number" ? ui.text(formatBytes(match.size), { style: { dim: true } }) : null,
@@ -1108,7 +1240,7 @@ const renderFilesCard = (state: TuiState, actions: TuiActions) => denseSection({
1108
1240
  key: `draft-input-${state.draftInputKeyVersion}`,
1109
1241
  value: state.draftInput,
1110
1242
  placeholder: "path/to/file.txt",
1111
- onInput: value => actions.setDraftInput(value),
1243
+ onInput: (value, cursor) => actions.setDraftInput(value, cursor),
1112
1244
  }),
1113
1245
  ]),
1114
1246
  actionButton("add-drafts", "Add", actions.addDrafts, "primary", !state.draftInput.trim()),
@@ -1202,9 +1334,12 @@ export const renderTuiView = (state: TuiState, actions: TuiActions): VNode => {
1202
1334
  }, [page])
1203
1335
  : page
1204
1336
 
1205
- return state.aboutOpen
1206
- ? ui.layers([basePage, renderAboutModal(state, actions)])
1207
- : basePage
1337
+ const overlays = [
1338
+ state.inviteDropdownOpen && !state.aboutOpen ? renderInviteDropdown(state, actions) : null,
1339
+ state.aboutOpen ? renderAboutModal(state, actions) : null,
1340
+ ].filter((overlay): overlay is VNode => !!overlay)
1341
+
1342
+ return overlays.length ? ui.layers([basePage, ...overlays]) : basePage
1208
1343
  }
1209
1344
 
1210
1345
  const withNotice = (state: TuiState, notice: Notice): TuiState => ({ ...state, notice })
@@ -1214,6 +1349,7 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
1214
1349
  ...state,
1215
1350
  draftInput,
1216
1351
  draftInputKeyVersion: state.draftInputKeyVersion + 1,
1352
+ draftHistory: resetDraftHistoryBrowse(state.draftHistory),
1217
1353
  filePreview,
1218
1354
  pendingFocusTarget: DRAFT_INPUT_ID,
1219
1355
  focusRequestEpoch: state.focusRequestEpoch + 1,
@@ -1233,6 +1369,40 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1233
1369
  let previewWorker: Worker | null = null
1234
1370
  let previewSessionId: string | null = null
1235
1371
  let previewSessionRoot: string | null = null
1372
+ let draftCursor = state.draftInput.length
1373
+ let draftCursorBeforeEvent = draftCursor
1374
+ let osc52SupportPromise: Promise<boolean> | null = null
1375
+
1376
+ const ensureOsc52Support = () => osc52SupportPromise ??= app.backend.getCaps().then(caps => caps.supportsOsc52, () => false)
1377
+ const openExternalUrl = (url: string) => {
1378
+ try {
1379
+ const bun = getBunRuntime()
1380
+ const child = bun?.spawn?.(externalOpenCommand(url), { stdin: "ignore", stdout: "ignore", stderr: "ignore" })
1381
+ if (!child) return false
1382
+ child.unref?.()
1383
+ return true
1384
+ } catch {
1385
+ return false
1386
+ }
1387
+ }
1388
+ const copyInvitePayload = async (payload: string, label: "WEB" | "CLI") => {
1389
+ const closeDropdown = (notice: Notice) => commit(current => withNotice({ ...current, inviteDropdownOpen: false }, notice))
1390
+ const rawWrite = getBackendRawWriter(app.backend)
1391
+ if (rawWrite && await ensureOsc52Support()) {
1392
+ const sequence = buildOsc52ClipboardSequence(payload)
1393
+ if (sequence) {
1394
+ try {
1395
+ rawWrite(sequence)
1396
+ closeDropdown({ text: `Copied ${label} invite.`, variant: "success" })
1397
+ return
1398
+ } catch {}
1399
+ }
1400
+ }
1401
+ const opened = openExternalUrl(inviteCopyUrl(payload))
1402
+ closeDropdown(opened
1403
+ ? { text: `Opened ${label} copy link.`, variant: "info" }
1404
+ : { text: `Unable to copy ${label} invite.`, variant: "error" })
1405
+ }
1236
1406
 
1237
1407
  const flushUpdate = () => {
1238
1408
  if (updateQueued || stopping || cleanedUp) return
@@ -1270,6 +1440,10 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1270
1440
  ...emptyFilePreviewState(),
1271
1441
  ...overrides,
1272
1442
  })
1443
+ const exitDraftHistoryBrowse = () => commit(current =>
1444
+ current.draftHistory.index == null && current.draftHistory.baseInput == null
1445
+ ? current
1446
+ : { ...current, draftHistory: resetDraftHistoryBrowse(current.draftHistory) })
1273
1447
 
1274
1448
  const ensurePreviewSession = (workspaceRoot: string) => {
1275
1449
  if (previewWorker && previewSessionId && previewSessionRoot === workspaceRoot) return
@@ -1365,17 +1539,21 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1365
1539
  return scope
1366
1540
  }
1367
1541
 
1368
- const updateDraftInput = (value: string) => {
1369
- const scope = deriveFileSearchScope(value, previewBaseRoot)
1370
- const shouldDispose = !scope || state.filePreview.dismissedQuery === value
1542
+ const applyDraftInputValue = (value: string, options: { cursor?: number; history?: DraftHistoryState } = {}) => {
1543
+ const nextValue = normalizeSearchQuery(value)
1544
+ if (options.cursor !== undefined) draftCursor = Math.min(options.cursor, nextValue.length)
1545
+ const scope = deriveFileSearchScope(nextValue, previewBaseRoot)
1546
+ const shouldDispose = !scope || state.filePreview.dismissedQuery === nextValue
1371
1547
  commit(current => {
1372
- if (!scope) return { ...current, draftInput: value, filePreview: resetFilePreview() }
1373
- const shouldDismiss = current.filePreview.dismissedQuery === value
1548
+ const draftHistory = options.history ?? resetDraftHistoryBrowse(current.draftHistory)
1549
+ if (!scope) return { ...current, draftInput: nextValue, draftHistory, filePreview: resetFilePreview() }
1550
+ const shouldDismiss = current.filePreview.dismissedQuery === nextValue
1374
1551
  const rootChanged = current.filePreview.workspaceRoot !== scope.workspaceRoot
1375
1552
  const basePreview = rootChanged ? resetFilePreview() : current.filePreview
1376
1553
  return {
1377
1554
  ...current,
1378
- draftInput: value,
1555
+ draftInput: nextValue,
1556
+ draftHistory,
1379
1557
  filePreview: {
1380
1558
  ...basePreview,
1381
1559
  workspaceRoot: scope.workspaceRoot,
@@ -1391,7 +1569,19 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1391
1569
  stopPreviewSession()
1392
1570
  return
1393
1571
  }
1394
- requestFilePreview(value)
1572
+ requestFilePreview(nextValue)
1573
+ }
1574
+
1575
+ const updateDraftInput = (value: string, cursor = draftCursor) => {
1576
+ applyDraftInputValue(value, { cursor })
1577
+ }
1578
+
1579
+ const recallDraftHistory = (direction: -1 | 1) => {
1580
+ const next = moveDraftHistory(state.draftHistory, state.draftInput, direction)
1581
+ if (!next.changed) return false
1582
+ draftCursorBeforeEvent = 0
1583
+ applyDraftInputValue(next.value, { cursor: 0, history: next.history })
1584
+ return true
1395
1585
  }
1396
1586
 
1397
1587
  const acceptSelectedFilePreview = () => {
@@ -1413,6 +1603,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1413
1603
  selectedIndex: null,
1414
1604
  scrollTop: 0,
1415
1605
  }, { text: `Browsing ${nextValue}`, variant: "info" }))
1606
+ draftCursor = nextValue.length
1607
+ draftCursorBeforeEvent = draftCursor
1416
1608
  requestFilePreview(nextValue)
1417
1609
  return true
1418
1610
  }
@@ -1422,6 +1614,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1422
1614
  resetFilePreview({ dismissedQuery: displayPath }),
1423
1615
  { text: `Selected ${displayPath}.`, variant: "success" },
1424
1616
  ))
1617
+ draftCursor = displayPath.length
1618
+ draftCursorBeforeEvent = draftCursor
1425
1619
  stopPreviewSession()
1426
1620
  return true
1427
1621
  }
@@ -1473,10 +1667,12 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1473
1667
  sessionSeed: nextSeed,
1474
1668
  peerSelectionByRoom: current.peerSelectionByRoom,
1475
1669
  snapshot: nextSession.snapshot(),
1670
+ inviteDropdownOpen: false,
1476
1671
  peerSearch: "",
1477
1672
  roomInput: nextSeed.room,
1478
1673
  nameInput: visibleNameInput(nextSeed.name),
1479
1674
  draftInput: "",
1675
+ draftHistory: resetDraftHistoryBrowse(current.draftHistory),
1480
1676
  filePreview: resetFilePreview(),
1481
1677
  drafts: [],
1482
1678
  offeringDrafts: false,
@@ -1486,8 +1682,10 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1486
1682
  pendingFocusTarget: current.pendingFocusTarget,
1487
1683
  focusRequestEpoch: current.focusRequestEpoch,
1488
1684
  bootNameJumpPending: current.bootNameJumpPending,
1489
- }),
1685
+ }),
1490
1686
  }, { text, variant: "success" }))
1687
+ draftCursor = 0
1688
+ draftCursorBeforeEvent = 0
1491
1689
  bindSession(nextSession)
1492
1690
  void previousSession.close()
1493
1691
  }
@@ -1499,6 +1697,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1499
1697
  return
1500
1698
  }
1501
1699
  replaceSession({ ...state.sessionSeed, room: nextRoom }, `Joined room ${nextRoom}.`)
1700
+ draftCursor = 0
1502
1701
  }
1503
1702
 
1504
1703
  const commitName = () => {
@@ -1529,9 +1728,14 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1529
1728
  commit(current => withNotice({
1530
1729
  ...current,
1531
1730
  draftInput: current.draftInput === submittedInput ? "" : current.draftInput,
1731
+ draftHistory: pushDraftHistoryEntry(current.draftHistory, submittedInput),
1532
1732
  filePreview: current.draftInput === submittedInput ? resetFilePreview() : current.filePreview,
1533
1733
  drafts: [...current.drafts, created],
1534
1734
  }, { text: `Added ${plural(1, "draft file")}.`, variant: "success" }))
1735
+ if (shouldDispose) {
1736
+ draftCursor = 0
1737
+ draftCursorBeforeEvent = 0
1738
+ }
1535
1739
  if (shouldDispose) stopPreviewSession()
1536
1740
  maybeOfferDrafts()
1537
1741
  }, error => {
@@ -1541,8 +1745,12 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1541
1745
 
1542
1746
  const actions: TuiActions = {
1543
1747
  toggleEvents: () => commit(current => ({ ...withNotice(current, { text: current.eventsExpanded ? "Events hidden." : "Events shown.", variant: "info" }), eventsExpanded: !current.eventsExpanded })),
1544
- openAbout: () => commit(current => ({ ...current, aboutOpen: true })),
1748
+ openAbout: () => commit(current => ({ ...current, aboutOpen: true, inviteDropdownOpen: false })),
1545
1749
  closeAbout: () => commit(current => ({ ...current, aboutOpen: false })),
1750
+ toggleInviteDropdown: () => commit(current => ({ ...current, inviteDropdownOpen: !current.inviteDropdownOpen })),
1751
+ closeInviteDropdown: () => commit(current => current.inviteDropdownOpen ? { ...current, inviteDropdownOpen: false } : current),
1752
+ copyWebInvite: () => { void copyInvitePayload(webInviteUrl(state), "WEB") },
1753
+ copyCliInvite: () => { void copyInvitePayload(inviteCliText(state), "CLI") },
1546
1754
  jumpToRandomRoom: () => replaceSession({ ...state.sessionSeed, room: uid(8) }, "Joined a new room."),
1547
1755
  commitRoom,
1548
1756
  setRoomInput: value => commit(current => ({ ...current, roomInput: value })),
@@ -1614,7 +1822,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1614
1822
  error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
1615
1823
  )
1616
1824
  },
1617
- setDraftInput: value => updateDraftInput(value),
1825
+ setDraftInput: (value, cursor) => updateDraftInput(value, cursor),
1618
1826
  addDrafts,
1619
1827
  removeDraft: draftId => commit(current => withNotice({ ...current, drafts: current.drafts.filter(draft => draft.id !== draftId) }, { text: "Draft removed.", variant: "warning" })),
1620
1828
  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" })),
@@ -1662,6 +1870,21 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1662
1870
  }
1663
1871
 
1664
1872
  app.view(model => renderTuiView(model, actions))
1873
+ app.onEvent((event: UiEvent) => {
1874
+ if (event.kind !== "engine" || state.focusedId !== DRAFT_INPUT_ID) return
1875
+ draftCursorBeforeEvent = draftCursor
1876
+ const edit = applyInputEditEvent(event.event, {
1877
+ id: DRAFT_INPUT_ID,
1878
+ value: state.draftInput,
1879
+ cursor: draftCursor,
1880
+ selectionStart: null,
1881
+ selectionEnd: null,
1882
+ multiline: false,
1883
+ })
1884
+ if (!edit) return
1885
+ draftCursor = edit.nextCursor
1886
+ if (!edit.action && state.draftHistory.index != null && draftCursor !== 0) exitDraftHistoryBrowse()
1887
+ })
1665
1888
  app.onFocusChange(info => {
1666
1889
  const previousFocusedId = state.focusedId
1667
1890
  commit(current => {
@@ -1672,6 +1895,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1672
1895
  stopPreviewSession()
1673
1896
  commit(current => ({
1674
1897
  ...current,
1898
+ draftHistory: resetDraftHistoryBrowse(current.draftHistory),
1675
1899
  filePreview: resetFilePreview({
1676
1900
  dismissedQuery: current.filePreview.dismissedQuery === current.draftInput ? current.draftInput : null,
1677
1901
  }),
@@ -1710,17 +1934,26 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1710
1934
  acceptSelectedFilePreview()
1711
1935
  },
1712
1936
  },
1937
+ right: {
1938
+ description: "Accept focused preview row at end of Files input",
1939
+ when: ctx => canAcceptFilePreviewWithRight(state, draftCursorBeforeEvent) && ctx.focusedId === DRAFT_INPUT_ID,
1940
+ handler: () => {
1941
+ acceptSelectedFilePreview()
1942
+ },
1943
+ },
1713
1944
  up: {
1714
- description: "Move file preview selection up",
1715
- when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
1945
+ description: "Recall history or move file preview selection up",
1946
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) || filePreviewVisible(state) && state.filePreview.results.length > 0),
1716
1947
  handler: () => {
1948
+ if (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) && recallDraftHistory(-1)) return
1717
1949
  commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, -1) }))
1718
1950
  },
1719
1951
  },
1720
1952
  down: {
1721
- description: "Move file preview selection down",
1722
- when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
1953
+ description: "Recall history or move file preview selection down",
1954
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) || filePreviewVisible(state) && state.filePreview.results.length > 0),
1723
1955
  handler: () => {
1956
+ if (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) && recallDraftHistory(1)) return
1724
1957
  commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
1725
1958
  },
1726
1959
  },
@@ -1755,13 +1988,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1755
1988
  if (ctx.focusedId === NAME_INPUT_ID) commit(current => withNotice({ ...current, nameInput: visibleNameInput(current.snapshot.name) }, { text: "Name input reset.", variant: "warning" }))
1756
1989
  if (ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state)) {
1757
1990
  stopPreviewSession()
1991
+ exitDraftHistoryBrowse()
1758
1992
  commit(current => withNotice({
1759
1993
  ...current,
1760
1994
  filePreview: resetFilePreview({ dismissedQuery: current.draftInput }),
1761
1995
  }, { text: "File preview hidden.", variant: "warning" }))
1762
1996
  } else if (ctx.focusedId === DRAFT_INPUT_ID) {
1763
1997
  stopPreviewSession()
1764
- commit(current => withNotice({ ...current, draftInput: "", filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
1998
+ draftCursor = 0
1999
+ commit(current => withNotice({ ...current, draftInput: "", draftHistory: resetDraftHistoryBrowse(current.draftHistory), filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
1765
2000
  }
1766
2001
  },
1767
2002
  },
@@ -1787,6 +2022,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1787
2022
  try {
1788
2023
  bindSession(state.session)
1789
2024
  await app.start()
2025
+ void ensureOsc52Support()
1790
2026
  await quitController.promise
1791
2027
  } finally {
1792
2028
  try {
@@ -53,13 +53,16 @@ const BOUNDARY_CHARS = new Set(["/", "_", "-", "."])
53
53
  export const FILE_SEARCH_SLOW_MOUNT_MS = 250
54
54
 
55
55
  const trimTrailingCrLf = (value: string) => value.replace(/[\r\n]+$/u, "")
56
+ const trimMatchingQuotes = (value: string) => value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))
57
+ ? value.slice(1, -1)
58
+ : value
56
59
  const normalizeSeparators = (value: string) => value.replace(/\\/gu, "/")
57
60
  const pathChars = (value: string) => Array.from(value)
58
61
  const lower = (value: string) => value.toLocaleLowerCase("en-US")
59
62
  const renderedDisplayPrefix = (displayPrefix: string) => displayPrefix === "~" ? "~/" : displayPrefix
60
63
  const MOUNTINFO_PATH = "/proc/self/mountinfo"
61
64
 
62
- export const normalizeSearchQuery = (value: string) => normalizeSeparators(trimTrailingCrLf(value))
65
+ export const normalizeSearchQuery = (value: string) => normalizeSeparators(trimMatchingQuotes(trimTrailingCrLf(value)))
63
66
  export const normalizeRelativePath = (value: string) => normalizeSeparators(value.split(sep).join("/"))
64
67
  export const shouldSkipSearchDirectory = (name: string) => SKIPPED_DIRECTORIES.has(name)
65
68
  export const isCaseSensitiveQuery = (query: string) => /[A-Z]/u.test(query)