Implement tab_bar_filter

Useful to manage multiple sessions in a single kitty OS Window.
Add some docs to sessions.rst describing this use case.
This commit is contained in:
Kovid Goyal 2025-08-24 12:25:15 +05:30
parent 7bd912c093
commit cbca4d6593
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
10 changed files with 192 additions and 110 deletions

View file

@ -229,6 +229,20 @@ When you run the session file in another kitty instance you will see both
windows re-created, as expected with the correct working directories and
running programs.
Managing multi tab sessions in a single OS Window
----------------------------------------------------
The natural way to organise sessions in kitty is one per :term:`os_window`.
However, if you prefer to manage multiple sessions in a single OS Window, you
can configure the kitty tab bar to only show tabs that belong to the currently
active session. To do so, use :opt:`tab_bar_filter` in :file:`kitty.conf` set::
tab_bar_filter session:~ or session:^$
This will restrict the tab bar to only showing tabs from the currently active
session as well tabs that do not belong to any session. Furthermore, when you
are in a window or tab that does not belong to any session, the tab bar will
show the tabs from the most recent active session, to maintain context.
Keyword reference
---------------------

View file

@ -129,6 +129,7 @@
default_save_as_session_opts,
get_os_window_sizing_data,
goto_session,
most_recent_session,
save_as_session,
)
from .shaders import load_shader_programs
@ -546,6 +547,7 @@ def match_windows(self, match: str, self_window: Optional['Window'] = None, all_
wids = {w.id for w in all_windows}
window_id_limit = max(wids, default=-1) + 1
active_session = self.active_session
prev_active_session = most_recent_session()
def get_matches(location: str, query: str, candidates: set[int]) -> set[int]:
if location == 'id' and query.startswith('-'):
@ -555,7 +557,7 @@ def get_matches(location: str, query: str, candidates: set[int]) -> set[int]:
return set()
if q < 0:
query = str(window_id_limit + q)
return {wid for wid in candidates if self.window_id_map[wid].matches_query(location, query, tab, self_window, active_session)}
return {wid for wid in candidates if self.window_id_map[wid].matches_query(location, query, tab, self_window, active_session, prev_active_session)}
for wid in search(match, (
'id', 'title', 'pid', 'cwd', 'cmdline', 'num', 'env', 'var', 'recent', 'state', 'neighbor', 'session',
@ -574,6 +576,8 @@ def match_tabs(self, match: str, all_tabs: Iterable[Tab] | None = None) -> Itera
tim = {t.id: t for t in all_tabs}
tab_id_limit = max(tim, default=-1) + 1
window_id_limit = max(self.window_id_map, default=-1) + 1
active_session = self.active_session
prev_active_session = most_recent_session()
def get_matches(location: str, query: str, candidates: set[int]) -> set[int]:
if location in ('id', 'window_id') and query.startswith('-'):
@ -584,7 +588,7 @@ def get_matches(location: str, query: str, candidates: set[int]) -> set[int]:
if q < 0:
limit = tab_id_limit if location == 'id' else window_id_limit
query = str(limit + q)
return {wid for wid in candidates if tim[wid].matches_query(location, query, tm)}
return {wid for wid in candidates if tim[wid].matches_query(location, query, tm, active_session, prev_active_session)}
found = False
for tid in search(match, (

View file

@ -1386,6 +1386,18 @@
'''
)
opt('tab_bar_filter', '', long_text='''
A :ref:`search expression <search_syntax>`. Only tabs that match this expression
will be shown in the tab bar. The currently active tab is :italic:`always` shown,
regardless of whether it matches or not. When using this option, the tab bar may
be displayed with less tabs than specified in :opt:`tab_bar_min_tabs`, as evaluating
the filter is expensive and is done only at display time. This is most useful when
using :ref:`sessions <sessions>`. An expression of :code:`session:~ or session:^$`
will show only tabs that belong to the current session or no session. The various
tab navigation actions such as :ac:`goto_tab`, :ac:`next_tab`, :ac:`previous_tab`, etc.
are automatically restricted to work only on matching tabs.
''')
opt('tab_bar_align', 'left',
choices=('left', 'center', 'right'),
long_text='''

View file

@ -1270,6 +1270,9 @@ def tab_bar_background(self, val: str, ans: dict[str, typing.Any]) -> None:
def tab_bar_edge(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['tab_bar_edge'] = tab_bar_edge(val)
def tab_bar_filter(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['tab_bar_filter'] = str(val)
def tab_bar_margin_color(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['tab_bar_margin_color'] = to_color_or_none(val)

View file

@ -432,6 +432,7 @@
'tab_bar_align',
'tab_bar_background',
'tab_bar_edge',
'tab_bar_filter',
'tab_bar_margin_color',
'tab_bar_margin_height',
'tab_bar_margin_width',
@ -604,6 +605,7 @@ class Options:
tab_bar_align: choices_for_tab_bar_align = 'left'
tab_bar_background: kitty.fast_data_types.Color | None = None
tab_bar_edge: int = 8
tab_bar_filter: str = ''
tab_bar_margin_color: kitty.fast_data_types.Color | None = None
tab_bar_margin_height: TabBarMarginHeight = TabBarMarginHeight(outer=0, inner=0)
tab_bar_margin_width: float = 0

View file

@ -114,7 +114,8 @@ def __call__(self, key: str, opt_name: str | None = None, missing: Any = None) -
The field :code:`session` matches windows that were created in the specified session.
Use the expression :code:`^$` to match windows that were not created in a session and
:code:`.` to match the currently active session.
:code:`.` to match the currently active session and :code:`~` to match either the currently
active sesison or the last active session when no session is active.
When using the :code:`env` field to match on environment variables, you can specify only the environment variable name
or a name and value, for example, :code:`env:MY_ENV_VAR=2`.
@ -160,8 +161,9 @@ def __call__(self, key: str, opt_name: str | None = None, missing: Any = None) -
active tab, one the previously active tab and so on.
The field :code:`session` matches tabs that were created in the specified session.
Use the expression :code:`^$` to match tabs that were not created in a session and
:code:`.` to match the currently active session.
Use the expression :code:`^$` to match windows that were not created in a session and
:code:`.` to match the currently active session and :code:`~` to match either the currently
active sesison or the last active session when no session is active.
When using the :code:`env` field to match on environment variables, you can specify only the environment variable name
or a name and value, for example, :code:`env:MY_ENV_VAR=2`. Tabs containing any window with the specified environment

View file

@ -396,6 +396,10 @@ def append_to_session_history(name: str) -> None:
goto_session_history.append(name)
def most_recent_session() -> str:
return goto_session_history[-1] if goto_session_history else ''
def switch_to_session(boss: BossType, session_name: str) -> bool:
w = window_for_session_name(boss, session_name)
if w is not None:

View file

@ -512,6 +512,19 @@ def draw_tab(
return draw_tab
class CellRange(NamedTuple):
start: int
end: int
class TabExtent(NamedTuple):
tab_id: int
cell_range: CellRange
def shifted(self, shift: int) -> 'TabExtent':
return TabExtent(self.tab_id, CellRange(self.cell_range.start + shift, self.cell_range.end + shift))
class TabBar:
def __init__(self, os_window_id: int):
@ -519,7 +532,7 @@ def __init__(self, os_window_id: int):
self.num_tabs = 1
self.data_buffer_size = 0
self.blank_rects: tuple[Border, ...] = ()
self.cell_ranges: list[tuple[int, int]] = []
self.tab_extents: Sequence[TabExtent] = ()
self.laid_out_once = False
self.apply_options()
@ -673,7 +686,7 @@ def update(self, data: Sequence[TabBarData]) -> None:
last_tab = data[-1] if data else None
ed = ExtraData()
def draw_tab(i: int, tab: TabBarData, cell_ranges: list[tuple[int, int]], max_tab_length: int) -> None:
def draw_tab(i: int, tab: TabBarData, cell_ranges: list[TabExtent], max_tab_length: int) -> None:
ed.prev_tab = data[i - 1] if i > 0 else None
ed.next_tab = data[i + 1] if i + 1 < len(data) else None
s.cursor.bg = as_rgb(self.draw_data.tab_bg(t))
@ -682,7 +695,7 @@ def draw_tab(i: int, tab: TabBarData, cell_ranges: list[tuple[int, int]], max_ta
before = s.cursor.x
end = self.draw_func(self.draw_data, s, t, before, max_tab_length, i + 1, t is last_tab, ed)
s.cursor.bg = s.cursor.fg = 0
cell_ranges.append((before, end))
cell_ranges.append(TabExtent(tab_id=tab.tab_id, cell_range=CellRange(before, end)))
if not ed.for_layout and t is not last_tab and s.cursor.x > s.columns - max_tab_lengths[i+1]:
# Stop if there is no space for next tab
s.cursor.x = s.columns - 2
@ -722,36 +735,36 @@ def draw_tab(i: int, tab: TabBarData, cell_ranges: list[tuple[int, int]], max_ta
s.cursor.x = 0
s.erase_in_line(2, False)
cr: list[tuple[int, int]] = []
cr: list[TabExtent] = []
ed.for_layout = False
for i, t in enumerate(data):
try:
draw_tab(i, t, cr, max_tab_lengths[i])
except StopIteration:
break
self.cell_ranges = cr
self.tab_extents = cr
s.erase_in_line(0, False) # Ensure no long titles bleed after the last tab
self.align()
update_tab_bar_edge_colors(self.os_window_id)
def align_with_factor(self, factor: int = 1) -> None:
if not self.cell_ranges:
if not self.tab_extents:
return
end = self.cell_ranges[-1][1]
end = self.tab_extents[-1].cell_range[1]
if end < self.screen.columns - 1:
shift = (self.screen.columns - end) // factor
self.screen.cursor.x = 0
self.screen.insert_characters(shift)
self.cell_ranges = [(s + shift, e + shift) for (s, e) in self.cell_ranges]
self.tab_extents = tuple(te.shifted(shift) for te in self.tab_extents)
def destroy(self) -> None:
self.screen.reset_callbacks()
del self.screen
def tab_at(self, x: int) -> int | None:
def tab_id_at(self, x: int) -> int:
if self.laid_out_once:
x = (x - self.window_geometry.left) // self.cell_width
for i, (a, b) in enumerate(self.cell_ranges):
if a <= x <= b:
return i
return None
for te in self.tab_extents:
if te.cell_range.start <= x <= te.cell_range.end:
return te.tab_id
return 0

View file

@ -64,7 +64,7 @@ class TabMouseEvent(NamedTuple):
modifiers: int
action: int
at: float
tab_idx: int | None
tab_id: int = 0
class TabDict(TypedDict):
@ -926,7 +926,10 @@ def list_windows(self, self_window: Window | None = None, window_filter: Callabl
def list_groups(self) -> list[dict[str, Any]]:
return [g.as_simple_dict() for g in self.windows.groups]
def matches_query(self, field: str, query: str, active_tab_manager: Optional['TabManager'] = None) -> bool:
def matches_query(
self, field: str, query: str, active_tab_manager: Optional['TabManager'] = None,
active_session: str = '', most_recent_session: str = ''
) -> bool:
if field == 'title':
return re.search(query, self.effective_title) is not None
if field == 'id':
@ -962,8 +965,11 @@ def matches_query(self, field: str, query: str, active_tab_manager: Optional['Ta
return active_tab_manager is not None and self.tab_manager_ref() is active_tab_manager and self.os_window_id == last_focused_os_window_id()
return False
if field == 'session':
if query == '.':
return self.created_in_session_name == get_boss().active_session
match query:
case '.':
return self.created_in_session_name == active_session
case '~':
return self.created_in_session_name == active_session or self.created_in_session_name == most_recent_session
return re.search(query, self.created_in_session_name) is not None
return False
@ -1148,9 +1154,38 @@ def set_active_tab(self, tab: Tab, for_keep_focus: Tab | None = None) -> bool:
h.pop()
return True
def filtered_tabs(self, filter_expression: str) -> Iterator[Tab]:
yield from get_boss().match_tabs(filter_expression, all_tabs=self)
@property
def tabs_to_be_shown_in_tab_bar(self) -> Iterable[Tab]:
f = get_options().tab_bar_filter
if f:
at = self.active_tab
m = set(self.filtered_tabs(f))
return (t for t in self if t is at or t in m)
return self.tabs
@property
def tabs_to_be_shown_in_tab_bar_as_sequence(self) -> Sequence[Tab]:
f = get_options().tab_bar_filter
if f:
at = self.active_tab
m = set(self.filtered_tabs(f))
return tuple(t for t in self if t is at or t in m)
return self.tabs
def next_tab(self, delta: int = 1) -> None:
if len(self.tabs) > 1:
self.set_active_tab_idx((self.active_tab_idx + len(self.tabs) + delta) % len(self.tabs))
if (tabs := self.tabs_to_be_shown_in_tab_bar_as_sequence) is self.tabs:
if (num := len(tabs)) > 1:
self.set_active_tab_idx((self.active_tab_idx + num + delta) % num)
else:
num = len(tabs)
at = self.active_tab
if at is not None:
active_idx = tabs.index(at)
new_active_tab = (active_idx + num + delta) % num
self.set_active_tab(tabs[new_active_tab])
def toggle_tab(self, match_expression: str) -> None:
tabs = set(get_boss().match_tabs(match_expression, all_tabs=self))
@ -1165,32 +1200,29 @@ def toggle_tab(self, match_expression: str) -> None:
break
def tab_at_location(self, loc: str) -> Tab | None:
tabs = self.tabs_to_be_shown_in_tab_bar_as_sequence
if loc == 'prev':
if self.active_tab_history:
old_active_tab_id = self.active_tab_history[-1]
for idx, tab in enumerate(self.tabs):
if tab.id == old_active_tab_id:
return tab
return self.tab_for_id(self.active_tab_history[-1])
elif loc in ('left', 'right'):
delta = -1 if loc == 'left' else 1
idx = (len(self.tabs) + self.active_tab_idx + delta) % len(self.tabs)
return self.tabs[idx]
idx = (len(tabs) + self.active_tab_idx + delta) % len(tabs)
return tabs[idx]
return None
def goto_tab(self, tab_num: int) -> None:
if tab_num >= len(self.tabs):
tab_num = max(0, len(self.tabs) - 1)
tabs = self.tabs_to_be_shown_in_tab_bar_as_sequence
if tab_num >= len(tabs):
tab_num = max(0, len(tabs) - 1)
if tab_num >= 0:
self.set_active_tab_idx(tab_num)
self.set_active_tab(tabs[tab_num])
elif self.active_tab_history:
try:
old_active_tab_id = self.active_tab_history[tab_num]
except IndexError:
old_active_tab_id = self.active_tab_history[0]
for idx, tab in enumerate(self.tabs):
if tab.id == old_active_tab_id:
self.set_active_tab_idx(idx)
break
if tab := self.tab_for_id(old_active_tab_id):
self.set_active_tab(tab)
def nth_active_tab(self, n: int = 0) -> Tab | None:
if n <= 0:
@ -1259,24 +1291,11 @@ def skey(tab: Tab) -> int:
@property
def active_tab(self) -> Tab | None:
try:
return self.tabs[self.active_tab_idx] if self.tabs else None
except Exception:
return None
return self.tabs[self.active_tab_idx] if 0 <= self.active_tab_idx < len(self.tabs) else None
@property
def active_window(self) -> Window | None:
t = self.active_tab
if t is not None:
return t.active_window
return None
@property
def number_of_windows(self) -> int:
count = 0
for tab in self:
count += len(tab)
return count
return t.active_window if (t := self.active_tab) else None
def tab_for_id(self, tab_id: int) -> Tab | None:
for t in self.tabs:
@ -1285,9 +1304,11 @@ def tab_for_id(self, tab_id: int) -> Tab | None:
return None
def move_tab(self, delta: int = 1) -> None:
if len(self.tabs) > 1:
tabs = self.tabs_to_be_shown_in_tab_bar_as_sequence
if len(tabs) > 1:
idx = self.active_tab_idx
nidx = (idx + len(self.tabs) + delta) % len(self.tabs)
new_active_tab = tabs[(idx + len(tabs) + delta) % len(tabs)]
nidx = self.tabs.index(new_active_tab)
step = 1 if idx < nidx else -1
for i in range(idx, nidx, step):
self.tabs[i], self.tabs[i + step] = self.tabs[i + step], self.tabs[i]
@ -1304,7 +1325,10 @@ def new_tab(
location: str = 'last',
) -> Tab:
idx = len(self.tabs)
orig_active_tab_idx = self.active_tab_idx
tabs = self.tabs_to_be_shown_in_tab_bar_as_sequence
orig_active_tab_idx = 0
with suppress(ValueError):
orig_active_tab_idx = tabs.index(self.active_tab)
session_name = ''
if cwd_from is not None and (sw := cwd_from.window):
session_name = sw.created_in_session_name
@ -1320,11 +1344,12 @@ def new_tab(
location = 'after'
if location == 'default':
location = 'last'
if len(self.tabs) > 1 and location != 'last':
if len(tabs) > 1 and location != 'last':
if location == 'first':
desired_idx = 0
else:
desired_idx = orig_active_tab_idx + (0 if location == 'before' else 1)
desired_idx = self.tabs.index(tabs[desired_idx])
if idx != desired_idx:
for i in range(idx, desired_idx, -1):
self.tabs[i], self.tabs[i-1] = self.tabs[i-1], self.tabs[i]
@ -1332,64 +1357,63 @@ def new_tab(
idx = desired_idx
self._set_active_tab(idx)
self.mark_tab_bar_dirty()
return self.tabs[idx]
return t
def remove(self, tab: Tab) -> None:
def remove(self, removed_tab: Tab) -> None:
active_tab_before_removal = self.active_tab
active_tab_needs_to_change = active_tab_before_removal is tab
self._remove_tab(tab)
tabs = self.tabs_to_be_shown_in_tab_bar_as_sequence
self._remove_tab(removed_tab)
while True:
try:
self.active_tab_history.remove(tab.id)
self.active_tab_history.remove(removed_tab.id)
except ValueError:
break
def idx_for_id(tab_id: int) -> int:
for idx, qtab in enumerate(self.tabs):
if qtab.id == tab_id:
return idx
return -1
def remove_from_end_of_active_history(idx: int) -> None:
while self.active_tab_history and idx_for_id(self.active_tab_history[-1]) == idx:
def remove_from_end_of_active_history(tab: Tab) -> None:
while self.active_tab_history and self.active_tab_history[-1] == tab.id:
self.active_tab_history.pop()
if active_tab_needs_to_change:
next_active_tab = -1
tss = get_options().tab_switch_strategy
if tss == 'previous':
while self.active_tab_history and next_active_tab < 0:
tab_id = self.active_tab_history.pop()
next_active_tab = idx_for_id(tab_id)
elif tss == 'left':
next_active_tab = max(0, self.active_tab_idx - 1)
remove_from_end_of_active_history(next_active_tab)
elif tss == 'right':
next_active_tab = min(self.active_tab_idx, len(self.tabs) - 1)
remove_from_end_of_active_history(next_active_tab)
elif tss == 'last':
next_active_tab = len(self.tabs) - 1
remove_from_end_of_active_history(next_active_tab)
if next_active_tab < 0:
next_active_tab = max(0, min(self.active_tab_idx, len(self.tabs) - 1))
self._set_active_tab(next_active_tab, store_in_history=False)
elif active_tab_before_removal is not None:
try:
idx = self.tabs.index(active_tab_before_removal)
except Exception:
pass
if active_tab_before_removal is removed_tab:
if len(self.tabs) == 0:
self._active_tab_idx = 0
elif len(self.tabs) == 1:
remove_from_end_of_active_history(self.tabs[0])
self._set_active_tab(0, store_in_history=False)
else:
self._active_tab_idx = idx
next_active_tab: Tab | None = None
match get_options().tab_switch_strategy:
case 'previous':
while self.active_tab_history and next_active_tab is None:
tab_id = self.active_tab_history.pop()
next_active_tab = self.tab_for_id(tab_id)
case 'left':
next_active_tab = tabs[(tabs.index(active_tab_before_removal) - 1 + len(tabs)) % len(tabs)]
remove_from_end_of_active_history(next_active_tab)
case 'right':
next_active_tab = tabs[(tabs.index(active_tab_before_removal) + 1) % len(tabs)]
remove_from_end_of_active_history(next_active_tab)
case 'last':
next_active_tab = tabs[-1]
remove_from_end_of_active_history(next_active_tab)
if next_active_tab not in self.tabs:
next_active_tab = self.tabs[max(0, min(self.active_tab_idx, len(self.tabs) - 1))]
self._set_active_tab(self.tabs.index(next_active_tab), store_in_history=False)
else:
if len(self.tabs):
if active_tab_before_removal is None:
self._set_active_tab(0, store_in_history=False)
else:
self._set_active_tab(self.tabs.index(active_tab_before_removal), store_in_history=False)
else:
self._active_tab_idx = 0
self.mark_tab_bar_dirty()
tab.destroy()
removed_tab.destroy()
@property
def tab_bar_data(self) -> list[TabBarData]:
at = self.active_tab
ans = []
for t in self.tabs:
for t in self.tabs_to_be_shown_in_tab_bar:
title = t.name or t.title or appname
needs_attention = False
has_activity_since_last_focus = False
@ -1409,16 +1433,16 @@ def tab_bar_data(self) -> list[TabBarData]:
return ans
def handle_click_on_tab(self, x: int, button: int, modifiers: int, action: int) -> None:
i = self.tab_bar.tab_at(x)
tab = self.tab_for_id(self.tab_bar.tab_id_at(x))
now = monotonic()
if i is None:
if tab is None:
if button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE and len(self.recent_mouse_events) > 2:
ci = get_click_interval()
prev, prev2 = self.recent_mouse_events[-1], self.recent_mouse_events[-2]
if (
prev.button == button and prev2.button == button and
prev.action == GLFW_PRESS and prev2.action == GLFW_RELEASE and
prev.tab_idx is None and prev2.tab_idx is None and
prev.tab_id == 0 and prev2.tab_id == 0 and
now - prev.at <= ci and now - prev2.at <= 2 * ci
): # double click
self.new_tab()
@ -1426,13 +1450,12 @@ def handle_click_on_tab(self, x: int, button: int, modifiers: int, action: int)
return
else:
if action == GLFW_PRESS and button == GLFW_MOUSE_BUTTON_LEFT:
self.set_active_tab_idx(i)
self.set_active_tab(tab)
elif button == GLFW_MOUSE_BUTTON_MIDDLE and action == GLFW_RELEASE and self.recent_mouse_events:
p = self.recent_mouse_events[-1]
if p.button == button and p.action == GLFW_PRESS and p.tab_idx == i:
tab = self.tabs[i]
if p.button == button and p.action == GLFW_PRESS and p.tab_id == tab.id:
get_boss().close_tab(tab)
self.recent_mouse_events.append(TabMouseEvent(button, modifiers, action, now, i))
self.recent_mouse_events.append(TabMouseEvent(button, modifiers, action, now, tab.id if tab else 0))
if len(self.recent_mouse_events) > 5:
self.recent_mouse_events.popleft()

View file

@ -839,7 +839,7 @@ def at_prompt(self) -> bool:
def has_running_program(self) -> bool:
return not self.at_prompt
def matches(self, field: str, pat: MatchPatternType, active_session: str) -> bool:
def matches(self, field: str, pat: MatchPatternType, active_session: str, most_recent_session: str) -> bool:
if isinstance(pat, tuple):
if field == 'env':
return key_val_matcher(self.child.environ.items(), *pat)
@ -861,14 +861,19 @@ def matches(self, field: str, pat: MatchPatternType, active_session: str) -> boo
return True
return False
if field == 'session':
if pat.pattern == '.':
return self.created_in_session_name == active_session
match pat.pattern:
case '.':
return self.created_in_session_name == active_session
case '~':
return self.created_in_session_name == active_session or self.created_in_session_name == most_recent_session
return pat.search(self.created_in_session_name) is not None
return False
def matches_query(
self, field: str, query: str, active_tab: TabType | None = None,
self_window: Optional['Window'] = None, active_session: str = ''
self_window: Optional['Window'] = None, active_session: str = '',
most_recent_session: str = '',
) -> bool:
if field in ('num', 'recent'):
if active_tab is not None:
@ -918,7 +923,7 @@ def matches_query(
return gid is not None and t.windows.active_window_in_group_id(gid) is self
pat = compile_match_query(query, field not in ('env', 'var'))
return self.matches(field, pat, active_session)
return self.matches(field, pat, active_session, most_recent_session)
def set_visible_in_layout(self, val: bool) -> None:
val = bool(val)