Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0158a8d
feat(terminal): add tmux window control helper functions
dguerizec Mar 1, 2026
acc8b33
feat(terminal): handle tmux window control messages in WS handler
dguerizec Mar 1, 2026
3e94ee6
feat(terminal): add tmux window control to useTerminal composable
dguerizec Mar 1, 2026
6bb936a
feat(terminal): create TmuxNavigator shell picker component
dguerizec Mar 1, 2026
d261102
feat(terminal): integrate TmuxNavigator into TerminalPanel
dguerizec Mar 1, 2026
84b229e
feat(terminal): toggle shell navigator on terminal tab re-click
dguerizec Mar 1, 2026
46fa519
fix(terminal): use @click on terminal tab button for navigator toggle
dguerizec Mar 1, 2026
cb8cc5d
feat(terminal): add Ctrl+C and Ctrl+Z buttons for mobile
dguerizec Mar 1, 2026
bbf8ab9
fix(terminal): open new tmux windows in project working directory
dguerizec Mar 2, 2026
439e97c
feat(terminal): support preconfigured shells from .twicc-tmux.json
dguerizec Mar 2, 2026
a4ccd56
feat(terminal): add window tab bar for quick shell switching
dguerizec Mar 2, 2026
d474b82
feat(terminal): mobile touch scroll and copy mode toggle
dguerizec Mar 2, 2026
7e51813
feat(terminal): keep presets visible in navigator and prefix tabs wit…
dguerizec Mar 2, 2026
4b3a149
docs: document .twicc-tmux.json terminal presets format
dguerizec Mar 2, 2026
c58b7c9
update .twicc-tmux.json with devctl restart and log presets
dguerizec Mar 6, 2026
99f7b3a
feat(terminal): multi-source preset resolution with per-directory .tw…
dguerizec Mar 6, 2026
288b757
feat(terminal): intercept mouse events in tmux mode for native copy/p…
dguerizec Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .twicc-tmux.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[
{
"name": "Restart Twicc",
"command": "exec uv run ./devctl.py restart"
},
{
"name": "Restart Twicc Backend",
"command": "exec uv run ./devctl.py restart back"
},
{
"name": "Restart Twicc Frontend",
"command": "exec uv run ./devctl.py restart front"
},
{
"name": "Backend Logs",
"command": "tail -f ~/.twicc/logs/backend.log"
},
{
"name": "Frontend Logs",
"command": "tail -f ~/.twicc/logs/frontend.log"
}
]
35 changes: 35 additions & 0 deletions docs/terminal-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Terminal Presets (`.twicc-tmux.json`)

Define preset shell sessions for the terminal's tmux navigator by placing a `.twicc-tmux.json` file at the root of your project directory.

## Format

The file is a JSON array of preset objects:

| Field | Type | Required | Description |
|-----------|--------|----------|----------------------------------------------------------|
| `name` | string | **yes** | Display name, also used as the tmux window name |
| `command` | string | no | Command to run automatically when the window is created |
| `cwd` | string | no | Working directory (absolute, or relative to project root) |

## Example

```json
[
{ "name": "dev", "cwd": "./frontend", "command": "npm run dev" },
{ "name": "logs", "command": "tail -f logs/backend.log" },
{ "name": "shell" }
]
```

## Behavior

- Presets appear in the terminal's **shell navigator** (the full-page picker) and are prefixed with a ▶ icon in the **tab bar** (desktop) and **dropdown** (mobile).
- A preset that is currently running shows a green ▶ icon in the navigator. Clicking it switches to the existing window instead of creating a duplicate.
- When a preset window is created, its `command` (if any) is sent as keystrokes to the new tmux window.
- Relative `cwd` paths are resolved against the project directory.
- The file is re-read every time the window list is refreshed, so changes take effect without restarting the server.

## Implementation

Loaded by `load_tmux_presets()` in `src/twicc/terminal.py`.
172 changes: 168 additions & 4 deletions frontend/src/components/TerminalPanel.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<script setup>
import { watch } from 'vue'
import { watch, computed } from 'vue'
import { useTerminal } from '../composables/useTerminal'
import { useSettingsStore } from '../stores/settings'
import TmuxNavigator from './TmuxNavigator.vue'

const settingsStore = useSettingsStore()

const props = defineProps({
sessionId: {
Expand All @@ -13,7 +17,22 @@ const props = defineProps({
},
})

const { containerRef, isConnected, started, start, reconnect } = useTerminal(props.sessionId)
const {
containerRef, isConnected, started, start, reconnect, sendInput, focusTerminal, copyMode,
windows, presets, showNavigator, listWindows, createWindow, selectWindow, toggleNavigator,
} = useTerminal(props.sessionId)

const activeWindowName = computed(() => windows.value.find(w => w.active)?.name ?? '')
/** Flatten all preset names across all sources for tab bar icons + mobile dropdown. */
const presetNames = computed(() => {
const names = new Set()
for (const source of presets.value) {
for (const p of source.presets || []) {
names.add(p.name)
}
}
return names
})

// Lazy init: start the terminal only when the tab becomes active for the first time
watch(
Expand All @@ -25,14 +44,90 @@ watch(
},
{ immediate: true },
)

function handleNavigatorSelect(name) {
selectWindow(name)
}

function handleNavigatorCreate(nameOrPreset) {
createWindow(nameOrPreset)
// Backend responds with updated windows list automatically
}

// Fetch window list when navigator is shown
watch(showNavigator, (show) => {
if (show) {
listWindows()
}
})

// Expose toggleNavigator for parent component (SessionView tab re-click)
defineExpose({ toggleNavigator })
</script>

<template>
<div class="terminal-panel">
<div ref="containerRef" class="terminal-container"></div>
<!-- Mobile toolbar — dropdown for window switching + control buttons -->
<div v-if="settingsStore.isTouchDevice && started && !showNavigator" class="mobile-toolbar">
<wa-select
v-if="windows.length > 1"
:value.prop="activeWindowName"
size="small"
class="window-select"
@change="selectWindow($event.target.value); focusTerminal()"
>
<wa-option
v-for="win in windows"
:key="win.name"
:value="win.name"
><wa-icon v-if="presetNames.has(win.name)" name="circle-play" class="option-preset-icon"></wa-icon>{{ win.name }}</wa-option>
</wa-select>
<span class="toolbar-spacer"></span>
<wa-button
size="small"
:appearance="copyMode ? 'filled' : 'plain'"
:variant="copyMode ? 'brand' : 'neutral'"
@click="copyMode = !copyMode"
>
<wa-icon slot="start" name="copy"></wa-icon>
Copy
</wa-button>
<wa-button size="small" appearance="plain" variant="neutral" @click="sendInput('\x03')">
Ctrl+C
</wa-button>
<wa-button size="small" appearance="plain" variant="neutral" @click="sendInput('\x1a')">
Ctrl+Z
</wa-button>
</div>

<!-- Desktop window tab bar — shown when multiple windows exist -->
<div v-else-if="windows.length > 1 && started && !showNavigator" class="window-tabs">
<button
v-for="win in windows"
:key="win.name"
class="window-tab"
:class="{ active: win.active }"
@click="selectWindow(win.name); focusTerminal()"
>
<wa-icon v-if="presetNames.has(win.name)" name="circle-play" class="tab-preset-icon"></wa-icon>
{{ win.name }}
</button>
</div>

<!-- Terminal xterm.js container — hidden when navigator is shown -->
<div ref="containerRef" class="terminal-container" :class="{ hidden: showNavigator }"></div>

<!-- Tmux Navigator overlay -->
<TmuxNavigator
v-if="showNavigator"
:windows="windows"
:preset-sources="presets"
@select="handleNavigatorSelect"
@create="handleNavigatorCreate"
/>

<!-- Disconnect overlay -->
<div v-if="started && !isConnected" class="disconnect-overlay">
<div v-if="started && !isConnected && !showNavigator" class="disconnect-overlay">
<wa-callout variant="warning" appearance="outlined">
<wa-icon slot="icon" name="plug-circle-xmark"></wa-icon>
<div class="disconnect-content">
Expand Down Expand Up @@ -60,13 +155,82 @@ watch(
position: relative;
}

.window-tabs {
display: flex;
align-items: stretch;
gap: var(--wa-space-3xs);
flex-shrink: 0;
overflow-x: auto;
padding: 0 var(--wa-space-xs);
border-bottom: 1px solid var(--wa-color-border-default);
background: var(--wa-color-surface-alt);
}

.window-tab {
appearance: none;
padding: var(--wa-space-xs) var(--wa-space-m);
margin: 0;
background: transparent;
border: none;
border-radius: 0;
border-bottom: 2px solid transparent;
color: var(--wa-color-text-subtle);
font-size: var(--wa-font-size-s);
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}

.window-tab:hover {
color: var(--wa-color-text-default);
}

.window-tab.active {
color: var(--wa-color-brand-600);
border-bottom-color: var(--wa-color-brand-600);
}

.tab-preset-icon {
font-size: 0.75em;
margin-right: 0.25em;
}

.option-preset-icon {
font-size: 0.85em;
margin-right: 0.25em;
vertical-align: -0.1em;
}

.mobile-toolbar {
display: flex;
align-items: center;
gap: var(--wa-space-2xs);
padding: var(--wa-space-2xs) var(--wa-space-xs);
flex-shrink: 0;
border-bottom: 1px solid var(--wa-color-border-default);
}

.window-select {
min-width: 0;
max-width: 10rem;
}

.toolbar-spacer {
flex: 1;
}

.terminal-container {
flex: 1;
min-height: 0;
width: 100%;
padding: var(--wa-space-2xs);
}

.terminal-container.hidden {
display: none;
}

/* Ensure xterm fills its container */
.terminal-container :deep(.xterm) {
height: 100%;
Expand Down
Loading