Skip to content

Commit c37d129

Browse files
authored
Fix host terminal background color behavior (#14)
* [#24193] Initial approach Signed-off-by: danipiza <dpizarrogallego@gmail.com> * [#24193] Applied Ruff Signed-off-by: danipiza <dpizarrogallego@gmail.com> * [#24193] Applied revision Signed-off-by: danipiza <dpizarrogallego@gmail.com> * [#24193] Applied revision Signed-off-by: danipiza <dpizarrogallego@gmail.com> * [#24193] Applied revision Signed-off-by: danipiza <dpizarrogallego@gmail.com> --------- Signed-off-by: danipiza <dpizarrogallego@gmail.com>
1 parent 3ea0d5c commit c37d129

3 files changed

Lines changed: 285 additions & 19 deletions

File tree

src/vulcanai/console/console.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@
3030

3131
from vulcanai.console.logger import VulcanAILogger
3232
from vulcanai.console.modal_screens import CheckListModal, RadioListModal, ReverseSearchModal
33-
from vulcanai.console.utils import SpinnerHook, StreamToTextual, attach_ros_logger_to_console, common_prefix
33+
from vulcanai.console.terminal_session import TerminalSession
34+
from vulcanai.console.utils import (
35+
SpinnerHook,
36+
StreamToTextual,
37+
attach_ros_logger_to_console,
38+
common_prefix,
39+
)
3440
from vulcanai.console.widget_custom_log_text_area import CustomLogTextArea
3541
from vulcanai.console.widget_spinner import SpinnerStatus
3642

@@ -53,25 +59,36 @@ class VulcanConsole(App):
5359
CSS = """
5460
Screen {
5561
layout: horizontal;
62+
overflow: hidden hidden;
63+
}
64+
65+
#root {
66+
width: 100%;
67+
height: 100%;
68+
overflow: hidden hidden;
5669
}
5770
5871
#left {
5972
width: 1fr;
6073
layout: vertical;
74+
overflow: hidden hidden;
6175
}
6276
6377
#right {
6478
width: 48;
6579
layout: vertical;
6680
border: tall #56AA08;
6781
padding: 0;
82+
overflow: hidden hidden;
6883
}
6984
7085
#logcontent {
7186
height: auto;
7287
min-height: 1;
7388
max-height: 1fr;
7489
border: tall #333333;
90+
scrollbar-size-vertical: 0;
91+
scrollbar-size-horizontal: 0;
7592
}
7693
7794
#llm_spinner {
@@ -94,6 +111,8 @@ class VulcanConsole(App):
94111
#history_scroll {
95112
height: 1fr;
96113
margin: 1;
114+
scrollbar-size-vertical: 0;
115+
scrollbar-size-horizontal: 0;
97116
}
98117
99118
#history {
@@ -170,6 +189,9 @@ def __init__(
170189
self.suggestion_index = -1
171190
self.suggestion_index_changed = threading.Event()
172191

192+
self._gnome_profile_schema: str | None = None
193+
self._gnome_scrollbar_policy_backup: str | None = None
194+
173195
async def on_mouse_down(self, event: MouseEvent) -> None:
174196
"""
175197
Function used to paste the string for the user clipboard
@@ -1075,7 +1097,10 @@ def run_console(self) -> None:
10751097
"""
10761098
Function used to run VulcanAI.
10771099
"""
1078-
self.run()
1100+
1101+
session = TerminalSession()
1102+
with session:
1103+
self.run()
10791104

10801105
def init_manager(self) -> None:
10811106
"""
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima).
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import os
16+
import subprocess
17+
import sys
18+
from dataclasses import dataclass
19+
from typing import Any, Optional, Protocol
20+
21+
22+
def write_terminal_sequence(sequence: str) -> None:
23+
"""
24+
Write a raw escape sequence to the active terminal.
25+
26+
Used to change the color of the terminal.
27+
- Change the terminal color using the same color of VulcanAI
28+
- Restore the terminal color
29+
"""
30+
if not sys.stdout.isatty():
31+
return
32+
try:
33+
sys.stdout.write(sequence)
34+
sys.stdout.flush()
35+
except Exception:
36+
pass
37+
38+
39+
class TerminalAdapter(Protocol):
40+
"""
41+
Abstract parent class to enhance VulcanAI visualization in each terminal.
42+
Currently supported: Gnome
43+
Not yet implemented: Terminator, Zsh
44+
"""
45+
46+
name: str
47+
48+
def detect(self) -> bool: ...
49+
50+
def apply(self) -> Any: ...
51+
52+
def restore(self, state: Any) -> None: ...
53+
54+
55+
# region TERMINALS
56+
57+
# region gnome
58+
59+
60+
def _run_gsettings(*args: str) -> Optional[str]:
61+
"""
62+
@brief Run gsettings and return trimmed stdout on success.
63+
@param args Positional arguments forwarded to ``gsettings``.
64+
@return Command stdout without trailing whitespace, or ``None`` on failure.
65+
"""
66+
try:
67+
completed = subprocess.run(
68+
["gsettings", *args],
69+
check=False,
70+
capture_output=True,
71+
text=True,
72+
)
73+
except Exception:
74+
return None
75+
if completed.returncode != 0:
76+
return None
77+
return completed.stdout.strip()
78+
79+
80+
@dataclass
81+
class GnomeState:
82+
"""@brief State required to restore GNOME Terminal settings."""
83+
84+
schema: str
85+
scrollbar_policy_backup: str
86+
87+
88+
class GnomeTerminalAdapter:
89+
"""@brief GNOME Terminal adapter that hides and restores the scrollbar."""
90+
91+
name = "gnome-terminal"
92+
93+
def detect(self) -> bool:
94+
"""
95+
@brief Detect whether the current terminal is GNOME Terminal.
96+
@return ``True`` when GNOME Terminal environment markers are found.
97+
"""
98+
is_gnome = (
99+
"GNOME_TERMINAL_SCREEN" in os.environ
100+
or "gnome-terminal" in os.environ.get("TERMINAL_EMULATOR", "").lower()
101+
or "gnome-terminal" in os.environ.get("TERM_PROGRAM", "").lower()
102+
)
103+
return is_gnome
104+
105+
def apply(self) -> Optional[GnomeState]:
106+
"""
107+
@brief Hide GNOME scrollbar and return state for later restoration.
108+
@return ``GnomeState`` when the change is applied/confirmed, else ``None``.
109+
"""
110+
# The return value could be None, empty string or string with just single quotes
111+
profile_id = _run_gsettings("get", "org.gnome.Terminal.ProfilesList", "default")
112+
if not profile_id:
113+
return None
114+
profile_id = profile_id.strip("'")
115+
if not profile_id:
116+
return None
117+
118+
# GNOME stores per-profile keys under this dynamic schema path.
119+
schema = f"org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:{profile_id}/"
120+
current_policy = _run_gsettings("get", schema, "scrollbar-policy")
121+
if not current_policy:
122+
return None
123+
124+
# set only if needed
125+
if current_policy != "'never'":
126+
_run_gsettings("set", schema, "scrollbar-policy", "never")
127+
128+
return GnomeState(schema=schema, scrollbar_policy_backup=current_policy)
129+
130+
def restore(self, state: Optional[GnomeState]) -> None:
131+
"""
132+
@brief Restore the scrollbar policy captured by ``apply``.
133+
@param state Previously saved state; no-op when ``None``.
134+
@return None
135+
"""
136+
if not state:
137+
return
138+
restore_value = state.scrollbar_policy_backup.strip("'")
139+
if restore_value:
140+
_run_gsettings("set", state.schema, "scrollbar-policy", restore_value)
141+
142+
143+
# endregion
144+
145+
# endregion
146+
147+
148+
# region SESSION
149+
150+
151+
@dataclass
152+
class TerminalSessionConfig:
153+
"""@brief Runtime options controlling generic terminal tweaks."""
154+
155+
# Background color used by OSC 11 (set default background color).
156+
bg_color: str = "#121212"
157+
# Emit OSC sequences to set and later reset background color.
158+
force_bg: bool = True
159+
# Emit DEC private mode sequence to hide/show scrollbar.
160+
hide_scrollbar: bool = True
161+
162+
163+
class TerminalSession:
164+
"""
165+
Session helper that applies terminal tweaks and safely restores them.
166+
"""
167+
168+
def __init__(
169+
self,
170+
config: Optional[TerminalSessionConfig] = None,
171+
adapters: Optional[list[TerminalAdapter]] = None,
172+
):
173+
self.config = config if config is not None else TerminalSessionConfig()
174+
self.adapters = adapters if adapters is not None else [GnomeTerminalAdapter()]
175+
self._active: list[tuple[TerminalAdapter, Any]] = []
176+
177+
def start(self) -> None:
178+
"""
179+
Apply generic and adapter-specific terminal tweaks.
180+
"""
181+
# Generic sequences (independent from specific emulators)
182+
if self.config.force_bg:
183+
# OSC 11: set default background color.
184+
write_terminal_sequence(f"\x1b]11;{self.config.bg_color}\x07")
185+
if self.config.hide_scrollbar:
186+
# DECSET private mode 30: hide scrollbar where supported.
187+
write_terminal_sequence("\x1b[?30l")
188+
189+
# Terminal-specific adapters
190+
for adapter in self.adapters:
191+
if adapter.detect():
192+
state = adapter.apply()
193+
self._active.append((adapter, state))
194+
195+
def end(self) -> None:
196+
"""
197+
Restore adapter state and generic terminal tweaks.
198+
"""
199+
# Restore adapters in reverse order
200+
for adapter, state in reversed(self._active):
201+
try:
202+
adapter.restore(state)
203+
except Exception:
204+
pass
205+
self._active.clear()
206+
207+
# Restore generic sequences
208+
if self.config.hide_scrollbar:
209+
# DECRST private mode 30: show scrollbar again.
210+
write_terminal_sequence("\x1b[?30h")
211+
if self.config.force_bg:
212+
# OSC 111: reset default background color.
213+
write_terminal_sequence("\x1b]111\x07")
214+
215+
def __enter__(self):
216+
"""
217+
Context-manager entrypoint.
218+
"""
219+
self.start()
220+
return self
221+
222+
def __exit__(self, exc_type, exc, tb):
223+
"""
224+
Context-manager exitpoint; always restores terminal state.
225+
"""
226+
self.end()
227+
return False
228+
229+
230+
# endregion

src/vulcanai/console/utils.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,26 @@
2424
from textual.markup import escape # To remove potential errors in textual terminal
2525

2626

27+
class SpinnerHook:
28+
"""
29+
Single entrant spinner controller for console.
30+
- Starts the spinner on the LLM request.
31+
- Stops the spinner when LLM request is over.
32+
"""
33+
34+
def __init__(self, spinner_status):
35+
self.spinner_status = spinner_status
36+
37+
def on_request_start(self, text="Querying LLM..."):
38+
self.spinner_status.start(text)
39+
40+
def on_request_end(self):
41+
self.spinner_status.stop()
42+
43+
44+
# region CONSOLE_REDIRECT
45+
46+
2747
class StreamToTextual:
2848
"""
2949
Class used to redirect the stdout/stderr streams in the textual terminal
@@ -46,23 +66,6 @@ def flush(self):
4666
self.real_stream.flush()
4767

4868

49-
class SpinnerHook:
50-
"""
51-
Single entrant spinner controller for console.
52-
- Starts the spinner on the LLM request.
53-
- Stops the spinner when LLM request is over.
54-
"""
55-
56-
def __init__(self, spinner_status):
57-
self.spinner_status = spinner_status
58-
59-
def on_request_start(self, text="Querying LLM..."):
60-
self.spinner_status.start(text)
61-
62-
def on_request_end(self):
63-
self.spinner_status.stop()
64-
65-
6669
def attach_ros_logger_to_console(console):
6770
"""
6871
Redirect ALL rclpy RcutilsLogger output (nodes + executor + rclpy internals)
@@ -123,6 +126,11 @@ def patched_log(self, msg, level, *args, **kwargs):
123126
RcutilsLogger._textual_patched = True
124127

125128

129+
# endregion
130+
131+
# region TEXTUAL
132+
133+
126134
def common_prefix(strings: str) -> str:
127135
if not strings:
128136
return ""
@@ -349,3 +357,6 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl
349357
console.suggestion_index_changed.clear()
350358

351359
return ret
360+
361+
362+
# endregion

0 commit comments

Comments
 (0)