File size: 14,872 Bytes
09aec41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
/** React-free browser terminal state and reconnecting Remote-stream ownership. */
import { preferredShell, rememberShell } from './shell-preference.ts'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client'
import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
  TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
  WebTerminalId, WebTerminalInfo,
} from '../types.ts'

/** The generated terminal namespace's browser-facing operations. */
export type TerminalRemote = ClientRemote['terminal']

/** Product error identifiers translated by the terminal UI. */
export type TerminalViewIssue = 'missingTerminal' | 'inputFull' | 'attachmentEnded' | 'invalidOutput' | 'terminalLimit'

declare module '@deepseek-ai/dsh-typert-protocol' {
  interface RemoteErrorDetailsMap {
    /** Client terminal failure preserved through the Remote stream supervisor. */
    'terminal/view': { readonly issue: TerminalViewIssue }
  }
}

class TerminalViewError extends RemoteError<'terminal/view'> {
  constructor(issue: TerminalViewIssue, message: string = issue) { super('terminal/view', message, { issue }) }
}

/** One screen write awaiting the DOM emulator's callback. */
export interface TerminalRenderFrame {
  readonly revision: number
  readonly frame: Extract<TerminalFrame, { type: 'snapshot' | 'output' }>
}

/** Observable state of one sidebar occurrence. */
export interface TerminalViewState {
  readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
  readonly environment?: TerminalEnvironment | undefined
  readonly title?: string | undefined
  readonly info?: WebTerminalInfo | undefined
  readonly writable: boolean
  readonly render?: TerminalRenderFrame | undefined
  readonly error?: string | undefined
  readonly issue?: TerminalViewIssue | undefined
}

/** A view survives DOM unmount; its process only ends on explicit close. */
export class TerminalView {
  /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
  readonly state: SnapshotStore<TerminalViewState> = createSnapshotStore<TerminalViewState>({ phase: 'idle', writable: false })
  private readonly lifetime = new AbortController()
  private stream: RemoteStream<TerminalFrame> | undefined
  private mounted = false
  private attachmentId: TerminalAttachmentId | undefined
  private pendingRender: { revision: number; resolve: () => void } | undefined
  private revision = 0
  private creation: Promise<void> | undefined
  private loading: Promise<void> | undefined
  private closing: Promise<void> | undefined
  private writes: Promise<void> = Promise.resolve()
  private queuedInput = 0
  private readonly detaching = new Set<Promise<void>>()

  /**
   * @param sessionId - Session owning the terminal.
   * @param remote - typed terminal Remote operations.
   * @param gateway - reconnecting stream factory.
   * @param id - Host terminal identity, reused when recovering an item from its Session list.
   * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
   * @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
   */
  constructor(
    private readonly sessionId: SessionId,
    private readonly remote: TerminalRemote,
    private readonly gateway: Pick<ClientRemote, '$stream'>,
    readonly id: WebTerminalId,
    private readonly createWhenMissing = true,
    private readonly shellPath?: string,
  ) {}

  /**
   * Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process.
   * @returns a detach callback that leaves the terminal process alive.
   */
  mount(): () => void {
    this.mounted = true
    if (this.state.getSnapshot().info === undefined) void this.refresh()
    else this.connect()
    return () => {
      this.mounted = false
      this.detach()
    }
  }

  /**
   * Start or recover this tab, deduplicating mounts and retries during allocation.
   * Only a new tab may allocate a shell; listed terminals cannot be silently replaced.
   * @returns after environment lookup and creation or recovery settle.
   */
  refresh(): Promise<void> {
    if (this.creation !== undefined) return this.creation
    if (this.loading !== undefined) return this.loading
    if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
    this.patch({ phase: 'loading', error: undefined, issue: undefined })
    this.loading = (async () => {
      const [environment, available] = await Promise.all([
        this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId),
      ])
      if (this.stopped()) return
      this.patch({ environment: valueOf(environment) })
      const info = valueOf(available).find(item => item.id === this.id)
      if (info !== undefined) this.adopt(info)
      else if (this.createWhenMissing) {
        let path = this.shellPath
        if (path === undefined) {
          const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal))
          const previous = preferredShell()
          path = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path
        }
        if (this.stopped()) return
        if (path !== undefined) rememberShell(path)
        await this.create(valueOf(environment), path)
      }
      else throw new TerminalViewError('missingTerminal')
    })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined })
    return this.loading
  }

  private stopped(): boolean { return this.lifetime.signal.aborted || this.closing !== undefined }

  private async create(environment: TerminalEnvironment, shellPath?: string): Promise<void> {
    this.patch({ phase: 'creating', error: undefined, issue: undefined })
    this.creation = (async () => {
      const info = valueOf(await this.remote.create(this.sessionId, {
        id: this.id, ...shellPath === undefined ? {} : { shellPath },
        cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
      }, this.lifetime.signal))
      if (!this.lifetime.signal.aborted) {
        this.adopt(info)
      }
    })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined })
    await this.creation
  }

  private adopt(info: WebTerminalInfo): void {
    this.patch({ info, title: info.title })
    if (this.mounted && this.closing === undefined) this.connect()
  }

  /** Reattach with a fresh screen and regain input control. */
  connect(): void {
    const info = this.state.getSnapshot().info
    if (info === undefined || !this.mounted || this.closing !== undefined || this.lifetime.signal.aborted) return
    this.detach()
    const stream = this.gateway.$stream<TerminalFrame>({
      name: 'Browser terminal output',
      open: (signal) => {
        const attachmentId = randomUUID() as TerminalAttachmentId
        this.attachmentId = attachmentId
        return this.remote.follow(this.sessionId, info.id, attachmentId, signal)
      },
      ended: () => new TerminalViewError('attachmentEnded'),
      carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
    })
    this.stream = stream
    this.patch({ phase: 'connecting', writable: false, error: undefined, issue: undefined, render: undefined })
    void this.consume(stream)
  }

  /**
   * Release the next stream item after xterm has parsed this frame.
   * @param revision - locally delivered render revision.
   */
  acknowledge(revision: number): void {
    if (this.pendingRender?.revision !== revision) return
    this.pendingRender.resolve()
    this.pendingRender = undefined
  }

  /**
   * Serialize raw input so concurrent RPC requests cannot reorder keystrokes.
   * @param data - input from the terminal emulator.
   */
  write(data: string): void {
    const state = this.state.getSnapshot()
    const attachmentId = this.attachmentId
    if (!state.writable || state.info === undefined || attachmentId === undefined) return
    const bytes = new TextEncoder().encode(data).byteLength
    if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return }
    this.queuedInput += bytes
    const id = state.info.id
    this.writes = this.writes.then(async () => {
      if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return
      valueOf(await this.remote.write(this.sessionId, id, attachmentId, data))
    }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes })
  }

  /**
   * Resize only from the currently writable view.
   * @param cols - measured column count.
   * @param rows - measured row count.
   */
  resize(cols: number, rows: number): void {
    const state = this.state.getSnapshot()
    const attachmentId = this.attachmentId
    if (!state.writable || state.info === undefined || attachmentId === undefined) return
    if (state.info.cols === cols && state.info.rows === rows) return
    const id = state.info.id
    cols = Math.min(cols, state.environment?.maxCols ?? cols)
    rows = Math.min(rows, state.environment?.maxRows ?? rows)
    this.writes = this.writes.then(async () => {
      if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return
      valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows))
    }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) })
  }

  /**
   * Update the Host terminal's display name.
   * @param title - user-entered terminal title.
   * @returns after the rename settles and its result is reflected in view state.
   */
  async rename(title: string): Promise<void> {
    if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return
    try {
      valueOf(await this.remote.rename(this.sessionId, this.id, title))
      const current = this.state.getSnapshot().info
      this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() })
    } catch (error) { this.fail(error) }
  }

  /**
   * Explicitly terminate this view's process independently of its DOM lifetime.
   * @returns after Host process cleanup succeeds; failures remain retryable by the owner.
   */
  close(): Promise<void> {
    if (this.closing !== undefined) return this.closing
    this.patch({ phase: 'closing', writable: false, error: undefined, issue: undefined })
    this.detach()
    this.closing = (async () => {
      // Even a refused or lost creation response may leave an allocation to close.
      await this.creation
      valueOf(await this.remote.close(this.sessionId, this.id))
      this.detach()
      this.patch({ phase: 'closed', writable: false })
    })().catch((error: unknown) => { this.closing = undefined; this.fail(error); throw error })
    return this.closing
  }

  /**
   * Stop Client work on plugin unload without closing Host terminals.
   * @returns after active and previously detached stream iterators have closed.
   */
  async dispose(): Promise<void> {
    this.mounted = false
    this.lifetime.abort()
    this.detach()
    await Promise.all(this.detaching)
  }

  private detach(): void {
    const previous = this.stream
    this.stream = undefined
    this.attachmentId = undefined
    this.pendingRender?.resolve()
    this.pendingRender = undefined
    if (previous !== undefined) {
      const cleanup = previous.dispose().finally(() => { this.detaching.delete(cleanup) })
      this.detaching.add(cleanup)
    }
  }

  private async consume(stream: RemoteStream<TerminalFrame>): Promise<void> {
    let generation = 0
    let sequence = 0
    try {
      for await (const item of stream) {
        if (this.stream !== stream) return
        const frame = item.value
        if (generation !== item.generation) {
          if (frame.type !== 'snapshot') throw new TerminalViewError('invalidOutput', 'Terminal output generation is missing its screen snapshot')
          generation = item.generation
          sequence = frame.sequence
          item.accept()
        } else if (frame.type === 'output') {
          if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap')
          sequence = frame.sequence
        } else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot')
        if (frame.type !== 'output') {
          this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId })
        }
        if (frame.type !== 'state') {
          const revision = ++this.revision
          await new Promise<void>((resolve) => {
            this.pendingRender = { revision, resolve }
            const aborted = (): void => { this.acknowledge(revision) }
            item.signal.addEventListener('abort', aborted, { once: true })
            this.pendingRender.resolve = () => { item.signal.removeEventListener('abort', aborted); resolve() }
            this.patch({ render: { revision, frame } })
            if (item.signal.aborted) aborted()
          })
        }
      }
    } catch (error) {
      if (this.stream === stream) {
        if (this.state.getSnapshot().info?.state === 'exited') this.patch({ phase: 'closed', writable: false })
        else this.fail(error)
      }
    }
  }

  private patch(patch: Partial<TerminalViewState>): void {
    if (this.lifetime.signal.aborted) return
    this.state.set({ ...this.state.getSnapshot(), ...patch })
  }

  private fail(error: unknown): void {
    const failure = remoteErrorOf(error)
    if (failure?.code === 'terminal/control-unavailable') {
      this.patch({ writable: false, error: undefined, issue: undefined })
      return
    }
    const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined
    this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) })
  }
}

function valueOf<T>(result: RemoteResult<T>): T {
  if (!result.ok) throw result.error
  return result.value
}