diff --git a/.twicc-tmux.json b/.twicc-tmux.json new file mode 100644 index 0000000..41ee400 --- /dev/null +++ b/.twicc-tmux.json @@ -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" + } +] diff --git a/docs/terminal-presets.md b/docs/terminal-presets.md new file mode 100644 index 0000000..3e20241 --- /dev/null +++ b/docs/terminal-presets.md @@ -0,0 +1,82 @@ +# Terminal Presets (`.twicc-tmux.json`) + +Define preset shell sessions for the terminal's tmux navigator by placing a `.twicc-tmux.json` file in your project. + +## 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 `relative_to` base) | +| `relative_to` | string | no | Base for relative `cwd`: `preset_dir` (default), `project_dir`, `git_dir`, `session_cwd` | + +## Example + +```json +[ + { "name": "dev", "cwd": "frontend", "command": "npm run dev" }, + { "name": "logs", "command": "tail -f logs/backend.log" }, + { "name": "shell" } +] +``` + +### Library preset file (loaded from another directory) + +```json +[ + { "name": "Build", "cwd": "src", "relative_to": "project_dir", "command": "make" }, + { "name": "Docs", "cwd": "docs", "relative_to": "git_dir" }, + { "name": "Tool scripts", "cwd": "scripts", "relative_to": "preset_dir" } +] +``` + +## `relative_to` values + +| Value | Resolves `cwd` relative to | Default | +|---------------|------------------------------------------|---------| +| `preset_dir` | Directory containing the preset file | **yes** | +| `project_dir` | Project directory (from Claude projects) | | +| `git_dir` | Git root directory | | +| `session_cwd` | Session's working directory | | + +If the base directory is not available (e.g., no git root), the preset is shown greyed out and cannot be launched. + +## File discovery + +TwiCC looks for `.twicc-tmux.json` in up to three locations (checked in order, each producing a separate section in the navigator): + +1. **Project directory** — the Claude project's directory (`~/.claude/projects//` maps to a filesystem path). +2. **Git root** — the resolved git root directory, if different from the project directory. +3. **Session CWD walk** — starting from the session's working directory, walks up parent directories until a `.twicc-tmux.json` is found (stops at the project or git boundary). + +Each source that contains a valid file appears as a labelled section. If the same directory is reached by multiple sources, it only appears once. + +## 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. +- Without `relative_to`, relative `cwd` paths are resolved against the directory containing the preset file (`preset_dir`). +- The file is re-read every time the window list is refreshed, so changes take effect without restarting the server. + +## Custom preset files + +Users can add arbitrary JSON preset files per project via the navigator's **Add preset file** button. These files follow the same format and are stored as references in `~/.twicc/presets/.json`. Files already loaded from the project/git/cwd sources are deduplicated. + +## Claude Code skill + +A Claude Code skill is available to create, edit, and validate `.twicc-tmux.json` files interactively. Install it with: + +``` +/plugin marketplace add dguerizec/twicc-tmux-presets +/plugin install twicc-presets@twicc-tmux-presets +``` + +Then use it naturally (e.g., "create presets for dev server and logs") or invoke it directly with `/twicc-presets`. + +## Implementation + +Loaded by `load_tmux_presets_from_file()` in `src/twicc/terminal.py`. CWD resolution happens in `_resolve_preset_cwd()` using the session context. diff --git a/frontend/src/components/AddPresetDialog.vue b/frontend/src/components/AddPresetDialog.vue new file mode 100644 index 0000000..e685242 --- /dev/null +++ b/frontend/src/components/AddPresetDialog.vue @@ -0,0 +1,391 @@ + + + + + diff --git a/frontend/src/components/ShortcutConfigDialog.vue b/frontend/src/components/ShortcutConfigDialog.vue new file mode 100644 index 0000000..277aca0 --- /dev/null +++ b/frontend/src/components/ShortcutConfigDialog.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/frontend/src/components/TerminalPanel.vue b/frontend/src/components/TerminalPanel.vue index 64859db..d9b4539 100644 --- a/frontend/src/components/TerminalPanel.vue +++ b/frontend/src/components/TerminalPanel.vue @@ -1,6 +1,13 @@ @@ -67,6 +212,10 @@ watch( padding: var(--wa-space-2xs); } +.terminal-container.hidden { + display: none; +} + /* Ensure xterm fills its container */ .terminal-container :deep(.xterm) { height: 100%; @@ -76,6 +225,82 @@ watch( overflow-y: auto !important; } +/* ── Window tab bar (desktop) ─────────────────────────────────────── */ + +.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; +} + +/* ── Toolbars ─────────────────────────────────────────────────────── */ + +.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; +} + +.shortcut-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); +} + +/* ── Disconnect overlay ──────────────────────────────────────────── */ + .disconnect-overlay { position: absolute; inset: 0; diff --git a/frontend/src/components/TmuxNavigator.vue b/frontend/src/components/TmuxNavigator.vue new file mode 100644 index 0000000..e0ff990 --- /dev/null +++ b/frontend/src/components/TmuxNavigator.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/frontend/src/composables/useTerminal.js b/frontend/src/composables/useTerminal.js index 8ee6316..ce883e8 100644 --- a/frontend/src/composables/useTerminal.js +++ b/frontend/src/composables/useTerminal.js @@ -99,6 +99,18 @@ export function useTerminal(sessionId) { /** @type {boolean} */ let intentionalClose = false + // ── tmux window management state ──────────────────────────────────── + const windows = ref([]) + const presets = ref([]) + /** Whether the active tmux pane is in alternate screen (less, vim, etc.) */ + const paneAlternate = ref(false) + const showNavigator = ref(false) + /** @type {((wins: Array) => void) | null} — resolver for pending listWindows() call */ + let windowsResolver = null + + // ── Config panel visibility (toggled by re-clicking Terminal tab) ──── + const showConfig = ref(false) + // ── Touch selection state (mobile) ───────────────────────────────────── let selectStartCol = 0 let selectStartRow = 0 @@ -154,11 +166,42 @@ export function useTerminal(sessionId) { if (terminal) { wsSend({ type: 'resize', cols: terminal.cols, rows: terminal.rows }) } + // Fetch the window list so the tab bar / dropdown appears immediately + if (shouldUseTmux()) { + wsSend({ type: 'list_windows' }) + } } ws.onmessage = (event) => { - // Server sends raw PTY output as text - terminal?.write(event.data) + const data = event.data + // Check for JSON control messages (type field present) + if (data.charAt(0) === '{') { + try { + const msg = JSON.parse(data) + if (msg.type === 'windows') { + windows.value = msg.windows + if (msg.presets) presets.value = msg.presets + if ('alternate_on' in msg) paneAlternate.value = msg.alternate_on + if (windowsResolver) { + windowsResolver(msg.windows) + windowsResolver = null + } + return + } + if (msg.type === 'window_changed') { + // Update active flag in local list + for (const w of windows.value) { + w.active = (w.name === msg.name) + } + showNavigator.value = false + return + } + } catch { + // Not JSON — fall through to terminal write + } + } + // Raw PTY output + terminal?.write(data) } ws.onclose = (event) => { @@ -403,6 +446,80 @@ export function useTerminal(sessionId) { connectWs() } + /** + * Send raw input data to the PTY (e.g. control characters). + * @param {string} data + */ + function sendInput(data) { + wsSend({ type: 'input', data }) + } + + /** + * Focus the xterm.js terminal (e.g. after selecting a window from a dropdown). + */ + function focusTerminal() { + terminal?.focus() + } + + // ── tmux window control ───────────────────────────────────────────── + + /** + * Request the list of tmux windows from the backend. + * Returns a Promise that resolves with the window list. + */ + function listWindows() { + return new Promise((resolve) => { + windowsResolver = resolve + wsSend({ type: 'list_windows' }) + // Timeout fallback — resolve with current state after 3s + setTimeout(() => { + if (windowsResolver === resolve) { + windowsResolver = null + resolve(windows.value) + } + }, 3000) + }) + } + + /** + * Create a new tmux window. + * + * Accepts either a plain string (manual create) or a preset object + * with {name, cwd?, command?} from .twicc-tmux.json presets. + */ + function createWindow(nameOrPreset) { + if (typeof nameOrPreset === 'string') { + wsSend({ type: 'create_window', name: nameOrPreset }) + } else { + const msg = { type: 'create_window', name: nameOrPreset.name } + if (nameOrPreset.cwd) msg.preset_cwd = nameOrPreset.cwd + if (nameOrPreset.command) msg.command = nameOrPreset.command + wsSend(msg) + } + } + + /** + * Switch to a tmux window by name. + * The backend responds with window_changed, which hides the navigator. + */ + function selectWindow(name) { + wsSend({ type: 'select_window', name }) + } + + /** + * Toggle the shell navigator visibility. + */ + function toggleNavigator() { + showNavigator.value = !showNavigator.value + } + + /** + * Toggle the config panel visibility. + */ + function toggleConfig() { + showConfig.value = !showConfig.value + } + /** * Clean up everything: terminal, WebSocket, observers. */ @@ -471,5 +588,9 @@ export function useTerminal(sessionId) { cleanup() }) - return { containerRef, isConnected, started, start, reconnect } + return { + containerRef, isConnected, started, start, reconnect, sendInput, focusTerminal, + windows, presets, paneAlternate, showNavigator, listWindows, createWindow, selectWindow, toggleNavigator, + showConfig, toggleConfig, + } } diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 46b0665..2cc43c3 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -295,6 +295,23 @@ export const CLAUDE_IN_CHROME_DISPLAY_LABELS = { [CLAUDE_IN_CHROME.DISABLED]: 'No Chrome MCP', } +/** + * Maximum number of configurable terminal shortcut buttons. + */ +export const MAX_TERMINAL_SHORTCUTS = 3 + +/** + * Default terminal shortcut buttons. + * Each slot has: label (display text), sequence (escape sequence to send), + * and showOnDesktop (whether to show on non-touch devices). + * Empty label+sequence means the slot is unused and hidden in the toolbar. + */ +export const DEFAULT_TERMINAL_SHORTCUTS = [ + { label: 'Ctrl+C', sequence: '\x03', showOnDesktop: false }, + { label: 'Ctrl+Z', sequence: '\x1a', showOnDesktop: false }, + { label: '', sequence: '', showOnDesktop: false }, +] + /** * Settings keys that are synced across devices via backend settings.json. * All other settings remain local to the browser (localStorage only). @@ -314,4 +331,5 @@ export const SYNCED_SETTINGS_KEYS = new Set([ 'alwaysApplyDefaultClaudeInChrome', 'autoUnpinOnArchive', 'terminalUseTmux', + 'terminalShortcuts', ]) diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index cbe2e65..f1ac642 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -2,8 +2,8 @@ // Persistent settings store with localStorage + backend sync for global settings import { defineStore, acceptHMRUpdate } from 'pinia' -import { watch } from 'vue' -import { DEFAULT_DISPLAY_MODE, DEFAULT_THEME_MODE, DEFAULT_SESSION_TIME_FORMAT, DEFAULT_TITLE_SYSTEM_PROMPT, DEFAULT_MAX_CACHED_SESSIONS, DEFAULT_PERMISSION_MODE, DEFAULT_MODEL, DEFAULT_EFFORT, DEFAULT_THINKING, DEFAULT_CLAUDE_IN_CHROME, DISPLAY_MODE, THEME_MODE, SESSION_TIME_FORMAT, PERMISSION_MODE, MODEL, EFFORT, SYNCED_SETTINGS_KEYS } from '../constants' +import { watch, nextTick } from 'vue' +import { DEFAULT_DISPLAY_MODE, DEFAULT_THEME_MODE, DEFAULT_SESSION_TIME_FORMAT, DEFAULT_TITLE_SYSTEM_PROMPT, DEFAULT_MAX_CACHED_SESSIONS, DEFAULT_PERMISSION_MODE, DEFAULT_MODEL, DEFAULT_EFFORT, DEFAULT_THINKING, DEFAULT_CLAUDE_IN_CHROME, DEFAULT_TERMINAL_SHORTCUTS, MAX_TERMINAL_SHORTCUTS, DISPLAY_MODE, THEME_MODE, SESSION_TIME_FORMAT, PERMISSION_MODE, MODEL, EFFORT, SYNCED_SETTINGS_KEYS } from '../constants' import { NOTIFICATION_SOUNDS } from '../utils/notificationSounds' // Note: useDataStore is imported lazily to avoid circular dependency (settings.js ↔ data.js) import { setThemeMode } from '../utils/theme' @@ -42,6 +42,8 @@ const SETTINGS_SCHEMA = { alwaysApplyDefaultThinking: false, defaultClaudeInChrome: DEFAULT_CLAUDE_IN_CHROME, alwaysApplyDefaultClaudeInChrome: false, + // Terminal shortcut buttons (per-device, not synced) + terminalShortcuts: structuredClone(DEFAULT_TERMINAL_SHORTCUTS), // Notification settings: sound + browser notification for each event type notifUserTurnSound: NOTIFICATION_SOUNDS.NONE, notifUserTurnBrowser: false, @@ -89,6 +91,9 @@ const SETTINGS_VALIDATORS = { alwaysApplyDefaultThinking: (v) => typeof v === 'boolean', defaultClaudeInChrome: (v) => typeof v === 'boolean', alwaysApplyDefaultClaudeInChrome: (v) => typeof v === 'boolean', + terminalShortcuts: (v) => Array.isArray(v) && v.length <= MAX_TERMINAL_SHORTCUTS && v.every( + s => s && typeof s === 'object' && typeof s.label === 'string' && typeof s.sequence === 'string' && typeof s.showOnDesktop === 'boolean', + ), notifUserTurnSound: (v) => Object.values(NOTIFICATION_SOUNDS).includes(v), notifUserTurnBrowser: (v) => typeof v === 'boolean', notifPendingRequestSound: (v) => Object.values(NOTIFICATION_SOUNDS).includes(v), @@ -170,6 +175,7 @@ export const useSettingsStore = defineStore('settings', { getMaxCachedSessions: (state) => state.maxCachedSessions, isAutoUnpinOnArchive: (state) => state.autoUnpinOnArchive, isTerminalUseTmux: (state) => state.terminalUseTmux, + getTerminalShortcuts: (state) => state.terminalShortcuts, isDiffSideBySide: (state) => state.diffSideBySide, isEditorWordWrap: (state) => state.editorWordWrap, isCompactSessionList: (state) => state.compactSessionList, @@ -326,6 +332,19 @@ export const useSettingsStore = defineStore('settings', { } }, + /** + * Update a terminal shortcut button at the given index. + * @param {number} index - Slot index (0-based) + * @param {{ label: string, sequence: string, showOnDesktop: boolean }} shortcut + */ + setTerminalShortcut(index, shortcut) { + if (index >= 0 && index < MAX_TERMINAL_SHORTCUTS && shortcut + && typeof shortcut.label === 'string' && typeof shortcut.sequence === 'string' + && typeof shortcut.showOnDesktop === 'boolean') { + this.terminalShortcuts[index] = { ...shortcut } + } + }, + /** * Set diff side-by-side default mode. * @param {boolean} enabled @@ -544,7 +563,12 @@ export const useSettingsStore = defineStore('settings', { } } } - this._isApplyingRemoteSettings = false + // Reset guard on next tick so the async synced-settings watcher + // still sees it as true and skips re-sending to the backend. + // Without this, assigning array/object values (e.g. terminalShortcuts) + // creates new references that trigger the deep watcher, causing a + // ping-pong loop: client → backend → broadcast → client → ... + nextTick(() => { this._isApplyingRemoteSettings = false }) }, /** @@ -596,6 +620,7 @@ export function initSettings() { maxCachedSessions: store.maxCachedSessions, autoUnpinOnArchive: store.autoUnpinOnArchive, terminalUseTmux: store.terminalUseTmux, + terminalShortcuts: store.terminalShortcuts, diffSideBySide: store.diffSideBySide, editorWordWrap: store.editorWordWrap, compactSessionList: store.compactSessionList, diff --git a/frontend/src/views/SessionView.vue b/frontend/src/views/SessionView.vue index 9ee8e4e..a2c96eb 100644 --- a/frontend/src/views/SessionView.vue +++ b/frontend/src/views/SessionView.vue @@ -27,6 +27,9 @@ const sessionItemsListRef = ref(null) // Reference to FilesPanel for cross-tab file reveal const filesPanelRef = ref(null) +// Reference to TerminalPanel for config toggle +const terminalPanelRef = ref(null) + // ═══════════════════════════════════════════════════════════════════════════ // KeepAlive lifecycle: active state, listener setup/teardown // ═══════════════════════════════════════════════════════════════════════════ @@ -274,6 +277,31 @@ function switchToTabAndCollapse(panel) { } } +/** + * Handle click on the Terminal tab button. + * If already on the terminal tab, toggle the shell navigator. + */ +function onTerminalTabClick() { + if (activeTabId.value === 'terminal') { + terminalPanelRef.value?.toggleNavigator() + } +} + +/** + * Handle click on the Terminal tab button in compact mode (mobile header). + * Toggles config if already on terminal, otherwise switches tab + collapses header. + */ +function onTerminalTabClickCompact() { + if (activeTabId.value === 'terminal') { + onTerminalTabClick() + if (sessionHeaderRef.value?.isCompactExpanded) { + sessionHeaderRef.value.isCompactExpanded = false + } + } else { + switchToTabAndCollapse('terminal') + } +} + /** * Handle tab change event from wa-tab-group. * Updates the URL to reflect the new active tab. @@ -649,8 +677,11 @@ onBeforeUnmount(() => { :appearance="activeTabId === 'terminal' ? 'outlined' : 'plain'" :variant="activeTabId === 'terminal' ? 'brand' : 'neutral'" size="small" - @click="switchToTabAndCollapse('terminal')" - >Terminal + @click="onTerminalTabClickCompact" + > + Terminal + + @@ -736,8 +767,10 @@ onBeforeUnmount(() => { :appearance="activeTabId === 'terminal' ? 'outlined' : 'plain'" :variant="activeTabId === 'terminal' ? 'brand' : 'neutral'" size="small" + @click="onTerminalTabClick" > Terminal + @@ -795,6 +828,7 @@ onBeforeUnmount(() => { @@ -996,4 +1030,9 @@ wa-tab::part(base) { right: var(--wa-space-xs); } } + +.tab-config-icon { + font-size: 0.85em; + opacity: 0.5; +} diff --git a/src/twicc/paths.py b/src/twicc/paths.py index 6a2445e..c6de47f 100644 --- a/src/twicc/paths.py +++ b/src/twicc/paths.py @@ -84,6 +84,16 @@ def get_synced_settings_path() -> Path: return get_data_dir() / "settings.json" +def get_presets_dir() -> Path: + """Return the presets directory (/presets/).""" + return get_data_dir() / "presets" + + +def get_project_presets_path(project_id: str) -> Path: + """Return the custom preset file list for a project (/presets/.json).""" + return get_presets_dir() / f"{project_id}.json" + + def ensure_data_dirs() -> None: """Create the data directory structure if it doesn't exist.""" get_db_dir().mkdir(parents=True, exist_ok=True) diff --git a/src/twicc/terminal.py b/src/twicc/terminal.py index 9922005..6e7f4f5 100644 --- a/src/twicc/terminal.py +++ b/src/twicc/terminal.py @@ -10,11 +10,19 @@ Protocol: Client → Server (JSON text frames): - { "type": "input", "data": "ls -la\n" } — keyboard input + { "type": "input", "data": "ls -la\\n" } — keyboard input { "type": "resize", "cols": 120, "rows": 30 } — terminal resize - - Server → Client (plain text frames): - Raw PTY output (no JSON wrapping for performance). + { "type": "list_windows" } — list tmux windows + presets + { "type": "create_window", "name": "build" } — create named window + { "type": "create_window", "name": "dev", — create from preset + "preset_cwd": "/path", "command": "npm run dev" } + { "type": "select_window", "name": "build" } — switch to window + + Server → Client: + Plain text frames — raw PTY output (no JSON wrapping for performance). + JSON text frames (when type field present) — control responses: + { "type": "windows", "windows": [...] } — window list + { "type": "window_changed", "name": "..." } — window switched """ import asyncio @@ -28,6 +36,7 @@ import struct import subprocess import termios +from typing import NamedTuple from urllib.parse import parse_qs from asgiref.sync import sync_to_async @@ -52,20 +61,25 @@ TMUX_SOCKET_NAME = "twicc" +class SessionContext(NamedTuple): + """All directory context needed for terminal startup and preset resolution.""" + cwd: str # Resolved working directory for PTY spawn + archived: bool + project_dir: str | None # project.directory (if exists on disk) + git_dir: str | None # session.git_directory or project.git_root (if exists on disk) + session_cwd: str | None # session.cwd (raw from JSONL, if exists on disk) + project_id: str | None # project.id (for custom preset file resolution) + + @sync_to_async -def get_session_info(session_id: str) -> tuple[str, bool]: - """Resolve the working directory and archived status for a terminal session. - - Returns (cwd, archived). - - Working directory priority order (with existence check at each level): - - If session has a git_directory (active git context from tool_use): - 1. Session.git_directory - 2. Project.directory - - Otherwise (no session git context): - 1. Project.directory - 2. Project.git_root (project happens to be inside a git repo) - - Fallback: ~ (home directory) +def get_session_context(session_id: str) -> SessionContext: + """Resolve the full directory context for a terminal session. + + Returns a SessionContext with: + - cwd: the resolved working directory for spawning the PTY (same priority + logic as before: git_directory > project.directory > project.git_root > ~) + - archived: whether the session is archived + - project_dir, git_dir, session_cwd: raw directory fields for preset resolution """ from twicc.core.models import Session @@ -74,26 +88,49 @@ def get_session_info(session_id: str) -> tuple[str, bool]: try: session = Session.objects.select_related("project").get(id=session_id) except Session.DoesNotExist: - return home, False + return SessionContext(cwd=home, archived=False, project_dir=None, git_dir=None, session_cwd=None, project_id=None) + + project = session.project + + # Resolve project_dir (validated existence) + project_dir = project.directory if project and project.directory and os.path.isdir(project.directory) else None + + # Resolve git_dir: prefer session.git_directory, then project.git_root + git_dir = None + if session.git_directory and os.path.isdir(session.git_directory): + git_dir = session.git_directory + elif project and project.git_root and os.path.isdir(project.git_root): + git_dir = project.git_root + # Resolve session_cwd (the actual CWD from the JSONL) + session_cwd = session.cwd if session.cwd and os.path.isdir(session.cwd) else None + + # Resolve cwd for PTY spawn (same priority logic as before) if session.git_directory: - # Session has active git context — git directory is preferred candidates = [ session.git_directory, - session.project.directory if session.project else None, + project.directory if project else None, ] else: - # No session git context — project directory is preferred candidates = [ - session.project.directory if session.project else None, - session.project.git_root if session.project else None, + project.directory if project else None, + project.git_root if project else None, ] + cwd = home for candidate in candidates: if candidate and os.path.isdir(candidate): - return candidate, session.archived + cwd = candidate + break - return home, session.archived + return SessionContext( + cwd=cwd, + archived=session.archived, + project_dir=project_dir, + git_dir=git_dir, + session_cwd=session_cwd, + project_id=project.id if project else None, + ) # ── tmux helpers ────────────────────────────────────────────────────────── @@ -291,6 +328,482 @@ def kill_tmux_session(session_id: str) -> bool: return False +def tmux_set_option(session_id: str, option: str, value: str) -> bool: + """Set a tmux session option. + + Returns True on success, False on failure. + """ + tmux_path = get_tmux_path() + if tmux_path is None: + return False + + name = tmux_session_name(session_id) + try: + result = subprocess.run( + [tmux_path, "-L", TMUX_SOCKET_NAME, "set-option", "-t", name, option, value], + capture_output=True, timeout=5, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +def tmux_pane_is_alternate(session_id: str) -> bool: + """Check if the active pane of the active window is in alternate screen mode. + + Alternate screen is used by full-screen apps (less, vim, htop, etc.). + This allows the frontend to choose the right scroll strategy: + - Alternate on: send arrow keys (the app handles scrolling) + - Alternate off: send mouse wheel (tmux copy-mode scrolls the buffer) + """ + tmux_path = get_tmux_path() + if tmux_path is None: + return False + + name = tmux_session_name(session_id) + try: + result = subprocess.run( + [tmux_path, "-L", TMUX_SOCKET_NAME, "display-message", + "-t", name, "-p", "#{alternate_on}"], + capture_output=True, text=True, timeout=5, + ) + return result.stdout.strip() == "1" + except (subprocess.TimeoutExpired, OSError): + return False + + +# ── tmux window management ─────────────────────────────────────────────── + + +def tmux_list_windows(session_id: str) -> list[dict[str, object]]: + """List all windows in the tmux session for the given twicc session ID. + + Returns a list of dicts: [{"name": "main", "active": True}, ...] + Returns an empty list if the session doesn't exist or tmux is not installed. + """ + tmux_path = get_tmux_path() + if tmux_path is None: + return [] + + name = tmux_session_name(session_id) + try: + result = subprocess.run( + [tmux_path, "-L", TMUX_SOCKET_NAME, "list-windows", + "-t", name, "-F", "#{window_name}\t#{window_active}"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0: + return [] + windows = [] + for line in result.stdout.strip().splitlines(): + parts = line.split("\t") + if len(parts) == 2: + windows.append({"name": parts[0], "active": parts[1] == "1"}) + return windows + except (subprocess.TimeoutExpired, OSError): + return [] + + +def tmux_create_window(session_id: str, window_name: str, cwd: str | None = None) -> bool: + """Create a new window in the tmux session with the given name. + + Args: + session_id: The Claude session ID + window_name: Display name for the new tmux window + cwd: Working directory for the new window. If None, inherits tmux default. + + Returns True on success, False on failure. + """ + tmux_path = get_tmux_path() + if tmux_path is None: + return False + + session_name = tmux_session_name(session_id) + cmd = [tmux_path, "-L", TMUX_SOCKET_NAME, "new-window", + "-t", session_name, "-n", window_name] + if cwd: + cmd.extend(["-c", cwd]) + try: + result = subprocess.run(cmd, capture_output=True, timeout=5) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +def tmux_select_window(session_id: str, window_name: str) -> bool: + """Switch the active window in the tmux session. + + Returns True on success, False on failure. + """ + tmux_path = get_tmux_path() + if tmux_path is None: + return False + + session_name = tmux_session_name(session_id) + try: + result = subprocess.run( + [tmux_path, "-L", TMUX_SOCKET_NAME, "select-window", + "-t", f"{session_name}:{window_name}"], + capture_output=True, timeout=5, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +def tmux_rename_window(session_id: str, target: str, new_name: str) -> bool: + """Rename a window in the tmux session. + + target can be a window index (e.g., "0") or name. + Returns True on success, False on failure. + """ + tmux_path = get_tmux_path() + if tmux_path is None: + return False + + session_name = tmux_session_name(session_id) + try: + result = subprocess.run( + [tmux_path, "-L", TMUX_SOCKET_NAME, "rename-window", + "-t", f"{session_name}:{target}", new_name], + capture_output=True, timeout=5, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +def tmux_send_keys(session_id: str, window_name: str, keys: str) -> bool: + """Send keys to a specific tmux window. + + Used to execute a command in a newly created preset window. + Appends Enter to simulate pressing the Enter key. + + Returns True on success, False on failure. + """ + tmux_path = get_tmux_path() + if tmux_path is None: + return False + + session_name = tmux_session_name(session_id) + try: + result = subprocess.run( + [tmux_path, "-L", TMUX_SOCKET_NAME, "send-keys", + "-t", f"{session_name}:{window_name}", keys, "Enter"], + capture_output=True, timeout=5, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +def load_tmux_presets(project_dir: str) -> list[dict]: + """Load tmux shell presets from a .twicc-tmux.json file in the given directory. + + Convenience wrapper around load_tmux_presets_from_file() for the standard + `.twicc-tmux.json` filename. + """ + return load_tmux_presets_from_file(os.path.join(project_dir, ".twicc-tmux.json")) + + +VALID_RELATIVE_TO = {"preset_dir", "project_dir", "git_dir", "session_cwd"} + + +def load_tmux_presets_from_file(file_path: str) -> list[dict]: + """Load tmux shell presets from any JSON file. + + The file must contain an array of preset objects. Each preset + has a name (required), optional cwd (relative or absolute), optional + command, and optional relative_to (preset_dir|project_dir|git_dir|session_cwd). + + Returns raw preset dicts without resolving cwd — resolution happens in + resolve_preset_sources() where the session context is available. + + Returns [] on missing file / parse error. + """ + try: + with open(file_path, "r") as f: + data = json.loads(f.read()) + except (OSError, json.JSONDecodeError): + return [] + + if not isinstance(data, list): + return [] + + preset_dir = os.path.dirname(os.path.abspath(file_path)) + presets = [] + for entry in data: + if not isinstance(entry, dict) or not entry.get("name"): + continue + preset: dict = {"name": str(entry["name"]), "preset_dir": preset_dir} + if "command" in entry: + preset["command"] = str(entry["command"]) + if "cwd" in entry: + preset["raw_cwd"] = str(entry["cwd"]) + relative_to = str(entry.get("relative_to", "preset_dir")) + if relative_to not in VALID_RELATIVE_TO: + relative_to = "preset_dir" + preset["relative_to"] = relative_to + presets.append(preset) + return presets + + +def _resolve_preset_cwd(preset: dict, ctx: SessionContext) -> dict: + """Resolve a preset's cwd using session context and relative_to field. + + Mutates and returns the preset dict, adding 'cwd' (resolved path) and + optionally 'unavailable' (True if the base directory is not available). + """ + relative_to = preset.get("relative_to", "preset_dir") + preset_dir = preset.get("preset_dir", "") + + # Map relative_to to actual base directory + base_map: dict[str, str | None] = { + "preset_dir": preset_dir, + "project_dir": ctx.project_dir, + "git_dir": ctx.git_dir, + "session_cwd": ctx.session_cwd, + } + base_dir = base_map.get(relative_to) + + if not base_dir or not os.path.isdir(base_dir): + # Base not available — mark preset as unavailable + preset["unavailable"] = True + preset["cwd"] = None + preset.pop("raw_cwd", None) + preset.pop("preset_dir", None) + return preset + + raw_cwd = preset.pop("raw_cwd", None) + if raw_cwd: + if os.path.isabs(raw_cwd): + preset["cwd"] = raw_cwd + else: + preset["cwd"] = os.path.normpath(os.path.join(base_dir, raw_cwd)) + else: + preset["cwd"] = base_dir + + # Clean up internal fields not needed by the frontend + preset.pop("preset_dir", None) + + return preset + + +def get_custom_preset_files(project_id: str) -> list[dict]: + """Read the list of custom preset file references for a project. + + Returns a list of dicts: [{"name": "My tools", "path": "/path/to/presets.json"}, ...] + Returns [] if no file exists or on parse error. + """ + from twicc.paths import get_project_presets_path + + presets_path = get_project_presets_path(project_id) + try: + with open(presets_path, "r") as f: + data = json.loads(f.read()) + except (OSError, json.JSONDecodeError): + return [] + + if not isinstance(data, list): + return [] + + result = [] + for entry in data: + if isinstance(entry, dict) and entry.get("name") and entry.get("path"): + result.append({"name": str(entry["name"]), "path": str(entry["path"])}) + return result + + +def save_custom_preset_files(project_id: str, entries: list[dict]) -> None: + """Write the list of custom preset file references for a project.""" + from twicc.paths import get_project_presets_path + + presets_path = get_project_presets_path(project_id) + presets_path.parent.mkdir(parents=True, exist_ok=True) + with open(presets_path, "w") as f: + f.write(json.dumps(entries, indent=2)) + + +def add_custom_preset_file(project_id: str, name: str, path: str) -> list[dict]: + """Add a custom preset file reference for a project. Returns the updated list.""" + entries = get_custom_preset_files(project_id) + # Avoid duplicates by path + if any(e["path"] == path for e in entries): + return entries + entries.append({"name": name, "path": path}) + save_custom_preset_files(project_id, entries) + return entries + + +def remove_custom_preset_file(project_id: str, path: str) -> list[dict]: + """Remove a custom preset file reference by path. Returns the updated list.""" + entries = get_custom_preset_files(project_id) + entries = [e for e in entries if e["path"] != path] + save_custom_preset_files(project_id, entries) + return entries + + +def resolve_preset_sources(ctx: SessionContext, project_id: str | None = None) -> list[dict]: + """Resolve up to 3 preset sources from session context. + + Returns a list of dicts with {label, directory, presets} for each source + that has a valid .twicc-tmux.json file. Sources: + 1. Project dir — always checked. + 2. Git dir — checked if different from project dir. + 3. CWD walk — walk parents from session_cwd up to first .twicc-tmux.json, + bounded by project_dir / git_dir. + """ + sources: list[dict] = [] + seen_dirs: set[str] = set() + seen_files: set[str] = set() # Track loaded file paths for dedup with custom sources + home = os.path.expanduser("~") + + def _norm(path: str) -> str: + return os.path.normpath(os.path.realpath(path)) + + def _resolve_all(presets: list[dict]) -> list[dict]: + """Resolve cwd for all presets using the session context.""" + return [_resolve_preset_cwd(p, ctx) for p in presets] + + def _try_add(label: str, directory: str) -> bool: + """Load presets from directory. Add to sources if file exists. Returns True if added.""" + norm = _norm(directory) + if norm in seen_dirs: + return False + seen_dirs.add(norm) + presets = load_tmux_presets(directory) + if presets: + seen_files.add(_norm(os.path.join(directory, ".twicc-tmux.json"))) + sources.append({"label": label, "directory": directory, "presets": _resolve_all(presets)}) + return True + return False + + def _shorten(path: str) -> str: + """Replace $HOME prefix with ~.""" + if path.startswith(home): + return "~" + path[len(home):] + return path + + # Source 1: Project directory + if ctx.project_dir: + _try_add("Project", ctx.project_dir) + + # Source 2: Git directory (only if different from project dir) + if ctx.git_dir: + _try_add("Git root", ctx.git_dir) + + # Source 3: Walk up from session CWD + if ctx.session_cwd: + cwd_norm = _norm(ctx.session_cwd) + if cwd_norm not in seen_dirs: + current = ctx.session_cwd + while True: + norm_current = _norm(current) + if norm_current in seen_dirs: + break # Already covered by project or git source + presets = load_tmux_presets(current) + if presets: + seen_files.add(_norm(os.path.join(current, ".twicc-tmux.json"))) + sources.append({"label": _shorten(current), "directory": current, "presets": _resolve_all(presets)}) + break # Stop at first found + parent = os.path.dirname(current) + if parent == current: + break # Reached filesystem root + current = parent + + # Source 4: Custom preset files (user-added per project) + if project_id: + for entry in get_custom_preset_files(project_id): + file_path = entry["path"] + if not os.path.isfile(file_path): + continue + if _norm(file_path) in seen_files: + continue + file_presets = load_tmux_presets_from_file(file_path) + if file_presets: + sources.append({ + "label": entry["name"], + "directory": os.path.dirname(file_path), + "presets": _resolve_all(file_presets), + "custom_file": file_path, + }) + + return sources + + +def _configure_tmux_scroll_bindings() -> None: + """Configure tmux mouse wheel bindings for proper scroll in all contexts. + + Overrides the default WheelUpPane / WheelDownPane bindings so that: + - If the pane is already in copy-mode or the app captures the mouse + (e.g. vim with mouse): pass the mouse event through (send-keys -M). + - If the pane is in alternate screen mode (less, htop, etc.) WITHOUT + mouse capture: send arrow keys so the app scrolls natively. + - Otherwise (shell prompt): enter copy-mode for tmux scrollback. + + These bindings are server-wide (shared by all twicc tmux sessions on + the same socket), so setting them repeatedly is harmless. + """ + tmux_path = get_tmux_path() + if tmux_path is None: + return + + condition = "#{||:#{pane_in_mode},#{mouse_any_flag}}" + + for event, arrow_keys, normal_cmd in [ + ("WheelUpPane", "Up Up Up", "copy-mode -e ; send-keys -M"), + ("WheelDownPane", "Down Down Down", "send-keys -M"), + ]: + alt_branch = ( + f'if-shell -F "#{{alternate_on}}" ' + f'"send-keys {arrow_keys}" ' + f'"{normal_cmd}"' + ) + subprocess.run( + [tmux_path, "-L", TMUX_SOCKET_NAME, + "bind-key", "-T", "root", event, + "if-shell", "-F", condition, "send-keys -M", alt_branch], + capture_output=True, timeout=5, + ) + + +# ── tmux window monitor ────────────────────────────────────────────────── + +# Polling interval for detecting external window changes (close, rename, etc.) +_WINDOW_POLL_INTERVAL = 2 # seconds + + +async def _tmux_window_monitor(session_id: str, send, ctx: SessionContext) -> None: + """Periodically check for tmux window and pane state changes and push updates. + + Detects when windows are created or destroyed externally (e.g., shell + exits, user closes a pane) and when the active pane switches between + normal and alternate screen (e.g., entering/exiting less or vim). + Sends updated state to the frontend so the tab bar / dropdown and + scroll behavior stay in sync. + """ + # Initialize with current state to avoid a duplicate update on first poll + prev_windows = await asyncio.to_thread(tmux_list_windows, session_id) + prev_alternate = await asyncio.to_thread(tmux_pane_is_alternate, session_id) + try: + while True: + await asyncio.sleep(_WINDOW_POLL_INTERVAL) + win_list = await asyncio.to_thread(tmux_list_windows, session_id) + alternate = await asyncio.to_thread(tmux_pane_is_alternate, session_id) + if win_list != prev_windows or alternate != prev_alternate: + prev_windows = win_list + prev_alternate = alternate + presets = await asyncio.to_thread(resolve_preset_sources, ctx, ctx.project_id) + await send({"type": "websocket.send", + "text": json.dumps({"type": "windows", "windows": win_list, + "presets": presets, + "alternate_on": alternate})}) + except asyncio.CancelledError: + return + except Exception: + return + + # ── Raw ASGI WebSocket application ──────────────────────────────────────── async def terminal_application(scope, receive, send): @@ -322,7 +835,9 @@ async def terminal_application(scope, receive, send): # ── Resolve working directory and session state ────────────────── session_id = scope["url_route"]["kwargs"]["session_id"] - cwd, archived = await get_session_info(session_id) + ctx = await get_session_context(session_id) + cwd = ctx.cwd + archived = ctx.archived # ── Spawn PTY (tmux or raw shell) ──────────────────────────────── use_tmux = wants_tmux(scope) @@ -353,6 +868,17 @@ async def terminal_application(scope, receive, send): # ── Accept connection ───────────────────────────────────────────── await send({"type": "websocket.accept"}) + # Configure tmux session + if use_tmux: + # Enable mouse mode so wheel events scroll the buffer (not command history) + tmux_set_option(session_id, "mouse", "on") + # Override default wheel bindings for proper scroll in less/htop/etc. + _configure_tmux_scroll_bindings() + # Rename the default tmux window to "main" for fresh sessions + windows = tmux_list_windows(session_id) + if windows and windows[0]["name"] != "main": + tmux_rename_window(session_id, "0", "main") + # ── PTY output reader task ──────────────────────────────────────── # Uses add_reader for event-driven reading, and an asyncio.Queue # to bridge the sync callback to the async send loop. @@ -436,15 +962,59 @@ async def receive_loop(): except OSError: pass + # ── tmux window control messages ────────────────── + elif msg_type == "list_windows" and use_tmux: + ctx = await get_session_context(session_id) + win_list = tmux_list_windows(session_id) + presets = resolve_preset_sources(ctx, ctx.project_id) + alternate = tmux_pane_is_alternate(session_id) + await send({"type": "websocket.send", + "text": json.dumps({"type": "windows", "windows": win_list, + "presets": presets, + "alternate_on": alternate})}) + + elif msg_type == "create_window" and use_tmux: + ctx = await get_session_context(session_id) + window_name = msg.get("name", "").strip() + if window_name: + window_cwd = msg.get("preset_cwd") or ctx.cwd + tmux_create_window(session_id, window_name, cwd=window_cwd) + # Run optional command in the new window + command = msg.get("command") + if command: + tmux_send_keys(session_id, window_name, command) + win_list = tmux_list_windows(session_id) + presets = resolve_preset_sources(ctx, ctx.project_id) + alternate = tmux_pane_is_alternate(session_id) + await send({"type": "websocket.send", + "text": json.dumps({"type": "windows", "windows": win_list, + "presets": presets, + "alternate_on": alternate})}) + + elif msg_type == "select_window" and use_tmux: + window_name = msg.get("name", "") + if window_name and tmux_select_window(session_id, window_name): + await send({"type": "websocket.send", + "text": json.dumps({"type": "window_changed", "name": window_name})}) + elif message["type"] == "websocket.disconnect": return recv_task = asyncio.create_task(receive_loop()) + # ── tmux window monitor (detects external changes like shell exit) ── + monitor_task = None + if use_tmux: + monitor_task = asyncio.create_task(_tmux_window_monitor(session_id, send, ctx)) + try: # Wait for either the PTY to die or the client to disconnect + tasks = [sender_task, recv_task] + if monitor_task: + tasks.append(monitor_task) + done, pending = await asyncio.wait( - [sender_task, recv_task], + tasks, return_when=asyncio.FIRST_COMPLETED, ) @@ -467,4 +1037,10 @@ async def receive_loop(): logger.exception("Error in terminal WebSocket for session %s", session_id) finally: # ── Cleanup ─────────────────────────────────────────────────── + if monitor_task and not monitor_task.done(): + monitor_task.cancel() + try: + await monitor_task + except asyncio.CancelledError: + pass cleanup_pty(master_fd, child_pid) diff --git a/src/twicc/urls.py b/src/twicc/urls.py index 29c283b..08eb830 100644 --- a/src/twicc/urls.py +++ b/src/twicc/urls.py @@ -18,6 +18,7 @@ path("api/projects/", views.project_list), path("api/projects//", views.project_detail), path("api/projects//slash-commands/", views.slash_commands), + path("api/projects//custom-presets/", views.custom_preset_files), path("api/projects//daily-activity/", views.daily_activity), # Per-project daily activity path("api/projects//sessions/", views.project_sessions), # Project-level file system endpoints (for draft sessions and project-level browsing) diff --git a/src/twicc/views.py b/src/twicc/views.py index 41c8b54..a3a0670 100644 --- a/src/twicc/views.py +++ b/src/twicc/views.py @@ -701,6 +701,65 @@ def home_directory(request): return JsonResponse({"path": os.path.expanduser("~")}) +def custom_preset_files(request, project_id): + """GET/POST/DELETE custom preset file references for a project. + + GET: returns the list of custom preset files. + POST: adds a new preset file reference (body: {"name": "...", "path": "..."}). + DELETE: removes a preset file reference (body: {"path": "..."}). + """ + from twicc.terminal import add_custom_preset_file, get_custom_preset_files, remove_custom_preset_file + + # Validate project exists + try: + Project.objects.get(id=project_id) + except Project.DoesNotExist: + raise Http404("Project not found") + + if request.method == "GET": + return JsonResponse({"files": get_custom_preset_files(project_id)}) + + if request.method == "POST": + try: + data = orjson.loads(request.body) + except orjson.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + name = data.get("name", "").strip() + path = data.get("path", "").strip() + + if not name or not path: + return JsonResponse({"error": "Both 'name' and 'path' are required"}, status=400) + if not os.path.isabs(path): + return JsonResponse({"error": "Path must be absolute"}, status=400) + if not os.path.isfile(path): + return JsonResponse({"error": "File not found"}, status=404) + + # Validate the file contains valid preset data + from twicc.terminal import load_tmux_presets_from_file + presets = load_tmux_presets_from_file(path) + if not presets: + return JsonResponse({"error": "File does not contain valid presets"}, status=400) + + entries = add_custom_preset_file(project_id, name, path) + return JsonResponse({"files": entries}) + + if request.method == "DELETE": + try: + data = orjson.loads(request.body) + except orjson.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + path = data.get("path", "").strip() + if not path: + return JsonResponse({"error": "'path' is required"}, status=400) + + entries = remove_custom_preset_file(project_id, path) + return JsonResponse({"files": entries}) + + return JsonResponse({"error": "Method not allowed"}, status=405) + + def file_content(request, project_id, session_id=None): """GET/PUT file content.