mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-08 22:28:24 +02:00
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:
@@ -84,10 +84,12 @@ consumption to do the same tasks.
|
|||||||
Detailed list of changes
|
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`)
|
- When mapping a custom kitten allow using shell escaping for the kitten path (:iss:`8178`)
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
option_type='tab_title_template',
|
||||||
long_text='''
|
long_text='''
|
||||||
A template to render the tab title. The default just renders the title with
|
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.
|
The maximum title length available.
|
||||||
:code:`keyboard_mode`
|
:code:`keyboard_mode`
|
||||||
The name of the current :ref:`keyboard mode <modal_mappings>` or the empty string if no keyboard mode is active.
|
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
|
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
|
use, for instance, :code:`{layout_name[:2].upper()}` to show only the first two
|
||||||
|
|||||||
3
kitty/options/types.py
generated
3
kitty/options/types.py
generated
@@ -607,7 +607,7 @@ class Options:
|
|||||||
tab_separator: str = ' ┇'
|
tab_separator: str = ' ┇'
|
||||||
tab_switch_strategy: choices_for_tab_switch_strategy = 'previous'
|
tab_switch_strategy: choices_for_tab_switch_strategy = 'previous'
|
||||||
tab_title_max_length: int = 0
|
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'
|
term: str = 'xterm-kitty'
|
||||||
terminfo_type: choices_for_terminfo_type = 'path'
|
terminfo_type: choices_for_terminfo_type = 'path'
|
||||||
text_composition_strategy: str = 'platform'
|
text_composition_strategy: str = 'platform'
|
||||||
@@ -1039,6 +1039,7 @@ defaults.mouse_map = [
|
|||||||
MouseMapping(button=1, mods=5, definition='mouse_show_command_output'),
|
MouseMapping(button=1, mods=5, definition='mouse_show_command_output'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
nullable_colors = frozenset({
|
nullable_colors = frozenset({
|
||||||
'cursor'
|
'cursor'
|
||||||
'cursor_text_color'
|
'cursor_text_color'
|
||||||
|
|||||||
55
kitty/progress.py
Normal file
55
kitty/progress.py
Normal 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
|
||||||
@@ -29,6 +29,7 @@ from .fast_data_types import (
|
|||||||
viewport_for_window,
|
viewport_for_window,
|
||||||
wcswidth,
|
wcswidth,
|
||||||
)
|
)
|
||||||
|
from .progress import ProgressState
|
||||||
from .rgb import alpha_blend, color_as_sgr, color_from_int, to_color
|
from .rgb import alpha_blend, color_as_sgr, color_from_int, to_color
|
||||||
from .types import WindowGeometry, run_once
|
from .types import WindowGeometry, run_once
|
||||||
from .typing import EdgeLiteral, PowerlineStyle
|
from .typing import EdgeLiteral, PowerlineStyle
|
||||||
@@ -48,6 +49,9 @@ class TabBarData(NamedTuple):
|
|||||||
active_bg: Optional[int]
|
active_bg: Optional[int]
|
||||||
inactive_fg: Optional[int]
|
inactive_fg: Optional[int]
|
||||||
inactive_bg: 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):
|
class DrawData(NamedTuple):
|
||||||
@@ -219,6 +223,36 @@ class TabAccessor:
|
|||||||
tab = get_boss().tab_for_id(self.tab_id)
|
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 '')
|
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 = {
|
safe_builtins = {
|
||||||
'max': max, 'min': min, 'str': str, 'repr': repr, 'abs': abs, 'len': len, 'chr': chr, 'ord': ord, 're': re,
|
'max': max, 'min': min, 'str': str, 'repr': repr, 'abs': abs, 'len': len, 'chr': chr, 'ord': ord, 're': re,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ from .fast_data_types import (
|
|||||||
)
|
)
|
||||||
from .layout.base import Layout
|
from .layout.base import Layout
|
||||||
from .layout.interface import create_layout_object_for, evict_cached_layouts
|
from .layout.interface import create_layout_object_for, evict_cached_layouts
|
||||||
|
from .progress import ProgressState
|
||||||
from .tab_bar import TabBar, TabBarData
|
from .tab_bar import TabBar, TabBarData
|
||||||
from .types import ac
|
from .types import ac
|
||||||
from .typing import EdgeLiteral, SessionTab, SessionType, TypedDict
|
from .typing import EdgeLiteral, SessionTab, SessionType, TypedDict
|
||||||
@@ -125,6 +126,9 @@ class Tab: # {{{
|
|||||||
inactive_fg: Optional[int] = None
|
inactive_fg: Optional[int] = None
|
||||||
inactive_bg: Optional[int] = None
|
inactive_bg: Optional[int] = None
|
||||||
confirm_close_window_id: int = 0
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -163,6 +167,23 @@ class Tab: # {{{
|
|||||||
self._set_current_layout(l0)
|
self._set_current_layout(l0)
|
||||||
self.startup(session_tab)
|
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:
|
def has_single_window_visible(self) -> bool:
|
||||||
if self.current_layout.only_active_window_visible:
|
if self.current_layout.only_active_window_visible:
|
||||||
return True
|
return True
|
||||||
@@ -1245,7 +1266,8 @@ class TabManager: # {{{
|
|||||||
title, t is at, needs_attention, t.id,
|
title, t is at, needs_attention, t.id,
|
||||||
len(t), t.num_window_groups, t.current_layout.name or '',
|
len(t), t.num_window_groups, t.current_layout.name or '',
|
||||||
has_activity_since_last_focus, t.active_fg, t.active_bg,
|
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
|
return ans
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ from .rgb import to_color
|
|||||||
from .terminfo import get_capabilities
|
from .terminfo import get_capabilities
|
||||||
from .types import MouseEvent, OverlayType, WindowGeometry, ac, run_once
|
from .types import MouseEvent, OverlayType, WindowGeometry, ac, run_once
|
||||||
from .typing import BossType, ChildType, EdgeLiteral, TabType, TypedDict
|
from .typing import BossType, ChildType, EdgeLiteral, TabType, TypedDict
|
||||||
|
from .progress import Progress
|
||||||
from .utils import (
|
from .utils import (
|
||||||
color_as_int,
|
color_as_int,
|
||||||
docs_url,
|
docs_url,
|
||||||
@@ -627,6 +628,7 @@ class Window:
|
|||||||
self.keys_redirected_till_ready_from: int = 0
|
self.keys_redirected_till_ready_from: int = 0
|
||||||
self.last_focused_at = 0.
|
self.last_focused_at = 0.
|
||||||
self.is_focused: bool = False
|
self.is_focused: bool = False
|
||||||
|
self.progress = Progress()
|
||||||
self.last_resized_at = 0.
|
self.last_resized_at = 0.
|
||||||
self.started_at = monotonic()
|
self.started_at = monotonic()
|
||||||
self.created_at = time_ns()
|
self.created_at = time_ns()
|
||||||
@@ -1107,14 +1109,30 @@ class Window:
|
|||||||
return # unknown OSC 777
|
return # unknown OSC 777
|
||||||
raw_data = raw_data[len('notify;'):]
|
raw_data = raw_data[len('notify;'):]
|
||||||
if osc_code == 9 and raw_data.startswith('4;'):
|
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
|
# implementation which sadly some thoughtless people have
|
||||||
# implemented in unix CLI programs. So ignore it rather than
|
# implemented in unix CLI programs.
|
||||||
# spamming the user with continuous notifications. See for example:
|
# See for example: https://github.com/kovidgoyal/kitty/issues/8011
|
||||||
# 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
|
return
|
||||||
get_boss().notification_manager.handle_notification_cmd(self.id, osc_code, raw_data)
|
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:
|
def on_mouse_event(self, event: dict[str, Any]) -> bool:
|
||||||
event['mods'] = event.get('mods', 0) & mod_mask
|
event['mods'] = event.get('mods', 0) & mod_mask
|
||||||
ev = MouseEvent(**event)
|
ev = MouseEvent(**event)
|
||||||
|
|||||||
Reference in New Issue
Block a user