aline-ai 0.6.0__py3-none-any.whl → 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,12 @@
1
- """Terminal controls panel (tmux-backed).
1
+ """Terminal controls panel with native terminal and tmux support.
2
2
 
3
- This panel does not embed a terminal widget. Instead it controls a tmux layout:
4
- - Left pane: Aline dashboard
5
- - Right pane: an inner tmux session with multiple "terminal tabs" as tmux windows
3
+ This panel controls terminal tabs in either:
4
+ 1. Native terminals (iTerm2/Kitty) - for better performance with high-frequency updates
5
+ 2. tmux - the traditional approach with embedded terminal rendering
6
+
7
+ The mode is determined by the ALINE_TERMINAL_MODE environment variable:
8
+ - "native" or "iterm2" or "kitty": Use native terminal backend
9
+ - "tmux" or unset: Use tmux backend (default)
6
10
  """
7
11
 
8
12
  from __future__ import annotations
@@ -13,7 +17,7 @@ import re
13
17
  import shlex
14
18
  import traceback
15
19
  from pathlib import Path
16
- from typing import Callable
20
+ from typing import Callable, Union
17
21
 
18
22
  from textual.app import ComposeResult
19
23
  from textual.containers import Container, Horizontal, Vertical, VerticalScroll
@@ -22,6 +26,7 @@ from textual.widgets import Button, Static
22
26
  from rich.text import Text
23
27
 
24
28
  from .. import tmux_manager
29
+ from ..terminal_backend import TerminalBackend, TerminalInfo
25
30
  from ...logging_config import setup_logger
26
31
 
27
32
  logger = setup_logger("realign.dashboard.terminal", "dashboard.log")
@@ -30,6 +35,19 @@ logger = setup_logger("realign.dashboard.terminal", "dashboard.log")
30
35
  # Signal directory for permission request notifications
31
36
  PERMISSION_SIGNAL_DIR = Path.home() / ".aline" / ".signals" / "permission_request"
32
37
 
38
+ # Environment variable to control terminal mode
39
+ ENV_TERMINAL_MODE = "ALINE_TERMINAL_MODE"
40
+
41
+ # Terminal mode constants
42
+ MODE_TMUX = "tmux"
43
+ MODE_NATIVE = "native"
44
+ MODE_ITERM2 = "iterm2"
45
+ MODE_KITTY = "kitty"
46
+
47
+
48
+ # Type for window data (either tmux InnerWindow or native TerminalInfo)
49
+ WindowData = Union[tmux_manager.InnerWindow, TerminalInfo]
50
+
33
51
 
34
52
  class _SignalFileWatcher:
35
53
  """Watches for new signal files in the permission_request directory.
@@ -128,8 +146,75 @@ class _SignalFileWatcher:
128
146
  pass
129
147
 
130
148
 
149
+ def _detect_terminal_mode() -> str:
150
+ """Detect which terminal mode to use.
151
+
152
+ Returns:
153
+ Terminal mode string (MODE_TMUX, MODE_ITERM2, or MODE_KITTY)
154
+ """
155
+ mode = os.environ.get(ENV_TERMINAL_MODE, "").strip().lower()
156
+
157
+ if mode in {MODE_ITERM2, "iterm"}:
158
+ return MODE_ITERM2
159
+ if mode == MODE_KITTY:
160
+ return MODE_KITTY
161
+ if mode == MODE_NATIVE:
162
+ # Auto-detect best native terminal
163
+ term_program = os.environ.get("TERM_PROGRAM", "").strip()
164
+ if term_program in {"iTerm.app", "iTerm2"} or term_program.startswith("iTerm"):
165
+ return MODE_ITERM2
166
+ if term_program == "kitty":
167
+ return MODE_KITTY
168
+ # Default to iTerm2 on macOS
169
+ import sys
170
+ if sys.platform == "darwin":
171
+ return MODE_ITERM2
172
+ return MODE_TMUX
173
+
174
+ # Default to tmux
175
+ return MODE_TMUX
176
+
177
+
178
+ async def _get_native_backend(mode: str) -> TerminalBackend | None:
179
+ """Get the appropriate native terminal backend.
180
+
181
+ Args:
182
+ mode: Terminal mode (MODE_ITERM2 or MODE_KITTY)
183
+
184
+ Returns:
185
+ Backend instance if available, None otherwise
186
+ """
187
+ if mode == MODE_ITERM2:
188
+ try:
189
+ from ..backends.iterm2 import ITermBackend
190
+
191
+ # Check for split pane session ID from environment
192
+ right_pane_session_id = os.environ.get("ALINE_ITERM2_RIGHT_PANE")
193
+ if right_pane_session_id:
194
+ logger.debug(f"Using split pane mode with right pane: {right_pane_session_id}")
195
+
196
+ backend = ITermBackend(right_pane_session_id=right_pane_session_id)
197
+ if await backend.is_available():
198
+ return backend
199
+ except Exception as e:
200
+ logger.debug(f"iTerm2 backend not available: {e}")
201
+ elif mode == MODE_KITTY:
202
+ try:
203
+ from ..backends.kitty import KittyBackend
204
+ backend = KittyBackend()
205
+ if await backend.is_available():
206
+ return backend
207
+ except Exception as e:
208
+ logger.debug(f"Kitty backend not available: {e}")
209
+
210
+ return None
211
+
212
+
131
213
  class TerminalPanel(Container, can_focus=True):
132
- """Terminal controls panel with permission request notifications."""
214
+ """Terminal controls panel with permission request notifications.
215
+
216
+ Supports both native terminal backends (iTerm2/Kitty) and tmux.
217
+ """
133
218
 
134
219
  class PermissionRequestDetected(Message):
135
220
  """Posted when a new permission request signal file is detected."""
@@ -258,42 +343,83 @@ class TerminalPanel(Container, can_focus=True):
258
343
 
259
344
  @staticmethod
260
345
  def supported() -> bool:
261
- return (
262
- tmux_manager.tmux_available()
263
- and tmux_manager.in_tmux()
264
- and tmux_manager.managed_env_enabled()
265
- )
346
+ """Check if terminal controls are supported.
347
+
348
+ Supports both native terminal mode and tmux mode.
349
+ """
350
+ mode = _detect_terminal_mode()
351
+
352
+ if mode == MODE_TMUX:
353
+ return (
354
+ tmux_manager.tmux_available()
355
+ and tmux_manager.in_tmux()
356
+ and tmux_manager.managed_env_enabled()
357
+ )
358
+
359
+ # For native mode, we check availability asynchronously in refresh_data
360
+ # Here we just return True if native mode is requested
361
+ return mode in {MODE_ITERM2, MODE_KITTY}
266
362
 
267
363
  @staticmethod
268
364
  def _support_message() -> str:
269
- if not tmux_manager.tmux_available():
270
- return "tmux not installed. Run `aline add tmux`, then restart `aline`."
271
- if not tmux_manager.in_tmux():
272
- return "Not running inside tmux. Restart with `aline` to enable terminal controls."
273
- if not tmux_manager.managed_env_enabled():
274
- return "Not in an Aline-managed tmux session. Start via `aline` to enable terminal controls."
365
+ mode = _detect_terminal_mode()
366
+
367
+ if mode == MODE_TMUX:
368
+ if not tmux_manager.tmux_available():
369
+ return "tmux not installed. Run `aline add tmux`, then restart `aline`."
370
+ if not tmux_manager.in_tmux():
371
+ return "Not running inside tmux. Restart with `aline` to enable terminal controls."
372
+ if not tmux_manager.managed_env_enabled():
373
+ return "Not in an Aline-managed tmux session. Start via `aline` to enable terminal controls."
374
+ elif mode == MODE_ITERM2:
375
+ return "iTerm2 Python API not available. Install with: pip install iterm2"
376
+ elif mode == MODE_KITTY:
377
+ return "Kitty remote control not available. Configure listen_on in kitty.conf"
378
+
275
379
  return ""
276
380
 
277
381
  @staticmethod
278
- def _is_claude_window(w: tmux_manager.InnerWindow) -> bool:
279
- # Prefer an explicit "cc"/"cc-N" window name because those are created by the dashboard.
280
- # This avoids accidental relabeling when external hooks set @aline_provider/@aline_session_type
281
- # on unrelated windows (e.g. "codex-2").
382
+ def _is_claude_window(w: WindowData) -> bool:
383
+ """Check if a window is a Claude terminal."""
384
+ if isinstance(w, TerminalInfo):
385
+ # Native terminal: check provider
386
+ return w.provider == "claude"
387
+
388
+ # tmux: check window name and tags
282
389
  window_name = (w.window_name or "").strip().lower()
283
390
  if re.fullmatch(r"cc(?:-\d+)?", window_name or ""):
284
391
  return True
285
392
 
286
- # Fallback: treat as Claude only when it looks Aline-managed (terminal_id/context_id),
287
- # not merely because a hook tagged the window.
288
393
  is_claude_tagged = (w.provider == "claude") or (w.session_type == "claude")
289
394
  return bool(is_claude_tagged and (w.terminal_id or w.context_id))
290
395
 
291
- def __init__(self) -> None:
396
+ def __init__(self, use_native_terminal: bool | None = None) -> None:
397
+ """Initialize the terminal panel.
398
+
399
+ Args:
400
+ use_native_terminal: If True, use native terminal backend.
401
+ If False, use tmux.
402
+ If None (default), auto-detect from environment.
403
+ """
292
404
  super().__init__()
293
405
  self._refresh_lock = asyncio.Lock()
294
406
  self._expanded_window_id: str | None = None
295
407
  self._signal_watcher: _SignalFileWatcher | None = None
296
408
 
409
+ # Determine terminal mode
410
+ if use_native_terminal is True:
411
+ self._mode = _detect_terminal_mode()
412
+ if self._mode == MODE_TMUX:
413
+ self._mode = MODE_ITERM2 # Default to iTerm2 if native requested
414
+ elif use_native_terminal is False:
415
+ self._mode = MODE_TMUX
416
+ else:
417
+ self._mode = _detect_terminal_mode()
418
+
419
+ # Native backend (initialized lazily)
420
+ self._native_backend: TerminalBackend | None = None
421
+ self._native_backend_checked = False
422
+
297
423
  def compose(self) -> ComposeResult:
298
424
  logger.debug("TerminalPanel.compose() started")
299
425
  try:
@@ -318,9 +444,6 @@ class TerminalPanel(Container, can_focus=True):
318
444
  raise
319
445
 
320
446
  def on_show(self) -> None:
321
- # Don't `await refresh_data()` directly here: Textual may do an initial layout pass with
322
- # width=0 while a widget becomes visible, and Rich can raise when wrapping text at width 0.
323
- # Scheduling after the next refresh avoids a hard crash ("flash exit") when entering the tab.
324
447
  self.call_after_refresh(
325
448
  lambda: self.run_worker(
326
449
  self.refresh_data(),
@@ -328,11 +451,9 @@ class TerminalPanel(Container, can_focus=True):
328
451
  exclusive=True,
329
452
  )
330
453
  )
331
- # Start watching for permission request signals
332
454
  self._start_signal_watcher()
333
455
 
334
456
  def on_hide(self) -> None:
335
- # Stop watching when panel is hidden
336
457
  self._stop_signal_watcher()
337
458
 
338
459
  def _start_signal_watcher(self) -> None:
@@ -350,7 +471,6 @@ class TerminalPanel(Container, can_focus=True):
350
471
 
351
472
  def _on_permission_signal(self) -> None:
352
473
  """Called when a new permission request signal is detected."""
353
- # Post message to trigger refresh on the main thread
354
474
  self.post_message(self.PermissionRequestDetected())
355
475
 
356
476
  def on_terminal_panel_permission_request_detected(
@@ -363,52 +483,135 @@ class TerminalPanel(Container, can_focus=True):
363
483
  exclusive=True,
364
484
  )
365
485
 
486
+ async def _ensure_native_backend(self) -> TerminalBackend | None:
487
+ """Ensure native backend is initialized."""
488
+ if self._native_backend_checked:
489
+ return self._native_backend
490
+
491
+ self._native_backend_checked = True
492
+
493
+ if self._mode in {MODE_ITERM2, MODE_KITTY}:
494
+ self._native_backend = await _get_native_backend(self._mode)
495
+
496
+ return self._native_backend
497
+
498
+ def _is_native_mode(self) -> bool:
499
+ """Check if we're using native terminal mode."""
500
+ return self._mode in {MODE_ITERM2, MODE_KITTY}
501
+
366
502
  async def refresh_data(self) -> None:
367
503
  async with self._refresh_lock:
504
+ if self._is_native_mode():
505
+ await self._refresh_native_data()
506
+ else:
507
+ await self._refresh_tmux_data()
508
+
509
+ async def _refresh_native_data(self) -> None:
510
+ """Refresh data using native terminal backend."""
511
+ backend = await self._ensure_native_backend()
512
+ if not backend:
513
+ # Fall back to showing error message
368
514
  try:
369
- supported = self.supported()
515
+ container = self.query_one("#terminals", Vertical)
516
+ await container.remove_children()
517
+ await container.mount(Static(self._support_message()))
370
518
  except Exception:
371
- return
519
+ pass
520
+ return
372
521
 
373
- if not supported:
374
- return
522
+ try:
523
+ windows = await backend.list_tabs()
524
+ except Exception as e:
525
+ logger.error(f"Failed to list native terminals: {e}")
526
+ return
375
527
 
376
- try:
377
- windows = tmux_manager.list_inner_windows()
378
- except Exception:
379
- return
380
- active_window_id = next((w.window_id for w in windows if w.active), None)
381
- if self._expanded_window_id and self._expanded_window_id != active_window_id:
382
- self._expanded_window_id = None
383
- claude_ids = [
384
- w.session_id for w in windows if self._is_claude_window(w) and w.session_id
385
- ]
386
- titles = self._fetch_claude_session_titles(claude_ids)
387
-
388
- context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
389
- all_context_session_ids: set[str] = set()
390
- for w in windows:
391
- if not self._is_claude_window(w) or not w.context_id:
392
- continue
393
- session_ids, session_count, event_count = self._get_loaded_context_info(
394
- w.context_id
395
- )
396
- if not session_ids and session_count == 0 and event_count == 0:
397
- continue
398
- context_info_by_context_id[w.context_id] = (
399
- session_ids,
400
- session_count,
401
- event_count,
402
- )
403
- all_context_session_ids.update(session_ids)
528
+ active_window_id = next(
529
+ (w.session_id for w in windows if w.active), None
530
+ )
531
+ if self._expanded_window_id and self._expanded_window_id != active_window_id:
532
+ self._expanded_window_id = None
404
533
 
405
- if all_context_session_ids:
406
- titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
534
+ # Get Claude session IDs for title lookup
535
+ claude_ids = [
536
+ w.claude_session_id for w in windows
537
+ if self._is_claude_window(w) and w.claude_session_id
538
+ ]
539
+ titles = self._fetch_claude_session_titles(claude_ids)
407
540
 
408
- try:
409
- await self._render_terminals(windows, titles, context_info_by_context_id)
410
- except Exception:
411
- return
541
+ # Get context info
542
+ context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
543
+ all_context_session_ids: set[str] = set()
544
+ for w in windows:
545
+ if not self._is_claude_window(w) or not w.context_id:
546
+ continue
547
+ session_ids, session_count, event_count = self._get_loaded_context_info(
548
+ w.context_id
549
+ )
550
+ if not session_ids and session_count == 0 and event_count == 0:
551
+ continue
552
+ context_info_by_context_id[w.context_id] = (
553
+ session_ids,
554
+ session_count,
555
+ event_count,
556
+ )
557
+ all_context_session_ids.update(session_ids)
558
+
559
+ if all_context_session_ids:
560
+ titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
561
+
562
+ try:
563
+ await self._render_terminals_native(windows, titles, context_info_by_context_id)
564
+ except Exception:
565
+ return
566
+
567
+ async def _refresh_tmux_data(self) -> None:
568
+ """Refresh data using tmux backend."""
569
+ try:
570
+ supported = self.supported()
571
+ except Exception:
572
+ return
573
+
574
+ if not supported:
575
+ return
576
+
577
+ try:
578
+ windows = tmux_manager.list_inner_windows()
579
+ except Exception:
580
+ return
581
+
582
+ active_window_id = next((w.window_id for w in windows if w.active), None)
583
+ if self._expanded_window_id and self._expanded_window_id != active_window_id:
584
+ self._expanded_window_id = None
585
+
586
+ claude_ids = [
587
+ w.session_id for w in windows if self._is_claude_window(w) and w.session_id
588
+ ]
589
+ titles = self._fetch_claude_session_titles(claude_ids)
590
+
591
+ context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
592
+ all_context_session_ids: set[str] = set()
593
+ for w in windows:
594
+ if not self._is_claude_window(w) or not w.context_id:
595
+ continue
596
+ session_ids, session_count, event_count = self._get_loaded_context_info(
597
+ w.context_id
598
+ )
599
+ if not session_ids and session_count == 0 and event_count == 0:
600
+ continue
601
+ context_info_by_context_id[w.context_id] = (
602
+ session_ids,
603
+ session_count,
604
+ event_count,
605
+ )
606
+ all_context_session_ids.update(session_ids)
607
+
608
+ if all_context_session_ids:
609
+ titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
610
+
611
+ try:
612
+ await self._render_terminals_tmux(windows, titles, context_info_by_context_id)
613
+ except Exception:
614
+ return
412
615
 
413
616
  def _fetch_claude_session_titles(self, session_ids: list[str]) -> dict[str, str]:
414
617
  if not session_ids:
@@ -428,10 +631,7 @@ class TerminalPanel(Container, can_focus=True):
428
631
  return {}
429
632
 
430
633
  def _get_loaded_context_info(self, context_id: str) -> tuple[list[str], int, int]:
431
- """Best-effort: read ~/.aline/load.json for a context_id, and return its session ids.
432
-
433
- Also expands any context event ids to their constituent sessions (when DB is available).
434
- """
634
+ """Best-effort: read ~/.aline/load.json for a context_id, and return its session ids."""
435
635
  context_id = (context_id or "").strip()
436
636
  if not context_id:
437
637
  return ([], 0, 0)
@@ -473,12 +673,103 @@ class TerminalPanel(Container, can_focus=True):
473
673
  except Exception:
474
674
  return ([], 0, 0)
475
675
 
476
- async def _render_terminals(
676
+ async def _render_terminals_native(
677
+ self,
678
+ windows: list[TerminalInfo],
679
+ titles: dict[str, str],
680
+ context_info_by_context_id: dict[str, tuple[list[str], int, int]],
681
+ ) -> None:
682
+ """Render terminal list for native backend."""
683
+ container = self.query_one("#terminals", Vertical)
684
+ await container.remove_children()
685
+
686
+ if not windows:
687
+ await container.mount(
688
+ Static("No terminals yet. Click 'Create' to open a new agent terminal.")
689
+ )
690
+ return
691
+
692
+ for w in windows:
693
+ safe = self._safe_id_fragment(w.session_id)
694
+ row = Horizontal(classes="terminal-row")
695
+ await container.mount(row)
696
+
697
+ if w.attention:
698
+ await row.mount(Static("●", classes="attention-dot"))
699
+
700
+ switch_classes = "terminal-switch active" if w.active else "terminal-switch"
701
+ loaded_ids: list[str] = []
702
+ raw_sessions = 0
703
+ raw_events = 0
704
+ if self._is_claude_window(w) and w.context_id:
705
+ loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
706
+ w.context_id, ([], 0, 0)
707
+ )
708
+
709
+ label = self._window_label_native(w, titles, raw_sessions, raw_events)
710
+ await row.mount(
711
+ Button(
712
+ label,
713
+ id=f"switch-{safe}",
714
+ name=w.session_id,
715
+ classes=switch_classes,
716
+ )
717
+ )
718
+
719
+ can_toggle_ctx = bool(
720
+ self._is_claude_window(w) and w.context_id and (raw_sessions or raw_events)
721
+ )
722
+ expanded = bool(w.active and w.session_id == self._expanded_window_id)
723
+ if w.active and can_toggle_ctx:
724
+ await row.mount(
725
+ Button(
726
+ "▼" if expanded else "▶",
727
+ id=f"toggle-{safe}",
728
+ name=w.session_id,
729
+ variant="default",
730
+ classes="terminal-toggle",
731
+ )
732
+ )
733
+
734
+ await row.mount(
735
+ Button(
736
+ "✕",
737
+ id=f"close-{safe}",
738
+ name=w.session_id,
739
+ variant="error",
740
+ classes="terminal-close",
741
+ )
742
+ )
743
+
744
+ if w.active and self._is_claude_window(w) and w.context_id and expanded:
745
+ ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
746
+ await container.mount(ctx)
747
+ if loaded_ids:
748
+ for idx, sid in enumerate(loaded_ids):
749
+ title = titles.get(sid, "").strip() or "(no title)"
750
+ await ctx.mount(
751
+ Button(
752
+ f"{title} ({self._short_id(sid)})",
753
+ id=f"ctxsess-{safe}-{idx}",
754
+ name=sid,
755
+ variant="default",
756
+ classes="context-session",
757
+ )
758
+ )
759
+ else:
760
+ await ctx.mount(
761
+ Static(
762
+ "[dim]Context loaded, but session list isn't available (events not expanded).[/dim]"
763
+ )
764
+ )
765
+
766
+ async def _render_terminals_tmux(
477
767
  self,
478
768
  windows: list[tmux_manager.InnerWindow],
479
769
  titles: dict[str, str],
480
770
  context_info_by_context_id: dict[str, tuple[list[str], int, int]],
481
771
  ) -> None:
772
+ """Render terminal list for tmux backend."""
482
773
  container = self.query_one("#terminals", Vertical)
483
774
  await container.remove_children()
484
775
 
@@ -503,7 +794,7 @@ class TerminalPanel(Container, can_focus=True):
503
794
  loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
504
795
  w.context_id, ([], 0, 0)
505
796
  )
506
- label = self._window_label(w, titles, raw_sessions, raw_events)
797
+ label = self._window_label_tmux(w, titles, raw_sessions, raw_events)
507
798
  await row.mount(
508
799
  Button(
509
800
  label,
@@ -528,7 +819,6 @@ class TerminalPanel(Container, can_focus=True):
528
819
  )
529
820
  await row.mount(
530
821
  Button(
531
- # Avoid Rich crash when measured with width=0.
532
822
  "✕",
533
823
  id=f"close-{safe}",
534
824
  name=w.window_id,
@@ -570,13 +860,48 @@ class TerminalPanel(Container, can_focus=True):
570
860
  return "ctx 0"
571
861
  return "ctx " + " ".join(parts)
572
862
 
573
- def _window_label(
863
+ def _window_label_native(
864
+ self,
865
+ w: TerminalInfo,
866
+ titles: dict[str, str],
867
+ raw_sessions: int = 0,
868
+ raw_events: int = 0,
869
+ ) -> str | Text:
870
+ """Generate label for native terminal window."""
871
+ if not self._is_claude_window(w):
872
+ return Text(w.name, no_wrap=True, overflow="ellipsis")
873
+
874
+ title = titles.get(w.claude_session_id or "", "").strip() if w.claude_session_id else ""
875
+ header = title or ("Claude" if w.claude_session_id else "New Claude")
876
+
877
+ details = Text(no_wrap=True, overflow="ellipsis")
878
+ details.append(header)
879
+ details.append("\n")
880
+
881
+ detail_line = "[Claude]"
882
+ if w.claude_session_id:
883
+ detail_line = f"{detail_line} #{self._short_id(w.claude_session_id)}"
884
+ if w.active:
885
+ loaded_count = raw_sessions + raw_events
886
+ detail_line = f"{detail_line} | loaded context: {loaded_count}"
887
+ else:
888
+ detail_line = (
889
+ f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
890
+ )
891
+ # Show no-track indicator
892
+ if w.metadata.get("no_track") == "1":
893
+ detail_line = f"{detail_line} [NT]"
894
+ details.append(detail_line, style="dim not bold")
895
+ return details
896
+
897
+ def _window_label_tmux(
574
898
  self,
575
899
  w: tmux_manager.InnerWindow,
576
900
  titles: dict[str, str],
577
901
  raw_sessions: int = 0,
578
902
  raw_events: int = 0,
579
903
  ) -> str | Text:
904
+ """Generate label for tmux window."""
580
905
  if not self._is_claude_window(w):
581
906
  return Text(w.window_name, no_wrap=True, overflow="ellipsis")
582
907
 
@@ -587,7 +912,6 @@ class TerminalPanel(Container, can_focus=True):
587
912
  details.append(header)
588
913
  details.append("\n")
589
914
 
590
- # Build detail line with Claude label
591
915
  detail_line = "[Claude]"
592
916
  if w.session_id:
593
917
  detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
@@ -598,6 +922,9 @@ class TerminalPanel(Container, can_focus=True):
598
922
  detail_line = (
599
923
  f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
600
924
  )
925
+ # Show no-track indicator
926
+ if w.no_track:
927
+ detail_line = f"{detail_line} [NT]"
601
928
  details.append(detail_line, style="dim not bold")
602
929
  return details
603
930
 
@@ -623,24 +950,26 @@ class TerminalPanel(Container, can_focus=True):
623
950
  """Wrap a command to run in a specific directory."""
624
951
  return f"cd {shlex.quote(directory)} && {command}"
625
952
 
626
- def _on_create_agent_result(self, result: tuple[str, str, bool] | None) -> None:
953
+ def _on_create_agent_result(self, result: tuple[str, str, bool, bool] | None) -> None:
627
954
  """Handle the result from CreateAgentScreen modal."""
628
955
  if result is None:
629
956
  return
630
957
 
631
- agent_type, workspace, skip_permissions = result
958
+ agent_type, workspace, skip_permissions, no_track = result
632
959
  self.run_worker(
633
- self._create_agent(agent_type, workspace, skip_permissions=skip_permissions),
960
+ self._create_agent(
961
+ agent_type, workspace, skip_permissions=skip_permissions, no_track=no_track
962
+ ),
634
963
  group="terminal-panel-create",
635
964
  exclusive=True,
636
965
  )
637
966
 
638
967
  async def _create_agent(
639
- self, agent_type: str, workspace: str, *, skip_permissions: bool = False
968
+ self, agent_type: str, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
640
969
  ) -> None:
641
970
  """Create a new agent terminal based on the selected type and workspace."""
642
971
  if agent_type == "claude":
643
- await self._create_claude_terminal(workspace, skip_permissions=skip_permissions)
972
+ await self._create_claude_terminal(workspace, skip_permissions=skip_permissions, no_track=no_track)
644
973
  elif agent_type == "codex":
645
974
  await self._create_codex_terminal(workspace)
646
975
  elif agent_type == "opencode":
@@ -650,9 +979,64 @@ class TerminalPanel(Container, can_focus=True):
650
979
  await self.refresh_data()
651
980
 
652
981
  async def _create_claude_terminal(
653
- self, workspace: str, *, skip_permissions: bool = False
982
+ self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
654
983
  ) -> None:
655
984
  """Create a new Claude terminal."""
985
+ if self._is_native_mode():
986
+ await self._create_claude_terminal_native(workspace, skip_permissions=skip_permissions, no_track=no_track)
987
+ else:
988
+ await self._create_claude_terminal_tmux(workspace, skip_permissions=skip_permissions, no_track=no_track)
989
+
990
+ async def _create_claude_terminal_native(
991
+ self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
992
+ ) -> None:
993
+ """Create a new Claude terminal using native backend."""
994
+ backend = await self._ensure_native_backend()
995
+ if not backend:
996
+ self.app.notify(
997
+ "Native terminal backend not available",
998
+ title="Terminal",
999
+ severity="error",
1000
+ )
1001
+ return
1002
+
1003
+ terminal_id = tmux_manager.new_terminal_id()
1004
+ context_id = tmux_manager.new_context_id("cc")
1005
+
1006
+ env = {
1007
+ tmux_manager.ENV_TERMINAL_ID: terminal_id,
1008
+ tmux_manager.ENV_TERMINAL_PROVIDER: "claude",
1009
+ tmux_manager.ENV_CONTEXT_ID: context_id,
1010
+ }
1011
+ if no_track:
1012
+ env["ALINE_NO_TRACK"] = "1"
1013
+
1014
+ # Install hooks
1015
+ self._install_claude_hooks(workspace)
1016
+
1017
+ claude_cmd = "claude"
1018
+ if skip_permissions:
1019
+ claude_cmd = "claude --dangerously-skip-permissions"
1020
+
1021
+ session_id = await backend.create_tab(
1022
+ command=claude_cmd,
1023
+ terminal_id=terminal_id,
1024
+ name="Claude Code",
1025
+ env=env,
1026
+ cwd=workspace,
1027
+ )
1028
+
1029
+ if not session_id:
1030
+ self.app.notify(
1031
+ "Failed to open Claude terminal",
1032
+ title="Terminal",
1033
+ severity="error",
1034
+ )
1035
+
1036
+ async def _create_claude_terminal_tmux(
1037
+ self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1038
+ ) -> None:
1039
+ """Create a new Claude terminal using tmux backend."""
656
1040
  terminal_id = tmux_manager.new_terminal_id()
657
1041
  context_id = tmux_manager.new_context_id("cc")
658
1042
  env = {
@@ -662,7 +1046,30 @@ class TerminalPanel(Container, can_focus=True):
662
1046
  tmux_manager.ENV_INNER_SESSION: tmux_manager.INNER_SESSION,
663
1047
  tmux_manager.ENV_CONTEXT_ID: context_id,
664
1048
  }
1049
+ if no_track:
1050
+ env["ALINE_NO_TRACK"] = "1"
665
1051
 
1052
+ # Install hooks
1053
+ self._install_claude_hooks(workspace)
1054
+
1055
+ claude_cmd = "claude"
1056
+ if skip_permissions:
1057
+ claude_cmd = "claude --dangerously-skip-permissions"
1058
+ command = self._command_in_directory(
1059
+ tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
1060
+ )
1061
+ created = tmux_manager.create_inner_window(
1062
+ "cc",
1063
+ tmux_manager.shell_command_with_env(command, env),
1064
+ terminal_id=terminal_id,
1065
+ provider="claude",
1066
+ context_id=context_id,
1067
+ )
1068
+ if not created:
1069
+ self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
1070
+
1071
+ def _install_claude_hooks(self, workspace: str) -> None:
1072
+ """Install Claude hooks for a workspace."""
666
1073
  try:
667
1074
  from ...claude_hooks.stop_hook_installer import (
668
1075
  ensure_stop_hook_installed,
@@ -712,24 +1119,25 @@ class TerminalPanel(Container, can_focus=True):
712
1119
  except Exception:
713
1120
  pass
714
1121
 
715
- claude_cmd = "claude"
716
- if skip_permissions:
717
- claude_cmd = "claude --dangerously-skip-permissions"
718
- command = self._command_in_directory(
719
- tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
720
- )
721
- created = tmux_manager.create_inner_window(
722
- "cc",
723
- tmux_manager.shell_command_with_env(command, env),
724
- terminal_id=terminal_id,
725
- provider="claude",
726
- context_id=context_id,
727
- )
728
- if not created:
729
- self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
730
-
731
1122
  async def _create_codex_terminal(self, workspace: str) -> None:
732
1123
  """Create a new Codex terminal."""
1124
+ if self._is_native_mode():
1125
+ backend = await self._ensure_native_backend()
1126
+ if backend:
1127
+ terminal_id = tmux_manager.new_terminal_id()
1128
+ session_id = await backend.create_tab(
1129
+ command="codex",
1130
+ terminal_id=terminal_id,
1131
+ name="Codex",
1132
+ cwd=workspace,
1133
+ )
1134
+ if not session_id:
1135
+ self.app.notify(
1136
+ "Failed to open Codex terminal", title="Terminal", severity="error"
1137
+ )
1138
+ return
1139
+
1140
+ # Tmux fallback
733
1141
  command = self._command_in_directory(
734
1142
  tmux_manager.zsh_run_and_keep_open("codex"), workspace
735
1143
  )
@@ -739,6 +1147,23 @@ class TerminalPanel(Container, can_focus=True):
739
1147
 
740
1148
  async def _create_opencode_terminal(self, workspace: str) -> None:
741
1149
  """Create a new Opencode terminal."""
1150
+ if self._is_native_mode():
1151
+ backend = await self._ensure_native_backend()
1152
+ if backend:
1153
+ terminal_id = tmux_manager.new_terminal_id()
1154
+ session_id = await backend.create_tab(
1155
+ command="opencode",
1156
+ terminal_id=terminal_id,
1157
+ name="Opencode",
1158
+ cwd=workspace,
1159
+ )
1160
+ if not session_id:
1161
+ self.app.notify(
1162
+ "Failed to open Opencode terminal", title="Terminal", severity="error"
1163
+ )
1164
+ return
1165
+
1166
+ # Tmux fallback
742
1167
  command = self._command_in_directory(
743
1168
  tmux_manager.zsh_run_and_keep_open("opencode"), workspace
744
1169
  )
@@ -748,6 +1173,23 @@ class TerminalPanel(Container, can_focus=True):
748
1173
 
749
1174
  async def _create_zsh_terminal(self, workspace: str) -> None:
750
1175
  """Create a new zsh terminal."""
1176
+ if self._is_native_mode():
1177
+ backend = await self._ensure_native_backend()
1178
+ if backend:
1179
+ terminal_id = tmux_manager.new_terminal_id()
1180
+ session_id = await backend.create_tab(
1181
+ command="zsh -l",
1182
+ terminal_id=terminal_id,
1183
+ name="zsh",
1184
+ cwd=workspace,
1185
+ )
1186
+ if not session_id:
1187
+ self.app.notify(
1188
+ "Failed to open zsh terminal", title="Terminal", severity="error"
1189
+ )
1190
+ return
1191
+
1192
+ # Tmux fallback
751
1193
  command = self._command_in_directory("zsh", workspace)
752
1194
  created = tmux_manager.create_inner_window("zsh", command)
753
1195
  if not created:
@@ -771,14 +1213,7 @@ class TerminalPanel(Container, can_focus=True):
771
1213
  return
772
1214
 
773
1215
  if button_id.startswith("switch-"):
774
- window_id = event.button.name or ""
775
- if not window_id or not tmux_manager.select_inner_window(window_id):
776
- self.app.notify("Failed to switch terminal", title="Terminal", severity="error")
777
- # Clear attention when user clicks on terminal
778
- if window_id:
779
- tmux_manager.clear_attention(window_id)
780
- self._expanded_window_id = None
781
- await self.refresh_data()
1216
+ await self._handle_switch(event.button.name or "")
782
1217
  return
783
1218
 
784
1219
  if button_id.startswith("toggle-"):
@@ -805,7 +1240,49 @@ class TerminalPanel(Container, can_focus=True):
805
1240
  return
806
1241
 
807
1242
  if button_id.startswith("close-"):
808
- window_id = event.button.name or ""
809
- if not window_id or not tmux_manager.kill_inner_window(window_id):
1243
+ await self._handle_close(event.button.name or "")
1244
+ return
1245
+
1246
+ async def _handle_switch(self, window_id: str) -> None:
1247
+ """Handle switching to a terminal."""
1248
+ if not window_id:
1249
+ return
1250
+
1251
+ if self._is_native_mode():
1252
+ backend = await self._ensure_native_backend()
1253
+ if backend:
1254
+ success = await backend.focus_tab(window_id, steal_focus=True)
1255
+ if not success:
1256
+ self.app.notify(
1257
+ "Failed to switch terminal", title="Terminal", severity="error"
1258
+ )
1259
+ else:
1260
+ if not tmux_manager.select_inner_window(window_id):
1261
+ self.app.notify("Failed to switch terminal", title="Terminal", severity="error")
1262
+ else:
1263
+ # Move cursor focus to the right pane (terminal area)
1264
+ tmux_manager.focus_right_pane()
1265
+ # Clear attention when user clicks on terminal
1266
+ tmux_manager.clear_attention(window_id)
1267
+
1268
+ self._expanded_window_id = None
1269
+ await self.refresh_data()
1270
+
1271
+ async def _handle_close(self, window_id: str) -> None:
1272
+ """Handle closing a terminal."""
1273
+ if not window_id:
1274
+ return
1275
+
1276
+ if self._is_native_mode():
1277
+ backend = await self._ensure_native_backend()
1278
+ if backend:
1279
+ success = await backend.close_tab(window_id)
1280
+ if not success:
1281
+ self.app.notify(
1282
+ "Failed to close terminal", title="Terminal", severity="error"
1283
+ )
1284
+ else:
1285
+ if not tmux_manager.kill_inner_window(window_id):
810
1286
  self.app.notify("Failed to close terminal", title="Terminal", severity="error")
811
- await self.refresh_data()
1287
+
1288
+ await self.refresh_data()