When a program running in kitty reports progress of a task display it as a percentage in the tab title

This commit is contained in:
Kovid Goyal
2025-01-05 08:48:46 +05:30
parent 1080b148d3
commit 9b9b313e77
7 changed files with 147 additions and 9 deletions

View File

@@ -84,10 +84,12 @@ consumption to do the same tasks.
Detailed list of changes
-------------------------------------
0.38.2 [future]
0.39.0 [future]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- diff kitten: Automatically use dark/light color scheme based on the color scheme of the parent terminal. Can be controlled via the new :opt:`kitten-diff.color_scheme` option. Note that this is a **behavior change** by default. (:iss:`8170`)
- diff kitten: Automatically use dark/light color scheme based on the color scheme of the parent terminal. Can be controlled via the new :opt:`kitten-diff.color_scheme` option. Note that this is a **default behavior change** (:iss:`8170`)
- When a program running in kitty reports progress of a task display it as a percentage in the tab title. Controlled by the :opt:`tab_title_template` option
- When mapping a custom kitten allow using shell escaping for the kitten path (:iss:`8178`)

View File

@@ -1400,7 +1400,7 @@ A value of zero means that no limit is applied.
'''
)
opt('tab_title_template', '"{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title}"',
opt('tab_title_template', '"{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{tab.last_focused_progress_percent}{title}"',
option_type='tab_title_template',
long_text='''
A template to render the tab title. The default just renders the title with
@@ -1431,6 +1431,12 @@ use :code:`{sup.index}`. All data available is:
The maximum title length available.
:code:`keyboard_mode`
The name of the current :ref:`keyboard mode <modal_mappings>` or the empty string if no keyboard mode is active.
:code:`tab.last_focused_progress_percent`
If a command running in a window reports the progress for a task, show this progress as a percentage
from the most recently focused window in the tab. Empty string if no progress is reported.
:code:`tab.progress_percent`
If a command running in a window reports the progress for a task, show this progress as a percentage
from all windows in the tab, averaged. Empty string is no progress is reported.
Note that formatting is done by Python's string formatting machinery, so you can
use, for instance, :code:`{layout_name[:2].upper()}` to show only the first two

View File

@@ -607,7 +607,7 @@ class Options:
tab_separator: str = ''
tab_switch_strategy: choices_for_tab_switch_strategy = 'previous'
tab_title_max_length: int = 0
tab_title_template: str = '{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title}'
tab_title_template: str = '{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{tab.last_focused_progress_percent}{title}'
term: str = 'xterm-kitty'
terminfo_type: choices_for_terminfo_type = 'path'
text_composition_strategy: str = 'platform'
@@ -1039,6 +1039,7 @@ defaults.mouse_map = [
MouseMapping(button=1, mods=5, definition='mouse_show_command_output'),
]
nullable_colors = frozenset({
'cursor'
'cursor_text_color'

55
kitty/progress.py Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python
# License: GPLv3 Copyright: 2025, Kovid Goyal <kovid at kovidgoyal.net>
from enum import Enum
from .fast_data_types import monotonic
from .utils import log_error
class ProgressState(Enum):
unset = 0
set = 1
error = 2
indeterminate = 3
paused = 4
class Progress:
state: ProgressState = ProgressState.unset
percent: int = 0
last_update_at: float = 0.
clear_timeout: float = 60.0
finished_clear_timeout: float = 5.0
def update(self, st: int, percent: int = -1) -> None:
self.last_update_at = monotonic()
if st == 0:
self.state = ProgressState.unset
self.percent = 0
elif st == 1:
self.state = ProgressState.set
self.percent = max(0, min(percent, 100))
elif st == 2:
self.state = ProgressState.error
self.percent = 0
elif st == 3:
self.state = ProgressState.indeterminate
self.percent = 0
elif st == 4:
self.state = ProgressState.paused
if percent > -1:
self.percent = max(0, min(percent, 100))
else:
log_error(f'Unknown OSC 9;4 state: {st}')
def clear_progress(self) -> bool:
time_since_last_update = monotonic() - self.last_update_at
threshold = self.finished_clear_timeout if self.percent == 100 and self.state is ProgressState.set else self.clear_timeout
if time_since_last_update >= threshold:
self.state = ProgressState.unset
self.percent = 0
return True
return False

View File

@@ -29,6 +29,7 @@ from .fast_data_types import (
viewport_for_window,
wcswidth,
)
from .progress import ProgressState
from .rgb import alpha_blend, color_as_sgr, color_from_int, to_color
from .types import WindowGeometry, run_once
from .typing import EdgeLiteral, PowerlineStyle
@@ -48,6 +49,9 @@ class TabBarData(NamedTuple):
active_bg: Optional[int]
inactive_fg: Optional[int]
inactive_bg: Optional[int]
num_of_windows_with_progress: int
total_progress: int
last_focused_window_with_progress_id: int
class DrawData(NamedTuple):
@@ -219,6 +223,36 @@ class TabAccessor:
tab = get_boss().tab_for_id(self.tab_id)
return os.path.basename((tab.get_exe_of_active_window(oldest=True) if tab else '') or '')
@property
def last_focused_progress_percent(self) -> str:
tab = get_boss().tab_for_id(self.tab_id)
if not tab or not tab.last_focused_window_with_progress_id:
return ''
w = get_boss().window_id_map.get(tab.last_focused_window_with_progress_id)
if w is None:
return ''
if w.progress.state is ProgressState.error:
return '\u26a0\ufe0f '
if w.progress.state is ProgressState.unset:
return ''
if w.progress.state is ProgressState.indeterminate:
return '🔄 '
p = f'{w.progress.percent}% '
if w.progress.state is ProgressState.paused:
return f'{p}'
return p
@property
def progress_percent(self) -> str:
tab = get_boss().tab_for_id(self.tab_id)
if not tab or not tab.last_focused_window_with_progress_id:
return ''
if tab.num_of_windows_with_progress <= 1:
return self.last_focused_progress_percent
p = int(tab.total_progress / tab.num_of_windows_with_progress)
return f'{p}% '
safe_builtins = {
'max': max, 'min': min, 'str': str, 'repr': repr, 'abs': abs, 'len': len, 'chr': chr, 'ord': ord, 're': re,

View File

@@ -52,6 +52,7 @@ from .fast_data_types import (
)
from .layout.base import Layout
from .layout.interface import create_layout_object_for, evict_cached_layouts
from .progress import ProgressState
from .tab_bar import TabBar, TabBarData
from .types import ac
from .typing import EdgeLiteral, SessionTab, SessionType, TypedDict
@@ -125,6 +126,9 @@ class Tab: # {{{
inactive_fg: Optional[int] = None
inactive_bg: Optional[int] = None
confirm_close_window_id: int = 0
num_of_windows_with_progress: int = 0
total_progress: int = 0
last_focused_window_with_progress_id: int = 0
def __init__(
self,
@@ -163,6 +167,23 @@ class Tab: # {{{
self._set_current_layout(l0)
self.startup(session_tab)
def update_progress(self) -> None:
self.num_of_windows_with_progress = 0
self.total_progress = 0
self.last_focused_window_with_progress_id = 0
focused_at = 0.
for window in self:
p = window.progress
if p.state is ProgressState.unset:
continue
if p.state in (ProgressState.set, ProgressState.paused):
self.total_progress += p.percent
self.num_of_windows_with_progress += 1
if window.last_focused_at > focused_at or (not window.last_focused_at and window.id > self.last_focused_window_with_progress_id):
focused_at = window.last_focused_at
self.last_focused_window_with_progress_id = window.id
self.mark_tab_bar_dirty()
def has_single_window_visible(self) -> bool:
if self.current_layout.only_active_window_visible:
return True
@@ -1245,7 +1266,8 @@ class TabManager: # {{{
title, t is at, needs_attention, t.id,
len(t), t.num_window_groups, t.current_layout.name or '',
has_activity_since_last_focus, t.active_fg, t.active_bg,
t.inactive_fg, t.inactive_bg
t.inactive_fg, t.inactive_bg, t.num_of_windows_with_progress,
t.total_progress, t.last_focused_window_with_progress_id,
))
return ans

View File

@@ -93,6 +93,7 @@ from .rgb import to_color
from .terminfo import get_capabilities
from .types import MouseEvent, OverlayType, WindowGeometry, ac, run_once
from .typing import BossType, ChildType, EdgeLiteral, TabType, TypedDict
from .progress import Progress
from .utils import (
color_as_int,
docs_url,
@@ -627,6 +628,7 @@ class Window:
self.keys_redirected_till_ready_from: int = 0
self.last_focused_at = 0.
self.is_focused: bool = False
self.progress = Progress()
self.last_resized_at = 0.
self.started_at = monotonic()
self.created_at = time_ns()
@@ -1107,14 +1109,30 @@ class Window:
return # unknown OSC 777
raw_data = raw_data[len('notify;'):]
if osc_code == 9 and raw_data.startswith('4;'):
# This is probably the Windows Terminal "progress reporting" conflicting
# This is probably the ConEmu "progress reporting" conflicting
# implementation which sadly some thoughtless people have
# implemented in unix CLI programs. So ignore it rather than
# spamming the user with continuous notifications. See for example:
# https://github.com/kovidgoyal/kitty/issues/8011
# implemented in unix CLI programs.
# See for example: https://github.com/kovidgoyal/kitty/issues/8011
try:
parts = tuple(map(int, raw_data.split(';')))[1:]
except Exception:
log_error(f'Ignoring malmormed OSC 9;4 progress report: {raw_data!r}')
return
self.progress.update(*parts[:2])
if (tab := self.tabref()) is not None:
tab.update_progress()
self.clear_progress_if_needed()
return
get_boss().notification_manager.handle_notification_cmd(self.id, osc_code, raw_data)
def clear_progress_if_needed(self, timer_id: Optional[int] = None) -> None:
# Clear stuck or completed progress
if self.progress.clear_progress():
if (tab := self.tabref()) is not None:
tab.update_progress()
else:
add_timer(self.clear_progress_if_needed, 1.0, False)
def on_mouse_event(self, event: dict[str, Any]) -> bool:
event['mods'] = event.get('mods', 0) & mod_mask
ev = MouseEvent(**event)