Skip to content

Commit fd2b560

Browse files
committed
Set terminal title per user input
1 parent 2fc41fb commit fd2b560

File tree

3 files changed

+114
-0
lines changed

3 files changed

+114
-0
lines changed

cli/src/chat.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { computeInputLayoutMetrics } from './utils/text-layout'
7373
import { reportActivity } from './utils/activity-tracker'
7474
import { trackEvent } from './utils/analytics'
7575
import { logger } from './utils/logger'
76+
import { setTerminalTitle } from './utils/terminal-title'
7677

7778
import type { CommandResult } from './commands/command-registry'
7879
import type { MatchedSlashCommand } from './hooks/use-suggestion-engine'
@@ -808,6 +809,10 @@ export const Chat = ({
808809
const handleSubmit = useCallback(async () => {
809810
// Report activity for ad rotation
810811
reportActivity()
812+
// Update terminal title with truncated user input
813+
if (inputValue.trim()) {
814+
setTerminalTitle(inputValue)
815+
}
811816
const result = await onSubmitPrompt(inputValue, agentMode)
812817
handleCommandResult(result)
813818
}, [onSubmitPrompt, inputValue, agentMode, handleCommandResult])

cli/src/utils/renderer-cleanup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { CliRenderer } from '@opentui/core'
22

3+
import { resetTerminalTitle } from './terminal-title'
4+
35
let renderer: CliRenderer | null = null
46
let handlersInstalled = false
57
let terminalStateReset = false
@@ -39,6 +41,8 @@ function resetTerminalState(): void {
3941
terminalStateReset = true
4042

4143
try {
44+
// Reset terminal title to default
45+
resetTerminalTitle()
4246
// Write directly to stdout - this is synchronous and will complete
4347
// before the process exits, ensuring the terminal is reset
4448
process.stdout.write(TERMINAL_RESET_SEQUENCES)

cli/src/utils/terminal-title.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Terminal title utilities using OSC (Operating System Command) escape sequences.
3+
*
4+
* OSC sequence format for setting title:
5+
* - `\x1b]0;${title}\x07` - Sets both window title and icon name
6+
* - `\x1b` is ESC, `]0;` starts the title command, `\x07` (BEL) ends it
7+
*
8+
* We write directly to /dev/tty to bypass OpenTUI's stdout capture,
9+
* similar to how clipboard.ts handles OSC52 sequences.
10+
*/
11+
12+
import { closeSync, constants, openSync, writeSync } from 'fs'
13+
14+
const MAX_TITLE_LENGTH = 60
15+
const TITLE_PREFIX = 'Codebuff: '
16+
const OSC_TERMINATOR = '\x07' // BEL
17+
18+
function isInTmux(): boolean {
19+
return Boolean(process.env.TMUX)
20+
}
21+
22+
function isInScreen(): boolean {
23+
if (process.env.STY) return true
24+
const term = process.env.TERM ?? ''
25+
return term.startsWith('screen') && !isInTmux()
26+
}
27+
28+
/**
29+
* Build the OSC title sequence with tmux/screen passthrough if needed
30+
*/
31+
function buildTitleSequence(title: string): string {
32+
const osc = `\x1b]0;${title}${OSC_TERMINATOR}`
33+
34+
// tmux passthrough: wrap in DCS and double ESC characters
35+
if (isInTmux()) {
36+
const escaped = osc.replace(/\x1b/g, '\x1b\x1b')
37+
return `\x1bPtmux;${escaped}\x1b\\`
38+
}
39+
40+
// GNU screen passthrough: wrap in DCS
41+
if (isInScreen()) {
42+
return `\x1bP${osc}\x1b\\`
43+
}
44+
45+
return osc
46+
}
47+
48+
/**
49+
* Write an escape sequence directly to the controlling terminal.
50+
* This bypasses OpenTUI's stdout capture by writing to /dev/tty directly.
51+
*/
52+
function writeToTty(sequence: string): boolean {
53+
const ttyPath = process.platform === 'win32' ? 'CON' : '/dev/tty'
54+
55+
let fd: number | null = null
56+
try {
57+
fd = openSync(ttyPath, constants.O_WRONLY)
58+
writeSync(fd, sequence)
59+
return true
60+
} catch {
61+
return false
62+
} finally {
63+
if (fd !== null) {
64+
try {
65+
closeSync(fd)
66+
} catch {
67+
// Ignore close errors
68+
}
69+
}
70+
}
71+
}
72+
73+
/**
74+
* Set the terminal window title.
75+
* Works on most modern terminal emulators, including through tmux and screen.
76+
*
77+
* @param title - The title to set (will be truncated if too long)
78+
*/
79+
export function setTerminalTitle(title: string): void {
80+
// Sanitize: remove control characters and newlines
81+
const sanitized = title.replace(/[\x00-\x1f\x7f]/g, ' ').trim()
82+
if (!sanitized) return
83+
84+
// Truncate to reasonable length
85+
const maxInputLength = MAX_TITLE_LENGTH - TITLE_PREFIX.length
86+
const truncated =
87+
sanitized.length > maxInputLength
88+
? sanitized.slice(0, maxInputLength - 1) + '…'
89+
: sanitized
90+
91+
const fullTitle = `${TITLE_PREFIX}${truncated}`
92+
const sequence = buildTitleSequence(fullTitle)
93+
94+
writeToTty(sequence)
95+
}
96+
97+
/**
98+
* Reset the terminal title to the default.
99+
* Call this when the CLI exits to restore the terminal to a clean state.
100+
*/
101+
export function resetTerminalTitle(): void {
102+
// Empty title resets to terminal's default behavior
103+
const sequence = buildTitleSequence('')
104+
writeToTty(sequence)
105+
}

0 commit comments

Comments
 (0)