diff --git a/CHANGELOG.md b/CHANGELOG.md index a159cfa..772db4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.8.2 + +- Replaced the large CX Deck iTerm badge with compact native session-title + presentation. +- Made CX Deck scrollback timestamps opt-in by default while preserving explicit + stored preferences. +- Added natural forwarding of bare Codex CLI flags, so `cx --yolo` and similar + interactive launches work directly. +- Preserved conversation identity, zmx runtime generations, workspace behavior, + and resume semantics. + ## 0.8.1 - Added exact native iTerm workspace capture and ordered split-tree restoration; diff --git a/README.md b/README.md index 1ec2e5b..d094184 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ multiplexer, a replacement for Codex, or an agent messaging system. - Closing a terminal pane does not kill Codex. - Exact Codex UUIDs prevent fuzzy or accidental resume. - Native iTerm2 windows, tabs, and splits stay the presentation layer. -- Persistent pane badges keep long conversation names visible in narrow layouts. -- Native iTerm2 timestamps show when scrollback lines were last modified. +- Native iTerm2 session titles provide compact conversation labels. +- Optional native iTerm2 timestamps show when scrollback lines were last modified. - Groups, pins, display names, workspaces, and layouts stay local and private. ## How it works @@ -57,8 +57,8 @@ cx The installer checks prerequisites and installs CX Deck under `~/.local/share/cxdeck`. It does not install zmx, restart Codex, or modify a running zmx session. It creates only the `CX Deck` iTerm dynamic profile, which -inherits the user's default profile and supplies CX Deck-scoped badges and -timestamps. +inherits the user's default profile and supplies the CX Deck-scoped timestamp +preference. To update an existing checkout: @@ -71,8 +71,10 @@ git pull --ff-only ```zsh cx # start a persistent Codex session here +cx --yolo # start a persistent Codex session in YOLO mode cx "Model Evaluation" # start or reuse an exact display name cx new --split # new native iTerm2 split +cx new --split -- --yolo # explicit split with Codex launch flags cx resume # select from saved and live conversations cx status cx dashboard @@ -92,6 +94,7 @@ Closing the pane detaches the view. The Codex process remains in its zmx PTY. ## Core commands ```text +cx [CODEX_FLAGS...] cx new [--split|--tab|--window] [--count N] cx resume [--safe|--yolo] [--select EXACT_UUID ...] [--no-iterm] cx focus EXACT_NAME @@ -159,19 +162,26 @@ presentation only and never resume a saved conversation. ## Native iTerm experience -CX Deck-created views use a native iTerm2 session badge backed by the -`user.cxdeck_name` session variable, with native session-name metadata as a -supplement. `cx rename` updates verified current views without reconnecting -Codex. At very narrow widths -iTerm2 may visually truncate presentation, while the untruncated CX Deck name -remains in the native session variable and session metadata. +CX Deck supplies each view's native iTerm2 session title. `cx rename` updates +verified current views without reconnecting Codex. To show compact labels for +split panes, enable this user-owned iTerm2 preference: -Native iTerm2 scrollback timestamps are enabled by default only in the CX Deck -profile. Toggle CX Deck views with: +```text +iTerm2 Settings +→ Appearance +→ Panes +→ Show per-pane title bar with split panes +``` + +CX Deck does not change that global preference and does not draw labels inside +terminal output. + +Native iTerm2 scrollback timestamps are off by default. Enable them for CX Deck +views with: ```zsh -cx config timestamps off cx config timestamps on +cx config timestamps off ``` No timestamp text is inserted into Codex output. CX Deck does not configure diff --git a/agent_console.py b/agent_console.py index 78305fb..6ba97f8 100644 --- a/agent_console.py +++ b/agent_console.py @@ -173,7 +173,7 @@ def _prepare_current_view(display_name): import cx_iterm try: cx_iterm.prepare_current_view( - display_name, Store().preference('timestamps', True)) + display_name, Store().preference('timestamps', False)) except (RuntimeError, OSError) as exc: print('cx: presentation warning: ' + str(exc) + '. The Codex process and zmx generation are unchanged.', file=sys.stderr) @@ -380,6 +380,7 @@ def doctor(): HELP = f"""CX Deck {VERSION}: persistent Codex sessions with a native terminal experience codex / cx / cx new new persistent Codex session here + cx --yolo / cx --model M forward Codex flags into a managed new session cx LABEL create or attach a named session cx new --split|--tab|--window open native iTerm2 views cx resume saved/live picker; cold resume defaults to YOLO @@ -407,6 +408,8 @@ def main(argv=None): if not argv: start_agent([]) return 0 + if argv[0].startswith('-'): + return run_codex(argv) command, rest = argv[0], argv[1:] if command in ("resume", "fork"): return run_codex([command, *rest]) diff --git a/console_entry.py b/console_entry.py index eba8b5a..e714b1a 100644 --- a/console_entry.py +++ b/console_entry.py @@ -42,7 +42,7 @@ cx views status read-only runtime/presentation health cx views rebuild [--workspace NAME] create missing verified views only cx views refresh refresh names on verified existing iTerm views - cx config timestamps on|off native iTerm scrollback timestamps (default on) + cx config timestamps on|off native iTerm scrollback timestamps (default off) cx upgrade status runtime compatibility for the installed cx version cx upgrade apply defined safe upgrades; never restart implicitly diff --git a/cx_iterm.py b/cx_iterm.py index 3f4d3bf..90528e3 100644 --- a/cx_iterm.py +++ b/cx_iterm.py @@ -200,8 +200,8 @@ set rows of childPane to 56 end try end if - set childGuid to (unique id of childPane) as text - set childTTY to (tty of childPane) as text + set childGuid to (get unique id of childPane) as text + set childTTY to (get tty of childPane) as text set paneGuids to {childGuid} else if bestGuid is "" then set targetWindow to missing value @@ -218,8 +218,8 @@ set nextTab to (create tab with profile cxProfile command paneCommand) set childPane to current session of nextTab end tell - set childGuid to (unique id of childPane) as text - set childTTY to (tty of childPane) as text + set childGuid to (get unique id of childPane) as text + set childTTY to (get tty of childPane) as text set paneGuids to {childGuid} else set splitPane to missing value @@ -249,11 +249,11 @@ set childPane to (split horizontally with profile cxProfile command paneCommand) end if end tell - set childGuid to (unique id of childPane) as text - set childTTY to (tty of childPane) as text + set childGuid to (get unique id of childPane) as text + set childTTY to (get tty of childPane) as text set end of paneGuids to childGuid end if - if (profile name of childPane) is not cxProfile then error "iTerm2 did not create the pane with the CX Deck profile" + if (get profile name of childPane) is not cxProfile then error "iTerm2 did not create the pane with the CX Deck profile" set name of childPane to paneName set variable childPane named "user.cxdeck_name" to paneName set output to output & childGuid & (ASCII character 9) & childTTY & linefeed @@ -308,7 +308,7 @@ def parse_views(text): return views -def profile_payload(timestamps=True): +def profile_payload(timestamps=False): """Return the minimal iTerm dynamic profile owned by CX Deck. iTerm merges omitted keys from the current default profile. These overrides @@ -318,7 +318,6 @@ def profile_payload(timestamps=True): 'Profiles': [{ 'Name': PROFILE_NAME, 'Guid': PROFILE_GUID, - 'Badge Text': r'\(user.cxdeck_name)', 'Timestamps Visible': bool(timestamps), 'Timestamps Style': 1, }] @@ -339,7 +338,7 @@ def _validate_profile_parents(path): raise StateError(f'iTerm2 profile path is not an owned directory: {parent}') -def ensure_profile(timestamps=True, home=None): +def ensure_profile(timestamps=False, home=None): """Install/update only CX Deck's dynamic iTerm profile, atomically.""" path = _profile_path(home) _validate_profile_parents(path) @@ -416,7 +415,7 @@ def remove_profile(home=None): return True -def prepare_current_view(display_name, timestamps=True, stream=None, environ=None, +def prepare_current_view(display_name, timestamps=False, stream=None, environ=None, platform=None, home=None): """Apply the CX Deck profile to the current iTerm session before attach. @@ -439,7 +438,7 @@ def prepare_current_view(display_name, timestamps=True, stream=None, environ=Non class ITerm: - def configure(self, timestamps=True): + def configure(self, timestamps=False): return ensure_profile(timestamps) def call(self, *args): @@ -543,7 +542,7 @@ def show(rows, b, store, gui=None, mode='window', anchor=None, anchor = caller_tty() if anchor is None else anchor configure = getattr(gui, 'configure', None) if configure: - configure(store.preference('timestamps', True)) + configure(store.preference('timestamps', False)) gui.preflight(mode, anchor) with store.lock('views'): data, procs = _presentation_snapshot(b) @@ -603,7 +602,7 @@ def refresh(rows, b, store, gui=None): gui = gui or ITerm() configure = getattr(gui, 'configure', None) if configure: - configure(store.preference('timestamps', True)) + configure(store.preference('timestamps', False)) gui.preflight() data, procs = _presentation_snapshot(b) current = {r['_key']: r for r in data['sessions']} diff --git a/cx_version.py b/cx_version.py index 8828c33..f861172 100644 --- a/cx_version.py +++ b/cx_version.py @@ -1,3 +1,3 @@ """CX Deck release identity.""" -VERSION = "0.8.1" +VERSION = "0.8.2" diff --git a/docs/ITERM_PRESENTATION.md b/docs/ITERM_PRESENTATION.md index 6761544..67a0d28 100644 --- a/docs/ITERM_PRESENTATION.md +++ b/docs/ITERM_PRESENTATION.md @@ -10,17 +10,19 @@ CX Deck owns one iTerm settings from the user's current default profile. The file overrides only: ```text -Badge Text = \(user.cxdeck_name) Timestamps Visible = true|false Timestamps Style = overlap ``` Views are created explicitly with that profile, so unrelated iTerm sessions and -the Default Profile are unchanged. The badge is iTerm's native overlay and does -not consume terminal rows. CX Deck also sets native session-name metadata, while -the attached program remains free to update its ordinary terminal title. The -badge and session-name metadata use the complete Store display name; iTerm may -visually shorten them when physical space is insufficient. +the Default Profile are unchanged. CX Deck sets the native iTerm session name, +while the attached program remains free to update its ordinary terminal title. +The compatibility variable `user.cxdeck_name` also carries the complete Store +display name, but the dynamic profile does not render it as a badge. + +For compact labels, users may enable iTerm2 Settings → Appearance → Panes → +Show per-pane title bar with split panes. That is a global, user-owned iTerm2 +preference; CX Deck never changes it. No title is drawn inside terminal output. For `cx` and `cx new` in the caller's current pane, CX Deck selects its profile and sets the same user variable with iTerm's documented OSC controls immediately @@ -38,13 +40,16 @@ to all verified existing views. Neither operation writes to the PTY, sends input attaches, detaches, or changes the zmx generation. iTerm's `Timestamps Visible` session-profile property supplies per-line scrollback -times. `cx config timestamps on|off` updates only the CX Deck dynamic profile; -iTerm applies that profile update to existing views that use it as well as future -views. No prefix is inserted into terminal output, no scrollback database is -created, and no agent is reattached. +times. It defaults off when no preference is stored. `cx config timestamps +on|off` updates only the CX Deck dynamic profile and preserves an explicit +stored choice. A disposable iTerm2 3.7.0 probe confirmed that an already-open +session using a dynamic profile dropped a removed badge property within 0.5 +seconds of the profile update. Existing CX Deck views therefore converge without +closing panes; future views use the same updated profile. No prefix is inserted +into terminal output, no scrollback database is created, and no agent is +reattached. -These mechanisms follow iTerm2's supported -[badge](https://iterm2.com/documentation-badges.html) and +These mechanisms follow iTerm2's supported session-title and [session timestamp](https://iterm2.com/documentation-preferences-profiles-session.html) features. CX Deck uses AppleScript only for supported session creation, GUID/TTY inventory, focus, title, and user-variable operations; it does not use @@ -87,6 +92,7 @@ links, terminal close, reattach, and child-exit cleanup. Common direct and zmx behavior is equivalent; closing the zmx client preserves the child. The disposable real-iTerm harness verifies native window, tab, and split -creation, unique badges/titles across five panes, narrow-pane resize, timestamps, +creation, unique native session titles across five panes, no badge override, +narrow-pane resize, timestamps, inventory, focus, preferred-view reuse, pane disappearance, workspace reopen, process preservation, and detach-key disabling. diff --git a/install.py b/install.py index 7da0319..6439620 100644 --- a/install.py +++ b/install.py @@ -115,7 +115,7 @@ def install(home, source, configure_iterm=None): payloads = {name: (source / name).read_bytes() for name in FILES} old_store = home / LEGACY_STATE_RELATIVE / 'workbench' active_store = old_store if old_store.exists() else state_home(home) / 'workbench' - timestamps = Store(active_store).preference('timestamps', True) + timestamps = Store(active_store).preference('timestamps', False) configure_iterm = sys.platform == 'darwin' if configure_iterm is None else configure_iterm stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') backups = state_home(home) / 'backups' diff --git a/tests/iterm_e2e.py b/tests/iterm_e2e.py index bb7483c..7c404c4 100644 --- a/tests/iterm_e2e.py +++ b/tests/iterm_e2e.py @@ -137,8 +137,6 @@ async def inspect(connection): if session is None: raise RuntimeError('Disposable iTerm session disappeared before profile verification') properties = (await session.async_get_profile()).all_properties - if properties.get('Badge Text') != r'\(user.cxdeck_name)': - raise RuntimeError('Live iTerm session did not retain the CX Deck badge profile property') if ('Timestamps Visible' not in properties or bool(properties['Timestamps Visible']) != timestamps): observed = {key: value for key, value in properties.items() @@ -160,12 +158,14 @@ def main(): api_environment = {'HOME': str(Path.home()), **{ key: value for key, value in os.environ.items() if key.startswith('ITERM')}} profile_path = cx_iterm._profile_path() - timestamps = True + timestamps = False if profile_path.exists(): profile = json.loads(profile_path.read_text())['Profiles'][0] if profile.get('Guid') != cx_iterm.PROFILE_GUID: raise RuntimeError('Existing CX Deck profile is not owned by this installation') - timestamps = bool(profile.get('Timestamps Visible', True)) + if 'Badge Text' in profile: + raise RuntimeError('CX Deck dynamic profile still contains a badge override') + timestamps = bool(profile.get('Timestamps Visible', False)) gui.configure(timestamps) gui.preflight() runtime = "/tmp/cxgi-" + uuid.uuid4().hex[:10] @@ -225,7 +225,7 @@ def main(): presentation = gui.inspect(view) if any((presentation['badge_name'] != item['display_name'], presentation['session_name'] != item['display_name'])): - raise RuntimeError('Incorrect iTerm title/badge/profile assignment: ' + repr(presentation)) + raise RuntimeError('Incorrect iTerm session-title metadata assignment: ' + repr(presentation)) profile_api_verified = verify_profile_api( list(store.read()['views'].values()), timestamps, api_environment) long_view = store.read()['views'][refreshed[0]['_key']] @@ -280,8 +280,8 @@ def main(): print(json.dumps({"result": "PASS", "agents": 5, "zmx_version": "0.8.1", "timestamps_enabled": timestamps, "profile_api_verified": profile_api_verified, - "real_gui": True, "checks": ["native windows/splits/tabs", "five unique badges/titles", - "160/100/80/60-column name metadata", "native timestamp profile", "verified view reuse", + "real_gui": True, "checks": ["native windows/splits/tabs", "five unique native session titles", + "no CX Deck badge override", "160/100/80/60-column name metadata", "native timestamp profile", "verified view reuse", "one preferred view", "direct attach focuses preferred view", "rename and workspace reopen", "close pane keeps PID", "recreate missing view", "ZMX_NO_DETACH_KEY=1"]})) finally: diff --git a/tests/test_agent_console.py b/tests/test_agent_console.py index 720cbfa..793f54f 100644 --- a/tests/test_agent_console.py +++ b/tests/test_agent_console.py @@ -71,7 +71,7 @@ def test_new_agent_uses_direct_zmx_create_and_required_labels(self): call = self.backend.create.call_args self.assertEqual(call.args[1], ["/test/bin/codex"]) self.assertEqual(call.args[2], os.path.realpath("/home/test")) - self.assertEqual(call.args[3], {"cx_managed": "1", "cx_version": "0.8.1", + self.assertEqual(call.args[3], {"cx_managed": "1", "cx_version": "0.8.2", "cx_codex_home": "encoded-home", "cx_launch_policy": "safe", "cx_launch_mode": "new"}) self.assertTrue(call.kwargs["detached"]) self.assertEqual(call.kwargs["env"]["CX_MANAGED"], "1") @@ -187,6 +187,38 @@ def test_main_preserves_bare_label_resume_and_raw_routing(self): agent.main(["resume", "--all"]) run.assert_called_once_with(["resume", "--all"]) + def test_bare_codex_flags_route_to_interactive_wrapper(self): + cases = (["--model", "foo"], ["-m", "foo"], + ["--sandbox", "workspace-write"], ["-C", "/some/path"], + ["--profile", "research"], ["--future-codex-option"]) + for argv in cases: + with self.subTest(argv=argv), \ + patch("agent_console.run_codex", return_value=0) as run: + self.assertEqual(agent.main(argv), 0) + run.assert_called_once_with(list(argv)) + + def test_bare_yolo_launch_executes_codex_yolo_and_records_truthful_policy(self): + with patch('sys.stdin.isatty', return_value=True), \ + patch('sys.stdout.isatty', return_value=True), \ + patch.object(agent, '_prepare_current_view'): + self.assertEqual(agent.main(['--yolo']), 0) + call = self.backend.create.call_args + self.assertEqual(call.args[1], ['/test/bin/codex', '--yolo']) + self.assertEqual(call.args[3]['cx_launch_policy'], 'yolo') + self.assertEqual(call.args[3]['cx_launch_mode'], 'new') + + def test_cx_owned_help_version_and_explicit_start_detach_keep_routing(self): + with patch('agent_console.run_codex') as run, \ + patch('agent_console.start_agent') as start: + self.assertEqual(agent.main(['--help']), 0) + self.assertEqual(agent.main(['-h']), 0) + self.assertEqual(agent.main(['--version']), 0) + run.assert_not_called() + start.assert_not_called() + with patch('agent_console.start_agent') as start: + self.assertEqual(agent.main(['start', '--detach']), 0) + start.assert_called_once_with([], None, True) + def test_project_annotation_updates_store_without_relaunch(self): row = session(task="one") self.backend.git.return_value = "/projects/real-repo\n" @@ -211,7 +243,7 @@ def test_doctor_reports_only_current_runtime_diagnostics(self): patch.object(agent.sys, "platform", "linux"): agent.doctor() output = self.output.getvalue() - for expected in ("CX Deck 0.8.1", "Python ", "codex path:", "codex version:", + for expected in ("CX Deck 0.8.2", "Python ", "codex path:", "codex version:", "zmx version: 0.8.1 (minimum 0.8.1: PASS)", "zmx runtime/socket directory: /tmp/zmx", "managed zmx sessions: 1", "YOLO=1 SAFE=0 UNKNOWN=0", diff --git a/tests/test_iterm_object_refs.py b/tests/test_iterm_object_refs.py index cd77eda..c7f6d91 100644 --- a/tests/test_iterm_object_refs.py +++ b/tests/test_iterm_object_refs.py @@ -49,8 +49,8 @@ def test_layout_still_uses_returned_objects_only_immediately(self): script = cx_iterm.APPLESCRIPT # Fresh objects returned by iTerm are read immediately, then converted to IDs. self.assertIn('set newWindow to (create window with profile cxProfile command paneCommand)', script) - self.assertIn('set childGuid to (unique id of childPane) as text', script) - self.assertIn('set childTTY to (tty of childPane) as text', script) + self.assertIn('set childGuid to (get unique id of childPane) as text', script) + self.assertIn('set childTTY to (get tty of childPane) as text', script) self.assertIn('set end of paneGuids to childGuid', script) self.assertIn('set variable childPane named "user.cxdeck_name" to paneName', script) diff --git a/tests/test_presentation.py b/tests/test_presentation.py index 9e43e2e..2a03bfc 100644 --- a/tests/test_presentation.py +++ b/tests/test_presentation.py @@ -66,11 +66,13 @@ def focus(self, view): class PresentationProfileTests(unittest.TestCase): - def test_profile_is_scoped_and_uses_native_badge_and_timestamps(self): + def test_profile_is_scoped_without_badge_and_defaults_timestamps_off(self): + default = cx_iterm.profile_payload()['Profiles'][0] enabled = cx_iterm.profile_payload(True)['Profiles'][0] disabled = cx_iterm.profile_payload(False)['Profiles'][0] self.assertEqual(enabled['Name'], 'CX Deck') - self.assertEqual(enabled['Badge Text'], r'\(user.cxdeck_name)') + self.assertNotIn('Badge Text', enabled) + self.assertIs(default['Timestamps Visible'], False) self.assertIs(enabled['Timestamps Visible'], True) self.assertEqual(enabled['Timestamps Style'], 1) self.assertIs(disabled['Timestamps Visible'], False) @@ -165,7 +167,7 @@ def test_five_panes_receive_exact_full_names_without_cross_assignment(self): self.assertEqual(result, dict(opened=5, reused=0)) self.assertEqual(gui.open_calls, 1) self.assertEqual(gui.names, [item['display_name'] for item in self.rows]) - self.assertEqual(gui.configured, [True]) + self.assertEqual(gui.configured, [False]) def test_view_reuse_uses_one_nonbinding_runtime_and_client_snapshot(self): target = row(1, attached=1) @@ -206,12 +208,19 @@ def test_disabled_timestamp_preference_is_used_by_view_rebuild(self): cx_iterm.show(self.rows, self.backend, self.store, gui=gui) self.assertEqual(gui.configured, [False]) + def test_enabled_timestamp_preference_is_used_by_view_rebuild(self): + self.store.set_preference('timestamps', True) + gui = FakeGUI(self.rows) + with patch.object(cx_iterm, 'client_map', side_effect=lambda backend, *args: gui.clients): + cx_iterm.show(self.rows, self.backend, self.store, gui=gui) + self.assertEqual(gui.configured, [True]) + def test_full_name_is_independent_of_terminal_width(self): full = 'Visual Computation Convergence Across Narrow Panes' profile = cx_iterm.profile_payload(True)['Profiles'][0] + self.assertNotIn('Badge Text', profile) for columns in (160, 100, 80, 60): with self.subTest(columns=columns): - self.assertEqual(profile['Badge Text'], r'\(user.cxdeck_name)') self.assertEqual(validate_name(full), full) def test_codex_title_is_used_when_no_custom_name_exists(self): diff --git a/tests/test_public_hygiene.py b/tests/test_public_hygiene.py index 06b8645..1f03316 100644 --- a/tests/test_public_hygiene.py +++ b/tests/test_public_hygiene.py @@ -13,11 +13,11 @@ class PublicHygieneTests(unittest.TestCase): def test_product_name_version_and_tagline_are_current(self): readme = (ROOT / 'README.md').read_text() - self.assertEqual(cx_version.VERSION, '0.8.1') + self.assertEqual(cx_version.VERSION, '0.8.2') self.assertIn('# CX Deck', readme) self.assertIn('Persistent Codex sessions with a native terminal experience.', readme) self.assertIn('independent, unofficial project', readme) - self.assertIn('CX Deck 0.8.1', console_entry.HELP) + self.assertIn('CX Deck 0.8.2', console_entry.HELP) self.assertNotIn('codex-tmux', console_entry.HELP) def test_package_paths_and_install_files_use_cxdeck(self): diff --git a/tests/test_unit.py b/tests/test_unit.py index 70a79ae..07cc084 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -32,7 +32,7 @@ def test_v070_upgrade_preserves_v1_state_and_installs_every_v080_module(self): state.write_text(json.dumps(payload)) installer.install(home, source, configure_iterm=False) self.assertEqual(json.loads(state.read_text()), payload) - self.assertIn('VERSION = "0.8.1"', (module / 'cx_version.py').read_text()) + self.assertIn('VERSION = "0.8.2"', (module / 'cx_version.py').read_text()) for filename in ('cx_inventory.py', 'cx_workspace_layout.py', 'cx_workspace_restore.py'): self.assertTrue((module / filename).is_file()) @@ -73,6 +73,25 @@ def test_idempotent_install_synchronizes_owned_module_directory(self): '{"version":1,"agents":{"a":{"name":"Study"}},"views":{},"workspaces":{},"groups":{}}\n') self.assertTrue(cx_iterm._profile_path(home).exists()) + def test_installer_converges_profile_without_badge_and_preserves_timestamp_preference(self): + source = Path(__file__).resolve().parents[1] + for stored, expected in ((None, False), (True, True), (False, False)): + with self.subTest(stored=stored), tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + (home / '.zshrc').write_text('# personal\n') + if stored is not None: + state = home / '.local/state/cxdeck/workbench/state.json' + state.parent.mkdir(parents=True) + state.write_text(json.dumps({'version': 1, 'agents': {}, 'views': {}, + 'workspaces': {}, 'groups': {}, 'config': {'timestamps': stored}})) + installer.install(home, source, configure_iterm=True) + profile = json.loads(cx_iterm._profile_path(home).read_text())['Profiles'][0] + self.assertNotIn('Badge Text', profile) + self.assertIs(profile['Timestamps Visible'], expected) + if stored is None: + state = home / '.local/state/cxdeck/workbench/state.json' + self.assertFalse(state.exists()) + def test_preserves_zshrc_symlink(self): with tempfile.TemporaryDirectory() as temporary: home = Path(temporary) @@ -220,6 +239,19 @@ def test_shell_wrapper_bypass_and_managed_calls_route_to_codex_binary(self): text=True, capture_output=True, check=True) self.assertEqual(result.stdout.splitlines(), ['direct:bypass', 'direct:managed']) + def test_shell_wrapper_preserves_bare_cx_flag_order(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + python = root / 'python3' + python.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n') + python.chmod(0o755) + wrapper = shlex.quote(str(Path(__file__).resolve().parents[1] / 'cxdeck.zsh')) + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH']) + result = subprocess.run( + ['zsh', '-f', '-c', f'source {wrapper}; cx --yolo --model foo'], + env=env, text=True, capture_output=True, check=True) + self.assertEqual(result.stdout.splitlines()[1:], ['--yolo', '--model', 'foo']) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_workbench_edges.py b/tests/test_workbench_edges.py index 9ab7e42..9b0bee5 100644 --- a/tests/test_workbench_edges.py +++ b/tests/test_workbench_edges.py @@ -31,8 +31,14 @@ def backend(self): def test_unnamed_gui_launch_forwards_flags_after_separator(self): b = self.backend() with patch.object(ui, 'ITerm'), patch.object(w, 'focus_rows'): - w.new_agents(['--split', '--', '--profile', 'research', '--model', 'literal'], b) - b.console.start_agent.assert_called_once_with(['--profile', 'research', '--model', 'literal'], None, detached=True) + w.new_agents(['--split', '--', '--yolo'], b) + b.console.start_agent.assert_called_once_with(['--yolo'], None, detached=True) + + def test_named_gui_launch_forwards_yolo_after_separator(self): + b = self.backend() + with patch.object(ui, 'ITerm'), patch.object(w, 'focus_rows'): + w.new_agents(['--split', 'My Agent', '--', '--yolo'], b) + b.console.start_agent.assert_called_once_with(['--yolo'], 'My Agent', detached=True) def test_batch_launch_creates_arbitrary_count_from_same_directory(self): b = self.backend() diff --git a/tests/test_zmx.py b/tests/test_zmx.py index aeef64a..5b75ed5 100644 --- a/tests/test_zmx.py +++ b/tests/test_zmx.py @@ -90,7 +90,7 @@ def test_detached_create_uses_direct_attach_atomic_labels_and_env(self): self.assertEqual(argv[:3], ["/bin/zmx", "attach", "--labels"]) self.assertEqual(argv[-4:], ["cx-chat-test", "/bin/codex", "resume", ID]) labels = zmx.parse_labels(argv[3]) - self.assertEqual(labels["cx_version"], "0.8.1") + self.assertEqual(labels["cx_version"], "0.8.2") self.assertEqual(labels["cx_zmx_version"], "0.8.1") self.assertEqual(zmx.decode_path(labels["cx_launch_cwd"]), os.path.realpath(cwd)) self.assertEqual(run.call_args.kwargs["env"]["ZMX_NO_DETACH_KEY"], "1") diff --git a/workbench.py b/workbench.py index 3d5c164..36b5e79 100644 --- a/workbench.py +++ b/workbench.py @@ -386,7 +386,7 @@ def new_agents(argv, b): mode = 'tab' if a.tab else 'window' if a.window else 'split' anchor = cx_iterm.caller_tty() gui = cx_iterm.ITerm() - gui.configure(b.store.preference('timestamps', True)) + gui.configure(b.store.preference('timestamps', False)) gui.preflight(mode, anchor) created = [] try: @@ -410,7 +410,7 @@ def launch_selected(chosen, history, args, b): opts = layout(args) gui = cx_iterm.ITerm() if not args.no_iterm: - gui.configure(b.store.preference('timestamps', True)) + gui.configure(b.store.preference('timestamps', False)) gui.preflight() with r.launch_lock(r.codex_home()): fresh, unknown, _, failed = catalog(b, history) @@ -466,7 +466,7 @@ def config_command(argv, b): parser.add_argument('value', nargs='?', choices=('on', 'off')) args = parser.parse_args(argv) if args.value is None: - enabled = b.store.preference('timestamps', True) + enabled = b.store.preference('timestamps', False) print('timestamps: ' + ('on' if enabled else 'off')) return 0 enabled = args.value == 'on' @@ -852,7 +852,7 @@ def __getattr__(self, key): try: final_observation = observe() restore.revalidate_runtimes(landed.items, final_observation) - cx_iterm.ensure_profile(b.store.preference('timestamps', True)) + cx_iterm.ensure_profile(b.store.preference('timestamps', False)) result = restore.restore_live( landed.layout, landed.items, observe, final_observation['context'], cx_iterm.caller_tty())