dsh-bash-terminal-ts 0.2.5
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/LICENSE +21 -0
- package/README.en.md +195 -0
- package/README.ja.md +194 -0
- package/README.ko.md +192 -0
- package/README.md +192 -0
- package/cordis.patch.yml +5 -0
- package/dist/client.core.js +132 -0
- package/dist/client.js +141 -0
- package/dsh.plugin.json +22 -0
- package/lib/dsh-types.d.ts +260 -0
- package/lib/dsh-types.js +9 -0
- package/lib/dsh.d.ts +33 -0
- package/lib/dsh.js +29 -0
- package/lib/index.d.ts +201 -0
- package/lib/index.js +759 -0
- package/lib/terminal.d.ts +87 -0
- package/lib/terminal.js +273 -0
- package/package.json +122 -0
- package/scripts/build-client.mjs +46 -0
- package/src/client.tsx +161 -0
- package/src/dsh-types.ts +313 -0
- package/src/dsh.ts +68 -0
- package/src/index.ts +952 -0
- package/src/terminal.ts +368 -0
- package/tsconfig.client.json +17 -0
- package/tsconfig.json +23 -0
- package/tsconfig.test.json +11 -0
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// dsh-bash-terminal-ts: interactive terminal tool over the official PTY seam.
|
|
2
|
+
// ctx.subprocess.spawnTerminal (node-pty under the hood) allocates a real
|
|
3
|
+
// terminal; this module owns model-facing sessions: open / send / read /
|
|
4
|
+
// signal (Ctrl+C etc.) / close. The backend follows the user's default
|
|
5
|
+
// terminal setting, exactly like the shell tool.
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { isAbsolute, resolve } from "node:path";
|
|
9
|
+
import { TOOL_ABORTED, defineTool, HarnessError } from "./dsh.js";
|
|
10
|
+
import { buildEnv } from "./index.js";
|
|
11
|
+
import type {
|
|
12
|
+
BashTerminalContext,
|
|
13
|
+
ResolvedPaths,
|
|
14
|
+
TerminalHandle
|
|
15
|
+
} from "./dsh-types.js";
|
|
16
|
+
|
|
17
|
+
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
18
|
+
const READ_SETTLE_MS = 800;
|
|
19
|
+
const DEFAULT_ROWS = 30;
|
|
20
|
+
const DEFAULT_COLS = 110;
|
|
21
|
+
const TERMINAL_SIGNALS = ["SIGINT", "SIGTERM", "SIGKILL", "SIGTSTP", "SIGHUP"] as const;
|
|
22
|
+
|
|
23
|
+
export type TerminalSignal = (typeof TERMINAL_SIGNALS)[number];
|
|
24
|
+
|
|
25
|
+
function delay(ms: number): Promise<void> {
|
|
26
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TerminalBufferRead {
|
|
30
|
+
delta: string;
|
|
31
|
+
nextOffset: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TerminalBuffer {
|
|
35
|
+
append(chunk: string): void;
|
|
36
|
+
readFrom(offset: number): TerminalBufferRead;
|
|
37
|
+
snapshot(): string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Interactive-shell argv (no -c: the terminal itself is the session). */
|
|
41
|
+
export function terminalArgv(
|
|
42
|
+
shell: string,
|
|
43
|
+
paths: ResolvedPaths,
|
|
44
|
+
distro?: string
|
|
45
|
+
): Array<string | undefined> {
|
|
46
|
+
switch (shell) {
|
|
47
|
+
case "powershell": return [paths.pwsh, "-NoLogo", "-NoProfile"];
|
|
48
|
+
case "gitbash": return [paths.gitbash, "-i"];
|
|
49
|
+
case "msys2": return [paths.msys2, "-l"];
|
|
50
|
+
case "wsl": {
|
|
51
|
+
const distroArg = distro !== undefined && distro.trim().length > 0 ? ["-d", distro.trim()] : [];
|
|
52
|
+
return [paths.wsl, ...distroArg, "-e", "bash", "-i"];
|
|
53
|
+
}
|
|
54
|
+
default: throw new Error("invalid shell: " + JSON.stringify(shell));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** In-memory output ring for one session (drop-oldest at the cap). */
|
|
59
|
+
function createBuffer(): TerminalBuffer {
|
|
60
|
+
let text = "";
|
|
61
|
+
return {
|
|
62
|
+
append(chunk: string) {
|
|
63
|
+
text += chunk;
|
|
64
|
+
if (text.length > MAX_BUFFER_BYTES) text = text.slice(text.length - MAX_BUFFER_BYTES);
|
|
65
|
+
},
|
|
66
|
+
readFrom(offset: number): TerminalBufferRead {
|
|
67
|
+
const delta = offset >= text.length ? "" : text.slice(offset);
|
|
68
|
+
return { delta, nextOffset: text.length };
|
|
69
|
+
},
|
|
70
|
+
snapshot: () => text
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface TerminalSession {
|
|
75
|
+
id: string;
|
|
76
|
+
handle: TerminalHandle;
|
|
77
|
+
buffer: TerminalBuffer;
|
|
78
|
+
shell: string;
|
|
79
|
+
distro?: string;
|
|
80
|
+
closed: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TerminalOpenOptions {
|
|
84
|
+
argv: string[];
|
|
85
|
+
shell: string;
|
|
86
|
+
cwd: string;
|
|
87
|
+
env: Record<string, string | undefined>;
|
|
88
|
+
rows: number;
|
|
89
|
+
cols: number;
|
|
90
|
+
distro?: string;
|
|
91
|
+
initial?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TerminalRegistry {
|
|
95
|
+
open(options: TerminalOpenOptions): Promise<TerminalSession>;
|
|
96
|
+
get(id: string): TerminalSession;
|
|
97
|
+
send(id: string, input: string): Promise<TerminalBufferRead>;
|
|
98
|
+
read(id: string): TerminalBufferRead;
|
|
99
|
+
signal(id: string, sig: string): Promise<TerminalBufferRead>;
|
|
100
|
+
close(id: string): Promise<boolean>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Terminal-session registry owned by the plugin fiber: opens PTYs through
|
|
105
|
+
* the official seam, forwards output into a capped buffer, and tears every
|
|
106
|
+
* session down on plugin disposal.
|
|
107
|
+
*/
|
|
108
|
+
export function createTerminalRegistry(ctx: BashTerminalContext): TerminalRegistry {
|
|
109
|
+
const sessions = new Map<string, TerminalSession>();
|
|
110
|
+
ctx.effect(() => () => {
|
|
111
|
+
for (const session of sessions.values()) {
|
|
112
|
+
void session.handle.terminate().catch(() => { /* already dead */ });
|
|
113
|
+
}
|
|
114
|
+
sessions.clear();
|
|
115
|
+
}, "bash-terminal: terminal sessions teardown");
|
|
116
|
+
|
|
117
|
+
async function open(options: TerminalOpenOptions): Promise<TerminalSession> {
|
|
118
|
+
const { argv, shell, cwd, env, rows, cols, distro, initial } = options;
|
|
119
|
+
const spawnTerminal = ctx.subprocess?.spawnTerminal;
|
|
120
|
+
if (spawnTerminal === undefined) {
|
|
121
|
+
throw new Error("terminal: ctx.subprocess.spawnTerminal seam unavailable");
|
|
122
|
+
}
|
|
123
|
+
const handle = await spawnTerminal({
|
|
124
|
+
argv,
|
|
125
|
+
cwd,
|
|
126
|
+
env,
|
|
127
|
+
rows,
|
|
128
|
+
cols,
|
|
129
|
+
graceMs: 3000
|
|
130
|
+
});
|
|
131
|
+
const buffer = createBuffer();
|
|
132
|
+
handle.output.on("data", (chunk: unknown) => buffer.append(typeof chunk === "string" ? chunk : String(chunk)));
|
|
133
|
+
const session: TerminalSession = {
|
|
134
|
+
id: randomUUID(),
|
|
135
|
+
handle,
|
|
136
|
+
buffer,
|
|
137
|
+
shell,
|
|
138
|
+
distro,
|
|
139
|
+
closed: false
|
|
140
|
+
};
|
|
141
|
+
sessions.set(session.id, session);
|
|
142
|
+
if (initial !== undefined && initial.length > 0) {
|
|
143
|
+
await handle.write(initial);
|
|
144
|
+
await delay(READ_SETTLE_MS);
|
|
145
|
+
}
|
|
146
|
+
return session;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function get(id: string): TerminalSession {
|
|
150
|
+
const session = sessions.get(id);
|
|
151
|
+
if (session === undefined) throw new Error("terminal session not found: " + JSON.stringify(id));
|
|
152
|
+
if (session.closed) throw new Error("terminal session is closed: " + JSON.stringify(id));
|
|
153
|
+
return session;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function send(id: string, input: string): Promise<TerminalBufferRead> {
|
|
157
|
+
const session = get(id);
|
|
158
|
+
await session.handle.write(input);
|
|
159
|
+
await delay(READ_SETTLE_MS);
|
|
160
|
+
return read(id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function read(id: string): TerminalBufferRead {
|
|
164
|
+
const session = get(id);
|
|
165
|
+
const { delta, nextOffset } = session.buffer.readFrom(0);
|
|
166
|
+
return { delta, nextOffset };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function signal(id: string, sig: string): Promise<TerminalBufferRead> {
|
|
170
|
+
const session = get(id);
|
|
171
|
+
if (!(TERMINAL_SIGNALS as readonly string[]).includes(sig)) {
|
|
172
|
+
throw new Error("invalid terminal signal: " + JSON.stringify(sig));
|
|
173
|
+
}
|
|
174
|
+
await session.handle.signalForeground(sig);
|
|
175
|
+
await delay(READ_SETTLE_MS);
|
|
176
|
+
return read(id);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function close(id: string): Promise<boolean> {
|
|
180
|
+
const session = get(id);
|
|
181
|
+
session.closed = true;
|
|
182
|
+
sessions.delete(id);
|
|
183
|
+
await session.handle.terminate();
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { open, get, send, read, signal, close };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The terminal tool's arguments after runtime validation (discriminated by action). */
|
|
191
|
+
export type ValidatedTerminalArgs =
|
|
192
|
+
| { action: "open"; sessionId?: string; command?: string; distro?: string; workdir?: string }
|
|
193
|
+
| { action: "send"; sessionId: string; input: string }
|
|
194
|
+
| { action: "read"; sessionId: string }
|
|
195
|
+
| { action: "signal"; sessionId: string; signal: TerminalSignal }
|
|
196
|
+
| { action: "close"; sessionId: string };
|
|
197
|
+
|
|
198
|
+
export function validateArgs(args: Record<string, unknown>): asserts args is ValidatedTerminalArgs {
|
|
199
|
+
const action = args.action;
|
|
200
|
+
if (typeof action !== "string" || ["open", "send", "read", "signal", "close"].indexOf(action) === -1) {
|
|
201
|
+
throw new Error("invalid action: expected open, send, read, signal, or close");
|
|
202
|
+
}
|
|
203
|
+
if ((action === "send" || action === "read" || action === "signal" || action === "close") && (typeof args.sessionId !== "string" || args.sessionId.length === 0)) {
|
|
204
|
+
throw new Error("invalid sessionId: required for " + action);
|
|
205
|
+
}
|
|
206
|
+
if (action === "send" && (typeof args.input !== "string" || args.input.length === 0)) {
|
|
207
|
+
throw new Error("invalid input: expected a non-empty string for send");
|
|
208
|
+
}
|
|
209
|
+
if (action === "signal" && (typeof args.signal !== "string" || (TERMINAL_SIGNALS as readonly string[]).indexOf(args.signal) === -1)) {
|
|
210
|
+
throw new Error("invalid signal: expected one of " + TERMINAL_SIGNALS.join(", "));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface TerminalOpenResult {
|
|
215
|
+
kind: "open";
|
|
216
|
+
sessionId: string;
|
|
217
|
+
pid: number;
|
|
218
|
+
shell: string;
|
|
219
|
+
output: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface TerminalSessionResult {
|
|
223
|
+
kind: "session";
|
|
224
|
+
output: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface TerminalClosedResult {
|
|
228
|
+
kind: "closed";
|
|
229
|
+
sessionId: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type TerminalToolResult = TerminalOpenResult | TerminalSessionResult | TerminalClosedResult;
|
|
233
|
+
|
|
234
|
+
export function terminalTool(
|
|
235
|
+
ctx: BashTerminalContext,
|
|
236
|
+
registry: TerminalRegistry,
|
|
237
|
+
paths: ResolvedPaths,
|
|
238
|
+
defaultShell: () => string
|
|
239
|
+
) {
|
|
240
|
+
return defineTool<TerminalToolResult>({
|
|
241
|
+
name: "terminal",
|
|
242
|
+
description: "Interactive terminal session over the user's default terminal (Settings -> General -> Default terminal: powershell / gitbash / msys2 / wsl). A real PTY hosts a persistent shell: open a session, send input and read output across turns, deliver signals (Ctrl+C = SIGINT) to the foreground process, and close when done. Backend and env follow the shell tool exactly; the session survives between calls until closed. Use this for interactive programs (REPLs, ssh, databases, TUI tools) or when you need shell state (cwd, variables, aliases) to persist across calls.",
|
|
243
|
+
parameters: {
|
|
244
|
+
action: {
|
|
245
|
+
type: "string",
|
|
246
|
+
enum: ["open", "send", "read", "signal", "close"],
|
|
247
|
+
required: true,
|
|
248
|
+
description: "open: create a session and return its id. send: write input and read new output. read: read new output without writing. signal: send a signal to the foreground process (SIGINT for Ctrl+C). close: terminate the session."
|
|
249
|
+
},
|
|
250
|
+
sessionId: {
|
|
251
|
+
type: "string",
|
|
252
|
+
description: "Session id returned by open; required for send/read/signal/close."
|
|
253
|
+
},
|
|
254
|
+
command: {
|
|
255
|
+
type: "string",
|
|
256
|
+
description: "With action open: optional command to run immediately in the fresh shell (Enter appended). Default starts an interactive shell."
|
|
257
|
+
},
|
|
258
|
+
distro: {
|
|
259
|
+
type: "string",
|
|
260
|
+
description: "WSL distribution (only when the configured default terminal is wsl)."
|
|
261
|
+
},
|
|
262
|
+
input: {
|
|
263
|
+
type: "string",
|
|
264
|
+
description: "With action send: the input to write (no implicit newline; append \\n or \\r for Enter)."
|
|
265
|
+
},
|
|
266
|
+
signal: {
|
|
267
|
+
type: "string",
|
|
268
|
+
enum: TERMINAL_SIGNALS,
|
|
269
|
+
description: "With action signal: signal to the foreground process group (SIGINT = Ctrl+C, SIGKILL, SIGTERM, SIGTSTP, SIGHUP)."
|
|
270
|
+
},
|
|
271
|
+
workdir: {
|
|
272
|
+
type: "string",
|
|
273
|
+
description: "Working directory for the session (open only). Defaults to the session workspace."
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
output: {
|
|
277
|
+
schema: {
|
|
278
|
+
oneOf: [
|
|
279
|
+
{
|
|
280
|
+
type: "object",
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
properties: {
|
|
283
|
+
kind: { type: "string", required: true, const: "open" },
|
|
284
|
+
sessionId: { type: "string", required: true },
|
|
285
|
+
pid: { type: "integer", required: true },
|
|
286
|
+
shell: { type: "string", required: true },
|
|
287
|
+
output: { type: "string" }
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
type: "object",
|
|
292
|
+
additionalProperties: false,
|
|
293
|
+
properties: {
|
|
294
|
+
kind: { type: "string", required: true, const: "session" },
|
|
295
|
+
output: { type: "string", required: true }
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
type: "object",
|
|
300
|
+
additionalProperties: false,
|
|
301
|
+
properties: {
|
|
302
|
+
kind: { type: "string", required: true, const: "closed" },
|
|
303
|
+
sessionId: { type: "string", required: true }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
]
|
|
307
|
+
},
|
|
308
|
+
render: (_args, value) => [{
|
|
309
|
+
type: "text",
|
|
310
|
+
text: value.kind === "open"
|
|
311
|
+
? "terminal session " + value.sessionId + " (pid " + value.pid + ", " + value.shell + ")" + (value.output ? "\n" + value.output : "")
|
|
312
|
+
: value.kind === "closed"
|
|
313
|
+
? "terminal session " + value.sessionId + " closed"
|
|
314
|
+
: value.output
|
|
315
|
+
}]
|
|
316
|
+
},
|
|
317
|
+
async execute(args, exec): Promise<TerminalToolResult> {
|
|
318
|
+
validateArgs(args);
|
|
319
|
+
if (exec.signal.aborted) {
|
|
320
|
+
const error = new HarnessError("tool call aborted", TOOL_ABORTED);
|
|
321
|
+
error.name = "AbortError";
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
const headerCwd = exec.agent?.session.header.cwd;
|
|
325
|
+
switch (args.action) {
|
|
326
|
+
case "open": {
|
|
327
|
+
const shell = defaultShell();
|
|
328
|
+
const argv0 = terminalArgv(shell, paths, args.distro);
|
|
329
|
+
if (argv0[0] === undefined) throw new Error("terminal: " + shell + " backend unavailable - executable not found");
|
|
330
|
+
// Only element 0 (the resolved executable) can be undefined; guarded above.
|
|
331
|
+
const argv = argv0 as string[];
|
|
332
|
+
const cwd = args.workdir !== undefined ? (headerCwd !== undefined && !isAbsolute(args.workdir) ? resolve(headerCwd, args.workdir) : args.workdir) : (headerCwd ?? process.cwd());
|
|
333
|
+
// buildEnv, not an inline duplicate: the msys2 backend needs its
|
|
334
|
+
// MSYSTEM=MINGW64 injection here too, or the login shell sources
|
|
335
|
+
// /etc/profile with the default MSYS environment and /mingw64/bin
|
|
336
|
+
// (gcc, make) never joins PATH. DSH's spawnTerminal replaces the child
|
|
337
|
+
// environment with exactly this object (childEnv(spec.env)), so the
|
|
338
|
+
// inherited WSLENV is passed through explicitly for the WSL allow-list;
|
|
339
|
+
// for every other backend buildEnv ignores the third argument.
|
|
340
|
+
const env = buildEnv(shell, ctx.shellEnv.collect(exec), process.env.WSLENV);
|
|
341
|
+
const session = await registry.open({ argv, shell, cwd, env, rows: DEFAULT_ROWS, cols: DEFAULT_COLS, distro: args.distro, initial: args.command !== undefined ? args.command + "\r" : undefined });
|
|
342
|
+
return { kind: "open", sessionId: session.id, pid: session.handle.pid, shell, output: session.buffer.snapshot() };
|
|
343
|
+
}
|
|
344
|
+
case "send": {
|
|
345
|
+
const { delta } = await registry.send(args.sessionId, args.input);
|
|
346
|
+
return { kind: "session", output: delta };
|
|
347
|
+
}
|
|
348
|
+
case "read": {
|
|
349
|
+
const { delta } = registry.read(args.sessionId);
|
|
350
|
+
return { kind: "session", output: delta };
|
|
351
|
+
}
|
|
352
|
+
case "signal": {
|
|
353
|
+
const { delta } = await registry.signal(args.sessionId, args.signal);
|
|
354
|
+
return { kind: "session", output: delta };
|
|
355
|
+
}
|
|
356
|
+
case "close": {
|
|
357
|
+
await registry.close(args.sessionId);
|
|
358
|
+
return { kind: "closed", sessionId: args.sessionId };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
presentCall: (args) => ({
|
|
363
|
+
card: "terminal",
|
|
364
|
+
title: "terminal " + String(args.action) + (args.sessionId !== undefined ? " " + String(args.sessionId) : ""),
|
|
365
|
+
...(args.input !== undefined ? { description: args.input } : {})
|
|
366
|
+
})
|
|
367
|
+
});
|
|
368
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "ESNext", "DOM"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"types": []
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/client.tsx", "src/client-types.d.ts"]
|
|
17
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "ESNext"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"outDir": "lib",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": false,
|
|
11
|
+
"sourceMap": false,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"noImplicitOverride": true,
|
|
15
|
+
"noFallthroughCasesInSwitch": true,
|
|
16
|
+
"forceConsistentCasingInFileNames": true,
|
|
17
|
+
"esModuleInterop": true,
|
|
18
|
+
"skipLibCheck": true,
|
|
19
|
+
"types": ["node"]
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*.ts"],
|
|
22
|
+
"exclude": ["src/client.tsx"]
|
|
23
|
+
}
|