Skip to content

Commit 9504de1

Browse files
committed
[#24244] Hide terminator scroll
Signed-off-by: danipiza <dpizarrogallego@gmail.com>
1 parent 4053da9 commit 9504de1

2 files changed

Lines changed: 240 additions & 28 deletions

File tree

src/vulcanai/console/terminal_session.py

Lines changed: 239 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
# limitations under the License.
1414

1515
import os
16+
import re
17+
import shutil
1618
import subprocess
1719
import sys
1820
from dataclasses import dataclass
21+
from pathlib import Path
1922
from typing import Any, Optional, Protocol
2023

2124

@@ -39,8 +42,8 @@ def write_terminal_sequence(sequence: str) -> None:
3942
class TerminalAdapter(Protocol):
4043
"""
4144
Abstract parent class to enhance VulcanAI visualization in each terminal.
42-
Currently supported: Gnome
43-
Not yet implemented: Terminator, Zsh
45+
Currently supported: Gnome, Terminator
46+
Not yet implemented: Zsh
4447
"""
4548

4649
name: str
@@ -57,26 +60,6 @@ def restore(self, state: Any) -> None: ...
5760
# region gnome
5861

5962

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-
8063
@dataclass
8164
class GnomeState:
8265
"""@brief State required to restore GNOME Terminal settings."""
@@ -90,6 +73,26 @@ class GnomeTerminalAdapter:
9073

9174
name = "gnome-terminal"
9275

76+
@staticmethod
77+
def _run_gsettings(*args: str) -> Optional[str]:
78+
"""
79+
@brief Run gsettings and return trimmed stdout on success.
80+
@param args Positional arguments forwarded to ``gsettings``.
81+
@return Command stdout without trailing whitespace, or ``None`` on failure.
82+
"""
83+
try:
84+
completed = subprocess.run(
85+
["gsettings", *args],
86+
check=False,
87+
capture_output=True,
88+
text=True,
89+
)
90+
except Exception:
91+
return None
92+
if completed.returncode != 0:
93+
return None
94+
return completed.stdout.strip()
95+
9396
def detect(self) -> bool:
9497
"""
9598
@brief Detect whether the current terminal is GNOME Terminal.
@@ -108,7 +111,7 @@ def apply(self) -> Optional[GnomeState]:
108111
@return ``GnomeState`` when the change is applied/confirmed, else ``None``.
109112
"""
110113
# 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")
114+
profile_id = self._run_gsettings("get", "org.gnome.Terminal.ProfilesList", "default")
112115
if not profile_id:
113116
return None
114117
profile_id = profile_id.strip("'")
@@ -117,13 +120,13 @@ def apply(self) -> Optional[GnomeState]:
117120

118121
# GNOME stores per-profile keys under this dynamic schema path.
119122
schema = f"org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:{profile_id}/"
120-
current_policy = _run_gsettings("get", schema, "scrollbar-policy")
123+
current_policy = self._run_gsettings("get", schema, "scrollbar-policy")
121124
if not current_policy:
122125
return None
123126

124127
# set only if needed
125128
if current_policy != "'never'":
126-
_run_gsettings("set", schema, "scrollbar-policy", "never")
129+
self._run_gsettings("set", schema, "scrollbar-policy", "never")
127130

128131
return GnomeState(schema=schema, scrollbar_policy_backup=current_policy)
129132

@@ -137,7 +140,210 @@ def restore(self, state: Optional[GnomeState]) -> None:
137140
return
138141
restore_value = state.scrollbar_policy_backup.strip("'")
139142
if restore_value:
140-
_run_gsettings("set", state.schema, "scrollbar-policy", restore_value)
143+
self._run_gsettings("set", state.schema, "scrollbar-policy", restore_value)
144+
145+
146+
# endregion
147+
148+
# region terminator
149+
150+
151+
class TerminatorTerminalAdapter:
152+
"""@brief Terminator adapter that switches to a hidden-scroll profile temporarily."""
153+
154+
name = "terminator"
155+
# Matches any top-level section like [profiles], [global_config], [layouts].
156+
# Needed to detect where the [profiles] block ends.
157+
_TOP_LEVEL_SECTION_RE = re.compile(r"^\s*\[[^\[\]].*\]\s*$")
158+
# Matches profile headers inside [profiles], e.g. " [[default]]".
159+
# Group 1 stores indentation so cloned profiles preserve style.
160+
# Group 2 stores the profile name.
161+
_PROFILE_HEADER_RE = re.compile(r"^(\s*)\[\[(.+?)\]\]\s*$")
162+
# Matches generic "key = value" rows and captures indentation.
163+
# Used when appending missing keys with consistent formatting.
164+
_KEY_VALUE_RE = re.compile(r"^(\s*)[A-Za-z0-9_]+\s*=")
165+
# Matches the specific scrollbar setting row.
166+
# Used to rewrite current value to "disabled" without touching other keys.
167+
_SCROLLBAR_RE = re.compile(r"^(\s*)scrollbar_position\s*=")
168+
169+
def __init__(self, config: "TerminalSessionConfig"):
170+
self._config = config
171+
172+
# -- Utils ----------------------------------------------------------------
173+
174+
@staticmethod
175+
def _run(*args: str) -> bool:
176+
"""
177+
Execute a command and return success status.
178+
179+
@return ``True`` when process exits with code ``0``, else ``False``.
180+
"""
181+
try:
182+
completed = subprocess.run(
183+
[*args],
184+
check=False,
185+
capture_output=True,
186+
text=True,
187+
)
188+
except Exception:
189+
return False
190+
191+
return completed.returncode == 0
192+
193+
@staticmethod
194+
def _config_path() -> Path:
195+
"""
196+
Resolve Terminator config file location.
197+
198+
@return Absolute path to ``terminator/config`` under ``XDG_CONFIG_HOME`` or ``~/.config``.
199+
"""
200+
config_root = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
201+
return Path(config_root) / "terminator" / "config"
202+
203+
@classmethod
204+
def _ensure_hidden_profile(cls, config_path: Path, base_profile: str, hidden_profile: str) -> bool:
205+
"""
206+
Ensure hidden profile exists and has ``scrollbar_position = disabled``.
207+
208+
@return ``True`` when config is ready for profile switching, else ``False``.
209+
"""
210+
try:
211+
lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
212+
except Exception:
213+
return False
214+
215+
profiles_start = next((i for i, line in enumerate(lines) if line.strip() == "[profiles]"), None)
216+
if profiles_start is None:
217+
return False
218+
219+
profiles_end = len(lines)
220+
for index in range(profiles_start + 1, len(lines)):
221+
if cls._TOP_LEVEL_SECTION_RE.match(lines[index]) and lines[index].strip() != "[profiles]":
222+
profiles_end = index
223+
break
224+
225+
profile_headers: list[tuple[str, int, str]] = []
226+
for index in range(profiles_start + 1, profiles_end):
227+
header_match = cls._PROFILE_HEADER_RE.match(lines[index].rstrip("\r\n"))
228+
if header_match:
229+
profile_headers.append((header_match.group(2).strip(), index, header_match.group(1)))
230+
231+
if not profile_headers:
232+
return False
233+
234+
blocks: dict[str, tuple[int, int, str]] = {}
235+
for idx, (name, start_idx, indent) in enumerate(profile_headers):
236+
end_idx = profile_headers[idx + 1][1] if idx + 1 < len(profile_headers) else profiles_end
237+
blocks[name] = (start_idx, end_idx, indent)
238+
239+
def ensure_disabled(block: list[str], section_indent: str) -> list[str]:
240+
"""
241+
Update one profile block so scrollbar is always disabled.
242+
"""
243+
updated = [block[0]]
244+
scrollbar_found = False
245+
key_indent = None
246+
for line in block[1:]:
247+
stripped = line.rstrip("\r\n")
248+
if key_indent is None:
249+
key_match = cls._KEY_VALUE_RE.match(stripped)
250+
if key_match:
251+
key_indent = key_match.group(1)
252+
scrollbar_match = cls._SCROLLBAR_RE.match(stripped)
253+
if scrollbar_match:
254+
updated.append(f"{scrollbar_match.group(1)}scrollbar_position = disabled\n")
255+
scrollbar_found = True
256+
else:
257+
updated.append(line)
258+
if not scrollbar_found:
259+
indent = key_indent if key_indent is not None else f"{section_indent} "
260+
updated.append(f"{indent}scrollbar_position = disabled\n")
261+
return updated
262+
263+
changed = False
264+
if hidden_profile in blocks:
265+
hidden_start, hidden_end, hidden_indent = blocks[hidden_profile]
266+
hidden = ensure_disabled(lines[hidden_start:hidden_end], hidden_indent)
267+
if hidden != lines[hidden_start:hidden_end]:
268+
lines = lines[:hidden_start] + hidden + lines[hidden_end:]
269+
changed = True
270+
else:
271+
if base_profile not in blocks:
272+
return False
273+
base_start, base_end, base_indent = blocks[base_profile]
274+
hidden = [f"{base_indent}[[{hidden_profile}]]\n", *lines[base_start:base_end][1:]]
275+
lines = lines[:profiles_end] + ensure_disabled(hidden, base_indent) + lines[profiles_end:]
276+
changed = True
277+
278+
if changed:
279+
try:
280+
config_path.write_text("".join(lines), encoding="utf-8")
281+
except Exception:
282+
return False
283+
return True
284+
285+
# -------------------------------------------------------------------------
286+
287+
def detect(self) -> bool:
288+
"""
289+
@brief Detect whether current terminal is Terminator.
290+
@return ``True`` when Terminator environment markers are present.
291+
"""
292+
return (
293+
"TERMINATOR_UUID" in os.environ
294+
or "terminator" in os.environ.get("TERMINAL_EMULATOR", "").lower()
295+
or "terminator" in os.environ.get("TERM_PROGRAM", "").lower()
296+
)
297+
298+
def apply(self) -> Optional[tuple[str, str]]:
299+
"""
300+
@brief Switch current Terminator tab to hidden-scroll profile.
301+
@return ``(uuid, base_profile)`` when switching succeeds, else ``None``.
302+
"""
303+
terminal_uuid = os.environ.get("TERMINATOR_UUID")
304+
if not terminal_uuid:
305+
return None
306+
307+
if not shutil.which("remotinator"):
308+
return None
309+
310+
config_path = self._config_path()
311+
if not config_path.is_file():
312+
return None
313+
314+
if not self._ensure_hidden_profile(
315+
config_path=config_path,
316+
base_profile=self._config.terminator_profile_base,
317+
hidden_profile=self._config.terminator_profile_hidden,
318+
):
319+
return None
320+
321+
switched = self._run(
322+
"remotinator",
323+
"switch_profile",
324+
"-u",
325+
terminal_uuid,
326+
"-p",
327+
self._config.terminator_profile_hidden,
328+
)
329+
if not switched:
330+
return None
331+
332+
return (terminal_uuid, self._config.terminator_profile_base)
333+
334+
def restore(self, state: Optional[tuple[str, str]]) -> None:
335+
"""
336+
@brief Restore previous Terminator profile.
337+
@param state Previously saved state; no-op when ``None``.
338+
@return None
339+
"""
340+
if not state:
341+
return
342+
if not shutil.which("remotinator"):
343+
return
344+
345+
terminal_uuid, base_profile = state
346+
self._run("remotinator", "switch_profile", "-u", terminal_uuid, "-p", base_profile)
141347

142348

143349
# endregion
@@ -158,6 +364,10 @@ class TerminalSessionConfig:
158364
force_bg: bool = True
159365
# Emit DEC private mode sequence to hide/show scrollbar.
160366
hide_scrollbar: bool = True
367+
# Terminator profile to restore once session ends.
368+
terminator_profile_base: str = "default"
369+
# Terminator profile used while session is running.
370+
terminator_profile_hidden: str = "vulcanai-no-scroll"
161371

162372

163373
class TerminalSession:
@@ -171,7 +381,9 @@ def __init__(
171381
adapters: Optional[list[TerminalAdapter]] = None,
172382
):
173383
self.config = config if config is not None else TerminalSessionConfig()
174-
self.adapters = adapters if adapters is not None else [GnomeTerminalAdapter()]
384+
self.adapters = (
385+
adapters if adapters is not None else [GnomeTerminalAdapter(), TerminatorTerminalAdapter(self.config)]
386+
)
175387
self._active: list[tuple[TerminalAdapter, Any]] = []
176388

177389
def start(self) -> None:

src/vulcanai/core/plan_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def __str__(self) -> str:
117117
if node.success_criteria:
118118
# Succes Criteria: <node.success_criteria>
119119
lines.append(
120-
f"\<{color_tool}>tSuccess Criteria</{color_tool}>: "
120+
f"\t<{color_tool}>Success Criteria</{color_tool}>: "
121121
+ f"<{color_value}>{node.success_criteria}</{color_value}>"
122122
)
123123
if node.on_fail:

0 commit comments

Comments
 (0)