@deepseek-ai/dsh-api-terminal-controller 0.1.6-alpha.1
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.i18n.yaml +6 -0
- package/README.md +93 -0
- package/README.zh.md +93 -0
- package/lib/client.js +677 -0
- package/lib/index.js +844 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1131 -0
- package/lib/typert.remote-client.d.ts +49 -0
- package/lib/typert.remote-client.js +529 -0
- package/lib/types/client/close-requests.d.ts +31 -0
- package/lib/types/client/close-requests.js +75 -0
- package/lib/types/client/index.d.ts +88 -0
- package/lib/types/client/index.js +157 -0
- package/lib/types/client/model.d.ts +119 -0
- package/lib/types/client/model.js +323 -0
- package/lib/types/client/shell-preference.d.ts +11 -0
- package/lib/types/client/shell-preference.js +26 -0
- package/lib/types/index.d.ts +135 -0
- package/lib/types/index.js +371 -0
- package/lib/types/shells.d.ts +21 -0
- package/lib/types/shells.js +54 -0
- package/lib/types/stream.d.ts +29 -0
- package/lib/types/stream.js +78 -0
- package/lib/types/terminal.d.ts +61 -0
- package/lib/types/terminal.js +168 -0
- package/lib/types/types.d.ts +69 -0
- package/lib/types/types.js +2 -0
- package/package.json +93 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Client terminal model service; views are keyed independently from Host terminal identities. */
|
|
2
|
+
import { Service, type Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
4
|
+
import { TerminalView, type TerminalRemote } from './model.ts';
|
|
5
|
+
import { type SnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
6
|
+
import type { TerminalShell, WebTerminalId, WebTerminalInfo } from '../types.ts';
|
|
7
|
+
export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts';
|
|
8
|
+
declare module '@deepseek-ai/cordis' {
|
|
9
|
+
interface Context {
|
|
10
|
+
/** React-free browser terminal views and explicit process cleanup. */
|
|
11
|
+
webTerminals: ClientTerminals;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Host-discovered shell menu with the browser's remembered available choice. */
|
|
15
|
+
export interface TerminalLaunchShells {
|
|
16
|
+
readonly shells: readonly TerminalShell[];
|
|
17
|
+
readonly selectedShell: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
/** A failed background close that can be retried without restoring its tab. */
|
|
20
|
+
export interface TerminalCloseFailure {
|
|
21
|
+
readonly id: WebTerminalId;
|
|
22
|
+
readonly title: string;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
}
|
|
25
|
+
/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */
|
|
26
|
+
export declare class ClientTerminals extends Service {
|
|
27
|
+
private readonly remote;
|
|
28
|
+
/** Failed cleanup tasks; successful and in-progress closes have no visible notification. */
|
|
29
|
+
readonly closeFailures: SnapshotStore<readonly TerminalCloseFailure[]>;
|
|
30
|
+
private readonly requests;
|
|
31
|
+
private readonly closing;
|
|
32
|
+
private readonly closed;
|
|
33
|
+
private disposed;
|
|
34
|
+
private readonly views;
|
|
35
|
+
/**
|
|
36
|
+
* @param ctx - Client root Context with Gateway and terminal Remote namespace.
|
|
37
|
+
* @param remote - generated terminal namespace.
|
|
38
|
+
*/
|
|
39
|
+
constructor(ctx: Context, remote: TerminalRemote);
|
|
40
|
+
/**
|
|
41
|
+
* Return the stable model for one sidebar occurrence.
|
|
42
|
+
* @param sessionId - owning Session.
|
|
43
|
+
* @param key - sidebar occurrence key.
|
|
44
|
+
* @param terminalId - existing Host identity when restoring a listed terminal.
|
|
45
|
+
* @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell.
|
|
46
|
+
* @returns its observable state and terminal commands.
|
|
47
|
+
*/
|
|
48
|
+
view(sessionId: SessionId, key: string, terminalId?: WebTerminalId, shellPath?: string): TerminalView;
|
|
49
|
+
/**
|
|
50
|
+
* Discover available launch choices on demand without allocating a PTY.
|
|
51
|
+
* @param sessionId - target Session.
|
|
52
|
+
* @param signal - the menu request lifetime.
|
|
53
|
+
* @returns installed shells and the currently usable browser preference.
|
|
54
|
+
*/
|
|
55
|
+
launchShells(sessionId: SessionId, signal: AbortSignal): Promise<TerminalLaunchShells>;
|
|
56
|
+
/**
|
|
57
|
+
* Remember the guide selection before allocating its terminal tab.
|
|
58
|
+
* @param path - shell selected from Host discovery.
|
|
59
|
+
*/
|
|
60
|
+
selectShell(path: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
|
|
63
|
+
* @param sessionId - owning Session.
|
|
64
|
+
* @param key - sidebar occurrence key, including an inactive restored tab.
|
|
65
|
+
* @param terminalId - restored identity if the tab has no model yet.
|
|
66
|
+
*/
|
|
67
|
+
close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void;
|
|
68
|
+
/**
|
|
69
|
+
* Query Host terminals that have neither a tab in this page nor an unfinished close.
|
|
70
|
+
* @param sessionId - Session being displayed.
|
|
71
|
+
* @returns terminals available for opening as recovered tabs.
|
|
72
|
+
*/
|
|
73
|
+
recover(sessionId: SessionId): Promise<WebTerminalInfo[]>;
|
|
74
|
+
/**
|
|
75
|
+
* Retry a saved close request without reopening its tab.
|
|
76
|
+
* @param id - failed terminal identity.
|
|
77
|
+
*/
|
|
78
|
+
retryClose(id: WebTerminalId): void;
|
|
79
|
+
private cleanup;
|
|
80
|
+
}
|
|
81
|
+
/** Required Client transport and terminal namespace. */
|
|
82
|
+
export declare const inject: string[];
|
|
83
|
+
/**
|
|
84
|
+
* Install the Client terminal models.
|
|
85
|
+
* @param ctx - Client root Context.
|
|
86
|
+
*/
|
|
87
|
+
export declare function apply(ctx: Context): void;
|
|
88
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Client terminal model service; views are keyed independently from Host terminal identities. */
|
|
2
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
3
|
+
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol';
|
|
4
|
+
import { TerminalView } from "./model.js";
|
|
5
|
+
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
6
|
+
import { randomUUID } from '@deepseek-ai/dsh-util-crypto';
|
|
7
|
+
import { preferredShell, rememberShell } from "./shell-preference.js";
|
|
8
|
+
import { TerminalCloseRequests } from "./close-requests.js";
|
|
9
|
+
/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */
|
|
10
|
+
export class ClientTerminals extends Service {
|
|
11
|
+
remote;
|
|
12
|
+
/** Failed cleanup tasks; successful and in-progress closes have no visible notification. */
|
|
13
|
+
closeFailures = createSnapshotStore([]);
|
|
14
|
+
requests = new TerminalCloseRequests();
|
|
15
|
+
closing = new Map();
|
|
16
|
+
closed = new Set(this.requests.pending().map(request => request.id));
|
|
17
|
+
disposed = false;
|
|
18
|
+
views = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* @param ctx - Client root Context with Gateway and terminal Remote namespace.
|
|
21
|
+
* @param remote - generated terminal namespace.
|
|
22
|
+
*/
|
|
23
|
+
constructor(ctx, remote) {
|
|
24
|
+
super(ctx, 'webTerminals');
|
|
25
|
+
this.remote = remote;
|
|
26
|
+
ctx.effect(() => async () => {
|
|
27
|
+
this.disposed = true;
|
|
28
|
+
const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose()));
|
|
29
|
+
this.views.clear();
|
|
30
|
+
await Promise.all([...detaching, ...this.closing.values()]);
|
|
31
|
+
}, 'terminal-controller.client.views');
|
|
32
|
+
for (const request of this.requests.pending())
|
|
33
|
+
this.cleanup(request);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Return the stable model for one sidebar occurrence.
|
|
37
|
+
* @param sessionId - owning Session.
|
|
38
|
+
* @param key - sidebar occurrence key.
|
|
39
|
+
* @param terminalId - existing Host identity when restoring a listed terminal.
|
|
40
|
+
* @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell.
|
|
41
|
+
* @returns its observable state and terminal commands.
|
|
42
|
+
*/
|
|
43
|
+
view(sessionId, key, terminalId, shellPath) {
|
|
44
|
+
let views = this.views.get(sessionId);
|
|
45
|
+
if (views === undefined) {
|
|
46
|
+
views = new Map();
|
|
47
|
+
this.views.set(sessionId, views);
|
|
48
|
+
}
|
|
49
|
+
let view = views.get(key);
|
|
50
|
+
if (view === undefined) {
|
|
51
|
+
const id = terminalId ?? randomUUID();
|
|
52
|
+
view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined, shellPath);
|
|
53
|
+
views.set(key, view);
|
|
54
|
+
void view.refresh();
|
|
55
|
+
}
|
|
56
|
+
return view;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Discover available launch choices on demand without allocating a PTY.
|
|
60
|
+
* @param sessionId - target Session.
|
|
61
|
+
* @param signal - the menu request lifetime.
|
|
62
|
+
* @returns installed shells and the currently usable browser preference.
|
|
63
|
+
*/
|
|
64
|
+
async launchShells(sessionId, signal) {
|
|
65
|
+
const result = await this.remote.shells(sessionId, signal);
|
|
66
|
+
if (!result.ok)
|
|
67
|
+
throw new Error(result.error.message);
|
|
68
|
+
const previous = preferredShell();
|
|
69
|
+
return { shells: result.value, selectedShell: result.value.find(shell => shell.path === previous)?.path ?? result.value[0]?.path };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Remember the guide selection before allocating its terminal tab.
|
|
73
|
+
* @param path - shell selected from Host discovery.
|
|
74
|
+
*/
|
|
75
|
+
selectShell(path) { rememberShell(path); }
|
|
76
|
+
/**
|
|
77
|
+
* Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
|
|
78
|
+
* @param sessionId - owning Session.
|
|
79
|
+
* @param key - sidebar occurrence key, including an inactive restored tab.
|
|
80
|
+
* @param terminalId - restored identity if the tab has no model yet.
|
|
81
|
+
*/
|
|
82
|
+
close(sessionId, key, terminalId) {
|
|
83
|
+
const views = this.views.get(sessionId);
|
|
84
|
+
const view = views?.get(key);
|
|
85
|
+
const id = view?.id ?? terminalId;
|
|
86
|
+
if (id === undefined)
|
|
87
|
+
return;
|
|
88
|
+
const request = { sessionId, id, title: view?.state.getSnapshot().title ?? key };
|
|
89
|
+
this.closed.add(id);
|
|
90
|
+
this.requests.save(request);
|
|
91
|
+
views?.delete(key);
|
|
92
|
+
if (views?.size === 0)
|
|
93
|
+
this.views.delete(sessionId);
|
|
94
|
+
this.cleanup(request, view);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Query Host terminals that have neither a tab in this page nor an unfinished close.
|
|
98
|
+
* @param sessionId - Session being displayed.
|
|
99
|
+
* @returns terminals available for opening as recovered tabs.
|
|
100
|
+
*/
|
|
101
|
+
async recover(sessionId) {
|
|
102
|
+
const result = await this.remote.list(sessionId);
|
|
103
|
+
if (!result.ok)
|
|
104
|
+
throw new Error(result.error.message);
|
|
105
|
+
const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id));
|
|
106
|
+
const closing = new Set(this.requests.pending().map(request => request.id));
|
|
107
|
+
return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Retry a saved close request without reopening its tab.
|
|
111
|
+
* @param id - failed terminal identity.
|
|
112
|
+
*/
|
|
113
|
+
retryClose(id) {
|
|
114
|
+
const record = this.requests.pending().find(item => item.id === id);
|
|
115
|
+
if (record !== undefined)
|
|
116
|
+
this.cleanup(record);
|
|
117
|
+
}
|
|
118
|
+
cleanup(record, view) {
|
|
119
|
+
if (this.closing.has(record.id) || this.disposed)
|
|
120
|
+
return;
|
|
121
|
+
this.closeFailures.set(this.closeFailures.getSnapshot().filter(failure => failure.id !== record.id));
|
|
122
|
+
const pending = (async () => {
|
|
123
|
+
if (view !== undefined)
|
|
124
|
+
await view.close();
|
|
125
|
+
else {
|
|
126
|
+
const result = await this.remote.close(record.sessionId, record.id);
|
|
127
|
+
if (!result.ok)
|
|
128
|
+
throw result.error;
|
|
129
|
+
}
|
|
130
|
+
this.requests.remove(record.id);
|
|
131
|
+
})().catch((error) => {
|
|
132
|
+
if (remoteErrorOf(error)?.code === 'session/not-found') {
|
|
133
|
+
this.requests.remove(record.id);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!this.disposed)
|
|
137
|
+
this.closeFailures.set([...this.closeFailures.getSnapshot(), {
|
|
138
|
+
id: record.id, title: record.title,
|
|
139
|
+
message: error instanceof Error ? error.message : String(error),
|
|
140
|
+
}]);
|
|
141
|
+
}).then(async () => {
|
|
142
|
+
await view?.dispose();
|
|
143
|
+
this.closing.delete(record.id);
|
|
144
|
+
});
|
|
145
|
+
this.closing.set(record.id, pending);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Required Client transport and terminal namespace. */
|
|
149
|
+
export const inject = ['remote', 'remote.terminal'];
|
|
150
|
+
/**
|
|
151
|
+
* Install the Client terminal models.
|
|
152
|
+
* @param ctx - Client root Context.
|
|
153
|
+
*/
|
|
154
|
+
export function apply(ctx) {
|
|
155
|
+
new ClientTerminals(ctx, ctx.remote.terminal);
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type SnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
2
|
+
import { type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client';
|
|
3
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
4
|
+
import type { TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../types.ts';
|
|
5
|
+
/** The generated terminal namespace's browser-facing operations. */
|
|
6
|
+
export type TerminalRemote = ClientRemote['terminal'];
|
|
7
|
+
/** Product error identifiers translated by the terminal UI. */
|
|
8
|
+
export type TerminalViewIssue = 'missingTerminal' | 'inputFull' | 'attachmentEnded' | 'invalidOutput' | 'terminalLimit';
|
|
9
|
+
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
10
|
+
interface RemoteErrorDetailsMap {
|
|
11
|
+
/** Client terminal failure preserved through the Remote stream supervisor. */
|
|
12
|
+
'terminal/view': {
|
|
13
|
+
readonly issue: TerminalViewIssue;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** One screen write awaiting the DOM emulator's callback. */
|
|
18
|
+
export interface TerminalRenderFrame {
|
|
19
|
+
readonly revision: number;
|
|
20
|
+
readonly frame: Extract<TerminalFrame, {
|
|
21
|
+
type: 'snapshot' | 'output';
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/** Observable state of one sidebar occurrence. */
|
|
25
|
+
export interface TerminalViewState {
|
|
26
|
+
readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed';
|
|
27
|
+
readonly environment?: TerminalEnvironment | undefined;
|
|
28
|
+
readonly title?: string | undefined;
|
|
29
|
+
readonly info?: WebTerminalInfo | undefined;
|
|
30
|
+
readonly writable: boolean;
|
|
31
|
+
readonly render?: TerminalRenderFrame | undefined;
|
|
32
|
+
readonly error?: string | undefined;
|
|
33
|
+
readonly issue?: TerminalViewIssue | undefined;
|
|
34
|
+
}
|
|
35
|
+
/** A view survives DOM unmount; its process only ends on explicit close. */
|
|
36
|
+
export declare class TerminalView {
|
|
37
|
+
private readonly sessionId;
|
|
38
|
+
private readonly remote;
|
|
39
|
+
private readonly gateway;
|
|
40
|
+
readonly id: WebTerminalId;
|
|
41
|
+
private readonly createWhenMissing;
|
|
42
|
+
private readonly shellPath?;
|
|
43
|
+
/** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
|
|
44
|
+
readonly state: SnapshotStore<TerminalViewState>;
|
|
45
|
+
private readonly lifetime;
|
|
46
|
+
private stream;
|
|
47
|
+
private mounted;
|
|
48
|
+
private attachmentId;
|
|
49
|
+
private pendingRender;
|
|
50
|
+
private revision;
|
|
51
|
+
private creation;
|
|
52
|
+
private loading;
|
|
53
|
+
private closing;
|
|
54
|
+
private writes;
|
|
55
|
+
private queuedInput;
|
|
56
|
+
private readonly detaching;
|
|
57
|
+
/**
|
|
58
|
+
* @param sessionId - Session owning the terminal.
|
|
59
|
+
* @param remote - typed terminal Remote operations.
|
|
60
|
+
* @param gateway - reconnecting stream factory.
|
|
61
|
+
* @param id - Host terminal identity, reused when recovering an item from its Session list.
|
|
62
|
+
* @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
|
|
63
|
+
* @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
|
|
64
|
+
*/
|
|
65
|
+
constructor(sessionId: SessionId, remote: TerminalRemote, gateway: Pick<ClientRemote, '$stream'>, id: WebTerminalId, createWhenMissing?: boolean, shellPath?: string | undefined);
|
|
66
|
+
/**
|
|
67
|
+
* Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process.
|
|
68
|
+
* @returns a detach callback that leaves the terminal process alive.
|
|
69
|
+
*/
|
|
70
|
+
mount(): () => void;
|
|
71
|
+
/**
|
|
72
|
+
* Start or recover this tab, deduplicating mounts and retries during allocation.
|
|
73
|
+
* Only a new tab may allocate a shell; listed terminals cannot be silently replaced.
|
|
74
|
+
* @returns after environment lookup and creation or recovery settle.
|
|
75
|
+
*/
|
|
76
|
+
refresh(): Promise<void>;
|
|
77
|
+
private stopped;
|
|
78
|
+
private create;
|
|
79
|
+
private adopt;
|
|
80
|
+
/** Reattach with a fresh screen and regain input control. */
|
|
81
|
+
connect(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Release the next stream item after xterm has parsed this frame.
|
|
84
|
+
* @param revision - locally delivered render revision.
|
|
85
|
+
*/
|
|
86
|
+
acknowledge(revision: number): void;
|
|
87
|
+
/**
|
|
88
|
+
* Serialize raw input so concurrent RPC requests cannot reorder keystrokes.
|
|
89
|
+
* @param data - input from the terminal emulator.
|
|
90
|
+
*/
|
|
91
|
+
write(data: string): void;
|
|
92
|
+
/**
|
|
93
|
+
* Resize only from the currently writable view.
|
|
94
|
+
* @param cols - measured column count.
|
|
95
|
+
* @param rows - measured row count.
|
|
96
|
+
*/
|
|
97
|
+
resize(cols: number, rows: number): void;
|
|
98
|
+
/**
|
|
99
|
+
* Update the Host terminal's display name.
|
|
100
|
+
* @param title - user-entered terminal title.
|
|
101
|
+
* @returns after the rename settles and its result is reflected in view state.
|
|
102
|
+
*/
|
|
103
|
+
rename(title: string): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Explicitly terminate this view's process independently of its DOM lifetime.
|
|
106
|
+
* @returns after Host process cleanup succeeds; failures remain retryable by the owner.
|
|
107
|
+
*/
|
|
108
|
+
close(): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Stop Client work on plugin unload without closing Host terminals.
|
|
111
|
+
* @returns after active and previously detached stream iterators have closed.
|
|
112
|
+
*/
|
|
113
|
+
dispose(): Promise<void>;
|
|
114
|
+
private detach;
|
|
115
|
+
private consume;
|
|
116
|
+
private patch;
|
|
117
|
+
private fail;
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=model.d.ts.map
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/** React-free browser terminal state and reconnecting Remote-stream ownership. */
|
|
2
|
+
import { preferredShell, rememberShell } from "./shell-preference.js";
|
|
3
|
+
import { randomUUID } from '@deepseek-ai/dsh-util-crypto';
|
|
4
|
+
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
5
|
+
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client';
|
|
6
|
+
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol';
|
|
7
|
+
class TerminalViewError extends RemoteError {
|
|
8
|
+
constructor(issue, message = issue) { super('terminal/view', message, { issue }); }
|
|
9
|
+
}
|
|
10
|
+
/** A view survives DOM unmount; its process only ends on explicit close. */
|
|
11
|
+
export class TerminalView {
|
|
12
|
+
sessionId;
|
|
13
|
+
remote;
|
|
14
|
+
gateway;
|
|
15
|
+
id;
|
|
16
|
+
createWhenMissing;
|
|
17
|
+
shellPath;
|
|
18
|
+
/** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
|
|
19
|
+
state = createSnapshotStore({ phase: 'idle', writable: false });
|
|
20
|
+
lifetime = new AbortController();
|
|
21
|
+
stream;
|
|
22
|
+
mounted = false;
|
|
23
|
+
attachmentId;
|
|
24
|
+
pendingRender;
|
|
25
|
+
revision = 0;
|
|
26
|
+
creation;
|
|
27
|
+
loading;
|
|
28
|
+
closing;
|
|
29
|
+
writes = Promise.resolve();
|
|
30
|
+
queuedInput = 0;
|
|
31
|
+
detaching = new Set();
|
|
32
|
+
/**
|
|
33
|
+
* @param sessionId - Session owning the terminal.
|
|
34
|
+
* @param remote - typed terminal Remote operations.
|
|
35
|
+
* @param gateway - reconnecting stream factory.
|
|
36
|
+
* @param id - Host terminal identity, reused when recovering an item from its Session list.
|
|
37
|
+
* @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
|
|
38
|
+
* @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
|
|
39
|
+
*/
|
|
40
|
+
constructor(sessionId, remote, gateway, id, createWhenMissing = true, shellPath) {
|
|
41
|
+
this.sessionId = sessionId;
|
|
42
|
+
this.remote = remote;
|
|
43
|
+
this.gateway = gateway;
|
|
44
|
+
this.id = id;
|
|
45
|
+
this.createWhenMissing = createWhenMissing;
|
|
46
|
+
this.shellPath = shellPath;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process.
|
|
50
|
+
* @returns a detach callback that leaves the terminal process alive.
|
|
51
|
+
*/
|
|
52
|
+
mount() {
|
|
53
|
+
this.mounted = true;
|
|
54
|
+
if (this.state.getSnapshot().info === undefined)
|
|
55
|
+
void this.refresh();
|
|
56
|
+
else
|
|
57
|
+
this.connect();
|
|
58
|
+
return () => {
|
|
59
|
+
this.mounted = false;
|
|
60
|
+
this.detach();
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Start or recover this tab, deduplicating mounts and retries during allocation.
|
|
65
|
+
* Only a new tab may allocate a shell; listed terminals cannot be silently replaced.
|
|
66
|
+
* @returns after environment lookup and creation or recovery settle.
|
|
67
|
+
*/
|
|
68
|
+
refresh() {
|
|
69
|
+
if (this.creation !== undefined)
|
|
70
|
+
return this.creation;
|
|
71
|
+
if (this.loading !== undefined)
|
|
72
|
+
return this.loading;
|
|
73
|
+
if (this.closing !== undefined || this.lifetime.signal.aborted)
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
this.patch({ phase: 'loading', error: undefined, issue: undefined });
|
|
76
|
+
this.loading = (async () => {
|
|
77
|
+
const [environment, available] = await Promise.all([
|
|
78
|
+
this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId),
|
|
79
|
+
]);
|
|
80
|
+
if (this.stopped())
|
|
81
|
+
return;
|
|
82
|
+
this.patch({ environment: valueOf(environment) });
|
|
83
|
+
const info = valueOf(available).find(item => item.id === this.id);
|
|
84
|
+
if (info !== undefined)
|
|
85
|
+
this.adopt(info);
|
|
86
|
+
else if (this.createWhenMissing) {
|
|
87
|
+
let path = this.shellPath;
|
|
88
|
+
if (path === undefined) {
|
|
89
|
+
const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal));
|
|
90
|
+
const previous = preferredShell();
|
|
91
|
+
path = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path;
|
|
92
|
+
}
|
|
93
|
+
if (this.stopped())
|
|
94
|
+
return;
|
|
95
|
+
if (path !== undefined)
|
|
96
|
+
rememberShell(path);
|
|
97
|
+
await this.create(valueOf(environment), path);
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
throw new TerminalViewError('missingTerminal');
|
|
101
|
+
})().catch((error) => { this.fail(error); }).finally(() => { this.loading = undefined; });
|
|
102
|
+
return this.loading;
|
|
103
|
+
}
|
|
104
|
+
stopped() { return this.lifetime.signal.aborted || this.closing !== undefined; }
|
|
105
|
+
async create(environment, shellPath) {
|
|
106
|
+
this.patch({ phase: 'creating', error: undefined, issue: undefined });
|
|
107
|
+
this.creation = (async () => {
|
|
108
|
+
const info = valueOf(await this.remote.create(this.sessionId, {
|
|
109
|
+
id: this.id, ...shellPath === undefined ? {} : { shellPath },
|
|
110
|
+
cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
|
|
111
|
+
}, this.lifetime.signal));
|
|
112
|
+
if (!this.lifetime.signal.aborted) {
|
|
113
|
+
this.adopt(info);
|
|
114
|
+
}
|
|
115
|
+
})().catch((error) => { this.fail(error); }).finally(() => { this.creation = undefined; });
|
|
116
|
+
await this.creation;
|
|
117
|
+
}
|
|
118
|
+
adopt(info) {
|
|
119
|
+
this.patch({ info, title: info.title });
|
|
120
|
+
if (this.mounted && this.closing === undefined)
|
|
121
|
+
this.connect();
|
|
122
|
+
}
|
|
123
|
+
/** Reattach with a fresh screen and regain input control. */
|
|
124
|
+
connect() {
|
|
125
|
+
const info = this.state.getSnapshot().info;
|
|
126
|
+
if (info === undefined || !this.mounted || this.closing !== undefined || this.lifetime.signal.aborted)
|
|
127
|
+
return;
|
|
128
|
+
this.detach();
|
|
129
|
+
const stream = this.gateway.$stream({
|
|
130
|
+
name: 'Browser terminal output',
|
|
131
|
+
open: (signal) => {
|
|
132
|
+
const attachmentId = randomUUID();
|
|
133
|
+
this.attachmentId = attachmentId;
|
|
134
|
+
return this.remote.follow(this.sessionId, info.id, attachmentId, signal);
|
|
135
|
+
},
|
|
136
|
+
ended: () => new TerminalViewError('attachmentEnded'),
|
|
137
|
+
carrierFailed: () => { if (this.stream === stream)
|
|
138
|
+
this.patch({ phase: 'disconnected', writable: false }); },
|
|
139
|
+
});
|
|
140
|
+
this.stream = stream;
|
|
141
|
+
this.patch({ phase: 'connecting', writable: false, error: undefined, issue: undefined, render: undefined });
|
|
142
|
+
void this.consume(stream);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Release the next stream item after xterm has parsed this frame.
|
|
146
|
+
* @param revision - locally delivered render revision.
|
|
147
|
+
*/
|
|
148
|
+
acknowledge(revision) {
|
|
149
|
+
if (this.pendingRender?.revision !== revision)
|
|
150
|
+
return;
|
|
151
|
+
this.pendingRender.resolve();
|
|
152
|
+
this.pendingRender = undefined;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Serialize raw input so concurrent RPC requests cannot reorder keystrokes.
|
|
156
|
+
* @param data - input from the terminal emulator.
|
|
157
|
+
*/
|
|
158
|
+
write(data) {
|
|
159
|
+
const state = this.state.getSnapshot();
|
|
160
|
+
const attachmentId = this.attachmentId;
|
|
161
|
+
if (!state.writable || state.info === undefined || attachmentId === undefined)
|
|
162
|
+
return;
|
|
163
|
+
const bytes = new TextEncoder().encode(data).byteLength;
|
|
164
|
+
if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) {
|
|
165
|
+
this.fail(new TerminalViewError('inputFull'));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.queuedInput += bytes;
|
|
169
|
+
const id = state.info.id;
|
|
170
|
+
this.writes = this.writes.then(async () => {
|
|
171
|
+
if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable)
|
|
172
|
+
return;
|
|
173
|
+
valueOf(await this.remote.write(this.sessionId, id, attachmentId, data));
|
|
174
|
+
}).catch((error) => { if (this.attachmentId === attachmentId)
|
|
175
|
+
this.fail(error); }).finally(() => { this.queuedInput -= bytes; });
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Resize only from the currently writable view.
|
|
179
|
+
* @param cols - measured column count.
|
|
180
|
+
* @param rows - measured row count.
|
|
181
|
+
*/
|
|
182
|
+
resize(cols, rows) {
|
|
183
|
+
const state = this.state.getSnapshot();
|
|
184
|
+
const attachmentId = this.attachmentId;
|
|
185
|
+
if (!state.writable || state.info === undefined || attachmentId === undefined)
|
|
186
|
+
return;
|
|
187
|
+
if (state.info.cols === cols && state.info.rows === rows)
|
|
188
|
+
return;
|
|
189
|
+
const id = state.info.id;
|
|
190
|
+
cols = Math.min(cols, state.environment?.maxCols ?? cols);
|
|
191
|
+
rows = Math.min(rows, state.environment?.maxRows ?? rows);
|
|
192
|
+
this.writes = this.writes.then(async () => {
|
|
193
|
+
if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable)
|
|
194
|
+
return;
|
|
195
|
+
valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows));
|
|
196
|
+
}).catch((error) => { if (this.attachmentId === attachmentId)
|
|
197
|
+
this.fail(error); });
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Update the Host terminal's display name.
|
|
201
|
+
* @param title - user-entered terminal title.
|
|
202
|
+
* @returns after the rename settles and its result is reflected in view state.
|
|
203
|
+
*/
|
|
204
|
+
async rename(title) {
|
|
205
|
+
if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted)
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
valueOf(await this.remote.rename(this.sessionId, this.id, title));
|
|
209
|
+
const current = this.state.getSnapshot().info;
|
|
210
|
+
this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
this.fail(error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Explicitly terminate this view's process independently of its DOM lifetime.
|
|
218
|
+
* @returns after Host process cleanup succeeds; failures remain retryable by the owner.
|
|
219
|
+
*/
|
|
220
|
+
close() {
|
|
221
|
+
if (this.closing !== undefined)
|
|
222
|
+
return this.closing;
|
|
223
|
+
this.patch({ phase: 'closing', writable: false, error: undefined, issue: undefined });
|
|
224
|
+
this.detach();
|
|
225
|
+
this.closing = (async () => {
|
|
226
|
+
// Even a refused or lost creation response may leave an allocation to close.
|
|
227
|
+
await this.creation;
|
|
228
|
+
valueOf(await this.remote.close(this.sessionId, this.id));
|
|
229
|
+
this.detach();
|
|
230
|
+
this.patch({ phase: 'closed', writable: false });
|
|
231
|
+
})().catch((error) => { this.closing = undefined; this.fail(error); throw error; });
|
|
232
|
+
return this.closing;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Stop Client work on plugin unload without closing Host terminals.
|
|
236
|
+
* @returns after active and previously detached stream iterators have closed.
|
|
237
|
+
*/
|
|
238
|
+
async dispose() {
|
|
239
|
+
this.mounted = false;
|
|
240
|
+
this.lifetime.abort();
|
|
241
|
+
this.detach();
|
|
242
|
+
await Promise.all(this.detaching);
|
|
243
|
+
}
|
|
244
|
+
detach() {
|
|
245
|
+
const previous = this.stream;
|
|
246
|
+
this.stream = undefined;
|
|
247
|
+
this.attachmentId = undefined;
|
|
248
|
+
this.pendingRender?.resolve();
|
|
249
|
+
this.pendingRender = undefined;
|
|
250
|
+
if (previous !== undefined) {
|
|
251
|
+
const cleanup = previous.dispose().finally(() => { this.detaching.delete(cleanup); });
|
|
252
|
+
this.detaching.add(cleanup);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async consume(stream) {
|
|
256
|
+
let generation = 0;
|
|
257
|
+
let sequence = 0;
|
|
258
|
+
try {
|
|
259
|
+
for await (const item of stream) {
|
|
260
|
+
if (this.stream !== stream)
|
|
261
|
+
return;
|
|
262
|
+
const frame = item.value;
|
|
263
|
+
if (generation !== item.generation) {
|
|
264
|
+
if (frame.type !== 'snapshot')
|
|
265
|
+
throw new TerminalViewError('invalidOutput', 'Terminal output generation is missing its screen snapshot');
|
|
266
|
+
generation = item.generation;
|
|
267
|
+
sequence = frame.sequence;
|
|
268
|
+
item.accept();
|
|
269
|
+
}
|
|
270
|
+
else if (frame.type === 'output') {
|
|
271
|
+
if (frame.sequence !== sequence + 1)
|
|
272
|
+
throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap');
|
|
273
|
+
sequence = frame.sequence;
|
|
274
|
+
}
|
|
275
|
+
else if (frame.type === 'snapshot')
|
|
276
|
+
throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot');
|
|
277
|
+
if (frame.type !== 'output') {
|
|
278
|
+
this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId });
|
|
279
|
+
}
|
|
280
|
+
if (frame.type !== 'state') {
|
|
281
|
+
const revision = ++this.revision;
|
|
282
|
+
await new Promise((resolve) => {
|
|
283
|
+
this.pendingRender = { revision, resolve };
|
|
284
|
+
const aborted = () => { this.acknowledge(revision); };
|
|
285
|
+
item.signal.addEventListener('abort', aborted, { once: true });
|
|
286
|
+
this.pendingRender.resolve = () => { item.signal.removeEventListener('abort', aborted); resolve(); };
|
|
287
|
+
this.patch({ render: { revision, frame } });
|
|
288
|
+
if (item.signal.aborted)
|
|
289
|
+
aborted();
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (this.stream === stream) {
|
|
296
|
+
if (this.state.getSnapshot().info?.state === 'exited')
|
|
297
|
+
this.patch({ phase: 'closed', writable: false });
|
|
298
|
+
else
|
|
299
|
+
this.fail(error);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
patch(patch) {
|
|
304
|
+
if (this.lifetime.signal.aborted)
|
|
305
|
+
return;
|
|
306
|
+
this.state.set({ ...this.state.getSnapshot(), ...patch });
|
|
307
|
+
}
|
|
308
|
+
fail(error) {
|
|
309
|
+
const failure = remoteErrorOf(error);
|
|
310
|
+
if (failure?.code === 'terminal/control-unavailable') {
|
|
311
|
+
this.patch({ writable: false, error: undefined, issue: undefined });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined;
|
|
315
|
+
this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function valueOf(result) {
|
|
319
|
+
if (!result.ok)
|
|
320
|
+
throw result.error;
|
|
321
|
+
return result.value;
|
|
322
|
+
}
|
|
323
|
+
//# sourceMappingURL=model.js.map
|