Skip to content

Commit 439a30b

Browse files
committed
feat(plugin-terminals): surface aggregated hub sessions as read-only
Show terminal sessions contributed by other devframes through the hub (ctx.terminals) — such as code-server — in the terminals plugin's own tab. They render read-only with the contributing tool's icon and name, stream output from the hub's channel, and refresh as they start/stop. The plugin offers no rename/restart/kill controls for sessions it doesn't own.
1 parent fb199be commit 439a30b

14 files changed

Lines changed: 282 additions & 28 deletions

File tree

plugins/terminals/src/client/App.svelte

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@
3939
return info.customTitle || info.processName || info.title
4040
}
4141
42+
/**
43+
* Sessions aggregated from other devframes via the hub (they carry a
44+
* `channel`) are surfaced read-only — this plugin doesn't own their process,
45+
* so it offers no rename / restart / kill controls for them.
46+
*/
47+
function isExternal(info: TerminalSessionInfo): boolean {
48+
return Boolean(info.channel)
49+
}
50+
4251
function pickActive(list: TerminalSessionInfo[]): void {
4352
if (activeId && !list.some(x => x.id === activeId))
4453
activeId = null
@@ -176,20 +185,25 @@
176185
<button
177186
type="button"
178187
class={navTab({ active: activeId === s.id, class: 'group' })}
179-
title={`${displayName(s)} — double-click to rename`}
188+
title={isExternal(s) ? displayName(s) : `${displayName(s)} — double-click to rename`}
180189
onclick={() => (activeId = s.id)}
181-
ondblclick={(e) => { e.preventDefault(); e.stopPropagation(); renamingId = s.id }}
190+
ondblclick={(e) => { if (isExternal(s)) return; e.preventDefault(); e.stopPropagation(); renamingId = s.id }}
182191
>
183192
<span class={dot(statusDot(s.status))}></span>
193+
{#if s.icon}
194+
<div class="{s.icon} shrink-0"></div>
195+
{/if}
184196
<span class="truncate">{displayName(s)}</span>
185-
<span
186-
role="button"
187-
tabindex="-1"
188-
aria-label="Close terminal"
189-
class="i-ph-x op0 group-hover:op60 hover:op100! transition-opacity shrink-0"
190-
onclick={(e) => { e.stopPropagation(); rpc.call('devframes-plugin-terminals:remove', { id: s.id }).catch(() => {}) }}
191-
onkeydown={() => {}}
192-
></span>
197+
{#if !isExternal(s)}
198+
<span
199+
role="button"
200+
tabindex="-1"
201+
aria-label="Close terminal"
202+
class="i-ph-x op0 group-hover:op60 hover:op100! transition-opacity shrink-0"
203+
onclick={(e) => { e.stopPropagation(); rpc.call('devframes-plugin-terminals:remove', { id: s.id }).catch(() => {}) }}
204+
onkeydown={() => {}}
205+
></span>
206+
{/if}
193207
</button>
194208
{/if}
195209
{/each}
@@ -248,7 +262,10 @@
248262
<span class={tag(s.mode === 'interactive' ? 'blue' : 'amber')}>
249263
{s.mode === 'interactive' ? 'interactive' : 'readonly'}
250264
</span>
251-
<span class="font-mono truncate op-fade" title={`${s.command} ${s.args.join(' ')}`}>
265+
<span class="font-mono truncate op-fade flex items-center gap-1.5" title={`${s.command} ${s.args.join(' ')}`}>
266+
{#if s.icon}
267+
<div class="{s.icon} shrink-0 text-base"></div>
268+
{/if}
252269
{s.command}{s.args.length ? ` ${s.args.join(' ')}` : ''}
253270
</span>
254271
<span class="flex items-center gap-1.5 op-mute font-mono text-xs tabular-nums shrink-0">
@@ -262,12 +279,14 @@
262279

263280
<div class="flex-1"></div>
264281

265-
<button type="button" class={iconButton({ variant: 'ghost', size: 'sm' })} title="Restart" onclick={() => rpc.call('devframes-plugin-terminals:restart', { id: s.id }).catch(() => {})}>
266-
<div class="i-ph-arrow-clockwise-duotone"></div>
267-
</button>
268-
<button type="button" class={iconButton({ variant: 'ghost', size: 'sm' })} title="Kill" onclick={() => rpc.call('devframes-plugin-terminals:remove', { id: s.id }).catch(() => {})}>
269-
<div class="i-ph-trash-duotone"></div>
270-
</button>
282+
{#if !isExternal(s)}
283+
<button type="button" class={iconButton({ variant: 'ghost', size: 'sm' })} title="Restart" onclick={() => rpc.call('devframes-plugin-terminals:restart', { id: s.id }).catch(() => {})}>
284+
<div class="i-ph-arrow-clockwise-duotone"></div>
285+
</button>
286+
<button type="button" class={iconButton({ variant: 'ghost', size: 'sm' })} title="Kill" onclick={() => rpc.call('devframes-plugin-terminals:remove', { id: s.id }).catch(() => {})}>
287+
<div class="i-ph-trash-duotone"></div>
288+
</button>
289+
{/if}
271290
</div>
272291
{/if}
273292

plugins/terminals/src/client/TerminalView.svelte

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,15 @@
7272
})
7373
}
7474
75-
term.onResize(({ cols, rows }) => {
76-
rpc.call('devframes-plugin-terminals:resize', { id: info.id, cols, rows }).catch(() => {})
77-
})
75+
// Aggregated hub sessions (they carry a `channel`) aren't owned by this
76+
// plugin, so there's no local process to resize.
77+
if (!info.channel) {
78+
term.onResize(({ cols, rows }) => {
79+
rpc.call('devframes-plugin-terminals:resize', { id: info.id, cols, rows }).catch(() => {})
80+
})
81+
}
7882
79-
reader = rpc.streaming.subscribe(TERMINAL_STREAM_CHANNEL, info.id)
83+
reader = rpc.streaming.subscribe(info.channel || TERMINAL_STREAM_CHANNEL, info.id)
8084
;(async () => {
8185
try {
8286
for await (const chunk of reader) {

plugins/terminals/src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ export const PLUGIN_ID = 'devframes-plugin-terminals'
88
*/
99
export const TERMINAL_STREAM_CHANNEL = 'devframes-plugin-terminals:output'
1010

11+
/**
12+
* Streaming channel the hub's own terminals subsystem (`ctx.terminals`) uses
13+
* for aggregated sessions contributed by *other* devframes (e.g. code-server).
14+
* Mirrors `@devframes/hub`'s internal channel name; the plugin surfaces those
15+
* sessions read-only and reads their output from here. Kept as a literal so the
16+
* plugin needs no build dependency on the hub.
17+
*/
18+
export const HUB_TERMINAL_STREAM_CHANNEL = 'devframe:terminals'
19+
1120
/** Shared-state key holding the serializable session list. */
1221
export const SESSIONS_STATE_KEY = 'devframes-plugin-terminals:sessions'
1322

plugins/terminals/src/node/manager.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
DEFAULT_COLS,
1818
DEFAULT_ROWS,
1919
DEFAULT_SCROLLBACK,
20+
HUB_TERMINAL_STREAM_CHANNEL,
2021
PRESETS_STATE_KEY,
2122
SESSIONS_STATE_KEY,
2223
TERMINAL_STREAM_CHANNEL,
@@ -58,13 +59,16 @@ interface HubTerminalEntry {
5859
title: string
5960
description?: string
6061
status: 'running' | 'stopped' | 'error'
61-
icon?: string
62+
icon?: string | { light: string, dark: string }
6263
}
6364
interface HubTerminalsBridge {
64-
sessions: Map<string, { id: string }>
65+
sessions: Map<string, HubTerminalEntry>
6566
register: (session: HubTerminalEntry) => unknown
6667
update: (session: HubTerminalEntry) => void
6768
remove?: (session: { id: string }) => void
69+
events?: {
70+
on: (event: 'terminal:session:updated', cb: (session: HubTerminalEntry) => void) => void
71+
}
6872
}
6973

7074
/** Map the plugin's session status onto the hub's coarser status set. */
@@ -137,10 +141,52 @@ export class TerminalManager {
137141
icon: p.icon,
138142
}))
139143
})
144+
145+
// When mounted in a hub, refresh our session list whenever another
146+
// devframe's terminal session (e.g. code-server) changes, so those
147+
// aggregated sessions appear/update/disappear in this plugin's UI. Guarded
148+
// to foreign ids so mirroring our *own* sessions into the hub can't loop.
149+
const hub = this.hubTerminals()
150+
hub?.events?.on('terminal:session:updated', (session) => {
151+
if (!this.sessions.has(session.id))
152+
this.refreshSessionsState()
153+
})
154+
}
155+
156+
/** The hub's terminals subsystem when mounted in a hub, else undefined. */
157+
private hubTerminals(): HubTerminalsBridge | undefined {
158+
return (this.ctx as { terminals?: HubTerminalsBridge }).terminals
140159
}
141160

142161
list(): TerminalSessionInfo[] {
143-
return Array.from(this.sessions.values()).map(s => ({ ...s.info }))
162+
const own = Array.from(this.sessions.values()).map(s => ({ ...s.info }))
163+
const hub = this.hubTerminals()
164+
if (!hub?.sessions)
165+
return own
166+
167+
// Surface sessions contributed by *other* devframes (aggregated in the
168+
// hub) as read-only entries, reading their output from the hub's channel.
169+
const foreign: TerminalSessionInfo[] = []
170+
for (const session of hub.sessions.values()) {
171+
if (this.sessions.has(session.id))
172+
continue
173+
foreign.push({
174+
id: session.id,
175+
title: session.title,
176+
mode: 'readonly',
177+
status: session.status === 'stopped' ? 'exited' : session.status,
178+
backend: 'pipe',
179+
command: '',
180+
args: [],
181+
cwd: '',
182+
cols: DEFAULT_COLS,
183+
rows: DEFAULT_ROWS,
184+
createdAt: 0,
185+
icon: typeof session.icon === 'string' ? session.icon : session.icon?.light,
186+
channel: HUB_TERMINAL_STREAM_CHANNEL,
187+
})
188+
}
189+
return [...own, ...foreign]
144190
}
145191

146192
getPresets(): TerminalPreset[] {
@@ -415,10 +461,20 @@ export class TerminalManager {
415461
}
416462

417463
private publish(): void {
464+
this.refreshSessionsState()
465+
this.syncHub()
466+
}
467+
468+
/**
469+
* Push the current session list (own + aggregated hub sessions) into shared
470+
* state. Kept separate from {@link publish} so the hub-session listener can
471+
* refresh without re-running {@link syncHub} (which would re-emit hub events
472+
* and loop).
473+
*/
474+
private refreshSessionsState(): void {
418475
this.sessionsState?.mutate((draft) => {
419476
draft.sessions = this.list()
420477
})
421-
this.syncHub()
422478
}
423479

424480
/**

plugins/terminals/src/rpc/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const sessionInfoSchema = v.object({
2929
rows: v.number(),
3030
pid: v.optional(v.number()),
3131
exitCode: v.optional(v.number()),
32+
icon: v.optional(v.string()),
33+
channel: v.optional(v.string()),
3234
presetId: v.optional(v.string()),
3335
createdAt: v.number(),
3436
})

plugins/terminals/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export interface TerminalSessionInfo {
4242
rows: number
4343
pid?: number
4444
exitCode?: number
45+
icon?: string
46+
channel?: string
4547
/** Preset this session was spawned from, if any. */
4648
presetId?: string
4749
createdAt: number

plugins/terminals/test/_utils.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from 'devframe/node'
1111
import { createRpcClient } from 'devframe/rpc/client'
1212
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
13+
import { createEventEmitter } from 'devframe/utils/events'
1314
import { getPort } from 'get-port-please'
1415
import { H3 } from 'h3'
1516
import { createTerminalsDevframe } from '../src/index'
@@ -20,11 +21,61 @@ export type TerminalsServer = StartedServer & {
2021
port: number
2122
}
2223

24+
interface FakeHubEntry {
25+
id: string
26+
title: string
27+
description?: string
28+
status: 'running' | 'stopped' | 'error'
29+
icon?: string | { light: string, dark: string }
30+
}
31+
32+
export interface FakeHubTerminals {
33+
sessions: Map<string, FakeHubEntry>
34+
events: ReturnType<typeof createEventEmitter>
35+
register: (entry: FakeHubEntry) => FakeHubEntry
36+
update: (patch: { id: string } & Partial<FakeHubEntry>) => void
37+
remove: (entry: { id: string }) => void
38+
}
39+
40+
/**
41+
* Minimal stand-in for the hub's `ctx.terminals` aggregation host — a sessions
42+
* map plus a `terminal:session:updated` emitter — so tests can exercise how the
43+
* terminals plugin surfaces sessions contributed by *other* devframes.
44+
*/
45+
export function createFakeHubTerminals(): FakeHubTerminals {
46+
const sessions = new Map<string, FakeHubEntry>()
47+
const events = createEventEmitter()
48+
return {
49+
sessions,
50+
events,
51+
register(entry) {
52+
sessions.set(entry.id, entry)
53+
events.emit('terminal:session:updated', entry)
54+
return entry
55+
},
56+
update(patch) {
57+
const cur = sessions.get(patch.id)
58+
if (cur)
59+
Object.assign(cur, patch)
60+
events.emit('terminal:session:updated', sessions.get(patch.id) ?? patch)
61+
},
62+
remove(entry) {
63+
const cur = sessions.get(entry.id)
64+
sessions.delete(entry.id)
65+
events.emit('terminal:session:updated', cur ?? entry)
66+
},
67+
}
68+
}
69+
2370
/**
2471
* Boot the terminals devframe in-process over real HTTP + WebSocket so the
25-
* full RPC + streaming path is exercised end to end.
72+
* full RPC + streaming path is exercised end to end. Pass `hub` to attach a
73+
* fake `ctx.terminals` before setup (as a hub mount would).
2674
*/
27-
export async function startTerminalsServer(options: TerminalsOptions = {}): Promise<TerminalsServer> {
75+
export async function startTerminalsServer(
76+
options: TerminalsOptions = {},
77+
{ hub }: { hub?: FakeHubTerminals } = {},
78+
): Promise<TerminalsServer> {
2879
const definition = createTerminalsDevframe({ allowArbitraryCommands: true, ...options })
2980
const host = '127.0.0.1'
3081
const port = await getPort({ host, random: true })
@@ -38,6 +89,8 @@ export async function startTerminalsServer(options: TerminalsOptions = {}): Prom
3889
})
3990

4091
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host })
92+
if (hub)
93+
(ctx as { terminals?: FakeHubTerminals }).terminals = hub
4194
await definition.setup(ctx)
4295

4396
const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false })

0 commit comments

Comments
 (0)