Skip to content

Commit 0df6c03

Browse files
committed
feat(terminals): kill process without discarding the session, and clear stopped sessions
The UI only ever called the remove RPC, so killing a terminal always discarded it along with its scrollback. Wire up the existing terminate RPC (stop the process, keep the session) and distinguish it from remove in the toolbar: running sessions get a Kill action, stopped ones get Remove. Add a clear-exited RPC (TerminalManager.clearExited) plus a nav-level Clear exited sweep to drop every stopped owned session at once.
1 parent 57f3ef2 commit 0df6c03

5 files changed

Lines changed: 148 additions & 5 deletions

File tree

‎plugins/terminals/src/client/App.svelte‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
3434
const activeSession = $derived(sessions.find(s => s.id === activeId) ?? null)
3535
36+
// Only own sessions can be killed/removed; hub-aggregated ones are read-only.
37+
// `exitedCount` drives the nav-level "Clear exited" sweep.
38+
const exitedCount = $derived(
39+
sessions.filter(s => !isExternal(s) && s.status !== 'running').length,
40+
)
41+
3642
// A focus request that arrived (via the hub's dock-activation slot) before
3743
// its session showed up in the list. Applied one-shot the moment a matching
3844
// session appears, then cleared so the user's own tab clicks stay honored.
@@ -212,6 +218,21 @@
212218
spawn({ presetId: id })
213219
}
214220
221+
/** Stop the running process but keep the (now stopped) session and its scrollback. */
222+
function killSession(id: string): void {
223+
rpc.call('devframes:plugin:terminals:terminate', { id }).catch(() => {})
224+
}
225+
226+
/** Discard a session entirely — process, stream, and scrollback. */
227+
function removeSession(id: string): void {
228+
rpc.call('devframes:plugin:terminals:remove', { id }).catch(() => {})
229+
}
230+
231+
/** Sweep every stopped session away in one go. */
232+
function clearExited(): void {
233+
rpc.call('devframes:plugin:terminals:clear-exited').catch(() => {})
234+
}
235+
215236
function commitRename(id: string, title: string): void {
216237
renamingId = null
217238
rpc.call('devframes:plugin:terminals:rename', { id, title: title.trim() }).catch(() => {})
@@ -287,7 +308,7 @@
287308
tabindex="-1"
288309
aria-label="Close terminal"
289310
class="i-ph-x op0 group-hover:op60 hover:op100! transition-opacity shrink-0"
290-
onclick={(e) => { e.stopPropagation(); rpc.call('devframes:plugin:terminals:remove', { id: s.id }).catch(() => {}) }}
311+
onclick={(e) => { e.stopPropagation(); removeSession(s.id) }}
291312
onkeydown={() => {}}
292313
></span>
293314
{/if}
@@ -305,6 +326,18 @@
305326
</button>
306327
</div>
307328

329+
{#if exitedCount > 0}
330+
<button
331+
type="button"
332+
class={button({ variant: 'outline', size: 'sm', class: 'shrink-0' })}
333+
title="Remove all stopped sessions"
334+
onclick={() => clearExited()}
335+
>
336+
<div class="i-ph-broom-duotone"></div>
337+
<span class="hidden sm:inline">Clear exited</span>
338+
</button>
339+
{/if}
340+
308341
{#if presets.length}
309342
<div class="relative shrink-0">
310343
<button
@@ -376,9 +409,15 @@
376409
<button type="button" class={iconButton({ variant: 'ghost', size: 'sm' })} title="Restart" onclick={() => rpc.call('devframes:plugin:terminals:restart', { id: s.id }).catch(() => {})}>
377410
<div class="i-ph-arrow-clockwise-duotone"></div>
378411
</button>
379-
<button type="button" class={iconButton({ variant: 'ghost', size: 'sm' })} title="Kill" onclick={() => rpc.call('devframes:plugin:terminals:remove', { id: s.id }).catch(() => {})}>
380-
<div class="i-ph-trash-duotone"></div>
381-
</button>
412+
{#if s.status === 'running'}
413+
<button type="button" class={iconButton({ variant: 'ghost', size: 'sm' })} title="Kill process" onclick={() => killSession(s.id)}>
414+
<div class="i-ph-stop-duotone"></div>
415+
</button>
416+
{:else}
417+
<button type="button" class={iconButton({ variant: 'ghost', size: 'sm' })} title="Remove session" onclick={() => removeSession(s.id)}>
418+
<div class="i-ph-trash-duotone"></div>
419+
</button>
420+
{/if}
382421
{/if}
383422
</div>
384423
{/if}

‎plugins/terminals/src/node/manager.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,30 @@ export class TerminalManager {
483483
const session = this.sessions.get(id)
484484
if (!session)
485485
throw diagnostics.DP_TERMINALS_0001({ id })
486+
this.disposeSession(id, session)
487+
this.publish()
488+
}
489+
490+
/**
491+
* Drop every stopped (non-running) session this manager owns, closing their
492+
* streams and forgetting their scrollback. Sessions still running are left
493+
* untouched; aggregated hub sessions aren't owned here so they never appear
494+
* in {@link sessions}. Publishes once for the whole sweep.
495+
*/
496+
clearExited(): void {
497+
for (const [id, session] of this.sessions) {
498+
if (session.info.status !== 'running')
499+
this.disposeSession(id, session)
500+
}
501+
this.publish()
502+
}
503+
504+
/**
505+
* Tear a single session down — kill its process, stop polling, dispose the
506+
* OSC inspector, close the sink, and drop it from the store — without
507+
* publishing. Callers publish once they've finished mutating the store.
508+
*/
509+
private disposeSession(id: string, session: ManagedSession): void {
486510
const proc = session.proc
487511
session.proc = undefined
488512
this.stopProcessPoll(session)
@@ -492,7 +516,6 @@ export class TerminalManager {
492516
if (!session.sink.closed)
493517
session.sink.close()
494518
this.sessions.delete(id)
495-
this.publish()
496519
}
497520

498521
/** Tear everything down — used on server shutdown and in tests. */
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { defineRpcFunction } from 'devframe'
2+
import * as v from 'valibot'
3+
import { getTerminalManager } from '../../node/context'
4+
5+
export const clearExited = defineRpcFunction({
6+
name: 'devframes:plugin:terminals:clear-exited',
7+
type: 'action',
8+
jsonSerializable: true,
9+
args: [],
10+
returns: v.void(),
11+
agent: {
12+
description: 'Discard every stopped (exited or errored) terminal session at once. Running sessions are left untouched.',
13+
safety: 'destructive',
14+
},
15+
setup: ctx => ({
16+
handler: () => {
17+
getTerminalManager(ctx).clearExited()
18+
},
19+
}),
20+
})

‎plugins/terminals/src/rpc/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
22
import type { TerminalPreset, TerminalsSharedState } from '../types'
3+
import { clearExited } from './functions/clear-exited'
34
import { list } from './functions/list'
45
import { presets } from './functions/presets'
56
import { remove } from './functions/remove'
@@ -20,6 +21,7 @@ export const serverFunctions = [
2021
restart,
2122
rename,
2223
remove,
24+
clearExited,
2325
] as const
2426

2527
declare module 'devframe' {

‎plugins/terminals/test/terminals.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,65 @@ describe('@devframes/plugin-terminals', () => {
247247
expect(list.some(s => s.id === info.id)).toBe(false)
248248
})
249249

250+
it('kills a running process but keeps the session as stopped', async () => {
251+
const client = bootClient(server.port)
252+
await new Promise(r => setTimeout(r, 50))
253+
254+
const info = await call<TerminalSessionInfo>(client, 'devframes:plugin:terminals:spawn', {
255+
command: NODE,
256+
args: ['-e', 'setInterval(() => {}, 1000)'],
257+
mode: 'readonly',
258+
})
259+
260+
await vi.waitFor(async () => {
261+
const list = await sessions(server)
262+
expect(list.find(s => s.id === info.id)?.status).toBe('running')
263+
})
264+
265+
// terminate stops the process, but the session (and its scrollback) survive.
266+
await call(client, 'devframes:plugin:terminals:terminate', { id: info.id })
267+
268+
await vi.waitFor(async () => {
269+
const list = await sessions(server)
270+
const s = list.find(x => x.id === info.id)
271+
expect(s).toBeDefined()
272+
expect(s?.status).not.toBe('running')
273+
})
274+
275+
await call(client, 'devframes:plugin:terminals:remove', { id: info.id })
276+
})
277+
278+
it('clears stopped sessions, leaving running ones untouched', async () => {
279+
const client = bootClient(server.port)
280+
await new Promise(r => setTimeout(r, 50))
281+
282+
// One session exits on its own; another stays alive.
283+
const done = await call<TerminalSessionInfo>(client, 'devframes:plugin:terminals:spawn', {
284+
command: NODE,
285+
args: ['-e', 'process.stdout.write("bye")'],
286+
mode: 'readonly',
287+
})
288+
const alive = await call<TerminalSessionInfo>(client, 'devframes:plugin:terminals:spawn', {
289+
command: NODE,
290+
args: ['-e', 'setInterval(() => {}, 1000)'],
291+
mode: 'readonly',
292+
})
293+
294+
await vi.waitFor(async () => {
295+
const list = await sessions(server)
296+
expect(list.find(s => s.id === done.id)?.status).toBe('exited')
297+
expect(list.find(s => s.id === alive.id)?.status).toBe('running')
298+
})
299+
300+
await call(client, 'devframes:plugin:terminals:clear-exited')
301+
302+
const list = await sessions(server)
303+
expect(list.some(s => s.id === done.id)).toBe(false)
304+
expect(list.some(s => s.id === alive.id)).toBe(true)
305+
306+
await call(client, 'devframes:plugin:terminals:remove', { id: alive.id })
307+
})
308+
250309
it('exposes presets and spawns from them', async () => {
251310
await server.close()
252311
server = await startTerminalsServer({

0 commit comments

Comments
 (0)