From c294d1db1e25acd43f916925d4dcbf587f1d3631 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 10 May 2026 14:18:25 +0530 Subject: [PATCH 01/12] feat(core): add ghostty macos history capture --- nbs/00_core.ipynb | 111 ++++++++++- shell_sage/_modidx.py | 14 ++ shell_sage/core.py | 116 ++++++++++- tests/__init__.py | 0 tests/test_terminal_history_dispatch.py | 243 ++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 14 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_terminal_history_dispatch.py diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index 8836cf8..ad5de17 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -54,7 +54,7 @@ "from fastllm.chat import AsyncChat, StreamAccum\n", "\n", "import rgapi\n", - "import asyncio,os,pyperclip,re,subprocess,sys,builtins\n", + "import asyncio,os,pyperclip,re,subprocess,sys,tempfile,time,builtins\n", "from typing import Annotated" ] }, @@ -493,6 +493,91 @@ "outputs": [], "source": [ "#| export\n", + "GHOSTTY_SCROLLBACK_SCRIPT = \"\"\"\n", + "tell application \"Ghostty\"\n", + " perform action \"write_scrollback_file:copy,plain\" on focused terminal of selected tab of front window\n", + "end tell\n", + "\"\"\"\n", + "\n", + "def _pbpaste():\n", + " try:\n", + " return co(['pbpaste'], text=True, stderr=DEVNULL)\n", + " except Exception:\n", + " return ''\n", + "\n", + "\n", + "def _pbcopy(clip):\n", + " try:\n", + " subprocess.run(['pbcopy'], input=clip or '', text=True, check=False, stdout=DEVNULL, stderr=DEVNULL)\n", + " except Exception:\n", + " pass\n", + "\n", + "\n", + "def _run_osascript(script):\n", + " subprocess.run(['osascript', '-e', script], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL)\n", + "\n", + "\n", + "def _ghostty_history_temp_roots():\n", + " roots = {tempfile.gettempdir(), '/var/folders', '/private/var/folders'}\n", + " roots.update('/private' + root for root in list(roots) if root.startswith('/var/'))\n", + " roots.update(root.removeprefix('/private') for root in list(roots) if root.startswith('/private/'))\n", + " return [Path(root).resolve() for root in roots if root]\n", + "\n", + "\n", + "def _valid_ghostty_history_path(candidate):\n", + " try:\n", + " path = Path(str(candidate).strip())\n", + " if path.name != 'history.txt' or not path.is_file() or path.stat().st_size > 10 * 1024 * 1024:\n", + " return False\n", + " path = path.resolve()\n", + " return any(os.path.commonpath([str(path), str(root)]) == str(root) for root in _ghostty_history_temp_roots())\n", + " except Exception:\n", + " return False\n", + "\n", + "\n", + "def _wait_for_ghostty_history_path(old_clip=None, timeout=1.0):\n", + " start = time.time()\n", + " old_clip = (old_clip or '').strip()\n", + " while time.time() < start + timeout:\n", + " candidate = (_pbpaste() or '').strip()\n", + " if _valid_ghostty_history_path(candidate):\n", + " if candidate != old_clip:\n", + " return candidate\n", + " try:\n", + " if Path(candidate).stat().st_mtime >= start:\n", + " return candidate\n", + " except OSError:\n", + " pass\n", + " time.sleep(0.05)\n", + " return None\n", + "\n", + "\n", + "def _tail_lines(text, n):\n", + " if n is None or n < 0:\n", + " return text\n", + " if n == 0:\n", + " return ''\n", + " return '\\n'.join(text.splitlines()[-n:])\n", + "\n", + "\n", + "def get_ghostty_history_macos(n):\n", + " old_clip = _pbpaste()\n", + " try:\n", + " _run_osascript(GHOSTTY_SCROLLBACK_SCRIPT)\n", + " path = _wait_for_ghostty_history_path(old_clip)\n", + " if not path:\n", + " return None\n", + " return _tail_lines(Path(path).read_text(encoding='utf-8', errors='replace'), n)\n", + " except Exception:\n", + " return None\n", + " finally:\n", + " _pbcopy(old_clip)\n", + "\n", + "\n", + "def is_ghostty():\n", + " return os.environ.get('TERM_PROGRAM') == 'ghostty' or os.environ.get('TERM', '').startswith('xterm-ghostty')\n", + "\n", + "\n", "def get_hist_tmux(n, pid='current'):\n", " if not os.environ.get('TMUX'): return None\n", " try:\n", @@ -553,7 +638,24 @@ "source": [ "#| export\n", "def get_history(n, pid='current'):\n", - " return get_hist_tmux(n, pid) or get_hist_osa(n)" + " return get_hist_tmux(n, pid) or get_hist_osa(n)\n", + "\n", + "\n", + "def get_ghostty_history(n, pid='current'):\n", + " if sys.platform != 'darwin' or pid not in ('current', None):\n", + " return None\n", + " return get_ghostty_history_macos(n)\n", + "\n", + "\n", + "def get_terminal_history(n, pid='current'):\n", + " if os.environ.get('TMUX'):\n", + " n = tmux_history_lim() if n is None or n < 0 else n\n", + " return get_hist_tmux(n, pid)\n", + "\n", + " n = 3000 if n is None or n < 0 else n\n", + " if is_ghostty():\n", + " return get_ghostty_history(n, pid)\n", + " return get_history(n, pid)" ] }, { @@ -1082,10 +1184,7 @@ " query = ' '.join(query)\n", " ctxt = '' if skip_system else _sys_info()\n", "\n", - " # Get tmux history if in a tmux session\n", - " if opts.history_lines is None or opts.history_lines < 0:\n", - " opts.history_lines = tmux_history_lim()\n", - " history = get_history(opts.history_lines, pid)\n", + " history = get_terminal_history(opts.history_lines, pid)\n", " if history: ctxt += f'\\n{history}\\n'\n", "\n", " # Read from redirect stdin if available\n", diff --git a/shell_sage/_modidx.py b/shell_sage/_modidx.py index 0c1df96..0d000c6 100644 --- a/shell_sage/_modidx.py +++ b/shell_sage/_modidx.py @@ -12,12 +12,24 @@ 'shell_sage/core.py'), 'shell_sage.core.Log': ('core.html#log', 'shell_sage/core.py'), 'shell_sage.core._aliases': ('core.html#_aliases', 'shell_sage/core.py'), + 'shell_sage.core._ghostty_history_temp_roots': ( 'core.html#_ghostty_history_temp_roots', + 'shell_sage/core.py'), 'shell_sage.core._pause_live': ('core.html#_pause_live', 'shell_sage/core.py'), + 'shell_sage.core._pbcopy': ('core.html#_pbcopy', 'shell_sage/core.py'), + 'shell_sage.core._pbpaste': ('core.html#_pbpaste', 'shell_sage/core.py'), + 'shell_sage.core._run_osascript': ('core.html#_run_osascript', 'shell_sage/core.py'), 'shell_sage.core._sys_info': ('core.html#_sys_info', 'shell_sage/core.py'), + 'shell_sage.core._tail_lines': ('core.html#_tail_lines', 'shell_sage/core.py'), 'shell_sage.core._tmux_fmt': ('core.html#_tmux_fmt', 'shell_sage/core.py'), + 'shell_sage.core._valid_ghostty_history_path': ( 'core.html#_valid_ghostty_history_path', + 'shell_sage/core.py'), + 'shell_sage.core._wait_for_ghostty_history_path': ( 'core.html#_wait_for_ghostty_history_path', + 'shell_sage/core.py'), 'shell_sage.core.extract': ('core.html#extract', 'shell_sage/core.py'), 'shell_sage.core.extract_cf': ('core.html#extract_cf', 'shell_sage/core.py'), 'shell_sage.core.fd': ('core.html#fd', 'shell_sage/core.py'), + 'shell_sage.core.get_ghostty_history': ('core.html#get_ghostty_history', 'shell_sage/core.py'), + 'shell_sage.core.get_ghostty_history_macos': ('core.html#get_ghostty_history_macos', 'shell_sage/core.py'), 'shell_sage.core.get_hist_osa': ('core.html#get_hist_osa', 'shell_sage/core.py'), 'shell_sage.core.get_hist_tmux': ('core.html#get_hist_tmux', 'shell_sage/core.py'), 'shell_sage.core.get_history': ('core.html#get_history', 'shell_sage/core.py'), @@ -26,6 +38,8 @@ 'shell_sage.core.get_panes': ('core.html#get_panes', 'shell_sage/core.py'), 'shell_sage.core.get_res': ('core.html#get_res', 'shell_sage/core.py'), 'shell_sage.core.get_sage': ('core.html#get_sage', 'shell_sage/core.py'), + 'shell_sage.core.get_terminal_history': ('core.html#get_terminal_history', 'shell_sage/core.py'), + 'shell_sage.core.is_ghostty': ('core.html#is_ghostty', 'shell_sage/core.py'), 'shell_sage.core.ls': ('core.html#ls', 'shell_sage/core.py'), 'shell_sage.core.main': ('core.html#main', 'shell_sage/core.py'), 'shell_sage.core.mk_db': ('core.html#mk_db', 'shell_sage/core.py'), diff --git a/shell_sage/core.py b/shell_sage/core.py index d5889a4..560d5f2 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -3,9 +3,10 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb. # %% auto #0 -__all__ = ['console', 'print', 'sp', 'ssp', 'default_cfg', 'tools', 'sps', 'log_path', 'pane_mark', 'get_pane', 'get_panes', - 'tmux_history_lim', 'get_hist_tmux', 'get_hist_osa', 'get_history', 'get_opts', 'with_permission', 'rg', - 'ls', 'fd', 'get_sage', 'get_res', 'Log', 'mk_db', 'main', 'extract_cf', 'extract'] +__all__ = ['console', 'print', 'sp', 'ssp', 'GHOSTTY_SCROLLBACK_SCRIPT', 'default_cfg', 'tools', 'sps', 'log_path', 'pane_mark', + 'get_pane', 'get_panes', 'tmux_history_lim', 'get_ghostty_history_macos', 'is_ghostty', 'get_hist_tmux', + 'get_hist_osa', 'get_history', 'get_ghostty_history', 'get_terminal_history', 'get_opts', 'with_permission', + 'rg', 'ls', 'fd', 'get_sage', 'get_res', 'Log', 'mk_db', 'main', 'extract_cf', 'extract'] # %% ../nbs/00_core.ipynb #d7c5634a from contextlib import contextmanager @@ -28,7 +29,7 @@ from fastllm.chat import AsyncChat, StreamAccum import rgapi -import asyncio,os,pyperclip,re,subprocess,sys,builtins +import asyncio,os,pyperclip,re,subprocess,sys,tempfile,time,builtins from typing import Annotated # %% ../nbs/00_core.ipynb #4d0676fd @@ -180,6 +181,91 @@ def tmux_history_lim(): # %% ../nbs/00_core.ipynb #0d70591e +GHOSTTY_SCROLLBACK_SCRIPT = """ +tell application "Ghostty" + perform action "write_scrollback_file:copy,plain" on focused terminal of selected tab of front window +end tell +""" + +def _pbpaste(): + try: + return co(['pbpaste'], text=True, stderr=DEVNULL) + except Exception: + return '' + + +def _pbcopy(clip): + try: + subprocess.run(['pbcopy'], input=clip or '', text=True, check=False, stdout=DEVNULL, stderr=DEVNULL) + except Exception: + pass + + +def _run_osascript(script): + subprocess.run(['osascript', '-e', script], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL) + + +def _ghostty_history_temp_roots(): + roots = {tempfile.gettempdir(), '/var/folders', '/private/var/folders'} + roots.update('/private' + root for root in list(roots) if root.startswith('/var/')) + roots.update(root.removeprefix('/private') for root in list(roots) if root.startswith('/private/')) + return [Path(root).resolve() for root in roots if root] + + +def _valid_ghostty_history_path(candidate): + try: + path = Path(str(candidate).strip()) + if path.name != 'history.txt' or not path.is_file() or path.stat().st_size > 10 * 1024 * 1024: + return False + path = path.resolve() + return any(os.path.commonpath([str(path), str(root)]) == str(root) for root in _ghostty_history_temp_roots()) + except Exception: + return False + + +def _wait_for_ghostty_history_path(old_clip=None, timeout=1.0): + start = time.time() + old_clip = (old_clip or '').strip() + while time.time() < start + timeout: + candidate = (_pbpaste() or '').strip() + if _valid_ghostty_history_path(candidate): + if candidate != old_clip: + return candidate + try: + if Path(candidate).stat().st_mtime >= start: + return candidate + except OSError: + pass + time.sleep(0.05) + return None + + +def _tail_lines(text, n): + if n is None or n < 0: + return text + if n == 0: + return '' + return '\n'.join(text.splitlines()[-n:]) + + +def get_ghostty_history_macos(n): + old_clip = _pbpaste() + try: + _run_osascript(GHOSTTY_SCROLLBACK_SCRIPT) + path = _wait_for_ghostty_history_path(old_clip) + if not path: + return None + return _tail_lines(Path(path).read_text(encoding='utf-8', errors='replace'), n) + except Exception: + return None + finally: + _pbcopy(old_clip) + + +def is_ghostty(): + return os.environ.get('TERM_PROGRAM') == 'ghostty' or os.environ.get('TERM', '').startswith('xterm-ghostty') + + def get_hist_tmux(n, pid='current'): if not os.environ.get('TMUX'): return None try: @@ -201,6 +287,23 @@ def get_hist_osa(n, pid=''): def get_history(n, pid='current'): return get_hist_tmux(n, pid) or get_hist_osa(n) + +def get_ghostty_history(n, pid='current'): + if sys.platform != 'darwin' or pid not in ('current', None): + return None + return get_ghostty_history_macos(n) + + +def get_terminal_history(n, pid='current'): + if os.environ.get('TMUX'): + n = tmux_history_lim() if n is None or n < 0 else n + return get_hist_tmux(n, pid) + + n = 3000 if n is None or n < 0 else n + if is_ghostty(): + return get_ghostty_history(n, pid) + return get_history(n, pid) + # %% ../nbs/00_core.ipynb #0dcbb503 default_cfg = asdict(ShellSageConfig()) def get_opts(**opts): @@ -360,10 +463,7 @@ async def main( query = ' '.join(query) ctxt = '' if skip_system else _sys_info() - # Get tmux history if in a tmux session - if opts.history_lines is None or opts.history_lines < 0: - opts.history_lines = tmux_history_lim() - history = get_history(opts.history_lines, pid) + history = get_terminal_history(opts.history_lines, pid) if history: ctxt += f'\n{history}\n' # Read from redirect stdin if available diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_terminal_history_dispatch.py b/tests/test_terminal_history_dispatch.py new file mode 100644 index 0000000..07f0323 --- /dev/null +++ b/tests/test_terminal_history_dispatch.py @@ -0,0 +1,243 @@ +import importlib +import sys +import tempfile +import types +import unittest +from dataclasses import asdict, dataclass +from pathlib import Path +from unittest.mock import patch + + +def _module(name, **attrs): + module = types.ModuleType(name) + for key, value in attrs.items(): + setattr(module, key, value) + return module + + +def _install_import_stubs(): + """Import shell_sage.core without loading optional runtime dependencies.""" + fastcore_script = _module('fastcore.script', call_parse=lambda f: f) + fastcore_tools = _module( + 'fastcore.tools', + rg=lambda *args, **kwargs: None, + view_file=lambda *args, **kwargs: None, + create_file=lambda *args, **kwargs: None, + file_str_replace=lambda *args, **kwargs: None, + file_insert_line=lambda *args, **kwargs: None, + ) + fastcore_utils = _module( + 'fastcore.utils', + patch=lambda f: f, + IN_NOTEBOOK=False, + noop=lambda x=None, *args, **kwargs: x, + asdict=asdict, + Path=Path, + ) + + class AttrDict(dict): + def __getattr__(self, key): return self[key] + def __setattr__(self, key, value): self[key] = value + + fastcore_utils.AttrDict = AttrDict + fastcore_meta = _module('fastcore.meta', delegates=lambda *args, **kwargs: lambda f: f) + fastcore = _module( + 'fastcore', script=fastcore_script, tools=fastcore_tools, + utils=fastcore_utils, meta=fastcore_meta, + ) + fastcore.__path__ = [] + + class Console: + def print(self, *args, **kwargs): pass + + rich_live = _module('rich.live', Live=object) + rich_spinner = _module('rich.spinner', Spinner=object) + rich_console = _module('rich.console', Console=Console) + rich_markdown = _module( + 'rich.markdown', + CodeBlock=type('CodeBlock', (), {}), + Markdown=lambda *args, **kwargs: args[0] if args else '', + ) + rich_syntax = _module('rich.syntax', Syntax=lambda *args, **kwargs: args[0] if args else '') + rich = _module( + 'rich', live=rich_live, spinner=rich_spinner, console=rich_console, + markdown=rich_markdown, syntax=rich_syntax, + ) + rich.__path__ = [] + + @dataclass + class ShellSageConfig: + model: str = 'test-model' + search: str = '' + think: str = '' + trust: str = '' + mode: str = 'default' + base_url: str = '' + api_key: str = '' + vendor_name: str = '' + history_lines: int = -1 + code_theme: str = 'monokai' + code_lexer: str = 'python' + log: bool = False + safecmd: bool = False + custom_instructions: str = '' + + shell_config = _module( + 'shell_sage.config', ShellSageConfig=ShellSageConfig, get_cfg=lambda: {}, + ) + + class AsyncChat: + def __init__(self, *args, **kwargs): pass + def _call(self, *args, **kwargs): pass + + fastllm_chat = _module('fastllm.chat', AsyncChat=AsyncChat) + fastllm = _module('fastllm', chat=fastllm_chat) + fastllm.__path__ = [] + + modules = { + 'fastcore': fastcore, + 'fastcore.script': fastcore_script, + 'fastcore.tools': fastcore_tools, + 'fastcore.utils': fastcore_utils, + 'fastcore.meta': fastcore_meta, + 'fastlite': _module('fastlite', database=lambda *args, **kwargs: None), + 'rich': rich, + 'rich.live': rich_live, + 'rich.spinner': rich_spinner, + 'rich.console': rich_console, + 'rich.markdown': rich_markdown, + 'rich.syntax': rich_syntax, + 'shell_sage.config': shell_config, + 'safecmd': _module('safecmd', bash=lambda *args, **kwargs: None), + 'pyperclip': _module('pyperclip', copy=lambda *args, **kwargs: None), + 'rgapi': _module( + 'rgapi', + rg=lambda *args, **kwargs: None, + ls=lambda *args, **kwargs: None, + fd=lambda *args, **kwargs: [], + ), + 'fastllm': fastllm, + 'fastllm.chat': fastllm_chat, + } + sys.modules.update(modules) + + +def import_core(): + _install_import_stubs() + sys.modules.pop('shell_sage.core', None) + return importlib.import_module('shell_sage.core') + + +class TerminalHistoryDispatchTests(unittest.TestCase): + def setUp(self): + self.core = import_core() + + def test_tmux_provider_wins_and_defaults_to_tmux_history_limit(self): + with patch.dict(self.core.os.environ, {'TMUX': '/tmp/tmux', 'TERM_PROGRAM': 'ghostty'}, clear=True), \ + patch.object(self.core, 'tmux_history_lim', return_value=123) as history_lim, \ + patch.object(self.core, 'get_hist_tmux', return_value='tmux history') as get_tmux, \ + patch.object(self.core, 'get_ghostty_history_macos') as get_ghostty: + self.assertEqual(self.core.get_terminal_history(None, 'all'), 'tmux history') + history_lim.assert_called_once_with() + get_tmux.assert_called_once_with(123, 'all') + get_ghostty.assert_not_called() + + def test_explicit_history_lines_pass_through_to_tmux(self): + with patch.dict(self.core.os.environ, {'TMUX': '/tmp/tmux'}, clear=True), \ + patch.object(self.core, 'tmux_history_lim') as history_lim, \ + patch.object(self.core, 'get_hist_tmux', return_value='tmux history') as get_tmux: + self.assertEqual(self.core.get_terminal_history(42, '%1'), 'tmux history') + history_lim.assert_not_called() + get_tmux.assert_called_once_with(42, '%1') + + def test_get_history_keeps_main_provider_order(self): + with patch.object(self.core, 'get_hist_tmux', return_value=None) as get_tmux, \ + patch.object(self.core, 'get_hist_osa', return_value='terminal history') as get_osa: + self.assertEqual(self.core.get_history(12, 'current'), 'terminal history') + get_tmux.assert_called_once_with(12, 'current') + get_osa.assert_called_once_with(12) + + def test_ghostty_detection_uses_term_program_or_term(self): + with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'ghostty'}, clear=True): + self.assertTrue(self.core.is_ghostty()) + with patch.dict(self.core.os.environ, {'TERM': 'xterm-ghostty'}, clear=True): + self.assertTrue(self.core.is_ghostty()) + with patch.dict( + self.core.os.environ, + {'TERM_PROGRAM': 'Apple_Terminal', 'TERM': 'xterm-256color'}, + clear=True, + ): + self.assertFalse(self.core.is_ghostty()) + + def test_ghostty_provider_uses_default_history_lines(self): + with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'ghostty'}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, 'get_ghostty_history_macos', return_value='ghostty history') as get_ghostty: + self.assertEqual(self.core.get_terminal_history(-1, 'current'), 'ghostty history') + get_ghostty.assert_called_once_with(3000) + + def test_ghostty_rejects_unsupported_pid(self): + with patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, 'get_ghostty_history_macos') as macos_history: + self.assertIsNone(self.core.get_ghostty_history(10, 'all')) + self.assertIsNone(self.core.get_ghostty_history(10, '%2')) + macos_history.assert_not_called() + + def test_ghostty_macos_happy_path_reads_temp_history_and_restores_clipboard(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: + history_path = Path(directory) / 'history.txt' + history_path.write_text('one\ntwo\nthree\nfour', encoding='utf-8') + with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path)]), \ + patch.object(self.core, '_pbcopy') as pbcopy, \ + patch.object(self.core, '_run_osascript') as run_osascript: + self.assertEqual(self.core.get_ghostty_history_macos(2), 'three\nfour') + run_osascript.assert_called_once_with(self.core.GHOSTTY_SCROLLBACK_SCRIPT) + pbcopy.assert_called_once_with('original clip') + + def test_ghostty_macos_invalid_clipboard_path_returns_none_and_restores_clipboard(self): + clipboard_values = iter(['original clip', 'not a path']) + + def fake_pbpaste(): + return next(clipboard_values, 'not a path') + + with patch.object(self.core, '_pbpaste', side_effect=fake_pbpaste), \ + patch.object(self.core, '_pbcopy') as pbcopy, \ + patch.object(self.core, '_run_osascript'), \ + patch.object(self.core.time, 'sleep'), \ + patch.object(self.core.time, 'time', side_effect=[0, 0, 2]): + self.assertIsNone(self.core.get_ghostty_history_macos(10)) + pbcopy.assert_called_once_with('original clip') + + def test_ghostty_macos_read_errors_return_none_and_restore_clipboard(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: + history_path = Path(directory) / 'history.txt' + history_path.write_text('history', encoding='utf-8') + with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path)]), \ + patch.object(self.core, '_pbcopy') as pbcopy, \ + patch.object(self.core, '_run_osascript'), \ + patch.object(self.core.Path, 'read_text', side_effect=OSError('boom')): + self.assertIsNone(self.core.get_ghostty_history_macos(10)) + pbcopy.assert_called_once_with('original clip') + + def test_ghostty_path_validation_requires_temp_history_file(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: + valid = Path(directory) / 'history.txt' + valid.write_text('history', encoding='utf-8') + wrong_name = Path(directory) / 'not-history.txt' + wrong_name.write_text('history', encoding='utf-8') + self.assertTrue(self.core._valid_ghostty_history_path(str(valid))) + self.assertFalse(self.core._valid_ghostty_history_path(str(wrong_name))) + self.assertFalse(self.core._valid_ghostty_history_path('not a path')) + + def test_tail_lines_respects_history_line_count(self): + self.assertEqual(self.core._tail_lines('one\ntwo\nthree', 2), 'two\nthree') + self.assertEqual(self.core._tail_lines('one\ntwo', -1), 'one\ntwo') + self.assertEqual(self.core._tail_lines('one\ntwo', 0), '') + + def test_no_terminal_provider_returns_none(self): + with patch.dict(self.core.os.environ, {}, clear=True): + self.assertIsNone(self.core.get_terminal_history(10, 'current')) + + +if __name__ == '__main__': + unittest.main() From b54f85c7b2b4efb40796e7ea1adb3edf6bbb87b4 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 10 May 2026 14:26:13 +0530 Subject: [PATCH 02/12] refactor(core): simplify ghostty history helpers --- nbs/00_core.ipynb | 22 ++++++++++++++-------- shell_sage/core.py | 22 ++++++++++++++-------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index ad5de17..e98d387 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -41,7 +41,7 @@ "from fastcore.utils import *\n", "from fastcore.meta import delegates\n", "from fastlite import database\n", - "from functools import partial, wraps\n", + "from functools import lru_cache, partial, wraps\n", "from rich.live import Live\n", "from rich.spinner import Spinner\n", "from rich.console import Console\n", @@ -493,6 +493,10 @@ "outputs": [], "source": [ "#| export\n", + "_GHOSTTY_DEFAULT_HISTORY_LINES = 3000\n", + "_GHOSTTY_HISTORY_MAX_BYTES = 10 * 1024 * 1024\n", + "_GHOSTTY_POLL_INTERVAL = 0.05\n", + "\n", "GHOSTTY_SCROLLBACK_SCRIPT = \"\"\"\n", "tell application \"Ghostty\"\n", " perform action \"write_scrollback_file:copy,plain\" on focused terminal of selected tab of front window\n", @@ -517,17 +521,18 @@ " subprocess.run(['osascript', '-e', script], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL)\n", "\n", "\n", + "@lru_cache(maxsize=1)\n", "def _ghostty_history_temp_roots():\n", " roots = {tempfile.gettempdir(), '/var/folders', '/private/var/folders'}\n", - " roots.update('/private' + root for root in list(roots) if root.startswith('/var/'))\n", - " roots.update(root.removeprefix('/private') for root in list(roots) if root.startswith('/private/'))\n", - " return [Path(root).resolve() for root in roots if root]\n", + " roots |= {'/private' + root for root in roots if root.startswith('/var/')}\n", + " roots |= {root.removeprefix('/private') for root in roots if root.startswith('/private/')}\n", + " return tuple(Path(root).resolve() for root in roots if root)\n", "\n", "\n", "def _valid_ghostty_history_path(candidate):\n", " try:\n", " path = Path(str(candidate).strip())\n", - " if path.name != 'history.txt' or not path.is_file() or path.stat().st_size > 10 * 1024 * 1024:\n", + " if path.name != 'history.txt' or not path.is_file() or path.stat().st_size > _GHOSTTY_HISTORY_MAX_BYTES:\n", " return False\n", " path = path.resolve()\n", " return any(os.path.commonpath([str(path), str(root)]) == str(root) for root in _ghostty_history_temp_roots())\n", @@ -537,8 +542,9 @@ "\n", "def _wait_for_ghostty_history_path(old_clip=None, timeout=1.0):\n", " start = time.time()\n", + " deadline = start + timeout\n", " old_clip = (old_clip or '').strip()\n", - " while time.time() < start + timeout:\n", + " while time.time() < deadline:\n", " candidate = (_pbpaste() or '').strip()\n", " if _valid_ghostty_history_path(candidate):\n", " if candidate != old_clip:\n", @@ -548,7 +554,7 @@ " return candidate\n", " except OSError:\n", " pass\n", - " time.sleep(0.05)\n", + " time.sleep(_GHOSTTY_POLL_INTERVAL)\n", " return None\n", "\n", "\n", @@ -652,7 +658,7 @@ " n = tmux_history_lim() if n is None or n < 0 else n\n", " return get_hist_tmux(n, pid)\n", "\n", - " n = 3000 if n is None or n < 0 else n\n", + " n = _GHOSTTY_DEFAULT_HISTORY_LINES if n is None or n < 0 else n\n", " if is_ghostty():\n", " return get_ghostty_history(n, pid)\n", " return get_history(n, pid)" diff --git a/shell_sage/core.py b/shell_sage/core.py index 560d5f2..41cf46c 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -16,7 +16,7 @@ from fastcore.utils import * from fastcore.meta import delegates from fastlite import database -from functools import partial, wraps +from functools import lru_cache, partial, wraps from rich.live import Live from rich.spinner import Spinner from rich.console import Console @@ -181,6 +181,10 @@ def tmux_history_lim(): # %% ../nbs/00_core.ipynb #0d70591e +_GHOSTTY_DEFAULT_HISTORY_LINES = 3000 +_GHOSTTY_HISTORY_MAX_BYTES = 10 * 1024 * 1024 +_GHOSTTY_POLL_INTERVAL = 0.05 + GHOSTTY_SCROLLBACK_SCRIPT = """ tell application "Ghostty" perform action "write_scrollback_file:copy,plain" on focused terminal of selected tab of front window @@ -205,17 +209,18 @@ def _run_osascript(script): subprocess.run(['osascript', '-e', script], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL) +@lru_cache(maxsize=1) def _ghostty_history_temp_roots(): roots = {tempfile.gettempdir(), '/var/folders', '/private/var/folders'} - roots.update('/private' + root for root in list(roots) if root.startswith('/var/')) - roots.update(root.removeprefix('/private') for root in list(roots) if root.startswith('/private/')) - return [Path(root).resolve() for root in roots if root] + roots |= {'/private' + root for root in roots if root.startswith('/var/')} + roots |= {root.removeprefix('/private') for root in roots if root.startswith('/private/')} + return tuple(Path(root).resolve() for root in roots if root) def _valid_ghostty_history_path(candidate): try: path = Path(str(candidate).strip()) - if path.name != 'history.txt' or not path.is_file() or path.stat().st_size > 10 * 1024 * 1024: + if path.name != 'history.txt' or not path.is_file() or path.stat().st_size > _GHOSTTY_HISTORY_MAX_BYTES: return False path = path.resolve() return any(os.path.commonpath([str(path), str(root)]) == str(root) for root in _ghostty_history_temp_roots()) @@ -225,8 +230,9 @@ def _valid_ghostty_history_path(candidate): def _wait_for_ghostty_history_path(old_clip=None, timeout=1.0): start = time.time() + deadline = start + timeout old_clip = (old_clip or '').strip() - while time.time() < start + timeout: + while time.time() < deadline: candidate = (_pbpaste() or '').strip() if _valid_ghostty_history_path(candidate): if candidate != old_clip: @@ -236,7 +242,7 @@ def _wait_for_ghostty_history_path(old_clip=None, timeout=1.0): return candidate except OSError: pass - time.sleep(0.05) + time.sleep(_GHOSTTY_POLL_INTERVAL) return None @@ -299,7 +305,7 @@ def get_terminal_history(n, pid='current'): n = tmux_history_lim() if n is None or n < 0 else n return get_hist_tmux(n, pid) - n = 3000 if n is None or n < 0 else n + n = _GHOSTTY_DEFAULT_HISTORY_LINES if n is None or n < 0 else n if is_ghostty(): return get_ghostty_history(n, pid) return get_history(n, pid) From 4e6aa3ac7d6dfd12bf2f8d33b21aa48a25809a97 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 10 May 2026 14:31:00 +0530 Subject: [PATCH 03/12] docs(readme): document ghostty terminal context --- README.md | 12 +++++++----- nbs/index.ipynb | 14 ++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 91e9cce..bdc54ea 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ -ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. +ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. [![PyPI version](https://badge.fury.io/py/shell-sage.svg)](https://badge.fury.io/py/shell-sage) [![Python 3.8+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) @@ -12,9 +12,9 @@ ShellSage is an AI-powered command-line assistant that integrates seamlessly wit ## Overview -ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. +ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. -ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It uses tmux to automatically read your terminal history or multiple pane histories to provide contextual assistance. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can even search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference. +ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It uses supported terminal-history providers—tmux and macOS Ghostty—to automatically read recent terminal context. Inside tmux, it can also read multiple pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can even search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference. ## Installation @@ -82,6 +82,8 @@ export OPENAI_API_KEY=sk... ShellSage works best with a properly configured tmux environment. I’ve created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt. +On macOS, ShellSage can also capture history from the front, focused Ghostty terminal. This requires Ghostty 1.3+ with AppleScript support enabled (`macos-applescript = true`). ShellSage invokes Ghostty’s `write_scrollback_file:copy,plain` action through AppleScript, briefly uses the macOS clipboard to receive the temporary `history.txt` path, reads that file, and restores the clipboard afterward. If Ghostty history capture fails, `ssage` behaves as before and sends no `` block. + ## Getting Started ### Your First Command @@ -154,7 +156,7 @@ ShellSage will provide the command, explain how it works, and give you practical ### Using Terminal Context -ShellSage automatically reads your tmux history to understand what you’re working on: +ShellSage automatically reads terminal history from supported providers to understand what you’re working on. Provider priority is tmux first, then macOS Ghostty when `TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`. Ghostty capture targets the front, focused terminal only; `--pid all` and pane-id targeting remain tmux-only, and Linux/GTK Ghostty history capture is not supported yet. If history cannot be captured, `ssage` continues without terminal context: ``` python # After running some commands that produced errors (e.g. find -name "*.tmp" .) @@ -275,7 +277,7 @@ One of ShellSage’s most powerful features is analyzing piped input: ### Working with Multiple Tmux Panes -When you have multiple panes open, you can reference specific ones by their ID (shown in your status bar): +When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is a tmux-only feature; Ghostty macOS support captures only the front, focused terminal for now. ![btop output](./screenshots/btop_output.png) diff --git a/nbs/index.ipynb b/nbs/index.ipynb index 65c4c93..fdb34b2 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -15,7 +15,7 @@ "id": "4038037e", "metadata": {}, "source": [ - "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", + "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", "\n", "[![PyPI version](https://badge.fury.io/py/shell-sage.svg)](https://badge.fury.io/py/shell-sage)\n", "[![Python 3.8+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)\n", @@ -36,9 +36,9 @@ "id": "0c0e8bb9", "metadata": {}, "source": [ - "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", + "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", "\n", - "ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It uses tmux to automatically read your terminal history or multiple pane histories to provide contextual assistance. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can even search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference." + "ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It uses supported terminal-history providers—tmux and macOS Ghostty—to automatically read recent terminal context. Inside tmux, it can also read multiple pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can even search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference." ] }, { @@ -158,7 +158,9 @@ "export OPENAI_API_KEY=sk...\n", "```\n", "\n", - "ShellSage works best with a properly configured tmux environment. I've created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt." + "ShellSage works best with a properly configured tmux environment. I've created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt.\n", + "\n", + "On macOS, ShellSage can also capture history from the front, focused Ghostty terminal. This requires Ghostty 1.3+ with AppleScript support enabled (`macos-applescript = true`). ShellSage invokes Ghostty's `write_scrollback_file:copy,plain` action through AppleScript, briefly uses the macOS clipboard to receive the temporary `history.txt` path, reads that file, and restores the clipboard afterward. If Ghostty history capture fails, `ssage` behaves as before and sends no `` block." ] }, { @@ -310,7 +312,7 @@ "id": "c98cda12", "metadata": {}, "source": [ - "ShellSage automatically reads your tmux history to understand what you're working on:" + "ShellSage automatically reads terminal history from supported providers to understand what you're working on. Provider priority is tmux first, then macOS Ghostty when `TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`. Ghostty capture targets the front, focused terminal only; `--pid all` and pane-id targeting remain tmux-only, and Linux/GTK Ghostty history capture is not supported yet. If history cannot be captured, `ssage` continues without terminal context:" ] }, { @@ -485,7 +487,7 @@ "id": "e64a6ecb", "metadata": {}, "source": [ - "When you have multiple panes open, you can reference specific ones by their ID (shown in your status bar):\n", + "When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is a tmux-only feature; Ghostty macOS support captures only the front, focused terminal for now.\n", "\n", "![btop output](./screenshots/btop_output.png)" ] From d13b2462a3adb560946e5507627f432c37bd24c9 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 10 May 2026 15:29:11 +0530 Subject: [PATCH 04/12] docs(readme): simplify ghostty terminal context --- README.md | 8 ++++---- nbs/index.ipynb | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bdc54ea..dea70f8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ShellSage is an AI-powered command-line assistant that integrates seamlessly wit ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. -ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It uses supported terminal-history providers—tmux and macOS Ghostty—to automatically read recent terminal context. Inside tmux, it can also read multiple pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can even search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference. +ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It reads recent terminal context from tmux, or from Ghostty on macOS; tmux can also provide specific pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference. ## Installation @@ -82,7 +82,7 @@ export OPENAI_API_KEY=sk... ShellSage works best with a properly configured tmux environment. I’ve created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt. -On macOS, ShellSage can also capture history from the front, focused Ghostty terminal. This requires Ghostty 1.3+ with AppleScript support enabled (`macos-applescript = true`). ShellSage invokes Ghostty’s `write_scrollback_file:copy,plain` action through AppleScript, briefly uses the macOS clipboard to receive the temporary `history.txt` path, reads that file, and restores the clipboard afterward. If Ghostty history capture fails, `ssage` behaves as before and sends no `` block. +On macOS, ShellSage can also read the front, focused Ghostty terminal. This requires Ghostty 1.3+ with AppleScript enabled (`macos-applescript = true`). ShellSage runs Ghostty’s `write_scrollback_file:copy,plain` action, uses the macOS clipboard only to receive the temporary `history.txt` path, then restores the clipboard. If capture fails, `ssage` behaves as before and sends no `` block. ## Getting Started @@ -156,7 +156,7 @@ ShellSage will provide the command, explain how it works, and give you practical ### Using Terminal Context -ShellSage automatically reads terminal history from supported providers to understand what you’re working on. Provider priority is tmux first, then macOS Ghostty when `TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`. Ghostty capture targets the front, focused terminal only; `--pid all` and pane-id targeting remain tmux-only, and Linux/GTK Ghostty history capture is not supported yet. If history cannot be captured, `ssage` continues without terminal context: +ShellSage automatically reads terminal history to understand what you’re working on. It uses tmux first. Outside tmux, it can use macOS Ghostty when `TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`. Ghostty capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. If capture fails, `ssage` continues without terminal context: ``` python # After running some commands that produced errors (e.g. find -name "*.tmp" .) @@ -277,7 +277,7 @@ One of ShellSage’s most powerful features is analyzing piped input: ### Working with Multiple Tmux Panes -When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is a tmux-only feature; Ghostty macOS support captures only the front, focused terminal for now. +When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is tmux-only; Ghostty macOS support captures only the front, focused terminal for now. ![btop output](./screenshots/btop_output.png) diff --git a/nbs/index.ipynb b/nbs/index.ipynb index fdb34b2..e70a8ac 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -38,7 +38,7 @@ "source": [ "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", "\n", - "ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It uses supported terminal-history providers—tmux and macOS Ghostty—to automatically read recent terminal context. Inside tmux, it can also read multiple pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can even search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference." + "ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It reads recent terminal context from tmux, or from Ghostty on macOS; tmux can also provide specific pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference." ] }, { @@ -160,7 +160,7 @@ "\n", "ShellSage works best with a properly configured tmux environment. I've created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt.\n", "\n", - "On macOS, ShellSage can also capture history from the front, focused Ghostty terminal. This requires Ghostty 1.3+ with AppleScript support enabled (`macos-applescript = true`). ShellSage invokes Ghostty's `write_scrollback_file:copy,plain` action through AppleScript, briefly uses the macOS clipboard to receive the temporary `history.txt` path, reads that file, and restores the clipboard afterward. If Ghostty history capture fails, `ssage` behaves as before and sends no `` block." + "On macOS, ShellSage can also read the front, focused Ghostty terminal. This requires Ghostty 1.3+ with AppleScript enabled (`macos-applescript = true`). ShellSage runs Ghostty's `write_scrollback_file:copy,plain` action, uses the macOS clipboard only to receive the temporary `history.txt` path, then restores the clipboard. If capture fails, `ssage` behaves as before and sends no `` block." ] }, { @@ -312,7 +312,7 @@ "id": "c98cda12", "metadata": {}, "source": [ - "ShellSage automatically reads terminal history from supported providers to understand what you're working on. Provider priority is tmux first, then macOS Ghostty when `TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`. Ghostty capture targets the front, focused terminal only; `--pid all` and pane-id targeting remain tmux-only, and Linux/GTK Ghostty history capture is not supported yet. If history cannot be captured, `ssage` continues without terminal context:" + "ShellSage automatically reads terminal history to understand what you're working on. It uses tmux first. Outside tmux, it can use macOS Ghostty when `TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`. Ghostty capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. If capture fails, `ssage` continues without terminal context:" ] }, { @@ -487,7 +487,7 @@ "id": "e64a6ecb", "metadata": {}, "source": [ - "When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is a tmux-only feature; Ghostty macOS support captures only the front, focused terminal for now.\n", + "When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is tmux-only; Ghostty macOS support captures only the front, focused terminal for now.\n", "\n", "![btop output](./screenshots/btop_output.png)" ] From 7ca268a76afea24d8fb0456bff407745b9e25c0e Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Tue, 12 May 2026 13:31:47 +0530 Subject: [PATCH 05/12] feat(core): add macos terminal history capture --- README.md | 12 ++--- nbs/00_core.ipynb | 34 ++++++++++---- nbs/index.ipynb | 12 ++--- shell_sage/_modidx.py | 2 + shell_sage/core.py | 43 +++++++++++------ tests/test_terminal_history_dispatch.py | 62 +++++++++++++++++++++++-- 6 files changed, 126 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index dea70f8..c914ed2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ -ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. +ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux, macOS Ghostty, Terminal.app, or iTerm2. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. [![PyPI version](https://badge.fury.io/py/shell-sage.svg)](https://badge.fury.io/py/shell-sage) [![Python 3.8+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) @@ -12,9 +12,9 @@ ShellSage is an AI-powered command-line assistant that integrates seamlessly wit ## Overview -ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. +ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux, macOS Ghostty, Terminal.app, or iTerm2. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system. -ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It reads recent terminal context from tmux, or from Ghostty on macOS; tmux can also provide specific pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference. +ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It reads recent terminal context from tmux, or on macOS from Ghostty, Terminal.app, or iTerm2; tmux can also provide specific pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference. ## Installation @@ -82,7 +82,7 @@ export OPENAI_API_KEY=sk... ShellSage works best with a properly configured tmux environment. I’ve created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt. -On macOS, ShellSage can also read the front, focused Ghostty terminal. This requires Ghostty 1.3+ with AppleScript enabled (`macos-applescript = true`). ShellSage runs Ghostty’s `write_scrollback_file:copy,plain` action, uses the macOS clipboard only to receive the temporary `history.txt` path, then restores the clipboard. If capture fails, `ssage` behaves as before and sends no `` block. +On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal. Ghostty needs version 1.3+ with AppleScript enabled (`macos-applescript = true`); ShellSage uses the macOS clipboard only to receive Ghostty’s temporary `history.txt` path, then restores the clipboard. Terminal.app and iTerm2 use AppleScript text capture directly. If capture fails, `ssage` behaves as before and sends no `` block. ## Getting Started @@ -156,7 +156,7 @@ ShellSage will provide the command, explain how it works, and give you practical ### Using Terminal Context -ShellSage automatically reads terminal history to understand what you’re working on. It uses tmux first. Outside tmux, it can use macOS Ghostty when `TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`. Ghostty capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. If capture fails, `ssage` continues without terminal context: +ShellSage automatically reads terminal history to understand what you’re working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. If capture fails, `ssage` continues without terminal context: ``` python # After running some commands that produced errors (e.g. find -name "*.tmp" .) @@ -277,7 +277,7 @@ One of ShellSage’s most powerful features is analyzing piped input: ### Working with Multiple Tmux Panes -When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is tmux-only; Ghostty macOS support captures only the front, focused terminal for now. +When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is tmux-only; Ghostty, Terminal.app, and iTerm2 macOS support capture only the front, focused terminal for now. ![btop output](./screenshots/btop_output.png) diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index e98d387..634ff0a 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -493,7 +493,7 @@ "outputs": [], "source": [ "#| export\n", - "_GHOSTTY_DEFAULT_HISTORY_LINES = 3000\n", + "_DEFAULT_TERMINAL_HISTORY_LINES = 3000\n", "_GHOSTTY_HISTORY_MAX_BYTES = 10 * 1024 * 1024\n", "_GHOSTTY_POLL_INTERVAL = 0.05\n", "\n", @@ -503,6 +503,12 @@ "end tell\n", "\"\"\"\n", "\n", + "MACOS_TERMINAL_HISTORY_SCRIPTS = {\n", + " 'Apple_Terminal': 'tell application \"Terminal\" to get the contents of the selected tab of the front window',\n", + " 'iTerm.app': 'tell application id \"com.googlecode.iterm2\" to get contents of current session of current tab of current window',\n", + "}\n", + "\n", + "\n", "def _pbpaste():\n", " try:\n", " return co(['pbpaste'], text=True, stderr=DEVNULL)\n", @@ -617,12 +623,8 @@ "source": [ "#| export\n", "def get_hist_osa(n, pid=''):\n", - " script = ({\n", - " 'Apple_Terminal':'tell application \"Terminal\" to get the contents of the selected tab of the front window',\n", - " 'iTerm.app':'tell application id \"com.googlecode.iterm2\" to get text of current session of current tab of current window',\n", - " }).get(os.getenv('TERM_PROGRAM'))\n", - " if not script: return None\n", - " return \"\\n\".join(co(['osascript', '-e', script], text=True).splitlines()[:n])" + " \"Backwards-compatible wrapper for macOS terminal history capture.\"\n", + " return get_macos_terminal_history(n, pid or 'current')" ] }, { @@ -644,7 +646,7 @@ "source": [ "#| export\n", "def get_history(n, pid='current'):\n", - " return get_hist_tmux(n, pid) or get_hist_osa(n)\n", + " return get_hist_tmux(n, pid) or get_hist_osa(n, pid)\n", "\n", "\n", "def get_ghostty_history(n, pid='current'):\n", @@ -653,15 +655,27 @@ " return get_ghostty_history_macos(n)\n", "\n", "\n", + "def get_macos_terminal_history(n, pid='current'):\n", + " if sys.platform != 'darwin' or pid not in ('current', None):\n", + " return None\n", + " script = MACOS_TERMINAL_HISTORY_SCRIPTS.get(os.environ.get('TERM_PROGRAM'))\n", + " if not script:\n", + " return None\n", + " try:\n", + " return _tail_lines(co(['osascript', '-e', script], text=True, stderr=DEVNULL), n)\n", + " except Exception:\n", + " return None\n", + "\n", + "\n", "def get_terminal_history(n, pid='current'):\n", " if os.environ.get('TMUX'):\n", " n = tmux_history_lim() if n is None or n < 0 else n\n", " return get_hist_tmux(n, pid)\n", "\n", - " n = _GHOSTTY_DEFAULT_HISTORY_LINES if n is None or n < 0 else n\n", + " n = _DEFAULT_TERMINAL_HISTORY_LINES if n is None or n < 0 else n\n", " if is_ghostty():\n", " return get_ghostty_history(n, pid)\n", - " return get_history(n, pid)" + " return get_macos_terminal_history(n, pid)" ] }, { diff --git a/nbs/index.ipynb b/nbs/index.ipynb index e70a8ac..9d6be01 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -15,7 +15,7 @@ "id": "4038037e", "metadata": {}, "source": [ - "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", + "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux, macOS Ghostty, Terminal.app, or iTerm2. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", "\n", "[![PyPI version](https://badge.fury.io/py/shell-sage.svg)](https://badge.fury.io/py/shell-sage)\n", "[![Python 3.8+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)\n", @@ -36,9 +36,9 @@ "id": "0c0e8bb9", "metadata": {}, "source": [ - "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux or macOS Ghostty. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", + "ShellSage is an AI-powered command-line assistant that integrates seamlessly with your terminal workflow through tmux, macOS Ghostty, Terminal.app, or iTerm2. It provides contextual help for shell operations, making it easier to navigate complex command-line tasks, debug scripts, and manage your system.\n", "\n", - "ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It reads recent terminal context from tmux, or from Ghostty on macOS; tmux can also provide specific pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference." + "ShellSage works with multiple LLM providers including Claude, GPT, and Ollama. It reads recent terminal context from tmux, or on macOS from Ghostty, Terminal.app, or iTerm2; tmux can also provide specific pane histories. You can pipe command output or file contents directly to ShellSage, and it can view files, search code, create files, and make edits with your permission. When needed, it can search the internet for up-to-date information. You can also log all your interactions directly to SQLite for later reference." ] }, { @@ -160,7 +160,7 @@ "\n", "ShellSage works best with a properly configured tmux environment. I've created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt.\n", "\n", - "On macOS, ShellSage can also read the front, focused Ghostty terminal. This requires Ghostty 1.3+ with AppleScript enabled (`macos-applescript = true`). ShellSage runs Ghostty's `write_scrollback_file:copy,plain` action, uses the macOS clipboard only to receive the temporary `history.txt` path, then restores the clipboard. If capture fails, `ssage` behaves as before and sends no `` block." + "On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal. Ghostty needs version 1.3+ with AppleScript enabled (`macos-applescript = true`); ShellSage uses the macOS clipboard only to receive Ghostty's temporary `history.txt` path, then restores the clipboard. Terminal.app and iTerm2 use AppleScript text capture directly. If capture fails, `ssage` behaves as before and sends no `` block." ] }, { @@ -312,7 +312,7 @@ "id": "c98cda12", "metadata": {}, "source": [ - "ShellSage automatically reads terminal history to understand what you're working on. It uses tmux first. Outside tmux, it can use macOS Ghostty when `TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`. Ghostty capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. If capture fails, `ssage` continues without terminal context:" + "ShellSage automatically reads terminal history to understand what you're working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. If capture fails, `ssage` continues without terminal context:" ] }, { @@ -487,7 +487,7 @@ "id": "e64a6ecb", "metadata": {}, "source": [ - "When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is tmux-only; Ghostty macOS support captures only the front, focused terminal for now.\n", + "When you have multiple tmux panes open, you can reference specific ones by their ID (shown in your status bar). This is tmux-only; Ghostty, Terminal.app, and iTerm2 macOS support capture only the front, focused terminal for now.\n", "\n", "![btop output](./screenshots/btop_output.png)" ] diff --git a/shell_sage/_modidx.py b/shell_sage/_modidx.py index 0d000c6..09d614c 100644 --- a/shell_sage/_modidx.py +++ b/shell_sage/_modidx.py @@ -33,6 +33,8 @@ 'shell_sage.core.get_hist_osa': ('core.html#get_hist_osa', 'shell_sage/core.py'), 'shell_sage.core.get_hist_tmux': ('core.html#get_hist_tmux', 'shell_sage/core.py'), 'shell_sage.core.get_history': ('core.html#get_history', 'shell_sage/core.py'), + 'shell_sage.core.get_macos_terminal_history': ( 'core.html#get_macos_terminal_history', + 'shell_sage/core.py'), 'shell_sage.core.get_opts': ('core.html#get_opts', 'shell_sage/core.py'), 'shell_sage.core.get_pane': ('core.html#get_pane', 'shell_sage/core.py'), 'shell_sage.core.get_panes': ('core.html#get_panes', 'shell_sage/core.py'), diff --git a/shell_sage/core.py b/shell_sage/core.py index 41cf46c..232c481 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -3,10 +3,11 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb. # %% auto #0 -__all__ = ['console', 'print', 'sp', 'ssp', 'GHOSTTY_SCROLLBACK_SCRIPT', 'default_cfg', 'tools', 'sps', 'log_path', 'pane_mark', - 'get_pane', 'get_panes', 'tmux_history_lim', 'get_ghostty_history_macos', 'is_ghostty', 'get_hist_tmux', - 'get_hist_osa', 'get_history', 'get_ghostty_history', 'get_terminal_history', 'get_opts', 'with_permission', - 'rg', 'ls', 'fd', 'get_sage', 'get_res', 'Log', 'mk_db', 'main', 'extract_cf', 'extract'] +__all__ = ['console', 'print', 'sp', 'ssp', 'GHOSTTY_SCROLLBACK_SCRIPT', 'MACOS_TERMINAL_HISTORY_SCRIPTS', 'default_cfg', 'tools', + 'sps', 'log_path', 'pane_mark', 'get_pane', 'get_panes', 'tmux_history_lim', 'get_ghostty_history_macos', + 'is_ghostty', 'get_hist_tmux', 'get_hist_osa', 'get_history', 'get_ghostty_history', + 'get_macos_terminal_history', 'get_terminal_history', 'get_opts', 'with_permission', 'rg', 'ls', 'fd', + 'get_sage', 'get_res', 'Log', 'mk_db', 'main', 'extract_cf', 'extract'] # %% ../nbs/00_core.ipynb #d7c5634a from contextlib import contextmanager @@ -181,7 +182,7 @@ def tmux_history_lim(): # %% ../nbs/00_core.ipynb #0d70591e -_GHOSTTY_DEFAULT_HISTORY_LINES = 3000 +_DEFAULT_TERMINAL_HISTORY_LINES = 3000 _GHOSTTY_HISTORY_MAX_BYTES = 10 * 1024 * 1024 _GHOSTTY_POLL_INTERVAL = 0.05 @@ -191,6 +192,12 @@ def tmux_history_lim(): end tell """ +MACOS_TERMINAL_HISTORY_SCRIPTS = { + 'Apple_Terminal': 'tell application "Terminal" to get the contents of the selected tab of the front window', + 'iTerm.app': 'tell application id "com.googlecode.iterm2" to get contents of current session of current tab of current window', +} + + def _pbpaste(): try: return co(['pbpaste'], text=True, stderr=DEVNULL) @@ -282,16 +289,12 @@ def get_hist_tmux(n, pid='current'): # %% ../nbs/00_core.ipynb #0eda0216 def get_hist_osa(n, pid=''): - script = ({ - 'Apple_Terminal':'tell application "Terminal" to get the contents of the selected tab of the front window', - 'iTerm.app':'tell application id "com.googlecode.iterm2" to get text of current session of current tab of current window', - }).get(os.getenv('TERM_PROGRAM')) - if not script: return None - return "\n".join(co(['osascript', '-e', script], text=True).splitlines()[:n]) + "Backwards-compatible wrapper for macOS terminal history capture." + return get_macos_terminal_history(n, pid or 'current') # %% ../nbs/00_core.ipynb #5344a2bd def get_history(n, pid='current'): - return get_hist_tmux(n, pid) or get_hist_osa(n) + return get_hist_tmux(n, pid) or get_hist_osa(n, pid) def get_ghostty_history(n, pid='current'): @@ -300,15 +303,27 @@ def get_ghostty_history(n, pid='current'): return get_ghostty_history_macos(n) +def get_macos_terminal_history(n, pid='current'): + if sys.platform != 'darwin' or pid not in ('current', None): + return None + script = MACOS_TERMINAL_HISTORY_SCRIPTS.get(os.environ.get('TERM_PROGRAM')) + if not script: + return None + try: + return _tail_lines(co(['osascript', '-e', script], text=True, stderr=DEVNULL), n) + except Exception: + return None + + def get_terminal_history(n, pid='current'): if os.environ.get('TMUX'): n = tmux_history_lim() if n is None or n < 0 else n return get_hist_tmux(n, pid) - n = _GHOSTTY_DEFAULT_HISTORY_LINES if n is None or n < 0 else n + n = _DEFAULT_TERMINAL_HISTORY_LINES if n is None or n < 0 else n if is_ghostty(): return get_ghostty_history(n, pid) - return get_history(n, pid) + return get_macos_terminal_history(n, pid) # %% ../nbs/00_core.ipynb #0dcbb503 default_cfg = asdict(ShellSageConfig()) diff --git a/tests/test_terminal_history_dispatch.py b/tests/test_terminal_history_dispatch.py index 07f0323..3eeefbe 100644 --- a/tests/test_terminal_history_dispatch.py +++ b/tests/test_terminal_history_dispatch.py @@ -136,11 +136,13 @@ def test_tmux_provider_wins_and_defaults_to_tmux_history_limit(self): with patch.dict(self.core.os.environ, {'TMUX': '/tmp/tmux', 'TERM_PROGRAM': 'ghostty'}, clear=True), \ patch.object(self.core, 'tmux_history_lim', return_value=123) as history_lim, \ patch.object(self.core, 'get_hist_tmux', return_value='tmux history') as get_tmux, \ - patch.object(self.core, 'get_ghostty_history_macos') as get_ghostty: + patch.object(self.core, 'get_ghostty_history_macos') as get_ghostty, \ + patch.object(self.core, 'get_macos_terminal_history') as get_terminal: self.assertEqual(self.core.get_terminal_history(None, 'all'), 'tmux history') history_lim.assert_called_once_with() get_tmux.assert_called_once_with(123, 'all') get_ghostty.assert_not_called() + get_terminal.assert_not_called() def test_explicit_history_lines_pass_through_to_tmux(self): with patch.dict(self.core.os.environ, {'TMUX': '/tmp/tmux'}, clear=True), \ @@ -155,7 +157,12 @@ def test_get_history_keeps_main_provider_order(self): patch.object(self.core, 'get_hist_osa', return_value='terminal history') as get_osa: self.assertEqual(self.core.get_history(12, 'current'), 'terminal history') get_tmux.assert_called_once_with(12, 'current') - get_osa.assert_called_once_with(12) + get_osa.assert_called_once_with(12, 'current') + + def test_get_hist_osa_remains_compatible(self): + with patch.object(self.core, 'get_macos_terminal_history', return_value='terminal history') as get_terminal: + self.assertEqual(self.core.get_hist_osa(12), 'terminal history') + get_terminal.assert_called_once_with(12, 'current') def test_ghostty_detection_uses_term_program_or_term(self): with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'ghostty'}, clear=True): @@ -172,9 +179,58 @@ def test_ghostty_detection_uses_term_program_or_term(self): def test_ghostty_provider_uses_default_history_lines(self): with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'ghostty'}, clear=True), \ patch.object(self.core.sys, 'platform', 'darwin'), \ - patch.object(self.core, 'get_ghostty_history_macos', return_value='ghostty history') as get_ghostty: + patch.object(self.core, 'get_ghostty_history_macos', return_value='ghostty history') as get_ghostty, \ + patch.object(self.core, 'get_macos_terminal_history') as get_terminal: self.assertEqual(self.core.get_terminal_history(-1, 'current'), 'ghostty history') get_ghostty.assert_called_once_with(3000) + get_terminal.assert_not_called() + + def test_terminal_app_provider_uses_default_history_lines(self): + with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ + patch.object(self.core, 'get_macos_terminal_history', return_value='terminal history') as get_terminal: + self.assertEqual(self.core.get_terminal_history(-1, 'current'), 'terminal history') + get_terminal.assert_called_once_with(3000, 'current') + + def test_terminal_app_history_reads_recent_lines_from_osascript(self): + with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, 'co', return_value='one\ntwo\nthree\n') as co: + self.assertEqual(self.core.get_macos_terminal_history(2), 'two\nthree') + co.assert_called_once_with( + ['osascript', '-e', self.core.MACOS_TERMINAL_HISTORY_SCRIPTS['Apple_Terminal']], + text=True, + stderr=self.core.DEVNULL, + ) + + def test_iterm_history_reads_recent_lines_from_osascript(self): + with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'iTerm.app'}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, 'co', return_value='alpha\nbeta\ngamma\n') as co: + self.assertEqual(self.core.get_macos_terminal_history(1), 'gamma') + co.assert_called_once_with( + ['osascript', '-e', self.core.MACOS_TERMINAL_HISTORY_SCRIPTS['iTerm.app']], + text=True, + stderr=self.core.DEVNULL, + ) + + def test_macos_terminal_rejects_unsupported_pid(self): + with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, 'co') as co: + self.assertIsNone(self.core.get_macos_terminal_history(10, 'all')) + self.assertIsNone(self.core.get_macos_terminal_history(10, '%2')) + co.assert_not_called() + + def test_macos_terminal_unsupported_or_failed_capture_returns_none(self): + with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ + patch.object(self.core.sys, 'platform', 'linux'), \ + patch.object(self.core, 'co') as co: + self.assertIsNone(self.core.get_macos_terminal_history(10)) + co.assert_not_called() + with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, 'co', side_effect=Exception('boom')): + self.assertIsNone(self.core.get_macos_terminal_history(10)) def test_ghostty_rejects_unsupported_pid(self): with patch.object(self.core.sys, 'platform', 'darwin'), \ From 2fa225374e9b533226457513235026167d11126e Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Tue, 12 May 2026 16:45:10 +0530 Subject: [PATCH 06/12] refactor(core): simplify terminal history dispatch --- nbs/00_core.ipynb | 16 +++++++--------- shell_sage/_modidx.py | 2 ++ shell_sage/core.py | 16 +++++++--------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index 634ff0a..e4baa3d 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -649,17 +649,17 @@ " return get_hist_tmux(n, pid) or get_hist_osa(n, pid)\n", "\n", "\n", + "def _is_current_macos_terminal(pid):\n", + " return sys.platform == 'darwin' and pid in ('current', None)\n", + "\n", + "\n", "def get_ghostty_history(n, pid='current'):\n", - " if sys.platform != 'darwin' or pid not in ('current', None):\n", - " return None\n", - " return get_ghostty_history_macos(n)\n", + " return get_ghostty_history_macos(n) if _is_current_macos_terminal(pid) else None\n", "\n", "\n", "def get_macos_terminal_history(n, pid='current'):\n", - " if sys.platform != 'darwin' or pid not in ('current', None):\n", - " return None\n", " script = MACOS_TERMINAL_HISTORY_SCRIPTS.get(os.environ.get('TERM_PROGRAM'))\n", - " if not script:\n", + " if not script or not _is_current_macos_terminal(pid):\n", " return None\n", " try:\n", " return _tail_lines(co(['osascript', '-e', script], text=True, stderr=DEVNULL), n)\n", @@ -673,9 +673,7 @@ " return get_hist_tmux(n, pid)\n", "\n", " n = _DEFAULT_TERMINAL_HISTORY_LINES if n is None or n < 0 else n\n", - " if is_ghostty():\n", - " return get_ghostty_history(n, pid)\n", - " return get_macos_terminal_history(n, pid)" + " return get_ghostty_history(n, pid) if is_ghostty() else get_macos_terminal_history(n, pid)" ] }, { diff --git a/shell_sage/_modidx.py b/shell_sage/_modidx.py index 09d614c..4c37e37 100644 --- a/shell_sage/_modidx.py +++ b/shell_sage/_modidx.py @@ -14,6 +14,8 @@ 'shell_sage.core._aliases': ('core.html#_aliases', 'shell_sage/core.py'), 'shell_sage.core._ghostty_history_temp_roots': ( 'core.html#_ghostty_history_temp_roots', 'shell_sage/core.py'), + 'shell_sage.core._is_current_macos_terminal': ( 'core.html#_is_current_macos_terminal', + 'shell_sage/core.py'), 'shell_sage.core._pause_live': ('core.html#_pause_live', 'shell_sage/core.py'), 'shell_sage.core._pbcopy': ('core.html#_pbcopy', 'shell_sage/core.py'), 'shell_sage.core._pbpaste': ('core.html#_pbpaste', 'shell_sage/core.py'), diff --git a/shell_sage/core.py b/shell_sage/core.py index 232c481..ae3fc6f 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -297,17 +297,17 @@ def get_history(n, pid='current'): return get_hist_tmux(n, pid) or get_hist_osa(n, pid) +def _is_current_macos_terminal(pid): + return sys.platform == 'darwin' and pid in ('current', None) + + def get_ghostty_history(n, pid='current'): - if sys.platform != 'darwin' or pid not in ('current', None): - return None - return get_ghostty_history_macos(n) + return get_ghostty_history_macos(n) if _is_current_macos_terminal(pid) else None def get_macos_terminal_history(n, pid='current'): - if sys.platform != 'darwin' or pid not in ('current', None): - return None script = MACOS_TERMINAL_HISTORY_SCRIPTS.get(os.environ.get('TERM_PROGRAM')) - if not script: + if not script or not _is_current_macos_terminal(pid): return None try: return _tail_lines(co(['osascript', '-e', script], text=True, stderr=DEVNULL), n) @@ -321,9 +321,7 @@ def get_terminal_history(n, pid='current'): return get_hist_tmux(n, pid) n = _DEFAULT_TERMINAL_HISTORY_LINES if n is None or n < 0 else n - if is_ghostty(): - return get_ghostty_history(n, pid) - return get_macos_terminal_history(n, pid) + return get_ghostty_history(n, pid) if is_ghostty() else get_macos_terminal_history(n, pid) # %% ../nbs/00_core.ipynb #0dcbb503 default_cfg = asdict(ShellSageConfig()) From e1cef8d3ae193c568359a57f0897b8206ecb6a06 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 17 May 2026 10:54:34 +0530 Subject: [PATCH 07/12] chore(core): prep terminal history PR --- README.md | 4 ++-- nbs/00_core.ipynb | 2 +- nbs/index.ipynb | 4 ++-- shell_sage/core.py | 2 +- tests/test_terminal_history_dispatch.py | 29 +++++++++++++++++++++++-- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c914ed2..e846b24 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ export OPENAI_API_KEY=sk... ShellSage works best with a properly configured tmux environment. I’ve created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt. -On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal. Ghostty needs version 1.3+ with AppleScript enabled (`macos-applescript = true`); ShellSage uses the macOS clipboard only to receive Ghostty’s temporary `history.txt` path, then restores the clipboard. Terminal.app and iTerm2 use AppleScript text capture directly. If capture fails, `ssage` behaves as before and sends no `` block. +On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal. Ghostty needs version 1.3+ with AppleScript enabled (`macos-applescript = true`); ShellSage uses the macOS clipboard only to receive Ghostty’s temporary `history.txt` path, then restores previous text clipboard contents when available. Terminal.app and iTerm2 use AppleScript text capture directly. If capture fails, `ssage` behaves as before and sends no `` block. ## Getting Started @@ -349,7 +349,7 @@ ShellSage can be customized through a configuration file located at `~/.config/s mode = 'default' # or "sassy" base_url = '' # Alternative API base URL api_key = '' # API key override instead of the default env var - history_lines = -1 # Lines of terminal history to include. -1 means include all + history_lines = -1 # Lines of terminal history; -1 uses tmux limit or 3000 outside tmux code_theme = "monokai" # Syntax highlighting theme code_lexer = "python" # Default lexer for inline code blocks log = False # Enable SQLite logging (required for code extraction) diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index e4baa3d..835373f 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -1167,7 +1167,7 @@ " v: Annotated[str, \"Print version\", dict(action='version')] = '%(prog)s ' + __version__,\n", " pid: str = 'current', # `current`, `all` or tmux pane_id (e.g. %0) for context\n", " skip_system: bool = False, # Whether to skip system information in the AI's context\n", - " history_lines: int = None, # Number of history lines. Defaults to tmux scrollback history length\n", + " history_lines: int = None, # Number of terminal history lines; defaults to tmux limit or 3000 outside tmux\n", " mode: str = 'default', # Available ShellSage modes: ['default', 'sassy']\n", " model: str = None, # The LLM model, optionally vendor-prefixed (e.g. 'codex/gpt-5.5', 'claude_code/claude-sonnet-4-6')\n", " search: str = None, # Wheather to allow the LLM to search the internet\n", diff --git a/nbs/index.ipynb b/nbs/index.ipynb index 9d6be01..7884d98 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -160,7 +160,7 @@ "\n", "ShellSage works best with a properly configured tmux environment. I've created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt.\n", "\n", - "On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal. Ghostty needs version 1.3+ with AppleScript enabled (`macos-applescript = true`); ShellSage uses the macOS clipboard only to receive Ghostty's temporary `history.txt` path, then restores the clipboard. Terminal.app and iTerm2 use AppleScript text capture directly. If capture fails, `ssage` behaves as before and sends no `` block." + "On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal. Ghostty needs version 1.3+ with AppleScript enabled (`macos-applescript = true`); ShellSage uses the macOS clipboard only to receive Ghostty's temporary `history.txt` path, then restores previous text clipboard contents when available. Terminal.app and iTerm2 use AppleScript text capture directly. If capture fails, `ssage` behaves as before and sends no `` block." ] }, { @@ -590,7 +590,7 @@ "mode = 'default' # or \"sassy\"\n", "base_url = '' # Alternative API base URL\n", "api_key = '' # API key override instead of the default env var\n", - "history_lines = -1 # Lines of terminal history to include. -1 means include all\n", + "history_lines = -1 # Lines of terminal history; -1 uses tmux limit or 3000 outside tmux\n", "code_theme = \"monokai\" # Syntax highlighting theme\n", "code_lexer = \"python\" # Default lexer for inline code blocks\n", "log = False # Enable SQLite logging (required for code extraction)\n", diff --git a/shell_sage/core.py b/shell_sage/core.py index ae3fc6f..e423388 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -447,7 +447,7 @@ async def main( v: Annotated[str, "Print version", dict(action='version')] = '%(prog)s ' + __version__, pid: str = 'current', # `current`, `all` or tmux pane_id (e.g. %0) for context skip_system: bool = False, # Whether to skip system information in the AI's context - history_lines: int = None, # Number of history lines. Defaults to tmux scrollback history length + history_lines: int = None, # Number of terminal history lines; defaults to tmux limit or 3000 outside tmux mode: str = 'default', # Available ShellSage modes: ['default', 'sassy'] model: str = None, # The LLM model, optionally vendor-prefixed (e.g. 'codex/gpt-5.5', 'claude_code/claude-sonnet-4-6') search: str = None, # Wheather to allow the LLM to search the internet diff --git a/tests/test_terminal_history_dispatch.py b/tests/test_terminal_history_dispatch.py index 3eeefbe..1577484 100644 --- a/tests/test_terminal_history_dispatch.py +++ b/tests/test_terminal_history_dispatch.py @@ -15,6 +15,27 @@ def _module(name, **attrs): return module +_MISSING = object() +_STUB_MODULES = [ + 'fastcore', 'fastcore.script', 'fastcore.tools', 'fastcore.utils', 'fastcore.meta', + 'fastlite', 'rich', 'rich.live', 'rich.spinner', 'rich.console', 'rich.markdown', + 'rich.syntax', 'shell_sage.config', 'safecmd', 'pyperclip', 'rgapi', 'fastllm', + 'fastllm.chat', 'shell_sage.core', +] + + +def _save_modules(): + return {name: sys.modules.get(name, _MISSING) for name in _STUB_MODULES} + + +def _restore_modules(saved): + for name, module in saved.items(): + if module is _MISSING: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + def _install_import_stubs(): """Import shell_sage.core without loading optional runtime dependencies.""" fastcore_script = _module('fastcore.script', call_parse=lambda f: f) @@ -123,14 +144,18 @@ def _call(self, *args, **kwargs): pass def import_core(): + saved_modules = _save_modules() _install_import_stubs() sys.modules.pop('shell_sage.core', None) - return importlib.import_module('shell_sage.core') + return importlib.import_module('shell_sage.core'), saved_modules class TerminalHistoryDispatchTests(unittest.TestCase): def setUp(self): - self.core = import_core() + self.core, self._saved_modules = import_core() + + def tearDown(self): + _restore_modules(self._saved_modules) def test_tmux_provider_wins_and_defaults_to_tmux_history_limit(self): with patch.dict(self.core.os.environ, {'TMUX': '/tmp/tmux', 'TERM_PROGRAM': 'ghostty'}, clear=True), \ From bcaa8404804e4e3d64a0c8896598b4722f499794 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 17 May 2026 11:42:39 +0530 Subject: [PATCH 08/12] refactor(core): simplify macos history dispatch --- README.md | 2 +- nbs/00_core.ipynb | 20 ++-- nbs/index.ipynb | 2 +- shell_sage/_modidx.py | 2 - shell_sage/core.py | 26 ++--- tests/test_terminal_history_dispatch.py | 129 +++++++++++++----------- 6 files changed, 87 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index e846b24..8ac2dbd 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ export OPENAI_API_KEY=sk... ShellSage works best with a properly configured tmux environment. I’ve created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt. -On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal. Ghostty needs version 1.3+ with AppleScript enabled (`macos-applescript = true`); ShellSage uses the macOS clipboard only to receive Ghostty’s temporary `history.txt` path, then restores previous text clipboard contents when available. Terminal.app and iTerm2 use AppleScript text capture directly. If capture fails, `ssage` behaves as before and sends no `` block. +On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal; see [Using Terminal Context](#using-terminal-context) for provider details and limitations. ## Getting Started diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index 835373f..440a108 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -523,10 +523,6 @@ " pass\n", "\n", "\n", - "def _run_osascript(script):\n", - " subprocess.run(['osascript', '-e', script], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL)\n", - "\n", - "\n", "@lru_cache(maxsize=1)\n", "def _ghostty_history_temp_roots():\n", " roots = {tempfile.gettempdir(), '/var/folders', '/private/var/folders'}\n", @@ -575,7 +571,7 @@ "def get_ghostty_history_macos(n):\n", " old_clip = _pbpaste()\n", " try:\n", - " _run_osascript(GHOSTTY_SCROLLBACK_SCRIPT)\n", + " subprocess.run(['osascript', '-e', GHOSTTY_SCROLLBACK_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL)\n", " path = _wait_for_ghostty_history_path(old_clip)\n", " if not path:\n", " return None\n", @@ -624,7 +620,7 @@ "#| export\n", "def get_hist_osa(n, pid=''):\n", " \"Backwards-compatible wrapper for macOS terminal history capture.\"\n", - " return get_macos_terminal_history(n, pid or 'current')" + " return get_macos_terminal_history(n) if _is_current_macos_terminal(pid or 'current') else None" ] }, { @@ -653,13 +649,9 @@ " return sys.platform == 'darwin' and pid in ('current', None)\n", "\n", "\n", - "def get_ghostty_history(n, pid='current'):\n", - " return get_ghostty_history_macos(n) if _is_current_macos_terminal(pid) else None\n", - "\n", - "\n", - "def get_macos_terminal_history(n, pid='current'):\n", + "def get_macos_terminal_history(n):\n", " script = MACOS_TERMINAL_HISTORY_SCRIPTS.get(os.environ.get('TERM_PROGRAM'))\n", - " if not script or not _is_current_macos_terminal(pid):\n", + " if not script or sys.platform != 'darwin':\n", " return None\n", " try:\n", " return _tail_lines(co(['osascript', '-e', script], text=True, stderr=DEVNULL), n)\n", @@ -671,9 +663,11 @@ " if os.environ.get('TMUX'):\n", " n = tmux_history_lim() if n is None or n < 0 else n\n", " return get_hist_tmux(n, pid)\n", + " if not _is_current_macos_terminal(pid):\n", + " return None\n", "\n", " n = _DEFAULT_TERMINAL_HISTORY_LINES if n is None or n < 0 else n\n", - " return get_ghostty_history(n, pid) if is_ghostty() else get_macos_terminal_history(n, pid)" + " return get_ghostty_history_macos(n) if is_ghostty() else get_macos_terminal_history(n)" ] }, { diff --git a/nbs/index.ipynb b/nbs/index.ipynb index 7884d98..09998e3 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -160,7 +160,7 @@ "\n", "ShellSage works best with a properly configured tmux environment. I've created a preconfigured [tmux configuration](tmux.conf) that works well with ShellSage. This configuration enables mouse support, adds pane IDs to your status bar so you can quickly reference them when having ShellSage read from specific panes, turns off alternative-screen so editor content like vim stays in the tmux buffer where ShellSage can see it, and adds a convenient shortcut (CTRL+B+E followed by the index number) for automatically extracting code fence blocks into your command prompt.\n", "\n", - "On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal. Ghostty needs version 1.3+ with AppleScript enabled (`macos-applescript = true`); ShellSage uses the macOS clipboard only to receive Ghostty's temporary `history.txt` path, then restores previous text clipboard contents when available. Terminal.app and iTerm2 use AppleScript text capture directly. If capture fails, `ssage` behaves as before and sends no `` block." + "On macOS, ShellSage can also read the front, focused Ghostty, Terminal.app, or iTerm2 terminal; see [Using Terminal Context](#using-terminal-context) for provider details and limitations." ] }, { diff --git a/shell_sage/_modidx.py b/shell_sage/_modidx.py index 4c37e37..8884546 100644 --- a/shell_sage/_modidx.py +++ b/shell_sage/_modidx.py @@ -19,7 +19,6 @@ 'shell_sage.core._pause_live': ('core.html#_pause_live', 'shell_sage/core.py'), 'shell_sage.core._pbcopy': ('core.html#_pbcopy', 'shell_sage/core.py'), 'shell_sage.core._pbpaste': ('core.html#_pbpaste', 'shell_sage/core.py'), - 'shell_sage.core._run_osascript': ('core.html#_run_osascript', 'shell_sage/core.py'), 'shell_sage.core._sys_info': ('core.html#_sys_info', 'shell_sage/core.py'), 'shell_sage.core._tail_lines': ('core.html#_tail_lines', 'shell_sage/core.py'), 'shell_sage.core._tmux_fmt': ('core.html#_tmux_fmt', 'shell_sage/core.py'), @@ -30,7 +29,6 @@ 'shell_sage.core.extract': ('core.html#extract', 'shell_sage/core.py'), 'shell_sage.core.extract_cf': ('core.html#extract_cf', 'shell_sage/core.py'), 'shell_sage.core.fd': ('core.html#fd', 'shell_sage/core.py'), - 'shell_sage.core.get_ghostty_history': ('core.html#get_ghostty_history', 'shell_sage/core.py'), 'shell_sage.core.get_ghostty_history_macos': ('core.html#get_ghostty_history_macos', 'shell_sage/core.py'), 'shell_sage.core.get_hist_osa': ('core.html#get_hist_osa', 'shell_sage/core.py'), 'shell_sage.core.get_hist_tmux': ('core.html#get_hist_tmux', 'shell_sage/core.py'), diff --git a/shell_sage/core.py b/shell_sage/core.py index e423388..1f9d657 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -5,9 +5,9 @@ # %% auto #0 __all__ = ['console', 'print', 'sp', 'ssp', 'GHOSTTY_SCROLLBACK_SCRIPT', 'MACOS_TERMINAL_HISTORY_SCRIPTS', 'default_cfg', 'tools', 'sps', 'log_path', 'pane_mark', 'get_pane', 'get_panes', 'tmux_history_lim', 'get_ghostty_history_macos', - 'is_ghostty', 'get_hist_tmux', 'get_hist_osa', 'get_history', 'get_ghostty_history', - 'get_macos_terminal_history', 'get_terminal_history', 'get_opts', 'with_permission', 'rg', 'ls', 'fd', - 'get_sage', 'get_res', 'Log', 'mk_db', 'main', 'extract_cf', 'extract'] + 'is_ghostty', 'get_hist_tmux', 'get_hist_osa', 'get_history', 'get_macos_terminal_history', + 'get_terminal_history', 'get_opts', 'with_permission', 'rg', 'ls', 'fd', 'get_sage', 'get_res', 'Log', + 'mk_db', 'main', 'extract_cf', 'extract'] # %% ../nbs/00_core.ipynb #d7c5634a from contextlib import contextmanager @@ -212,10 +212,6 @@ def _pbcopy(clip): pass -def _run_osascript(script): - subprocess.run(['osascript', '-e', script], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL) - - @lru_cache(maxsize=1) def _ghostty_history_temp_roots(): roots = {tempfile.gettempdir(), '/var/folders', '/private/var/folders'} @@ -264,7 +260,7 @@ def _tail_lines(text, n): def get_ghostty_history_macos(n): old_clip = _pbpaste() try: - _run_osascript(GHOSTTY_SCROLLBACK_SCRIPT) + subprocess.run(['osascript', '-e', GHOSTTY_SCROLLBACK_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL) path = _wait_for_ghostty_history_path(old_clip) if not path: return None @@ -290,7 +286,7 @@ def get_hist_tmux(n, pid='current'): # %% ../nbs/00_core.ipynb #0eda0216 def get_hist_osa(n, pid=''): "Backwards-compatible wrapper for macOS terminal history capture." - return get_macos_terminal_history(n, pid or 'current') + return get_macos_terminal_history(n) if _is_current_macos_terminal(pid or 'current') else None # %% ../nbs/00_core.ipynb #5344a2bd def get_history(n, pid='current'): @@ -301,13 +297,9 @@ def _is_current_macos_terminal(pid): return sys.platform == 'darwin' and pid in ('current', None) -def get_ghostty_history(n, pid='current'): - return get_ghostty_history_macos(n) if _is_current_macos_terminal(pid) else None - - -def get_macos_terminal_history(n, pid='current'): +def get_macos_terminal_history(n): script = MACOS_TERMINAL_HISTORY_SCRIPTS.get(os.environ.get('TERM_PROGRAM')) - if not script or not _is_current_macos_terminal(pid): + if not script or sys.platform != 'darwin': return None try: return _tail_lines(co(['osascript', '-e', script], text=True, stderr=DEVNULL), n) @@ -319,9 +311,11 @@ def get_terminal_history(n, pid='current'): if os.environ.get('TMUX'): n = tmux_history_lim() if n is None or n < 0 else n return get_hist_tmux(n, pid) + if not _is_current_macos_terminal(pid): + return None n = _DEFAULT_TERMINAL_HISTORY_LINES if n is None or n < 0 else n - return get_ghostty_history(n, pid) if is_ghostty() else get_macos_terminal_history(n, pid) + return get_ghostty_history_macos(n) if is_ghostty() else get_macos_terminal_history(n) # %% ../nbs/00_core.ipynb #0dcbb503 default_cfg = asdict(ShellSageConfig()) diff --git a/tests/test_terminal_history_dispatch.py b/tests/test_terminal_history_dispatch.py index 1577484..096a451 100644 --- a/tests/test_terminal_history_dispatch.py +++ b/tests/test_terminal_history_dispatch.py @@ -185,9 +185,11 @@ def test_get_history_keeps_main_provider_order(self): get_osa.assert_called_once_with(12, 'current') def test_get_hist_osa_remains_compatible(self): - with patch.object(self.core, 'get_macos_terminal_history', return_value='terminal history') as get_terminal: + with patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, 'get_macos_terminal_history', return_value='terminal history') as get_terminal: self.assertEqual(self.core.get_hist_osa(12), 'terminal history') - get_terminal.assert_called_once_with(12, 'current') + self.assertIsNone(self.core.get_hist_osa(12, '%2')) + get_terminal.assert_called_once_with(12) def test_ghostty_detection_uses_term_program_or_term(self): with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'ghostty'}, clear=True): @@ -201,68 +203,67 @@ def test_ghostty_detection_uses_term_program_or_term(self): ): self.assertFalse(self.core.is_ghostty()) - def test_ghostty_provider_uses_default_history_lines(self): - with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'ghostty'}, clear=True), \ - patch.object(self.core.sys, 'platform', 'darwin'), \ - patch.object(self.core, 'get_ghostty_history_macos', return_value='ghostty history') as get_ghostty, \ - patch.object(self.core, 'get_macos_terminal_history') as get_terminal: - self.assertEqual(self.core.get_terminal_history(-1, 'current'), 'ghostty history') - get_ghostty.assert_called_once_with(3000) - get_terminal.assert_not_called() + def test_non_tmux_providers_use_default_history_lines(self): + cases = [ + ('ghostty', 'get_ghostty_history_macos', 'get_macos_terminal_history', 'ghostty history'), + ('Apple_Terminal', 'get_macos_terminal_history', 'get_ghostty_history_macos', 'terminal history'), + ] + for term, provider, other, expected in cases: + with self.subTest(term=term), \ + patch.dict(self.core.os.environ, {'TERM_PROGRAM': term}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, provider, return_value=expected) as provider_fn, \ + patch.object(self.core, other) as other_fn: + self.assertEqual(self.core.get_terminal_history(-1, 'current'), expected) + provider_fn.assert_called_once_with(3000) + other_fn.assert_not_called() + + def test_macos_terminal_history_reads_recent_lines_from_osascript(self): + cases = [ + ('Apple_Terminal', 2, 'one\ntwo\nthree\n', 'two\nthree'), + ('iTerm.app', 1, 'alpha\nbeta\ngamma\n', 'gamma'), + ] + for term, n, output, expected in cases: + with self.subTest(term=term), \ + patch.dict(self.core.os.environ, {'TERM_PROGRAM': term}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, 'co', return_value=output) as co: + self.assertEqual(self.core.get_macos_terminal_history(n), expected) + co.assert_called_once_with( + ['osascript', '-e', self.core.MACOS_TERMINAL_HISTORY_SCRIPTS[term]], + text=True, + stderr=self.core.DEVNULL, + ) + + def test_non_tmux_rejects_unsupported_pid_or_platform_before_capture(self): + for term, provider in [ + ('Apple_Terminal', 'get_macos_terminal_history'), + ('ghostty', 'get_ghostty_history_macos'), + ]: + with self.subTest(term=term), \ + patch.dict(self.core.os.environ, {'TERM_PROGRAM': term}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'), \ + patch.object(self.core, provider) as capture: + self.assertIsNone(self.core.get_terminal_history(10, 'all')) + self.assertIsNone(self.core.get_terminal_history(10, '%2')) + capture.assert_not_called() - def test_terminal_app_provider_uses_default_history_lines(self): with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ - patch.object(self.core, 'get_macos_terminal_history', return_value='terminal history') as get_terminal: - self.assertEqual(self.core.get_terminal_history(-1, 'current'), 'terminal history') - get_terminal.assert_called_once_with(3000, 'current') + patch.object(self.core.sys, 'platform', 'linux'), \ + patch.object(self.core, 'get_macos_terminal_history') as capture: + self.assertIsNone(self.core.get_terminal_history(10)) + capture.assert_not_called() - def test_terminal_app_history_reads_recent_lines_from_osascript(self): - with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ - patch.object(self.core.sys, 'platform', 'darwin'), \ - patch.object(self.core, 'co', return_value='one\ntwo\nthree\n') as co: - self.assertEqual(self.core.get_macos_terminal_history(2), 'two\nthree') - co.assert_called_once_with( - ['osascript', '-e', self.core.MACOS_TERMINAL_HISTORY_SCRIPTS['Apple_Terminal']], - text=True, - stderr=self.core.DEVNULL, - ) - - def test_iterm_history_reads_recent_lines_from_osascript(self): - with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'iTerm.app'}, clear=True), \ - patch.object(self.core.sys, 'platform', 'darwin'), \ - patch.object(self.core, 'co', return_value='alpha\nbeta\ngamma\n') as co: - self.assertEqual(self.core.get_macos_terminal_history(1), 'gamma') - co.assert_called_once_with( - ['osascript', '-e', self.core.MACOS_TERMINAL_HISTORY_SCRIPTS['iTerm.app']], - text=True, - stderr=self.core.DEVNULL, - ) - - def test_macos_terminal_rejects_unsupported_pid(self): + def test_macos_terminal_failed_capture_returns_none(self): with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ patch.object(self.core.sys, 'platform', 'darwin'), \ - patch.object(self.core, 'co') as co: - self.assertIsNone(self.core.get_macos_terminal_history(10, 'all')) - self.assertIsNone(self.core.get_macos_terminal_history(10, '%2')) - co.assert_not_called() - - def test_macos_terminal_unsupported_or_failed_capture_returns_none(self): + patch.object(self.core, 'co', side_effect=Exception('boom')): + self.assertIsNone(self.core.get_terminal_history(10)) with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ patch.object(self.core.sys, 'platform', 'linux'), \ patch.object(self.core, 'co') as co: self.assertIsNone(self.core.get_macos_terminal_history(10)) co.assert_not_called() - with patch.dict(self.core.os.environ, {'TERM_PROGRAM': 'Apple_Terminal'}, clear=True), \ - patch.object(self.core.sys, 'platform', 'darwin'), \ - patch.object(self.core, 'co', side_effect=Exception('boom')): - self.assertIsNone(self.core.get_macos_terminal_history(10)) - - def test_ghostty_rejects_unsupported_pid(self): - with patch.object(self.core.sys, 'platform', 'darwin'), \ - patch.object(self.core, 'get_ghostty_history_macos') as macos_history: - self.assertIsNone(self.core.get_ghostty_history(10, 'all')) - self.assertIsNone(self.core.get_ghostty_history(10, '%2')) - macos_history.assert_not_called() def test_ghostty_macos_happy_path_reads_temp_history_and_restores_clipboard(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: @@ -270,20 +271,25 @@ def test_ghostty_macos_happy_path_reads_temp_history_and_restores_clipboard(self history_path.write_text('one\ntwo\nthree\nfour', encoding='utf-8') with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path)]), \ patch.object(self.core, '_pbcopy') as pbcopy, \ - patch.object(self.core, '_run_osascript') as run_osascript: + patch.object(self.core.subprocess, 'run') as run: self.assertEqual(self.core.get_ghostty_history_macos(2), 'three\nfour') - run_osascript.assert_called_once_with(self.core.GHOSTTY_SCROLLBACK_SCRIPT) + run.assert_called_once_with( + ['osascript', '-e', self.core.GHOSTTY_SCROLLBACK_SCRIPT], + text=True, + check=True, + stdout=self.core.DEVNULL, + stderr=self.core.DEVNULL, + ) pbcopy.assert_called_once_with('original clip') def test_ghostty_macos_invalid_clipboard_path_returns_none_and_restores_clipboard(self): clipboard_values = iter(['original clip', 'not a path']) - def fake_pbpaste(): - return next(clipboard_values, 'not a path') + def fake_pbpaste(): return next(clipboard_values, 'not a path') with patch.object(self.core, '_pbpaste', side_effect=fake_pbpaste), \ patch.object(self.core, '_pbcopy') as pbcopy, \ - patch.object(self.core, '_run_osascript'), \ + patch.object(self.core.subprocess, 'run'), \ patch.object(self.core.time, 'sleep'), \ patch.object(self.core.time, 'time', side_effect=[0, 0, 2]): self.assertIsNone(self.core.get_ghostty_history_macos(10)) @@ -295,7 +301,7 @@ def test_ghostty_macos_read_errors_return_none_and_restore_clipboard(self): history_path.write_text('history', encoding='utf-8') with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path)]), \ patch.object(self.core, '_pbcopy') as pbcopy, \ - patch.object(self.core, '_run_osascript'), \ + patch.object(self.core.subprocess, 'run'), \ patch.object(self.core.Path, 'read_text', side_effect=OSError('boom')): self.assertIsNone(self.core.get_ghostty_history_macos(10)) pbcopy.assert_called_once_with('original clip') @@ -316,7 +322,8 @@ def test_tail_lines_respects_history_line_count(self): self.assertEqual(self.core._tail_lines('one\ntwo', 0), '') def test_no_terminal_provider_returns_none(self): - with patch.dict(self.core.os.environ, {}, clear=True): + with patch.dict(self.core.os.environ, {}, clear=True), \ + patch.object(self.core.sys, 'platform', 'darwin'): self.assertIsNone(self.core.get_terminal_history(10, 'current')) From 263a10bd4f8af5f0c8afb2711a542d7aad2cd6e3 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Tue, 21 Jul 2026 13:06:16 +0530 Subject: [PATCH 09/12] fix(core): harden terminal history capture --- README.md | 2 +- nbs/00_core.ipynb | 65 +++++++++++++++--- nbs/index.ipynb | 2 +- shell_sage/_modidx.py | 2 + shell_sage/core.py | 65 +++++++++++++++--- tests/test_terminal_history_dispatch.py | 88 +++++++++++++++++++++---- 6 files changed, 194 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 8ac2dbd..f332ecf 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ ShellSage will provide the command, explain how it works, and give you practical ### Using Terminal Context -ShellSage automatically reads terminal history to understand what you’re working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. If capture fails, `ssage` continues without terminal context: +ShellSage automatically reads terminal history to understand what you’re working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. Ghostty 1.3+ requires AppleScript enabled (`macos-applescript = true`). Its capture action replaces the macOS clipboard with a temporary history-file path; ShellSage restores previous text only if that path is still present, and cannot preserve non-text clipboard formats. If capture fails, `ssage` continues without terminal context: ``` python # After running some commands that produced errors (e.g. find -name "*.tmp" .) diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index 440a108..86ea918 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -54,7 +54,7 @@ "from fastllm.chat import AsyncChat, StreamAccum\n", "\n", "import rgapi\n", - "import asyncio,os,pyperclip,re,subprocess,sys,tempfile,time,builtins\n", + "import asyncio,os,pyperclip,re,stat,subprocess,sys,tempfile,time,builtins\n", "from typing import Annotated" ] }, @@ -513,12 +513,14 @@ " try:\n", " return co(['pbpaste'], text=True, stderr=DEVNULL)\n", " except Exception:\n", - " return ''\n", + " return None\n", "\n", "\n", "def _pbcopy(clip):\n", + " if clip is None:\n", + " return\n", " try:\n", - " subprocess.run(['pbcopy'], input=clip or '', text=True, check=False, stdout=DEVNULL, stderr=DEVNULL)\n", + " subprocess.run(['pbcopy'], input=clip, text=True, check=False, stdout=DEVNULL, stderr=DEVNULL)\n", " except Exception:\n", " pass\n", "\n", @@ -568,18 +570,64 @@ " return '\\n'.join(text.splitlines()[-n:])\n", "\n", "\n", + "# [tag:ghostty_history_fd_validation] Validate and read the same descriptor so path swaps cannot escape temp roots.\n", + "def _read_ghostty_history_file(candidate, n):\n", + " descriptor = None\n", + " try:\n", + " path = Path(str(candidate).strip())\n", + " if path.name != 'history.txt':\n", + " return None\n", + " flags = os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0)\n", + " descriptor = os.open(path, flags)\n", + " opened = os.fstat(descriptor)\n", + " if not stat.S_ISREG(opened.st_mode) or opened.st_size > _GHOSTTY_HISTORY_MAX_BYTES:\n", + " return None\n", + "\n", + " resolved = path.resolve(strict=True)\n", + " if not any(os.path.commonpath([str(resolved), str(root)]) == str(root) for root in _ghostty_history_temp_roots()):\n", + " return None\n", + " current = resolved.stat()\n", + " if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):\n", + " return None\n", + "\n", + " chunks = []\n", + " remaining = _GHOSTTY_HISTORY_MAX_BYTES + 1\n", + " while remaining:\n", + " chunk = os.read(descriptor, min(64 * 1024, remaining))\n", + " if not chunk:\n", + " break\n", + " chunks.append(chunk)\n", + " remaining -= len(chunk)\n", + " data = b''.join(chunks)\n", + " if len(data) > _GHOSTTY_HISTORY_MAX_BYTES:\n", + " return None\n", + " return _tail_lines(data.decode('utf-8', errors='replace'), n)\n", + " except Exception:\n", + " return None\n", + " finally:\n", + " if descriptor is not None:\n", + " try:\n", + " os.close(descriptor)\n", + " except OSError:\n", + " pass\n", + "\n", + "\n", "def get_ghostty_history_macos(n):\n", " old_clip = _pbpaste()\n", + " history_path = None\n", " try:\n", " subprocess.run(['osascript', '-e', GHOSTTY_SCROLLBACK_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL)\n", - " path = _wait_for_ghostty_history_path(old_clip)\n", - " if not path:\n", + " history_path = _wait_for_ghostty_history_path(old_clip)\n", + " if not history_path:\n", " return None\n", - " return _tail_lines(Path(path).read_text(encoding='utf-8', errors='replace'), n)\n", + " return _read_ghostty_history_file(history_path, n)\n", " except Exception:\n", " return None\n", " finally:\n", - " _pbcopy(old_clip)\n", + " # [tag:ghostty_clipboard_restore] Never overwrite clipboard content changed while capture was running.\n", + " current_clip = _pbpaste() if old_clip is not None and history_path else None\n", + " if current_clip is not None and current_clip.strip() == str(history_path).strip():\n", + " _pbcopy(old_clip)\n", "\n", "\n", "def is_ghostty():\n", @@ -642,7 +690,8 @@ "source": [ "#| export\n", "def get_history(n, pid='current'):\n", - " return get_hist_tmux(n, pid) or get_hist_osa(n, pid)\n", + " \"Backwards-compatible entry point for terminal history dispatch.\"\n", + " return get_terminal_history(n, pid)\n", "\n", "\n", "def _is_current_macos_terminal(pid):\n", diff --git a/nbs/index.ipynb b/nbs/index.ipynb index 09998e3..c306152 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -312,7 +312,7 @@ "id": "c98cda12", "metadata": {}, "source": [ - "ShellSage automatically reads terminal history to understand what you're working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. If capture fails, `ssage` continues without terminal context:" + "ShellSage automatically reads terminal history to understand what you're working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. Ghostty 1.3+ requires AppleScript enabled (`macos-applescript = true`). Its capture action replaces the macOS clipboard with a temporary history-file path; ShellSage restores previous text only if that path is still present, and cannot preserve non-text clipboard formats. If capture fails, `ssage` continues without terminal context:" ] }, { diff --git a/shell_sage/_modidx.py b/shell_sage/_modidx.py index 8884546..f3e1e86 100644 --- a/shell_sage/_modidx.py +++ b/shell_sage/_modidx.py @@ -19,6 +19,8 @@ 'shell_sage.core._pause_live': ('core.html#_pause_live', 'shell_sage/core.py'), 'shell_sage.core._pbcopy': ('core.html#_pbcopy', 'shell_sage/core.py'), 'shell_sage.core._pbpaste': ('core.html#_pbpaste', 'shell_sage/core.py'), + 'shell_sage.core._read_ghostty_history_file': ( 'core.html#_read_ghostty_history_file', + 'shell_sage/core.py'), 'shell_sage.core._sys_info': ('core.html#_sys_info', 'shell_sage/core.py'), 'shell_sage.core._tail_lines': ('core.html#_tail_lines', 'shell_sage/core.py'), 'shell_sage.core._tmux_fmt': ('core.html#_tmux_fmt', 'shell_sage/core.py'), diff --git a/shell_sage/core.py b/shell_sage/core.py index 1f9d657..dad4b17 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -30,7 +30,7 @@ from fastllm.chat import AsyncChat, StreamAccum import rgapi -import asyncio,os,pyperclip,re,subprocess,sys,tempfile,time,builtins +import asyncio,os,pyperclip,re,stat,subprocess,sys,tempfile,time,builtins from typing import Annotated # %% ../nbs/00_core.ipynb #4d0676fd @@ -202,12 +202,14 @@ def _pbpaste(): try: return co(['pbpaste'], text=True, stderr=DEVNULL) except Exception: - return '' + return None def _pbcopy(clip): + if clip is None: + return try: - subprocess.run(['pbcopy'], input=clip or '', text=True, check=False, stdout=DEVNULL, stderr=DEVNULL) + subprocess.run(['pbcopy'], input=clip, text=True, check=False, stdout=DEVNULL, stderr=DEVNULL) except Exception: pass @@ -257,18 +259,64 @@ def _tail_lines(text, n): return '\n'.join(text.splitlines()[-n:]) +# [tag:ghostty_history_fd_validation] Validate and read the same descriptor so path swaps cannot escape temp roots. +def _read_ghostty_history_file(candidate, n): + descriptor = None + try: + path = Path(str(candidate).strip()) + if path.name != 'history.txt': + return None + flags = os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0) + descriptor = os.open(path, flags) + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_size > _GHOSTTY_HISTORY_MAX_BYTES: + return None + + resolved = path.resolve(strict=True) + if not any(os.path.commonpath([str(resolved), str(root)]) == str(root) for root in _ghostty_history_temp_roots()): + return None + current = resolved.stat() + if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino): + return None + + chunks = [] + remaining = _GHOSTTY_HISTORY_MAX_BYTES + 1 + while remaining: + chunk = os.read(descriptor, min(64 * 1024, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + data = b''.join(chunks) + if len(data) > _GHOSTTY_HISTORY_MAX_BYTES: + return None + return _tail_lines(data.decode('utf-8', errors='replace'), n) + except Exception: + return None + finally: + if descriptor is not None: + try: + os.close(descriptor) + except OSError: + pass + + def get_ghostty_history_macos(n): old_clip = _pbpaste() + history_path = None try: subprocess.run(['osascript', '-e', GHOSTTY_SCROLLBACK_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL) - path = _wait_for_ghostty_history_path(old_clip) - if not path: + history_path = _wait_for_ghostty_history_path(old_clip) + if not history_path: return None - return _tail_lines(Path(path).read_text(encoding='utf-8', errors='replace'), n) + return _read_ghostty_history_file(history_path, n) except Exception: return None finally: - _pbcopy(old_clip) + # [tag:ghostty_clipboard_restore] Never overwrite clipboard content changed while capture was running. + current_clip = _pbpaste() if old_clip is not None and history_path else None + if current_clip is not None and current_clip.strip() == str(history_path).strip(): + _pbcopy(old_clip) def is_ghostty(): @@ -290,7 +338,8 @@ def get_hist_osa(n, pid=''): # %% ../nbs/00_core.ipynb #5344a2bd def get_history(n, pid='current'): - return get_hist_tmux(n, pid) or get_hist_osa(n, pid) + "Backwards-compatible entry point for terminal history dispatch." + return get_terminal_history(n, pid) def _is_current_macos_terminal(pid): diff --git a/tests/test_terminal_history_dispatch.py b/tests/test_terminal_history_dispatch.py index 096a451..d7c93fc 100644 --- a/tests/test_terminal_history_dispatch.py +++ b/tests/test_terminal_history_dispatch.py @@ -177,12 +177,10 @@ def test_explicit_history_lines_pass_through_to_tmux(self): history_lim.assert_not_called() get_tmux.assert_called_once_with(42, '%1') - def test_get_history_keeps_main_provider_order(self): - with patch.object(self.core, 'get_hist_tmux', return_value=None) as get_tmux, \ - patch.object(self.core, 'get_hist_osa', return_value='terminal history') as get_osa: + def test_get_history_delegates_to_canonical_dispatcher(self): + with patch.object(self.core, 'get_terminal_history', return_value='terminal history') as get_terminal: self.assertEqual(self.core.get_history(12, 'current'), 'terminal history') - get_tmux.assert_called_once_with(12, 'current') - get_osa.assert_called_once_with(12, 'current') + get_terminal.assert_called_once_with(12, 'current') def test_get_hist_osa_remains_compatible(self): with patch.object(self.core.sys, 'platform', 'darwin'), \ @@ -269,7 +267,7 @@ def test_ghostty_macos_happy_path_reads_temp_history_and_restores_clipboard(self with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: history_path = Path(directory) / 'history.txt' history_path.write_text('one\ntwo\nthree\nfour', encoding='utf-8') - with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path)]), \ + with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ patch.object(self.core, '_pbcopy') as pbcopy, \ patch.object(self.core.subprocess, 'run') as run: self.assertEqual(self.core.get_ghostty_history_macos(2), 'three\nfour') @@ -282,7 +280,7 @@ def test_ghostty_macos_happy_path_reads_temp_history_and_restores_clipboard(self ) pbcopy.assert_called_once_with('original clip') - def test_ghostty_macos_invalid_clipboard_path_returns_none_and_restores_clipboard(self): + def test_ghostty_macos_invalid_path_does_not_restore_clipboard(self): clipboard_values = iter(['original clip', 'not a path']) def fake_pbpaste(): return next(clipboard_values, 'not a path') @@ -293,29 +291,95 @@ def fake_pbpaste(): return next(clipboard_values, 'not a path') patch.object(self.core.time, 'sleep'), \ patch.object(self.core.time, 'time', side_effect=[0, 0, 2]): self.assertIsNone(self.core.get_ghostty_history_macos(10)) - pbcopy.assert_called_once_with('original clip') + pbcopy.assert_not_called() + + # [ref:ghostty_clipboard_restore] + def test_ghostty_capture_does_not_overwrite_concurrent_clipboard_change(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: + history_path = Path(directory) / 'history.txt' + history_path.write_text('history', encoding='utf-8') + with patch.object( + self.core, + '_pbpaste', + side_effect=['original clip', str(history_path), 'new user clip'], + ), patch.object(self.core, '_pbcopy') as pbcopy, \ + patch.object(self.core.subprocess, 'run'): + self.assertEqual(self.core.get_ghostty_history_macos(10), 'history') + pbcopy.assert_not_called() + + def test_ghostty_capture_does_not_restore_unavailable_clipboard_text(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: + history_path = Path(directory) / 'history.txt' + history_path.write_text('history', encoding='utf-8') + with patch.object( + self.core, + '_pbpaste', + side_effect=[None, str(history_path), str(history_path)], + ), patch.object(self.core, '_pbcopy') as pbcopy, \ + patch.object(self.core.subprocess, 'run'): + self.assertEqual(self.core.get_ghostty_history_macos(10), 'history') + pbcopy.assert_not_called() + + def test_clipboard_helpers_preserve_unavailable_text_state(self): + with patch.object(self.core, 'co', side_effect=OSError('no text clipboard')): + self.assertIsNone(self.core._pbpaste()) + with patch.object(self.core.subprocess, 'run') as run: + self.core._pbcopy(None) + run.assert_not_called() def test_ghostty_macos_read_errors_return_none_and_restore_clipboard(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: history_path = Path(directory) / 'history.txt' history_path.write_text('history', encoding='utf-8') - with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path)]), \ + with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ patch.object(self.core, '_pbcopy') as pbcopy, \ patch.object(self.core.subprocess, 'run'), \ - patch.object(self.core.Path, 'read_text', side_effect=OSError('boom')): + patch.object(self.core.os, 'read', side_effect=OSError('boom')): self.assertIsNone(self.core.get_ghostty_history_macos(10)) pbcopy.assert_called_once_with('original clip') - def test_ghostty_path_validation_requires_temp_history_file(self): - with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: + def test_ghostty_path_validation_enforces_name_root_and_size(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ + tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: valid = Path(directory) / 'history.txt' valid.write_text('history', encoding='utf-8') wrong_name = Path(directory) / 'not-history.txt' wrong_name.write_text('history', encoding='utf-8') + oversized_target = Path(directory) / 'history.txt' + outside = Path(outside_directory) / 'history.txt' + outside.write_text('outside history', encoding='utf-8') + self.assertTrue(self.core._valid_ghostty_history_path(str(valid))) self.assertFalse(self.core._valid_ghostty_history_path(str(wrong_name))) + self.assertFalse(self.core._valid_ghostty_history_path(str(outside))) + self.assertIsNone(self.core._read_ghostty_history_file(outside, 10)) self.assertFalse(self.core._valid_ghostty_history_path('not a path')) + valid.unlink() + oversized_target.touch() + oversized_target.write_bytes(b'') + with oversized_target.open('r+b') as stream: + stream.truncate(self.core._GHOSTTY_HISTORY_MAX_BYTES + 1) + self.assertFalse(self.core._valid_ghostty_history_path(str(oversized_target))) + self.assertIsNone(self.core._read_ghostty_history_file(oversized_target, 10)) + + # [ref:ghostty_history_fd_validation] + def test_ghostty_safe_read_rejects_symlink_swap_after_validation(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ + tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: + safe_target = Path(directory) / 'safe-history.txt' + safe_target.write_text('safe history', encoding='utf-8') + candidate = Path(directory) / 'history.txt' + candidate.symlink_to(safe_target) + outside = Path(outside_directory) / 'secret.txt' + outside.write_text('secret outside history', encoding='utf-8') + + self.assertTrue(self.core._valid_ghostty_history_path(str(candidate))) + candidate.unlink() + candidate.symlink_to(outside) + + self.assertIsNone(self.core._read_ghostty_history_file(candidate, 10)) + def test_tail_lines_respects_history_line_count(self): self.assertEqual(self.core._tail_lines('one\ntwo\nthree', 2), 'two\nthree') self.assertEqual(self.core._tail_lines('one\ntwo', -1), 'one\ntwo') From efd1e4329783151d17b2a1f24e24a7b822539f49 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Wed, 22 Jul 2026 21:26:32 +0530 Subject: [PATCH 10/12] fix(core): scrub ghostty history artifacts --- nbs/00_core.ipynb | 24 +++++- shell_sage/core.py | 24 +++++- tests/test_terminal_history_dispatch.py | 101 ++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 8 deletions(-) diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index 86ea918..1290c8a 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -536,7 +536,10 @@ "def _valid_ghostty_history_path(candidate):\n", " try:\n", " path = Path(str(candidate).strip())\n", - " if path.name != 'history.txt' or not path.is_file() or path.stat().st_size > _GHOSTTY_HISTORY_MAX_BYTES:\n", + " info = path.stat()\n", + " owner_uid = getattr(os, 'getuid', lambda: info.st_uid)()\n", + " if (path.name != 'history.txt' or not stat.S_ISREG(info.st_mode) or\n", + " info.st_size > _GHOSTTY_HISTORY_MAX_BYTES or info.st_nlink != 1 or info.st_uid != owner_uid):\n", " return False\n", " path = path.resolve()\n", " return any(os.path.commonpath([str(path), str(root)]) == str(root) for root in _ghostty_history_temp_roots())\n", @@ -573,22 +576,27 @@ "# [tag:ghostty_history_fd_validation] Validate and read the same descriptor so path swaps cannot escape temp roots.\n", "def _read_ghostty_history_file(candidate, n):\n", " descriptor = None\n", + " validated = False\n", " try:\n", " path = Path(str(candidate).strip())\n", " if path.name != 'history.txt':\n", " return None\n", - " flags = os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0)\n", + " flags = os.O_RDWR | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0)\n", " descriptor = os.open(path, flags)\n", " opened = os.fstat(descriptor)\n", - " if not stat.S_ISREG(opened.st_mode) or opened.st_size > _GHOSTTY_HISTORY_MAX_BYTES:\n", + " owner_uid = getattr(os, 'getuid', lambda: opened.st_uid)()\n", + " if (not stat.S_ISREG(opened.st_mode) or opened.st_size > _GHOSTTY_HISTORY_MAX_BYTES or\n", + " opened.st_nlink != 1 or opened.st_uid != owner_uid):\n", " return None\n", "\n", " resolved = path.resolve(strict=True)\n", " if not any(os.path.commonpath([str(resolved), str(root)]) == str(root) for root in _ghostty_history_temp_roots()):\n", " return None\n", " current = resolved.stat()\n", - " if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):\n", + " if ((opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino) or\n", + " current.st_nlink != 1 or current.st_uid != owner_uid):\n", " return None\n", + " validated = True\n", "\n", " chunks = []\n", " remaining = _GHOSTTY_HISTORY_MAX_BYTES + 1\n", @@ -606,6 +614,14 @@ " return None\n", " finally:\n", " if descriptor is not None:\n", + " # [tag:ghostty_history_cleanup] Scrub the validated descriptor; unlinking by path would reintroduce a swap race.\n", + " if validated:\n", + " try:\n", + " latest = os.fstat(descriptor)\n", + " if latest.st_nlink == 1 and latest.st_uid == owner_uid:\n", + " os.ftruncate(descriptor, 0)\n", + " except OSError:\n", + " pass\n", " try:\n", " os.close(descriptor)\n", " except OSError:\n", diff --git a/shell_sage/core.py b/shell_sage/core.py index dad4b17..26b3530 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -225,7 +225,10 @@ def _ghostty_history_temp_roots(): def _valid_ghostty_history_path(candidate): try: path = Path(str(candidate).strip()) - if path.name != 'history.txt' or not path.is_file() or path.stat().st_size > _GHOSTTY_HISTORY_MAX_BYTES: + info = path.stat() + owner_uid = getattr(os, 'getuid', lambda: info.st_uid)() + if (path.name != 'history.txt' or not stat.S_ISREG(info.st_mode) or + info.st_size > _GHOSTTY_HISTORY_MAX_BYTES or info.st_nlink != 1 or info.st_uid != owner_uid): return False path = path.resolve() return any(os.path.commonpath([str(path), str(root)]) == str(root) for root in _ghostty_history_temp_roots()) @@ -262,22 +265,27 @@ def _tail_lines(text, n): # [tag:ghostty_history_fd_validation] Validate and read the same descriptor so path swaps cannot escape temp roots. def _read_ghostty_history_file(candidate, n): descriptor = None + validated = False try: path = Path(str(candidate).strip()) if path.name != 'history.txt': return None - flags = os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0) + flags = os.O_RDWR | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0) descriptor = os.open(path, flags) opened = os.fstat(descriptor) - if not stat.S_ISREG(opened.st_mode) or opened.st_size > _GHOSTTY_HISTORY_MAX_BYTES: + owner_uid = getattr(os, 'getuid', lambda: opened.st_uid)() + if (not stat.S_ISREG(opened.st_mode) or opened.st_size > _GHOSTTY_HISTORY_MAX_BYTES or + opened.st_nlink != 1 or opened.st_uid != owner_uid): return None resolved = path.resolve(strict=True) if not any(os.path.commonpath([str(resolved), str(root)]) == str(root) for root in _ghostty_history_temp_roots()): return None current = resolved.stat() - if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino): + if ((opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino) or + current.st_nlink != 1 or current.st_uid != owner_uid): return None + validated = True chunks = [] remaining = _GHOSTTY_HISTORY_MAX_BYTES + 1 @@ -295,6 +303,14 @@ def _read_ghostty_history_file(candidate, n): return None finally: if descriptor is not None: + # [tag:ghostty_history_cleanup] Scrub the validated descriptor; unlinking by path would reintroduce a swap race. + if validated: + try: + latest = os.fstat(descriptor) + if latest.st_nlink == 1 and latest.st_uid == owner_uid: + os.ftruncate(descriptor, 0) + except OSError: + pass try: os.close(descriptor) except OSError: diff --git a/tests/test_terminal_history_dispatch.py b/tests/test_terminal_history_dispatch.py index d7c93fc..9aa3c7d 100644 --- a/tests/test_terminal_history_dispatch.py +++ b/tests/test_terminal_history_dispatch.py @@ -1,3 +1,4 @@ +import errno import importlib import sys import tempfile @@ -263,6 +264,103 @@ def test_macos_terminal_failed_capture_returns_none(self): self.assertIsNone(self.core.get_macos_terminal_history(10)) co.assert_not_called() + # [ref:ghostty_history_cleanup] + def test_ghostty_capture_scrubs_consumed_history_file(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: + history_path = Path(directory) / 'history.txt' + history_path.write_text('history', encoding='utf-8') + with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ + patch.object(self.core, '_pbcopy'), \ + patch.object(self.core.subprocess, 'run'): + self.assertEqual(self.core.get_ghostty_history_macos(10), 'history') + self.assertTrue(history_path.exists()) + self.assertEqual(history_path.read_bytes(), b'') + + # [ref:ghostty_history_cleanup] + def test_ghostty_cleanup_uses_descriptor_not_replacement_path(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ + tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: + history_path = Path(directory) / 'history.txt' + history_path.write_text('history', encoding='utf-8') + replacement = Path(outside_directory) / 'replacement.txt' + replacement.write_text('do not scrub', encoding='utf-8') + original_read = self.core.os.read + swapped = False + + def swap_path_then_read(descriptor, size): + nonlocal swapped + if not swapped: + history_path.unlink() + history_path.symlink_to(replacement) + swapped = True + return original_read(descriptor, size) + + with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ + patch.object(self.core, '_pbcopy'), \ + patch.object(self.core.subprocess, 'run'), \ + patch.object(self.core.os, 'read', side_effect=swap_path_then_read): + self.assertEqual(self.core.get_ghostty_history_macos(10), 'history') + self.assertTrue(history_path.is_symlink()) + self.assertEqual(replacement.read_text(encoding='utf-8'), 'do not scrub') + + def test_ghostty_cleanup_rejects_preexisting_hard_link(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ + tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: + outside = Path(outside_directory) / 'outside.txt' + outside.write_text('do not scrub', encoding='utf-8') + history_path = Path(directory) / 'history.txt' + try: + self.core.os.link(outside, history_path) + except OSError as error: + if error.errno in (errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP): + self.skipTest(f'hard links unavailable: {error}') + raise + + self.assertIsNone(self.core._read_ghostty_history_file(history_path, 10)) + self.assertEqual(outside.read_text(encoding='utf-8'), 'do not scrub') + self.assertFalse(self.core._valid_ghostty_history_path(history_path)) + + def test_ghostty_cleanup_skips_scrub_if_hard_link_appears_during_read(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ + tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: + history_path = Path(directory) / 'history.txt' + history_path.write_text('history', encoding='utf-8') + alias = Path(outside_directory) / 'alias.txt' + probe = Path(outside_directory) / 'probe.txt' + try: + self.core.os.link(history_path, probe) + except OSError as error: + if error.errno in (errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP): + self.skipTest(f'hard links unavailable: {error}') + raise + probe.unlink() + original_read = self.core.os.read + linked = False + + def link_then_read(descriptor, size): + nonlocal linked + if not linked: + self.core.os.link(history_path, alias) + linked = True + return original_read(descriptor, size) + + with patch.object(self.core.os, 'read', side_effect=link_then_read): + self.assertEqual(self.core._read_ghostty_history_file(history_path, 10), 'history') + self.assertEqual(history_path.read_text(encoding='utf-8'), 'history') + self.assertEqual(alias.read_text(encoding='utf-8'), 'history') + + def test_ghostty_scrub_failure_does_not_mask_history_or_clipboard_restore(self): + with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: + history_path = Path(directory) / 'history.txt' + history_path.write_text('history', encoding='utf-8') + with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ + patch.object(self.core, '_pbcopy') as pbcopy, \ + patch.object(self.core.subprocess, 'run'), \ + patch.object(self.core.os, 'ftruncate', side_effect=OSError('cannot scrub')): + self.assertEqual(self.core.get_ghostty_history_macos(10), 'history') + pbcopy.assert_called_once_with('original clip') + self.assertEqual(history_path.read_text(encoding='utf-8'), 'history') + def test_ghostty_macos_happy_path_reads_temp_history_and_restores_clipboard(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: history_path = Path(directory) / 'history.txt' @@ -337,6 +435,8 @@ def test_ghostty_macos_read_errors_return_none_and_restore_clipboard(self): patch.object(self.core.os, 'read', side_effect=OSError('boom')): self.assertIsNone(self.core.get_ghostty_history_macos(10)) pbcopy.assert_called_once_with('original clip') + self.assertTrue(history_path.exists()) + self.assertEqual(history_path.read_bytes(), b'') def test_ghostty_path_validation_enforces_name_root_and_size(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ @@ -353,6 +453,7 @@ def test_ghostty_path_validation_enforces_name_root_and_size(self): self.assertFalse(self.core._valid_ghostty_history_path(str(wrong_name))) self.assertFalse(self.core._valid_ghostty_history_path(str(outside))) self.assertIsNone(self.core._read_ghostty_history_file(outside, 10)) + self.assertEqual(outside.read_text(encoding='utf-8'), 'outside history') self.assertFalse(self.core._valid_ghostty_history_path('not a path')) valid.unlink() From 55af1136920b372ef339ac9253f6a053781e8874 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 26 Jul 2026 15:23:34 -0400 Subject: [PATCH 11/12] fix(core): capture full ghostty screen --- README.md | 2 +- nbs/00_core.ipynb | 10 ++++---- nbs/index.ipynb | 2 +- shell_sage/_modidx.py | 2 +- shell_sage/core.py | 12 +++++----- tests/test_terminal_history_dispatch.py | 31 +++++++++++++------------ 6 files changed, 30 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f332ecf..f3ecedf 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ ShellSage will provide the command, explain how it works, and give you practical ### Using Terminal Context -ShellSage automatically reads terminal history to understand what you’re working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. Ghostty 1.3+ requires AppleScript enabled (`macos-applescript = true`). Its capture action replaces the macOS clipboard with a temporary history-file path; ShellSage restores previous text only if that path is still present, and cannot preserve non-text clipboard formats. If capture fails, `ssage` continues without terminal context: +ShellSage automatically reads terminal history to understand what you’re working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. Ghostty 1.3+ requires AppleScript enabled (`macos-applescript = true`) and captures its full screen buffer: scrollback plus visible content, including alternate-screen apps. Its capture action temporarily replaces the macOS clipboard with a screen-file path; ShellSage restores previous text only if that path is still present and attempts to scrub the captured file after reading, but cannot preserve non-text clipboard formats. If capture fails, `ssage` continues without terminal context: ``` python # After running some commands that produced errors (e.g. find -name "*.tmp" .) diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index 1290c8a..e98dbbe 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -497,9 +497,9 @@ "_GHOSTTY_HISTORY_MAX_BYTES = 10 * 1024 * 1024\n", "_GHOSTTY_POLL_INTERVAL = 0.05\n", "\n", - "GHOSTTY_SCROLLBACK_SCRIPT = \"\"\"\n", + "GHOSTTY_SCREEN_SCRIPT = \"\"\"\n", "tell application \"Ghostty\"\n", - " perform action \"write_scrollback_file:copy,plain\" on focused terminal of selected tab of front window\n", + " perform action \"write_screen_file:copy,plain\" on focused terminal of selected tab of front window\n", "end tell\n", "\"\"\"\n", "\n", @@ -538,7 +538,7 @@ " path = Path(str(candidate).strip())\n", " info = path.stat()\n", " owner_uid = getattr(os, 'getuid', lambda: info.st_uid)()\n", - " if (path.name != 'history.txt' or not stat.S_ISREG(info.st_mode) or\n", + " if (path.name != 'screen.txt' or not stat.S_ISREG(info.st_mode) or\n", " info.st_size > _GHOSTTY_HISTORY_MAX_BYTES or info.st_nlink != 1 or info.st_uid != owner_uid):\n", " return False\n", " path = path.resolve()\n", @@ -579,7 +579,7 @@ " validated = False\n", " try:\n", " path = Path(str(candidate).strip())\n", - " if path.name != 'history.txt':\n", + " if path.name != 'screen.txt':\n", " return None\n", " flags = os.O_RDWR | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0)\n", " descriptor = os.open(path, flags)\n", @@ -632,7 +632,7 @@ " old_clip = _pbpaste()\n", " history_path = None\n", " try:\n", - " subprocess.run(['osascript', '-e', GHOSTTY_SCROLLBACK_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL)\n", + " subprocess.run(['osascript', '-e', GHOSTTY_SCREEN_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL)\n", " history_path = _wait_for_ghostty_history_path(old_clip)\n", " if not history_path:\n", " return None\n", diff --git a/nbs/index.ipynb b/nbs/index.ipynb index c306152..5d05760 100644 --- a/nbs/index.ipynb +++ b/nbs/index.ipynb @@ -312,7 +312,7 @@ "id": "c98cda12", "metadata": {}, "source": [ - "ShellSage automatically reads terminal history to understand what you're working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. Ghostty 1.3+ requires AppleScript enabled (`macos-applescript = true`). Its capture action replaces the macOS clipboard with a temporary history-file path; ShellSage restores previous text only if that path is still present, and cannot preserve non-text clipboard formats. If capture fails, `ssage` continues without terminal context:" + "ShellSage automatically reads terminal history to understand what you're working on. It uses tmux first. Outside tmux, it can use macOS Ghostty (`TERM_PROGRAM=ghostty` or `TERM` starts with `xterm-ghostty`) or Terminal.app/iTerm2 (`TERM_PROGRAM=Apple_Terminal`/`iTerm.app`). macOS terminal capture is focused-terminal only: pane IDs and `--pid all` remain tmux-only, and Linux/GTK Ghostty is not supported yet. Ghostty 1.3+ requires AppleScript enabled (`macos-applescript = true`) and captures its full screen buffer: scrollback plus visible content, including alternate-screen apps. Its capture action temporarily replaces the macOS clipboard with a screen-file path; ShellSage restores previous text only if that path is still present and attempts to scrub the captured file after reading, but cannot preserve non-text clipboard formats. If capture fails, `ssage` continues without terminal context:" ] }, { diff --git a/shell_sage/_modidx.py b/shell_sage/_modidx.py index f3e1e86..0f2c8d5 100644 --- a/shell_sage/_modidx.py +++ b/shell_sage/_modidx.py @@ -20,7 +20,7 @@ 'shell_sage.core._pbcopy': ('core.html#_pbcopy', 'shell_sage/core.py'), 'shell_sage.core._pbpaste': ('core.html#_pbpaste', 'shell_sage/core.py'), 'shell_sage.core._read_ghostty_history_file': ( 'core.html#_read_ghostty_history_file', - 'shell_sage/core.py'), + 'shell_sage/core.py'), 'shell_sage.core._sys_info': ('core.html#_sys_info', 'shell_sage/core.py'), 'shell_sage.core._tail_lines': ('core.html#_tail_lines', 'shell_sage/core.py'), 'shell_sage.core._tmux_fmt': ('core.html#_tmux_fmt', 'shell_sage/core.py'), diff --git a/shell_sage/core.py b/shell_sage/core.py index 26b3530..90390c1 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -3,7 +3,7 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb. # %% auto #0 -__all__ = ['console', 'print', 'sp', 'ssp', 'GHOSTTY_SCROLLBACK_SCRIPT', 'MACOS_TERMINAL_HISTORY_SCRIPTS', 'default_cfg', 'tools', +__all__ = ['console', 'print', 'sp', 'ssp', 'GHOSTTY_SCREEN_SCRIPT', 'MACOS_TERMINAL_HISTORY_SCRIPTS', 'default_cfg', 'tools', 'sps', 'log_path', 'pane_mark', 'get_pane', 'get_panes', 'tmux_history_lim', 'get_ghostty_history_macos', 'is_ghostty', 'get_hist_tmux', 'get_hist_osa', 'get_history', 'get_macos_terminal_history', 'get_terminal_history', 'get_opts', 'with_permission', 'rg', 'ls', 'fd', 'get_sage', 'get_res', 'Log', @@ -186,9 +186,9 @@ def tmux_history_lim(): _GHOSTTY_HISTORY_MAX_BYTES = 10 * 1024 * 1024 _GHOSTTY_POLL_INTERVAL = 0.05 -GHOSTTY_SCROLLBACK_SCRIPT = """ +GHOSTTY_SCREEN_SCRIPT = """ tell application "Ghostty" - perform action "write_scrollback_file:copy,plain" on focused terminal of selected tab of front window + perform action "write_screen_file:copy,plain" on focused terminal of selected tab of front window end tell """ @@ -227,7 +227,7 @@ def _valid_ghostty_history_path(candidate): path = Path(str(candidate).strip()) info = path.stat() owner_uid = getattr(os, 'getuid', lambda: info.st_uid)() - if (path.name != 'history.txt' or not stat.S_ISREG(info.st_mode) or + if (path.name != 'screen.txt' or not stat.S_ISREG(info.st_mode) or info.st_size > _GHOSTTY_HISTORY_MAX_BYTES or info.st_nlink != 1 or info.st_uid != owner_uid): return False path = path.resolve() @@ -268,7 +268,7 @@ def _read_ghostty_history_file(candidate, n): validated = False try: path = Path(str(candidate).strip()) - if path.name != 'history.txt': + if path.name != 'screen.txt': return None flags = os.O_RDWR | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0) descriptor = os.open(path, flags) @@ -321,7 +321,7 @@ def get_ghostty_history_macos(n): old_clip = _pbpaste() history_path = None try: - subprocess.run(['osascript', '-e', GHOSTTY_SCROLLBACK_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL) + subprocess.run(['osascript', '-e', GHOSTTY_SCREEN_SCRIPT], text=True, check=True, stdout=DEVNULL, stderr=DEVNULL) history_path = _wait_for_ghostty_history_path(old_clip) if not history_path: return None diff --git a/tests/test_terminal_history_dispatch.py b/tests/test_terminal_history_dispatch.py index 9aa3c7d..70010ea 100644 --- a/tests/test_terminal_history_dispatch.py +++ b/tests/test_terminal_history_dispatch.py @@ -267,7 +267,7 @@ def test_macos_terminal_failed_capture_returns_none(self): # [ref:ghostty_history_cleanup] def test_ghostty_capture_scrubs_consumed_history_file(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' history_path.write_text('history', encoding='utf-8') with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ patch.object(self.core, '_pbcopy'), \ @@ -280,7 +280,7 @@ def test_ghostty_capture_scrubs_consumed_history_file(self): def test_ghostty_cleanup_uses_descriptor_not_replacement_path(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' history_path.write_text('history', encoding='utf-8') replacement = Path(outside_directory) / 'replacement.txt' replacement.write_text('do not scrub', encoding='utf-8') @@ -308,7 +308,7 @@ def test_ghostty_cleanup_rejects_preexisting_hard_link(self): tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: outside = Path(outside_directory) / 'outside.txt' outside.write_text('do not scrub', encoding='utf-8') - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' try: self.core.os.link(outside, history_path) except OSError as error: @@ -323,7 +323,7 @@ def test_ghostty_cleanup_rejects_preexisting_hard_link(self): def test_ghostty_cleanup_skips_scrub_if_hard_link_appears_during_read(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' history_path.write_text('history', encoding='utf-8') alias = Path(outside_directory) / 'alias.txt' probe = Path(outside_directory) / 'probe.txt' @@ -351,7 +351,7 @@ def link_then_read(descriptor, size): def test_ghostty_scrub_failure_does_not_mask_history_or_clipboard_restore(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' history_path.write_text('history', encoding='utf-8') with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ patch.object(self.core, '_pbcopy') as pbcopy, \ @@ -363,14 +363,15 @@ def test_ghostty_scrub_failure_does_not_mask_history_or_clipboard_restore(self): def test_ghostty_macos_happy_path_reads_temp_history_and_restores_clipboard(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' history_path.write_text('one\ntwo\nthree\nfour', encoding='utf-8') with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ patch.object(self.core, '_pbcopy') as pbcopy, \ patch.object(self.core.subprocess, 'run') as run: self.assertEqual(self.core.get_ghostty_history_macos(2), 'three\nfour') + self.assertIn('perform action "write_screen_file:copy,plain"', self.core.GHOSTTY_SCREEN_SCRIPT) run.assert_called_once_with( - ['osascript', '-e', self.core.GHOSTTY_SCROLLBACK_SCRIPT], + ['osascript', '-e', self.core.GHOSTTY_SCREEN_SCRIPT], text=True, check=True, stdout=self.core.DEVNULL, @@ -394,7 +395,7 @@ def fake_pbpaste(): return next(clipboard_values, 'not a path') # [ref:ghostty_clipboard_restore] def test_ghostty_capture_does_not_overwrite_concurrent_clipboard_change(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' history_path.write_text('history', encoding='utf-8') with patch.object( self.core, @@ -407,7 +408,7 @@ def test_ghostty_capture_does_not_overwrite_concurrent_clipboard_change(self): def test_ghostty_capture_does_not_restore_unavailable_clipboard_text(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' history_path.write_text('history', encoding='utf-8') with patch.object( self.core, @@ -427,7 +428,7 @@ def test_clipboard_helpers_preserve_unavailable_text_state(self): def test_ghostty_macos_read_errors_return_none_and_restore_clipboard(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory: - history_path = Path(directory) / 'history.txt' + history_path = Path(directory) / 'screen.txt' history_path.write_text('history', encoding='utf-8') with patch.object(self.core, '_pbpaste', side_effect=['original clip', str(history_path), str(history_path)]), \ patch.object(self.core, '_pbcopy') as pbcopy, \ @@ -441,12 +442,12 @@ def test_ghostty_macos_read_errors_return_none_and_restore_clipboard(self): def test_ghostty_path_validation_enforces_name_root_and_size(self): with tempfile.TemporaryDirectory(dir=tempfile.gettempdir()) as directory, \ tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: - valid = Path(directory) / 'history.txt' + valid = Path(directory) / 'screen.txt' valid.write_text('history', encoding='utf-8') - wrong_name = Path(directory) / 'not-history.txt' + wrong_name = Path(directory) / 'history.txt' wrong_name.write_text('history', encoding='utf-8') - oversized_target = Path(directory) / 'history.txt' - outside = Path(outside_directory) / 'history.txt' + oversized_target = Path(directory) / 'screen.txt' + outside = Path(outside_directory) / 'screen.txt' outside.write_text('outside history', encoding='utf-8') self.assertTrue(self.core._valid_ghostty_history_path(str(valid))) @@ -470,7 +471,7 @@ def test_ghostty_safe_read_rejects_symlink_swap_after_validation(self): tempfile.TemporaryDirectory(dir=Path.cwd()) as outside_directory: safe_target = Path(directory) / 'safe-history.txt' safe_target.write_text('safe history', encoding='utf-8') - candidate = Path(directory) / 'history.txt' + candidate = Path(directory) / 'screen.txt' candidate.symlink_to(safe_target) outside = Path(outside_directory) / 'secret.txt' outside.write_text('secret outside history', encoding='utf-8') From 742ad6d14b61d8d27492b7433f93beced6474630 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sun, 26 Jul 2026 22:17:20 -0400 Subject: [PATCH 12/12] fix(core): restore fastcore tool imports --- nbs/00_core.ipynb | 4 ++++ shell_sage/core.py | 4 ++++ tests/test_entrypoint.py | 17 +++++++++++++++++ tests/test_terminal_history_dispatch.py | 7 +++++-- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 tests/test_entrypoint.py diff --git a/nbs/00_core.ipynb b/nbs/00_core.ipynb index e98dbbe..dae1ce6 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_core.ipynb @@ -38,6 +38,10 @@ "from datetime import datetime\n", "from fastcore.script import *\n", "from fastcore.tools import *\n", + "try: from fastcore.tools import view_file, create_file, file_str_replace, file_insert_line\n", + "except ImportError:\n", + " from fastcore.tools import view as view_file, create as create_file\n", + " from fastcore.tools import str_replace as file_str_replace, insert as file_insert_line\n", "from fastcore.utils import *\n", "from fastcore.meta import delegates\n", "from fastlite import database\n", diff --git a/shell_sage/core.py b/shell_sage/core.py index 90390c1..374330f 100644 --- a/shell_sage/core.py +++ b/shell_sage/core.py @@ -14,6 +14,10 @@ from datetime import datetime from fastcore.script import * from fastcore.tools import * +try: from fastcore.tools import view_file, create_file, file_str_replace, file_insert_line +except ImportError: + from fastcore.tools import view as view_file, create as create_file + from fastcore.tools import str_replace as file_str_replace, insert as file_insert_line from fastcore.utils import * from fastcore.meta import delegates from fastlite import database diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py new file mode 100644 index 0000000..886fe1e --- /dev/null +++ b/tests/test_entrypoint.py @@ -0,0 +1,17 @@ +import subprocess +import sys +import unittest + + +class EntrypointTests(unittest.TestCase): + def test_core_imports_with_installed_dependencies(self): + result = subprocess.run( + [sys.executable, '-c', 'import shell_sage.core'], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_terminal_history_dispatch.py b/tests/test_terminal_history_dispatch.py index 70010ea..000e001 100644 --- a/tests/test_terminal_history_dispatch.py +++ b/tests/test_terminal_history_dispatch.py @@ -96,7 +96,6 @@ class ShellSageConfig: mode: str = 'default' base_url: str = '' api_key: str = '' - vendor_name: str = '' history_lines: int = -1 code_theme: str = 'monokai' code_lexer: str = 'python' @@ -112,7 +111,11 @@ class AsyncChat: def __init__(self, *args, **kwargs): pass def _call(self, *args, **kwargs): pass - fastllm_chat = _module('fastllm.chat', AsyncChat=AsyncChat) + class StreamAccum: + def __init__(self, *args, **kwargs): self.txt = '' + def __call__(self, *args, **kwargs): return False + + fastllm_chat = _module('fastllm.chat', AsyncChat=AsyncChat, StreamAccum=StreamAccum) fastllm = _module('fastllm', chat=fastllm_chat) fastllm.__path__ = []