Skip to content

Commit 4ac8146

Browse files
committed
feat(plugin-terminals): light/dark theming + live process-name tabs with rename
- Follow the system color mode and react to changes at runtime: the UI chrome (CSS variables) and every xterm instance switch between dark and a GitHub-light palette without reload, driven by prefers-color-scheme. - Tab labels show the live foreground process of the controlling TTY (e.g. bash → vim → bash), polled from node-pty for PTY sessions. - Custom renaming: double-click a tab to edit inline; backed by a new `devframes-plugin-terminals:rename` RPC. Display precedence is customTitle > processName > base title. Adds processName/customTitle to the session descriptor, a getProcessName hook on the PTY backend, an unref'd poll timer (cleared on exit/restart/ remove/dispose), and tests for process-name tracking and renaming.
1 parent 90486f1 commit 4ac8146

13 files changed

Lines changed: 358 additions & 25 deletions

File tree

plugins/terminals/src/client/index.ts

Lines changed: 146 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ITheme } from '@xterm/xterm'
12
import type { DevframeRpcClient } from 'devframe/client'
23
import type { StreamReader } from 'devframe/utils/streaming-channel'
34
import type { TerminalPreset, TerminalSessionInfo, TerminalsSharedState } from '../types'
@@ -33,48 +34,87 @@ interface SessionView {
3334

3435
const UI_CSS = `
3536
.dft-root { position: absolute; inset: 0; display: flex; flex-direction: column;
36-
font-family: system-ui, sans-serif; background: #0b0e14; color: #c9d1d9; }
37+
font-family: system-ui, sans-serif; background: var(--dft-bg); color: var(--dft-fg); }
38+
.dft-root.dft-dark {
39+
--dft-bg: #0d1117; --dft-fg: #c9d1d9; --dft-muted: #8b949e;
40+
--dft-border: #1c2128; --dft-surface: #161b22; --dft-surface-hover: #30363d;
41+
--dft-surface-active: #21262d; --dft-term-bg: #000000; --dft-accent: #58a6ff;
42+
}
43+
.dft-root.dft-light {
44+
--dft-bg: #f6f8fa; --dft-fg: #1f2328; --dft-muted: #59636e;
45+
--dft-border: #d0d7de; --dft-surface: #ffffff; --dft-surface-hover: #eaeef2;
46+
--dft-surface-active: #ffffff; --dft-term-bg: #ffffff; --dft-accent: #0969da;
47+
}
3748
.dft-header { display: flex; align-items: stretch; gap: 4px; padding: 6px 8px;
38-
border-bottom: 1px solid #1c2128; background: #0d1117; }
49+
border-bottom: 1px solid var(--dft-border); background: var(--dft-bg); }
3950
.dft-tabs { display: flex; gap: 4px; overflow-x: auto; flex: 1; align-items: center; }
4051
.dft-tab { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
41-
padding: 4px 10px; border-radius: 6px; border: 1px solid transparent; background: #161b22;
42-
color: #8b949e; font-size: 12px; cursor: pointer; }
43-
.dft-tab:hover { color: #c9d1d9; }
44-
.dft-tab.active { background: #21262d; color: #fff; border-color: #30363d; }
52+
padding: 4px 10px; border-radius: 6px; border: 1px solid transparent; background: var(--dft-surface);
53+
color: var(--dft-muted); font-size: 12px; cursor: pointer; }
54+
.dft-tab:hover { color: var(--dft-fg); }
55+
.dft-tab.active { background: var(--dft-surface-active); color: var(--dft-fg); border-color: var(--dft-border); }
4556
.dft-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb950; flex: none; }
4657
.dft-dot.exited { background: #6e7681; }
4758
.dft-dot.error { background: #f85149; }
4859
.dft-actions { display: flex; gap: 6px; align-items: center; }
49-
.dft-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #30363d;
50-
background: #21262d; color: #c9d1d9; font-size: 12px; cursor: pointer; }
51-
.dft-btn:hover { background: #30363d; }
60+
.dft-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--dft-border);
61+
background: var(--dft-surface); color: var(--dft-fg); font-size: 12px; cursor: pointer; }
62+
.dft-btn:hover { background: var(--dft-surface-hover); }
5263
.dft-btn:disabled { opacity: 0.45; cursor: default; }
53-
.dft-select { padding: 4px 8px; border-radius: 6px; border: 1px solid #30363d;
54-
background: #21262d; color: #c9d1d9; font-size: 12px; }
64+
.dft-select { padding: 4px 8px; border-radius: 6px; border: 1px solid var(--dft-border);
65+
background: var(--dft-surface); color: var(--dft-fg); font-size: 12px; }
66+
.dft-rename { font: inherit; font-size: 12px; width: 10ch; min-width: 64px; padding: 1px 5px;
67+
border: 1px solid var(--dft-accent); border-radius: 4px; background: var(--dft-bg);
68+
color: var(--dft-fg); outline: none; }
5569
.dft-toolbar { display: flex; align-items: center; gap: 8px; padding: 4px 10px;
56-
border-bottom: 1px solid #1c2128; font-size: 12px; color: #8b949e; min-height: 20px; }
70+
border-bottom: 1px solid var(--dft-border); font-size: 12px; color: var(--dft-muted); min-height: 20px; }
5771
.dft-badge { padding: 1px 7px; border-radius: 10px; font-size: 10px; text-transform: uppercase;
58-
letter-spacing: 0.03em; border: 1px solid #30363d; }
59-
.dft-badge.interactive { color: #58a6ff; border-color: #1f6feb55; }
60-
.dft-badge.readonly { color: #d29922; border-color: #9e6a0355; }
72+
letter-spacing: 0.03em; border: 1px solid var(--dft-border); }
73+
.dft-badge.interactive { color: var(--dft-accent); border-color: #1f6feb55; }
74+
.dft-badge.readonly { color: #bb8009; border-color: #9e6a0355; }
6175
.dft-spacer { flex: 1; }
62-
.dft-body { position: relative; flex: 1; overflow: hidden; background: #000; }
76+
.dft-body { position: relative; flex: 1; overflow: hidden; background: var(--dft-term-bg); }
6377
.dft-view { position: absolute; inset: 0; padding: 4px; display: none; }
6478
.dft-view.active { display: block; }
6579
.dft-empty { position: absolute; inset: 0; display: flex; align-items: center;
66-
justify-content: center; color: #6e7681; font-size: 13px; pointer-events: none; }
80+
justify-content: center; color: var(--dft-muted); font-size: 13px; pointer-events: none; }
6781
.dft-view .xterm, .dft-view .xterm-viewport, .dft-view .xterm-screen { height: 100%; }
68-
.dft-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #c9d1d9; }
82+
.dft-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--dft-fg); }
6983
`
7084

71-
const THEME = {
85+
const DARK_THEME: ITheme = {
7286
background: '#000000',
7387
foreground: '#c9d1d9',
7488
cursor: '#58a6ff',
89+
cursorAccent: '#000000',
7590
selectionBackground: '#234876',
7691
}
7792

93+
// GitHub-light palette so the default-bright ANSI colors stay legible on white.
94+
const LIGHT_THEME: ITheme = {
95+
background: '#ffffff',
96+
foreground: '#1f2328',
97+
cursor: '#0969da',
98+
cursorAccent: '#ffffff',
99+
selectionBackground: '#b6d7ff',
100+
black: '#24292f',
101+
red: '#cf222e',
102+
green: '#116329',
103+
yellow: '#7d4e00',
104+
blue: '#0969da',
105+
magenta: '#8250df',
106+
cyan: '#1b7c83',
107+
white: '#6e7781',
108+
brightBlack: '#57606a',
109+
brightRed: '#a40e26',
110+
brightGreen: '#1a7f37',
111+
brightYellow: '#633c01',
112+
brightBlue: '#218bff',
113+
brightMagenta: '#a475f9',
114+
brightCyan: '#3192aa',
115+
brightWhite: '#8c959f',
116+
}
117+
78118
let stylesInjected = false
79119
function injectStyles(): void {
80120
if (stylesInjected || typeof document === 'undefined')
@@ -133,6 +173,37 @@ export async function mountTerminals(
133173
let activeId: string | null = null
134174
let presets: TerminalPreset[] = []
135175
let disposed = false
176+
let renamingId: string | null = null
177+
178+
// Follow the system color mode and react to changes at runtime, switching
179+
// both the UI chrome (via CSS classes) and every xterm instance's theme.
180+
const colorScheme = typeof window !== 'undefined' && window.matchMedia
181+
? window.matchMedia('(prefers-color-scheme: dark)')
182+
: null
183+
let isDark = colorScheme ? colorScheme.matches : true
184+
185+
function activeTheme(): ITheme {
186+
return isDark ? DARK_THEME : LIGHT_THEME
187+
}
188+
189+
function applyColorScheme(): void {
190+
root.classList.toggle('dft-dark', isDark)
191+
root.classList.toggle('dft-light', !isDark)
192+
for (const view of views.values())
193+
view.term.options.theme = activeTheme()
194+
}
195+
196+
const onColorScheme = (e: MediaQueryListEvent): void => {
197+
isDark = e.matches
198+
applyColorScheme()
199+
}
200+
colorScheme?.addEventListener('change', onColorScheme)
201+
applyColorScheme()
202+
203+
/** Tab/toolbar label: custom name wins, then the live process, then the base title. */
204+
function displayName(info: TerminalSessionInfo): string {
205+
return info.customTitle || info.processName || info.title
206+
}
136207

137208
function spawn(req: Parameters<DevframeRpcClient['call']>[1]): void {
138209
rpc.call('devframes-plugin-terminals:spawn', req as any).catch(() => {})
@@ -202,7 +273,9 @@ export async function mountTerminals(
202273
const badge = el('span', `dft-badge ${info.mode}`)
203274
badge.textContent = info.mode
204275
const label = el('span', 'dft-mono')
205-
label.textContent = `${info.command}${info.args.length ? ` ${info.args.join(' ')}` : ''}`
276+
label.textContent = info.processName && info.processName !== info.command
277+
? `${info.processName} · ${info.command}`
278+
: `${info.command}${info.args.length ? ` ${info.args.join(' ')}` : ''}`
206279
const status = el('span')
207280
status.textContent = info.status === 'running'
208281
? `running · ${info.backend}${info.pid ? ` · pid ${info.pid}` : ''}`
@@ -234,7 +307,7 @@ export async function mountTerminals(
234307
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
235308
fontSize: 13,
236309
scrollback: 10000,
237-
theme: THEME,
310+
theme: activeTheme(),
238311
disableStdin: info.mode !== 'interactive',
239312
allowProposedApi: false,
240313
})
@@ -264,6 +337,13 @@ export async function mountTerminals(
264337

265338
const tab = el('button', 'dft-tab')
266339
tab.onclick = () => setActive(info.id)
340+
tab.ondblclick = (e) => {
341+
e.preventDefault()
342+
e.stopPropagation()
343+
const view = views.get(info.id)
344+
if (view)
345+
startRename(view)
346+
}
267347

268348
requestAnimationFrame(() => {
269349
try {
@@ -284,16 +364,57 @@ export async function mountTerminals(
284364

285365
function renderTabs(): void {
286366
for (const view of views.values()) {
367+
if (view.tab.parentElement !== tabs)
368+
tabs.append(view.tab)
369+
// Leave the tab being renamed untouched so its input survives
370+
// concurrent shared-state updates.
371+
if (view.info.id === renamingId)
372+
continue
287373
view.tab.replaceChildren()
288374
const dot = el('span', `dft-dot ${view.info.status === 'running' ? '' : view.info.status}`)
289375
const label = el('span')
290-
label.textContent = view.info.title
376+
label.textContent = displayName(view.info)
377+
view.tab.title = 'Double-click to rename'
291378
view.tab.append(dot, label)
292-
if (view.tab.parentElement !== tabs)
293-
tabs.append(view.tab)
294379
}
295380
}
296381

382+
/** Inline-edit a tab name; commits via the rename RPC on Enter/blur. */
383+
function startRename(view: SessionView): void {
384+
renamingId = view.info.id
385+
const input = el('input', 'dft-rename')
386+
input.value = displayName(view.info)
387+
input.spellcheck = false
388+
view.tab.replaceChildren(input)
389+
input.focus()
390+
input.select()
391+
392+
let settled = false
393+
const finish = (commit: boolean): void => {
394+
if (settled)
395+
return
396+
settled = true
397+
renamingId = null
398+
if (commit) {
399+
rpc.call('devframes-plugin-terminals:rename', { id: view.info.id, title: input.value.trim() })
400+
.catch(() => {})
401+
}
402+
renderTabs()
403+
}
404+
input.onclick = e => e.stopPropagation()
405+
input.onkeydown = (e) => {
406+
if (e.key === 'Enter') {
407+
e.preventDefault()
408+
finish(true)
409+
}
410+
else if (e.key === 'Escape') {
411+
e.preventDefault()
412+
finish(false)
413+
}
414+
}
415+
input.onblur = () => finish(true)
416+
}
417+
297418
function syncSessions(sessions: TerminalSessionInfo[]): void {
298419
if (disposed)
299420
return
@@ -362,6 +483,7 @@ export async function mountTerminals(
362483
disposed = true
363484
offSessions?.()
364485
offPresets?.()
486+
colorScheme?.removeEventListener('change', onColorScheme)
365487
resizeObserver?.disconnect()
366488
for (const view of views.values())
367489
disposeView(view)

plugins/terminals/src/node/backend.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export interface TerminalProcess {
1616
kill: (signal?: string) => void
1717
onData: (cb: (data: string) => void) => void
1818
onExit: (cb: (exitCode: number) => void) => void
19+
/**
20+
* Current foreground process name of the controlling TTY, when the
21+
* backend can resolve it (PTY only). Polled by the manager.
22+
*/
23+
getProcessName?: () => string | undefined
1924
}
2025

2126
export interface SpawnBackendOptions {
@@ -41,6 +46,8 @@ interface PtyModule {
4146

4247
interface PtyProcess {
4348
pid: number
49+
/** Foreground process title; updated by node-pty as the foreground changes. */
50+
readonly process?: string
4451
onData: (cb: (data: string) => void) => void
4552
onExit: (cb: (e: { exitCode: number, signal?: number }) => void) => void
4653
write: (data: string) => void
@@ -127,6 +134,14 @@ export async function spawnPty(options: SpawnBackendOptions): Promise<TerminalPr
127134
},
128135
onData: cb => proc.onData(cb),
129136
onExit: cb => proc.onExit(e => cb(e.exitCode ?? 0)),
137+
getProcessName: () => {
138+
try {
139+
return proc.process || undefined
140+
}
141+
catch {
142+
return undefined
143+
}
144+
},
130145
}
131146
}
132147

plugins/terminals/src/node/manager.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@ interface ManagedSession {
4040
sink: StreamSink<string>
4141
spawn: ResolvedSpawn
4242
proc?: TerminalProcess
43+
pollTimer?: ReturnType<typeof setInterval>
4344
}
4445

46+
/** How often a PTY session's foreground process name is polled. */
47+
const PROCESS_POLL_INTERVAL = 1000
48+
4549
function defaultShell(): string {
4650
if (process.platform === 'win32')
4751
return process.env.COMSPEC || 'powershell.exe'
@@ -253,17 +257,48 @@ export class TerminalManager {
253257
// Ignore the exit of a process replaced by restart().
254258
if (session.proc !== proc)
255259
return
260+
this.stopProcessPoll(session)
256261
session.info.status = code === 0 ? 'exited' : 'error'
257262
session.info.exitCode = code
258263
session.info.pid = undefined
264+
session.info.processName = undefined
259265
if (!sink.closed)
260266
sink.write(`\r\n\x1B[2m[process exited with code ${code}]\x1B[0m\r\n`)
261267
this.publish()
262268
})
263269

270+
this.startProcessPoll(session)
264271
this.publish()
265272
}
266273

274+
/**
275+
* Poll the PTY foreground process name and reflect changes (e.g. `bash`
276+
* → `vim` → `bash`) into the session info. The interval is `unref`'d so
277+
* it never keeps the process alive on its own.
278+
*/
279+
private startProcessPoll(session: ManagedSession): void {
280+
const read = session.proc?.getProcessName
281+
if (!read)
282+
return
283+
const poll = (): void => {
284+
const name = session.proc?.getProcessName?.()
285+
if (name && name !== session.info.processName) {
286+
session.info.processName = name
287+
this.publish()
288+
}
289+
}
290+
poll()
291+
session.pollTimer = setInterval(poll, PROCESS_POLL_INTERVAL)
292+
session.pollTimer.unref?.()
293+
}
294+
295+
private stopProcessPoll(session: ManagedSession): void {
296+
if (session.pollTimer) {
297+
clearInterval(session.pollTimer)
298+
session.pollTimer = undefined
299+
}
300+
}
301+
267302
write(id: string, data: string): void {
268303
const session = this.sessions.get(id)
269304
if (!session)
@@ -294,13 +329,24 @@ export class TerminalManager {
294329
session.proc?.kill()
295330
}
296331

332+
/** Set a custom display name. Pass an empty string to clear it. */
333+
rename(id: string, title: string): void {
334+
const session = this.sessions.get(id)
335+
if (!session)
336+
throw diagnostics.DP_TERMINALS_0001({ id })
337+
const trimmed = title.trim()
338+
session.info.customTitle = trimmed.length ? trimmed : undefined
339+
this.publish()
340+
}
341+
297342
/** Restart the session's command in place, reusing the same stream id. */
298343
restart(id: string): TerminalSessionInfo {
299344
const session = this.sessions.get(id)
300345
if (!session)
301346
throw diagnostics.DP_TERMINALS_0001({ id })
302347
const previous = session.proc
303348
session.proc = undefined
349+
this.stopProcessPoll(session)
304350
previous?.kill()
305351
if (!session.sink.closed)
306352
session.sink.write('\r\n\x1B[2m[restarting…]\x1B[0m\r\n')
@@ -318,6 +364,7 @@ export class TerminalManager {
318364
throw diagnostics.DP_TERMINALS_0001({ id })
319365
const proc = session.proc
320366
session.proc = undefined
367+
this.stopProcessPoll(session)
321368
proc?.kill()
322369
if (!session.sink.closed)
323370
session.sink.close()
@@ -328,6 +375,7 @@ export class TerminalManager {
328375
/** Tear everything down — used on server shutdown and in tests. */
329376
dispose(): void {
330377
for (const session of this.sessions.values()) {
378+
this.stopProcessPoll(session)
331379
session.proc?.kill()
332380
if (!session.sink.closed)
333381
session.sink.close()

0 commit comments

Comments
 (0)