1313# limitations under the License.
1414
1515import os
16+ import re
17+ import shutil
1618import subprocess
1719import sys
1820from dataclasses import dataclass
21+ from pathlib import Path
1922from typing import Any , Optional , Protocol
2023
2124
@@ -39,8 +42,8 @@ def write_terminal_sequence(sequence: str) -> None:
3942class 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
8164class 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
163373class 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 :
0 commit comments