@elefunc/send 0.1.13 → 0.1.15

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.13",
3
+ "version": "0.1.15",
4
4
  "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/core/files.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { access, mkdir, stat } from "node:fs/promises"
1
+ import { access, mkdir, open, rm, stat, type FileHandle } from "node:fs/promises"
2
2
  import { basename, extname, join, resolve } from "node:path"
3
3
  import { resolveUserPath } from "./paths"
4
4
 
@@ -8,16 +8,16 @@ export interface LocalFile {
8
8
  size: number
9
9
  type: string
10
10
  lastModified: number
11
- blob: Blob
11
+ reader?: FileHandle
12
12
  }
13
13
 
14
- export type LocalFileInfo = Omit<LocalFile, "blob">
14
+ export type LocalFileInfo = Omit<LocalFile, "reader">
15
15
  export interface LocalPathIssue {
16
16
  path: string
17
17
  error: string
18
18
  }
19
19
 
20
- const exists = async (path: string) => access(path).then(() => true, () => false)
20
+ export const pathExists = async (path: string) => access(path).then(() => true, () => false)
21
21
 
22
22
  export const inspectLocalFile = async (path: string): Promise<LocalFileInfo> => {
23
23
  const absolute = resolveUserPath(path)
@@ -52,23 +52,52 @@ export const inspectLocalPaths = async (paths: string[]) => {
52
52
 
53
53
  export const loadLocalFile = async (path: string): Promise<LocalFile> => {
54
54
  const info = await inspectLocalFile(path)
55
- return {
56
- ...info,
57
- blob: Bun.file(info.path),
58
- }
55
+ return { ...info }
59
56
  }
60
57
 
61
58
  export const loadLocalFiles = (paths: string[]) => Promise.all(paths.map(loadLocalFile))
62
59
 
63
- export const readFileChunk = async (file: LocalFile, offset: number, size: number) => Buffer.from(await file.blob.slice(offset, offset + size).arrayBuffer())
60
+ const readHandleChunk = async (handle: FileHandle, offset: number, size: number) => {
61
+ const chunk = Buffer.allocUnsafe(size)
62
+ let bytesReadTotal = 0
63
+ while (bytesReadTotal < size) {
64
+ const { bytesRead } = await handle.read(chunk, bytesReadTotal, size - bytesReadTotal, offset + bytesReadTotal)
65
+ if (!bytesRead) break
66
+ bytesReadTotal += bytesRead
67
+ }
68
+ return bytesReadTotal === size ? chunk : chunk.subarray(0, bytesReadTotal)
69
+ }
64
70
 
65
- export const uniqueOutputPath = async (directory: string, fileName: string) => {
71
+ export const readFileChunk = async (file: LocalFile, offset: number, size: number) => {
72
+ file.reader ||= await open(file.path, "r")
73
+ return readHandleChunk(file.reader, offset, size)
74
+ }
75
+
76
+ export const closeLocalFile = async (file?: LocalFile) => {
77
+ if (!file?.reader) return
78
+ const reader = file.reader
79
+ file.reader = undefined
80
+ await reader.close()
81
+ }
82
+
83
+ export const writeFileChunk = async (handle: FileHandle, data: Buffer, offset: number) => {
84
+ let bytesWrittenTotal = 0
85
+ while (bytesWrittenTotal < data.byteLength) {
86
+ const { bytesWritten } = await handle.write(data, bytesWrittenTotal, data.byteLength - bytesWrittenTotal, offset + bytesWrittenTotal)
87
+ if (!bytesWritten) throw new Error("short write")
88
+ bytesWrittenTotal += bytesWritten
89
+ }
90
+ return bytesWrittenTotal
91
+ }
92
+
93
+ export const uniqueOutputPath = async (directory: string, fileName: string, reservedPaths: ReadonlySet<string> = new Set()) => {
66
94
  await mkdir(directory, { recursive: true })
67
95
  const extension = extname(fileName)
68
96
  const stem = extension ? fileName.slice(0, -extension.length) : fileName
69
97
  for (let index = 0; ; index += 1) {
70
98
  const candidate = join(directory, index ? `${stem} (${index})${extension}` : fileName)
71
- if (!await exists(candidate)) return candidate
99
+ if (reservedPaths.has(candidate)) continue
100
+ if (!await pathExists(candidate)) return candidate
72
101
  }
73
102
  }
74
103
 
@@ -77,3 +106,7 @@ export const saveIncomingFile = async (directory: string, fileName: string, data
77
106
  await Bun.write(path, data)
78
107
  return path
79
108
  }
109
+
110
+ export const removePath = async (path: string) => {
111
+ await rm(path, { force: true }).catch(() => {})
112
+ }
@@ -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}`
@@ -1,7 +1,8 @@
1
+ import { open, rename, type FileHandle } from "node:fs/promises"
1
2
  import { resolve } from "node:path"
2
3
  import type { RTCDataChannel, RTCIceCandidateInit, RTCIceServer } from "werift"
3
4
  import { RTCPeerConnection } from "werift"
4
- import { loadLocalFiles, readFileChunk, saveIncomingFile, type LocalFile } from "./files"
5
+ import { closeLocalFile, loadLocalFiles, pathExists, readFileChunk, removePath, saveIncomingFile, uniqueOutputPath, writeFileChunk, type LocalFile } from "./files"
5
6
  import {
6
7
  BASE_ICE_SERVERS,
7
8
  BUFFER_HIGH,
@@ -87,12 +88,23 @@ interface TransferState {
87
88
  file?: LocalFile
88
89
  buffers?: Buffer[]
89
90
  data?: Buffer
91
+ incomingDisk?: IncomingDiskState
90
92
  inFlight: boolean
91
93
  cancel: boolean
92
94
  cancelReason?: string
93
95
  cancelSource?: "local" | "remote"
94
96
  }
95
97
 
98
+ interface IncomingDiskState {
99
+ finalPath: string
100
+ tempPath: string
101
+ handle: FileHandle
102
+ queue: Promise<void>
103
+ offset: number
104
+ error: string
105
+ closed: boolean
106
+ }
107
+
96
108
  export interface PeerSnapshot {
97
109
  id: string
98
110
  name: string
@@ -218,6 +230,7 @@ export const sanitizeProfile = (profile?: PeerProfile): PeerProfile => ({
218
230
  autoAcceptIncoming: typeof profile?.defaults?.autoAcceptIncoming === "boolean" ? profile.defaults.autoAcceptIncoming : undefined,
219
231
  autoSaveIncoming: typeof profile?.defaults?.autoSaveIncoming === "boolean" ? profile.defaults.autoSaveIncoming : undefined,
220
232
  },
233
+ streamingSaveIncoming: typeof profile?.streamingSaveIncoming === "boolean" ? profile.streamingSaveIncoming : undefined,
221
234
  ready: !!profile?.ready,
222
235
  error: cleanText(profile?.error, 120),
223
236
  })
@@ -242,6 +255,7 @@ export const localProfileFromResponse = (data: unknown, error = ""): PeerProfile
242
255
  ip: cleaned(value?.hs?.["cf-connecting-ip"] || value?.hs?.["x-real-ip"], 80),
243
256
  },
244
257
  ua: buildCliProfile().ua,
258
+ streamingSaveIncoming: true,
245
259
  ready: !!value?.cf,
246
260
  error,
247
261
  })
@@ -384,6 +398,7 @@ export class SendSession {
384
398
  private readonly logs: LogEntry[] = []
385
399
  private readonly subscribers = new Set<() => void>()
386
400
  private readonly eventSubscribers = new Set<(event: SessionEvent) => void>()
401
+ private readonly reservedSavePaths = new Set<string>()
387
402
 
388
403
  private rtcEpochCounter = 0
389
404
  private socket: WebSocket | null = null
@@ -674,7 +689,14 @@ export class SendSession {
674
689
  if (!transfer || transfer.direction !== "in" || isFinal(transfer)) return false
675
690
  const peer = this.peers.get(transfer.peerId)
676
691
  if (!peer || !this.isPeerReady(peer)) return false
677
- if (!this.sendDataControl(peer, { kind: "file-accept", transferId })) return false
692
+ const streamingToDisk = await this.startIncomingDiskTransfer(transfer)
693
+ transfer.data = undefined
694
+ transfer.buffers = streamingToDisk ? undefined : []
695
+ if (!this.sendDataControl(peer, { kind: "file-accept", transferId })) {
696
+ if (streamingToDisk) this.discardIncomingDiskTransfer(transfer)
697
+ transfer.buffers = []
698
+ return false
699
+ }
678
700
  transfer.status = "accepted"
679
701
  this.noteTransfer(transfer)
680
702
  this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
@@ -738,13 +760,12 @@ export class SendSession {
738
760
  async saveTransfer(transferId: string) {
739
761
  const transfer = this.transfers.get(transferId)
740
762
  if (!transfer || transfer.direction !== "in" || transfer.status !== "complete") return null
763
+ if (transfer.savedPath) return transfer.savedPath
741
764
  if (!transfer.data && transfer.buffers?.length) transfer.data = Buffer.concat(transfer.buffers)
742
765
  if (!transfer.data) return null
743
- transfer.savedPath ||= await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
766
+ transfer.savedPath = await saveIncomingFile(this.saveDir, transfer.name, transfer.data)
744
767
  transfer.savedAt ||= Date.now()
745
- const snapshot = this.transferSnapshot(transfer)
746
- this.pushLog("transfer:saved", { transferId: transfer.id, path: transfer.savedPath })
747
- this.emit({ type: "saved", transfer: snapshot })
768
+ this.emitTransferSaved(transfer)
748
769
  this.notify()
749
770
  return transfer.savedPath
750
771
  }
@@ -753,6 +774,124 @@ export class SendSession {
753
774
  return this.transfers.get(transferId)
754
775
  }
755
776
 
777
+ private emitTransferSaved(transfer: TransferState) {
778
+ const snapshot = this.transferSnapshot(transfer)
779
+ this.pushLog("transfer:saved", { transferId: transfer.id, path: transfer.savedPath })
780
+ this.emit({ type: "saved", transfer: snapshot })
781
+ }
782
+
783
+ private autoSaveTransfer(transferId: string) {
784
+ void this.saveTransfer(transferId).catch(error => {
785
+ this.pushLog("transfer:save-error", { transferId, error: `${error}` }, "error")
786
+ this.notify()
787
+ })
788
+ }
789
+
790
+ private async createIncomingDiskState(fileName: string): Promise<IncomingDiskState> {
791
+ const finalPath = await uniqueOutputPath(this.saveDir, fileName || "download", this.reservedSavePaths)
792
+ this.reservedSavePaths.add(finalPath)
793
+ for (let attempt = 0; ; attempt += 1) {
794
+ const tempPath = `${finalPath}.part.${uid(6)}${attempt ? `.${attempt}` : ""}`
795
+ try {
796
+ const handle = await open(tempPath, "wx")
797
+ return {
798
+ finalPath,
799
+ tempPath,
800
+ handle,
801
+ queue: Promise.resolve(),
802
+ offset: 0,
803
+ error: "",
804
+ closed: false,
805
+ }
806
+ } catch (error) {
807
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "EEXIST") continue
808
+ this.reservedSavePaths.delete(finalPath)
809
+ throw error
810
+ }
811
+ }
812
+ }
813
+
814
+ private takeIncomingDisk(transfer: TransferState) {
815
+ const disk = transfer.incomingDisk
816
+ transfer.incomingDisk = undefined
817
+ return disk
818
+ }
819
+
820
+ private async closeIncomingDiskState(disk: IncomingDiskState, removeTemp: boolean) {
821
+ if (!disk.closed) {
822
+ disk.closed = true
823
+ try { await disk.handle.close() } catch {}
824
+ }
825
+ if (removeTemp) await removePath(disk.tempPath)
826
+ this.reservedSavePaths.delete(disk.finalPath)
827
+ }
828
+
829
+ private discardIncomingDiskTransfer(transfer: TransferState) {
830
+ const disk = this.takeIncomingDisk(transfer)
831
+ if (!disk) return
832
+ void this.closeIncomingDiskState(disk, true)
833
+ }
834
+
835
+ private async startIncomingDiskTransfer(transfer: TransferState) {
836
+ if (!this.autoSaveIncoming || transfer.incomingDisk) return false
837
+ try {
838
+ transfer.incomingDisk = await this.createIncomingDiskState(transfer.name)
839
+ return true
840
+ } catch (error) {
841
+ this.pushLog("transfer:save-error", { transferId: transfer.id, error: `${error}` }, "error")
842
+ return false
843
+ }
844
+ }
845
+
846
+ private queueIncomingDiskWrite(peer: PeerState, transfer: TransferState, data: Buffer) {
847
+ const disk = transfer.incomingDisk
848
+ if (!disk) return
849
+ disk.queue = disk.queue.then(async () => {
850
+ if (disk.closed || disk.error || transfer.cancel || transfer.status !== "receiving") return
851
+ await writeFileChunk(disk.handle, data, disk.offset)
852
+ disk.offset += data.byteLength
853
+ }).catch(error => {
854
+ if (disk.error || isFinal(transfer)) return
855
+ disk.error = `${error}`
856
+ this.pushLog("transfer:save-error", { transferId: transfer.id, error: disk.error }, "error")
857
+ this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason: disk.error })
858
+ this.completeTransfer(transfer, "error", disk.error)
859
+ })
860
+ }
861
+
862
+ private async finalizeIncomingDiskTransfer(transfer: TransferState, expectedSize: number) {
863
+ const disk = this.takeIncomingDisk(transfer)
864
+ if (!disk) return null
865
+ await disk.queue
866
+ if (disk.error) {
867
+ await this.closeIncomingDiskState(disk, true)
868
+ return null
869
+ }
870
+ if (disk.offset !== expectedSize) throw new Error(`saved size mismatch: ${disk.offset} vs ${expectedSize}`)
871
+
872
+ let finalPath = disk.finalPath
873
+ try {
874
+ if (!disk.closed) {
875
+ disk.closed = true
876
+ await disk.handle.close()
877
+ }
878
+ if (await pathExists(finalPath)) {
879
+ this.reservedSavePaths.delete(finalPath)
880
+ finalPath = await uniqueOutputPath(this.saveDir, transfer.name || "download", this.reservedSavePaths)
881
+ this.reservedSavePaths.add(finalPath)
882
+ }
883
+ await rename(disk.tempPath, finalPath)
884
+ transfer.savedPath = finalPath
885
+ transfer.savedAt ||= Date.now()
886
+ return finalPath
887
+ } catch (error) {
888
+ await removePath(disk.tempPath)
889
+ throw error
890
+ } finally {
891
+ this.reservedSavePaths.delete(finalPath)
892
+ }
893
+ }
894
+
756
895
  async waitFor(predicate: () => boolean, timeoutMs: number, signal?: AbortSignal | null) {
757
896
  if (predicate()) return
758
897
  if (signal?.aborted) throw new SessionAbortedError()
@@ -930,9 +1069,11 @@ export class SendSession {
930
1069
  transfer.inFlight = false
931
1070
  transfer.endedAt = Date.now()
932
1071
  this.noteTransfer(transfer)
1072
+ if (transfer.direction === "out") void closeLocalFile(transfer.file).catch(() => {})
933
1073
  if (status !== "complete" && transfer.direction === "in") {
934
1074
  transfer.buffers = []
935
1075
  transfer.data = undefined
1076
+ this.discardIncomingDiskTransfer(transfer)
936
1077
  }
937
1078
 
938
1079
  const peer = this.peers.get(transfer.peerId)
@@ -945,7 +1086,7 @@ export class SendSession {
945
1086
 
946
1087
  const snapshot = this.transferSnapshot(transfer)
947
1088
  this.emit({ type: "transfer", transfer: snapshot })
948
- if (status === "complete" && transfer.direction === "in" && this.autoSaveIncoming) void this.saveTransfer(transfer.id)
1089
+ if (status === "complete" && transfer.direction === "in" && this.autoSaveIncoming && transfer.savedAt === 0) this.autoSaveTransfer(transfer.id)
949
1090
  this.notify()
950
1091
  }
951
1092
 
@@ -1036,6 +1177,7 @@ export class SendSession {
1036
1177
  autoAcceptIncoming: this.autoAcceptIncoming,
1037
1178
  autoSaveIncoming: this.autoSaveIncoming,
1038
1179
  },
1180
+ streamingSaveIncoming: true,
1039
1181
  })
1040
1182
  }
1041
1183
 
@@ -1517,8 +1659,11 @@ export class SendSession {
1517
1659
  private onBinary(peer: PeerState, data: Buffer) {
1518
1660
  const transfer = this.transfers.get(peer.activeIncoming)
1519
1661
  if (!transfer || transfer.status !== "receiving") return
1520
- transfer.buffers ||= []
1521
- transfer.buffers.push(data)
1662
+ if (transfer.incomingDisk) this.queueIncomingDiskWrite(peer, transfer, data)
1663
+ else {
1664
+ transfer.buffers ||= []
1665
+ transfer.buffers.push(data)
1666
+ }
1522
1667
  transfer.bytes += data.byteLength
1523
1668
  transfer.chunks += 1
1524
1669
  this.noteTransfer(transfer)
@@ -1579,7 +1724,8 @@ export class SendSession {
1579
1724
  peer.activeIncoming = transfer.id
1580
1725
  transfer.status = "receiving"
1581
1726
  transfer.startedAt ||= Date.now()
1582
- transfer.buffers = []
1727
+ transfer.data = undefined
1728
+ transfer.buffers = transfer.incomingDisk ? undefined : []
1583
1729
  this.noteTransfer(transfer)
1584
1730
  this.emit({ type: "transfer", transfer: this.transferSnapshot(transfer) })
1585
1731
  }
@@ -1593,6 +1739,7 @@ export class SendSession {
1593
1739
  case "file-end": {
1594
1740
  const transfer = this.transfers.get(message.transferId)
1595
1741
  if (!transfer || transfer.direction !== "in") break
1742
+ if (isFinal(transfer)) break
1596
1743
  if (transfer.status === "cancelling") {
1597
1744
  this.completeTransfer(transfer, "cancelled", transfer.cancelReason || "cancelled")
1598
1745
  break
@@ -1601,6 +1748,27 @@ export class SendSession {
1601
1748
  this.completeTransfer(transfer, "error", `unexpected end while ${transfer.status}`)
1602
1749
  break
1603
1750
  }
1751
+ if (transfer.bytes !== message.size) {
1752
+ const reason = `size mismatch: ${transfer.bytes} vs ${message.size}`
1753
+ this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason })
1754
+ this.completeTransfer(transfer, "error", reason)
1755
+ break
1756
+ }
1757
+ if (transfer.incomingDisk) {
1758
+ try {
1759
+ const savedPath = await this.finalizeIncomingDiskTransfer(transfer, message.size)
1760
+ if (!savedPath) break
1761
+ this.sendDataControl(peer, { kind: "file-done", transferId: transfer.id, size: transfer.bytes, totalChunks: transfer.chunks })
1762
+ this.completeTransfer(transfer, "complete")
1763
+ this.emitTransferSaved(transfer)
1764
+ } catch (error) {
1765
+ const reason = `${error}`
1766
+ this.pushLog("transfer:save-error", { transferId: transfer.id, error: reason }, "error")
1767
+ this.sendDataControl(peer, { kind: "file-error", transferId: transfer.id, reason })
1768
+ this.completeTransfer(transfer, "error", reason)
1769
+ }
1770
+ break
1771
+ }
1604
1772
  const data = Buffer.concat(transfer.buffers || [])
1605
1773
  if (data.byteLength !== message.size) {
1606
1774
  const reason = `size mismatch: ${data.byteLength} vs ${message.size}`
package/src/index.ts CHANGED
@@ -74,6 +74,7 @@ const TUI_TOGGLE_OPTIONS = [
74
74
  ["--offer <0|1>", "auto-offer drafts to matching ready peers: 1 on, 0 off"],
75
75
  ["--save <0|1>", "auto-save completed incoming files: 1 on, 0 off"],
76
76
  ] as const
77
+ export const ACCEPT_SESSION_DEFAULTS = { autoAcceptIncoming: true, autoSaveIncoming: true } as const
77
78
  const addOptions = (command: CliCommand, definitions: readonly (readonly [string, string])[]) =>
78
79
  definitions.reduce((next, [flag, description]) => next.option(flag, description), command)
79
80
 
@@ -233,7 +234,7 @@ const offerCommand = async (files: string[], options: Record<string, unknown>) =
233
234
 
234
235
  const acceptCommand = async (options: Record<string, unknown>) => {
235
236
  const { SendSession } = await loadSessionRuntime()
236
- const session = new SendSession(sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true }))
237
+ const session = new SendSession(sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS))
237
238
  handleSignals(session)
238
239
  printRoomAnnouncement(session.room, displayPeerName(session.name, session.localId), !!options.json)
239
240
  const detachReporter = attachReporter(session, !!options.json)
@@ -253,7 +254,7 @@ const acceptCommand = async (options: Record<string, unknown>) => {
253
254
  }
254
255
 
255
256
  const tuiCommand = async (options: Record<string, unknown>) => {
256
- const initialConfig = sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true })
257
+ const initialConfig = sessionConfigFrom(options, ACCEPT_SESSION_DEFAULTS)
257
258
  const { clean, offer } = parseBinaryOptions(options, ["clean", "offer"] as const)
258
259
  const { startTui } = await loadTuiRuntime()
259
260
  await startTui(initialConfig, {
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 { rgb, ui, 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
@@ -57,6 +65,7 @@ export interface TuiState {
57
65
  bootNameJumpPending: boolean
58
66
  draftInput: string
59
67
  draftInputKeyVersion: number
68
+ draftHistory: DraftHistoryState
60
69
  filePreview: FilePreviewState
61
70
  drafts: DraftItem[]
62
71
  autoOfferOutgoing: boolean
@@ -88,7 +97,7 @@ export interface TuiActions {
88
97
  toggleAutoOffer: TuiAction
89
98
  toggleAutoAccept: TuiAction
90
99
  toggleAutoSave: TuiAction
91
- setDraftInput: (value: string) => void
100
+ setDraftInput: (value: string, cursor?: number) => void
92
101
  addDrafts: TuiAction
93
102
  removeDraft: (draftId: string) => void
94
103
  clearDrafts: TuiAction
@@ -111,6 +120,7 @@ const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
111
120
  const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
112
121
  const PRIMARY_TEXT_STYLE = { fg: rgb(255, 255, 255) } as const
113
122
  const HEADING_TEXT_STYLE = { fg: rgb(255, 255, 255), bold: true } as const
123
+ const MUTED_TEXT_STYLE = { fg: rgb(159, 166, 178) } as const
114
124
  const DEFAULT_WEB_URL = "https://send.rt.ht/"
115
125
  const DEFAULT_SAVE_DIR = resolve(process.cwd())
116
126
  const ABOUT_ELEFUNC_URL = "https://elefunc.com/send"
@@ -129,6 +139,7 @@ const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0
129
139
  const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
130
140
  const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
131
141
  const pluralRules = new Intl.PluralRules()
142
+ const DRAFT_HISTORY_LIMIT = 50
132
143
 
133
144
  export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ? ["peers", "transfers", "logs"] : ["peers", "transfers"]
134
145
 
@@ -380,7 +391,7 @@ const uaSummary = (profile?: PeerProfile) => joinSummary([profile?.ua?.browser,
380
391
  const profileIp = (profile?: PeerProfile) => profile?.network?.ip || "—"
381
392
  const peerDefaultsVariant = (profile?: PeerProfile): BadgeVariant => {
382
393
  const token = peerDefaultsToken(profile)
383
- return token === "AS" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
394
+ return token === "AX" ? "success" : token === "as" ? "warning" : token === "??" ? "default" : "info"
384
395
  }
385
396
  const TIGHT_TAG_COLORS = {
386
397
  default: rgb(89, 194, 255),
@@ -389,6 +400,12 @@ const TIGHT_TAG_COLORS = {
389
400
  error: rgb(240, 113, 120),
390
401
  info: rgb(89, 194, 255),
391
402
  } as const
403
+ const PREVIEW_PREFIX_STYLE = { fg: rgb(112, 121, 136), dim: true } as const
404
+ const PREVIEW_PATH_STYLE = { ...MUTED_TEXT_STYLE, dim: true } as const
405
+ const PREVIEW_BASENAME_STYLE = PRIMARY_TEXT_STYLE
406
+ const PREVIEW_HIGHLIGHT_STYLE = { fg: TIGHT_TAG_COLORS.info, bold: true } as const
407
+ const PREVIEW_SELECTED_HIGHLIGHT_STYLE = { fg: TIGHT_TAG_COLORS.success, bold: true } as const
408
+ const PREVIEW_SELECTED_MARKER_STYLE = { fg: TIGHT_TAG_COLORS.info, bold: true } as const
392
409
  const tightTag = (text: string, props: { key?: string; variant?: BadgeVariant; bare?: boolean } = {}) => ui.text(props.bare ? text : `(${text})`, {
393
410
  ...(props.key === undefined ? {} : { key: props.key }),
394
411
  style: {
@@ -408,6 +425,71 @@ const emptyFilePreviewState = (): FilePreviewState => ({
408
425
  selectedIndex: null,
409
426
  scrollTop: 0,
410
427
  })
428
+ const emptyDraftHistoryState = (): DraftHistoryState => ({
429
+ entries: [],
430
+ index: null,
431
+ baseInput: null,
432
+ })
433
+
434
+ const resetDraftHistoryBrowse = (history: DraftHistoryState): DraftHistoryState =>
435
+ history.index == null && history.baseInput == null
436
+ ? history
437
+ : { ...history, index: null, baseInput: null }
438
+
439
+ export const pushDraftHistoryEntry = (history: DraftHistoryState, value: string, limit = DRAFT_HISTORY_LIMIT): DraftHistoryState => {
440
+ const nextValue = normalizeSearchQuery(value)
441
+ if (!nextValue) return resetDraftHistoryBrowse(history)
442
+ return {
443
+ entries: history.entries[0] === nextValue ? history.entries : [nextValue, ...history.entries].slice(0, limit),
444
+ index: null,
445
+ baseInput: null,
446
+ }
447
+ }
448
+
449
+ export const isDraftHistoryEntryPoint = (value: string, cursor: number, cwd = process.cwd()) => {
450
+ if (cursor !== 0) return false
451
+ const normalized = normalizeSearchQuery(value)
452
+ if (!normalized) return true
453
+ return !deriveFileSearchScope(value, cwd)?.query
454
+ }
455
+
456
+ export const canNavigateDraftHistory = (history: DraftHistoryState, value: string, cursor: number, cwd = process.cwd()) =>
457
+ cursor === 0 && (history.index != null || (history.entries.length > 0 && isDraftHistoryEntryPoint(value, cursor, cwd)))
458
+
459
+ export const moveDraftHistory = (history: DraftHistoryState, value: string, direction: -1 | 1) => {
460
+ if (!history.entries.length) return { history, value, changed: false }
461
+ if (history.index == null) {
462
+ if (direction > 0) return { history, value, changed: false }
463
+ const nextIndex = 0
464
+ return {
465
+ history: { ...history, index: nextIndex, baseInput: value },
466
+ value: history.entries[nextIndex]!,
467
+ changed: history.entries[nextIndex] !== value,
468
+ }
469
+ }
470
+ if (direction < 0) {
471
+ const nextIndex = Math.min(history.entries.length - 1, history.index + 1)
472
+ return {
473
+ history: nextIndex === history.index ? history : { ...history, index: nextIndex },
474
+ value: history.entries[nextIndex]!,
475
+ changed: nextIndex !== history.index || history.entries[nextIndex] !== value,
476
+ }
477
+ }
478
+ if (history.index === 0) {
479
+ const nextValue = history.baseInput ?? ""
480
+ return {
481
+ history: resetDraftHistoryBrowse(history),
482
+ value: nextValue,
483
+ changed: nextValue !== value || history.index != null,
484
+ }
485
+ }
486
+ const nextIndex = history.index - 1
487
+ return {
488
+ history: { ...history, index: nextIndex },
489
+ value: history.entries[nextIndex]!,
490
+ changed: history.entries[nextIndex] !== value,
491
+ }
492
+ }
411
493
 
412
494
  type FocusControllerState = Pick<TuiState, "pendingFocusTarget" | "focusRequestEpoch" | "bootNameJumpPending">
413
495
 
@@ -493,6 +575,12 @@ export const transferActualDurationMs = (transfer: TransferSnapshot, now = Date.
493
575
  export const transferWaitDurationMs = (transfer: TransferSnapshot, now = Date.now()) => Math.max(0, (transfer.startedAt || now) - transfer.createdAt)
494
576
  export const filePreviewVisible = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">) =>
495
577
  state.focusedId === DRAFT_INPUT_ID && !!normalizeSearchQuery(state.draftInput) && state.filePreview.dismissedQuery !== state.draftInput
578
+ export const canAcceptFilePreviewWithRight = (state: Pick<TuiState, "focusedId" | "draftInput" | "filePreview">, cursor: number) =>
579
+ state.focusedId === DRAFT_INPUT_ID
580
+ && cursor === state.draftInput.length
581
+ && filePreviewVisible(state)
582
+ && state.filePreview.selectedIndex != null
583
+ && !!state.filePreview.results[state.filePreview.selectedIndex]
496
584
  export const clampFilePreviewSelectedIndex = (selectedIndex: number | null, resultCount: number) =>
497
585
  !resultCount ? null : selectedIndex == null ? 0 : Math.max(0, Math.min(resultCount - 1, selectedIndex))
498
586
  export const ensureFilePreviewScrollTop = (selectedIndex: number | null, scrollTop: number, resultCount: number, visibleRows = FILE_SEARCH_VISIBLE_ROWS) => {
@@ -519,24 +607,36 @@ const selectedFilePreviewMatch = (state: TuiState) => {
519
607
  const index = state.filePreview.selectedIndex
520
608
  return index == null ? null : state.filePreview.results[index] ?? null
521
609
  }
522
- const highlightedSegments = (value: string, indices: number[]) => {
610
+ export const previewPathSegments = (value: string, prefixLength: number, indices: number[]) => {
523
611
  const marks = new Set(indices)
524
612
  const chars = Array.from(value)
525
- const segments: Array<{ text: string; highlighted: boolean }> = []
613
+ const basenameStart = Math.max(prefixLength, value.lastIndexOf("/") + 1)
614
+ const segments: PreviewSegment[] = []
526
615
  let current = ""
527
616
  let highlighted = false
617
+ let role: PreviewSegmentRole = "basename"
528
618
  for (let index = 0; index < chars.length; index += 1) {
529
619
  const nextHighlighted = marks.has(index)
530
- if (current && nextHighlighted !== highlighted) {
531
- segments.push({ text: current, highlighted })
620
+ const nextRole = index < prefixLength ? "prefix" : index < basenameStart ? "path" : "basename"
621
+ if (current && (nextHighlighted !== highlighted || nextRole !== role)) {
622
+ segments.push({ text: current, highlighted, role })
532
623
  current = ""
533
624
  }
534
625
  current += chars[index]
535
626
  highlighted = nextHighlighted
627
+ role = nextRole
536
628
  }
537
- if (current) segments.push({ text: current, highlighted })
629
+ if (current) segments.push({ text: current, highlighted, role })
538
630
  return segments
539
631
  }
632
+ export const previewSegmentStyle = (segment: PreviewSegment, selected: boolean): TextStyle =>
633
+ segment.highlighted
634
+ ? selected ? PREVIEW_SELECTED_HIGHLIGHT_STYLE : PREVIEW_HIGHLIGHT_STYLE
635
+ : segment.role === "prefix"
636
+ ? PREVIEW_PREFIX_STYLE
637
+ : segment.role === "path"
638
+ ? PREVIEW_PATH_STYLE
639
+ : PREVIEW_BASENAME_STYLE
540
640
 
541
641
  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
642
  const order = ["draft", "pending", "queued", "offered", "accepted", "receiving", "sending", "awaiting-done", "cancelling", "complete", "rejected", "cancelled", "error"]
@@ -609,6 +709,7 @@ export const createInitialTuiState = (initialConfig: SessionConfig, showEvents =
609
709
  ...focusState,
610
710
  draftInput: "",
611
711
  draftInputKeyVersion: 0,
712
+ draftHistory: emptyDraftHistoryState(),
612
713
  filePreview: emptyFilePreviewState(),
613
714
  drafts: [],
614
715
  autoOfferOutgoing: launchOptions.offer ?? true,
@@ -939,14 +1040,14 @@ const renderDraftSummary = (drafts: DraftItem[]) => denseSection({
939
1040
  ui.row({ gap: 1, wrap: true }, summarizeStates(drafts, () => "draft", draft => draft.size, ["draft"]).map(renderSummaryStat)),
940
1041
  ])
941
1042
 
942
- const renderHighlightedPreviewPath = (value: string, indices: number[], options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
1043
+ const renderHighlightedPreviewPath = (value: string, prefixLength: number, indices: number[], selected: boolean, options: { id?: string; key?: string; flex?: number } = {}) => ui.row({
943
1044
  gap: 0,
944
1045
  wrap: true,
945
1046
  ...(options.id === undefined ? {} : { id: options.id }),
946
1047
  ...(options.key === undefined ? {} : { key: options.key }),
947
1048
  ...(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 } } : {}) }),
1049
+ }, previewPathSegments(value, prefixLength, indices).map((segment, index) =>
1050
+ ui.text(segment.text, { key: `segment-${index}`, style: previewSegmentStyle(segment, selected) }),
950
1051
  ))
951
1052
 
952
1053
  const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: boolean, displayPrefix: string) => ui.row({
@@ -955,10 +1056,12 @@ const renderFilePreviewRow = (match: FileSearchMatch, index: number, selected: b
955
1056
  gap: 1,
956
1057
  wrap: true,
957
1058
  }, [
958
- ui.text(selected ? ">" : " "),
1059
+ ui.text(selected ? ">" : " ", { style: selected ? PREVIEW_SELECTED_MARKER_STYLE : PREVIEW_PATH_STYLE }),
959
1060
  renderHighlightedPreviewPath(
960
1061
  formatFileSearchDisplayPath(displayPrefix, match.relativePath),
1062
+ Array.from(formatFileSearchDisplayPath(displayPrefix, "")).length,
961
1063
  offsetFileSearchMatchIndices(displayPrefix, match.indices),
1064
+ selected,
962
1065
  { id: `file-preview-path-${index}`, flex: 1 },
963
1066
  ),
964
1067
  match.kind === "file" && typeof match.size === "number" ? ui.text(formatBytes(match.size), { style: { dim: true } }) : null,
@@ -1108,7 +1211,7 @@ const renderFilesCard = (state: TuiState, actions: TuiActions) => denseSection({
1108
1211
  key: `draft-input-${state.draftInputKeyVersion}`,
1109
1212
  value: state.draftInput,
1110
1213
  placeholder: "path/to/file.txt",
1111
- onInput: value => actions.setDraftInput(value),
1214
+ onInput: (value, cursor) => actions.setDraftInput(value, cursor),
1112
1215
  }),
1113
1216
  ]),
1114
1217
  actionButton("add-drafts", "Add", actions.addDrafts, "primary", !state.draftInput.trim()),
@@ -1214,6 +1317,7 @@ export const withAcceptedDraftInput = (state: TuiState, draftInput: string, file
1214
1317
  ...state,
1215
1318
  draftInput,
1216
1319
  draftInputKeyVersion: state.draftInputKeyVersion + 1,
1320
+ draftHistory: resetDraftHistoryBrowse(state.draftHistory),
1217
1321
  filePreview,
1218
1322
  pendingFocusTarget: DRAFT_INPUT_ID,
1219
1323
  focusRequestEpoch: state.focusRequestEpoch + 1,
@@ -1233,6 +1337,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1233
1337
  let previewWorker: Worker | null = null
1234
1338
  let previewSessionId: string | null = null
1235
1339
  let previewSessionRoot: string | null = null
1340
+ let draftCursor = state.draftInput.length
1341
+ let draftCursorBeforeEvent = draftCursor
1236
1342
 
1237
1343
  const flushUpdate = () => {
1238
1344
  if (updateQueued || stopping || cleanedUp) return
@@ -1270,6 +1376,10 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1270
1376
  ...emptyFilePreviewState(),
1271
1377
  ...overrides,
1272
1378
  })
1379
+ const exitDraftHistoryBrowse = () => commit(current =>
1380
+ current.draftHistory.index == null && current.draftHistory.baseInput == null
1381
+ ? current
1382
+ : { ...current, draftHistory: resetDraftHistoryBrowse(current.draftHistory) })
1273
1383
 
1274
1384
  const ensurePreviewSession = (workspaceRoot: string) => {
1275
1385
  if (previewWorker && previewSessionId && previewSessionRoot === workspaceRoot) return
@@ -1365,17 +1475,21 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1365
1475
  return scope
1366
1476
  }
1367
1477
 
1368
- const updateDraftInput = (value: string) => {
1369
- const scope = deriveFileSearchScope(value, previewBaseRoot)
1370
- const shouldDispose = !scope || state.filePreview.dismissedQuery === value
1478
+ const applyDraftInputValue = (value: string, options: { cursor?: number; history?: DraftHistoryState } = {}) => {
1479
+ const nextValue = normalizeSearchQuery(value)
1480
+ if (options.cursor !== undefined) draftCursor = Math.min(options.cursor, nextValue.length)
1481
+ const scope = deriveFileSearchScope(nextValue, previewBaseRoot)
1482
+ const shouldDispose = !scope || state.filePreview.dismissedQuery === nextValue
1371
1483
  commit(current => {
1372
- if (!scope) return { ...current, draftInput: value, filePreview: resetFilePreview() }
1373
- const shouldDismiss = current.filePreview.dismissedQuery === value
1484
+ const draftHistory = options.history ?? resetDraftHistoryBrowse(current.draftHistory)
1485
+ if (!scope) return { ...current, draftInput: nextValue, draftHistory, filePreview: resetFilePreview() }
1486
+ const shouldDismiss = current.filePreview.dismissedQuery === nextValue
1374
1487
  const rootChanged = current.filePreview.workspaceRoot !== scope.workspaceRoot
1375
1488
  const basePreview = rootChanged ? resetFilePreview() : current.filePreview
1376
1489
  return {
1377
1490
  ...current,
1378
- draftInput: value,
1491
+ draftInput: nextValue,
1492
+ draftHistory,
1379
1493
  filePreview: {
1380
1494
  ...basePreview,
1381
1495
  workspaceRoot: scope.workspaceRoot,
@@ -1391,7 +1505,19 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1391
1505
  stopPreviewSession()
1392
1506
  return
1393
1507
  }
1394
- requestFilePreview(value)
1508
+ requestFilePreview(nextValue)
1509
+ }
1510
+
1511
+ const updateDraftInput = (value: string, cursor = draftCursor) => {
1512
+ applyDraftInputValue(value, { cursor })
1513
+ }
1514
+
1515
+ const recallDraftHistory = (direction: -1 | 1) => {
1516
+ const next = moveDraftHistory(state.draftHistory, state.draftInput, direction)
1517
+ if (!next.changed) return false
1518
+ draftCursorBeforeEvent = 0
1519
+ applyDraftInputValue(next.value, { cursor: 0, history: next.history })
1520
+ return true
1395
1521
  }
1396
1522
 
1397
1523
  const acceptSelectedFilePreview = () => {
@@ -1413,6 +1539,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1413
1539
  selectedIndex: null,
1414
1540
  scrollTop: 0,
1415
1541
  }, { text: `Browsing ${nextValue}`, variant: "info" }))
1542
+ draftCursor = nextValue.length
1543
+ draftCursorBeforeEvent = draftCursor
1416
1544
  requestFilePreview(nextValue)
1417
1545
  return true
1418
1546
  }
@@ -1422,6 +1550,8 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1422
1550
  resetFilePreview({ dismissedQuery: displayPath }),
1423
1551
  { text: `Selected ${displayPath}.`, variant: "success" },
1424
1552
  ))
1553
+ draftCursor = displayPath.length
1554
+ draftCursorBeforeEvent = draftCursor
1425
1555
  stopPreviewSession()
1426
1556
  return true
1427
1557
  }
@@ -1477,6 +1607,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1477
1607
  roomInput: nextSeed.room,
1478
1608
  nameInput: visibleNameInput(nextSeed.name),
1479
1609
  draftInput: "",
1610
+ draftHistory: resetDraftHistoryBrowse(current.draftHistory),
1480
1611
  filePreview: resetFilePreview(),
1481
1612
  drafts: [],
1482
1613
  offeringDrafts: false,
@@ -1486,8 +1617,10 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1486
1617
  pendingFocusTarget: current.pendingFocusTarget,
1487
1618
  focusRequestEpoch: current.focusRequestEpoch,
1488
1619
  bootNameJumpPending: current.bootNameJumpPending,
1489
- }),
1620
+ }),
1490
1621
  }, { text, variant: "success" }))
1622
+ draftCursor = 0
1623
+ draftCursorBeforeEvent = 0
1491
1624
  bindSession(nextSession)
1492
1625
  void previousSession.close()
1493
1626
  }
@@ -1499,6 +1632,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1499
1632
  return
1500
1633
  }
1501
1634
  replaceSession({ ...state.sessionSeed, room: nextRoom }, `Joined room ${nextRoom}.`)
1635
+ draftCursor = 0
1502
1636
  }
1503
1637
 
1504
1638
  const commitName = () => {
@@ -1529,9 +1663,14 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1529
1663
  commit(current => withNotice({
1530
1664
  ...current,
1531
1665
  draftInput: current.draftInput === submittedInput ? "" : current.draftInput,
1666
+ draftHistory: pushDraftHistoryEntry(current.draftHistory, submittedInput),
1532
1667
  filePreview: current.draftInput === submittedInput ? resetFilePreview() : current.filePreview,
1533
1668
  drafts: [...current.drafts, created],
1534
1669
  }, { text: `Added ${plural(1, "draft file")}.`, variant: "success" }))
1670
+ if (shouldDispose) {
1671
+ draftCursor = 0
1672
+ draftCursorBeforeEvent = 0
1673
+ }
1535
1674
  if (shouldDispose) stopPreviewSession()
1536
1675
  maybeOfferDrafts()
1537
1676
  }, error => {
@@ -1614,7 +1753,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1614
1753
  error => commit(current => withNotice(current, { text: `${error}`, variant: "error" })),
1615
1754
  )
1616
1755
  },
1617
- setDraftInput: value => updateDraftInput(value),
1756
+ setDraftInput: (value, cursor) => updateDraftInput(value, cursor),
1618
1757
  addDrafts,
1619
1758
  removeDraft: draftId => commit(current => withNotice({ ...current, drafts: current.drafts.filter(draft => draft.id !== draftId) }, { text: "Draft removed.", variant: "warning" })),
1620
1759
  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 +1801,21 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1662
1801
  }
1663
1802
 
1664
1803
  app.view(model => renderTuiView(model, actions))
1804
+ app.onEvent((event: UiEvent) => {
1805
+ if (event.kind !== "engine" || state.focusedId !== DRAFT_INPUT_ID) return
1806
+ draftCursorBeforeEvent = draftCursor
1807
+ const edit = applyInputEditEvent(event.event, {
1808
+ id: DRAFT_INPUT_ID,
1809
+ value: state.draftInput,
1810
+ cursor: draftCursor,
1811
+ selectionStart: null,
1812
+ selectionEnd: null,
1813
+ multiline: false,
1814
+ })
1815
+ if (!edit) return
1816
+ draftCursor = edit.nextCursor
1817
+ if (!edit.action && state.draftHistory.index != null && draftCursor !== 0) exitDraftHistoryBrowse()
1818
+ })
1665
1819
  app.onFocusChange(info => {
1666
1820
  const previousFocusedId = state.focusedId
1667
1821
  commit(current => {
@@ -1672,6 +1826,7 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1672
1826
  stopPreviewSession()
1673
1827
  commit(current => ({
1674
1828
  ...current,
1829
+ draftHistory: resetDraftHistoryBrowse(current.draftHistory),
1675
1830
  filePreview: resetFilePreview({
1676
1831
  dismissedQuery: current.filePreview.dismissedQuery === current.draftInput ? current.draftInput : null,
1677
1832
  }),
@@ -1710,17 +1865,26 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1710
1865
  acceptSelectedFilePreview()
1711
1866
  },
1712
1867
  },
1868
+ right: {
1869
+ description: "Accept focused preview row at end of Files input",
1870
+ when: ctx => canAcceptFilePreviewWithRight(state, draftCursorBeforeEvent) && ctx.focusedId === DRAFT_INPUT_ID,
1871
+ handler: () => {
1872
+ acceptSelectedFilePreview()
1873
+ },
1874
+ },
1713
1875
  up: {
1714
- description: "Move file preview selection up",
1715
- when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
1876
+ description: "Recall history or move file preview selection up",
1877
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) || filePreviewVisible(state) && state.filePreview.results.length > 0),
1716
1878
  handler: () => {
1879
+ if (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) && recallDraftHistory(-1)) return
1717
1880
  commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, -1) }))
1718
1881
  },
1719
1882
  },
1720
1883
  down: {
1721
- description: "Move file preview selection down",
1722
- when: ctx => ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state) && state.filePreview.results.length > 0,
1884
+ description: "Recall history or move file preview selection down",
1885
+ when: ctx => ctx.focusedId === DRAFT_INPUT_ID && (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) || filePreviewVisible(state) && state.filePreview.results.length > 0),
1723
1886
  handler: () => {
1887
+ if (canNavigateDraftHistory(state.draftHistory, state.draftInput, draftCursorBeforeEvent, previewBaseRoot) && recallDraftHistory(1)) return
1724
1888
  commit(current => ({ ...current, filePreview: moveFilePreviewSelection(current.filePreview, 1) }))
1725
1889
  },
1726
1890
  },
@@ -1755,13 +1919,15 @@ export const startTui = async (initialConfig: SessionConfig, launchOptions: TuiL
1755
1919
  if (ctx.focusedId === NAME_INPUT_ID) commit(current => withNotice({ ...current, nameInput: visibleNameInput(current.snapshot.name) }, { text: "Name input reset.", variant: "warning" }))
1756
1920
  if (ctx.focusedId === DRAFT_INPUT_ID && filePreviewVisible(state)) {
1757
1921
  stopPreviewSession()
1922
+ exitDraftHistoryBrowse()
1758
1923
  commit(current => withNotice({
1759
1924
  ...current,
1760
1925
  filePreview: resetFilePreview({ dismissedQuery: current.draftInput }),
1761
1926
  }, { text: "File preview hidden.", variant: "warning" }))
1762
1927
  } else if (ctx.focusedId === DRAFT_INPUT_ID) {
1763
1928
  stopPreviewSession()
1764
- commit(current => withNotice({ ...current, draftInput: "", filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
1929
+ draftCursor = 0
1930
+ commit(current => withNotice({ ...current, draftInput: "", draftHistory: resetDraftHistoryBrowse(current.draftHistory), filePreview: resetFilePreview() }, { text: "Draft input cleared.", variant: "warning" }))
1765
1931
  }
1766
1932
  },
1767
1933
  },
@@ -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)