aline-ai 0.5.13__py3-none-any.whl → 0.6.1__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,45 @@ 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
+ details.append(detail_line, style="dim not bold")
892
+ return details
893
+
894
+ def _window_label_tmux(
574
895
  self,
575
896
  w: tmux_manager.InnerWindow,
576
897
  titles: dict[str, str],
577
898
  raw_sessions: int = 0,
578
899
  raw_events: int = 0,
579
900
  ) -> str | Text:
901
+ """Generate label for tmux window."""
580
902
  if not self._is_claude_window(w):
581
903
  return Text(w.window_name, no_wrap=True, overflow="ellipsis")
582
904
 
@@ -587,7 +909,6 @@ class TerminalPanel(Container, can_focus=True):
587
909
  details.append(header)
588
910
  details.append("\n")
589
911
 
590
- # Build detail line with Claude label
591
912
  detail_line = "[Claude]"
592
913
  if w.session_id:
593
914
  detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
@@ -653,6 +974,59 @@ class TerminalPanel(Container, can_focus=True):
653
974
  self, workspace: str, *, skip_permissions: bool = False
654
975
  ) -> None:
655
976
  """Create a new Claude terminal."""
977
+ if self._is_native_mode():
978
+ await self._create_claude_terminal_native(workspace, skip_permissions=skip_permissions)
979
+ else:
980
+ await self._create_claude_terminal_tmux(workspace, skip_permissions=skip_permissions)
981
+
982
+ async def _create_claude_terminal_native(
983
+ self, workspace: str, *, skip_permissions: bool = False
984
+ ) -> None:
985
+ """Create a new Claude terminal using native backend."""
986
+ backend = await self._ensure_native_backend()
987
+ if not backend:
988
+ self.app.notify(
989
+ "Native terminal backend not available",
990
+ title="Terminal",
991
+ severity="error",
992
+ )
993
+ return
994
+
995
+ terminal_id = tmux_manager.new_terminal_id()
996
+ context_id = tmux_manager.new_context_id("cc")
997
+
998
+ env = {
999
+ tmux_manager.ENV_TERMINAL_ID: terminal_id,
1000
+ tmux_manager.ENV_TERMINAL_PROVIDER: "claude",
1001
+ tmux_manager.ENV_CONTEXT_ID: context_id,
1002
+ }
1003
+
1004
+ # Install hooks
1005
+ self._install_claude_hooks(workspace)
1006
+
1007
+ claude_cmd = "claude"
1008
+ if skip_permissions:
1009
+ claude_cmd = "claude --dangerously-skip-permissions"
1010
+
1011
+ session_id = await backend.create_tab(
1012
+ command=claude_cmd,
1013
+ terminal_id=terminal_id,
1014
+ name="Claude Code",
1015
+ env=env,
1016
+ cwd=workspace,
1017
+ )
1018
+
1019
+ if not session_id:
1020
+ self.app.notify(
1021
+ "Failed to open Claude terminal",
1022
+ title="Terminal",
1023
+ severity="error",
1024
+ )
1025
+
1026
+ async def _create_claude_terminal_tmux(
1027
+ self, workspace: str, *, skip_permissions: bool = False
1028
+ ) -> None:
1029
+ """Create a new Claude terminal using tmux backend."""
656
1030
  terminal_id = tmux_manager.new_terminal_id()
657
1031
  context_id = tmux_manager.new_context_id("cc")
658
1032
  env = {
@@ -663,6 +1037,27 @@ class TerminalPanel(Container, can_focus=True):
663
1037
  tmux_manager.ENV_CONTEXT_ID: context_id,
664
1038
  }
665
1039
 
1040
+ # Install hooks
1041
+ self._install_claude_hooks(workspace)
1042
+
1043
+ claude_cmd = "claude"
1044
+ if skip_permissions:
1045
+ claude_cmd = "claude --dangerously-skip-permissions"
1046
+ command = self._command_in_directory(
1047
+ tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
1048
+ )
1049
+ created = tmux_manager.create_inner_window(
1050
+ "cc",
1051
+ tmux_manager.shell_command_with_env(command, env),
1052
+ terminal_id=terminal_id,
1053
+ provider="claude",
1054
+ context_id=context_id,
1055
+ )
1056
+ if not created:
1057
+ self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
1058
+
1059
+ def _install_claude_hooks(self, workspace: str) -> None:
1060
+ """Install Claude hooks for a workspace."""
666
1061
  try:
667
1062
  from ...claude_hooks.stop_hook_installer import (
668
1063
  ensure_stop_hook_installed,
@@ -712,24 +1107,25 @@ class TerminalPanel(Container, can_focus=True):
712
1107
  except Exception:
713
1108
  pass
714
1109
 
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
1110
  async def _create_codex_terminal(self, workspace: str) -> None:
732
1111
  """Create a new Codex terminal."""
1112
+ if self._is_native_mode():
1113
+ backend = await self._ensure_native_backend()
1114
+ if backend:
1115
+ terminal_id = tmux_manager.new_terminal_id()
1116
+ session_id = await backend.create_tab(
1117
+ command="codex",
1118
+ terminal_id=terminal_id,
1119
+ name="Codex",
1120
+ cwd=workspace,
1121
+ )
1122
+ if not session_id:
1123
+ self.app.notify(
1124
+ "Failed to open Codex terminal", title="Terminal", severity="error"
1125
+ )
1126
+ return
1127
+
1128
+ # Tmux fallback
733
1129
  command = self._command_in_directory(
734
1130
  tmux_manager.zsh_run_and_keep_open("codex"), workspace
735
1131
  )
@@ -739,6 +1135,23 @@ class TerminalPanel(Container, can_focus=True):
739
1135
 
740
1136
  async def _create_opencode_terminal(self, workspace: str) -> None:
741
1137
  """Create a new Opencode terminal."""
1138
+ if self._is_native_mode():
1139
+ backend = await self._ensure_native_backend()
1140
+ if backend:
1141
+ terminal_id = tmux_manager.new_terminal_id()
1142
+ session_id = await backend.create_tab(
1143
+ command="opencode",
1144
+ terminal_id=terminal_id,
1145
+ name="Opencode",
1146
+ cwd=workspace,
1147
+ )
1148
+ if not session_id:
1149
+ self.app.notify(
1150
+ "Failed to open Opencode terminal", title="Terminal", severity="error"
1151
+ )
1152
+ return
1153
+
1154
+ # Tmux fallback
742
1155
  command = self._command_in_directory(
743
1156
  tmux_manager.zsh_run_and_keep_open("opencode"), workspace
744
1157
  )
@@ -748,6 +1161,23 @@ class TerminalPanel(Container, can_focus=True):
748
1161
 
749
1162
  async def _create_zsh_terminal(self, workspace: str) -> None:
750
1163
  """Create a new zsh terminal."""
1164
+ if self._is_native_mode():
1165
+ backend = await self._ensure_native_backend()
1166
+ if backend:
1167
+ terminal_id = tmux_manager.new_terminal_id()
1168
+ session_id = await backend.create_tab(
1169
+ command="zsh -l",
1170
+ terminal_id=terminal_id,
1171
+ name="zsh",
1172
+ cwd=workspace,
1173
+ )
1174
+ if not session_id:
1175
+ self.app.notify(
1176
+ "Failed to open zsh terminal", title="Terminal", severity="error"
1177
+ )
1178
+ return
1179
+
1180
+ # Tmux fallback
751
1181
  command = self._command_in_directory("zsh", workspace)
752
1182
  created = tmux_manager.create_inner_window("zsh", command)
753
1183
  if not created:
@@ -771,14 +1201,7 @@ class TerminalPanel(Container, can_focus=True):
771
1201
  return
772
1202
 
773
1203
  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()
1204
+ await self._handle_switch(event.button.name or "")
782
1205
  return
783
1206
 
784
1207
  if button_id.startswith("toggle-"):
@@ -805,7 +1228,46 @@ class TerminalPanel(Container, can_focus=True):
805
1228
  return
806
1229
 
807
1230
  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):
1231
+ await self._handle_close(event.button.name or "")
1232
+ return
1233
+
1234
+ async def _handle_switch(self, window_id: str) -> None:
1235
+ """Handle switching to a terminal."""
1236
+ if not window_id:
1237
+ return
1238
+
1239
+ if self._is_native_mode():
1240
+ backend = await self._ensure_native_backend()
1241
+ if backend:
1242
+ success = await backend.focus_tab(window_id, steal_focus=False)
1243
+ if not success:
1244
+ self.app.notify(
1245
+ "Failed to switch terminal", title="Terminal", severity="error"
1246
+ )
1247
+ else:
1248
+ if not tmux_manager.select_inner_window(window_id):
1249
+ self.app.notify("Failed to switch terminal", title="Terminal", severity="error")
1250
+ # Clear attention when user clicks on terminal
1251
+ tmux_manager.clear_attention(window_id)
1252
+
1253
+ self._expanded_window_id = None
1254
+ await self.refresh_data()
1255
+
1256
+ async def _handle_close(self, window_id: str) -> None:
1257
+ """Handle closing a terminal."""
1258
+ if not window_id:
1259
+ return
1260
+
1261
+ if self._is_native_mode():
1262
+ backend = await self._ensure_native_backend()
1263
+ if backend:
1264
+ success = await backend.close_tab(window_id)
1265
+ if not success:
1266
+ self.app.notify(
1267
+ "Failed to close terminal", title="Terminal", severity="error"
1268
+ )
1269
+ else:
1270
+ if not tmux_manager.kill_inner_window(window_id):
810
1271
  self.app.notify("Failed to close terminal", title="Terminal", severity="error")
811
- await self.refresh_data()
1272
+
1273
+ await self.refresh_data()