diff --git a/README.md b/README.md index 868c4b7..9fbd22c 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,7 @@ Tokens are stripped from the in-memory history buffer and masked on every displa | `theme` | Terminal colour theme | `glean` (default), `mono`, `neon` | | `default_page_size` | Default result count for search and entities | Integer, default `10` | | `mock_corpus_path` | JSON file backing mock mode | Path; unset uses the built-in corpus | +| `window_title` | Terminal window/tab title | `full` (default, includes the instance host), `plain` (mode only), `off` | Config lives at `~/.gleancode/config.json`. Change any key with `/config set `. Use `/mode live|mock|auto` to force a mode without editing config. diff --git a/glean_code/cli.py b/glean_code/cli.py index 2d930b8..29816ad 100644 --- a/glean_code/cli.py +++ b/glean_code/cli.py @@ -30,25 +30,42 @@ def main() -> None: setup_readline() - while session.running: - try: - bar = ui.status_bar( - mode=config.effective_mode, - instance=config.instance, - has_token=config.is_live_ready, - act_as=config.act_as, - chat_id=session.current_chat_id, - ) - if bar: - print(bar) - line = input(ui.prompt_str(config.effective_mode)) - except (EOFError, KeyboardInterrupt): - print() - break - try: - dispatch(session, line) - except Exception as e: # defensive: never crash the REPL - ui.print_err(f"Unhandled error: {e}") + # Terminal title tracks mode and instance, so several open windows stay + # tellable apart. Only rewritten when the state actually changes. + last_title = None + try: + while session.running: + try: + title = ui.title_text( + mode=config.effective_mode, + instance=config.instance, + has_token=config.is_live_ready, + style_name=config.window_title, + ) + if title and title != last_title: + ui.set_title(title) + last_title = title + + bar = ui.status_bar( + mode=config.effective_mode, + instance=config.instance, + has_token=config.is_live_ready, + act_as=config.act_as, + chat_id=session.current_chat_id, + ) + if bar: + print(bar) + line = input(ui.prompt_str(config.effective_mode)) + except (EOFError, KeyboardInterrupt): + print() + break + try: + dispatch(session, line) + except Exception as e: # defensive: never crash the REPL + ui.print_err(f"Unhandled error: {e}") + finally: + if last_title: + ui.clear_title() if __name__ == "__main__": diff --git a/glean_code/config.py b/glean_code/config.py index 48fae9f..5eb9c90 100644 --- a/glean_code/config.py +++ b/glean_code/config.py @@ -46,6 +46,7 @@ class Config: theme: str = "glean" # glean | mono | neon default_page_size: int = 10 mock_corpus_path: Optional[str] = None # JSON file backing mock mode; falls back to the built-in corpus + window_title: str = "full" # full | plain (no hostname) | off history: list = field(default_factory=list) # ---- Interactive OAuth (SSO) settings ---- diff --git a/glean_code/ui.py b/glean_code/ui.py index 70d0d87..394cf7f 100644 --- a/glean_code/ui.py +++ b/glean_code/ui.py @@ -80,6 +80,51 @@ def hyperlink(url: str, text: str) -> str: return f"\033]8;;{url}\033\\{text}\033]8;;\033\\" +def title_text( + mode: str, + instance: Optional[str] = None, + has_token: bool = False, + style_name: str = "full", +) -> str: + """Build the terminal window/tab title for the current session state. + + style_name: + full "Glean Code - acme-be.glean.com (live)" + plain "Glean Code (live)" - omits the tenant hostname + off "" - caller should not set a title + """ + if style_name == "off": + return "" + + label = mode if mode in ("live", "mock") else "auto" + if mode == "auto": + label = "live" if has_token else "mock" + + if style_name == "plain" or not instance: + return f"Glean Code ({label})" + + host = instance + if "://" in host: + host = host.split("://", 1)[1].split("/")[0] + return f"Glean Code - {host} ({label})" + + +def set_title(text: str) -> None: + """Set the terminal window and tab title via OSC 0. + + No-op when stdout is not a TTY, so piped runs stay clean. + """ + if not sys.stdout.isatty(): + return + sys.stdout.write(f"\033]0;{text}\007") + sys.stdout.flush() + + +def clear_title() -> None: + """Hand the title back to the shell on exit.""" + set_title("") + + def term_width(default: int = 80) -> int: try: return shutil.get_terminal_size().columns diff --git a/tests/test_ui.py b/tests/test_ui.py index e897701..24c204a 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,8 +1,10 @@ """Tests for glean_code.ui""" +import io import os import sys import unittest from pathlib import Path +from unittest import mock from unittest.mock import patch sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -295,3 +297,56 @@ def test_short_chat_id_not_truncated(self): if __name__ == "__main__": unittest.main() + + +class TestTitleText(unittest.TestCase): + def test_full_includes_host_and_mode(self): + self.assertEqual( + ui.title_text("live", "acme-be.glean.com", True), + "Glean Code - acme-be.glean.com (live)", + ) + + def test_plain_omits_the_hostname(self): + self.assertEqual( + ui.title_text("live", "acme-be.glean.com", True, "plain"), + "Glean Code (live)", + ) + + def test_off_returns_empty(self): + self.assertEqual(ui.title_text("live", "acme-be.glean.com", True, "off"), "") + + def test_auto_resolves_to_live_or_mock(self): + self.assertIn("(live)", ui.title_text("auto", "h", has_token=True)) + self.assertIn("(mock)", ui.title_text("auto", "h", has_token=False)) + + def test_no_instance_falls_back_to_short_form(self): + self.assertEqual(ui.title_text("mock", None, False), "Glean Code (mock)") + + def test_scheme_and_path_are_stripped_from_host(self): + self.assertEqual( + ui.title_text("live", "https://acme-be.glean.com/rest/api/v1", True), + "Glean Code - acme-be.glean.com (live)", + ) + + +class TestSetTitle(unittest.TestCase): + def test_writes_osc_sequence_when_tty(self): + buf = io.StringIO() + buf.isatty = lambda: True + with mock.patch.object(ui.sys, "stdout", buf): + ui.set_title("Hello") + self.assertEqual(buf.getvalue(), "\033]0;Hello\007") + + def test_silent_when_not_a_tty(self): + buf = io.StringIO() + buf.isatty = lambda: False + with mock.patch.object(ui.sys, "stdout", buf): + ui.set_title("Hello") + self.assertEqual(buf.getvalue(), "") + + def test_clear_title_emits_empty_title(self): + buf = io.StringIO() + buf.isatty = lambda: True + with mock.patch.object(ui.sys, "stdout", buf): + ui.clear_title() + self.assertEqual(buf.getvalue(), "\033]0;\007")