Skip to content

Commit 9bfb2a4

Browse files
m-mcgowanclaude
andcommitted
feat: Add configurable terminal tab title
Sets the terminal tab title via OSC 1 escape sequence after each status line render. Supports a template with placeholders: {task}, {repo}, {branch}, {model}, {dir}. Segments separated by ' | ' are dropped when all their placeholders resolve to empty, so "{task} | {repo}/{branch}" gracefully falls back to just "{repo}/{branch}" when no task is set. Configuration in ~/.config/ccstatusline/settings.json: terminalTitle: { enabled: true, template: "{task} | {repo}/{branch}" } Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8d8819a commit 9bfb2a4

4 files changed

Lines changed: 83 additions & 0 deletions

File tree

src/ccstatusline.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
renderStatusLine
2929
} from './utils/renderer';
3030
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
31+
import { resolveTerminalTitle } from './utils/terminal-title';
3132
import {
3233
getSkillsFilePath,
3334
getSkillsMetrics
@@ -181,6 +182,16 @@ async function renderMultipleLines(data: StatusJSON) {
181182
}
182183
}
183184

185+
// Emit terminal tab title if configured
186+
if (settings.terminalTitle.enabled) {
187+
const title = resolveTerminalTitle(settings.terminalTitle.template, context);
188+
if (title) {
189+
// OSC 1 sets the terminal tab title; write to stderr to avoid
190+
// interfering with the status line output on stdout
191+
process.stderr.write(`\x1b]1;${title}\x07`);
192+
}
193+
}
194+
184195
// Check if there's an update message to display
185196
if (settings.updatemessage?.message
186197
&& settings.updatemessage.message.trim() !== ''

src/types/Settings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export const SettingsSchema = z.object({
5858
theme: undefined,
5959
autoAlign: false
6060
}),
61+
terminalTitle: z.object({
62+
enabled: z.boolean().default(false),
63+
template: z.string().default('{task} | {repo}/{branch}')
64+
}).default({ enabled: false, template: '{task} | {repo}/{branch}' }),
6165
updatemessage: z.object({
6266
message: z.string().nullable().optional(),
6367
remaining: z.number().nullable().optional()

src/utils/terminal-title.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { readFileSync } from 'fs';
2+
import { basename, join } from 'path';
3+
4+
import type { RenderContext } from '../types/RenderContext';
5+
import { getTaskDir } from '../widgets/TaskObjective';
6+
7+
import { runGit } from './git';
8+
9+
function getTaskFromFile(sessionId: string): string | null {
10+
try {
11+
const content = readFileSync(join(getTaskDir(), `claude-task-${sessionId}`), 'utf8').trim();
12+
if (!content) return null;
13+
try {
14+
const data = JSON.parse(content) as { task?: string };
15+
return data.task ?? null;
16+
} catch {
17+
return content.split('\n')[0] ?? null;
18+
}
19+
} catch {
20+
return null;
21+
}
22+
}
23+
24+
/**
25+
* Resolve a terminal title template string.
26+
*
27+
* Supported placeholders:
28+
* {task} - task objective from the session task file
29+
* {repo} - git repository name
30+
* {branch} - current git branch
31+
* {model} - model display name
32+
* {dir} - current working directory basename
33+
*
34+
* Segments separated by ' | ' are dropped if all their placeholders resolved
35+
* to empty, so "Task: {task} | {repo}/{branch}" gracefully falls back to
36+
* just "{repo}/{branch}" when no task is set.
37+
*/
38+
export function resolveTerminalTitle(template: string, context: RenderContext): string | null {
39+
const sessionId = context.data?.session_id;
40+
const task = sessionId ? getTaskFromFile(sessionId) : null;
41+
const branch = runGit('branch --show-current', context);
42+
const repoPath = runGit('rev-parse --show-toplevel', context);
43+
const repo = repoPath ? basename(repoPath) : null;
44+
const model = typeof context.data?.model === 'string'
45+
? context.data.model
46+
: context.data?.model?.display_name ?? null;
47+
const dir = context.data?.workspace?.current_dir
48+
? basename(context.data.workspace.current_dir)
49+
: null;
50+
51+
const vars: Record<string, string | null> = {
52+
task, repo, branch, model, dir
53+
};
54+
55+
// Split by ' | ' segments, resolve each, drop empty segments
56+
const segments = template.split(' | ').map(segment => {
57+
const resolved = segment.replace(/\{(\w+)\}/g, (_, key: string) => vars[key] ?? '');
58+
// If the segment is empty after resolving (all placeholders were empty), skip it
59+
return resolved.trim();
60+
}).filter(s => s.length > 0);
61+
62+
if (segments.length === 0) return null;
63+
return segments.join(' | ');
64+
}

src/widgets/__tests__/CurrentWorkingDir.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ describe('CurrentWorkingDirWidget', () => {
4848
startCaps: [],
4949
endCaps: [],
5050
autoAlign: false
51+
},
52+
terminalTitle: {
53+
enabled: false,
54+
template: '{task} | {repo}/{branch}'
5155
}
5256
};
5357

0 commit comments

Comments
 (0)