diff --git a/README.md b/README.md
index 9d2d39cb..aaa45b14 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Ashy Terminal
-
+
@@ -89,7 +89,8 @@ This gives you a clearer, more readable view of command output—especially in e
### 🖥️ Core Terminal Functionality
- **Session Management**: Save, organize (with folders), and launch Local, SSH, and SFTP sessions.
- **Flexible Layouts**: Split panes horizontally and vertically; save and restore complex window layouts.
-- **Directory Tracking**: Updates tab titles automatically based on the current working directory (OSC7 support).
+- **Tab Groups**: Chrome-style tab grouping with color-coded chips, drag-to-reorder, collapse/expand, and auto-join. Group names follow alphabetical sequence (A, B, ..., Z, 1A, 1B, ...).
+- **Live Directory Tracking**: Tab titles update in real-time to show the current working directory via `/proc//cwd` polling, with full path tooltip on hover.
- **Deep Customization**: Visual theme editor, font sizing, transparency (window and headerbar), and extensive keyboard shortcuts.
diff --git a/docs/chrome_tab_groups_reference.md b/docs/chrome_tab_groups_reference.md
new file mode 100644
index 00000000..9a498b07
--- /dev/null
+++ b/docs/chrome_tab_groups_reference.md
@@ -0,0 +1,165 @@
+# Referência: Como o Google Chrome Agrupa Abas (Tab Groups)
+
+> **Fonte:** blog.google, suporte oficial Chrome, observação direta do comportamento
+> **Data:** 2026-04-07
+
+---
+
+## 1. Criação de Grupos
+
+### Como criar
+- **Clique direito** em qualquer aba → opção **"Adicionar guia ao novo grupo"**
+- Também disponível: **"Adicionar guia ao grupo existente"** → mostra lista dos grupos abertos
+- Ao criar, o grupo recebe automaticamente uma **cor** da paleta e o cursor foca no **campo de nome** do label
+
+### Seleção múltipla
+- `Ctrl+Click` em várias abas → clique direito → **"Adicionar guias ao novo grupo"**
+- `Shift+Click` seleciona intervalo contíguo de abas
+
+---
+
+## 2. Visual do Grupo
+
+### Label (Chip) do grupo
+- É um **retângulo arredondado** com **fundo na cor do grupo**
+- Fica **à esquerda** das abas do grupo, na **mesma linha da barra de abas**
+- Contém o **nome do grupo** (texto) — pode ser texto, emoji, ou vazio
+- Se o nome estiver vazio, o chip é apenas um **círculo/pílula** colorido sem texto (um "dot")
+- **Altura** do chip é a mesma das abas — alinhamento vertical perfeito
+
+### Abas agrupadas
+- As abas do grupo ficam **contíguas** (sem espaço entre elas) logo após o chip
+- Cada aba do grupo tem uma **barra/underline colorida na parte inferior** na cor do grupo
+- A underline tem ~3px de espessura e é **contínua** entre as abas (parece uma linha só)
+- O restante da aba mantém aparência normal (mesma altura, ícone, título, botão fechar)
+- Abas agrupadas **não** têm fundo colorido — apenas a underline inferior
+
+### Espaçamento
+- **Entre chip e primeira aba do grupo:** 0px (grudados)
+- **Entre abas do mesmo grupo:** 0px (grudadas, underline contínua)
+- **Entre última aba do grupo e próxima aba/grupo:** espaçamento normal (4-6px)
+
+---
+
+## 3. Interações
+
+### Clique no chip
+- **Clique esquerdo** no chip → **colapsa/expande** o grupo
+ - **Colapsado:** todas as abas do grupo ficam ocultas, só o chip aparece
+ - **Expandido:** as abas voltam a aparecer à direita do chip
+ - Transição suave (animação de slide)
+
+### Clique direito no chip
+- Menu de contexto com:
+ - **"Novo grupo de guias neste grupo"** (abre nova aba já no grupo)
+ - **"Desagrupar"** (remove agrupamento, abas ficam soltas)
+ - **"Fechar grupo"** (fecha TODAS as abas do grupo)
+ - **Paleta de cores** (8 cores disponíveis para trocar)
+ - **Campo de nome** editável inline
+
+### Arrastar (Drag & Drop)
+- Arrastar uma aba para **dentro da área do grupo** (entre chip e última aba) → adiciona ao grupo
+- Arrastar uma aba para **fora do grupo** → remove do grupo (fica solta)
+- Arrastar o **chip** → move o grupo inteiro (todas as abas se movem junto)
+- Arrastar entre abas de um mesmo grupo → reordena dentro do grupo
+
+### Clique direito em aba agrupada
+- Menu normal da aba + opcão **"Remover do grupo"**
+- Também: **"Mover guia para outro grupo"** → lista grupos existentes
+
+---
+
+## 4. Cores da Paleta
+
+O Chrome oferece **8 cores** fixas para grupos:
+
+| Nome | Cor aproximada |
+|----------|---------------|
+| Grey | #5F6368 |
+| Blue | #1A73E8 |
+| Red | #D93025 |
+| Yellow | #F9AB00 |
+| Green | #188038 |
+| Pink | #D01884 |
+| Purple | #7627BB |
+| Cyan | #007B83 |
+
+- A primeira cor é atribuída automaticamente
+- As cores **rotacionam** — próximo grupo pega a próxima cor da paleta
+- O usuário pode trocar a cor a qualquer momento pelo menu do chip
+
+---
+
+## 5. Colapsar/Expandir
+
+- Clicar no chip **colapsa** → abas desaparecem, chip vira "pílula" compacta
+- Clicar de novo **expande** → abas reaparecem
+- Quando colapsado:
+ - O chip mantém a cor e o nome
+ - Tooltip mostra quantas abas estão no grupo
+ - O espaço das abas é liberado na barra de abas
+
+---
+
+## 6. Persistência
+
+- Grupos são **salvos** quando o Chrome é fechado
+- Ao reabrir, os grupos são restaurados com:
+ - Nome, cor, estado (colapsado/expandido)
+ - Ordem das abas dentro do grupo
+ - Posição do grupo na barra
+
+---
+
+## 7. Comportamentos Especiais
+
+- **Nova aba** aberta por link de uma aba agrupada → automaticamente adicionada ao mesmo grupo
+- **Arrastar aba para fora da janela** → cria nova janela, aba sai do grupo
+- **Fechar última aba** de um grupo → grupo é automaticamente deletado
+- **Renomear** → clique direto no texto do chip ou via menu de contexto
+- **Undo** (Ctrl+Z) após fechar grupo → restaura o grupo inteiro
+
+---
+
+## 8. Resumo do Layout Visual
+
+```
+Barra de Abas:
+┌─────────┬──────────┬──────────┬───┬──────────────┬──────────┬──────────┐
+│ ■ Dev │ Tab A │ Tab B │ │ ■ Prod │ Tab D │ Tab F │
+│ (azul) │ ________ │ ________ │ │ (vermelho) │ ________ │ (solta) │
+│ chip │ underline│ underline│ │ chip │ underline│ │
+└─────────┴──────────┴──────────┘ └──────────────┴──────────┘──────────┘
+ grupo 1 (expandido) grupo 2 (expandido) sem grupo
+
+Grupo colapsado:
+┌─────────┬───┬──────────────┬──────────┬──────────┐
+│ ■ Dev │ │ ■ Prod │ Tab D │ Tab F │
+│ (azul) │ │ (vermelho) │ ________ │ (solta) │
+│ pílula │ │ chip │ underline│ │
+└─────────┘ └──────────────┴──────────┘──────────┘
+ grupo 1 grupo 2 sem grupo
+ (colapsado) (expandido)
+```
+
+---
+
+## 9. O Que Implementar no AshyTerm
+
+### Prioridade Alta (Chrome Core)
+1. Chip com fundo colorido, nome editável, click para colapsar/expandir
+2. Underline colorida contínua nas abas agrupadas (3px, mesma cor do grupo)
+3. Menu de contexto do chip: renomear, trocar cor, desagrupar, fechar grupo
+4. Menu de contexto da aba: "Novo grupo", "Adicionar ao grupo X", "Remover do grupo"
+5. Colapso: chip vira pílula, abas somem; expandir restaura
+6. Persistência: salvar/restaurar grupos com estado
+
+### Prioridade Média (UX refinado)
+7. Seleção múltipla de abas (Ctrl+Click) para agrupar várias de uma vez
+8. Drag & drop: arrastar aba para dentro/fora do grupo
+9. Animação suave de colapso/expansão
+10. Atalho de teclado para criar grupo (Ctrl+Shift+G já implementado)
+
+### Prioridade Baixa (Nice to have)
+11. Tooltip com contagem de abas no chip colapsado
+12. Renomear inline direto no chip (clique duplo)
diff --git a/docs/tab_groups_plan.md b/docs/tab_groups_plan.md
new file mode 100644
index 00000000..debb96b4
--- /dev/null
+++ b/docs/tab_groups_plan.md
@@ -0,0 +1,317 @@
+# Planejamento: Sistema de Grupos de Abas (Tab Groups) — AshyTerm
+
+> **Status:** Planejamento
+> **Data:** 2026-03-30
+> **Autor:** Copilot + Ruscher
+
+---
+
+## 1. Resumo Executivo
+
+Implementar grupos de abas nomeados e coloridos, similares aos de navegadores modernos (Chrome/Edge), permitindo que o usuário organize terminais logicamente. Os grupos devem ser persistentes entre sessões.
+
+---
+
+## 2. Arquitetura Atual (Resumo)
+
+### Componentes-chave envolvidos
+
+| Arquivo | Responsabilidade |
+|---|---|
+| `terminal/tabs.py` → `TabManager` | Gerencia abas, `ViewStack`, tab bar custom (`Gtk.Box` horizontal), criação/fechamento, DnD manual, menu de contexto |
+| `terminal/manager.py` → `TerminalManager` | Ciclo de vida de terminais (local, SSH, SFTP), eventos, settings |
+| `terminal/registry.py` → `TerminalRegistry` | Registro central de terminais ativos com metadados |
+| `terminal/pane_manager.py` → `PaneManager` | Splits horizontais/verticais dentro de uma aba |
+| `state/window_state.py` → `WindowStateManager` | Serialização/restauração do layout (`session_state.json`) |
+| `sessions/models.py` → `SessionItem` | Modelo GObject de sessão (nome, host, cor, etc.) |
+| `window.py` → `CommTerminalWindow` | Janela principal, ações, atalhos, integração geral |
+| `data/styles/window.css` | Estilos das abas (`.custom-tab-button`, etc.) |
+
+### Estrutura atual da tab bar
+
+```
+CommTerminalWindow
+ └─ scrolled_tab_bar (Gtk.ScrolledWindow)
+ └─ tab_bar_box (Gtk.Box horizontal, spacing=4)
+ ├─ tab_widget_1 (Gtk.Box .custom-tab-button)
+ ├─ tab_widget_2 (Gtk.Box .custom-tab-button)
+ └─ ...
+```
+
+- Cada `tab_widget` é um `Gtk.Box` com ícone + label + botão fechar.
+- `TabManager.tabs` → `List[Gtk.Box]` (ordem visual).
+- `TabManager.pages` → `WeakKeyDictionary[Gtk.Box, Adw.ViewStackPage]`.
+- `TabManager.view_stack` → `Adw.ViewStack` contém o conteúdo de cada aba.
+- Não existe conceito de agrupamento — tudo é lista plana.
+
+### Persistência atual (`session_state.json`)
+
+```json
+{
+ "_schema_version": 1,
+ "tabs": [
+ { "type": "terminal", "session_type": "local", "session_name": "Local", "working_dir": "/home/user" },
+ { "type": "paned", "orientation": "horizontal", "start_child": {...}, "end_child": {...} }
+ ]
+}
+```
+
+---
+
+## 3. Design Proposto
+
+### 3.1 Modelo de Dados
+
+```python
+# Novo arquivo: src/ashyterm/terminal/tab_groups.py
+
+@dataclass
+class TabGroup:
+ id: str # UUID
+ name: str # Nome editável pelo usuário
+ color: str # Cor CSS (ex: "#f28b82")
+ is_collapsed: bool = False # Grupo recolhido?
+ tab_ids: list[str] = field(default_factory=list) # IDs de tab_widgets ordenados
+```
+
+### 3.2 Estrutura Visual Proposta
+
+```
+tab_bar_box (Gtk.Box horizontal)
+ ├─ [group_chip_A] (Gtk.Box .tab-group-chip) ← clicável: expande/recolhe
+ │ ├─ color_dot (Gtk.DrawingArea)
+ │ ├─ label "Servidores"
+ │ └─ chevron_icon (expand/collapse)
+ ├─ tab_widget_1 (Gtk.Box .custom-tab-button .in-group .group-A)
+ ├─ tab_widget_2 (Gtk.Box .custom-tab-button .in-group .group-A)
+ ├─ [group_chip_B]
+ │ └─ ...
+ ├─ tab_widget_5 (Gtk.Box .custom-tab-button .in-group .group-B)
+ ├─ tab_widget_6 (Gtk.Box .custom-tab-button) ← sem grupo
+ └─ ...
+```
+
+**Decisão arquitetural:** Em vez de `Gtk.Notebook` aninhado (complexo, conflita com `ViewStack`), manter a **tab bar plana** e inserir **"chips"** de grupo como widgets separados no `tab_bar_box`. Os chips atuam como cabeçalhos visuais. Abas do grupo ficam contíguas após o chip. Isso minimiza a refatoração e mantém o `Adw.ViewStack` inalterado.
+
+### 3.3 Persistência (Schema v2)
+
+```json
+{
+ "_schema_version": 2,
+ "groups": [
+ {
+ "id": "uuid-1",
+ "name": "Servidores",
+ "color": "#f28b82",
+ "is_collapsed": false
+ }
+ ],
+ "tabs": [
+ {
+ "type": "terminal",
+ "session_type": "ssh",
+ "session_name": "prod-server",
+ "working_dir": null,
+ "group_id": "uuid-1"
+ },
+ {
+ "type": "terminal",
+ "session_type": "local",
+ "session_name": "Local",
+ "working_dir": "/home/user",
+ "group_id": null
+ }
+ ],
+ "active_tab_id": null
+}
+```
+
+- **Migração v1→v2:** Se `groups` ausente, tratar todos os tabs como `group_id: null`.
+- Campo `group_id` (nullable) adicionado a cada tab serializado.
+- Lista `groups` no top-level define os metadados de cada grupo.
+
+---
+
+## 4. Arquivos a Criar/Modificar
+
+### Novos
+
+| Arquivo | Conteúdo |
+|---|---|
+| `src/ashyterm/terminal/tab_groups.py` | `TabGroup` (dataclass), `TabGroupManager` (CRUD de grupos, estado, serialização) |
+| `src/ashyterm/data/styles/tab_groups.css` | Estilos do chip de grupo, borda colorida, indicador de cor |
+
+### Modificados
+
+| Arquivo | Mudanças |
+|---|---|
+| `terminal/tabs.py` (`TabManager`) | Integrar `TabGroupManager`, renderizar chips no `tab_bar_box`, estender menu de contexto, lógica de colapsar/expandir, reordenação com grupos |
+| `state/window_state.py` (`WindowStateManager`) | Schema v2, serializar `group_id` por aba, serializar lista de grupos, migração v1→v2 |
+| `window.py` (`CommTerminalWindow`) | Registrar novos atalhos de teclado, ações GAction para grupos |
+| `data/styles/window.css` | Classe `.in-group` para abas agrupadas (borda inferior colorida) |
+| `settings/config.py` | (Opcional) Setting para habilitar/desabilitar grupos |
+
+---
+
+## 5. Fases de Implementação
+
+### Fase 1 — Modelo e CRUD (sem UI)
+**Objetivo:** Criar e testar a camada de dados isoladamente.
+
+- [ ] Criar `tab_groups.py` com `TabGroup` (dataclass) e `TabGroupManager`
+ - Métodos: `create_group()`, `delete_group()`, `rename_group()`, `set_color()`, `add_tab()`, `remove_tab()`, `toggle_collapsed()`, `get_group_for_tab()`, `reorder_tab_in_group()`, `to_dict()`, `from_dict()`
+- [ ] Testes unitários para `TabGroupManager`
+- [ ] Estender `WindowStateManager` com schema v2 + migração v1→v2
+- [ ] Testes para migração de schema
+
+### Fase 2 — Widget Chip de Grupo + Renderização
+**Objetivo:** Mostrar chips de grupo na tab bar e agrupar abas visualmente.
+
+- [ ] Criar widget `_create_group_chip(group: TabGroup) -> Gtk.Box`
+ - Cor (dot ou borda), nome (label editável inline), chevron expandir/colapsar
+- [ ] Criar CSS `tab_groups.css` com estilos para `.tab-group-chip`, `.in-group`, `.group-collapsed`
+- [ ] Método `_rebuild_tab_bar_with_groups()` em `TabManager`:
+ - Remove todos os filhos de `tab_bar_box`
+ - Insere chips e abas na ordem correta (grupos primeiro com suas abas, depois não agrupadas)
+ - Usa `group.is_collapsed` para esconder/mostrar abas do grupo
+- [ ] Conectar chip click → `toggle_collapsed()` → rebuild
+
+### Fase 3 — Menu de Contexto e Ações
+**Objetivo:** Permitir gestão de grupos via menu de contexto.
+
+- [ ] Estender `_on_tab_right_click` com seção "Grupo":
+ - "Adicionar ao Novo Grupo" → cria grupo com a aba selecionada
+ - "Mover para Grupo ▶" → submenu listando grupos existentes
+ - "Remover do Grupo" → desagrupa a aba (visível apenas se aba está num grupo)
+- [ ] Click direito no chip do grupo:
+ - "Renomear Grupo"
+ - "Cor do Grupo…"
+ - "Desagrupar Todas"
+ - "Fechar Grupo"
+ - "Mover Grupo para Nova Janela" (se `on_detach_tab_requested` suportar multiplos)
+- [ ] Criar `GAction`s para cada operação de grupo em `CommTerminalWindow._setup_actions()`
+
+### Fase 4 — Atalhos de Teclado
+**Objetivo:** Atalhos para ações frequentes.
+
+| Atalho | Ação |
+|---|---|
+| `Ctrl+Shift+G` | Adicionar aba atual a novo grupo |
+| `Ctrl+Shift+U` | Remover aba atual do grupo |
+| `Alt+Shift+Left/Right` | Mover aba entre grupos / reordenar dentro do grupo |
+
+- [ ] Registrar em `_setup_keyboard_shortcuts()`
+- [ ] Testar conflitos com atalhos existentes
+
+### Fase 5 — Drag and Drop entre Grupos
+**Objetivo:** Mover abas entre grupos via arrastar-e-soltar.
+
+- [ ] Estender a lógica de move existente (`_start_tab_move`, `_perform_tab_move`):
+ - Ao soltar sobre um chip de grupo → adicionar ao grupo
+ - Ao soltar entre abas de outro grupo → mover para aquele grupo na posição correta
+ - Ao soltar fora de qualquer grupo → desagrupar
+- [ ] Visual feedback durante drag: highlight do chip/grupo destino
+- [ ] **Nota:** DnD manual via `_tab_being_moved` já existe. Estender, não reescrever.
+
+### Fase 6 — Persistência e Restauração
+**Objetivo:** Grupos sobrevivem ao reinício.
+
+- [ ] `save_session_state()`: incluir `group_id` em cada tab + lista `groups`
+- [ ] `restore_session_state()`: recriar `TabGroupManager` a partir do state, recriar abas com grupo
+- [ ] Testar cenários:
+ - Salvar com grupos → restaurar → layout idêntico
+ - State v1 (sem grupos) → migrar → abrir sem erros
+ - Grupo com aba SSH que falha ao reconectar
+
+### Fase 7 — Polish e Edge Cases
+**Objetivo:** Robustecer e polir.
+
+- [ ] Fechar última aba de um grupo → deletar grupo automaticamente
+- [ ] Criar aba nova → herdar grupo da aba ativa (configurável)
+- [ ] Split terminal → manter no mesmo grupo da aba pai
+- [ ] Duplicar aba → manter no mesmo grupo
+- [ ] Detach tab → remover do grupo
+- [ ] Performance com 50+ abas: garantir que rebuild é O(n) e não acumula widgets
+- [ ] Acessibilidade: labels ARIA nos chips, navegação por teclado nos grupos
+
+---
+
+## 6. Considerações Técnicas
+
+### ViewStack e Grupos
+O `Adw.ViewStack` permanece inalterado — ele gerencia o conteúdo (area do terminal). Os grupos afetam apenas a **tab bar visual** (`tab_bar_box`). Isso é fundamental para minimizar o impacto.
+
+### Armazenamento do `group_id` na tab
+Cada `tab_widget` (Gtk.Box) já recebe atributos custom (`label_widget`, `session_item`, etc.). Adicionaremos `tab_widget.group_id: Optional[str]`.
+
+### CSS Approach
+```css
+/* Chip do grupo */
+.tab-group-chip {
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 0.85em;
+ font-weight: 600;
+ margin-right: 2px;
+}
+
+/* Aba dentro de um grupo — borda inferior colorida */
+.custom-tab-button.in-group {
+ border-bottom: 2px solid var(--group-color);
+ border-radius: 10px 10px 4px 4px;
+}
+
+/* Grupo recolhido — esconder abas filhas */
+/* Controlado programaticamente via widget.set_visible(False) */
+```
+
+### Wayland/X11
+O DnD atual já foi adaptado para Wayland (motion controllers desabilitados por freezes). O sistema de move manual via click é compatível. A extensão para grupos segue o mesmo padrão.
+
+---
+
+## 7. Riscos e Mitigações
+
+| Risco | Impacto | Mitigação |
+|---|---|---|
+| Rebuild frequente da tab bar causa flicker | Médio | Usar `tab_bar_box.freeze_child_notify()` / batch updates |
+| Conflito de DnD com lógica existente | Alto | Estender `_perform_tab_move`, não substituir |
+| Schema migration corrompe state | Alto | Backup automático antes de migrar; fallback para v1 |
+| Label editável inline no chip causa bugs de foco | Baixo | Usar dialog (Adw.MessageDialog) para renomear, não inline editing |
+| Performance com muitos grupos | Baixo | Lazy rebuild; grupos colapsados = widgets hidden, não destruídos |
+
+---
+
+## 8. Testes Necessários
+
+| Teste | Tipo | Fase |
+|---|---|---|
+| CRUD de TabGroup/TabGroupManager | Unitário | 1 |
+| Serialização/deserialização de grupos | Unitário | 1 |
+| Migração schema v1→v2 | Unitário | 1 |
+| Rebuild visual da tab bar com grupos | Integração | 2 |
+| Menu de contexto com opções de grupo | Manual/UI | 3 |
+| Persistência entre sessões | Integração | 6 |
+| Fechar grupo → fechar todas as abas | Integração | 3 |
+| Edge case: último tab do grupo | Unitário | 7 |
+
+---
+
+## 9. Dependências
+
+- Nenhuma dependência nova de pacote.
+- Usa apenas GTK4, libadwaita, Python stdlib (`uuid`, `dataclasses`, `json`).
+
+---
+
+## 10. Definição de Pronto (DoD)
+
+- [ ] Grupos podem ser criados, renomeados, recoloridos e deletados
+- [ ] Abas podem ser movidas entre grupos e desagrupadas
+- [ ] Grupos podem ser expandidos/recolhidos
+- [ ] Layout de grupos persiste entre sessões
+- [ ] Migração automática de sessões existentes (v1→v2)
+- [ ] Atalhos de teclado funcionais
+- [ ] CSS integrado ao tema Adwaita
+- [ ] Todos os testes passando
+- [ ] Sem regressão em abas sem grupo (comportamento padrão inalterado)
diff --git a/src/ashyterm/data/styles/components.css b/src/ashyterm/data/styles/components.css
index c8800685..b7688283 100644
--- a/src/ashyterm/data/styles/components.css
+++ b/src/ashyterm/data/styles/components.css
@@ -108,15 +108,15 @@
.tab-moving {
background-image: none;
- background-color: @accent_bg_color;
+ background-color: alpha(@accent_bg_color, 0.4);
border-radius: 6px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
- border: 2px dashed @theme_fg_color;
- opacity: 0.85;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ border: 2px dashed @accent_bg_color;
+ opacity: 0.5;
}
.tab-bar-move-mode {
- background-color: alpha(@accent_bg_color, 0.15);
+ background-color: alpha(@accent_bg_color, 0.08);
border-radius: 8px;
padding: 2px;
}
@@ -128,15 +128,17 @@
}
.tab-drop-left {
- background-color: alpha(@theme_fg_color, 0.2);
+ background-color: alpha(@accent_bg_color, 0.15);
border-radius: 6px;
box-shadow: inset 4px 0 0 0 @accent_bg_color;
+ transition: background-color 150ms ease, box-shadow 150ms ease;
}
.tab-drop-right {
- background-color: alpha(@theme_fg_color, 0.2);
+ background-color: alpha(@accent_bg_color, 0.15);
border-radius: 6px;
box-shadow: inset -4px 0 0 0 @accent_bg_color;
+ transition: background-color 150ms ease, box-shadow 150ms ease;
}
/* ==========================================================================
diff --git a/src/ashyterm/data/styles/tab_groups.css b/src/ashyterm/data/styles/tab_groups.css
new file mode 100644
index 00000000..1476950b
--- /dev/null
+++ b/src/ashyterm/data/styles/tab_groups.css
@@ -0,0 +1,55 @@
+/* ── Tab group chip (Chrome-style group label) ───────────── */
+.tab-group-chip {
+ padding: 4px 10px;
+ border-radius: 8px 8px 0 0;
+ font-size: 0.82em;
+ font-weight: 600;
+ margin: 0;
+ margin-top: 2px;
+ min-height: 22px;
+ transition: background 150ms ease, opacity 150ms ease;
+}
+
+.tab-group-chip:hover {
+ filter: brightness(1.1);
+}
+
+.tab-group-chip .group-name-label {
+ margin: 0 4px;
+ color: inherit;
+}
+
+/* ── Unnamed chip: show as small colored dot/circle ────── */
+.tab-group-chip.unnamed {
+ min-width: 12px;
+ min-height: 12px;
+ padding: 6px;
+ border-radius: 50%;
+}
+
+/* ── Collapsed chip: pílula compacta ───────────────────────── */
+.tab-group-chip.collapsed {
+ border-radius: 8px;
+ opacity: 0.9;
+ padding: 4px 8px;
+}
+
+.tab-group-chip.collapsed.unnamed {
+ border-radius: 50%;
+ padding: 6px;
+}
+
+/* ── Grouped tabs: Chrome-style colored underline ──────────── */
+.custom-tab-button.in-group {
+ border-bottom-width: 3px;
+ border-bottom-style: solid;
+ border-radius: 8px 8px 0 0;
+ margin-bottom: 0;
+ padding-bottom: 2px;
+}
+
+/* Reduce spacing between grouped tabs for "connected" feel */
+.tab-group-chip + .custom-tab-button.in-group,
+.custom-tab-button.in-group + .custom-tab-button.in-group {
+ margin-left: 0;
+}
diff --git a/src/ashyterm/settings/config.py b/src/ashyterm/settings/config.py
index 312bcee8..1dccbdec 100644
--- a/src/ashyterm/settings/config.py
+++ b/src/ashyterm/settings/config.py
@@ -45,7 +45,7 @@ class AppConstants:
APP_ID = "org.communitybig.ashyterm"
APP_TITLE = "Ashy Terminal"
- APP_VERSION = "1.8.7"
+ APP_VERSION = "1.8.8"
DEVELOPER_NAME = "BigCommunity"
DEVELOPER_TEAM = ["BigCommunity Team"]
COPYRIGHT = "© 2025 BigCommunity"
diff --git a/src/ashyterm/state/window_state.py b/src/ashyterm/state/window_state.py
index c6968f3d..38345701 100644
--- a/src/ashyterm/state/window_state.py
+++ b/src/ashyterm/state/window_state.py
@@ -30,8 +30,19 @@ class WindowStateManager:
splits, and user-defined layouts.
"""
- SCHEMA_VERSION = 1
- MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = {}
+ SCHEMA_VERSION = 2
+
+ @staticmethod
+ def _migrate_v1_to_v2(data: dict[str, Any]) -> dict[str, Any]:
+ """Add empty groups list and null group_id to each tab."""
+ data.setdefault("groups", [])
+ for tab in data.get("tabs", []):
+ tab.setdefault("group_id", None)
+ return data
+
+ MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = {
+ 1: _migrate_v1_to_v2.__func__, # type: ignore[attr-defined]
+ }
def __init__(self, window: "CommTerminalWindow"):
self.window = window
@@ -42,12 +53,23 @@ def __init__(self, window: "CommTerminalWindow"):
def save_session_state(self):
"""Serializes the current tab and pane layout to a state file."""
- state = stamp_version({"tabs": []}, self.SCHEMA_VERSION)
- for page in self.tab_manager.pages.values():
+ state = stamp_version({"groups": [], "tabs": []}, self.SCHEMA_VERSION)
+
+ # Serialize groups
+ state["groups"] = self.tab_manager.group_manager.to_list()
+
+ for tab_widget in self.tab_manager.tabs:
+ page = self.tab_manager.pages.get(tab_widget)
+ if not page:
+ continue
tab_content = page.get_child()
if tab_content:
tab_structure = self._serialize_widget_tree(tab_content)
if tab_structure:
+ # Attach group_id
+ tab_id = self.tab_manager.get_tab_id(tab_widget)
+ group = self.tab_manager.group_manager.get_group_for_tab(tab_id)
+ tab_structure["group_id"] = group.id if group else None
state["tabs"].append(tab_structure)
try:
@@ -76,9 +98,22 @@ def restore_session_state(self) -> bool:
if not state.get("tabs"):
return False
+ # Restore groups metadata
+ if state.get("groups"):
+ self.tab_manager.group_manager.load_from_list(state["groups"])
+
self.logger.info(f"Restoring {len(state['tabs'])} tabs from previous session.")
for tab_structure in state["tabs"]:
+ group_id = tab_structure.pop("group_id", None)
self.tab_manager.recreate_tab_from_structure(tab_structure)
+ # Re-assign group membership using the last-created tab
+ if group_id and self.tab_manager.tabs:
+ new_tab = self.tab_manager.tabs[-1]
+ tab_id = self.tab_manager.get_tab_id(new_tab)
+ self.tab_manager.group_manager.add_tab_to_group(group_id, tab_id)
+
+ # Rebuild tab bar to show group chips
+ self.tab_manager._rebuild_tab_bar_order()
self.clear_session_state()
return True
diff --git a/src/ashyterm/terminal/manager.py b/src/ashyterm/terminal/manager.py
index aea72347..78610171 100644
--- a/src/ashyterm/terminal/manager.py
+++ b/src/ashyterm/terminal/manager.py
@@ -91,18 +91,18 @@ def __init__(self, parent_window, settings_manager: SettingsManager, spawner=Non
self._highlight_manager = None
# Track when commands start for long-running command notifications
self._command_start_times: Dict[int, float] = {}
- # Process check timer runs every 3 seconds to reduce main thread load
- # (psutil calls can be blocking and cause UI freezes on some systems)
- self._process_check_timer_id = GLib.timeout_add_seconds(
- 3, self._periodic_process_check
+ # Process check timer — runs every second to keep tab titles
+ # up-to-date via /proc//cwd polling (readlink is near-instant).
+ self._process_check_timer_id = GLib.timeout_add(
+ 1000, self._periodic_process_check
)
self.logger.info("Terminal manager initialized")
def _ensure_process_check_timer(self) -> None:
"""Re-arm the periodic process check timer if it was stopped."""
if self._process_check_timer_id is None:
- self._process_check_timer_id = GLib.timeout_add_seconds(
- 3, self._periodic_process_check
+ self._process_check_timer_id = GLib.timeout_add(
+ 1000, self._periodic_process_check
)
def prepare_initial_terminal(self) -> None:
@@ -241,11 +241,11 @@ def set_terminal_exit_handler(self, handler: Callable):
def _periodic_process_check(self) -> bool:
"""
- Periodic check to detect manual SSH sessions in local terminals.
+ Periodic check to detect manual SSH sessions and update CWD titles.
- This runs every 3 seconds and checks for manual SSH sessions.
- Note: Context-aware highlighting is now handled by CommandDetector
- which parses the terminal output stream in real-time.
+ This runs every 3 seconds and checks for:
+ - Manual SSH sessions in local terminals
+ - Current working directory changes via /proc//cwd
"""
# Stop timer if no terminals are registered
if not self.registry.get_all_terminal_ids():
@@ -261,10 +261,42 @@ def _periodic_process_check(self) -> bool:
if terminal_id is not None:
# Check for manual SSH sessions
self.manual_ssh_tracker.check_process_tree(terminal_id)
+
+ # Poll /proc//cwd for all local terminals to keep tab titles updated
+ self._poll_terminal_cwd()
except Exception as e:
self.logger.debug(f"Periodic check error: {e}")
return True
+ def _poll_terminal_cwd(self) -> None:
+ """Read /proc//cwd for tracked processes and update tab titles."""
+ tracker = self.spawner.process_tracker
+ with tracker._lock:
+ processes = dict(tracker._processes)
+
+ for pid, info in processes.items():
+ if info.get("type") != "local":
+ continue
+ terminal = info.get("terminal")
+ if not terminal:
+ continue
+ try:
+ cwd = os.readlink(f"/proc/{pid}/cwd")
+ except OSError:
+ continue
+
+ # Only update if directory actually changed
+ last_cwd = getattr(terminal, "_last_polled_cwd", None)
+ if cwd == last_cwd:
+ continue
+ terminal._last_polled_cwd = cwd
+
+ osc7_info = parse_directory_uri(
+ f"file://{os.uname().nodename}{cwd}", self.osc7_tracker.parser
+ )
+ if osc7_info:
+ self._update_title(terminal, osc7_info)
+
def _on_manual_ssh_state_changed(self, terminal: Vte.Terminal):
self._update_title(terminal)
return False
@@ -449,6 +481,15 @@ def _update_title(
if uri:
osc7_info = parse_directory_uri(uri, self.osc7_tracker.parser)
+ # Fallback: use last polled CWD from /proc//cwd
+ if osc7_info is None:
+ last_cwd = getattr(terminal, "_last_polled_cwd", None)
+ if last_cwd:
+ osc7_info = parse_directory_uri(
+ f"file://{os.uname().nodename}{last_cwd}",
+ self.osc7_tracker.parser,
+ )
+
new_title = self._compute_terminal_title(
terminal_info, terminal_id, terminal, osc7_info
)
diff --git a/src/ashyterm/terminal/tab_groups.py b/src/ashyterm/terminal/tab_groups.py
new file mode 100644
index 00000000..f17cfc9c
--- /dev/null
+++ b/src/ashyterm/terminal/tab_groups.py
@@ -0,0 +1,213 @@
+"""Tab group data model and management for organizing tabs into named, colored groups."""
+
+from __future__ import annotations
+
+import uuid
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+@dataclass
+class TabGroup:
+ """Represents a named, colored group of tabs."""
+
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ name: str = ""
+ color: str = ""
+ is_collapsed: bool = False
+ tab_ids: list[str] = field(default_factory=list)
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "name": self.name,
+ "color": self.color,
+ "is_collapsed": self.is_collapsed,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> TabGroup:
+ return cls(
+ id=data.get("id", str(uuid.uuid4())),
+ name=data.get("name", ""),
+ color=data.get("color", ""),
+ is_collapsed=data.get("is_collapsed", False),
+ )
+
+
+class TabGroupManager:
+ """Manages the lifecycle and state of tab groups.
+
+ Tab IDs here correspond to the string identity of tab widgets
+ (derived from ``id(tab_widget)``). The manager is UI-agnostic:
+ it only tracks membership and metadata.
+ """
+
+ # Predefined palette aligned with Adwaita named colors
+ PALETTE: list[tuple[str, str]] = [
+ ("red", "#f66151"),
+ ("orange", "#ffa348"),
+ ("yellow", "#f8e45c"),
+ ("green", "#57e389"),
+ ("cyan", "#5bc8af"),
+ ("blue", "#62a0ea"),
+ ("purple", "#c061cb"),
+ ("pink", "#f5c2e7"),
+ ]
+
+ def __init__(self) -> None:
+ self._groups: dict[str, TabGroup] = {}
+ # Ordered list of group ids (visual order in tab bar)
+ self._group_order: list[str] = []
+
+ # ── queries ──────────────────────────────────────────────
+
+ @property
+ def groups(self) -> list[TabGroup]:
+ """Return groups in visual order."""
+ return [self._groups[gid] for gid in self._group_order if gid in self._groups]
+
+ def get_group(self, group_id: str) -> Optional[TabGroup]:
+ return self._groups.get(group_id)
+
+ def get_group_for_tab(self, tab_id: str) -> Optional[TabGroup]:
+ for group in self._groups.values():
+ if tab_id in group.tab_ids:
+ return group
+ return None
+
+ def has_groups(self) -> bool:
+ return bool(self._groups)
+
+ # ── CRUD ─────────────────────────────────────────────────
+
+ def create_group(
+ self,
+ name: str,
+ color: str = "",
+ initial_tab_ids: Optional[list[str]] = None,
+ ) -> TabGroup:
+ """Create a new group and optionally assign tabs to it."""
+ if not color:
+ color = self._next_color()
+ group = TabGroup(name=name, color=color)
+ if initial_tab_ids:
+ for tid in initial_tab_ids:
+ self._remove_tab_from_current_group(tid)
+ group.tab_ids = list(initial_tab_ids)
+ self._groups[group.id] = group
+ self._group_order.append(group.id)
+ return group
+
+ def delete_group(self, group_id: str) -> list[str]:
+ """Delete a group. Returns the tab_ids that were in it (now ungrouped)."""
+ group = self._groups.pop(group_id, None)
+ if not group:
+ return []
+ if group_id in self._group_order:
+ self._group_order.remove(group_id)
+ return list(group.tab_ids)
+
+ def rename_group(self, group_id: str, new_name: str) -> None:
+ group = self._groups.get(group_id)
+ if group:
+ group.name = new_name
+
+ def set_group_color(self, group_id: str, color: str) -> None:
+ group = self._groups.get(group_id)
+ if group:
+ group.color = color
+
+ def toggle_collapsed(self, group_id: str) -> bool:
+ """Toggle collapsed state. Returns the new state."""
+ group = self._groups.get(group_id)
+ if not group:
+ return False
+ group.is_collapsed = not group.is_collapsed
+ return group.is_collapsed
+
+ # ── tab membership ───────────────────────────────────────
+
+ def add_tab_to_group(self, group_id: str, tab_id: str, position: int = -1) -> bool:
+ """Add a tab to a group. Removes from current group first if any."""
+ group = self._groups.get(group_id)
+ if not group:
+ return False
+ self._remove_tab_from_current_group(tab_id)
+ if position < 0 or position >= len(group.tab_ids):
+ group.tab_ids.append(tab_id)
+ else:
+ group.tab_ids.insert(position, tab_id)
+ return True
+
+ def remove_tab_from_group(self, tab_id: str) -> Optional[str]:
+ """Remove a tab from whatever group it belongs to.
+ Returns the group_id it was removed from, or None."""
+ group = self.get_group_for_tab(tab_id)
+ if not group:
+ return None
+ group.tab_ids.remove(tab_id)
+ removed_from = group.id
+ # Auto-delete empty groups
+ if not group.tab_ids:
+ self.delete_group(group.id)
+ return removed_from
+
+ def move_tab_in_group(self, tab_id: str, new_position: int) -> None:
+ """Reorder a tab within its current group."""
+ group = self.get_group_for_tab(tab_id)
+ if not group or tab_id not in group.tab_ids:
+ return
+ group.tab_ids.remove(tab_id)
+ if new_position < 0 or new_position >= len(group.tab_ids):
+ group.tab_ids.append(tab_id)
+ else:
+ group.tab_ids.insert(new_position, tab_id)
+
+ def on_tab_removed(self, tab_id: str) -> None:
+ """Notify the manager that a tab was closed/destroyed."""
+ self.remove_tab_from_group(tab_id)
+
+ # ── serialization ────────────────────────────────────────
+
+ def to_list(self) -> list[dict]:
+ """Serialize all groups for persistence."""
+ return [self._groups[gid].to_dict() for gid in self._group_order if gid in self._groups]
+
+ def load_from_list(self, data: list[dict]) -> None:
+ """Restore groups from persisted data. Tab membership is set by the caller."""
+ self._groups.clear()
+ self._group_order.clear()
+ for item in data:
+ group = TabGroup.from_dict(item)
+ self._groups[group.id] = group
+ self._group_order.append(group.id)
+
+ # ── private helpers ──────────────────────────────────────
+
+ def _remove_tab_from_current_group(self, tab_id: str) -> None:
+ group = self.get_group_for_tab(tab_id)
+ if group and tab_id in group.tab_ids:
+ group.tab_ids.remove(tab_id)
+ if not group.tab_ids:
+ self.delete_group(group.id)
+
+ def move_group(self, group_id: str, new_index: int) -> None:
+ """Move a group to a new position in the visual order."""
+ if group_id not in self._group_order:
+ return
+ self._group_order.remove(group_id)
+ new_index = max(0, min(new_index, len(self._group_order)))
+ self._group_order.insert(new_index, group_id)
+
+ def _next_color(self) -> str:
+ """Pick the next color from the palette that is used the least."""
+ usage: dict[str, int] = {c: 0 for _, c in self.PALETTE}
+ for g in self._groups.values():
+ if g.color in usage:
+ usage[g.color] += 1
+ min_count = min(usage.values()) if usage else 0
+ for _, c in self.PALETTE:
+ if usage.get(c, 0) == min_count:
+ return c
+ return self.PALETTE[0][1]
diff --git a/src/ashyterm/terminal/tabs.py b/src/ashyterm/terminal/tabs.py
index c923ba69..dc370756 100644
--- a/src/ashyterm/terminal/tabs.py
+++ b/src/ashyterm/terminal/tabs.py
@@ -3,6 +3,7 @@
import re
import threading
import weakref
+from pathlib import Path
from typing import TYPE_CHECKING, Callable, List, Optional
import gi
@@ -25,6 +26,7 @@
from .manager import TerminalManager
from .pane_manager import PaneManager
from .scroll_handler import ScrollHandler
+from .tab_groups import TabGroupManager
if TYPE_CHECKING:
from ..filemanager.manager import FileManager
@@ -139,6 +141,7 @@ def __init__(
self._drop_side: str = (
"left" # "left" or "right" - which side of target to drop
)
+ self._group_being_moved = None # TabGroup being moved (or None)
# Set up tab bar for receiving move drop events
self._setup_tab_bar_move_handlers()
@@ -147,6 +150,9 @@ def __init__(
self._cleanup_lock = threading.Lock()
self._last_focused_terminal = None
+ # Tab groups
+ self.group_manager = TabGroupManager()
+
# Delegates
self.scroll_handler = ScrollHandler(self)
self.fm_handler = FileManagerIntegration(self)
@@ -213,27 +219,30 @@ def _perform_tab_move(self):
# Calculate final position
if side == "left":
- # Insert before target
new_idx = target_idx
else:
- # Insert after target
new_idx = target_idx + 1
# Adjust if moving from before the target
if moving_idx < new_idx:
new_idx -= 1
- # Only move if position actually changes
- if moving_idx == new_idx:
- return
+ # Remove tab from its current group
+ tab_id = self.get_tab_id(moving_tab)
+ self.group_manager.remove_tab_from_group(tab_id)
+ moving_tab.remove_css_class("in-group")
- # Remove from old position
- self.tabs.remove(moving_tab)
+ # Move in list only if position actually changes
+ if moving_idx != new_idx:
+ self.tabs.remove(moving_tab)
+ self.tabs.insert(new_idx, moving_tab)
- # Insert at new position
- self.tabs.insert(new_idx, moving_tab)
+ # Auto-join group if dropped next to a grouped tab
+ target_tab_id = self.get_tab_id(target_tab)
+ target_group = self.group_manager.get_group_for_tab(target_tab_id)
+ if target_group:
+ self.group_manager.add_tab_to_group(target_group.id, tab_id)
- # Rebuild visual order
self._rebuild_tab_bar_order()
self.logger.info(
@@ -242,6 +251,9 @@ def _perform_tab_move(self):
def cancel_tab_move_if_active(self) -> bool:
"""Cancel the tab move operation if one is active. Returns True if cancelled."""
+ if self._group_being_moved is not None:
+ self._cancel_group_move()
+ return True
if self._tab_being_moved is not None:
self._cancel_tab_move()
return True
@@ -257,6 +269,391 @@ def _update_tab_alignment(self):
else: # center or any other value defaults to center
self.tab_bar_box.set_halign(Gtk.Align.CENTER)
+ # ── Tab group helpers ────────────────────────────────────
+
+ def get_tab_id(self, tab_widget: Gtk.Box) -> str:
+ """Return a stable string identifier for a tab widget."""
+ return str(id(tab_widget))
+
+ def _get_tab_by_id(self, tab_id: str) -> Optional[Gtk.Box]:
+ """Find a tab widget by its string id."""
+ for tab in self.tabs:
+ if self.get_tab_id(tab) == tab_id:
+ return tab
+ return None
+
+ def _ensure_group_tabs_contiguous(self, group_id: str) -> None:
+ """Move group tabs together in self.tabs so they are contiguous."""
+ group = self.group_manager.get_group(group_id)
+ if not group or len(group.tab_ids) < 2:
+ return
+ group_tab_id_set = set(group.tab_ids)
+ # Find the first group tab position in self.tabs
+ first_idx = next(
+ (i for i, t in enumerate(self.tabs)
+ if self.get_tab_id(t) in group_tab_id_set),
+ None,
+ )
+ if first_idx is None:
+ return
+ # Collect and remove non-contiguous group tabs
+ insert_pos = first_idx + 1
+ i = insert_pos
+ while i < len(self.tabs):
+ tab_id = self.get_tab_id(self.tabs[i])
+ if tab_id in group_tab_id_set:
+ # Already in place, advance insert position
+ insert_pos = i + 1
+ i += 1
+ elif insert_pos <= i:
+ i += 1
+ else:
+ i += 1
+ # Simpler approach: extract all group tabs, remove them, reinsert contiguously
+ group_tabs = [t for t in self.tabs if self.get_tab_id(t) in group_tab_id_set]
+ for t in group_tabs:
+ self.tabs.remove(t)
+ # Insert at the position of the first group tab (or end if nothing before)
+ insert_at = min(first_idx, len(self.tabs))
+ for j, t in enumerate(group_tabs):
+ self.tabs.insert(insert_at + j, t)
+
+ def _next_group_name(self) -> str:
+ """Generate a unique sequential group name: Grupo A, B, ..., Z, 1A, 1B, ..."""
+ existing_names = {g.name for g in self.group_manager.groups}
+ prefix_num = 0
+ while True:
+ for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
+ prefix = f"{prefix_num}" if prefix_num > 0 else ""
+ candidate = f"{_('Group')} {prefix}{letter}"
+ if candidate not in existing_names:
+ return candidate
+ prefix_num += 1
+
+ def create_group_from_tabs(self, tab_widgets: List[Gtk.Box], name: str = "") -> None:
+ """Create a new group for the given tabs."""
+ tab_ids = [self.get_tab_id(t) for t in tab_widgets]
+ if not name:
+ name = self._next_group_name()
+ group = self.group_manager.create_group(name, initial_tab_ids=tab_ids)
+ self._ensure_group_tabs_contiguous(group.id)
+ self._rebuild_tab_bar_order()
+ self.logger.info(f"Created group '{group.name}' with {len(tab_ids)} tab(s)")
+
+ def create_group_from_active_tab(self) -> None:
+ """Create a new group containing only the active tab."""
+ if self.active_tab:
+ self.create_group_from_tabs([self.active_tab])
+
+ def ungroup_active_tab(self) -> None:
+ """Remove the active tab from its group."""
+ if not self.active_tab:
+ return
+ tab_id = self.get_tab_id(self.active_tab)
+ self.group_manager.remove_tab_from_group(tab_id)
+ self.active_tab.remove_css_class("in-group")
+ self._rebuild_tab_bar_order()
+
+ def _create_group_chip(self, group) -> Gtk.Box:
+ """Build the Chrome-style chip widget for a group header in the tab bar."""
+ chip = Gtk.Box(spacing=0)
+ chip.add_css_class("tab-group-chip")
+ if group.is_collapsed:
+ chip.add_css_class("collapsed")
+ if not group.name:
+ chip.add_css_class("unnamed")
+
+ # Apply group color as background
+ provider = Gtk.CssProvider()
+ text_color = self._get_contrasting_text_for_hex(group.color)
+ css = f"""
+ .tab-group-chip {{
+ background-color: {group.color};
+ color: {text_color};
+ }}
+ """
+ provider.load_from_data(css.encode("utf-8"))
+ chip.get_style_context().add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
+
+ # Group name label with tab count
+ tab_count = len(group.tab_ids)
+ display_name = f"{group.name} ({tab_count})" if group.name else f"({tab_count})"
+ label = Gtk.Label(label=display_name)
+ label.add_css_class("group-name-label")
+ chip.append(label)
+
+ # Tooltip with full name and count
+ chip.set_tooltip_text(display_name)
+
+ # Click gesture to toggle collapse
+ click = Gtk.GestureClick.new()
+ click.connect("pressed", self._on_group_chip_clicked, group)
+ chip.add_controller(click)
+
+ # Right-click for context menu
+ right_click = Gtk.GestureClick.new()
+ right_click.set_button(Gdk.BUTTON_SECONDARY)
+ right_click.connect("pressed", self._on_group_chip_right_click, chip, group)
+ chip.add_controller(right_click)
+
+ chip._group_id = group.id
+ return chip
+
+ @staticmethod
+ def _get_contrasting_text_for_hex(hex_color: str) -> str:
+ """Return black or white text based on hex background color luminance."""
+ try:
+ h = hex_color.lstrip("#")
+ r, g, b = int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, int(h[4:6], 16) / 255.0
+ luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
+ return "#000000" if luminance > 0.5 else "#FFFFFF"
+ except (ValueError, IndexError):
+ return "#000000"
+
+ def _on_group_chip_clicked(self, _gesture, _n, _x, _y, group):
+ """Toggle group collapsed state and rebuild tab bar."""
+ # If in group move mode, clicking a chip acts as drop target
+ if self._group_being_moved is not None:
+ if group.id != self._group_being_moved.id:
+ groups = self.group_manager.groups
+ target_idx = next(
+ (i for i, g in enumerate(groups) if g.id == group.id), -1
+ )
+ if target_idx >= 0:
+ self.group_manager.move_group(self._group_being_moved.id, target_idx)
+ self._rebuild_tab_bar_order()
+ self._cancel_group_move()
+ return
+ # Cancel tab move mode if active
+ if self._tab_being_moved is not None:
+ self._cancel_tab_move()
+ return
+ self.group_manager.toggle_collapsed(group.id)
+ self._rebuild_tab_bar_order()
+
+ def _on_group_chip_right_click(self, _gesture, _n, x, y, chip, group):
+ """Show context menu on group chip."""
+ menu = Gio.Menu()
+ menu.append(_("New Tab in Group"), "win.new-tab-in-group")
+
+ edit_section = Gio.Menu()
+ edit_section.append(_("Rename Group"), "win.rename-group")
+ edit_section.append(_("Group Color…"), "win.group-color")
+ edit_section.append(_("Move Group"), "win.move-group")
+ menu.append_section(None, edit_section)
+
+ action_section = Gio.Menu()
+ action_section.append(_("Ungroup"), "win.ungroup-all")
+ action_section.append(_("Close Group"), "win.close-group")
+ menu.append_section(None, action_section)
+
+ popover = create_themed_popover_menu(menu, chip)
+
+ action_group = Gio.SimpleActionGroup()
+
+ new_tab_action = Gio.SimpleAction.new("new-tab-in-group", None)
+ new_tab_action.connect("activate", lambda _a, _p, g=group: self._new_tab_in_group(g))
+ action_group.add_action(new_tab_action)
+
+ rename_action = Gio.SimpleAction.new("rename-group", None)
+ rename_action.connect("activate", lambda _a, _p, g=group: self._rename_group_dialog(g))
+ action_group.add_action(rename_action)
+
+ color_action = Gio.SimpleAction.new("group-color", None)
+ color_action.connect(
+ "activate",
+ lambda _a, _p, g=group, pop=popover: self._pick_group_color(g, pop),
+ )
+ action_group.add_action(color_action)
+
+ move_action = Gio.SimpleAction.new("move-group", None)
+ move_action.connect(
+ "activate",
+ lambda _a, _p, g=group: GLib.idle_add(self._start_group_move, g),
+ )
+ action_group.add_action(move_action)
+
+ ungroup_action = Gio.SimpleAction.new("ungroup-all", None)
+ ungroup_action.connect("activate", lambda _a, _p, g=group: self._ungroup_all(g))
+ action_group.add_action(ungroup_action)
+
+ close_action = Gio.SimpleAction.new("close-group", None)
+ close_action.connect("activate", lambda _a, _p, g=group: self._close_group(g))
+ action_group.add_action(close_action)
+
+ popover.insert_action_group("win", action_group)
+
+ rect = Gdk.Rectangle()
+ rect.x = int(x)
+ rect.y = int(y)
+ popover.set_pointing_to(rect)
+ popover.popup()
+
+ def _start_group_move(self, group) -> None:
+ """Enter group move mode — group chip + tabs shown as ghost."""
+ self._group_being_moved = group
+ self.tab_bar_box.add_css_class("tab-bar-move-mode")
+ # Apply ghost style to group chip and its tabs
+ child = self.tab_bar_box.get_first_child()
+ while child:
+ if hasattr(child, "_group_id") and child._group_id == group.id:
+ child.add_css_class("tab-moving")
+ child = child.get_next_sibling()
+ for tab_id in group.tab_ids:
+ tab = self._get_tab_by_id(tab_id)
+ if tab:
+ tab.add_css_class("tab-moving")
+ # Hide close buttons
+ for tab in self.tabs:
+ close_btn = self._get_tab_close_button(tab)
+ if close_btn:
+ close_btn.set_opacity(0)
+ close_btn.set_sensitive(False)
+ self.logger.info(f"Group move started for: {group.name}")
+
+ def _cancel_group_move(self) -> None:
+ """Cancel group move mode and restore visuals."""
+ if self._group_being_moved is None:
+ return
+ group = self._group_being_moved
+ self.tab_bar_box.remove_css_class("tab-bar-move-mode")
+ self._clear_tab_drop_highlights()
+ # Remove ghost from chip
+ child = self.tab_bar_box.get_first_child()
+ while child:
+ if hasattr(child, "_group_id") and child._group_id == group.id:
+ child.remove_css_class("tab-moving")
+ child = child.get_next_sibling()
+ # Remove ghost from tabs
+ for tab_id in group.tab_ids:
+ tab = self._get_tab_by_id(tab_id)
+ if tab:
+ tab.remove_css_class("tab-moving")
+ # Restore close buttons
+ for tab in self.tabs:
+ close_btn = self._get_tab_close_button(tab)
+ if close_btn:
+ close_btn.set_opacity(1)
+ close_btn.set_sensitive(True)
+ self._group_being_moved = None
+
+ def _perform_group_move(self, target_tab: Gtk.Box, side: str) -> None:
+ """Move all group tabs together in self.tabs to a new position."""
+ group = self._group_being_moved
+ if not group:
+ return
+
+ target_tab_id = self.get_tab_id(target_tab)
+ if target_tab_id in group.tab_ids:
+ return
+
+ # Collect group tabs in their current self.tabs order
+ group_tab_id_set = set(group.tab_ids)
+ group_tabs = [t for t in self.tabs if self.get_tab_id(t) in group_tab_id_set]
+
+ # Remove group tabs from self.tabs
+ for t in group_tabs:
+ self.tabs.remove(t)
+
+ # Find target position after removal
+ target_idx = self.tabs.index(target_tab)
+ insert_idx = target_idx if side == "left" else target_idx + 1
+
+ # Insert group tabs at the new position
+ for i, t in enumerate(group_tabs):
+ self.tabs.insert(insert_idx + i, t)
+
+ self._rebuild_tab_bar_order()
+ self.logger.info(f"Group '{group.name}' moved to position {insert_idx}")
+
+ def _new_tab_in_group(self, group) -> None:
+ """Create a new local tab and add it to the group."""
+ terminal = self.create_local_tab()
+ if terminal and self.tabs:
+ new_tab = self.tabs[-1]
+ tab_id = self.get_tab_id(new_tab)
+ self.group_manager.add_tab_to_group(group.id, tab_id)
+ self._ensure_group_tabs_contiguous(group.id)
+ self._rebuild_tab_bar_order()
+
+ def _pick_group_color(self, group, popover: Gtk.Popover) -> None:
+ """Open color chooser dialog for a group (same as tab color)."""
+ popover.popdown()
+ dialog = Gtk.ColorDialog(title=_("Group Color"))
+ dialog.choose_rgba(
+ self.view_stack.get_root(), None, None, self._on_group_color_chosen, group
+ )
+
+ def _on_group_color_chosen(self, dialog, result, group) -> None:
+ """Handle color chooser result for a group."""
+ try:
+ color = dialog.choose_rgba_finish(result)
+ except GLib.Error:
+ return
+ r, g, b = int(color.red * 255), int(color.green * 255), int(color.blue * 255)
+ hex_color = f"#{r:02x}{g:02x}{b:02x}"
+ self.group_manager.set_group_color(group.id, hex_color)
+ self._rebuild_tab_bar_order()
+
+ def _close_group(self, group) -> None:
+ """Close all tabs in a group (Chrome 'Close group' behavior)."""
+ tabs_to_close = []
+ for tab_id in list(group.tab_ids):
+ tab = self._get_tab_by_id(tab_id)
+ if tab:
+ tabs_to_close.append(tab)
+ self.group_manager.delete_group(group.id)
+ for tab in tabs_to_close:
+ self._on_tab_close_button_clicked(None, tab)
+
+ def _rename_group_dialog(self, group) -> None:
+ """Show dialog to rename a group."""
+ dialog = Adw.AlertDialog(
+ heading=_("Rename Group"),
+ body=_("Enter a new name for the group:"),
+ close_response="cancel",
+ )
+ entry = Gtk.Entry(text=group.name, activates_default=True)
+ dialog.set_extra_child(entry)
+ dialog.add_response("cancel", _("Cancel"))
+ dialog.add_response("rename", _("Rename"))
+ dialog.set_response_appearance("rename", Adw.ResponseAppearance.SUGGESTED)
+ dialog.set_default_response("rename")
+ dialog.connect(
+ "response",
+ lambda d, r, e=entry, g=group: self._on_rename_group_response(r, e, g),
+ )
+ dialog.present(self.view_stack.get_root())
+
+ def _on_rename_group_response(self, response: str, entry: Gtk.Entry, group) -> None:
+ if response != "rename":
+ return
+ new_name = entry.get_text().strip()
+ self.group_manager.rename_group(group.id, new_name)
+ self._rebuild_tab_bar_order()
+
+ def _ungroup_all(self, group) -> None:
+ """Remove all tabs from a group (tabs remain open)."""
+ for tab_id in list(group.tab_ids):
+ tab = self._get_tab_by_id(tab_id)
+ if tab:
+ tab.remove_css_class("in-group")
+ self.group_manager.delete_group(group.id)
+ self._rebuild_tab_bar_order()
+
+ def _apply_group_border_color(self, tab_widget: Gtk.Box, color: str) -> None:
+ """Apply a group color as bottom border indicator."""
+ style_context = tab_widget.get_style_context()
+ # Remove previous group border provider if any
+ if hasattr(tab_widget, "_group_border_provider"):
+ style_context.remove_provider(tab_widget._group_border_provider)
+
+ provider = Gtk.CssProvider()
+ css = f".custom-tab-button.in-group {{ border-bottom-color: {color}; }}"
+ provider.load_from_data(css.encode("utf-8"))
+ style_context.add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
+ tab_widget._group_border_provider = provider
+
def get_view_stack(self) -> Adw.ViewStack:
return self.view_stack
@@ -330,7 +727,8 @@ def create_local_tab(
close_after_execute=close_after_execute,
)
if terminal:
- self._create_tab_for_terminal(terminal, session)
+ resolved_dir = effective_working_dir or str(Path.home())
+ self._create_tab_for_terminal(terminal, session, working_directory=resolved_dir)
return terminal
def create_ssh_tab(
@@ -431,7 +829,8 @@ def _remove_bell_class():
tab_widget._bell_timeout_id = GLib.timeout_add(1500, _remove_bell_class)
def _create_tab_for_terminal(
- self, terminal: Vte.Terminal, session: SessionItem
+ self, terminal: Vte.Terminal, session: SessionItem,
+ working_directory: Optional[str] = None,
) -> None:
terminal.connect("contents-changed", self.scroll_handler.on_terminal_contents_changed)
terminal.connect("bell", self._on_terminal_bell)
@@ -469,6 +868,16 @@ def _create_tab_for_terminal(
self.tab_bar_box.append(tab_widget)
self.set_active_tab(tab_widget)
+
+ # For local tabs, set initial title to the working directory path
+ if session.is_local() and working_directory:
+ display_path = self.terminal_manager.osc7_tracker.parser._create_display_path(
+ working_directory
+ )
+ tab_widget._base_title = display_path
+ tab_widget.label_widget.set_text(display_path)
+ page.set_title(display_path)
+
self.update_all_tab_titles()
if self.on_tab_count_changed:
@@ -556,11 +965,10 @@ def _create_tab_widget(
tab_widget.add_controller(right_click)
# Motion controller for hover highlighting during tab move
- # DISABLED FOR WAYLAND DEBUGGING: Manual drag-and-drop can cause freezes
- # motion_controller = Gtk.EventControllerMotion()
- # motion_controller.connect("motion", self._on_tab_motion, tab_widget)
- # motion_controller.connect("leave", self._on_tab_leave, tab_widget)
- # tab_widget.add_controller(motion_controller)
+ motion_controller = Gtk.EventControllerMotion()
+ motion_controller.connect("motion", self._on_tab_motion, tab_widget)
+ motion_controller.connect("leave", self._on_tab_leave, tab_widget)
+ tab_widget.add_controller(motion_controller)
close_button.connect("clicked", self._on_tab_close_button_clicked, tab_widget)
@@ -576,8 +984,15 @@ def _create_tab_widget(
def _on_tab_motion(self, controller, x, y, tab_widget):
"""Handle mouse motion over a tab during move mode."""
- if self._tab_being_moved is None or tab_widget == self._tab_being_moved:
+ if self._tab_being_moved is None and self._group_being_moved is None:
+ return
+ if tab_widget == self._tab_being_moved:
return
+ # Skip tabs belonging to the group being moved
+ if self._group_being_moved:
+ tab_id = self.get_tab_id(tab_widget)
+ if tab_id in self._group_being_moved.tab_ids:
+ return
# Determine which half of the tab we're over
tab_width = tab_widget.get_width()
@@ -588,13 +1003,22 @@ def _on_tab_motion(self, controller, x, y, tab_widget):
def _on_tab_leave(self, controller, tab_widget):
"""Handle mouse leaving a tab during move mode."""
- if self._tab_being_moved is None:
+ if self._tab_being_moved is None and self._group_being_moved is None:
return
# Clear highlight when leaving a tab
self._clear_tab_drop_highlights()
def _on_tab_clicked(self, gesture, _n_press, x, _y, tab_widget):
- # If we're in move mode, handle the drop
+ # If we're in group move mode, handle the drop
+ if self._group_being_moved is not None:
+ tab_id = self.get_tab_id(tab_widget)
+ if tab_id not in self._group_being_moved.tab_ids:
+ tab_width = tab_widget.get_width()
+ side = "left" if x < tab_width / 2 else "right"
+ self._perform_group_move(tab_widget, side)
+ self._cancel_group_move()
+ return
+ # If we're in tab move mode, handle the drop
if self._tab_being_moved is not None:
if self._tab_being_moved != tab_widget:
# Determine which half of the tab was clicked
@@ -613,6 +1037,8 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget):
# Cancel any ongoing move operation when right-clicking
if self._tab_being_moved is not None:
self._cancel_tab_move()
+ if self._group_being_moved is not None:
+ self._cancel_group_move()
menu = Gio.Menu()
menu.append(_("Move Tab"), "win.move-tab")
@@ -626,6 +1052,24 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget):
color_section.append(_("Clear Tab Color"), "win.clear-tab-color")
menu.append_section(None, color_section)
+ # Group section
+ group_section = Gio.Menu()
+ tab_id = self.get_tab_id(tab_widget)
+ current_group = self.group_manager.get_group_for_tab(tab_id)
+ if current_group:
+ group_section.append(_("Remove from Group"), "win.remove-from-group")
+ else:
+ group_section.append(_("New Group from Tab"), "win.new-group-from-tab")
+ # Submenu to add to existing groups
+ if self.group_manager.has_groups():
+ for group in self.group_manager.groups:
+ if group != current_group:
+ group_section.append(
+ _("Add to '{}'").format(group.name),
+ f"win.add-to-group-{group.id}",
+ )
+ menu.append_section(None, group_section)
+
popover = create_themed_popover_menu(menu, tab_widget)
page = self.pages.get(tab_widget)
@@ -635,7 +1079,7 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget):
move_action = Gio.SimpleAction.new("move-tab", None)
move_action.connect(
"activate",
- lambda _action, _param, tab=tab_widget: self._start_tab_move(tab),
+ lambda _action, _param, tab=tab_widget: GLib.idle_add(self._start_tab_move, tab),
)
action_group.add_action(move_action)
@@ -669,6 +1113,30 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget):
)
action_group.add_action(clear_color_action)
+ # Group-related actions
+ new_group_action = Gio.SimpleAction.new("new-group-from-tab", None)
+ new_group_action.connect(
+ "activate",
+ lambda _a, _p, tab=tab_widget: self.create_group_from_tabs([tab]),
+ )
+ action_group.add_action(new_group_action)
+
+ remove_group_action = Gio.SimpleAction.new("remove-from-group", None)
+ remove_group_action.connect(
+ "activate",
+ lambda _a, _p, tab=tab_widget: self._remove_tab_from_group_action(tab),
+ )
+ action_group.add_action(remove_group_action)
+
+ # Dynamic "add to group X" actions
+ for group in self.group_manager.groups:
+ add_action = Gio.SimpleAction.new(f"add-to-group-{group.id}", None)
+ add_action.connect(
+ "activate",
+ lambda _a, _p, g_id=group.id, tab=tab_widget: self._add_tab_to_group_action(tab, g_id),
+ )
+ action_group.add_action(add_action)
+
popover.insert_action_group("win", action_group)
rect = Gdk.Rectangle()
@@ -677,6 +1145,24 @@ def _on_tab_right_click(self, _gesture, _n_press, x, y, tab_widget):
popover.set_pointing_to(rect)
popover.popup()
+ def _remove_tab_from_group_action(self, tab_widget: Gtk.Box) -> None:
+ """Action handler: remove tab from its current group."""
+ tab_id = self.get_tab_id(tab_widget)
+ self.group_manager.remove_tab_from_group(tab_id)
+ tab_widget.remove_css_class("in-group")
+ self._rebuild_tab_bar_order()
+
+ def _add_tab_to_group_action(self, tab_widget: Gtk.Box, group_id: str) -> None:
+ """Action handler: add tab to an existing group."""
+ tab_id = self.get_tab_id(tab_widget)
+ self.group_manager.add_tab_to_group(group_id, tab_id)
+ self._ensure_group_tabs_contiguous(group_id)
+ # If the group was collapsed, expand it so user sees the tab
+ group = self.group_manager.get_group(group_id)
+ if group and group.is_collapsed:
+ self.group_manager.toggle_collapsed(group_id)
+ self._rebuild_tab_bar_order()
+
def _pick_tab_color(self, tab_widget: Gtk.Box, popover: Gtk.Popover) -> None:
"""Open color chooser dialog for a tab."""
popover.popdown()
@@ -719,11 +1205,11 @@ def _start_tab_move(self, tab_widget: Gtk.Box) -> None:
tab_widget.add_css_class("tab-moving")
self.tab_bar_box.add_css_class("tab-bar-move-mode")
- # Hide and disable all close buttons during move mode to prevent accidental closing
+ # Make close buttons invisible but keep layout space during move mode
for tab in self.tabs:
close_btn = self._get_tab_close_button(tab)
if close_btn:
- close_btn.set_visible(False)
+ close_btn.set_opacity(0)
close_btn.set_sensitive(False)
self.logger.info(f"Tab move started for: {tab_widget.label_widget.get_text()}")
@@ -739,7 +1225,7 @@ def _cancel_tab_move(self) -> None:
for tab in self.tabs:
close_btn = self._get_tab_close_button(tab)
if close_btn:
- close_btn.set_visible(True)
+ close_btn.set_opacity(1)
close_btn.set_sensitive(True)
self.logger.debug("Tab move cancelled.")
@@ -759,14 +1245,39 @@ def _get_tab_close_button(self, tab_widget: Gtk.Box) -> Optional[Gtk.Button]:
return None
def _rebuild_tab_bar_order(self) -> None:
- """Rebuilds the tab bar widget order to match self.tabs list."""
- # Remove all tabs from the box
- for tab in self.tabs:
- self.tab_bar_box.remove(tab)
+ """Rebuilds tab bar based on self.tabs order. Groups are interspersed."""
+ # Remove ALL children from tab_bar_box (tabs + old group chips)
+ child = self.tab_bar_box.get_first_child()
+ while child:
+ next_child = child.get_next_sibling()
+ self.tab_bar_box.remove(child)
+ child = next_child
+
+ # Track which groups already have their chip placed
+ groups_with_chip: set[str] = set()
- # Re-add them in the correct order
for tab in self.tabs:
- self.tab_bar_box.append(tab)
+ tab_id = self.get_tab_id(tab)
+ group = self.group_manager.get_group_for_tab(tab_id)
+
+ if group:
+ # Insert chip before the first tab of this group in visual order
+ if group.id not in groups_with_chip:
+ chip = self._create_group_chip(group)
+ self.tab_bar_box.append(chip)
+ groups_with_chip.add(group.id)
+
+ tab.add_css_class("in-group")
+ self._apply_group_border_color(tab, group.color)
+ if group.is_collapsed:
+ tab.set_visible(False)
+ else:
+ tab.set_visible(True)
+ self.tab_bar_box.append(tab)
+ else:
+ tab.remove_css_class("in-group")
+ tab.set_visible(True)
+ self.tab_bar_box.append(tab)
def _duplicate_tab(self, tab_widget: Gtk.Box) -> None:
"""Creates a new tab duplicating the session represented by the given tab widget."""
@@ -864,6 +1375,13 @@ def set_active_tab(self, tab_to_activate: Gtk.Box):
if self.active_tab == tab_to_activate:
return
+ # Auto-expand collapsed group if the target tab is hidden
+ tab_id = self.get_tab_id(tab_to_activate)
+ group = self.group_manager.get_group_for_tab(tab_id)
+ if group and group.is_collapsed:
+ self.group_manager.toggle_collapsed(group.id)
+ self._rebuild_tab_bar_order()
+
self._handle_previous_tab_focus()
if self.active_tab:
@@ -1126,6 +1644,8 @@ def _remove_tab_from_tracking(self, tab: Gtk.Box):
if hasattr(tab, "_bell_timeout_id"):
GLib.source_remove(tab._bell_timeout_id)
del tab._bell_timeout_id
+ # Notify group manager
+ self.group_manager.on_tab_removed(self.get_tab_id(tab))
self.tab_bar_box.remove(tab)
self.tabs.remove(tab)
if tab in self.pages:
@@ -1196,8 +1716,11 @@ def update_titles_for_terminal(self, terminal, new_title: str, _osc7_info=None):
if not page:
return
+ # Full path for tooltip
+ full_path = _osc7_info.path if _osc7_info else None
+
# Update the main tab title
- self.set_tab_title(page, new_title)
+ self.set_tab_title(page, new_title, tooltip=full_path)
# Update the specific pane's title
pane = self.banner_handler.find_pane_for_terminal(page, terminal)
@@ -1233,7 +1756,7 @@ def _append_terminal_count(self, page, display_title: str) -> str:
return f"{display_title} ({terminal_count})"
return display_title
- def set_tab_title(self, page: Adw.ViewStackPage, new_title: str) -> None:
+ def set_tab_title(self, page: Adw.ViewStackPage, new_title: str, tooltip: Optional[str] = None) -> None:
if not (page and new_title):
return
@@ -1248,6 +1771,9 @@ def set_tab_title(self, page: Adw.ViewStackPage, new_title: str) -> None:
page.set_title(display_title)
a11y_label(tab_button, display_title)
+ if tooltip:
+ tab_button.set_tooltip_text(tooltip)
+
if hasattr(self.terminal_manager.parent_window, "_update_tab_layout"):
self.terminal_manager.parent_window._update_tab_layout()
diff --git a/src/ashyterm/ui/window_ui.py b/src/ashyterm/ui/window_ui.py
index c8ad70cb..5c103035 100644
--- a/src/ashyterm/ui/window_ui.py
+++ b/src/ashyterm/ui/window_ui.py
@@ -149,11 +149,12 @@ def _setup_styles(self) -> None:
self._load_css("components.css")
# Deferred: not needed for initial window render
- def load_dialog_styles():
+ def load_deferred_styles():
+ self._load_css("tab_groups.css")
self._load_css("dialogs.css")
return GLib.SOURCE_REMOVE
- GLib.idle_add(load_dialog_styles, priority=GLib.PRIORITY_LOW)
+ GLib.idle_add(load_deferred_styles, priority=GLib.PRIORITY_LOW)
# Separate provider for window borders (conditional on maximized state)
self.border_provider = Gtk.CssProvider()
diff --git a/src/ashyterm/utils/platform.py b/src/ashyterm/utils/platform.py
index df2b0764..875328e7 100644
--- a/src/ashyterm/utils/platform.py
+++ b/src/ashyterm/utils/platform.py
@@ -127,6 +127,17 @@ def get_terminal_environment(self) -> Dict[str, str]:
env = os.environ.copy()
env["TERM"] = "xterm-256color"
env["COLORTERM"] = "truecolor"
+ # VTE_VERSION is required by /etc/profile.d/vte.sh for OSC7 shell integration
+ try:
+ import gi
+ gi.require_version("Vte", "3.91")
+ from gi.repository import Vte
+ major = Vte.MAJOR_VERSION
+ minor = Vte.MINOR_VERSION
+ micro = Vte.MICRO_VERSION
+ env["VTE_VERSION"] = str(major * 10000 + minor * 100 + micro)
+ except Exception:
+ env["VTE_VERSION"] = "8203"
if "LANG" not in env:
import locale
diff --git a/src/ashyterm/window_actions.py b/src/ashyterm/window_actions.py
index 9070ddb6..012b4674 100644
--- a/src/ashyterm/window_actions.py
+++ b/src/ashyterm/window_actions.py
@@ -50,6 +50,8 @@ def _on_key_pressed(self, _controller, keyval, _keycode, state):
return Gdk.EVENT_STOP
if self._handle_search_shortcut(keyval, state):
return Gdk.EVENT_STOP
+ if self._handle_tab_group_shortcuts(keyval, state):
+ return Gdk.EVENT_STOP
accel_string = Gtk.accelerator_name(
keyval, state & Gtk.accelerator_get_default_mod_mask()
)
@@ -147,6 +149,19 @@ def _handle_emergency_dialog_close(self, keyval, state) -> bool:
return True
return False
+ def _handle_tab_group_shortcuts(self, keyval, state) -> bool:
+ """Handle Ctrl+Shift+G (new group) and Ctrl+Shift+U (ungroup)."""
+ ctrl_shift = Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK
+ if (state & ctrl_shift) != ctrl_shift:
+ return False
+ if keyval in (Gdk.KEY_g, Gdk.KEY_G):
+ self.tab_manager.create_group_from_active_tab()
+ return True
+ if keyval in (Gdk.KEY_u, Gdk.KEY_U):
+ self.tab_manager.ungroup_active_tab()
+ return True
+ return False
+
def _force_close_all_dialogs(self):
"""Force close all dialogs and transient windows."""
if hasattr(self, "get_dialogs"):
diff --git a/tests/test_tab_groups.py b/tests/test_tab_groups.py
new file mode 100644
index 00000000..34fbaf60
--- /dev/null
+++ b/tests/test_tab_groups.py
@@ -0,0 +1,151 @@
+"""Tests for TabGroup and TabGroupManager."""
+
+import pytest
+
+from ashyterm.terminal.tab_groups import TabGroup, TabGroupManager
+
+
+class TestTabGroup:
+ def test_defaults(self):
+ g = TabGroup(name="Dev", color="#f66151")
+ assert g.name == "Dev"
+ assert g.color == "#f66151"
+ assert g.is_collapsed is False
+ assert g.tab_ids == []
+ assert g.id # auto-generated uuid
+
+ def test_to_dict_roundtrip(self):
+ g = TabGroup(id="abc", name="Servers", color="#62a0ea", is_collapsed=True)
+ d = g.to_dict()
+ g2 = TabGroup.from_dict(d)
+ assert g2.id == "abc"
+ assert g2.name == "Servers"
+ assert g2.color == "#62a0ea"
+ assert g2.is_collapsed is True
+
+ def test_from_dict_missing_fields(self):
+ g = TabGroup.from_dict({})
+ assert g.name == ""
+ assert g.color == ""
+ assert g.is_collapsed is False
+
+
+class TestTabGroupManager:
+ def setup_method(self):
+ self.mgr = TabGroupManager()
+
+ def test_create_group(self):
+ g = self.mgr.create_group("Servers")
+ assert g.name == "Servers"
+ assert g.color # auto-picked from palette
+ assert self.mgr.get_group(g.id) is g
+ assert self.mgr.has_groups()
+
+ def test_create_group_with_initial_tabs(self):
+ g = self.mgr.create_group("Dev", initial_tab_ids=["t1", "t2"])
+ assert g.tab_ids == ["t1", "t2"]
+ assert self.mgr.get_group_for_tab("t1") is g
+ assert self.mgr.get_group_for_tab("t2") is g
+
+ def test_delete_group_returns_orphaned_tabs(self):
+ g = self.mgr.create_group("X", initial_tab_ids=["t1", "t2"])
+ orphans = self.mgr.delete_group(g.id)
+ assert set(orphans) == {"t1", "t2"}
+ assert not self.mgr.has_groups()
+
+ def test_delete_nonexistent_group(self):
+ assert self.mgr.delete_group("nope") == []
+
+ def test_rename_group(self):
+ g = self.mgr.create_group("Old")
+ self.mgr.rename_group(g.id, "New")
+ assert g.name == "New"
+
+ def test_set_group_color(self):
+ g = self.mgr.create_group("X")
+ self.mgr.set_group_color(g.id, "#000000")
+ assert g.color == "#000000"
+
+ def test_toggle_collapsed(self):
+ g = self.mgr.create_group("X")
+ assert g.is_collapsed is False
+ state = self.mgr.toggle_collapsed(g.id)
+ assert state is True
+ assert g.is_collapsed is True
+ state = self.mgr.toggle_collapsed(g.id)
+ assert state is False
+
+ def test_add_tab_to_group(self):
+ g = self.mgr.create_group("X")
+ assert self.mgr.add_tab_to_group(g.id, "t1")
+ assert "t1" in g.tab_ids
+
+ def test_add_tab_at_position(self):
+ g = self.mgr.create_group("X", initial_tab_ids=["t1", "t3"])
+ self.mgr.add_tab_to_group(g.id, "t2", position=1)
+ assert g.tab_ids == ["t1", "t2", "t3"]
+
+ def test_add_tab_moves_from_existing_group(self):
+ g1 = self.mgr.create_group("A", initial_tab_ids=["t1", "t2"])
+ g2 = self.mgr.create_group("B")
+ self.mgr.add_tab_to_group(g2.id, "t1")
+ assert "t1" not in g1.tab_ids
+ assert "t1" in g2.tab_ids
+
+ def test_remove_tab_from_group(self):
+ g = self.mgr.create_group("X", initial_tab_ids=["t1", "t2"])
+ removed_from = self.mgr.remove_tab_from_group("t1")
+ assert removed_from == g.id
+ assert "t1" not in g.tab_ids
+
+ def test_remove_tab_auto_deletes_empty_group(self):
+ g = self.mgr.create_group("X", initial_tab_ids=["t1"])
+ self.mgr.remove_tab_from_group("t1")
+ assert not self.mgr.has_groups()
+
+ def test_remove_tab_not_in_group(self):
+ assert self.mgr.remove_tab_from_group("t99") is None
+
+ def test_move_tab_in_group(self):
+ g = self.mgr.create_group("X", initial_tab_ids=["t1", "t2", "t3"])
+ self.mgr.move_tab_in_group("t3", 0)
+ assert g.tab_ids == ["t3", "t1", "t2"]
+
+ def test_on_tab_removed(self):
+ g = self.mgr.create_group("X", initial_tab_ids=["t1", "t2"])
+ self.mgr.on_tab_removed("t1")
+ assert "t1" not in g.tab_ids
+
+ def test_groups_order_preserved(self):
+ g1 = self.mgr.create_group("First")
+ g2 = self.mgr.create_group("Second")
+ g3 = self.mgr.create_group("Third")
+ assert self.mgr.groups == [g1, g2, g3]
+
+ def test_serialization_roundtrip(self):
+ g1 = self.mgr.create_group("A", color="#f66151", initial_tab_ids=["t1"])
+ g2 = self.mgr.create_group("B", color="#62a0ea", initial_tab_ids=["t2", "t3"])
+ self.mgr.toggle_collapsed(g2.id)
+
+ data = self.mgr.to_list()
+ assert len(data) == 2
+
+ mgr2 = TabGroupManager()
+ mgr2.load_from_list(data)
+ # tab_ids must be restored separately by the caller
+ assert len(mgr2.groups) == 2
+ assert mgr2.groups[0].name == "A"
+ assert mgr2.groups[1].name == "B"
+ assert mgr2.groups[1].is_collapsed is True
+
+ def test_next_color_rotates(self):
+ colors_seen = set()
+ for i in range(len(TabGroupManager.PALETTE)):
+ g = self.mgr.create_group(f"G{i}")
+ colors_seen.add(g.color)
+ # All palette colors should have been used
+ palette_colors = {c for _, c in TabGroupManager.PALETTE}
+ assert colors_seen == palette_colors
+
+ def test_get_group_for_ungrouped_tab(self):
+ assert self.mgr.get_group_for_tab("nonexistent") is None