1+ import type { ITheme } from '@xterm/xterm'
12import type { DevframeRpcClient } from 'devframe/client'
23import type { StreamReader } from 'devframe/utils/streaming-channel'
34import type { TerminalPreset , TerminalSessionInfo , TerminalsSharedState } from '../types'
@@ -33,48 +34,87 @@ interface SessionView {
3334
3435const 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+
78118let stylesInjected = false
79119function 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 )
0 commit comments