feat(config): display repo name or active nvim file in kitty tab titles

This commit is contained in:
Prad Nukala committed 2026-08-31 10:48:53 -04:00
1 parent 941d455cf5
commit e76f070caa
3 files changed
+69 -195

No files matched your search

+3 -3
View File
@@ -93,9 +93,9 @@ tab_powerline_style angled
tab_bar_align left tab_bar_align left
tab_bar_edge top tab_bar_edge top
# Titles come from ~/.config/kitty/tab_bar.py (`{custom}`): tab index, cwd, git # Titles come from ~/.config/kitty/tab_bar.py (`{custom}`): the repo the tab's
# branch and per-tab change counts. Active tabs use the same text; the powerline # cwd belongs to, or the file nvim is editing when nvim is the active process.
# colors already say which tab is focused. # Active and inactive tabs share the text; the powerline colors mark the focus.
tab_title_template "{custom}" tab_title_template "{custom}"
+66 -192
View File
@@ -1,215 +1,89 @@
#!/usr/bin/env python # ── tab_bar.py · kitty tab titles: repo dir, or the file nvim is editing ──
# ── tab_bar.py · kitty tab titles: directory + git state ──
# #
# kitty loads this file from the config dir and exposes `draw_title` as the # kitty loads this file from the config dir and exposes `draw_title` as the
# `{custom}` field of tab_title_template (see kitty/tab_bar.py: # `{custom}` field of tab_title_template (kitty/tab_bar.py:
# load_custom_draw_title). kitty keeps drawing the powerline chrome itself; only # load_custom_draw_title). kitty still draws the powerline chrome; only the text
# the text inside a tab comes from here. # inside a tab comes from here.
# #
# A title reads: 2 chezmoi main ⇡1 +2 ~1 ?3 # A title is one of:
# ^ ^ ^ ^ ^ ^ # 2 chezmoi <- repo the tab's cwd belongs to (dir name if no repo)
# | dir branch staged / unstaged / untracked files # 3 astrocore.lua <- nvim is the active process: the file it is editing
# tab index
# #
# git is *never* run on the render path. `draw_title` only ever reads a cache # No subprocesses, no timers: the repo root is found by walking up for `.git`,
# and notes which working dirs are on screen; a 1s repeating kitty timer (main # and the edited file comes from the window title nvim already sets
# thread, same as every other kitty callback) starts `git status` processes, # (~/.config/nvim/lua/plugins/astrocore.lua, custom_title autocmd:
# reaps the finished ones, and marks the tab bar dirty when a value changed. # "<file> | <org/repo>" or "<file> | Nvim", and "<org/repo>" / "Nvim" with no
# Output goes to a temp file, not a pipe, so a repo with thousands of dirty # file). kitty repaints the tab bar whenever that title changes, so the tab
# files can never fill a 64K pipe buffer and wedge the child. # tracks buffer switches for free.
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess
import tempfile
import time
from typing import Any from typing import Any
from kitty.boss import get_boss # Nerd Font glyphs; VictorMono Nerd Font Mono covers both.
from kitty.fast_data_types import add_timer
# Nerd Font glyphs (VictorMono Nerd Font Mono covers all of these).
FOLDER = '\uf07b' # nf-fa-folder FOLDER = '\uf07b' # nf-fa-folder
BRANCH = '\ue0a0' # nf-pl-branch NVIM = '\ue62b' # nf-custom-neovim
AHEAD = '\u21e1'
BEHIND = '\u21e3'
# A cached git summary is reused for this long; a working dir that stopped being EDITORS = frozenset({'nvim', 'vim', 'nvim-qt'})
# displayed is dropped after PRUNE_AFTER so closed tabs stop being polled. MAX_LEN = 28
TTL = 4.0
PRUNE_AFTER = 30.0
POLL_INTERVAL = 1.0
MAX_CONCURRENT = 4
KILL_AFTER = 15.0
MAX_DIR_LEN = 24
# wd -> git summary spec: (branch, ahead, behind, staged, unstaged, untracked, # wd -> repo root (or ''), memoized: a working dir keeps its answer for the life
# conflicts) or None when wd is not a work tree. # of the kitty instance, so `git init` in an already-open dir needs a config
_git: dict[str, tuple[str, int, int, int, int, int, int] | None] = {} # reload (ctrl+shift+alt+, in the herdr host, ctrl+shift+f5 here) to show up.
_fresh_until: dict[str, float] = {} _repo: dict[str, str] = {}
_wanted: dict[str, float] = {}
_running: dict[str, tuple[subprocess.Popen[bytes], Any, float]] = {}
_timer = 0
def draw_title(data: dict[str, Any]) -> str: def draw_title(data: dict[str, Any]) -> str:
global _timer tab = data['tab']
if not _timer: label = ''
_timer = add_timer(_tick, POLL_INTERVAL, True) if tab.active_exe in EDITORS:
wd = data['tab'].active_wd label = _nvim_file(data['title'])
fmt = data['fmt'] if label:
parts = [str(data['index'])] return f'{data["index"]} {NVIM} {_elide(label)}'
wd = tab.active_wd
if wd: if wd:
_wanted[wd] = _now() label = os.path.basename(_repo_root(wd) or wd.rstrip('/'))
parts.append(f'{FOLDER} {_dir_label(wd)}') if wd == os.path.expanduser('~'):
summary = _git.get(wd) label = '~'
if summary is not None: return f'{data["index"]} {FOLDER} {_elide(label or data["title"])}'
parts.append(_render_git(summary, fmt))
else:
parts.append(data['title'])
return ' '.join(parts)
def _dir_label(wd: str) -> str: def _repo_root(wd: str) -> str:
home = os.path.expanduser('~') root = _repo.get(wd)
if wd == home: if root is None:
return '~' root = ''
name = os.path.basename(wd.rstrip('/')) or wd path = wd
if len(name) > MAX_DIR_LEN: while True:
name = name[: MAX_DIR_LEN - 1] + '…' if os.path.exists(os.path.join(path, '.git')):
return name root = path
break
parent = os.path.dirname(path)
if parent == path:
break
path = parent
_repo[wd] = root
return root
def _render_git( def _nvim_file(title: str) -> str:
summary: tuple[str, int, int, int, int, int, int], fmt: Any """The file name out of an nvim window title, '' when it is editing none.
) -> str:
branch, ahead, behind, staged, unstaged, untracked, conflicts = summary
# color2/3/4/1 are palette slots, so the active Omarchy theme picks the
# actual greens and reds; fg.tab restores the tab's own foreground so the
# powerline chrome keeps drawing in its own colors.
tab = fmt.fg.tab
out = [f'{BRANCH} {branch}' if branch else BRANCH]
for count, mark, color in (
(ahead, AHEAD, fmt.fg.color6),
(behind, BEHIND, fmt.fg.color6),
(staged, '+', fmt.fg.color2),
(unstaged, '~', fmt.fg.color3),
(untracked, '?', fmt.fg.color4),
(conflicts, '!', fmt.fg.color1),
):
if count:
out.append(f'{color}{mark}{count}{tab}')
return ' '.join(out)
Handles this config's "<file> | <org/repo>" / "<file> | Nvim" and nvim's own
def _now() -> float: default "%t - Nvim" / "<file> (<dir>) - NVIM".
return time.monotonic()
def _tick(timer_id: int | None) -> None:
now = _now()
changed = _reap(now)
for wd, last_wanted in tuple(_wanted.items()):
if now - last_wanted > PRUNE_AFTER:
del _wanted[wd]
_git.pop(wd, None)
_fresh_until.pop(wd, None)
continue
if len(_running) >= MAX_CONCURRENT:
break
if wd in _running or _fresh_until.get(wd, 0.0) > now:
continue
_start(wd, now)
if changed:
for tm in get_boss().os_window_map.values():
tm.mark_tab_bar_dirty()
def _start(wd: str, now: float) -> None:
try:
out = tempfile.TemporaryFile()
proc = subprocess.Popen(
(
'git',
'--no-optional-locks',
'status',
'--porcelain=v2',
'--branch',
'--untracked-files=normal',
),
cwd=wd,
stdout=out,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
# start_new_session, not preexec_fn: kitty is multi-threaded, and
# only the former is implemented without running Python between
# fork and exec.
start_new_session=True,
)
except OSError:
# No git, or wd vanished: remember "not a repo" so we stop retrying
# until the TTL lapses.
_git[wd] = None
_fresh_until[wd] = now + TTL
return
_running[wd] = (proc, out, now)
def _reap(now: float) -> bool:
changed = False
for wd, (proc, out, started) in tuple(_running.items()):
rc = proc.poll()
if rc is None:
if now - started > KILL_AFTER:
proc.kill()
continue
del _running[wd]
summary = None
if rc == 0:
try:
out.seek(0)
summary = _parse(out.read().decode('utf-8', 'replace'))
except OSError:
summary = None
out.close()
if _git.get(wd, ...) != summary:
changed = True
_git[wd] = summary
_fresh_until[wd] = now + TTL
return changed
def _parse(text: str) -> tuple[str, int, int, int, int, int, int]:
"""git status --porcelain=v2 --branch -> title counts.
Header lines carry the branch and the ahead/behind pair; `1`/`2` records
carry a two-char XY status (X = index, Y = work tree), `?` is untracked and
`u` is unmerged. Renames (`2`) put the old path after a tab on the same
line, so line-splitting stays correct.
""" """
head = oid = '' text = title.strip()
ahead = behind = staged = unstaged = untracked = conflicts = 0 for suffix in (' - NVIM', ' - Nvim', ' - nvim'):
for line in text.splitlines(): if text.endswith(suffix):
if line.startswith('# branch.oid '): text = text[: -len(suffix)]
oid = line[13:20] text = text.split(' | ', 1)[0].strip()
elif line.startswith('# branch.head '): if text.endswith(')') and ' (' in text:
head = line[14:] text = text.split(' (', 1)[0].strip()
elif line.startswith('# branch.ab '): # Left with a bare repo slug ("org/repo") or the placeholder: no file open.
for token in line[12:].split(): if not text or '/' in text or text.lower() == 'nvim':
if token.startswith('+'): return ''
ahead = int(token[1:] or 0) return text
elif token.startswith('-'):
behind = int(token[1:] or 0)
elif line[:2] in ('1 ', '2 '): def _elide(label: str) -> str:
xy = line[2:4] return label if len(label) <= MAX_LEN else label[: MAX_LEN - 1] + '…'
if xy[0] != '.':
staged += 1
if xy[1] != '.':
unstaged += 1
elif line.startswith('? '):
untracked += 1
elif line.startswith('u '):
conflicts += 1
branch = oid if head in ('', '(detached)') else head
return branch, ahead, behind, staged, unstaged, untracked, conflicts