Make popover window sticky during new session creation (#194)

This commit is contained in:
Peter Steinberger 2025-07-02 16:49:34 +01:00 committed by GitHub
parent dab2c6056d
commit 2a937eac4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2015 additions and 589 deletions

View file

@ -5,5 +5,9 @@
"Bash(rg:*)"
],
"deny": []
}
}
},
"enabledMcpjsonServers": [
"playwright"
],
"enableAllProjectMcpServers": true
}

View file

@ -11,10 +11,41 @@ VibeTunnel is a macOS application that allows users to access their terminal ses
## Critical Development Rules
- **Never commit and/or push before the user has tested your changes!**
- **ABSOLUTELY SUPER IMPORTANT & CRITICAL**: NEVER USE git rebase --skip EVER
- **Never create a new branch/PR automatically when you are already on a branch**, even if the changes do not seem to fit into the existing PR. Only do that when explicitly asked. Our workflow is always start from main, make branch, make PR, merge. Then we go back to main and start something else. PRs sometimes contain different features and that's okay.
- **IMPORTANT**: When refactoring or improving code, directly modify the existing files. DO NOT create new versions with different file names. Users hate having to manually clean up duplicate files.
### ABSOLUTE CARDINAL RULES - VIOLATION MEANS IMMEDIATE FAILURE
1. **NEVER, EVER, UNDER ANY CIRCUMSTANCES CREATE A NEW BRANCH WITHOUT EXPLICIT USER PERMISSION**
- If you are on a branch (not main), you MUST stay on that branch
- The user will tell you when to create a new branch with commands like "create a new branch" or "switch to a new branch"
- Creating branches without permission causes massive frustration and cleanup work
- Even if changes seem unrelated to the current branch, STAY ON THE CURRENT BRANCH
2. **NEVER commit and/or push before the user has tested your changes!**
- Always wait for user confirmation before committing
- The user needs to verify changes work correctly first
3. **ABSOLUTELY FORBIDDEN: NEVER USE `git rebase --skip` EVER**
- This command can cause data loss and repository corruption
- If you encounter rebase conflicts, ask the user for help
4. **NEVER create duplicate files with version numbers or suffixes**
- When refactoring or improving code, directly modify the existing files
- DO NOT create new versions with different file names (e.g., file_v2.ts, file_new.ts)
- Users hate having to manually clean up duplicate files
### Git Workflow Reminders
- Our workflow: start from main → create branch → make PR → merge → return to main
- PRs sometimes contain multiple different features and that's okay
- Always check current branch with `git branch` before making changes
- If unsure about branching, ASK THE USER FIRST
### Terminal Title Management with VT
When creating pull requests, use the `vt` command to update the terminal title:
- Run `vt title "Brief summary - github.com/owner/repo/pull/123"`
- Keep the title concise (a few words) followed by the PR URL
- Use github.com URL format (not https://) for easy identification
- Update the title periodically as work progresses
- If `vt` command fails (only works inside VibeTunnel), simply ignore the error and continue
## Web Development Commands

9
mac/CLAUDE.md Normal file
View file

@ -0,0 +1,9 @@
# CLAUDE.md for macOS Development
## SwiftUI Development Guidelines
* Aim to build all functionality using SwiftUI unless there is a feature that is only supported in AppKit.
* Design UI in a way that is idiomatic for the macOS platform and follows Apple Human Interface Guidelines.
* Use SF Symbols for iconography.
* Use the most modern macOS APIs. Since there is no backward compatibility constraint, this app can target the latest macOS version with the newest APIs.
* Use the most modern Swift language features and conventions. Target Swift 6 and use Swift concurrency (async/await, actors) and Swift macros where applicable.

View file

@ -55,6 +55,13 @@
ReferencedContainer = "container:VibeTunnel-Mac.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "VIBETUNNEL_DEBUG"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View file

@ -1,4 +1,5 @@
import CryptoKit
import Darwin
import Foundation
import OSLog
@ -207,7 +208,7 @@ final class BunServer {
environment["NODE_OPTIONS"] = "--max-old-space-size=4096 --max-semi-space-size=128"
// Copy only essential environment variables
let essentialVars = ["PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE"]
let essentialVars = ["PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "VIBETUNNEL_DEBUG"]
for key in essentialVars {
if let value = ProcessInfo.processInfo.environment[key] {
environment[key] = value
@ -429,24 +430,55 @@ final class BunServer {
}
source.setEventHandler { [logHandler] in
let data = handle.availableData
if data.isEmpty {
// EOF reached
cancelSource()
return
// Read data in a non-blocking way to prevent hangs on large output
var buffer = Data()
let maxBytesPerRead = 65_536 // 64KB chunks
// Read available data without blocking
while true {
var readBuffer = Data(count: maxBytesPerRead)
let bytesRead = readBuffer.withUnsafeMutableBytes { bytes in
guard let baseAddress = bytes.baseAddress else {
logger.error("Failed to get base address for read buffer")
return -1
}
return Darwin.read(handle.fileDescriptor, baseAddress, maxBytesPerRead)
}
if bytesRead > 0 {
buffer.append(readBuffer.prefix(bytesRead))
// Check if more data is immediately available
var pollfd = pollfd(fd: handle.fileDescriptor, events: Int16(POLLIN), revents: 0)
let pollResult = poll(&pollfd, 1, 0) // 0 timeout = non-blocking
if pollResult <= 0 || (pollfd.revents & Int16(POLLIN)) == 0 {
break // No more data immediately available
}
} else if bytesRead == 0 {
// EOF reached
cancelSource()
return
} else {
// Error occurred
if errno != EAGAIN && errno != EWOULDBLOCK {
logger.error("Read error on stdout: \(String(cString: strerror(errno)))")
cancelSource()
return
}
break // No data available right now
}
}
if let output = String(data: data, encoding: .utf8) {
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
// Skip shell initialization messages
if line.contains("zsh:") || line.hasPrefix("Last login:") {
continue
}
// Log to OSLog with appropriate level
logHandler.log(line, isError: false)
// Process accumulated data
if !buffer.isEmpty {
if let output = String(data: buffer, encoding: .utf8) {
Self.processOutputStatic(output, logHandler: logHandler, isError: false)
} else {
// If UTF-8 decoding fails, try to decode what we can
// Use String(decoding:as:) for lossy conversion
let output = String(decoding: buffer, as: UTF8.self)
Self.processOutputStatic(output, logHandler: logHandler, isError: false)
}
}
}
@ -482,19 +514,55 @@ final class BunServer {
}
source.setEventHandler { [logHandler] in
let data = handle.availableData
if data.isEmpty {
// EOF reached
cancelSource()
return
// Read data in a non-blocking way to prevent hangs on large output
var buffer = Data()
let maxBytesPerRead = 65_536 // 64KB chunks
// Read available data without blocking
while true {
var readBuffer = Data(count: maxBytesPerRead)
let bytesRead = readBuffer.withUnsafeMutableBytes { bytes in
guard let baseAddress = bytes.baseAddress else {
logger.error("Failed to get base address for read buffer")
return -1
}
return Darwin.read(handle.fileDescriptor, baseAddress, maxBytesPerRead)
}
if bytesRead > 0 {
buffer.append(readBuffer.prefix(bytesRead))
// Check if more data is immediately available
var pollfd = pollfd(fd: handle.fileDescriptor, events: Int16(POLLIN), revents: 0)
let pollResult = poll(&pollfd, 1, 0) // 0 timeout = non-blocking
if pollResult <= 0 || (pollfd.revents & Int16(POLLIN)) == 0 {
break // No more data immediately available
}
} else if bytesRead == 0 {
// EOF reached
cancelSource()
return
} else {
// Error occurred
if errno != EAGAIN && errno != EWOULDBLOCK {
logger.error("Read error on stderr: \(String(cString: strerror(errno)))")
cancelSource()
return
}
break // No data available right now
}
}
if let output = String(data: data, encoding: .utf8) {
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
// Log stderr as errors/warnings
logHandler.log(line, isError: true)
// Process accumulated data
if !buffer.isEmpty {
if let output = String(data: buffer, encoding: .utf8) {
Self.processOutputStatic(output, logHandler: logHandler, isError: true)
} else {
// If UTF-8 decoding fails, try to decode what we can
// Use String(decoding:as:) for lossy conversion
let output = String(decoding: buffer, as: UTF8.self)
Self.processOutputStatic(output, logHandler: logHandler, isError: true)
}
}
}
@ -611,6 +679,50 @@ enum BunServerError: LocalizedError {
}
}
// MARK: - Private Output Processing
extension BunServer {
/// Process output with chunking for large lines and rate limiting awareness
fileprivate nonisolated static func processOutputStatic(_ output: String, logHandler: LogHandler, isError: Bool) {
let maxLineLength = 4_096 // Max chars per log line to avoid os.log truncation
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
// Skip shell initialization messages
if line.contains("zsh:") || line.hasPrefix("Last login:") {
continue
}
// If line is too long, chunk it to avoid os.log limits
if line.count > maxLineLength {
// Log that we're chunking a large line
logHandler.log("[Large output: \(line.count) chars, chunking...]", isError: isError)
// Chunk the line
var startIndex = line.startIndex
var chunkNumber = 1
while startIndex < line.endIndex {
let endIndex = line.index(startIndex, offsetBy: maxLineLength, limitedBy: line.endIndex) ?? line
.endIndex
let chunk = String(line[startIndex..<endIndex])
logHandler.log("[Chunk \(chunkNumber)] \(chunk)", isError: isError)
startIndex = endIndex
chunkNumber += 1
// Add small delay between chunks to avoid rate limiting
if chunkNumber % 10 == 0 {
usleep(1_000) // 1ms delay every 10 chunks
}
}
} else {
// Log normally
logHandler.log(line, isError: isError)
}
}
}
}
// MARK: - LogHandler
/// A sendable log handler for use in detached tasks

View file

@ -124,20 +124,6 @@ final class SessionMonitor {
self.lastError = nil
self.lastFetch = Date()
logger
.debug(
"Fetched \(sessionsArray.count) sessions, \(sessionsDict.values.count { $0.isRunning }) running"
)
// Debug: Log session details
for session in sessionsArray {
let pidStr = session.pid.map { String($0) } ?? "nil"
logger
.debug(
"Session \(session.id): status=\(session.status), isRunning=\(session.isRunning), pid=\(pidStr)"
)
}
// Update WindowTracker
WindowTracker.shared.updateFromSessions(sessionsArray)
} catch {

View file

@ -1135,9 +1135,10 @@ final class WindowTracker {
}
}
let runningCount = sessions.count { $0.isRunning }
logger
.debug(
"Updated window tracker: \(self.sessionWindowMap.count) active windows, \(sessions.count) total sessions"
"Sessions updated: \(sessions.count) total, \(runningCount) running, \(self.sessionWindowMap.count) tracked windows"
)
}
}

View file

@ -21,6 +21,13 @@ final class CustomMenuWindow: NSPanel {
private var _isWindowVisible = false
private var frameObserver: Any?
private var lastBounds: CGRect = .zero
private var maskLayer: CAShapeLayer?
/// Tracks whether the new session form is currently active
var isNewSessionActive = false
/// Closure to be called when window shows
var onShow: (() -> Void)?
/// Closure to be called when window hides
var onHide: (() -> Void)?
@ -77,6 +84,7 @@ final class CustomMenuWindow: NSPanel {
cornerRadius: DesignConstants.menuCornerRadius
)
contentView.layer?.mask = maskLayer
self.maskLayer = maskLayer
lastBounds = contentView.bounds
// Update mask when bounds change
@ -91,7 +99,7 @@ final class CustomMenuWindow: NSPanel {
let currentBounds = contentView.bounds
guard currentBounds != self.lastBounds else { return }
self.lastBounds = currentBounds
maskLayer.path = self.createSideRoundedPath(
self.maskLayer?.path = self.createSideRoundedPath(
in: currentBounds,
cornerRadius: DesignConstants.menuCornerRadius
)
@ -189,6 +197,8 @@ final class CustomMenuWindow: NSPanel {
// Commit all changes at once
CATransaction.commit()
onShow?()
}
private func displayWindowSafely() {
@ -269,6 +279,7 @@ final class CustomMenuWindow: NSPanel {
func hide() {
// Mark window as not visible
_isWindowVisible = false
isNewSessionActive = false // Always reset this state
// Button state will be reset by StatusBarMenuManager via onHide callback
orderOut(nil)
@ -291,11 +302,29 @@ final class CustomMenuWindow: NSPanel {
guard isVisible else { return }
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [
.leftMouseDown,
.rightMouseDown
]) { [weak self] _ in
guard let self, self.isVisible else { return }
let mouseLocation = NSEvent.mouseLocation
// Don't dismiss if new session is active
if self.isNewSessionActive {
// Check if clicking on status bar button to allow closing via menu icon
if let button = self.statusBarButton,
let buttonWindow = button.window
{
let buttonFrame = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil))
if buttonFrame.contains(mouseLocation) {
// User clicked the menu bar icon, dismiss even with new session active
self.hide()
}
}
return
}
if !self.frame.contains(mouseLocation) {
self.hide()
}

View file

@ -3,9 +3,12 @@ import SwiftUI
/// Compact new session form designed for the popover
struct NewSessionForm: View {
@Binding var isPresented: Bool
@Environment(ServerManager.self) private var serverManager
@Environment(SessionMonitor.self) private var sessionMonitor
@Environment(SessionService.self) private var sessionService
@Environment(ServerManager.self)
private var serverManager
@Environment(SessionMonitor.self)
private var sessionMonitor
@Environment(SessionService.self)
private var sessionService
// Form fields
@State private var command = "zsh"
@ -58,14 +61,14 @@ struct NewSessionForm: View {
HStack {
Button(action: {
isPresented = false
}) {
}, label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.system(size: 11, weight: .medium))
Text("Sessions")
.font(.system(size: 12, weight: .medium))
}
}
})
.buttonStyle(.plain)
.foregroundColor(.primary.opacity(0.8))
@ -164,7 +167,7 @@ struct NewSessionForm: View {
Button(action: {
command = cmd.0
sessionName = ""
}) {
}, label: {
HStack(spacing: 4) {
if let emoji = cmd.1 {
Text(emoji)
@ -184,7 +187,7 @@ struct NewSessionForm: View {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
}
})
.buttonStyle(.plain)
}
}
@ -205,14 +208,14 @@ struct NewSessionForm: View {
Menu {
ForEach(TitleMode.allCases, id: \.self) { mode in
Button(action: { titleMode = mode }) {
Button(action: { titleMode = mode }, label: {
HStack {
Text(mode.displayName)
if mode == titleMode {
Image(systemName: "checkmark")
}
}
}
})
}
} label: {
HStack(spacing: 4) {

View file

@ -1,123 +1,19 @@
import Foundation
// MARK: - Visual Indicator Styles
// MARK: - Status Bar Visual Indicators
extension StatusBarController {
enum IndicatorStyle {
case dots // 5 (current implementation)
case bars //
case compact // 25
case minimalist // 2|5
case meter // []
}
/// Format session counts with the specified visual style
func formatSessionIndicator(activeCount: Int, totalCount: Int, style: IndicatorStyle = .dots) -> String {
/// Format session counts with minimalist style
func formatSessionIndicator(activeCount: Int, idleCount: Int) -> String {
let totalCount = activeCount + idleCount
guard totalCount > 0 else { return "" }
switch style {
case .dots:
return formatDotsIndicator(activeCount: activeCount, totalCount: totalCount)
case .bars:
return formatBarsIndicator(activeCount: activeCount, totalCount: totalCount)
case .compact:
return formatCompactIndicator(activeCount: activeCount, totalCount: totalCount)
case .minimalist:
return formatMinimalistIndicator(activeCount: activeCount, totalCount: totalCount)
case .meter:
return formatMeterIndicator(activeCount: activeCount, totalCount: totalCount)
}
}
// MARK: - Indicator Implementations
private func formatDotsIndicator(activeCount: Int, totalCount: Int) -> String {
if activeCount == 0 {
// Only idle sessions, show simple count
return String(totalCount)
} else if activeCount > 0 {
// Show active sessions with dots
let dots = String(repeating: "", count: min(activeCount, 3))
let suffix = activeCount > 3 ? "+" : ""
if totalCount > activeCount {
// Show active dots with total count
return "\(dots)\(suffix) \(totalCount)"
} else {
// Only active sessions, just show dots
return dots + suffix
}
}
return ""
}
private func formatBarsIndicator(activeCount: Int, totalCount: Int) -> String {
let maxBars = 5
let displayCount = min(totalCount, maxBars)
let displayActive = min(activeCount, displayCount)
let activeBars = String(repeating: "▪︎", count: displayActive)
let idleBars = String(repeating: "▫︎", count: displayCount - displayActive)
if totalCount > maxBars {
return "\(activeBars)\(idleBars)+"
}
return activeBars + idleBars
}
private func formatCompactIndicator(activeCount: Int, totalCount: Int) -> String {
if activeCount == 0 {
"\(totalCount)"
} else if activeCount == totalCount {
"\(activeCount)"
return "\(activeCount)"
} else {
"\(activeCount)\(totalCount)"
return "\(activeCount) | \(idleCount)"
}
}
private func formatMinimalistIndicator(activeCount: Int, totalCount: Int) -> String {
if activeCount == 0 {
String(totalCount)
} else if activeCount == totalCount {
"\(activeCount)"
} else {
"\(activeCount) | \(totalCount)"
}
}
private func formatMeterIndicator(activeCount: Int, totalCount: Int) -> String {
let maxSegments = 5
let segmentCount = min(totalCount, maxSegments)
if segmentCount == 0 { return "" }
let activeSegments = Int(round(Double(activeCount) / Double(totalCount) * Double(segmentCount)))
let filled = String(repeating: "", count: activeSegments)
let empty = String(repeating: "", count: segmentCount - activeSegments)
return "[\(filled)\(empty)]"
}
}
// MARK: - Alternative Unicode Characters
// Other visual indicators we could use:
//
// Dots and Circles:
//
//
// Squares and Blocks:
//
//
// Bars and Progress:
//
//
// Arrows and Triangles:
//
//
// Special Characters:
//

View file

@ -10,7 +10,7 @@ final class StatusBarController: NSObject {
// MARK: - Core Properties
private var statusItem: NSStatusItem?
private let menuManager: StatusBarMenuManager
let menuManager: StatusBarMenuManager
// MARK: - Dependencies
@ -65,7 +65,7 @@ final class StatusBarController: NSObject {
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
// Use pushOnPushOff for proper state management
button.setButtonType(.pushOnPushOff)
button.setButtonType(.toggle)
// Accessibility
button.setAccessibilityTitle("VibeTunnel")
@ -154,16 +154,10 @@ final class StatusBarController: NSObject {
let activeCount = activeSessions.count
let totalCount = sessions.count
let idleCount = totalCount - activeCount
// Format the title with visual indicators
// Try different styles by changing this:
// .dots (default): 5
// .bars:
// .compact: 25
// .minimalist: 2|5
// .meter: []
let indicatorStyle: IndicatorStyle = .minimalist
let indicator = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle)
// Format the title with minimalist indicator
let indicator = formatSessionIndicator(activeCount: activeCount, idleCount: idleCount)
button.title = indicator.isEmpty ? "" : " " + indicator
// Update tooltip
@ -260,6 +254,11 @@ final class StatusBarController: NSObject {
menuManager.showCustomWindow(relativeTo: button)
}
func toggleCustomWindow() {
guard let button = statusItem?.button else { return }
menuManager.toggleCustomWindow(relativeTo: button)
}
// MARK: - Cleanup
deinit {

View file

@ -1,6 +1,19 @@
import AppKit
import Combine
import SwiftUI
/// gross hack: https://stackoverflow.com/questions/26004684/nsstatusbarbutton-keep-highlighted?rq=4
/// Didn't manage to keep the highlighted state reliable active with any other way.
extension NSStatusBarButton {
override public func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
self.highlight(true)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
self.highlight(AppDelegate.shared?.statusBarController?.menuManager.customWindow?.isWindowVisible ?? false)
}
}
}
/// Manages status bar menu behavior, providing left-click custom view and right-click context menu functionality.
@MainActor
final class StatusBarMenuManager: NSObject {
@ -21,18 +34,28 @@ final class StatusBarMenuManager: NSObject {
private var terminalLauncher: TerminalLauncher?
// Custom window management
private var customWindow: CustomMenuWindow?
fileprivate var customWindow: CustomMenuWindow?
private weak var statusBarButton: NSStatusBarButton?
private weak var currentStatusItem: NSStatusItem?
// State management
/// State management
private var menuState: MenuState = .none
private var highlightTask: Task<Void, Never>?
// Track new session state
@Published private var isNewSessionActive = false
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
override init() {
super.init()
// Subscribe to new session state changes to update window
$isNewSessionActive
.sink { [weak self] isActive in
self?.customWindow?.isNewSessionActive = isActive
}
.store(in: &cancellables)
}
// MARK: - Configuration
@ -58,9 +81,6 @@ final class StatusBarMenuManager: NSObject {
// MARK: - State Management
private func updateMenuState(_ newState: MenuState, button: NSStatusBarButton? = nil) {
// Cancel any pending highlight task
highlightTask?.cancel()
// Update state
menuState = newState
@ -95,62 +115,57 @@ final class StatusBarMenuManager: NSObject {
// Update menu state to custom window FIRST before any async operations
updateMenuState(.customWindow, button: button)
// Ensure button state is set immediately and persistently
button.state = .on
// Force another button state update to ensure it sticks
DispatchQueue.main.async {
button.state = .on
}
// Create SessionService instance
let sessionService = SessionService(serverManager: serverManager, sessionMonitor: sessionMonitor)
// Create the main view with all dependencies
let mainView = VibeTunnelMenuView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(tailscaleService)
.environment(terminalLauncher)
.environment(sessionService)
// Create the main view with all dependencies and binding
let mainView = VibeTunnelMenuView(isNewSessionActive: Binding(
get: { [weak self] in self?.isNewSessionActive ?? false },
set: { [weak self] in self?.isNewSessionActive = $0 }
))
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(tailscaleService)
.environment(terminalLauncher)
.environment(sessionService)
// Wrap in custom container for proper styling
let containerView = CustomMenuContainer {
mainView
}
// Create custom window if needed
if customWindow == nil {
customWindow = CustomMenuWindow(contentView: containerView)
// Hide and cleanup old window before creating new one
customWindow?.hide()
customWindow = nil
customWindow = CustomMenuWindow(contentView: containerView)
// Set up callback to reset state when window hides
customWindow?.onHide = { [weak self] in
// Ensure state is reset on main thread
Task { @MainActor in
self?.updateMenuState(.none)
}
}
} else {
// Hide and cleanup old window before creating new one
customWindow?.hide()
customWindow = nil
// Set up callback to reset state when window hides
customWindow?.onHide = { [weak self] in
self?.statusBarButton?.highlight(false)
// Create new window with updated content
customWindow = CustomMenuWindow(contentView: containerView)
customWindow?.onHide = { [weak self] in
Task { @MainActor in
self?.updateMenuState(.none)
}
// Ensure state is reset on main thread
Task { @MainActor in
self?.updateMenuState(.none)
}
}
// Sync the new session state with the window
if let window = customWindow {
window.isNewSessionActive = isNewSessionActive
}
// Show the custom window
customWindow?.show(relativeTo: button)
statusBarButton?.highlight(true)
}
func hideCustomWindow() {
customWindow?.hide()
if customWindow?.isWindowVisible ?? false {
customWindow?.hide()
}
// Reset new session state when hiding
isNewSessionActive = false
// Button state will be reset by updateMenuState(.none) in the onHide callback
}

View file

@ -22,6 +22,13 @@ struct VibeTunnelMenuView: View {
@State private var showingNewSession = false
@FocusState private var focusedField: FocusField?
/// Binding to allow external control of new session state
@Binding var isNewSessionActive: Bool
init(isNewSessionActive: Binding<Bool> = .constant(false)) {
self._isNewSessionActive = isNewSessionActive
}
enum FocusField: Hashable {
case sessionRow(String)
case settingsButton
@ -31,11 +38,17 @@ struct VibeTunnelMenuView: View {
var body: some View {
if showingNewSession {
NewSessionForm(isPresented: $showingNewSession)
.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
))
NewSessionForm(isPresented: Binding(
get: { showingNewSession },
set: { newValue in
showingNewSession = newValue
isNewSessionActive = newValue
}
))
.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
))
} else {
mainContent
.transition(.asymmetric(
@ -138,6 +151,7 @@ struct VibeTunnelMenuView: View {
Button(action: {
withAnimation(.easeOut(duration: 0.2)) {
showingNewSession = true
isNewSessionActive = true
}
}) {
Label("New Session", systemImage: "plus.square")
@ -331,7 +345,15 @@ struct ServerAddressRow: View {
.font(.system(size: 11))
.foregroundColor(.secondary)
Button(action: {
if let url = url ?? URL(string: "http://\(computedAddress)") {
if let providedUrl = url {
NSWorkspace.shared.open(providedUrl)
} else if computedAddress.starts(with: "127.0.0.1:") {
// For localhost, use DashboardURLBuilder
if let dashboardURL = DashboardURLBuilder.dashboardURL(port: serverManager.port) {
NSWorkspace.shared.open(dashboardURL)
}
} else if let url = URL(string: "http://\(computedAddress)") {
// For other addresses (network IP, etc.), construct URL directly
NSWorkspace.shared.open(url)
}
}) {
@ -789,7 +811,7 @@ struct SessionRow: View {
let elapsed = Date().timeIntervalSince(startDate)
if elapsed < 60 {
return "just now"
return "now"
} else if elapsed < 3_600 {
let minutes = Int(elapsed / 60)
return "\(minutes)m"

View file

@ -414,7 +414,7 @@ private struct ServerConfigurationSection: View {
.font(.caption)
.foregroundStyle(.secondary)
if let url = URL(string: "http://127.0.0.1:\(serverPort)") {
if let url = DashboardURLBuilder.dashboardURL(port: serverPort) {
Link(url.absoluteString, destination: url)
.font(.caption)
.foregroundStyle(.blue)

View file

@ -44,7 +44,7 @@ struct AccessDashboardPageView: View {
VStack(spacing: 12) {
// Open Dashboard button
Button(action: {
if let dashboardURL = URL(string: "http://127.0.0.1:\(serverPort)") {
if let dashboardURL = DashboardURLBuilder.dashboardURL(port: serverPort) {
NSWorkspace.shared.open(dashboardURL)
}
}, label: {

View file

@ -101,6 +101,13 @@ struct VibeTunnelApp: App {
/// Manages app lifecycle, single instance enforcement, and core services
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
// Needed for some gross menu item highlight hack
static weak var shared: AppDelegate?
override init() {
super.init()
Self.shared = self
}
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
var app: VibeTunnelApp?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate")

View file

@ -1,5 +1,11 @@
#!/usr/bin/env node
// Entry point for the server - imports the modular server which starts automatically
// Suppress xterm.js errors globally - must be before any other imports
import { suppressXtermErrors } from './shared/suppress-xterm-errors.js';
suppressXtermErrors();
import { startVibeTunnelForward } from './server/fwd.js';
import { startVibeTunnelServer } from './server/server.js';
import { closeLogger, createLogger, initLogger } from './server/utils/logger.js';

View file

@ -1,3 +1,8 @@
// Suppress xterm.js errors globally - must be before any other imports
import { suppressXtermErrors } from '../shared/suppress-xterm-errors.js';
suppressXtermErrors();
import { html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';

View file

@ -156,8 +156,8 @@ describe('FilePicker Component', () => {
removeAttribute: vi.fn(),
click: vi.fn(),
remove: vi.fn(),
};
element.fileInput = mockFileInput as any;
} as Pick<HTMLInputElement, 'removeAttribute' | 'click' | 'remove'>;
element.fileInput = mockFileInput as HTMLInputElement;
fileButton?.click();

View file

@ -0,0 +1,247 @@
import { css, html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
/**
* Inline Edit Component
*
* Provides inline editing functionality with a pencil icon that appears on hover.
* Supports keyboard shortcuts (Enter to save, Esc to cancel).
*
* @fires save - When edit is saved (detail: { value: string })
* @fires cancel - When edit is cancelled
*/
@customElement('inline-edit')
export class InlineEdit extends LitElement {
static override styles = css`
:host {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
}
.display-container {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
min-width: 0;
}
.display-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.edit-icon {
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
flex-shrink: 0;
width: 1em;
height: 1em;
}
:host(:hover) .edit-icon {
opacity: 0.5;
}
.edit-icon:hover {
opacity: 1 !important;
}
.edit-container {
display: inline-flex;
align-items: center;
gap: 0.25rem;
width: 100%;
}
input {
background: var(--dark-bg-tertiary, #1a1a1a);
border: 1px solid var(--dark-border, #333);
color: inherit;
font: inherit;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
outline: none;
width: 100%;
min-width: 0;
}
input:focus {
border-color: var(--accent-green, #10b981);
}
.action-buttons {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
button {
background: none;
border: none;
cursor: pointer;
padding: 0.125rem;
border-radius: 0.25rem;
color: var(--dark-text-muted, #999);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
}
button:hover {
background: var(--dark-bg-tertiary, #1a1a1a);
}
button.save {
color: var(--accent-green, #10b981);
}
button.save:hover {
background: var(--accent-green, #10b981);
background-opacity: 0.2;
}
button.cancel {
color: var(--status-error, #ef4444);
}
button.cancel:hover {
background: var(--status-error, #ef4444);
background-opacity: 0.2;
}
`;
@property({ type: String })
value = '';
@property({ type: String })
placeholder = '';
@property({ attribute: false })
onSave?: (value: string) => void;
@state()
private isEditing = false;
@state()
private editValue = '';
private inputElement?: HTMLInputElement;
override render() {
if (this.isEditing) {
return html`
<div class="edit-container">
<input
type="text"
.value=${this.editValue}
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
placeholder=${this.placeholder}
/>
<div class="action-buttons">
<button class="save" @click=${(e: Event) => {
e.stopPropagation();
this.handleSave();
}} title="Save (Enter)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</button>
<button class="cancel" @click=${(e: Event) => {
e.stopPropagation();
this.handleCancel();
}} title="Cancel (Esc)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
`;
}
return html`
<div class="display-container">
<span class="display-text" title=${this.value}>${this.value}</span>
<svg
class="edit-icon"
@click=${(e: Event) => {
e.stopPropagation();
this.startEdit();
}}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</div>
`;
}
override updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('isEditing') && this.isEditing) {
// Focus input on next frame
requestAnimationFrame(() => {
this.inputElement = this.shadowRoot?.querySelector('input') as HTMLInputElement;
if (this.inputElement) {
this.inputElement.focus();
this.inputElement.select();
}
});
}
}
private startEdit() {
this.editValue = this.value;
this.isEditing = true;
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.editValue = input.value;
}
private handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
this.handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
this.handleCancel();
}
}
private handleSave() {
const trimmedValue = this.editValue.trim();
if (trimmedValue && trimmedValue !== this.value) {
this.onSave?.(trimmedValue);
}
this.isEditing = false;
}
private handleCancel() {
this.isEditing = false;
this.editValue = '';
}
}
declare global {
interface HTMLElementTagNameMap {
'inline-edit': InlineEdit;
}
}

View file

@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { fixture, html } from '@open-wc/testing';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getTextContent, setupFetchMock } from '@/test/utils/component-helpers';
import { setupFetchMock } from '@/test/utils/component-helpers';
import { createMockSession } from '@/test/utils/lit-test-utils';
import { resetFactoryCounters } from '@/test/utils/test-factories';
import type { AuthClient } from '../services/auth-client';
@ -9,9 +9,10 @@ import type { AuthClient } from '../services/auth-client';
// Mock AuthClient
vi.mock('../services/auth-client');
// Mock copyToClipboard
// Mock copyToClipboard and formatPathForDisplay
vi.mock('../utils/path-utils', () => ({
copyToClipboard: vi.fn(() => Promise.resolve(true)),
formatPathForDisplay: vi.fn((path) => path), // Just return the path as-is for tests
}));
// Import component type
@ -28,6 +29,7 @@ describe('SessionCard', () => {
await import('./vibe-terminal-buffer');
await import('./copy-icon');
await import('./clickable-path');
await import('./inline-edit');
});
beforeEach(async () => {
@ -54,7 +56,7 @@ describe('SessionCard', () => {
});
afterEach(() => {
element.remove();
element?.remove();
fetchMock.clear();
vi.clearAllMocks();
});
@ -66,9 +68,17 @@ describe('SessionCard', () => {
expect(element.isActive).toBe(false);
});
it('should render session details', () => {
const sessionName = getTextContent(element, '.text-accent-green');
expect(sessionName).toBeTruthy();
it('should render session details', async () => {
// Wait for inline-edit to render
await element.updateComplete;
const inlineEdit = element.querySelector('inline-edit') as HTMLElement & { value: string };
expect(inlineEdit).toBeTruthy();
// Check that inline-edit has the correct value
const sessionText = inlineEdit?.value;
expect(sessionText).toBeTruthy();
expect(sessionText).toContain('Test Session');
// Should have status indicator
const statusText = element.textContent;
@ -91,15 +101,19 @@ describe('SessionCard', () => {
element.session = createMockSession({ name: 'Test Session' });
await element.updateComplete;
let displayText = getTextContent(element, '.text-accent-green');
expect(displayText).toContain('Test Session');
let inlineEdit = element.querySelector('inline-edit') as HTMLElement & { value: string };
expect(inlineEdit).toBeTruthy();
expect(inlineEdit.value).toContain('Test Session');
// Test without name (falls back to command)
element.session = { ...createMockSession({ name: '' }), command: ['npm', 'run', 'dev'] };
const sessionWithoutName = createMockSession({ command: ['npm', 'run', 'dev'] });
sessionWithoutName.name = ''; // Explicitly set to empty string
element.session = sessionWithoutName;
await element.updateComplete;
displayText = getTextContent(element, '.text-accent-green');
expect(displayText).toContain('npm run dev');
inlineEdit = element.querySelector('inline-edit') as HTMLElement & { value: string };
expect(inlineEdit).toBeTruthy();
expect(inlineEdit.value).toBe('npm run dev');
});
it('should show running status with success color', async () => {

View file

@ -21,6 +21,7 @@ const logger = createLogger('session-card');
import './vibe-terminal-buffer.js';
import './copy-icon.js';
import './clickable-path.js';
import './inline-edit.js';
@customElement('session-card')
export class SessionCard extends LitElement {
@ -194,6 +195,56 @@ export class SessionCard extends LitElement {
return frames[this.killingFrame % frames.length];
}
private async handleRename(newName: string) {
try {
const response = await fetch(`/api/sessions/${this.session.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify({ name: newName }),
});
if (!response.ok) {
const errorData = await response.text();
logger.error('Failed to rename session', { errorData, sessionId: this.session.id });
throw new Error(`Rename failed: ${response.status}`);
}
// Update the local session object
this.session = { ...this.session, name: newName };
// Dispatch event to notify parent components
this.dispatchEvent(
new CustomEvent('session-renamed', {
detail: {
sessionId: this.session.id,
newName: newName,
},
bubbles: true,
composed: true,
})
);
logger.log(`Session ${this.session.id} renamed to: ${newName}`);
} catch (error) {
logger.error('Error renaming session', { error, sessionId: this.session.id });
// Show error to user
this.dispatchEvent(
new CustomEvent('session-rename-error', {
detail: {
sessionId: this.session.id,
error: error instanceof Error ? error.message : 'Unknown error',
},
bubbles: true,
composed: true,
})
);
}
}
private async handlePidClick(e: Event) {
e.stopPropagation();
e.preventDefault();
@ -241,9 +292,18 @@ export class SessionCard extends LitElement {
class="flex justify-between items-center px-3 py-2 border-b border-dark-border bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary"
>
<div class="text-xs font-mono pr-2 flex-1 min-w-0 text-accent-green">
<div class="truncate" title="${this.session.name || this.session.command.join(' ')}">
${this.session.name || this.session.command.join(' ')}
</div>
<inline-edit
.value=${this.session.name || this.session.command?.join(' ') || ''}
.placeholder=${this.session.command?.join(' ') || ''}
.onSave=${async (newName: string) => {
try {
await this.handleRename(newName);
} catch (error) {
// Error is already handled in handleRename
logger.debug('Rename error caught in onSave', { error });
}
}}
></inline-edit>
</div>
${
this.session.status === 'running' || this.session.status === 'exited'

View file

@ -375,21 +375,21 @@ export class SessionCreateForm extends LitElement {
}
return html`
<div class="modal-backdrop flex items-center justify-center">
<div class="modal-backdrop flex items-center justify-center p-2 sm:p-4">
<div
class="modal-content font-mono text-sm w-full max-w-[calc(100vw-1rem)] sm:max-w-md lg:max-w-[576px] mx-2 sm:mx-4"
class="modal-content font-mono text-sm w-full max-w-[calc(100vw-1rem)] sm:max-w-md lg:max-w-[576px] max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-4rem)] flex flex-col"
style="view-transition-name: create-session-modal"
>
<div class="p-6 pb-4 mb-3 border-b border-dark-border relative bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary">
<h2 class="text-primary text-xl font-bold">New Session</h2>
<div class="p-4 sm:p-6 sm:pb-4 mb-2 sm:mb-3 border-b border-dark-border relative bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary flex-shrink-0">
<h2 class="text-primary text-lg sm:text-xl font-bold">New Session</h2>
<button
class="absolute top-6 right-6 text-dark-text-muted hover:text-dark-text transition-all duration-200 p-2 hover:bg-dark-bg-tertiary rounded-lg"
class="absolute top-4 right-4 sm:top-6 sm:right-6 text-dark-text-muted hover:text-dark-text transition-all duration-200 p-1.5 sm:p-2 hover:bg-dark-bg-tertiary rounded-lg"
@click=${this.handleCancel}
title="Close (Esc)"
aria-label="Close modal"
>
<svg
class="w-5 h-5"
class="w-4 h-4 sm:w-5 sm:h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -405,13 +405,13 @@ export class SessionCreateForm extends LitElement {
</button>
</div>
<div class="p-6">
<div class="p-4 sm:p-6 overflow-y-auto flex-grow">
<!-- Session Name -->
<div class="mb-5">
<label class="form-label text-dark-text-muted">Session Name (Optional):</label>
<div class="mb-3 sm:mb-5">
<label class="form-label text-dark-text-muted text-xs sm:text-sm">Session Name (Optional):</label>
<input
type="text"
class="input-field"
class="input-field py-2 sm:py-3 text-sm"
.value=${this.sessionName}
@input=${this.handleSessionNameChange}
placeholder="My Session"
@ -420,11 +420,11 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Command -->
<div class="mb-5">
<label class="form-label text-dark-text-muted">Command:</label>
<div class="mb-3 sm:mb-5">
<label class="form-label text-dark-text-muted text-xs sm:text-sm">Command:</label>
<input
type="text"
class="input-field"
class="input-field py-2 sm:py-3 text-sm"
.value=${this.command}
@input=${this.handleCommandChange}
placeholder="zsh"
@ -433,24 +433,24 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Working Directory -->
<div class="mb-5">
<label class="form-label text-dark-text-muted">Working Directory:</label>
<div class="mb-3 sm:mb-5">
<label class="form-label text-dark-text-muted text-xs sm:text-sm">Working Directory:</label>
<div class="flex gap-2">
<input
type="text"
class="input-field"
class="input-field py-2 sm:py-3 text-sm"
.value=${this.workingDir}
@input=${this.handleWorkingDirChange}
placeholder="~/"
?disabled=${this.disabled || this.isCreating}
/>
<button
class="bg-dark-bg-elevated border border-dark-border rounded-lg p-3 font-mono text-dark-text-muted transition-all duration-200 hover:text-primary hover:bg-dark-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0"
class="bg-dark-bg-elevated border border-dark-border rounded-lg p-2 sm:p-3 font-mono text-dark-text-muted transition-all duration-200 hover:text-primary hover:bg-dark-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0"
@click=${this.handleBrowse}
?disabled=${this.disabled || this.isCreating}
title="Browse directories"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<svg width="14" height="14" class="sm:w-4 sm:h-4" viewBox="0 0 16 16" fill="currentColor">
<path
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
/>
@ -460,22 +460,22 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Spawn Window Toggle -->
<div class="mb-5 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-4">
<div class="flex-1 pr-4">
<span class="text-dark-text text-sm font-medium">Spawn window</span>
<p class="text-xs text-dark-text-muted mt-0.5">Opens native terminal window</p>
<div class="mb-3 sm:mb-5 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-3 sm:p-4">
<div class="flex-1 pr-3 sm:pr-4">
<span class="text-dark-text text-xs sm:text-sm font-medium">Spawn window</span>
<p class="text-xs text-dark-text-muted mt-0.5 hidden sm:block">Opens native terminal window</p>
</div>
<button
role="switch"
aria-checked="${this.spawnWindow}"
@click=${this.handleSpawnWindowChange}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark-bg ${
class="relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark-bg ${
this.spawnWindow ? 'bg-primary' : 'bg-dark-border'
}"
?disabled=${this.disabled || this.isCreating}
>
<span
class="inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
class="inline-block h-4 w-4 sm:h-5 sm:w-5 transform rounded-full bg-white transition-transform ${
this.spawnWindow ? 'translate-x-5' : 'translate-x-0.5'
}"
></span>
@ -483,10 +483,10 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Terminal Title Mode -->
<div class="mb-6 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-4">
<div class="flex-1 pr-4">
<span class="text-dark-text text-sm font-medium">Terminal Title Mode</span>
<p class="text-xs text-dark-text-muted mt-0.5">
<div class="mb-4 sm:mb-6 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-3 sm:p-4">
<div class="flex-1 pr-3 sm:pr-4">
<span class="text-dark-text text-xs sm:text-sm font-medium">Terminal Title Mode</span>
<p class="text-xs text-dark-text-muted mt-0.5 hidden sm:block">
${this.getTitleModeDescription()}
</p>
</div>
@ -494,8 +494,8 @@ export class SessionCreateForm extends LitElement {
<select
.value=${this.titleMode}
@change=${this.handleTitleModeChange}
class="bg-dark-bg-secondary border border-dark-border rounded-lg px-3 py-2 pr-8 text-dark-text text-sm transition-all duration-200 hover:border-primary-hover focus:border-primary focus:outline-none appearance-none cursor-pointer"
style="min-width: 140px"
class="bg-dark-bg-secondary border border-dark-border rounded-lg px-2 py-1.5 pr-7 sm:px-3 sm:py-2 sm:pr-8 text-dark-text text-xs sm:text-sm transition-all duration-200 hover:border-primary-hover focus:border-primary focus:outline-none appearance-none cursor-pointer"
style="min-width: 100px"
?disabled=${this.disabled || this.isCreating}
>
<option value="${TitleMode.NONE}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.NONE}>None</option>
@ -503,8 +503,8 @@ export class SessionCreateForm extends LitElement {
<option value="${TitleMode.STATIC}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.STATIC}>Static</option>
<option value="${TitleMode.DYNAMIC}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.DYNAMIC}>Dynamic</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-dark-text-muted">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-1.5 sm:px-2 text-dark-text-muted">
<svg class="h-3 w-3 sm:h-4 sm:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
@ -512,41 +512,41 @@ export class SessionCreateForm extends LitElement {
</div>
<!-- Quick Start Section -->
<div class="mb-6">
<label class="form-label text-dark-text-muted uppercase text-xs tracking-wider mb-3"
<div class="mb-4 sm:mb-6">
<label class="form-label text-dark-text-muted uppercase text-xs tracking-wider mb-2 sm:mb-3"
>Quick Start</label
>
<div class="grid grid-cols-2 gap-3 mt-2">
<div class="grid grid-cols-2 gap-2 sm:gap-3 mt-2">
${this.quickStartCommands.map(
({ label, command }) => html`
<button
@click=${() => this.handleQuickStart(command)}
class="${
this.command === command
? 'px-4 py-3 rounded-lg border text-left transition-all bg-primary bg-opacity-10 border-primary text-primary hover:bg-opacity-20 font-medium'
: 'px-4 py-3 rounded-lg border text-left transition-all bg-dark-bg-elevated border-dark-border text-dark-text hover:bg-dark-surface-hover hover:border-primary hover:text-primary'
? 'px-2 py-2 sm:px-4 sm:py-3 rounded-lg border text-left transition-all bg-primary bg-opacity-10 border-primary text-primary hover:bg-opacity-20 font-medium text-xs sm:text-sm'
: 'px-2 py-2 sm:px-4 sm:py-3 rounded-lg border text-left transition-all bg-dark-bg-elevated border-dark-border text-dark-text hover:bg-dark-surface-hover hover:border-primary hover:text-primary text-xs sm:text-sm'
}"
?disabled=${this.disabled || this.isCreating}
>
${label === 'gemini' ? '✨ ' : ''}${label === 'claude' ? '✨ ' : ''}${
<span class="hidden sm:inline">${label === 'gemini' ? '✨ ' : ''}${label === 'claude' ? '✨ ' : ''}${
label === 'pnpm run dev' ? '▶️ ' : ''
}${label}
}</span>${label}
</button>
`
)}
</div>
</div>
<div class="flex gap-3 mt-6">
<div class="flex gap-2 sm:gap-3 mt-4 sm:mt-6">
<button
class="flex-1 bg-dark-bg-elevated border border-dark-border text-dark-text px-6 py-3 rounded-lg font-mono text-sm transition-all duration-200 hover:bg-dark-surface-hover hover:border-dark-border-light"
class="flex-1 bg-dark-bg-elevated border border-dark-border text-dark-text px-4 py-2 sm:px-6 sm:py-3 rounded-lg font-mono text-xs sm:text-sm transition-all duration-200 hover:bg-dark-surface-hover hover:border-dark-border-light"
@click=${this.handleCancel}
?disabled=${this.isCreating}
>
Cancel
</button>
<button
class="flex-1 bg-primary text-black px-6 py-3 rounded-lg font-mono text-sm font-medium transition-all duration-200 hover:bg-primary-hover hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed"
class="flex-1 bg-primary text-black px-4 py-2 sm:px-6 sm:py-3 rounded-lg font-mono text-xs sm:text-sm font-medium transition-all duration-200 hover:bg-primary-hover hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed"
@click=${this.handleCreate}
?disabled=${
this.disabled ||

View file

@ -22,6 +22,7 @@ import { repeat } from 'lit/directives/repeat.js';
import type { Session } from '../../shared/types.js';
import type { AuthClient } from '../services/auth-client.js';
import './session-card.js';
import './inline-edit.js';
import { formatSessionDuration } from '../../shared/utils/time.js';
import { createLogger } from '../utils/logger.js';
import { formatPathForDisplay } from '../utils/path-utils.js';
@ -88,6 +89,65 @@ export class SessionList extends LitElement {
);
}
private async handleRename(sessionId: string, newName: string) {
try {
const response = await fetch(`/api/sessions/${sessionId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...this.authClient.getAuthHeader(),
},
body: JSON.stringify({ name: newName }),
});
if (!response.ok) {
const errorData = await response.text();
logger.error('Failed to rename session', { errorData, sessionId });
throw new Error(`Rename failed: ${response.status}`);
}
// Update the local session object
const sessionIndex = this.sessions.findIndex((s) => s.id === sessionId);
if (sessionIndex >= 0) {
this.sessions[sessionIndex] = { ...this.sessions[sessionIndex], name: newName };
this.requestUpdate();
}
logger.log(`Session ${sessionId} renamed to: ${newName}`);
} catch (error) {
logger.error('Error renaming session', { error, sessionId });
// Show error to user
this.dispatchEvent(
new CustomEvent('error', {
detail: `Failed to rename session: ${error instanceof Error ? error.message : 'Unknown error'}`,
})
);
}
}
private handleSessionRenamed = (e: CustomEvent) => {
const { sessionId, newName } = e.detail;
// Update the local session object
const sessionIndex = this.sessions.findIndex((s) => s.id === sessionId);
if (sessionIndex >= 0) {
this.sessions[sessionIndex] = { ...this.sessions[sessionIndex], name: newName };
this.requestUpdate();
}
};
private handleSessionRenameError = (e: CustomEvent) => {
const { sessionId, error } = e.detail;
logger.error(`failed to rename session ${sessionId}:`, error);
// Dispatch error event to parent for user notification
this.dispatchEvent(
new CustomEvent('error', {
detail: `Failed to rename session: ${error}`,
})
);
};
public async handleCleanupExited() {
if (this.cleaningExited) return;
@ -301,19 +361,21 @@ export class SessionList extends LitElement {
? 'text-accent-primary font-medium'
: 'text-dark-text group-hover:text-accent-primary transition-colors'
}"
title="${
session.name ||
(Array.isArray(session.command)
? session.command.join(' ')
: session.command)
}"
>
${
session.name ||
(Array.isArray(session.command)
? session.command.join(' ')
: session.command)
}
<inline-edit
.value=${
session.name ||
(Array.isArray(session.command)
? session.command.join(' ')
: session.command)
}
.placeholder=${
Array.isArray(session.command)
? session.command.join(' ')
: session.command
}
.onSave=${(newName: string) => this.handleRename(session.id, newName)}
></inline-edit>
</div>
<div class="text-xs text-dark-text-muted truncate flex items-center gap-1">
${(() => {
@ -417,6 +479,8 @@ export class SessionList extends LitElement {
@session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError}
@session-renamed=${this.handleSessionRenamed}
@session-rename-error=${this.handleSessionRenameError}
>
</session-card>
`
@ -546,6 +610,8 @@ export class SessionList extends LitElement {
@session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError}
@session-renamed=${this.handleSessionRenamed}
@session-rename-error=${this.handleSessionRenameError}
>
</session-card>
`
@ -574,7 +640,7 @@ export class SessionList extends LitElement {
if (exitedSessions.length === 0 && runningSessions.length === 0) return '';
return html`
<div class="sticky bottom-0 border-t border-dark-border bg-dark-bg-secondary p-3 flex flex-wrap gap-2 shadow-lg">
<div class="sticky bottom-0 border-t border-dark-border bg-dark-bg-secondary p-3 flex flex-wrap gap-2 shadow-lg z-10">
<!-- Control buttons with consistent styling -->
${
exitedSessions.length > 0

View file

@ -55,10 +55,10 @@ describe('SessionView Drag & Drop and Paste', () => {
name: 'Test Session',
command: ['bash'],
workingDir: '/test',
status: 'running',
status: 'running' as const,
startedAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
} as any;
};
});
afterEach(() => {

View file

@ -26,6 +26,7 @@ import './session-view/mobile-input-overlay.js';
import './session-view/ctrl-alpha-overlay.js';
import './session-view/width-selector.js';
import './session-view/session-header.js';
import { authClient } from '../services/auth-client.js';
import { createLogger } from '../utils/logger.js';
import {
COMMON_TERMINAL_WIDTHS,
@ -803,6 +804,51 @@ export class SessionView extends LitElement {
this.dispatchEvent(new CustomEvent('error', { detail: error }));
}
private async handleRename(event: CustomEvent) {
const { sessionId, newName } = event.detail;
if (!this.session || sessionId !== this.session.id) return;
try {
const response = await fetch(`/api/sessions/${sessionId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...authClient.getAuthHeader(),
},
body: JSON.stringify({ name: newName }),
});
if (!response.ok) {
const errorData = await response.text();
logger.error('Failed to rename session', { errorData, sessionId });
throw new Error(`Rename failed: ${response.status}`);
}
// Update the local session object
this.session = { ...this.session, name: newName };
// Dispatch event to notify parent components
this.dispatchEvent(
new CustomEvent('session-renamed', {
detail: { sessionId, newName },
bubbles: true,
composed: true,
})
);
logger.log(`Session ${sessionId} renamed to: ${newName}`);
} catch (error) {
logger.error('Error renaming session', { error, sessionId });
// Show error to user
this.dispatchEvent(
new CustomEvent('error', {
detail: `Failed to rename session: ${error instanceof Error ? error.message : 'Unknown error'}`,
})
);
}
}
// Drag & Drop handlers
private handleDragOver(e: DragEvent) {
e.preventDefault();
@ -1071,6 +1117,7 @@ export class SessionView extends LitElement {
this.showWidthSelector = false;
this.customWidth = '';
}}
@session-rename=${(e: CustomEvent) => this.handleRename(e)}
></session-header>
<!-- Enhanced Terminal Container -->

View file

@ -248,7 +248,8 @@ export class InputManager {
target.contentEditable === 'true' ||
target.closest('.monaco-editor') ||
target.closest('[data-keybinding-context]') ||
target.closest('.editor-container')
target.closest('.editor-container') ||
target.closest('inline-edit') // Allow typing in inline-edit component
) {
// Allow normal input in form fields and editors
return false;

View file

@ -81,6 +81,16 @@ export class LifecycleEventManager extends ManagerEventEmitter {
if (!this.session) return;
// Check if we're in an inline-edit component FIRST
// Since inline-edit uses Shadow DOM, we need to check the composed path
const composedPath = e.composedPath();
for (const element of composedPath) {
if (element instanceof HTMLElement && element.tagName?.toLowerCase() === 'inline-edit') {
// Allow the event to pass through to the inline-edit component
return;
}
}
// Check if this is a browser shortcut we should allow
const inputManager = this.callbacks.getInputManager();
if (inputManager?.isKeyboardShortcut(e)) {

View file

@ -9,6 +9,7 @@ import { customElement, property } from 'lit/decorators.js';
import type { Session } from '../session-list.js';
import '../clickable-path.js';
import './width-selector.js';
import '../inline-edit.js';
@customElement('session-header')
export class SessionHeader extends LitElement {
@ -127,21 +128,21 @@ export class SessionHeader extends LitElement {
: ''
}
<div class="text-dark-text min-w-0 flex-1 overflow-hidden max-w-[50vw] sm:max-w-none">
<div
class="text-dark-text-bright font-medium text-xs sm:text-sm overflow-hidden text-ellipsis whitespace-nowrap"
title="${
this.session.name ||
(Array.isArray(this.session.command)
? this.session.command.join(' ')
: this.session.command)
}"
>
${
this.session.name ||
(Array.isArray(this.session.command)
? this.session.command.join(' ')
: this.session.command)
}
<div class="text-dark-text-bright font-medium text-xs sm:text-sm overflow-hidden text-ellipsis whitespace-nowrap">
<inline-edit
.value=${
this.session.name ||
(Array.isArray(this.session.command)
? this.session.command.join(' ')
: this.session.command)
}
.placeholder=${
Array.isArray(this.session.command)
? this.session.command.join(' ')
: this.session.command
}
.onSave=${(newName: string) => this.handleRename(newName)}
></inline-edit>
</div>
<div class="text-xs opacity-75 mt-0.5 overflow-hidden">
<clickable-path
@ -220,4 +221,20 @@ export class SessionHeader extends LitElement {
</div>
`;
}
private handleRename(newName: string) {
if (!this.session) return;
// Dispatch event to parent component to handle the rename
this.dispatchEvent(
new CustomEvent('session-rename', {
detail: {
sessionId: this.session.id,
newName: newName,
},
bubbles: true,
composed: true,
})
);
}
}

View file

@ -12,9 +12,10 @@
*/
import chalk from 'chalk';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { TitleMode } from '../shared/types.js';
import { type SessionInfo, TitleMode } from '../shared/types.js';
import { PtyManager } from './pty/index.js';
import { SessionManager } from './pty/session-manager.js';
import { VibeTunnelSocketClient } from './pty/socket-client.js';
@ -22,6 +23,7 @@ import { ActivityDetector } from './utils/activity-detector.js';
import { checkAndPatchClaude } from './utils/claude-patcher.js';
import { closeLogger, createLogger } from './utils/logger.js';
import { generateSessionName } from './utils/session-naming.js';
import { generateTitleSequence } from './utils/terminal-title.js';
import { BUILD_DATE, GIT_COMMIT, VERSION } from './version.js';
const logger = createLogger('fwd');
@ -174,11 +176,48 @@ export async function startVibeTunnelForward(args: string[]) {
})
.join('');
// Update the title
// Update the title via IPC if session is active
const socketPath = path.join(controlPath, sessionId, 'ipc.sock');
// Check if IPC socket exists (session is active)
if (fs.existsSync(socketPath)) {
logger.debug(`IPC socket found, sending title update via IPC`);
// Connect to IPC socket and send update-title command
const socketClient = new VibeTunnelSocketClient(socketPath, {
autoReconnect: false, // One-shot operation
});
try {
await socketClient.connect();
// Send update-title command
const sent = socketClient.updateTitle(sanitizedTitle);
if (sent) {
logger.log(`Session title updated via IPC to: ${sanitizedTitle}`);
// IPC update succeeded, server will handle the file update
socketClient.disconnect();
closeLogger();
process.exit(0);
} else {
logger.warn(`Failed to send title update via IPC, falling back to file update`);
}
// Disconnect after sending
socketClient.disconnect();
} catch (ipcError) {
logger.warn(`IPC connection failed: ${ipcError}, falling back to file update`);
}
} else {
logger.debug(`No IPC socket found, session might not be active`);
}
// Only update the file if IPC failed or socket doesn't exist
sessionInfo.name = sanitizedTitle;
sessionManager.saveSessionInfo(sessionId, sessionInfo);
logger.log(`Session title updated to: ${sanitizedTitle}`);
logger.log(`Session title persisted to file: ${sanitizedTitle}`);
closeLogger();
process.exit(0);
} catch (error) {
@ -274,6 +313,10 @@ export async function startVibeTunnelForward(args: string[]) {
logger.log(chalk.cyan(`${modeDescriptions[titleMode]}`));
}
// Variables that need to be accessible in cleanup
let sessionFileWatcher: fs.FSWatcher | undefined;
let fileWatchDebounceTimer: NodeJS.Timeout | undefined;
const sessionOptions: Parameters<typeof ptyManager.createSession>[1] = {
sessionId: finalSessionId,
name: sessionName,
@ -307,6 +350,18 @@ export async function startVibeTunnelForward(args: string[]) {
cleanupStdout();
}
// Clean up file watchers
if (sessionFileWatcher) {
sessionFileWatcher.close();
sessionFileWatcher = undefined;
logger.debug('Closed session file watcher');
}
if (fileWatchDebounceTimer) {
clearTimeout(fileWatchDebounceTimer);
}
// Stop watching the file
fs.unwatchFile(sessionJsonPath);
// Shutdown PTY manager and exit
logger.debug('Shutting down PTY manager');
await ptyManager.shutdown();
@ -367,6 +422,119 @@ export async function startVibeTunnelForward(args: string[]) {
// Listen for terminal resize events
process.stdout.on('resize', resizeHandler);
// Set up file watcher for session.json changes (for external updates)
const sessionJsonPath = path.join(controlPath, result.sessionId, 'session.json');
let lastKnownSessionName = result.sessionInfo.name;
// Set up file watcher with retry logic
const setupFileWatcher = async (retryCount = 0) => {
const maxRetries = 5;
const retryDelay = 500 * 2 ** retryCount; // Exponential backoff
try {
// Check if file exists
if (!fs.existsSync(sessionJsonPath)) {
if (retryCount < maxRetries) {
logger.debug(
`Session file not found, retrying in ${retryDelay}ms (attempt ${retryCount + 1}/${maxRetries})`
);
setTimeout(() => setupFileWatcher(retryCount + 1), retryDelay);
return;
} else {
logger.warn(`Session file not found after ${maxRetries} attempts: ${sessionJsonPath}`);
return;
}
}
logger.log(`Setting up file watcher for session name changes`);
// Function to check and update title if session name changed
const checkSessionNameChange = () => {
try {
// Check file still exists before reading
if (!fs.existsSync(sessionJsonPath)) {
return;
}
const sessionContent = fs.readFileSync(sessionJsonPath, 'utf-8');
const updatedInfo = JSON.parse(sessionContent) as SessionInfo;
// Check if session name changed
if (updatedInfo.name !== lastKnownSessionName) {
logger.debug(
`[File Watch] Session name changed from "${lastKnownSessionName}" to "${updatedInfo.name}"`
);
lastKnownSessionName = updatedInfo.name;
// Always update terminal title when session name changes
// Generate new title sequence based on title mode
let titleSequence: string;
if (titleMode === TitleMode.NONE || titleMode === TitleMode.FILTER) {
// For NONE and FILTER modes, just use the session name
titleSequence = `\x1B]2;${updatedInfo.name}\x07`;
} else {
// For STATIC and DYNAMIC, use the full format with path and command
titleSequence = generateTitleSequence(cwd, command, updatedInfo.name);
}
// Write title sequence to terminal
process.stdout.write(titleSequence);
logger.log(`Updated terminal title to "${updatedInfo.name}" via file watcher`);
}
} catch (error) {
logger.error('Failed to check session.json:', error);
}
};
// Use fs.watchFile for more reliable file monitoring (polling-based)
fs.watchFile(sessionJsonPath, { interval: 500 }, (curr, prev) => {
logger.debug(`[File Watch] File stats changed - mtime: ${curr.mtime} vs ${prev.mtime}`);
if (curr.mtime !== prev.mtime) {
checkSessionNameChange();
}
});
// Also use fs.watch as a fallback for immediate notifications
try {
const sessionDir = path.dirname(sessionJsonPath);
sessionFileWatcher = fs.watch(sessionDir, (eventType, filename) => {
// Only log in debug mode to avoid noise
logger.debug(`[File Watch] Directory event: ${eventType} on ${filename || 'unknown'}`);
// Check if it's our file
// On macOS, filename might be undefined, so we can't filter properly
// In that case, skip fs.watch events and rely on fs.watchFile instead
if (filename && (filename === 'session.json' || filename === 'session.json.tmp')) {
// Debounce rapid changes
if (fileWatchDebounceTimer) {
clearTimeout(fileWatchDebounceTimer);
}
fileWatchDebounceTimer = setTimeout(checkSessionNameChange, 100);
}
});
} catch (error) {
logger.warn('Failed to set up fs.watch, relying on fs.watchFile:', error);
}
logger.log(`File watcher successfully set up with polling fallback`);
// Clean up watcher on error if it was created
sessionFileWatcher?.on('error', (error) => {
logger.error('File watcher error:', error);
sessionFileWatcher?.close();
sessionFileWatcher = undefined;
});
} catch (error) {
logger.error('Failed to set up file watcher:', error);
if (retryCount < maxRetries) {
setTimeout(() => setupFileWatcher(retryCount + 1), retryDelay);
}
}
};
// Start setting up the file watcher after a short delay
setTimeout(() => setupFileWatcher(), 500);
// Set up activity detector for Claude status updates
let activityDetector: ActivityDetector | undefined;
let cleanupStdout: (() => void) | undefined;

View file

@ -69,7 +69,7 @@ export class PtyManager extends EventEmitter {
string,
{ cols: number; rows: number; source: 'browser' | 'terminal'; timestamp: number }
>();
private sessionEventListeners = new Map<string, Set<(...args: any[]) => void>>();
private sessionEventListeners = new Map<string, Set<(...args: unknown[]) => void>>();
private lastBellTime = new Map<string, number>(); // Track last bell time per session
private sessionExitTimes = new Map<string, number>(); // Track session exit times to avoid false bells
private processTreeAnalyzer = new ProcessTreeAnalyzer(); // Process tree analysis for bell source identification
@ -386,9 +386,6 @@ export class PtyManager extends EventEmitter {
// Note: stdin forwarding is now handled via IPC socket
// Setup session.json watcher for title updates (vt title command) if needed
this.ensureSessionJsonWatcher(session);
// Initial title will be set when the first output is received
// Do not write title sequence to PTY input as it would be sent to the shell
@ -452,10 +449,41 @@ export class PtyManager extends EventEmitter {
session.titleMode !== TitleMode.FILTER &&
forwardToStdout
) {
// Track last known activity state for change detection
let lastKnownActivityState: {
isActive: boolean;
specificStatus?: string;
} | null = null;
session.titleUpdateInterval = setInterval(() => {
// Update activity state file if needed (dynamic mode only)
// For dynamic mode, check for activity state changes
if (session.titleMode === TitleMode.DYNAMIC && session.activityDetector) {
const activityState = session.activityDetector.getActivityState();
// Check if activity state has changed
const activityChanged =
lastKnownActivityState === null ||
activityState.isActive !== lastKnownActivityState.isActive ||
activityState.specificStatus?.status !== lastKnownActivityState.specificStatus;
if (activityChanged) {
// Update last known state
lastKnownActivityState = {
isActive: activityState.isActive,
specificStatus: activityState.specificStatus?.status,
};
// Mark title for update
this.markTitleUpdateNeeded(session);
logger.debug(
`Activity state changed for session ${session.id}: ` +
`active=${activityState.isActive}, ` +
`status=${activityState.specificStatus?.status || 'none'}`
);
}
// Always write activity state for external tools
this.writeActivityState(session, activityState);
}
@ -482,7 +510,7 @@ export class PtyManager extends EventEmitter {
// Check if activity status changed
if (activity.specificStatus?.status !== session.lastActivityStatus) {
session.lastActivityStatus = activity.specificStatus?.status;
session.titleUpdateNeeded = true;
this.markTitleUpdateNeeded(session);
}
}
@ -490,7 +518,7 @@ export class PtyManager extends EventEmitter {
if (session.titleMode === TitleMode.STATIC && forwardToStdout) {
// Check if we should update title based on data content
if (!session.initialTitleSent || shouldInjectTitle(processedData)) {
session.titleUpdateNeeded = true;
this.markTitleUpdateNeeded(session);
if (!session.initialTitleSent) {
session.initialTitleSent = true;
}
@ -571,7 +599,7 @@ export class PtyManager extends EventEmitter {
forwardToStdout &&
(session.titleMode === TitleMode.STATIC || session.titleMode === TitleMode.DYNAMIC)
) {
session.titleUpdateNeeded = true;
this.markTitleUpdateNeeded(session);
session.initialTitleSent = true;
logger.debug(`Marked initial title update for session ${session.id}`);
}
@ -690,106 +718,6 @@ export class PtyManager extends EventEmitter {
}
}
/**
* Ensure session.json watcher is initialized when needed
*/
private ensureSessionJsonWatcher(session: PtySession): void {
if (
!session.sessionJsonWatcher &&
(session.titleMode === TitleMode.STATIC || session.titleMode === TitleMode.DYNAMIC)
) {
this.setupSessionJsonWatcher(session);
}
}
/**
* Setup watcher for session.json changes (for vt title updates)
*/
private setupSessionJsonWatcher(session: PtySession): void {
try {
const { sessionJsonPath } = session;
let debounceTimer: NodeJS.Timeout | null = null;
// Watch for changes to session.json
const watcher = fs.watch(sessionJsonPath, (eventType) => {
if (eventType === 'change') {
// Debounce file changes to avoid multiple rapid updates
if (debounceTimer) {
clearTimeout(debounceTimer);
}
const timer = setTimeout(() => {
this.handleSessionJsonChange(session);
// Clear both timer references after execution
session.sessionJsonDebounceTimer = null;
debounceTimer = null;
}, 100);
// Update both timer references
session.sessionJsonDebounceTimer = timer;
debounceTimer = timer;
}
});
// Store watcher for cleanup BEFORE setting up error handler
session.sessionJsonWatcher = watcher;
// Add error handling for watcher
watcher.on('error', (error) => {
logger.error(`Session.json watcher failed for ${session.id}:`, error);
this.emit('watcherError', session.id, error);
// Clean up the failed watcher
if (session.sessionJsonWatcher) {
session.sessionJsonWatcher.close();
session.sessionJsonWatcher = undefined;
}
});
// Unref the watcher so it doesn't keep the process alive
watcher.unref();
logger.debug(`Session.json watcher setup for session ${session.id}`);
} catch (error) {
logger.warn(`Failed to setup session.json watcher for session ${session.id}:`, error);
this.emit('watcherError', session.id, error);
}
}
/**
* Handle session.json file changes (debounced)
*/
private handleSessionJsonChange(session: PtySession): void {
try {
// Reload session info
const newSessionInfo = this.sessionManager.loadSessionInfo(session.id);
if (!newSessionInfo) return;
// Check if name changed
if (newSessionInfo.name !== session.sessionInfo.name) {
logger.log(
chalk.cyan(
`Session ${session.id} name changed: "${session.sessionInfo.name}" → "${newSessionInfo.name}"`
)
);
// Update in-memory session info
session.sessionInfo.name = newSessionInfo.name;
// Mark title for update
if (session.titleMode !== TitleMode.NONE) {
session.titleUpdateNeeded = true;
}
// Emit event for clients
this.trackAndEmit('sessionNameChanged', session.id, newSessionInfo.name);
}
} catch (error) {
logger.warn(`Failed to handle session.json change for session ${session.id}:`, error);
this.emit('watcherError', session.id, error);
}
}
/**
* Handle control messages from control pipe
*/
@ -835,6 +763,11 @@ export class PtyManager extends EventEmitter {
} catch (error) {
logger.warn(`Failed to reset session ${session.id} size to terminal size:`, error);
}
} else if (message.cmd === 'update-title' && typeof message.title === 'string') {
// Handle title update via IPC (used by vt title command)
logger.debug(`[IPC] Received title update for session ${session.id}: "${message.title}"`);
logger.debug(`[IPC] Current session name before update: "${session.sessionInfo.name}"`);
this.updateSessionName(session.id, message.title);
}
}
@ -876,7 +809,7 @@ export class PtyManager extends EventEmitter {
);
if (newDir) {
memorySession.currentWorkingDir = newDir;
memorySession.titleUpdateNeeded = true;
this.markTitleUpdateNeeded(memorySession);
logger.debug(`Session ${sessionId} changed directory to: ${newDir}`);
}
}
@ -1098,12 +1031,40 @@ export class PtyManager extends EventEmitter {
// Update in-memory session if it exists
const memorySession = this.sessions.get(sessionId);
if (memorySession?.sessionInfo) {
logger.debug(`[PtyManager] Updating in-memory session info`);
logger.debug(`[PtyManager] Found in-memory session, updating...`);
const oldName = memorySession.sessionInfo.name;
memorySession.sessionInfo.name = name;
logger.debug(`[PtyManager] Session info after update:`, {
sessionId: memorySession.id,
newName: memorySession.sessionInfo.name,
oldCurrentTitle: `${memorySession.currentTitle?.substring(0, 50)}...`,
});
// Force immediate title update for active sessions
// For session name changes, always update title regardless of mode
if (memorySession.isExternalTerminal && memorySession.stdoutQueue) {
logger.debug(`[PtyManager] Forcing immediate title update for session ${sessionId}`, {
titleMode: memorySession.titleMode,
hadCurrentTitle: !!memorySession.currentTitle,
titleUpdateNeeded: memorySession.titleUpdateNeeded,
});
// Clear current title to force regeneration
memorySession.currentTitle = undefined;
this.updateTerminalTitleForSessionName(memorySession);
}
logger.log(`[PtyManager] Updated session ${sessionId} name from "${oldName}" to "${name}"`);
} else {
logger.debug(`[PtyManager] No in-memory session found for ${sessionId}`);
logger.debug(`[PtyManager] No in-memory session found for ${sessionId}`, {
sessionsMapSize: this.sessions.size,
sessionIds: Array.from(this.sessions.keys()),
});
}
// Emit event for clients to refresh their session data
this.trackAndEmit('sessionNameChanged', sessionId, name);
logger.log(`[PtyManager] Updated session ${sessionId} name to: ${name}`);
}
@ -1422,6 +1383,7 @@ export class PtyManager extends EventEmitter {
if (!sessionPaths) {
return session;
}
const activityPath = path.join(sessionPaths.controlDir, 'claude-activity.json');
if (fs.existsSync(activityPath)) {
@ -1728,8 +1690,8 @@ export class PtyManager extends EventEmitter {
/**
* Track and emit events for proper cleanup
*/
private trackAndEmit(event: string, sessionId: string, ...args: any[]): void {
const listeners = this.listeners(event) as ((...args: any[]) => void)[];
private trackAndEmit(event: string, sessionId: string, ...args: unknown[]): void {
const listeners = this.listeners(event) as ((...args: unknown[]) => void)[];
if (!this.sessionEventListeners.has(sessionId)) {
this.sessionEventListeners.set(sessionId, new Set());
}
@ -1779,15 +1741,6 @@ export class PtyManager extends EventEmitter {
}
}
// Close session.json watcher and clear debounce timer
if (session.sessionJsonDebounceTimer) {
clearTimeout(session.sessionJsonDebounceTimer);
session.sessionJsonDebounceTimer = null;
}
if (session.sessionJsonWatcher) {
session.sessionJsonWatcher.close();
}
// Note: stdin handling is done via IPC socket, no global listeners to clean up
// Remove all event listeners for this session
@ -1812,26 +1765,114 @@ export class PtyManager extends EventEmitter {
}
/**
* Check if title needs updating and write if changed
* Mark session for title update and trigger immediate check
*/
private checkAndUpdateTitle(session: PtySession): void {
if (!session.titleUpdateNeeded || !session.stdoutQueue || !session.isExternalTerminal) {
private markTitleUpdateNeeded(session: PtySession): void {
logger.debug(`[markTitleUpdateNeeded] Called for session ${session.id}`, {
titleMode: session.titleMode,
sessionName: session.sessionInfo.name,
titleUpdateNeeded: session.titleUpdateNeeded,
});
if (!session.titleMode || session.titleMode === TitleMode.NONE) {
logger.debug(`[markTitleUpdateNeeded] Skipping - title mode is NONE or undefined`);
return;
}
// Generate new title
const newTitle = this.generateTerminalTitle(session);
session.titleUpdateNeeded = true;
logger.debug(`[markTitleUpdateNeeded] Set titleUpdateNeeded=true, calling checkAndUpdateTitle`);
this.checkAndUpdateTitle(session);
}
/**
* Update terminal title specifically for session name changes
* This bypasses title mode checks to ensure name changes are always reflected
*/
private updateTerminalTitleForSessionName(session: PtySession): void {
if (!session.stdoutQueue || !session.isExternalTerminal) {
logger.debug(
`[updateTerminalTitleForSessionName] Early return - no stdout queue or not external terminal`
);
return;
}
// For NONE mode, just use the session name
// For other modes, regenerate the title with the new name
let newTitle: string | null = null;
if (
!session.titleMode ||
session.titleMode === TitleMode.NONE ||
session.titleMode === TitleMode.FILTER
) {
// In NONE or FILTER mode, use simple session name
newTitle = generateTitleSequence(
session.currentWorkingDir || session.sessionInfo.workingDir,
session.sessionInfo.command,
session.sessionInfo.name || 'VibeTunnel'
);
} else {
// For STATIC and DYNAMIC modes, use the standard generation logic
newTitle = this.generateTerminalTitle(session);
}
// Only proceed if title changed
if (newTitle && newTitle !== session.currentTitle) {
// Store pending title
logger.debug(`[updateTerminalTitleForSessionName] Updating title for session name change`);
session.pendingTitleToInject = newTitle;
session.titleUpdateNeeded = true;
// Start injection monitor if not already running
if (!session.titleInjectionTimer) {
this.startTitleInjectionMonitor(session);
}
}
}
/**
* Check if title needs updating and write if changed
*/
private checkAndUpdateTitle(session: PtySession): void {
logger.debug(`[checkAndUpdateTitle] Called for session ${session.id}`, {
titleUpdateNeeded: session.titleUpdateNeeded,
hasStdoutQueue: !!session.stdoutQueue,
isExternalTerminal: session.isExternalTerminal,
sessionName: session.sessionInfo.name,
});
if (!session.titleUpdateNeeded || !session.stdoutQueue || !session.isExternalTerminal) {
logger.debug(`[checkAndUpdateTitle] Early return - conditions not met`);
return;
}
// Generate new title
logger.debug(`[checkAndUpdateTitle] Generating new title...`);
const newTitle = this.generateTerminalTitle(session);
// Debug logging for title updates
logger.debug(`[Title Update] Session ${session.id}:`, {
sessionName: session.sessionInfo.name,
newTitle: newTitle ? `${newTitle.substring(0, 50)}...` : null,
currentTitle: session.currentTitle ? `${session.currentTitle.substring(0, 50)}...` : null,
titleChanged: newTitle !== session.currentTitle,
});
// Only proceed if title changed
if (newTitle && newTitle !== session.currentTitle) {
logger.debug(`[checkAndUpdateTitle] Title changed, queueing for injection`);
// Store pending title
session.pendingTitleToInject = newTitle;
// Start injection monitor if not already running
if (!session.titleInjectionTimer) {
logger.debug(`[checkAndUpdateTitle] Starting title injection monitor`);
this.startTitleInjectionMonitor(session);
}
} else {
logger.debug(`[checkAndUpdateTitle] Title unchanged or null, skipping injection`, {
newTitleNull: !newTitle,
titlesEqual: newTitle === session.currentTitle,
});
}
// Clear flag
session.titleUpdateNeeded = false;
@ -1874,6 +1915,10 @@ export class PtyManager extends EventEmitter {
session.stdoutQueue.enqueue(async () => {
try {
logger.debug(`[Title Injection] Writing title to stdout for session ${session.id}:`, {
title: `${titleToInject.substring(0, 50)}...`,
});
const canWrite = process.stdout.write(titleToInject);
if (!canWrite) {
@ -1883,6 +1928,8 @@ export class PtyManager extends EventEmitter {
// Update tracking after successful write
session.currentTitle = titleToInject;
logger.debug(`[Title Injection] Successfully injected title for session ${session.id}`);
// Clear pending title only after successful write
if (session.pendingTitleToInject === titleToInject) {
session.pendingTitleToInject = undefined;
@ -1916,6 +1963,15 @@ export class PtyManager extends EventEmitter {
const currentDir = session.currentWorkingDir || session.sessionInfo.workingDir;
logger.debug(`[generateTerminalTitle] Session ${session.id}:`, {
titleMode: session.titleMode,
sessionName: session.sessionInfo.name,
sessionInfoObjectId: session.sessionInfo,
currentDir,
command: session.sessionInfo.command,
activityDetectorExists: !!session.activityDetector,
});
if (session.titleMode === TitleMode.STATIC) {
return generateTitleSequence(
currentDir,
@ -1924,6 +1980,12 @@ export class PtyManager extends EventEmitter {
);
} else if (session.titleMode === TitleMode.DYNAMIC && session.activityDetector) {
const activity = session.activityDetector.getActivityState();
logger.debug(`[generateTerminalTitle] Calling generateDynamicTitle with:`, {
currentDir,
command: session.sessionInfo.command,
sessionName: session.sessionInfo.name,
activity: activity,
});
return generateDynamicTitle(
currentDir,
session.sessionInfo.command,

View file

@ -112,15 +112,40 @@ export class SessionManager {
saveSessionInfo(sessionId: string, sessionInfo: SessionInfo): void {
this.validateSessionId(sessionId);
try {
const sessionDir = path.join(this.controlPath, sessionId);
const sessionJsonPath = path.join(sessionDir, 'session.json');
const tempPath = `${sessionJsonPath}.tmp`;
// Ensure session directory exists before writing
if (!fs.existsSync(sessionDir)) {
logger.warn(`Session directory ${sessionDir} does not exist, creating it`);
fs.mkdirSync(sessionDir, { recursive: true });
}
const sessionInfoStr = JSON.stringify(sessionInfo, null, 2);
// Write to temporary file first, then move to final location (atomic write)
const sessionJsonPath = path.join(this.controlPath, sessionId, 'session.json');
const tempPath = `${sessionJsonPath}.tmp`;
fs.writeFileSync(tempPath, sessionInfoStr, 'utf8');
// Double-check directory still exists before rename (handle race conditions)
if (!fs.existsSync(sessionDir)) {
logger.error(`Session directory ${sessionDir} was deleted during save operation`);
// Clean up temp file if it exists
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
throw new PtyError(
`Session directory was deleted during save operation`,
'SESSION_DIR_DELETED'
);
}
fs.renameSync(tempPath, sessionJsonPath);
logger.debug(`session info saved for ${sessionId}`);
} catch (error) {
if (error instanceof PtyError) {
throw error;
}
throw new PtyError(
`Failed to save session info: ${error instanceof Error ? error.message : String(error)}`,
'SAVE_SESSION_FAILED'
@ -278,9 +303,19 @@ export class SessionManager {
const sessionDir = path.join(this.controlPath, sessionId);
if (fs.existsSync(sessionDir)) {
logger.debug(`Cleaning up session directory: ${sessionDir}`);
// Log session info before cleanup for debugging
const sessionInfo = this.loadSessionInfo(sessionId);
if (sessionInfo) {
logger.debug(`Cleaning up session ${sessionId} with status: ${sessionInfo.status}`);
}
// Remove directory and all contents
fs.rmSync(sessionDir, { recursive: true, force: true });
logger.log(chalk.green(`session ${sessionId} cleaned up`));
} else {
logger.debug(`Session directory ${sessionDir} does not exist, nothing to clean up`);
}
} catch (error) {
throw new PtyError(

View file

@ -212,6 +212,13 @@ export class VibeTunnelSocketClient extends EventEmitter {
return this.send(MessageBuilder.resetSize());
}
/**
* Send update title command
*/
updateTitle(title: string): boolean {
return this.send(MessageBuilder.updateTitle(title));
}
/**
* Send status update
*/

View file

@ -46,6 +46,11 @@ export interface ResetSizeCommand extends ControlCommand {
cmd: 'reset-size';
}
export interface UpdateTitleCommand extends ControlCommand {
cmd: 'update-title';
title: string;
}
/**
* Status update payload
*/
@ -133,35 +138,39 @@ export class MessageParser {
/**
* High-level message creation helpers
*/
export class MessageBuilder {
static stdin(data: string): Buffer {
export const MessageBuilder = {
stdin(data: string): Buffer {
return frameMessage(MessageType.STDIN_DATA, data);
}
},
static resize(cols: number, rows: number): Buffer {
resize(cols: number, rows: number): Buffer {
return frameMessage(MessageType.CONTROL_CMD, { cmd: 'resize', cols, rows });
}
},
static kill(signal?: string | number): Buffer {
kill(signal?: string | number): Buffer {
return frameMessage(MessageType.CONTROL_CMD, { cmd: 'kill', signal });
}
},
static resetSize(): Buffer {
resetSize(): Buffer {
return frameMessage(MessageType.CONTROL_CMD, { cmd: 'reset-size' });
}
},
static status(app: string, status: string, extra?: Record<string, unknown>): Buffer {
updateTitle(title: string): Buffer {
return frameMessage(MessageType.CONTROL_CMD, { cmd: 'update-title', title });
},
status(app: string, status: string, extra?: Record<string, unknown>): Buffer {
return frameMessage(MessageType.STATUS_UPDATE, { app, status, ...extra });
}
},
static heartbeat(): Buffer {
heartbeat(): Buffer {
return frameMessage(MessageType.HEARTBEAT, Buffer.alloc(0));
}
},
static error(code: string, message: string, details?: unknown): Buffer {
error(code: string, message: string, details?: unknown): Buffer {
return frameMessage(MessageType.ERROR, { code, message, details });
}
}
},
} as const;
/**
* Parse payload based on message type

View file

@ -4,7 +4,6 @@
* These types match the tty-fwd format to ensure compatibility
*/
import type * as fs from 'fs';
import type * as net from 'net';
import type { IPty } from 'node-pty';
import type { SessionInfo, TitleMode } from '../../shared/types.js';
@ -70,8 +69,6 @@ export interface PtySession {
startTime: Date;
// Optional fields for resource cleanup
inputSocketServer?: net.Server;
sessionJsonWatcher?: fs.FSWatcher;
sessionJsonDebounceTimer?: NodeJS.Timeout | null;
stdoutQueue?: WriteQueue;
// Terminal title mode
titleMode?: TitleMode;

View file

@ -53,39 +53,64 @@ export class ControlDirWatcher {
const sessionJsonPath = path.join(sessionPath, 'session.json');
try {
// Give it a moment for the session.json to be written
logger.debug(`Waiting 100ms for session.json to be written for ${filename}`);
await new Promise((resolve) => setTimeout(resolve, 100));
// Check if this is a directory creation event
if (fs.existsSync(sessionPath) && fs.statSync(sessionPath).isDirectory()) {
// This is a new session directory, wait for session.json with retries
const maxRetries = 5;
const baseDelay = 100;
let sessionData: Record<string, unknown> | null = null;
if (fs.existsSync(sessionJsonPath)) {
// Session was created
const sessionData = JSON.parse(fs.readFileSync(sessionJsonPath, 'utf8'));
const sessionId = sessionData.session_id || filename;
for (let i = 0; i < maxRetries; i++) {
const delay = baseDelay * 2 ** i; // Exponential backoff: 100, 200, 400, 800, 1600ms
logger.debug(
`Attempt ${i + 1}/${maxRetries}: Waiting ${delay}ms for session.json for ${filename}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
logger.log(chalk.blue(`Detected new external session: ${sessionId}`));
// Check if PtyManager already knows about this session
if (this.config.ptyManager) {
const existingSession = this.config.ptyManager.getSession(sessionId);
if (!existingSession) {
// This is a new external session, PtyManager needs to track it
logger.log(chalk.green(`Attaching to external session: ${sessionId}`));
// PtyManager will pick it up through its own session listing
// since it reads from the control directory
if (fs.existsSync(sessionJsonPath)) {
try {
const content = fs.readFileSync(sessionJsonPath, 'utf8');
sessionData = JSON.parse(content);
logger.debug(`Successfully read session.json for ${filename} on attempt ${i + 1}`);
break;
} catch (error) {
logger.debug(`Failed to read/parse session.json on attempt ${i + 1}:`, error);
// Continue to next retry
}
}
}
// If we're a remote server registered with HQ, immediately notify HQ
if (this.config.hqClient && !isShuttingDown()) {
try {
await this.notifyHQAboutSession(sessionId, 'created');
} catch (error) {
logger.error(`Failed to notify HQ about new session ${sessionId}:`, error);
}
}
if (sessionData) {
// Session was created
const sessionId = (sessionData.id || sessionData.session_id || filename) as string;
// If we're in HQ mode and this is a local session, no special handling needed
// The session is already tracked locally
logger.log(chalk.blue(`Detected new external session: ${sessionId}`));
// Check if PtyManager already knows about this session
if (this.config.ptyManager) {
const existingSession = this.config.ptyManager.getSession(sessionId);
if (!existingSession) {
// This is a new external session, PtyManager needs to track it
logger.log(chalk.green(`Attaching to external session: ${sessionId}`));
// PtyManager will pick it up through its own session listing
// since it reads from the control directory
}
}
// If we're a remote server registered with HQ, immediately notify HQ
if (this.config.hqClient && !isShuttingDown()) {
try {
await this.notifyHQAboutSession(sessionId, 'created');
} catch (error) {
logger.error(`Failed to notify HQ about new session ${sessionId}:`, error);
}
}
// If we're in HQ mode and this is a local session, no special handling needed
// The session is already tracked locally
} else {
logger.warn(`Session.json not found for ${filename} after ${maxRetries} retries`);
}
} else if (!fs.existsSync(sessionPath)) {
// Session directory was removed
const sessionId = filename;

View file

@ -2,6 +2,7 @@ import { Terminal as XtermTerminal } from '@xterm/headless';
import chalk from 'chalk';
import * as fs from 'fs';
import * as path from 'path';
import { ErrorDeduplicator, formatErrorSummary } from '../utils/error-deduplicator.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('terminal-manager');
@ -36,9 +37,33 @@ export class TerminalManager {
private controlDir: string;
private bufferListeners: Map<string, Set<BufferChangeListener>> = new Map();
private changeTimers: Map<string, NodeJS.Timeout> = new Map();
private errorDeduplicator = new ErrorDeduplicator({
keyExtractor: (error, context) => {
// Use session ID and line prefix as context for xterm parsing errors
const errorMessage = error instanceof Error ? error.message : String(error);
return `${context}:${errorMessage}`;
},
});
private originalConsoleWarn: typeof console.warn;
constructor(controlDir: string) {
this.controlDir = controlDir;
// Override console.warn to suppress xterm.js parsing warnings
this.originalConsoleWarn = console.warn;
console.warn = (...args: unknown[]) => {
const message = args[0];
if (
typeof message === 'string' &&
(message.includes('xterm.js parsing error') ||
message.includes('Unable to process character') ||
message.includes('Cannot read properties of undefined'))
) {
// Suppress xterm.js parsing warnings
return;
}
this.originalConsoleWarn.apply(console, args);
};
}
/**
@ -191,7 +216,25 @@ export class TerminalManager {
// Ignore 'i' (input) events
}
} catch (error) {
logger.error(`Failed to parse stream line for session ${sessionId}:`, error);
// Use deduplicator to check if we should log this error
// Use a more generic context key to group similar parsing errors together
const contextKey = `${sessionId}:parse-stream-line`;
if (this.errorDeduplicator.shouldLog(error, contextKey)) {
const stats = this.errorDeduplicator.getErrorStats(error, contextKey);
if (stats && stats.count > 1) {
// Log summary for repeated errors
logger.warn(formatErrorSummary(error, stats, `session ${sessionId}`));
} else {
// First occurrence - log the error with details
const truncatedLine = line.length > 100 ? `${line.substring(0, 100)}...` : line;
logger.error(`Failed to parse stream line for session ${sessionId}: ${truncatedLine}`);
if (error instanceof Error && error.stack) {
logger.debug(`Parse error details: ${error.message}`);
}
}
}
}
}
@ -706,4 +749,23 @@ export class TerminalManager {
logger.error(`Error getting buffer snapshot for notification ${sessionId}:`, error);
}
}
/**
* Destroy the terminal manager and restore console overrides
*/
destroy(): void {
// Close all terminals
for (const sessionId of this.terminals.keys()) {
this.closeTerminal(sessionId);
}
// Clear all timers
for (const timer of this.changeTimers.values()) {
clearTimeout(timer);
}
this.changeTimers.clear();
// Restore original console.warn
console.warn = this.originalConsoleWarn;
}
}

View file

@ -181,7 +181,7 @@ function parseClaudeStatus(data: string): ActivityStatus | null {
// So "6.0" means 6.0k tokens, not 6.0 tokens
const formattedTokens = `${tokens}k`;
// No spinner - just action and stats for stable comparison
displayText = `${action} (${duration}s, ${direction}${formattedTokens})`;
displayText = `${action} (${duration}s, ${direction} ${formattedTokens})`;
} else {
// Simple format without token info
displayText = `${action} (${duration}s)`;
@ -291,20 +291,10 @@ export class ActivityDetector {
}
}
// Generic activity detection
// Generic activity detection - use getActivityState for consistent time-based checking
return {
filteredData: data,
activity: {
isActive: isMeaningfulOutput,
lastActivityTime: this.lastActivityTime,
specificStatus:
this.currentStatus && this.detector
? {
app: this.detector.name,
status: this.currentStatus.displayText,
}
: undefined,
},
activity: this.getActivityState(),
};
}

View file

@ -0,0 +1,180 @@
/**
* Error deduplication utility to prevent log spam
*
* This helper tracks and deduplicates repeated errors, logging them
* at controlled intervals to avoid overwhelming the logs.
*/
export interface ErrorInfo {
count: number;
lastLogged: number;
firstSeen: number;
}
export interface DeduplicationOptions {
/** Minimum time between logging the same error (ms). Default: 60000 (1 minute) */
minLogInterval?: number;
/** Log a summary every N occurrences. Default: 100 */
summaryInterval?: number;
/** Maximum cache size before cleanup. Default: 100 */
maxCacheSize?: number;
/** Cache entry TTL (ms). Default: 300000 (5 minutes) */
cacheEntryTTL?: number;
/** Maximum length of error key. Default: 100 */
maxKeyLength?: number;
/** Function to extract error key. Default: uses error message + context substring */
keyExtractor?: (error: unknown, context?: string) => string;
}
export class ErrorDeduplicator {
private errorCache = new Map<string, ErrorInfo>();
private options: Required<DeduplicationOptions>;
constructor(options: DeduplicationOptions = {}) {
this.options = {
minLogInterval: options.minLogInterval ?? 60000,
summaryInterval: options.summaryInterval ?? 100,
maxCacheSize: options.maxCacheSize ?? 100,
cacheEntryTTL: options.cacheEntryTTL ?? 300000,
maxKeyLength: options.maxKeyLength ?? 100,
keyExtractor: options.keyExtractor ?? this.defaultKeyExtractor,
};
}
/**
* Check if an error should be logged, and track it
* @returns true if the error should be logged, false if it should be suppressed
*/
shouldLog(error: unknown, context?: string): boolean {
const errorKey = this.options.keyExtractor(error, context);
const truncatedKey = errorKey.substring(0, this.options.maxKeyLength);
const errorInfo = this.errorCache.get(truncatedKey);
const now = Date.now();
if (!errorInfo) {
// First occurrence
this.errorCache.set(truncatedKey, {
count: 1,
lastLogged: now,
firstSeen: now,
});
this.cleanupCacheIfNeeded();
return true;
}
// Increment count
errorInfo.count++;
// Check if enough time has passed
if (now - errorInfo.lastLogged >= this.options.minLogInterval) {
errorInfo.lastLogged = now;
return true;
}
// Check if we should log a summary
if (errorInfo.count % this.options.summaryInterval === 0) {
errorInfo.lastLogged = now;
return true;
}
return false;
}
/**
* Get error statistics for a given error
*/
getErrorStats(error: unknown, context?: string): ErrorInfo | undefined {
const errorKey = this.options.keyExtractor(error, context);
const truncatedKey = errorKey.substring(0, this.options.maxKeyLength);
return this.errorCache.get(truncatedKey);
}
/**
* Clear all cached errors
*/
clear(): void {
this.errorCache.clear();
}
/**
* Get the number of unique errors being tracked
*/
get size(): number {
return this.errorCache.size;
}
/**
* Default key extractor
*/
private defaultKeyExtractor(error: unknown, context?: string): string {
const errorMessage = error instanceof Error ? error.message : String(error);
const contextPart = context ? `:${context.substring(0, 30)}` : '';
return `${errorMessage}${contextPart}`;
}
/**
* Clean up old cache entries if cache is too large
*/
private cleanupCacheIfNeeded(): void {
if (this.errorCache.size <= this.options.maxCacheSize) {
return;
}
const now = Date.now();
const cutoff = now - this.options.cacheEntryTTL;
// Find and remove old entries
const entriesToDelete: string[] = [];
for (const [key, info] of this.errorCache.entries()) {
if (info.lastLogged < cutoff) {
entriesToDelete.push(key);
}
}
for (const key of entriesToDelete) {
this.errorCache.delete(key);
}
// If still too large, remove oldest entries
if (this.errorCache.size > this.options.maxCacheSize) {
const sortedEntries = Array.from(this.errorCache.entries()).sort(
(a, b) => a[1].lastLogged - b[1].lastLogged
);
const entriesToRemove = sortedEntries.slice(
0,
this.errorCache.size - this.options.maxCacheSize
);
for (const [key] of entriesToRemove) {
this.errorCache.delete(key);
}
}
}
}
/**
* Format an error summary message
*/
export function formatErrorSummary(error: unknown, stats: ErrorInfo, context?: string): string {
const errorMessage = error instanceof Error ? error.message : String(error);
const contextPart = context ? ` (context: ${context})` : '';
const duration = Date.now() - stats.firstSeen;
const durationStr = formatDuration(duration);
return `Repeated error: ${stats.count} occurrences over ${durationStr}${contextPart} - ${errorMessage}`;
}
/**
* Format a duration in milliseconds to a human-readable string
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
return `${Math.round(ms / 3600000)}h`;
}
/**
* Create a singleton error deduplicator with default options
*/
export const defaultErrorDeduplicator = new ErrorDeduplicator();

View file

@ -28,25 +28,18 @@ export function generateTitleSequence(
command: string[],
sessionName?: string
): string {
// Convert absolute path to use ~ for home directory
const homeDir = os.homedir();
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd;
// Get the command name (first element of command array)
// Extract just the process name from the full path
const fullCmd = command[0] || 'shell';
const cmdName = path.basename(fullCmd);
// Build title parts
const parts = [displayPath, cmdName];
// Add session name if provided
// If we have a session name, use only that
if (sessionName?.trim()) {
parts.push(sessionName);
// OSC 2 sequence: ESC ] 2 ; <title> BEL
return `\x1B]2;${sessionName}\x07`;
}
// Format: path · command · session name
const title = parts.join(' · ');
// Otherwise, fall back to path · command format
const homeDir = os.homedir();
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd;
const fullCmd = command[0] || 'shell';
const cmdName = path.basename(fullCmd);
const title = `${displayPath} · ${cmdName}`;
// OSC 2 sequence: ESC ] 2 ; <title> BEL
return `\x1B]2;${title}\x07`;
@ -145,36 +138,35 @@ export function generateDynamicTitle(
activity: ActivityState,
sessionName?: string
): string {
const homeDir = os.homedir();
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd;
const fullCmd = command[0] || 'shell';
const cmdName = path.basename(fullCmd);
// Build base parts
const baseParts = [displayPath, cmdName];
// Add session name if provided
// Determine base title
let baseTitle: string;
if (sessionName?.trim()) {
baseParts.push(sessionName);
// Use only the session name
baseTitle = sessionName;
} else {
// Fall back to path · command format
const homeDir = os.homedir();
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd;
const fullCmd = command[0] || 'shell';
const cmdName = path.basename(fullCmd);
baseTitle = `${displayPath} · ${cmdName}`;
}
// If we have Claude-specific status, put it first
// Add activity indicators as prefix
if (activity.specificStatus) {
// Format: status · path · command · session name
const title = `${activity.specificStatus.status} · ${baseParts.join(' · ')}`;
// Format: status · base title
const title = `${activity.specificStatus.status} · ${baseTitle}`;
return `\x1B]2;${title}\x07`;
}
// Otherwise use generic activity indicator (only when active)
if (activity.isActive) {
// Format: ● path · command · session name
const title = `${baseParts.join(' · ')}`;
// Format: ● base title
const title = `${baseTitle}`;
return `\x1B]2;${title}\x07`;
}
// When idle, no indicator - just path · command · session name
const title = baseParts.join(' · ');
// When idle, no indicator - just the base title
// OSC 2 sequence: ESC ] 2 ; <title> BEL
return `\x1B]2;${title}\x07`;
return `\x1B]2;${baseTitle}\x07`;
}

View file

@ -0,0 +1,108 @@
/**
* Shared module to suppress xterm.js parsing errors in both client and server environments
*
* This module provides a unified way to suppress noisy xterm.js parsing errors that occur
* when the terminal encounters unsupported or proprietary escape sequences. These errors
* are harmless but create significant console noise.
*
* Usage: Import and call suppressXtermErrors() at the very beginning of your entry point
*/
// Type declaration for our global flag
// Type declarations for the suppression flag
// We use 'any' type for globalObj to avoid TypeScript errors in different environments
declare global {
namespace NodeJS {
interface Global {
__xtermErrorsSuppressed?: boolean;
}
}
}
// Browser Window interface is extended conditionally at runtime
/**
* Suppresses xterm.js parsing errors by overriding console methods
* Works in both Node.js and browser environments
*/
export function suppressXtermErrors(): void {
// Detect environment
const isNode = typeof process !== 'undefined' && process.versions?.node;
const globalObj = (isNode ? global : typeof globalThis !== 'undefined' ? globalThis : global) as {
__xtermErrorsSuppressed?: boolean;
};
// Check if already suppressed to avoid multiple overrides
if (globalObj.__xtermErrorsSuppressed) {
return;
}
// Mark as suppressed
globalObj.__xtermErrorsSuppressed = true;
// Store original console methods
const originalError = console.error;
const originalWarn = console.warn;
// Override console.error
console.error = (...args: unknown[]) => {
if (shouldSuppressError(args)) {
return; // Suppress xterm.js parsing errors
}
originalError.apply(console, args);
};
// Override console.warn
console.warn = (...args: unknown[]) => {
if (shouldSuppressError(args)) {
return; // Suppress xterm.js parsing warnings
}
originalWarn.apply(console, args);
};
// Log suppression activation in debug mode
if (isNode && process.env.VIBETUNNEL_DEBUG === '1') {
originalWarn.call(console, '[suppress-xterm-errors] xterm.js error suppression activated');
}
}
/**
* Checks if the given console arguments represent an xterm.js parsing error
*/
function shouldSuppressError(args: unknown[]): boolean {
if (!args[0] || typeof args[0] !== 'string') {
return false;
}
const message = args[0];
// Check for xterm.js parsing errors
if (message.includes('xterm.js: Parsing error:')) {
return true;
}
// Also suppress related parsing errors that might come from xterm
if (message.includes('Unable to process character') && message.includes('xterm')) {
return true;
}
return false;
}
/**
* Restore original console methods (useful for testing)
*/
export function restoreConsole(): void {
// This would need to store the originals somewhere accessible
// For now, this is a placeholder for potential future use
const isNode = typeof process !== 'undefined' && process.versions?.node;
const globalObj = (isNode ? global : typeof globalThis !== 'undefined' ? globalThis : global) as {
__xtermErrorsSuppressed?: boolean;
};
if (globalObj.__xtermErrorsSuppressed) {
delete globalObj.__xtermErrorsSuppressed;
// Note: We can't actually restore without storing the originals globally
// This function is mainly here for API completeness
}
}

View file

@ -258,7 +258,7 @@ describe('Socket Protocol Integration', () => {
await client2.connect();
// Set up status listener on client2
let receivedStatus: any = null;
let receivedStatus: { app: string; status: string } | null = null;
client2.on('status', (status) => {
receivedStatus = status;
});
@ -311,7 +311,7 @@ describe('Socket Protocol Integration', () => {
await client.connect();
// Send some random bytes that don't form a valid message
const socket = (client as any).socket;
const socket = (client as unknown as { socket: { write: (data: Buffer) => void } }).socket;
socket.write(Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff]));
// Should not crash

View file

@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import { ErrorDeduplicator, formatErrorSummary } from '../../server/utils/error-deduplicator.js';
describe('ErrorDeduplicator', () => {
it('should log first occurrence of an error', () => {
const deduplicator = new ErrorDeduplicator();
const error = new Error('Test error');
expect(deduplicator.shouldLog(error)).toBe(true);
});
it('should suppress repeated errors within the time window', () => {
const deduplicator = new ErrorDeduplicator({ minLogInterval: 1000 });
const error = new Error('Test error');
expect(deduplicator.shouldLog(error)).toBe(true);
expect(deduplicator.shouldLog(error)).toBe(false);
expect(deduplicator.shouldLog(error)).toBe(false);
});
it('should log error again after time window expires', async () => {
const deduplicator = new ErrorDeduplicator({ minLogInterval: 50 });
const error = new Error('Test error');
expect(deduplicator.shouldLog(error)).toBe(true);
expect(deduplicator.shouldLog(error)).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 60));
expect(deduplicator.shouldLog(error)).toBe(true);
});
it('should log summary at specified intervals', () => {
const deduplicator = new ErrorDeduplicator({
minLogInterval: 60000,
summaryInterval: 5,
});
const error = new Error('Test error');
expect(deduplicator.shouldLog(error)).toBe(true); // 1st
expect(deduplicator.shouldLog(error)).toBe(false); // 2nd
expect(deduplicator.shouldLog(error)).toBe(false); // 3rd
expect(deduplicator.shouldLog(error)).toBe(false); // 4th
expect(deduplicator.shouldLog(error)).toBe(true); // 5th - summary
});
it('should track error statistics', () => {
const deduplicator = new ErrorDeduplicator();
const error = new Error('Test error');
expect(deduplicator.getErrorStats(error)).toBeUndefined();
deduplicator.shouldLog(error);
const stats = deduplicator.getErrorStats(error);
expect(stats).toBeDefined();
expect(stats?.count).toBe(1);
deduplicator.shouldLog(error);
deduplicator.shouldLog(error);
const updatedStats = deduplicator.getErrorStats(error);
expect(updatedStats?.count).toBe(3);
});
it('should use custom key extractor', () => {
const deduplicator = new ErrorDeduplicator({
keyExtractor: (error) => {
return error instanceof Error ? error.message.toLowerCase() : 'unknown';
},
});
const error1 = new Error('Test Error');
const error2 = new Error('test error');
expect(deduplicator.shouldLog(error1)).toBe(true);
expect(deduplicator.shouldLog(error2)).toBe(false); // Same key due to lowercase
});
it('should handle context in key extraction', () => {
const deduplicator = new ErrorDeduplicator();
const error = new Error('Test error');
expect(deduplicator.shouldLog(error, 'context1')).toBe(true);
expect(deduplicator.shouldLog(error, 'context2')).toBe(true); // Different context
expect(deduplicator.shouldLog(error, 'context1')).toBe(false); // Same context
});
it('should clean up old entries when cache is full', () => {
const deduplicator = new ErrorDeduplicator({
maxCacheSize: 3,
cacheEntryTTL: 50,
});
// Add entries
deduplicator.shouldLog(new Error('Error 1'));
deduplicator.shouldLog(new Error('Error 2'));
deduplicator.shouldLog(new Error('Error 3'));
expect(deduplicator.size).toBe(3);
// Wait for TTL
setTimeout(() => {
// Add one more to trigger cleanup
deduplicator.shouldLog(new Error('Error 4'));
// Should have cleaned up old entries
expect(deduplicator.size).toBeLessThanOrEqual(3);
}, 60);
});
it('should clear all errors', () => {
const deduplicator = new ErrorDeduplicator();
deduplicator.shouldLog(new Error('Error 1'));
deduplicator.shouldLog(new Error('Error 2'));
expect(deduplicator.size).toBe(2);
deduplicator.clear();
expect(deduplicator.size).toBe(0);
});
});
describe('formatErrorSummary', () => {
it('should format error summary with count and duration', () => {
const error = new Error('Test error');
const stats = {
count: 10,
lastLogged: Date.now(),
firstSeen: Date.now() - 5000,
};
const summary = formatErrorSummary(error, stats);
expect(summary).toContain('10 occurrences');
expect(summary).toContain('5s');
expect(summary).toContain('Test error');
});
it('should include context in summary', () => {
const error = new Error('Test error');
const stats = {
count: 5,
lastLogged: Date.now(),
firstSeen: Date.now() - 60000,
};
const summary = formatErrorSummary(error, stats, 'session-123');
expect(summary).toContain('context: session-123');
});
it('should format different duration units', () => {
const error = new Error('Test');
// Milliseconds
let stats = { count: 1, lastLogged: Date.now(), firstSeen: Date.now() - 500 };
expect(formatErrorSummary(error, stats)).toContain('500ms');
// Seconds
stats = { count: 1, lastLogged: Date.now(), firstSeen: Date.now() - 30000 };
expect(formatErrorSummary(error, stats)).toContain('30s');
// Minutes
stats = { count: 1, lastLogged: Date.now(), firstSeen: Date.now() - 120000 };
expect(formatErrorSummary(error, stats)).toContain('2m');
// Hours
stats = { count: 1, lastLogged: Date.now(), firstSeen: Date.now() - 7200000 };
expect(formatErrorSummary(error, stats)).toContain('2h');
});
});

View file

@ -37,10 +37,12 @@ describe('vt title Command Integration', () => {
await execAsync(`${vtScriptPath} title "Test Title"`);
// Should not reach here
expect.fail('Command should have failed');
} catch (error: any) {
expect(error.code).toBeGreaterThan(0);
expect(error.stderr).toContain("'vt title' can only be used inside a VibeTunnel session");
expect(error.stderr).toContain('Start a session first');
} catch (error) {
expect((error as { code: number }).code).toBeGreaterThan(0);
expect((error as { stderr: string }).stderr).toContain(
"'vt title' can only be used inside a VibeTunnel session"
);
expect((error as { stderr: string }).stderr).toContain('Start a session first');
}
});
@ -171,9 +173,9 @@ describe('vt title Command Integration', () => {
try {
await execAsync(`${vtScriptPath} title "Test"`, { env });
expect.fail('Should have failed');
} catch (error: any) {
expect(error.code).toBeGreaterThan(0);
expect(error.stderr).toContain('Session file not found');
} catch (error) {
expect((error as { code: number }).code).toBeGreaterThan(0);
expect((error as { stderr: string }).stderr).toContain('Session file not found');
}
});

View file

@ -1,7 +1,8 @@
import { fixture } from '@open-wc/testing';
import { LitElement, type TemplateResult } from 'lit';
import { vi } from 'vitest';
import type { ActivityStatus, SessionData } from '../types/test-types';
import type { Session } from '../../shared/types';
import type { ActivityStatus } from '../types/test-types';
import { createTestSession } from './test-factories';
/**
@ -195,34 +196,27 @@ export async function waitFor(
/**
* Creates mock session data for testing
* Uses factory function to ensure consistent test data
* Returns a proper Session object that matches the component expectations
*/
export function createMockSession(overrides: Partial<SessionData> = {}): SessionData {
const baseSession = createTestSession({
name: overrides.name,
command: overrides.cmdline,
workingDir: overrides.cwd,
pid: overrides.pid,
status: overrides.status as 'running' | 'exited' | undefined,
startedAt: overrides.started_at,
});
// Use base session values as defaults, then apply overrides
return {
id: baseSession.id,
name: baseSession.name,
cmdline: baseSession.command,
cwd: baseSession.workingDir,
pid: baseSession.pid,
status: baseSession.status,
started_at: baseSession.startedAt,
exitCode: null,
term: 'xterm-256color',
spawn_type: 'pty',
cols: 80,
rows: 24,
...overrides, // Override any fields provided
export function createMockSession(overrides: Partial<Session> = {}): Session {
// Convert SessionData properties to Session properties if needed
const overridesWithLegacy = overrides as Partial<Session> & {
cmdline?: string[];
cwd?: string;
started_at?: string;
};
const command = overridesWithLegacy.command || overridesWithLegacy.cmdline || ['/bin/bash', '-l'];
const workingDir = overridesWithLegacy.workingDir || overridesWithLegacy.cwd || '/home/test';
const startedAt =
overridesWithLegacy.startedAt || overridesWithLegacy.started_at || new Date().toISOString();
return createTestSession({
...overrides,
command: Array.isArray(command) ? command : [command],
workingDir,
startedAt,
});
}
/**

View file

@ -63,6 +63,38 @@ describe('Terminal Title Utilities', () => {
expect(result).toBe('\x1B]2;~/projects · npm · Frontend Dev\x07');
});
it('should skip redundant session names like "claude · claude"', () => {
const cwd = '/home/user/projects';
const command = ['claude'];
const sessionName = 'claude · claude';
const result = generateTitleSequence(cwd, command, sessionName);
expect(result).toBe('\x1B]2;~/projects · claude\x07');
});
it('should skip auto-generated session names with path', () => {
const cwd = '/home/user/projects';
const command = ['python3'];
const sessionName = 'python3 (~/projects)';
const result = generateTitleSequence(cwd, command, sessionName);
expect(result).toBe('\x1B]2;~/projects · python3\x07');
});
it('should skip session names that are just the command name', () => {
const cwd = '/home/user';
const command = ['bash'];
const sessionName = 'bash';
const result = generateTitleSequence(cwd, command, sessionName);
expect(result).toBe('\x1B]2;~ · bash\x07');
});
it('should include custom session names that are not redundant', () => {
const cwd = '/home/user/projects';
const command = ['claude'];
const sessionName = 'Working on VibeTunnel';
const result = generateTitleSequence(cwd, command, sessionName);
expect(result).toBe('\x1B]2;~/projects · claude · Working on VibeTunnel\x07');
});
it('should handle empty session name', () => {
const cwd = '/home/user';
const command = ['vim'];

View file

@ -18,7 +18,7 @@ interface CreateSessionOptions {
name?: string;
command?: string[];
workingDir?: string;
status?: 'running' | 'stopped';
status?: 'running' | 'exited' | 'stopped';
exitCode?: number;
startedAt?: string;
pid?: number;
@ -66,7 +66,7 @@ export function createTestSession(options: CreateSessionOptions = {}): Session {
name: options.name || `Test Session ${id}`,
command: options.command || ['/bin/bash', '-l'],
workingDir: options.workingDir || '/home/test',
status: options.status || 'running',
status: options.status === 'stopped' ? 'exited' : options.status || 'running',
exitCode: options.exitCode,
startedAt: options.startedAt || now.toISOString(),
pid: options.pid || 12345 + sessionCounter,