From 136080bf24fd0fbf4f2cee7e3df964e3d0cdd8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Thu, 18 Jun 2026 14:53:18 +0800 Subject: [PATCH] feat(cli): display session name in terminal title - Show session title in terminal window instead of fixed 'Qwen - ' - Clear terminal title on exit so it reverts to shell default (empty OSC sequence without 80-char padding) - Add try/catch around exit handler to suppress EPIPE when stdout is already closed - Extend multiplexer detection to include Zellij (ZELLIJ) and dvtm (DVTM) - Chain title callbacks so AppContainer preserves Session's existing ACP notification callback instead of overwriting it - Revert terminal title to static fallback when showStatusInTitle is toggled off at runtime - Reuse sanitizeForOsc for BiDi/RTL directional override protection - Update tests for new behavior --- packages/cli/src/config/settingsSchema.ts | 6 +- packages/cli/src/gemini.tsx | 29 +- packages/cli/src/ui/AppContainer.test.tsx | 334 ++++++++++++++---- packages/cli/src/ui/AppContainer.tsx | 77 ++-- packages/cli/src/utils/windowTitle.test.ts | 185 +++++++++- packages/cli/src/utils/windowTitle.ts | 89 ++++- .../core/src/services/chatRecordingService.ts | 11 + .../schemas/settings.schema.json | 4 +- 8 files changed, 606 insertions(+), 129 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d540595b46d..bf3256e1119 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -698,10 +698,10 @@ const SETTINGS_SCHEMA = { label: 'Show Status in Title', category: 'UI', requiresRestart: false, - default: false, + default: true, description: - 'Show Qwen Code status and thoughts in the terminal window title', - showInDialog: false, + 'Show Qwen Code session name and status in the terminal window title', + showInDialog: true, }, hideTips: { type: 'boolean', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 33431266e2d..235ada084cb 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -100,7 +100,7 @@ import { getCliVersion } from './utils/version.js'; import { initializeWarningHandler } from './utils/warningHandler.js'; import { writeStderrLine } from './utils/stdioHelpers.js'; import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js'; -import { computeWindowTitle } from './utils/windowTitle.js'; +import { computeWindowTitle, writeTerminalTitle } from './utils/windowTitle.js'; import { startEarlyInputCapture, stopAndGetCapturedInput, @@ -246,7 +246,7 @@ export async function startInteractiveUI( initializationResult: InitializationResult, ) { const version = await getCliVersion(); - setWindowTitle(basename(workspaceRoot), settings); + setWindowTitle(settings, basename(workspaceRoot)); // Write a small runtime.json sidecar next to the chat log so external // tools (terminal multiplexers, IDE integrations, status daemons) can @@ -1211,13 +1211,22 @@ export function createNonInteractivePromptId(sessionId: string): string { return `${sessionId}########0`; } -function setWindowTitle(title: string, settings: LoadedSettings) { - if (!settings.merged.ui?.hideWindowTitle) { - const windowTitle = computeWindowTitle(title); - process.stdout.write(`\x1b]2;${windowTitle}\x07`); - - process.on('exit', () => { - process.stdout.write(`\x1b]2;\x07`); - }); +function setWindowTitle(settings: LoadedSettings, folderName?: string) { + if ( + settings.merged.ui?.hideWindowTitle || + settings.merged.ui?.showStatusInTitle === false + ) { + return; } + const windowTitle = computeWindowTitle(folderName); + writeTerminalTitle((value) => process.stdout.write(value), windowTitle); + + process.on('exit', () => { + try { + writeTerminalTitle((value) => process.stdout.write(value), ''); + } catch { + // Best-effort: clearing the title during exit must not produce + // a visible error (e.g. EPIPE if stdout is already closed). + } + }); } diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index d4f8a71f45e..82daa8d69a1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -4,6 +4,24 @@ * SPDX-License-Identifier: Apache-2.0 */ +const { writeTerminalTitleSpy } = vi.hoisted(() => ({ + writeTerminalTitleSpy: vi.fn(), +})); + +vi.mock('../utils/windowTitle.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + writeTerminalTitle: ( + ...args: Parameters + ) => { + writeTerminalTitleSpy(...args); + return actual.writeTerminalTitle(...args); + }, + }; +}); + import { describe, it, @@ -22,6 +40,10 @@ import { isRenderModeToggleKey, mergeStartupWarnings, } from './AppContainer.js'; +import { + formatSessionWindowTitle, + writeTerminalTitle, +} from '../utils/windowTitle.js'; import ansiEscapes from 'ansi-escapes'; import { type Config, @@ -192,15 +214,6 @@ describe('AppContainer State Management', () => { // Initialize mock stdout for terminal title tests mockStdout = { write: vi.fn() }; - // Mock computeWindowTitle function to centralize title logic testing - vi.mock('../utils/windowTitle.js', async () => ({ - computeWindowTitle: vi.fn( - (folderName: string) => - // Default behavior: return "Gemini - {folderName}" unless CLI_TITLE is set - process.env['CLI_TITLE'] || `Gemini - ${folderName}`, - ), - })); - capturedUIState = null!; capturedUIActions = null!; capturedRenderMode = 'render'; @@ -2169,9 +2182,27 @@ describe('AppContainer State Management', () => { }); describe('Terminal Title Update Feature', () => { + /** + * Helper to build the expected padded OSC title escape sequence. + * writeTerminalTitle pads the title to 80 characters with trailing + * spaces and writes both \x1b]0; (icon+title) and \x1b]2; (title). + */ + const titleEscape = (title: string) => { + const padded = title.padEnd(80, ' '); + return `\x1b]0;${padded}\x07\x1b]2;${padded}\x07`; + }; + beforeEach(() => { - // Reset mock stdout for each test + // Reset mock stdout for each test. The title useEffect now uses + // process.stdout.write directly (to avoid Ink proxy corruption of + // OSC escape sequences), so we spy on that. mockStdout = { write: vi.fn() }; + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it('should not update terminal title when showStatusInTitle is false', () => { @@ -2199,9 +2230,9 @@ describe('AppContainer State Management', () => { ); // Assert: Check that no title-related writes occurred - const titleWrites = mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), - ); + const titleWrites = ( + process.stdout.write as ReturnType + ).mock.calls.filter((call: string[]) => call[0].includes('\x1b]2;')); expect(titleWrites).toHaveLength(0); unmount(); }); @@ -2231,14 +2262,14 @@ describe('AppContainer State Management', () => { ); // Assert: Check that no title-related writes occurred - const titleWrites = mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), - ); + const titleWrites = ( + process.stdout.write as ReturnType + ).mock.calls.filter((call: string[]) => call[0].includes('\x1b]2;')); expect(titleWrites).toHaveLength(0); unmount(); }); - it('should update terminal title with thought subject when in active state', () => { + it('should keep default terminal title when active without a session name', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = { ...mockSettings, @@ -2274,14 +2305,12 @@ describe('AppContainer State Management', () => { />, ); - // Assert: Check that title was updated with thought subject - const titleWrites = mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), - ); + // Assert: Check that title uses the default (not thought subject) + const titleWrites = ( + process.stdout.write as ReturnType + ).mock.calls.filter((call: string[]) => call[0].includes('\x1b]2;')); expect(titleWrites).toHaveLength(1); - expect(titleWrites[0][0]).toBe( - `\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, - ); + expect(titleWrites[0][0]).toBe(titleEscape('Qwen - workspace')); unmount(); }); @@ -2320,18 +2349,16 @@ describe('AppContainer State Management', () => { />, ); - // Assert: Check that title was updated with default Idle text - const titleWrites = mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), - ); + // Assert: Check that title was updated with default text + const titleWrites = ( + process.stdout.write as ReturnType + ).mock.calls.filter((call: string[]) => call[0].includes('\x1b]2;')); expect(titleWrites).toHaveLength(1); - expect(titleWrites[0][0]).toBe( - `\x1b]2;${'Gemini - workspace'.padEnd(80, ' ')}\x07`, - ); + expect(titleWrites[0][0]).toBe(titleEscape('Qwen - workspace')); unmount(); }); - it('should update terminal title when in WaitingForConfirmation state with thought subject', () => { + it('should keep default terminal title when waiting for confirmation without a session name', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = { ...mockSettings, @@ -2367,18 +2394,16 @@ describe('AppContainer State Management', () => { />, ); - // Assert: Check that title was updated with confirmation text - const titleWrites = mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), - ); + // Assert: Check that confirmation status does not replace the session title + const titleWrites = ( + process.stdout.write as ReturnType + ).mock.calls.filter((call: string[]) => call[0].includes('\x1b]2;')); expect(titleWrites).toHaveLength(1); - expect(titleWrites[0][0]).toBe( - `\x1b]2;${thoughtSubject.padEnd(80, ' ')}\x07`, - ); + expect(titleWrites[0][0]).toBe(titleEscape('Qwen - workspace')); unmount(); }); - it('should pad title to exactly 80 characters', () => { + it('should pad the terminal title to 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = { ...mockSettings, @@ -2415,21 +2440,20 @@ describe('AppContainer State Management', () => { ); // Assert: Check that title is padded to exactly 80 characters - const titleWrites = mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), - ); + const titleWrites = ( + process.stdout.write as ReturnType + ).mock.calls.filter((call: string[]) => call[0].includes('\x1b]2;')); expect(titleWrites).toHaveLength(1); const calledWith = titleWrites[0][0]; - const expectedTitle = shortTitle.padEnd(80, ' '); - - expect(calledWith).toContain(shortTitle); + expect(calledWith).toContain('Qwen - workspace'); + expect(calledWith).toContain('\x1b]0;'); expect(calledWith).toContain('\x1b]2;'); expect(calledWith).toContain('\x07'); - expect(calledWith).toBe('\x1b]2;' + expectedTitle + '\x07'); + expect(calledWith).toBe(titleEscape('Qwen - workspace')); unmount(); }); - it('should use correct ANSI escape code format', () => { + it('should use correct ANSI escape code format with padding', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = { ...mockSettings, @@ -2466,16 +2490,15 @@ describe('AppContainer State Management', () => { ); // Assert: Check that the correct ANSI escape sequence is used - const titleWrites = mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), - ); + const titleWrites = ( + process.stdout.write as ReturnType + ).mock.calls.filter((call: string[]) => call[0].includes('\x1b]2;')); expect(titleWrites).toHaveLength(1); - const expectedEscapeSequence = `\x1b]2;${title.padEnd(80, ' ')}\x07`; - expect(titleWrites[0][0]).toBe(expectedEscapeSequence); + expect(titleWrites[0][0]).toBe(titleEscape('Qwen - workspace')); unmount(); }); - it('should use CLI_TITLE environment variable when set', () => { + it('should format terminal title from CLI_TITLE when set', () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = { ...mockSettings, @@ -2490,7 +2513,7 @@ describe('AppContainer State Management', () => { } as unknown as LoadedSettings; // Mock CLI_TITLE environment variable - vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); + vi.stubEnv('CLI_TITLE', 'Custom Title'); // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue({ @@ -2513,15 +2536,204 @@ describe('AppContainer State Management', () => { />, ); - // Assert: Check that title was updated with CLI_TITLE value - const titleWrites = mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]2;'), - ); + // Assert: formatSessionWindowTitle falls back to computeWindowTitle() + // which respects CLI_TITLE, so the custom title appears padded to 80 chars. + const titleWrites = ( + process.stdout.write as ReturnType + ).mock.calls.filter((call: string[]) => call[0].includes('\x1b]2;')); expect(titleWrites).toHaveLength(1); - expect(titleWrites[0][0]).toBe( - `\x1b]2;${'Custom Gemini Title'.padEnd(80, ' ')}\x07`, + expect(titleWrites[0][0]).toBe(titleEscape('Custom Title')); + unmount(); + }); + + it('should register for recorded session titles and format them in the terminal title', async () => { + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + let titleRecordedCallback: ((customTitle: string) => void) | undefined; + let registeredTitleRecordedCallback: + | ((customTitle: string) => void) + | undefined; + const setTitleRecordedCallback = vi.fn( + (callback: ((customTitle: string) => void) | undefined) => { + titleRecordedCallback = callback; + if (callback) { + registeredTitleRecordedCallback = callback; + } + }, + ); + const getTitleRecordedCallback = vi.fn(() => titleRecordedCallback); + vi.spyOn(mockConfig, 'getChatRecordingService').mockReturnValue({ + setTitleRecordedCallback, + getTitleRecordedCallback, + } as unknown as NonNullable< + ReturnType + >); + + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + }); + + const { unmount } = render( + , + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(registeredTitleRecordedCallback).toBeDefined(); + + // Invoke the callback to exercise the full chain: + // recording service fires callback → setSessionName('Fix terminal title') + // → React re-render → title useEffect calls writeTerminalTitle + // + // Note: React 19's effect batching in the ink-testing-library + // environment prevents asserting the writeTerminalTitle call + // inline (effects are not flushed inside act()). The downstream + // title write is verified by the other tests that render + // AppContainer with different settings and assert the output via + // process.stdout.write. + expect(registeredTitleRecordedCallback).toStrictEqual( + expect.any(Function), ); + await act(async () => { + registeredTitleRecordedCallback!('Fix terminal title'); + }); + // The initial render wrote the default title; after the callback + // the next writeTerminalTitle call (when effects flush) should + // carry the session name. We validate the logic standalone: + expect(formatSessionWindowTitle('Fix terminal title')).toBe( + 'Fix terminal title', + ); + // When null, falls back to computeWindowTitle() which returns + // 'Qwen - qwen' when CLI_TITLE is not set. + expect(formatSessionWindowTitle(null)).toBe('Qwen - qwen'); + // When null with a folder name, adds the Qwen prefix. + expect(formatSessionWindowTitle(null, 'my-project')).toBe( + 'Qwen - my-project', + ); + // Session names with control characters are sanitized at entry point. + expect(formatSessionWindowTitle('Bad\x07Title')).toBe('BadTitle'); unmount(); + expect(titleRecordedCallback).toBeUndefined(); + }); + + it('should chain with existing titleRecordedCallback from Session (ACP notifications)', async () => { + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + const existingCallback = vi.fn(); + let titleRecordedCallback: + | ((customTitle: string, source: string) => void) + | undefined; + const setTitleRecordedCallback = vi.fn( + ( + callback: ((customTitle: string, source: string) => void) | undefined, + ) => { + titleRecordedCallback = callback; + }, + ); + // Simulate Session having already registered an ACP callback + const getTitleRecordedCallback = vi.fn(() => existingCallback); + vi.spyOn(mockConfig, 'getChatRecordingService').mockReturnValue({ + setTitleRecordedCallback, + getTitleRecordedCallback, + } as unknown as NonNullable< + ReturnType + >); + + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + }); + + const { unmount } = render( + , + ); + + await act(async () => { + await Promise.resolve(); + }); + + // The chained callback should exist + expect(titleRecordedCallback).toBeDefined(); + + // Invoke the chained callback — it should call both the existing + // ACP callback AND the new setSessionName setter + await act(async () => { + titleRecordedCallback!('Test title', 'rename'); + }); + + // The existing ACP callback was called (preserved by chaining) + expect(existingCallback).toHaveBeenCalledWith('Test title', 'rename'); + + unmount(); + // After unmount, the callback should be restored to the original + expect(titleRecordedCallback).toBe(existingCallback); + }); + + it('should revert to static title when showStatusInTitle toggles from true to false', () => { + // The revert logic in the useEffect calls formatSessionWindowTitle(null, folderName) + // when showStatusInTitle changes from true to false. This test verifies the + // formatting function produces the correct static fallback. + const folderName = 'my-project'; + + // When sessionName is null (revert case), should use computeWindowTitle fallback + const staticTitle = formatSessionWindowTitle(null, folderName); + expect(staticTitle).toBe('Qwen - my-project'); + + // When CLI_TITLE is set, it should use that instead + vi.stubEnv('CLI_TITLE', 'Custom Title'); + const staticTitleWithEnv = formatSessionWindowTitle(null, folderName); + expect(staticTitleWithEnv).toBe('Custom Title'); + vi.unstubAllEnvs(); + + // Verify the escape sequence format for the static title + const writeSpy = vi.fn(); + writeTerminalTitle(writeSpy, staticTitle); + const padded = staticTitle.padEnd(80, ' '); + expect(writeSpy).toHaveBeenCalledWith( + expect.stringContaining(`\x1b]2;${padded}\x07`), + ); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5a75ff6f287..e9428a2bfb6 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -133,7 +133,10 @@ import { useStdin, useStdout } from 'ink'; import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; import { basename } from 'node:path'; -import { computeWindowTitle } from '../utils/windowTitle.js'; +import { + formatSessionWindowTitle, + writeTerminalTitle, +} from '../utils/windowTitle.js'; import { clearScreen } from '../utils/stdioHelpers.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; @@ -487,9 +490,6 @@ export const AppContainer = (props: AppContainerProps) => { // Layout measurements const mainControlsRef = useRef(null); - const originalTitleRef = useRef( - computeWindowTitle(basename(config.getTargetDir())), - ); const lastTitleRef = useRef(null); const [startupWarnings, setStartupWarnings] = useState( () => props.startupWarnings || [], @@ -1036,6 +1036,23 @@ export const AppContainer = (props: AppContainerProps) => { // Session name state (set via /rename, restored on /resume) const [sessionName, setSessionName] = useState(null); + useEffect(() => { + const chatRecordingService = config.getChatRecordingService(); + if (!chatRecordingService?.setTitleRecordedCallback) return; + + // Chain with existing callback (e.g., Session's ACP notification) + const existingCallback = chatRecordingService.getTitleRecordedCallback(); + chatRecordingService.setTitleRecordedCallback((customTitle, source) => { + existingCallback?.(customTitle, source); + setSessionName(customTitle); + }); + + return () => { + // Restore original callback on unmount + chatRecordingService.setTitleRecordedCallback(existingCallback); + }; + }, [config]); + const { isResumeDialogOpen, resumeMatchedSessions, @@ -3199,40 +3216,44 @@ export const AppContainer = (props: AppContainerProps) => { useKeypress(handleGlobalKeypress, { isActive: true }); - // Update terminal title with Qwen Code status and thoughts + // Update terminal title with the session name, or a fallback derived + // from CLI_TITLE, the project folder, or the app default. + // showStatusInTitle gates whether dynamic title updates happen at all; + // it is kept for backward compatibility and future status-flag support. useEffect(() => { - // Respect both showStatusInTitle and hideWindowTitle settings - if ( - !settings.merged.ui?.showStatusInTitle || - settings.merged.ui?.hideWindowTitle - ) + if (settings.merged.ui?.hideWindowTitle) { return; + } - let title; - if (streamingState === StreamingState.Idle) { - title = originalTitleRef.current; - } else { - const statusText = thought?.subject - ?.replace(/[\r\n]+/g, ' ') - .substring(0, 80); - title = statusText || originalTitleRef.current; + if (settings.merged.ui?.showStatusInTitle === false) { + if (lastTitleRef.current !== null) { + lastTitleRef.current = null; + const folderName = basename(config.getTargetDir()); + writeTerminalTitle( + (value) => process.stdout.write(value), + formatSessionWindowTitle(null, folderName), + ); + } + return; } - // Pad the title to a fixed width to prevent taskbar icon resizing. - const paddedTitle = title.padEnd(80, ' '); + const folderName = basename(config.getTargetDir()); + const title = formatSessionWindowTitle(sessionName, folderName); // Only update the title if it's different from the last value we set - if (lastTitleRef.current !== paddedTitle) { - lastTitleRef.current = paddedTitle; - stdout.write(`\x1b]2;${paddedTitle}\x07`); + if (lastTitleRef.current !== title) { + lastTitleRef.current = title; + // Use process.stdout.write directly rather than Ink's proxied stdout + // to avoid corruption of OSC escape sequences (see writeRaw comment at + // line ~448 — Ink v6.2.3 proxies can mangle binary escape sequences). + writeTerminalTitle((value) => process.stdout.write(value), title); } - // Note: We don't need to reset the window title on exit because Qwen Code is already doing that elsewhere + // Exit cleanup is handled by setWindowTitle() in gemini.tsx → process.on('exit') }, [ - streamingState, - thought, - settings.merged.ui?.showStatusInTitle, + sessionName, settings.merged.ui?.hideWindowTitle, - stdout, + settings.merged.ui?.showStatusInTitle, + config, ]); // Drain queued messages when idle. `queueDrainNonce` re-fires the effect diff --git a/packages/cli/src/utils/windowTitle.test.ts b/packages/cli/src/utils/windowTitle.test.ts index 61b3e695935..abe8904f0a9 100644 --- a/packages/cli/src/utils/windowTitle.test.ts +++ b/packages/cli/src/utils/windowTitle.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { computeWindowTitle } from './windowTitle.js'; +import { + computeWindowTitle, + writeTerminalTitle, + formatSessionWindowTitle, +} from './windowTitle.js'; describe('computeWindowTitle', () => { let originalEnv: NodeJS.ProcessEnv; @@ -20,40 +24,191 @@ describe('computeWindowTitle', () => { }); it('should use default Qwen title when CLI_TITLE is not set', () => { + const result = computeWindowTitle(); + expect(result).toBe('Qwen - qwen'); + }); + + it('should use CLI_TITLE environment variable when set', () => { + vi.stubEnv('CLI_TITLE', 'Custom Title'); + const result = computeWindowTitle(); + expect(result).toBe('Custom Title'); + }); + + it('should use Qwen prefix with folder name when CLI_TITLE is not set', () => { const result = computeWindowTitle('my-project'); expect(result).toBe('Qwen - my-project'); }); - it('should use CLI_TITLE environment variable when set', () => { + it('should prefer CLI_TITLE over folder name', () => { vi.stubEnv('CLI_TITLE', 'Custom Title'); const result = computeWindowTitle('my-project'); expect(result).toBe('Custom Title'); }); - it('should remove control characters from title', () => { + it('should remove C0 control characters from title', () => { vi.stubEnv('CLI_TITLE', 'Title\x1b[31m with \x07 control chars'); - const result = computeWindowTitle('my-project'); + const result = computeWindowTitle(); // The \x1b[31m (ANSI escape sequence) and \x07 (bell character) should be removed expect(result).toBe('Title[31m with control chars'); }); - it('should handle folder names with control characters', () => { - const result = computeWindowTitle('project\x07name'); - expect(result).toBe('Qwen - projectname'); + it('should remove C1 control characters from title', () => { + vi.stubEnv('CLI_TITLE', 'Title\x9C with \x90 C1\x9F control'); + const result = computeWindowTitle(); + expect(result).toBe('Title with C1 control'); }); - it('should handle empty folder name', () => { + it('should fall back to default when folderName is empty string', () => { const result = computeWindowTitle(''); - expect(result).toBe('Qwen - '); + expect(result).toBe('Qwen - qwen'); + }); +}); + +describe('writeTerminalTitle', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should write both common terminal title sequences with 80-char padding', () => { + // Stub multiplexer env vars to ensure non-multiplexer path is taken + vi.stubEnv('TMUX', undefined); + vi.stubEnv('STY', undefined); + vi.stubEnv('ZELLIJ', undefined); + vi.stubEnv('DVTM', undefined); + const write = vi.fn(); + + writeTerminalTitle(write, 'Fix terminal title'); + + const padded = 'Fix terminal title'.padEnd(80, ' '); + expect(write).toHaveBeenCalledWith( + `\x1b]0;${padded}\x07\x1b]2;${padded}\x07`, + ); + }); + + it('should pad short titles to 80 characters', () => { + vi.stubEnv('TMUX', undefined); + vi.stubEnv('STY', undefined); + vi.stubEnv('ZELLIJ', undefined); + vi.stubEnv('DVTM', undefined); + const write = vi.fn(); + + writeTerminalTitle(write, 'qwen'); + + const padded = 'qwen'.padEnd(80, ' '); + expect(write).toHaveBeenCalledWith( + `\x1b]0;${padded}\x07\x1b]2;${padded}\x07`, + ); + }); + + it('should only write OSC 2 inside tmux', () => { + vi.stubEnv('TMUX', '/tmp/tmux-0/default'); + const write = vi.fn(); + + writeTerminalTitle(write, 'test'); + + expect(write).toHaveBeenCalledWith(`\x1b]2;test\x07`); + }); + + it('should only write OSC 2 inside screen', () => { + vi.stubEnv('STY', '12345.pts-0.host'); + const write = vi.fn(); + + writeTerminalTitle(write, 'test'); + + expect(write).toHaveBeenCalledWith(`\x1b]2;test\x07`); + }); + + it('should only write OSC 2 inside Zellij', () => { + vi.stubEnv('ZELLIJ', '1'); + const write = vi.fn(); + + writeTerminalTitle(write, 'test'); + + expect(write).toHaveBeenCalledWith(`\x1b]2;test\x07`); + }); + + it('should only write OSC 2 inside dvtm', () => { + vi.stubEnv('DVTM', '1'); + const write = vi.fn(); + + writeTerminalTitle(write, 'test'); + + expect(write).toHaveBeenCalledWith(`\x1b]2;test\x07`); + }); + + it('should truncate titles longer than 80 characters', () => { + vi.stubEnv('TMUX', undefined); + vi.stubEnv('STY', undefined); + vi.stubEnv('ZELLIJ', undefined); + vi.stubEnv('DVTM', undefined); + const write = vi.fn(); + const longTitle = 'A'.repeat(120); + + writeTerminalTitle(write, longTitle); + + const expected = 'A'.repeat(80); + expect(write).toHaveBeenCalledWith( + `\x1b]0;${expected}\x07\x1b]2;${expected}\x07`, + ); + }); + + it('should write empty OSC sequences without padding for empty title', () => { + vi.stubEnv('TMUX', undefined); + vi.stubEnv('STY', undefined); + vi.stubEnv('ZELLIJ', undefined); + vi.stubEnv('DVTM', undefined); + const write = vi.fn(); + + writeTerminalTitle(write, ''); + + expect(write).toHaveBeenCalledWith('\x1b]0;\x07\x1b]2;\x07'); + }); + + it('should write empty OSC 2 sequence inside tmux for empty title', () => { + vi.stubEnv('TMUX', '/tmp/tmux-0/default'); + const write = vi.fn(); + + writeTerminalTitle(write, ''); + + expect(write).toHaveBeenCalledWith('\x1b]2;\x07'); + }); +}); + +describe('formatSessionWindowTitle', () => { + beforeEach(() => { + vi.stubEnv('CLI_TITLE', undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should return session name when set', () => { + expect(formatSessionWindowTitle('Fix terminal title')).toBe( + 'Fix terminal title', + ); + }); + + it('should fall back to computeWindowTitle when sessionName is null', () => { + expect(formatSessionWindowTitle(null, 'my-project')).toBe( + 'Qwen - my-project', + ); + }); + + it('should prefer CLI_TITLE over folder name when sessionName is null', () => { + vi.stubEnv('CLI_TITLE', 'Custom Title'); + expect(formatSessionWindowTitle(null, 'my-project')).toBe('Custom Title'); }); - it('should handle folder names with spaces', () => { - const result = computeWindowTitle('my project'); - expect(result).toBe('Qwen - my project'); + it('should sanitize control characters from session name', () => { + expect(formatSessionWindowTitle('Bad\x07Title')).toBe('BadTitle'); }); - it('should handle folder names with special characters', () => { - const result = computeWindowTitle('project-name_v1.0'); - expect(result).toBe('Qwen - project-name_v1.0'); + it('should use default title when sessionName is null and no folder', () => { + expect(formatSessionWindowTitle(null)).toBe('Qwen - qwen'); }); }); diff --git a/packages/cli/src/utils/windowTitle.ts b/packages/cli/src/utils/windowTitle.ts index eab5eedd62c..110cddbcfac 100644 --- a/packages/cli/src/utils/windowTitle.ts +++ b/packages/cli/src/utils/windowTitle.ts @@ -4,19 +4,88 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { sanitizeForOsc } from '../ui/utils/osc8.js'; + +export const DEFAULT_WINDOW_TITLE = 'qwen'; + +const MULTIPLEXER_ENV_KEYS = ['TMUX', 'STY', 'ZELLIJ', 'DVTM'] as const; + +/** Strip control characters and BiDi/line-separator controls. */ +export function sanitizeWindowTitle(title: string): string { + return sanitizeForOsc(title); +} + /** * Computes the window title for the Qwen Code application. * - * @param folderName - The name of the current folder/workspace to display in the title - * @returns The computed window title, either from CLI_TITLE environment variable or the default Gemini title + * Priority chain: + * 1. CLI_TITLE environment variable (if set) + * 2. folderName — typically the basename of the workspace directory + * 3. DEFAULT_WINDOW_TITLE ('qwen') + * + * @param folderName - Optional workspace folder name for project identification. + * @returns The computed window title. */ -export function computeWindowTitle(folderName: string): string { - const title = process.env['CLI_TITLE'] || `Qwen - ${folderName}`; - - // Remove control characters that could cause issues in terminal titles - return title.replace( - // eslint-disable-next-line no-control-regex - /[\x00-\x1F\x7F]/g, - '', +export function computeWindowTitle(folderName?: string): string { + return sanitizeWindowTitle( + process.env['CLI_TITLE'] || `Qwen - ${folderName || DEFAULT_WINDOW_TITLE}`, ); } + +/** + * Writes the terminal window title escape sequences. + * + * Pads the title to 80 characters to prevent taskbar / dock icon resizing + * when the title length changes between updates. + * + * On Windows, also sets `process.title` so the title appears in Task Manager. + * + * In terminal multiplexers (tmux, screen), only OSC 2 (window title) is + * written to avoid cluttering the multiplexer's window list with padded + * titles. Outside multiplexers, both OSC 0 (icon name + window title) + * and OSC 2 are written for full terminal integration. + */ +export function writeTerminalTitle( + write: (value: string) => void, + title: string, +): void { + const clean = sanitizeWindowTitle(title); + if (process.platform === 'win32') { + process.title = clean; + } + const inMultiplexer = MULTIPLEXER_ENV_KEYS.some((k) => !!process.env[k]); + if (clean.length === 0) { + if (inMultiplexer) { + write('\x1b]2;\x07'); + } else { + write('\x1b]0;\x07\x1b]2;\x07'); + } + return; + } + if (inMultiplexer) { + write(`\x1b]2;${clean}\x07`); + } else { + const padded = clean.substring(0, 80).padEnd(80, ' '); + write(`\x1b]0;${padded}\x07\x1b]2;${padded}\x07`); + } +} + +/** + * Formats the terminal window title based on session name and fallback. + * + * Priority: + * 1. sessionName — from /rename, auto-title, or --resume + * 2. computeWindowTitle(folderName) — CLI_TITLE, project folder, or default + * + * @param sessionName - Current session name, or null if not set. + * @param folderName - Optional workspace folder name for the fallback chain. + * @returns The formatted title string with control characters removed. + */ +export function formatSessionWindowTitle( + sessionName: string | null, + folderName?: string, +): string { + return sessionName + ? sanitizeWindowTitle(sessionName) + : computeWindowTitle(folderName); +} diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index c62cbade884..67d49bc0a3a 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -1263,6 +1263,17 @@ export class ChatRecordingService { this.titleRecordedCallback = callback; } + /** + * Returns the currently registered title-recorded callback. + * Used to chain callbacks (e.g., when a UI component needs to observe + * title changes without replacing an existing ACP notification callback). + */ + getTitleRecordedCallback(): + | ((customTitle: string, titleSource: TitleSource) => void) + | undefined { + return this.titleRecordedCallback; + } + /** * Records a custom title for the session. * Appended as a system record so it persists with the session data. diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 5b0146025fd..3ab7974bcee 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -210,9 +210,9 @@ "default": false }, "showStatusInTitle": { - "description": "Show Qwen Code status and thoughts in the terminal window title", + "description": "Show Qwen Code session name and status in the terminal window title", "type": "boolean", - "default": false + "default": true }, "hideTips": { "description": "Hide helpful tips in the UI",