@elefunc/send 0.1.1 → 0.1.3
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/README.md +5 -0
- package/package.json +1 -1
- package/src/index.ts +46 -14
- package/src/tui/app.ts +35 -0
- package/src/tui/rezi-input-caret.ts +46 -0
package/README.md
CHANGED
|
@@ -15,16 +15,21 @@ bun add -g @elefunc/send
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
+
send
|
|
18
19
|
send peers
|
|
19
20
|
send offer ./file.txt
|
|
20
21
|
send accept
|
|
21
22
|
send tui --events
|
|
22
23
|
```
|
|
23
24
|
|
|
25
|
+
When no subcommand is provided, `send` launches the TUI by default.
|
|
26
|
+
|
|
24
27
|
## Rooms
|
|
25
28
|
|
|
26
29
|
`--room` is optional on all commands. If you omit it, `send` creates a random room and prints or shows it.
|
|
27
30
|
|
|
31
|
+
In the TUI, the room row includes a `📋` invite link that opens the equivalent web app URL for the current committed room and toggle state. Set `SEND_WEB_URL` to change its base URL; it defaults to `https://send.rt.ht/`.
|
|
32
|
+
|
|
28
33
|
## Self Identity
|
|
29
34
|
|
|
30
35
|
`--self` accepts three forms:
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { cac, type CAC } from "cac"
|
|
|
4
4
|
import { cleanRoom } from "./core/protocol"
|
|
5
5
|
import { SendSession, type SessionConfig, type SessionEvent } from "./core/session"
|
|
6
6
|
import { resolvePeerTargets } from "./core/targeting"
|
|
7
|
-
import {
|
|
7
|
+
import { ensureReziInputCaretPatch } from "./tui/rezi-input-caret"
|
|
8
8
|
|
|
9
9
|
export class ExitError extends Error {
|
|
10
10
|
constructor(message: string, readonly code = 1) {
|
|
@@ -194,11 +194,28 @@ const acceptCommand = async (options: Record<string, unknown>) => {
|
|
|
194
194
|
|
|
195
195
|
const tuiCommand = async (options: Record<string, unknown>) => {
|
|
196
196
|
const initialConfig = sessionConfigFrom(options, { autoAcceptIncoming: true, autoSaveIncoming: true })
|
|
197
|
+
await ensureReziInputCaretPatch()
|
|
198
|
+
const { startTui } = await import("./tui/app")
|
|
197
199
|
await startTui(initialConfig, !!options.events)
|
|
198
200
|
}
|
|
199
201
|
|
|
200
|
-
|
|
202
|
+
type CliHandlers = {
|
|
203
|
+
peers: typeof peersCommand
|
|
204
|
+
offer: typeof offerCommand
|
|
205
|
+
accept: typeof acceptCommand
|
|
206
|
+
tui: typeof tuiCommand
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const defaultCliHandlers: CliHandlers = {
|
|
210
|
+
peers: peersCommand,
|
|
211
|
+
offer: offerCommand,
|
|
212
|
+
accept: acceptCommand,
|
|
213
|
+
tui: tuiCommand,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const createCli = (handlers: CliHandlers = defaultCliHandlers) => {
|
|
201
217
|
const cli = cac("send")
|
|
218
|
+
cli.usage("[command] [options]")
|
|
202
219
|
|
|
203
220
|
cli
|
|
204
221
|
.command("peers", "list discovered peers")
|
|
@@ -210,7 +227,7 @@ export const createCli = () => {
|
|
|
210
227
|
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
211
228
|
.option("--turn-username <value>", "custom TURN username")
|
|
212
229
|
.option("--turn-credential <value>", "custom TURN credential")
|
|
213
|
-
.action(
|
|
230
|
+
.action(handlers.peers)
|
|
214
231
|
|
|
215
232
|
cli
|
|
216
233
|
.command("offer [...files]", "offer files to browser-compatible peers")
|
|
@@ -223,7 +240,7 @@ export const createCli = () => {
|
|
|
223
240
|
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
224
241
|
.option("--turn-username <value>", "custom TURN username")
|
|
225
242
|
.option("--turn-credential <value>", "custom TURN credential")
|
|
226
|
-
.action(
|
|
243
|
+
.action(handlers.offer)
|
|
227
244
|
|
|
228
245
|
cli
|
|
229
246
|
.command("accept", "receive and save files")
|
|
@@ -235,7 +252,7 @@ export const createCli = () => {
|
|
|
235
252
|
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
236
253
|
.option("--turn-username <value>", "custom TURN username")
|
|
237
254
|
.option("--turn-credential <value>", "custom TURN credential")
|
|
238
|
-
.action(
|
|
255
|
+
.action(handlers.accept)
|
|
239
256
|
|
|
240
257
|
cli
|
|
241
258
|
.command("tui", "launch the interactive terminal UI")
|
|
@@ -246,24 +263,39 @@ export const createCli = () => {
|
|
|
246
263
|
.option("--turn-url <url>", "custom TURN url, repeat or comma-separate")
|
|
247
264
|
.option("--turn-username <value>", "custom TURN username")
|
|
248
265
|
.option("--turn-credential <value>", "custom TURN credential")
|
|
249
|
-
.action(
|
|
266
|
+
.action(handlers.tui)
|
|
250
267
|
|
|
251
|
-
cli.help(
|
|
268
|
+
cli.help(sections => {
|
|
269
|
+
const usage = sections.find(section => section.title === "Usage:")
|
|
270
|
+
if (usage) usage.body = " $ send [command] [options]"
|
|
271
|
+
const moreInfoIndex = sections.findIndex(section => section.title?.startsWith("For more info"))
|
|
272
|
+
const defaultSection = {
|
|
273
|
+
title: "Default",
|
|
274
|
+
body: " send with no command launches the terminal UI (same as `send tui`).",
|
|
275
|
+
}
|
|
276
|
+
if (moreInfoIndex < 0) sections.push(defaultSection)
|
|
277
|
+
else sections.splice(moreInfoIndex, 0, defaultSection)
|
|
278
|
+
})
|
|
252
279
|
|
|
253
280
|
return cli
|
|
254
281
|
}
|
|
255
282
|
|
|
256
|
-
const
|
|
283
|
+
const explicitCommand = (cli: CAC, argv: string[]) => {
|
|
257
284
|
const command = argv[2]
|
|
258
|
-
if (!command || command.startsWith("-")) return
|
|
259
|
-
if (cli.commands.some(entry => entry.isMatched(command))) return
|
|
285
|
+
if (!command || command.startsWith("-")) return undefined
|
|
286
|
+
if (cli.commands.some(entry => entry.isMatched(command))) return command
|
|
260
287
|
throw new ExitError(`Unknown command \`${command}\``, 1)
|
|
261
288
|
}
|
|
262
289
|
|
|
263
|
-
export const runCli = async (argv = process.argv) => {
|
|
264
|
-
const cli = createCli()
|
|
265
|
-
|
|
266
|
-
cli.parse(argv, { run: false })
|
|
290
|
+
export const runCli = async (argv = process.argv, handlers: CliHandlers = defaultCliHandlers) => {
|
|
291
|
+
const cli = createCli(handlers)
|
|
292
|
+
const command = explicitCommand(cli, argv)
|
|
293
|
+
const parsed = cli.parse(argv, { run: false }) as { options: Record<string, unknown> }
|
|
294
|
+
const helpRequested = !!parsed.options.help || !!parsed.options.h
|
|
295
|
+
if (!command && !helpRequested) {
|
|
296
|
+
await handlers.tui(parsed.options)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
267
299
|
await cli.runMatchedCommand()
|
|
268
300
|
}
|
|
269
301
|
|
package/src/tui/app.ts
CHANGED
|
@@ -97,6 +97,7 @@ const NAME_INPUT_ID = "name-input"
|
|
|
97
97
|
const DRAFT_INPUT_ID = "draft-input"
|
|
98
98
|
const TRANSPARENT_BORDER_STYLE = { fg: rgb(7, 10, 12) } as const
|
|
99
99
|
const METRIC_BORDER_STYLE = { fg: rgb(20, 25, 32) } as const
|
|
100
|
+
const DEFAULT_WEB_URL = "https://send.rt.ht/"
|
|
100
101
|
|
|
101
102
|
const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
102
103
|
const percentFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 })
|
|
@@ -107,6 +108,32 @@ export const visiblePanes = (showEvents: boolean): VisiblePane[] => showEvents ?
|
|
|
107
108
|
|
|
108
109
|
const noop = () => {}
|
|
109
110
|
|
|
111
|
+
const hashBool = (value: boolean) => value ? "1" : "0"
|
|
112
|
+
|
|
113
|
+
export const resolveWebUrlBase = (value = process.env.SEND_WEB_URL) => {
|
|
114
|
+
const candidate = `${value ?? ""}`.trim() || DEFAULT_WEB_URL
|
|
115
|
+
try {
|
|
116
|
+
return new URL(candidate).toString()
|
|
117
|
+
} catch {
|
|
118
|
+
return DEFAULT_WEB_URL
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const webInviteUrl = (
|
|
123
|
+
state: Pick<TuiState, "snapshot" | "hideTerminalPeers" | "autoAcceptIncoming" | "autoOfferOutgoing" | "autoSaveIncoming">,
|
|
124
|
+
baseUrl = resolveWebUrlBase(),
|
|
125
|
+
) => {
|
|
126
|
+
const url = new URL(baseUrl)
|
|
127
|
+
url.hash = new URLSearchParams({
|
|
128
|
+
room: cleanRoom(state.snapshot.room),
|
|
129
|
+
clean: hashBool(state.hideTerminalPeers),
|
|
130
|
+
accept: hashBool(state.autoAcceptIncoming),
|
|
131
|
+
offer: hashBool(state.autoOfferOutgoing),
|
|
132
|
+
save: hashBool(state.autoSaveIncoming),
|
|
133
|
+
}).toString()
|
|
134
|
+
return url.toString()
|
|
135
|
+
}
|
|
136
|
+
|
|
110
137
|
export const createNoopTuiActions = (): TuiActions => ({
|
|
111
138
|
toggleEvents: noop,
|
|
112
139
|
jumpToRandomRoom: noop,
|
|
@@ -505,6 +532,14 @@ const renderRoomCard = (state: TuiState, actions: TuiActions) => denseSection({
|
|
|
505
532
|
onBlur: actions.commitRoom,
|
|
506
533
|
}),
|
|
507
534
|
]),
|
|
535
|
+
ui.row({ id: "room-invite-slot", width: 6, justify: "center", items: "center" }, [
|
|
536
|
+
ui.link({
|
|
537
|
+
id: "room-invite-link",
|
|
538
|
+
label: "📋",
|
|
539
|
+
accessibleLabel: "Open invite link",
|
|
540
|
+
url: webInviteUrl(state),
|
|
541
|
+
}),
|
|
542
|
+
]),
|
|
508
543
|
]),
|
|
509
544
|
])
|
|
510
545
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
|
|
4
|
+
const PATCH_FLAG = Symbol.for("send.rezi.inputCaretPatchInstalled")
|
|
5
|
+
|
|
6
|
+
type PatchedRuntime = {
|
|
7
|
+
[PATCH_FLAG]?: Set<string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type FilePatchSpec = {
|
|
11
|
+
relativeUrl: string
|
|
12
|
+
before: string
|
|
13
|
+
after: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const INPUT_PATCHES: readonly FilePatchSpec[] = [
|
|
17
|
+
{
|
|
18
|
+
relativeUrl: "./layout/engine/intrinsic.js",
|
|
19
|
+
before: "return ok(clampSize({ w: textW + 2, h: 1 }));",
|
|
20
|
+
after: "return ok(clampSize({ w: textW + 3, h: 1 }));",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
relativeUrl: "./layout/kinds/leaf.js",
|
|
24
|
+
before: "const w = Math.min(maxW, textW + 2);",
|
|
25
|
+
after: "const w = Math.min(maxW, textW + 3);",
|
|
26
|
+
},
|
|
27
|
+
] as const
|
|
28
|
+
|
|
29
|
+
const patchRuntime = globalThis as PatchedRuntime
|
|
30
|
+
|
|
31
|
+
const patchFile = async (spec: FilePatchSpec, coreIndexUrl: string) => {
|
|
32
|
+
const path = fileURLToPath(new URL(spec.relativeUrl, coreIndexUrl))
|
|
33
|
+
const source = await readFile(path, "utf8")
|
|
34
|
+
if (source.includes(spec.after)) return
|
|
35
|
+
if (!source.includes(spec.before)) throw new Error(`Unsupported @rezi-ui/core input layout at ${path}`)
|
|
36
|
+
await writeFile(path, source.replace(spec.before, spec.after))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const ensureReziInputCaretPatch = async () => {
|
|
40
|
+
const coreIndexUrl = await import.meta.resolve("@rezi-ui/core")
|
|
41
|
+
const patchedRoots = patchRuntime[PATCH_FLAG] ?? new Set<string>()
|
|
42
|
+
if (patchedRoots.has(coreIndexUrl)) return
|
|
43
|
+
for (const spec of INPUT_PATCHES) await patchFile(spec, coreIndexUrl)
|
|
44
|
+
patchedRoots.add(coreIndexUrl)
|
|
45
|
+
patchRuntime[PATCH_FLAG] = patchedRoots
|
|
46
|
+
}
|