diff --git a/docs/content/1.guide/21.events.md b/docs/content/1.guide/21.events.md index ffa998cd..b9d570d2 100644 --- a/docs/content/1.guide/21.events.md +++ b/docs/content/1.guide/21.events.md @@ -20,6 +20,7 @@ Each subsystem host emits on `ctx..events`, consumed **inside the sam | `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` | | `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` | | `terminals:session:updated` | `TerminalsHost` register / update / remove / status change | context → `devframe:terminals:updated`; terminals plugin | `DevframeTerminalSession` | +| `terminals:session:output` | `TerminalsHost` stream consumption | opt-in node listeners | session id, live output chunk | | `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; messages plugin | entry / entry / id / — | | `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id | diff --git a/packages/hub/src/events.ts b/packages/hub/src/events.ts index 6205d39f..ca026db4 100644 --- a/packages/hub/src/events.ts +++ b/packages/hub/src/events.ts @@ -24,6 +24,7 @@ export const HUB_EVENTS = { docksEntryUpdated: 'docks:entry:updated', docksActivate: 'docks:activate', terminalsSessionUpdated: 'terminals:session:updated', + terminalsSessionOutput: 'terminals:session:output', messagesAdded: 'messages:added', messagesUpdated: 'messages:updated', messagesRemoved: 'messages:removed', diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index b3b2e056..de02808c 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -72,6 +72,49 @@ async function waitUntil(assertion: () => void): Promise { } describe('devframeTerminalHost stream lifecycle', () => { + it('emits each consumed output chunk with its session id', async () => { + const { host } = createTerminalHost() + const output: Array<[sessionId: string, chunk: string]> = [] + let controller: ReadableStreamDefaultController + const stream = new ReadableStream({ + start(_controller) { + controller = _controller + }, + }) + + host.events.on('terminals:session:output', (sessionId, chunk) => output.push([sessionId, chunk])) + host.register({ id: 'terminal', title: 'Terminal', status: 'running', stream }) + controller!.enqueue('first') + controller!.enqueue('second') + + await waitUntil(() => { + expect(output).toEqual([ + ['terminal', 'first'], + ['terminal', 'second'], + ]) + }) + }) + + it('emits output from child-process sessions', async () => { + const { host } = createTerminalHost() + const output: Array<[sessionId: string, chunk: string]> = [] + host.events.on('terminals:session:output', (sessionId, chunk) => output.push([sessionId, chunk])) + + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'process.stdout.write("child output")'], + }, { + id: 'child', + title: 'Child', + }) + + await session.getResult() + await waitUntil(() => { + expect(output.map(([, chunk]) => chunk).join('')).toContain('child output') + }) + expect(output.every(([sessionId]) => sessionId === 'child')).toBe(true) + }) + it('cancels a bound stream when a session is removed', async () => { const { host, sinks } = createTerminalHost() let controller: ReadableStreamDefaultController diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index d1b3b542..10e52e3f 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -128,6 +128,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { break if (result.done) break + this.events.emit(HUB_EVENTS.bus.terminalsSessionOutput, session.id, result.value) // Mirror to the legacy session.buffer used by `terminals:read` — // bounded tail kept for the snapshot endpoint. sessionBuffer.push(result.value) diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index 939634a9..a425e4a3 100644 --- a/packages/hub/src/types/terminals.ts +++ b/packages/hub/src/types/terminals.ts @@ -6,6 +6,8 @@ export interface DevframeTerminalsHost { readonly sessions: Map readonly events: EventEmitter<{ 'terminals:session:updated': (session: DevframeTerminalSession) => void + /** Live output chunks consumed from a session stream. Chunks are not replayed. */ + 'terminals:session:output': (sessionId: string, chunk: string) => void }> register: (session: DevframeTerminalSession) => DevframeTerminalSession diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts index 4d2876e2..5fb9371b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/constants.snapshot.d.ts @@ -15,6 +15,7 @@ export declare const HUB_EVENTS: { readonly docksEntryUpdated: "docks:entry:updated"; readonly docksActivate: "docks:activate"; readonly terminalsSessionUpdated: "terminals:session:updated"; + readonly terminalsSessionOutput: "terminals:session:output"; readonly messagesAdded: "messages:added"; readonly messagesUpdated: "messages:updated"; readonly messagesRemoved: "messages:removed"; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 76e049e8..23bdbca7 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -265,6 +265,7 @@ export interface DevframeTerminalsHost { readonly sessions: Map; readonly events: EventEmitter<{ 'terminals:session:updated': (session: DevframeTerminalSession) => void; + 'terminals:session:output': (sessionId: string, chunk: string) => void; }>; register: (_: DevframeTerminalSession) => DevframeTerminalSession; update: (_: DevframeTerminalSession) => void;