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:*)" "Bash(rg:*)"
], ],
"deny": [] "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 ## Critical Development Rules
- **Never commit and/or push before the user has tested your changes!** ### ABSOLUTE CARDINAL RULES - VIOLATION MEANS IMMEDIATE FAILURE
- **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. 1. **NEVER, EVER, UNDER ANY CIRCUMSTANCES CREATE A NEW BRANCH WITHOUT EXPLICIT USER PERMISSION**
- **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. - 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 ## 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"> ReferencedContainer = "container:VibeTunnel-Mac.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "VIBETUNNEL_DEBUG"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

View file

@ -1,4 +1,5 @@
import CryptoKit import CryptoKit
import Darwin
import Foundation import Foundation
import OSLog import OSLog
@ -207,7 +208,7 @@ final class BunServer {
environment["NODE_OPTIONS"] = "--max-old-space-size=4096 --max-semi-space-size=128" environment["NODE_OPTIONS"] = "--max-old-space-size=4096 --max-semi-space-size=128"
// Copy only essential environment variables // 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 { for key in essentialVars {
if let value = ProcessInfo.processInfo.environment[key] { if let value = ProcessInfo.processInfo.environment[key] {
environment[key] = value environment[key] = value
@ -429,24 +430,55 @@ final class BunServer {
} }
source.setEventHandler { [logHandler] in source.setEventHandler { [logHandler] in
let data = handle.availableData // Read data in a non-blocking way to prevent hangs on large output
if data.isEmpty { var buffer = Data()
// EOF reached let maxBytesPerRead = 65_536 // 64KB chunks
cancelSource()
return // 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) { // Process accumulated data
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines) if !buffer.isEmpty {
.components(separatedBy: .newlines) if let output = String(data: buffer, encoding: .utf8) {
for line in lines where !line.isEmpty { Self.processOutputStatic(output, logHandler: logHandler, isError: false)
// Skip shell initialization messages } else {
if line.contains("zsh:") || line.hasPrefix("Last login:") { // If UTF-8 decoding fails, try to decode what we can
continue // Use String(decoding:as:) for lossy conversion
} let output = String(decoding: buffer, as: UTF8.self)
Self.processOutputStatic(output, logHandler: logHandler, isError: false)
// Log to OSLog with appropriate level
logHandler.log(line, isError: false)
} }
} }
} }
@ -482,19 +514,55 @@ final class BunServer {
} }
source.setEventHandler { [logHandler] in source.setEventHandler { [logHandler] in
let data = handle.availableData // Read data in a non-blocking way to prevent hangs on large output
if data.isEmpty { var buffer = Data()
// EOF reached let maxBytesPerRead = 65_536 // 64KB chunks
cancelSource()
return // 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) { // Process accumulated data
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines) if !buffer.isEmpty {
.components(separatedBy: .newlines) if let output = String(data: buffer, encoding: .utf8) {
for line in lines where !line.isEmpty { Self.processOutputStatic(output, logHandler: logHandler, isError: true)
// Log stderr as errors/warnings } else {
logHandler.log(line, isError: true) // 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 // MARK: - LogHandler
/// A sendable log handler for use in detached tasks /// A sendable log handler for use in detached tasks

View file

@ -124,20 +124,6 @@ final class SessionMonitor {
self.lastError = nil self.lastError = nil
self.lastFetch = Date() 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 // Update WindowTracker
WindowTracker.shared.updateFromSessions(sessionsArray) WindowTracker.shared.updateFromSessions(sessionsArray)
} catch { } catch {

View file

@ -1135,9 +1135,10 @@ final class WindowTracker {
} }
} }
let runningCount = sessions.count { $0.isRunning }
logger logger
.debug( .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 _isWindowVisible = false
private var frameObserver: Any? private var frameObserver: Any?
private var lastBounds: CGRect = .zero 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 /// Closure to be called when window hides
var onHide: (() -> Void)? var onHide: (() -> Void)?
@ -77,6 +84,7 @@ final class CustomMenuWindow: NSPanel {
cornerRadius: DesignConstants.menuCornerRadius cornerRadius: DesignConstants.menuCornerRadius
) )
contentView.layer?.mask = maskLayer contentView.layer?.mask = maskLayer
self.maskLayer = maskLayer
lastBounds = contentView.bounds lastBounds = contentView.bounds
// Update mask when bounds change // Update mask when bounds change
@ -91,7 +99,7 @@ final class CustomMenuWindow: NSPanel {
let currentBounds = contentView.bounds let currentBounds = contentView.bounds
guard currentBounds != self.lastBounds else { return } guard currentBounds != self.lastBounds else { return }
self.lastBounds = currentBounds self.lastBounds = currentBounds
maskLayer.path = self.createSideRoundedPath( self.maskLayer?.path = self.createSideRoundedPath(
in: currentBounds, in: currentBounds,
cornerRadius: DesignConstants.menuCornerRadius cornerRadius: DesignConstants.menuCornerRadius
) )
@ -189,6 +197,8 @@ final class CustomMenuWindow: NSPanel {
// Commit all changes at once // Commit all changes at once
CATransaction.commit() CATransaction.commit()
onShow?()
} }
private func displayWindowSafely() { private func displayWindowSafely() {
@ -269,6 +279,7 @@ final class CustomMenuWindow: NSPanel {
func hide() { func hide() {
// Mark window as not visible // Mark window as not visible
_isWindowVisible = false _isWindowVisible = false
isNewSessionActive = false // Always reset this state
// Button state will be reset by StatusBarMenuManager via onHide callback // Button state will be reset by StatusBarMenuManager via onHide callback
orderOut(nil) orderOut(nil)
@ -291,11 +302,29 @@ final class CustomMenuWindow: NSPanel {
guard isVisible else { return } 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 } guard let self, self.isVisible else { return }
let mouseLocation = NSEvent.mouseLocation 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) { if !self.frame.contains(mouseLocation) {
self.hide() self.hide()
} }

View file

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

View file

@ -1,123 +1,19 @@
import Foundation import Foundation
// MARK: - Visual Indicator Styles // MARK: - Status Bar Visual Indicators
extension StatusBarController { extension StatusBarController {
enum IndicatorStyle { /// Format session counts with minimalist style
case dots // 5 (current implementation) func formatSessionIndicator(activeCount: Int, idleCount: Int) -> String {
case bars // let totalCount = activeCount + idleCount
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 {
guard totalCount > 0 else { return "" } 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 { if activeCount == 0 {
// Only idle sessions, show simple count
return String(totalCount) 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 { } else if activeCount == totalCount {
"\(activeCount)" return "\(activeCount)"
} else { } 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 // MARK: - Core Properties
private var statusItem: NSStatusItem? private var statusItem: NSStatusItem?
private let menuManager: StatusBarMenuManager let menuManager: StatusBarMenuManager
// MARK: - Dependencies // MARK: - Dependencies
@ -65,7 +65,7 @@ final class StatusBarController: NSObject {
button.sendAction(on: [.leftMouseUp, .rightMouseUp]) button.sendAction(on: [.leftMouseUp, .rightMouseUp])
// Use pushOnPushOff for proper state management // Use pushOnPushOff for proper state management
button.setButtonType(.pushOnPushOff) button.setButtonType(.toggle)
// Accessibility // Accessibility
button.setAccessibilityTitle("VibeTunnel") button.setAccessibilityTitle("VibeTunnel")
@ -154,16 +154,10 @@ final class StatusBarController: NSObject {
let activeCount = activeSessions.count let activeCount = activeSessions.count
let totalCount = sessions.count let totalCount = sessions.count
let idleCount = totalCount - activeCount
// Format the title with visual indicators // Format the title with minimalist indicator
// Try different styles by changing this: let indicator = formatSessionIndicator(activeCount: activeCount, idleCount: idleCount)
// .dots (default): 5
// .bars:
// .compact: 25
// .minimalist: 2|5
// .meter: []
let indicatorStyle: IndicatorStyle = .minimalist
let indicator = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle)
button.title = indicator.isEmpty ? "" : " " + indicator button.title = indicator.isEmpty ? "" : " " + indicator
// Update tooltip // Update tooltip
@ -260,6 +254,11 @@ final class StatusBarController: NSObject {
menuManager.showCustomWindow(relativeTo: button) menuManager.showCustomWindow(relativeTo: button)
} }
func toggleCustomWindow() {
guard let button = statusItem?.button else { return }
menuManager.toggleCustomWindow(relativeTo: button)
}
// MARK: - Cleanup // MARK: - Cleanup
deinit { deinit {

View file

@ -1,6 +1,19 @@
import AppKit import AppKit
import Combine
import SwiftUI 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. /// Manages status bar menu behavior, providing left-click custom view and right-click context menu functionality.
@MainActor @MainActor
final class StatusBarMenuManager: NSObject { final class StatusBarMenuManager: NSObject {
@ -21,18 +34,28 @@ final class StatusBarMenuManager: NSObject {
private var terminalLauncher: TerminalLauncher? private var terminalLauncher: TerminalLauncher?
// Custom window management // Custom window management
private var customWindow: CustomMenuWindow? fileprivate var customWindow: CustomMenuWindow?
private weak var statusBarButton: NSStatusBarButton? private weak var statusBarButton: NSStatusBarButton?
private weak var currentStatusItem: NSStatusItem? private weak var currentStatusItem: NSStatusItem?
// State management /// State management
private var menuState: MenuState = .none 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 // MARK: - Initialization
override init() { override init() {
super.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 // MARK: - Configuration
@ -58,9 +81,6 @@ final class StatusBarMenuManager: NSObject {
// MARK: - State Management // MARK: - State Management
private func updateMenuState(_ newState: MenuState, button: NSStatusBarButton? = nil) { private func updateMenuState(_ newState: MenuState, button: NSStatusBarButton? = nil) {
// Cancel any pending highlight task
highlightTask?.cancel()
// Update state // Update state
menuState = newState menuState = newState
@ -95,62 +115,57 @@ final class StatusBarMenuManager: NSObject {
// Update menu state to custom window FIRST before any async operations // Update menu state to custom window FIRST before any async operations
updateMenuState(.customWindow, button: button) 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 // Create SessionService instance
let sessionService = SessionService(serverManager: serverManager, sessionMonitor: sessionMonitor) let sessionService = SessionService(serverManager: serverManager, sessionMonitor: sessionMonitor)
// Create the main view with all dependencies // Create the main view with all dependencies and binding
let mainView = VibeTunnelMenuView() let mainView = VibeTunnelMenuView(isNewSessionActive: Binding(
.environment(sessionMonitor) get: { [weak self] in self?.isNewSessionActive ?? false },
.environment(serverManager) set: { [weak self] in self?.isNewSessionActive = $0 }
.environment(ngrokService) ))
.environment(tailscaleService) .environment(sessionMonitor)
.environment(terminalLauncher) .environment(serverManager)
.environment(sessionService) .environment(ngrokService)
.environment(tailscaleService)
.environment(terminalLauncher)
.environment(sessionService)
// Wrap in custom container for proper styling // Wrap in custom container for proper styling
let containerView = CustomMenuContainer { let containerView = CustomMenuContainer {
mainView mainView
} }
// Create custom window if needed // Hide and cleanup old window before creating new one
if customWindow == nil { customWindow?.hide()
customWindow = CustomMenuWindow(contentView: containerView) customWindow = nil
customWindow = CustomMenuWindow(contentView: containerView)
// Set up callback to reset state when window hides // Set up callback to reset state when window hides
customWindow?.onHide = { [weak self] in customWindow?.onHide = { [weak self] in
// Ensure state is reset on main thread self?.statusBarButton?.highlight(false)
Task { @MainActor in
self?.updateMenuState(.none)
}
}
} else {
// Hide and cleanup old window before creating new one
customWindow?.hide()
customWindow = nil
// Create new window with updated content // Ensure state is reset on main thread
customWindow = CustomMenuWindow(contentView: containerView) Task { @MainActor in
customWindow?.onHide = { [weak self] in self?.updateMenuState(.none)
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 // Show the custom window
customWindow?.show(relativeTo: button) customWindow?.show(relativeTo: button)
statusBarButton?.highlight(true)
} }
func hideCustomWindow() { 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 // 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 @State private var showingNewSession = false
@FocusState private var focusedField: FocusField? @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 { enum FocusField: Hashable {
case sessionRow(String) case sessionRow(String)
case settingsButton case settingsButton
@ -31,11 +38,17 @@ struct VibeTunnelMenuView: View {
var body: some View { var body: some View {
if showingNewSession { if showingNewSession {
NewSessionForm(isPresented: $showingNewSession) NewSessionForm(isPresented: Binding(
.transition(.asymmetric( get: { showingNewSession },
insertion: .move(edge: .bottom).combined(with: .opacity), set: { newValue in
removal: .move(edge: .bottom).combined(with: .opacity) showingNewSession = newValue
)) isNewSessionActive = newValue
}
))
.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
))
} else { } else {
mainContent mainContent
.transition(.asymmetric( .transition(.asymmetric(
@ -138,6 +151,7 @@ struct VibeTunnelMenuView: View {
Button(action: { Button(action: {
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: 0.2)) {
showingNewSession = true showingNewSession = true
isNewSessionActive = true
} }
}) { }) {
Label("New Session", systemImage: "plus.square") Label("New Session", systemImage: "plus.square")
@ -331,7 +345,15 @@ struct ServerAddressRow: View {
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button(action: { 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) NSWorkspace.shared.open(url)
} }
}) { }) {
@ -789,7 +811,7 @@ struct SessionRow: View {
let elapsed = Date().timeIntervalSince(startDate) let elapsed = Date().timeIntervalSince(startDate)
if elapsed < 60 { if elapsed < 60 {
return "just now" return "now"
} else if elapsed < 3_600 { } else if elapsed < 3_600 {
let minutes = Int(elapsed / 60) let minutes = Int(elapsed / 60)
return "\(minutes)m" return "\(minutes)m"

View file

@ -414,7 +414,7 @@ private struct ServerConfigurationSection: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .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) Link(url.absoluteString, destination: url)
.font(.caption) .font(.caption)
.foregroundStyle(.blue) .foregroundStyle(.blue)

View file

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

View file

@ -101,6 +101,13 @@ struct VibeTunnelApp: App {
/// Manages app lifecycle, single instance enforcement, and core services /// Manages app lifecycle, single instance enforcement, and core services
@MainActor @MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { 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? private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
var app: VibeTunnelApp? var app: VibeTunnelApp?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate") private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate")

View file

@ -1,5 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
// Entry point for the server - imports the modular server which starts automatically // 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 { startVibeTunnelForward } from './server/fwd.js';
import { startVibeTunnelServer } from './server/server.js'; import { startVibeTunnelServer } from './server/server.js';
import { closeLogger, createLogger, initLogger } from './server/utils/logger.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 { html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js'; import { customElement, state } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js'; import { keyed } from 'lit/directives/keyed.js';

View file

@ -156,8 +156,8 @@ describe('FilePicker Component', () => {
removeAttribute: vi.fn(), removeAttribute: vi.fn(),
click: vi.fn(), click: vi.fn(),
remove: vi.fn(), remove: vi.fn(),
}; } as Pick<HTMLInputElement, 'removeAttribute' | 'click' | 'remove'>;
element.fileInput = mockFileInput as any; element.fileInput = mockFileInput as HTMLInputElement;
fileButton?.click(); 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 // @vitest-environment happy-dom
import { fixture, html } from '@open-wc/testing'; import { fixture, html } from '@open-wc/testing';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; 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 { createMockSession } from '@/test/utils/lit-test-utils';
import { resetFactoryCounters } from '@/test/utils/test-factories'; import { resetFactoryCounters } from '@/test/utils/test-factories';
import type { AuthClient } from '../services/auth-client'; import type { AuthClient } from '../services/auth-client';
@ -9,9 +9,10 @@ import type { AuthClient } from '../services/auth-client';
// Mock AuthClient // Mock AuthClient
vi.mock('../services/auth-client'); vi.mock('../services/auth-client');
// Mock copyToClipboard // Mock copyToClipboard and formatPathForDisplay
vi.mock('../utils/path-utils', () => ({ vi.mock('../utils/path-utils', () => ({
copyToClipboard: vi.fn(() => Promise.resolve(true)), copyToClipboard: vi.fn(() => Promise.resolve(true)),
formatPathForDisplay: vi.fn((path) => path), // Just return the path as-is for tests
})); }));
// Import component type // Import component type
@ -28,6 +29,7 @@ describe('SessionCard', () => {
await import('./vibe-terminal-buffer'); await import('./vibe-terminal-buffer');
await import('./copy-icon'); await import('./copy-icon');
await import('./clickable-path'); await import('./clickable-path');
await import('./inline-edit');
}); });
beforeEach(async () => { beforeEach(async () => {
@ -54,7 +56,7 @@ describe('SessionCard', () => {
}); });
afterEach(() => { afterEach(() => {
element.remove(); element?.remove();
fetchMock.clear(); fetchMock.clear();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@ -66,9 +68,17 @@ describe('SessionCard', () => {
expect(element.isActive).toBe(false); expect(element.isActive).toBe(false);
}); });
it('should render session details', () => { it('should render session details', async () => {
const sessionName = getTextContent(element, '.text-accent-green'); // Wait for inline-edit to render
expect(sessionName).toBeTruthy(); 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 // Should have status indicator
const statusText = element.textContent; const statusText = element.textContent;
@ -91,15 +101,19 @@ describe('SessionCard', () => {
element.session = createMockSession({ name: 'Test Session' }); element.session = createMockSession({ name: 'Test Session' });
await element.updateComplete; await element.updateComplete;
let displayText = getTextContent(element, '.text-accent-green'); let inlineEdit = element.querySelector('inline-edit') as HTMLElement & { value: string };
expect(displayText).toContain('Test Session'); expect(inlineEdit).toBeTruthy();
expect(inlineEdit.value).toContain('Test Session');
// Test without name (falls back to command) // 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; await element.updateComplete;
displayText = getTextContent(element, '.text-accent-green'); inlineEdit = element.querySelector('inline-edit') as HTMLElement & { value: string };
expect(displayText).toContain('npm run dev'); expect(inlineEdit).toBeTruthy();
expect(inlineEdit.value).toBe('npm run dev');
}); });
it('should show running status with success color', async () => { 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 './vibe-terminal-buffer.js';
import './copy-icon.js'; import './copy-icon.js';
import './clickable-path.js'; import './clickable-path.js';
import './inline-edit.js';
@customElement('session-card') @customElement('session-card')
export class SessionCard extends LitElement { export class SessionCard extends LitElement {
@ -194,6 +195,56 @@ export class SessionCard extends LitElement {
return frames[this.killingFrame % frames.length]; 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) { private async handlePidClick(e: Event) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); 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" 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="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(' ')}"> <inline-edit
${this.session.name || this.session.command.join(' ')} .value=${this.session.name || this.session.command?.join(' ') || ''}
</div> .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> </div>
${ ${
this.session.status === 'running' || this.session.status === 'exited' this.session.status === 'running' || this.session.status === 'exited'

View file

@ -375,21 +375,21 @@ export class SessionCreateForm extends LitElement {
} }
return html` 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 <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" 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"> <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-xl font-bold">New Session</h2> <h2 class="text-primary text-lg sm:text-xl font-bold">New Session</h2>
<button <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} @click=${this.handleCancel}
title="Close (Esc)" title="Close (Esc)"
aria-label="Close modal" aria-label="Close modal"
> >
<svg <svg
class="w-5 h-5" class="w-4 h-4 sm:w-5 sm:h-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -405,13 +405,13 @@ export class SessionCreateForm extends LitElement {
</button> </button>
</div> </div>
<div class="p-6"> <div class="p-4 sm:p-6 overflow-y-auto flex-grow">
<!-- Session Name --> <!-- Session Name -->
<div class="mb-5"> <div class="mb-3 sm:mb-5">
<label class="form-label text-dark-text-muted">Session Name (Optional):</label> <label class="form-label text-dark-text-muted text-xs sm:text-sm">Session Name (Optional):</label>
<input <input
type="text" type="text"
class="input-field" class="input-field py-2 sm:py-3 text-sm"
.value=${this.sessionName} .value=${this.sessionName}
@input=${this.handleSessionNameChange} @input=${this.handleSessionNameChange}
placeholder="My Session" placeholder="My Session"
@ -420,11 +420,11 @@ export class SessionCreateForm extends LitElement {
</div> </div>
<!-- Command --> <!-- Command -->
<div class="mb-5"> <div class="mb-3 sm:mb-5">
<label class="form-label text-dark-text-muted">Command:</label> <label class="form-label text-dark-text-muted text-xs sm:text-sm">Command:</label>
<input <input
type="text" type="text"
class="input-field" class="input-field py-2 sm:py-3 text-sm"
.value=${this.command} .value=${this.command}
@input=${this.handleCommandChange} @input=${this.handleCommandChange}
placeholder="zsh" placeholder="zsh"
@ -433,24 +433,24 @@ export class SessionCreateForm extends LitElement {
</div> </div>
<!-- Working Directory --> <!-- Working Directory -->
<div class="mb-5"> <div class="mb-3 sm:mb-5">
<label class="form-label text-dark-text-muted">Working Directory:</label> <label class="form-label text-dark-text-muted text-xs sm:text-sm">Working Directory:</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input <input
type="text" type="text"
class="input-field" class="input-field py-2 sm:py-3 text-sm"
.value=${this.workingDir} .value=${this.workingDir}
@input=${this.handleWorkingDirChange} @input=${this.handleWorkingDirChange}
placeholder="~/" placeholder="~/"
?disabled=${this.disabled || this.isCreating} ?disabled=${this.disabled || this.isCreating}
/> />
<button <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} @click=${this.handleBrowse}
?disabled=${this.disabled || this.isCreating} ?disabled=${this.disabled || this.isCreating}
title="Browse directories" 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 <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" 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> </div>
<!-- Spawn Window Toggle --> <!-- 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="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-4"> <div class="flex-1 pr-3 sm:pr-4">
<span class="text-dark-text text-sm font-medium">Spawn window</span> <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">Opens native terminal window</p> <p class="text-xs text-dark-text-muted mt-0.5 hidden sm:block">Opens native terminal window</p>
</div> </div>
<button <button
role="switch" role="switch"
aria-checked="${this.spawnWindow}" aria-checked="${this.spawnWindow}"
@click=${this.handleSpawnWindowChange} @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' this.spawnWindow ? 'bg-primary' : 'bg-dark-border'
}" }"
?disabled=${this.disabled || this.isCreating} ?disabled=${this.disabled || this.isCreating}
> >
<span <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' this.spawnWindow ? 'translate-x-5' : 'translate-x-0.5'
}" }"
></span> ></span>
@ -483,10 +483,10 @@ export class SessionCreateForm extends LitElement {
</div> </div>
<!-- Terminal Title Mode --> <!-- 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="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-4"> <div class="flex-1 pr-3 sm:pr-4">
<span class="text-dark-text text-sm font-medium">Terminal Title Mode</span> <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"> <p class="text-xs text-dark-text-muted mt-0.5 hidden sm:block">
${this.getTitleModeDescription()} ${this.getTitleModeDescription()}
</p> </p>
</div> </div>
@ -494,8 +494,8 @@ export class SessionCreateForm extends LitElement {
<select <select
.value=${this.titleMode} .value=${this.titleMode}
@change=${this.handleTitleModeChange} @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" 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: 140px" style="min-width: 100px"
?disabled=${this.disabled || this.isCreating} ?disabled=${this.disabled || this.isCreating}
> >
<option value="${TitleMode.NONE}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.NONE}>None</option> <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.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> <option value="${TitleMode.DYNAMIC}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.DYNAMIC}>Dynamic</option>
</select> </select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-dark-text-muted"> <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-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg> </svg>
</div> </div>
@ -512,41 +512,41 @@ export class SessionCreateForm extends LitElement {
</div> </div>
<!-- Quick Start Section --> <!-- Quick Start Section -->
<div class="mb-6"> <div class="mb-4 sm:mb-6">
<label class="form-label text-dark-text-muted uppercase text-xs tracking-wider mb-3" <label class="form-label text-dark-text-muted uppercase text-xs tracking-wider mb-2 sm:mb-3"
>Quick Start</label >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( ${this.quickStartCommands.map(
({ label, command }) => html` ({ label, command }) => html`
<button <button
@click=${() => this.handleQuickStart(command)} @click=${() => this.handleQuickStart(command)}
class="${ class="${
this.command === command 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-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-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-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} ?disabled=${this.disabled || this.isCreating}
> >
${label === 'gemini' ? '✨ ' : ''}${label === 'claude' ? '✨ ' : ''}${ <span class="hidden sm:inline">${label === 'gemini' ? '✨ ' : ''}${label === 'claude' ? '✨ ' : ''}${
label === 'pnpm run dev' ? '▶️ ' : '' label === 'pnpm run dev' ? '▶️ ' : ''
}${label} }</span>${label}
</button> </button>
` `
)} )}
</div> </div>
</div> </div>
<div class="flex gap-3 mt-6"> <div class="flex gap-2 sm:gap-3 mt-4 sm:mt-6">
<button <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} @click=${this.handleCancel}
?disabled=${this.isCreating} ?disabled=${this.isCreating}
> >
Cancel Cancel
</button> </button>
<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} @click=${this.handleCreate}
?disabled=${ ?disabled=${
this.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 { Session } from '../../shared/types.js';
import type { AuthClient } from '../services/auth-client.js'; import type { AuthClient } from '../services/auth-client.js';
import './session-card.js'; import './session-card.js';
import './inline-edit.js';
import { formatSessionDuration } from '../../shared/utils/time.js'; import { formatSessionDuration } from '../../shared/utils/time.js';
import { createLogger } from '../utils/logger.js'; import { createLogger } from '../utils/logger.js';
import { formatPathForDisplay } from '../utils/path-utils.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() { public async handleCleanupExited() {
if (this.cleaningExited) return; if (this.cleaningExited) return;
@ -301,19 +361,21 @@ export class SessionList extends LitElement {
? 'text-accent-primary font-medium' ? 'text-accent-primary font-medium'
: 'text-dark-text group-hover:text-accent-primary transition-colors' : 'text-dark-text group-hover:text-accent-primary transition-colors'
}" }"
title="${
session.name ||
(Array.isArray(session.command)
? session.command.join(' ')
: session.command)
}"
> >
${ <inline-edit
session.name || .value=${
(Array.isArray(session.command) session.name ||
? session.command.join(' ') (Array.isArray(session.command)
: 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>
<div class="text-xs text-dark-text-muted truncate flex items-center gap-1"> <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-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled} @session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError} @session-kill-error=${this.handleSessionKillError}
@session-renamed=${this.handleSessionRenamed}
@session-rename-error=${this.handleSessionRenameError}
> >
</session-card> </session-card>
` `
@ -546,6 +610,8 @@ export class SessionList extends LitElement {
@session-select=${this.handleSessionSelect} @session-select=${this.handleSessionSelect}
@session-killed=${this.handleSessionKilled} @session-killed=${this.handleSessionKilled}
@session-kill-error=${this.handleSessionKillError} @session-kill-error=${this.handleSessionKillError}
@session-renamed=${this.handleSessionRenamed}
@session-rename-error=${this.handleSessionRenameError}
> >
</session-card> </session-card>
` `
@ -574,7 +640,7 @@ export class SessionList extends LitElement {
if (exitedSessions.length === 0 && runningSessions.length === 0) return ''; if (exitedSessions.length === 0 && runningSessions.length === 0) return '';
return html` 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 --> <!-- Control buttons with consistent styling -->
${ ${
exitedSessions.length > 0 exitedSessions.length > 0

View file

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

View file

@ -26,6 +26,7 @@ import './session-view/mobile-input-overlay.js';
import './session-view/ctrl-alpha-overlay.js'; import './session-view/ctrl-alpha-overlay.js';
import './session-view/width-selector.js'; import './session-view/width-selector.js';
import './session-view/session-header.js'; import './session-view/session-header.js';
import { authClient } from '../services/auth-client.js';
import { createLogger } from '../utils/logger.js'; import { createLogger } from '../utils/logger.js';
import { import {
COMMON_TERMINAL_WIDTHS, COMMON_TERMINAL_WIDTHS,
@ -803,6 +804,51 @@ export class SessionView extends LitElement {
this.dispatchEvent(new CustomEvent('error', { detail: error })); 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 // Drag & Drop handlers
private handleDragOver(e: DragEvent) { private handleDragOver(e: DragEvent) {
e.preventDefault(); e.preventDefault();
@ -1071,6 +1117,7 @@ export class SessionView extends LitElement {
this.showWidthSelector = false; this.showWidthSelector = false;
this.customWidth = ''; this.customWidth = '';
}} }}
@session-rename=${(e: CustomEvent) => this.handleRename(e)}
></session-header> ></session-header>
<!-- Enhanced Terminal Container --> <!-- Enhanced Terminal Container -->

View file

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

View file

@ -81,6 +81,16 @@ export class LifecycleEventManager extends ManagerEventEmitter {
if (!this.session) return; 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 // Check if this is a browser shortcut we should allow
const inputManager = this.callbacks.getInputManager(); const inputManager = this.callbacks.getInputManager();
if (inputManager?.isKeyboardShortcut(e)) { 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 type { Session } from '../session-list.js';
import '../clickable-path.js'; import '../clickable-path.js';
import './width-selector.js'; import './width-selector.js';
import '../inline-edit.js';
@customElement('session-header') @customElement('session-header')
export class SessionHeader extends LitElement { 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 min-w-0 flex-1 overflow-hidden max-w-[50vw] sm:max-w-none">
<div <div class="text-dark-text-bright font-medium text-xs sm:text-sm overflow-hidden text-ellipsis whitespace-nowrap">
class="text-dark-text-bright font-medium text-xs sm:text-sm overflow-hidden text-ellipsis whitespace-nowrap" <inline-edit
title="${ .value=${
this.session.name || this.session.name ||
(Array.isArray(this.session.command) (Array.isArray(this.session.command)
? this.session.command.join(' ') ? this.session.command.join(' ')
: this.session.command) : this.session.command)
}" }
> .placeholder=${
${ Array.isArray(this.session.command)
this.session.name || ? this.session.command.join(' ')
(Array.isArray(this.session.command) : this.session.command
? this.session.command.join(' ') }
: this.session.command) .onSave=${(newName: string) => this.handleRename(newName)}
} ></inline-edit>
</div> </div>
<div class="text-xs opacity-75 mt-0.5 overflow-hidden"> <div class="text-xs opacity-75 mt-0.5 overflow-hidden">
<clickable-path <clickable-path
@ -220,4 +221,20 @@ export class SessionHeader extends LitElement {
</div> </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 chalk from 'chalk';
import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; 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 { PtyManager } from './pty/index.js';
import { SessionManager } from './pty/session-manager.js'; import { SessionManager } from './pty/session-manager.js';
import { VibeTunnelSocketClient } from './pty/socket-client.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 { checkAndPatchClaude } from './utils/claude-patcher.js';
import { closeLogger, createLogger } from './utils/logger.js'; import { closeLogger, createLogger } from './utils/logger.js';
import { generateSessionName } from './utils/session-naming.js'; import { generateSessionName } from './utils/session-naming.js';
import { generateTitleSequence } from './utils/terminal-title.js';
import { BUILD_DATE, GIT_COMMIT, VERSION } from './version.js'; import { BUILD_DATE, GIT_COMMIT, VERSION } from './version.js';
const logger = createLogger('fwd'); const logger = createLogger('fwd');
@ -174,11 +176,48 @@ export async function startVibeTunnelForward(args: string[]) {
}) })
.join(''); .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; sessionInfo.name = sanitizedTitle;
sessionManager.saveSessionInfo(sessionId, sessionInfo); sessionManager.saveSessionInfo(sessionId, sessionInfo);
logger.log(`Session title updated to: ${sanitizedTitle}`); logger.log(`Session title persisted to file: ${sanitizedTitle}`);
closeLogger(); closeLogger();
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
@ -274,6 +313,10 @@ export async function startVibeTunnelForward(args: string[]) {
logger.log(chalk.cyan(`${modeDescriptions[titleMode]}`)); 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] = { const sessionOptions: Parameters<typeof ptyManager.createSession>[1] = {
sessionId: finalSessionId, sessionId: finalSessionId,
name: sessionName, name: sessionName,
@ -307,6 +350,18 @@ export async function startVibeTunnelForward(args: string[]) {
cleanupStdout(); 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 // Shutdown PTY manager and exit
logger.debug('Shutting down PTY manager'); logger.debug('Shutting down PTY manager');
await ptyManager.shutdown(); await ptyManager.shutdown();
@ -367,6 +422,119 @@ export async function startVibeTunnelForward(args: string[]) {
// Listen for terminal resize events // Listen for terminal resize events
process.stdout.on('resize', resizeHandler); 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 // Set up activity detector for Claude status updates
let activityDetector: ActivityDetector | undefined; let activityDetector: ActivityDetector | undefined;
let cleanupStdout: (() => void) | undefined; let cleanupStdout: (() => void) | undefined;

View file

@ -69,7 +69,7 @@ export class PtyManager extends EventEmitter {
string, string,
{ cols: number; rows: number; source: 'browser' | 'terminal'; timestamp: number } { 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 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 sessionExitTimes = new Map<string, number>(); // Track session exit times to avoid false bells
private processTreeAnalyzer = new ProcessTreeAnalyzer(); // Process tree analysis for bell source identification 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 // 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 // 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 // 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 && session.titleMode !== TitleMode.FILTER &&
forwardToStdout forwardToStdout
) { ) {
// Track last known activity state for change detection
let lastKnownActivityState: {
isActive: boolean;
specificStatus?: string;
} | null = null;
session.titleUpdateInterval = setInterval(() => { 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) { if (session.titleMode === TitleMode.DYNAMIC && session.activityDetector) {
const activityState = session.activityDetector.getActivityState(); 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); this.writeActivityState(session, activityState);
} }
@ -482,7 +510,7 @@ export class PtyManager extends EventEmitter {
// Check if activity status changed // Check if activity status changed
if (activity.specificStatus?.status !== session.lastActivityStatus) { if (activity.specificStatus?.status !== session.lastActivityStatus) {
session.lastActivityStatus = activity.specificStatus?.status; 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) { if (session.titleMode === TitleMode.STATIC && forwardToStdout) {
// Check if we should update title based on data content // Check if we should update title based on data content
if (!session.initialTitleSent || shouldInjectTitle(processedData)) { if (!session.initialTitleSent || shouldInjectTitle(processedData)) {
session.titleUpdateNeeded = true; this.markTitleUpdateNeeded(session);
if (!session.initialTitleSent) { if (!session.initialTitleSent) {
session.initialTitleSent = true; session.initialTitleSent = true;
} }
@ -571,7 +599,7 @@ export class PtyManager extends EventEmitter {
forwardToStdout && forwardToStdout &&
(session.titleMode === TitleMode.STATIC || session.titleMode === TitleMode.DYNAMIC) (session.titleMode === TitleMode.STATIC || session.titleMode === TitleMode.DYNAMIC)
) { ) {
session.titleUpdateNeeded = true; this.markTitleUpdateNeeded(session);
session.initialTitleSent = true; session.initialTitleSent = true;
logger.debug(`Marked initial title update for session ${session.id}`); 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 * Handle control messages from control pipe
*/ */
@ -835,6 +763,11 @@ export class PtyManager extends EventEmitter {
} catch (error) { } catch (error) {
logger.warn(`Failed to reset session ${session.id} size to terminal size:`, 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) { if (newDir) {
memorySession.currentWorkingDir = newDir; memorySession.currentWorkingDir = newDir;
memorySession.titleUpdateNeeded = true; this.markTitleUpdateNeeded(memorySession);
logger.debug(`Session ${sessionId} changed directory to: ${newDir}`); logger.debug(`Session ${sessionId} changed directory to: ${newDir}`);
} }
} }
@ -1098,12 +1031,40 @@ export class PtyManager extends EventEmitter {
// Update in-memory session if it exists // Update in-memory session if it exists
const memorySession = this.sessions.get(sessionId); const memorySession = this.sessions.get(sessionId);
if (memorySession?.sessionInfo) { 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; 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 { } 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}`); logger.log(`[PtyManager] Updated session ${sessionId} name to: ${name}`);
} }
@ -1422,6 +1383,7 @@ export class PtyManager extends EventEmitter {
if (!sessionPaths) { if (!sessionPaths) {
return session; return session;
} }
const activityPath = path.join(sessionPaths.controlDir, 'claude-activity.json'); const activityPath = path.join(sessionPaths.controlDir, 'claude-activity.json');
if (fs.existsSync(activityPath)) { if (fs.existsSync(activityPath)) {
@ -1728,8 +1690,8 @@ export class PtyManager extends EventEmitter {
/** /**
* Track and emit events for proper cleanup * Track and emit events for proper cleanup
*/ */
private trackAndEmit(event: string, sessionId: string, ...args: any[]): void { private trackAndEmit(event: string, sessionId: string, ...args: unknown[]): void {
const listeners = this.listeners(event) as ((...args: any[]) => void)[]; const listeners = this.listeners(event) as ((...args: unknown[]) => void)[];
if (!this.sessionEventListeners.has(sessionId)) { if (!this.sessionEventListeners.has(sessionId)) {
this.sessionEventListeners.set(sessionId, new Set()); 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 // Note: stdin handling is done via IPC socket, no global listeners to clean up
// Remove all event listeners for this session // 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 { private markTitleUpdateNeeded(session: PtySession): void {
if (!session.titleUpdateNeeded || !session.stdoutQueue || !session.isExternalTerminal) { 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; return;
} }
// Generate new title session.titleUpdateNeeded = true;
const newTitle = this.generateTerminalTitle(session); 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) { if (newTitle && newTitle !== session.currentTitle) {
// Store pending title logger.debug(`[updateTerminalTitleForSessionName] Updating title for session name change`);
session.pendingTitleToInject = newTitle; session.pendingTitleToInject = newTitle;
session.titleUpdateNeeded = true;
// Start injection monitor if not already running // Start injection monitor if not already running
if (!session.titleInjectionTimer) { if (!session.titleInjectionTimer) {
this.startTitleInjectionMonitor(session); 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 // Clear flag
session.titleUpdateNeeded = false; session.titleUpdateNeeded = false;
@ -1874,6 +1915,10 @@ export class PtyManager extends EventEmitter {
session.stdoutQueue.enqueue(async () => { session.stdoutQueue.enqueue(async () => {
try { try {
logger.debug(`[Title Injection] Writing title to stdout for session ${session.id}:`, {
title: `${titleToInject.substring(0, 50)}...`,
});
const canWrite = process.stdout.write(titleToInject); const canWrite = process.stdout.write(titleToInject);
if (!canWrite) { if (!canWrite) {
@ -1883,6 +1928,8 @@ export class PtyManager extends EventEmitter {
// Update tracking after successful write // Update tracking after successful write
session.currentTitle = titleToInject; session.currentTitle = titleToInject;
logger.debug(`[Title Injection] Successfully injected title for session ${session.id}`);
// Clear pending title only after successful write // Clear pending title only after successful write
if (session.pendingTitleToInject === titleToInject) { if (session.pendingTitleToInject === titleToInject) {
session.pendingTitleToInject = undefined; session.pendingTitleToInject = undefined;
@ -1916,6 +1963,15 @@ export class PtyManager extends EventEmitter {
const currentDir = session.currentWorkingDir || session.sessionInfo.workingDir; 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) { if (session.titleMode === TitleMode.STATIC) {
return generateTitleSequence( return generateTitleSequence(
currentDir, currentDir,
@ -1924,6 +1980,12 @@ export class PtyManager extends EventEmitter {
); );
} else if (session.titleMode === TitleMode.DYNAMIC && session.activityDetector) { } else if (session.titleMode === TitleMode.DYNAMIC && session.activityDetector) {
const activity = session.activityDetector.getActivityState(); const activity = session.activityDetector.getActivityState();
logger.debug(`[generateTerminalTitle] Calling generateDynamicTitle with:`, {
currentDir,
command: session.sessionInfo.command,
sessionName: session.sessionInfo.name,
activity: activity,
});
return generateDynamicTitle( return generateDynamicTitle(
currentDir, currentDir,
session.sessionInfo.command, session.sessionInfo.command,

View file

@ -112,15 +112,40 @@ export class SessionManager {
saveSessionInfo(sessionId: string, sessionInfo: SessionInfo): void { saveSessionInfo(sessionId: string, sessionInfo: SessionInfo): void {
this.validateSessionId(sessionId); this.validateSessionId(sessionId);
try { 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); const sessionInfoStr = JSON.stringify(sessionInfo, null, 2);
// Write to temporary file first, then move to final location (atomic write) // 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'); 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); fs.renameSync(tempPath, sessionJsonPath);
logger.debug(`session info saved for ${sessionId}`); logger.debug(`session info saved for ${sessionId}`);
} catch (error) { } catch (error) {
if (error instanceof PtyError) {
throw error;
}
throw new PtyError( throw new PtyError(
`Failed to save session info: ${error instanceof Error ? error.message : String(error)}`, `Failed to save session info: ${error instanceof Error ? error.message : String(error)}`,
'SAVE_SESSION_FAILED' 'SAVE_SESSION_FAILED'
@ -278,9 +303,19 @@ export class SessionManager {
const sessionDir = path.join(this.controlPath, sessionId); const sessionDir = path.join(this.controlPath, sessionId);
if (fs.existsSync(sessionDir)) { 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 // Remove directory and all contents
fs.rmSync(sessionDir, { recursive: true, force: true }); fs.rmSync(sessionDir, { recursive: true, force: true });
logger.log(chalk.green(`session ${sessionId} cleaned up`)); logger.log(chalk.green(`session ${sessionId} cleaned up`));
} else {
logger.debug(`Session directory ${sessionDir} does not exist, nothing to clean up`);
} }
} catch (error) { } catch (error) {
throw new PtyError( throw new PtyError(

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import { Terminal as XtermTerminal } from '@xterm/headless';
import chalk from 'chalk'; import chalk from 'chalk';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ErrorDeduplicator, formatErrorSummary } from '../utils/error-deduplicator.js';
import { createLogger } from '../utils/logger.js'; import { createLogger } from '../utils/logger.js';
const logger = createLogger('terminal-manager'); const logger = createLogger('terminal-manager');
@ -36,9 +37,33 @@ export class TerminalManager {
private controlDir: string; private controlDir: string;
private bufferListeners: Map<string, Set<BufferChangeListener>> = new Map(); private bufferListeners: Map<string, Set<BufferChangeListener>> = new Map();
private changeTimers: Map<string, NodeJS.Timeout> = 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) { constructor(controlDir: string) {
this.controlDir = controlDir; 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 // Ignore 'i' (input) events
} }
} catch (error) { } 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); 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 // So "6.0" means 6.0k tokens, not 6.0 tokens
const formattedTokens = `${tokens}k`; const formattedTokens = `${tokens}k`;
// No spinner - just action and stats for stable comparison // No spinner - just action and stats for stable comparison
displayText = `${action} (${duration}s, ${direction}${formattedTokens})`; displayText = `${action} (${duration}s, ${direction} ${formattedTokens})`;
} else { } else {
// Simple format without token info // Simple format without token info
displayText = `${action} (${duration}s)`; 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 { return {
filteredData: data, filteredData: data,
activity: { activity: this.getActivityState(),
isActive: isMeaningfulOutput,
lastActivityTime: this.lastActivityTime,
specificStatus:
this.currentStatus && this.detector
? {
app: this.detector.name,
status: this.currentStatus.displayText,
}
: undefined,
},
}; };
} }

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[], command: string[],
sessionName?: string sessionName?: string
): string { ): string {
// Convert absolute path to use ~ for home directory // If we have a session name, use only that
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 (sessionName?.trim()) { if (sessionName?.trim()) {
parts.push(sessionName); // OSC 2 sequence: ESC ] 2 ; <title> BEL
return `\x1B]2;${sessionName}\x07`;
} }
// Format: path · command · session name // Otherwise, fall back to path · command format
const title = parts.join(' · '); 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 // OSC 2 sequence: ESC ] 2 ; <title> BEL
return `\x1B]2;${title}\x07`; return `\x1B]2;${title}\x07`;
@ -145,36 +138,35 @@ export function generateDynamicTitle(
activity: ActivityState, activity: ActivityState,
sessionName?: string sessionName?: string
): string { ): string {
const homeDir = os.homedir(); // Determine base title
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd; let baseTitle: string;
const fullCmd = command[0] || 'shell';
const cmdName = path.basename(fullCmd);
// Build base parts
const baseParts = [displayPath, cmdName];
// Add session name if provided
if (sessionName?.trim()) { 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) { if (activity.specificStatus) {
// Format: status · path · command · session name // Format: status · base title
const title = `${activity.specificStatus.status} · ${baseParts.join(' · ')}`; const title = `${activity.specificStatus.status} · ${baseTitle}`;
return `\x1B]2;${title}\x07`; return `\x1B]2;${title}\x07`;
} }
// Otherwise use generic activity indicator (only when active) // Otherwise use generic activity indicator (only when active)
if (activity.isActive) { if (activity.isActive) {
// Format: ● path · command · session name // Format: ● base title
const title = `${baseParts.join(' · ')}`; const title = `${baseTitle}`;
return `\x1B]2;${title}\x07`; return `\x1B]2;${title}\x07`;
} }
// When idle, no indicator - just path · command · session name // When idle, no indicator - just the base title
const title = baseParts.join(' · ');
// OSC 2 sequence: ESC ] 2 ; <title> BEL // 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(); await client2.connect();
// Set up status listener on client2 // Set up status listener on client2
let receivedStatus: any = null; let receivedStatus: { app: string; status: string } | null = null;
client2.on('status', (status) => { client2.on('status', (status) => {
receivedStatus = status; receivedStatus = status;
}); });
@ -311,7 +311,7 @@ describe('Socket Protocol Integration', () => {
await client.connect(); await client.connect();
// Send some random bytes that don't form a valid message // 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])); socket.write(Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff]));
// Should not crash // 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"`); await execAsync(`${vtScriptPath} title "Test Title"`);
// Should not reach here // Should not reach here
expect.fail('Command should have failed'); expect.fail('Command should have failed');
} catch (error: any) { } catch (error) {
expect(error.code).toBeGreaterThan(0); expect((error as { code: number }).code).toBeGreaterThan(0);
expect(error.stderr).toContain("'vt title' can only be used inside a VibeTunnel session"); expect((error as { stderr: string }).stderr).toContain(
expect(error.stderr).toContain('Start a session first'); "'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 { try {
await execAsync(`${vtScriptPath} title "Test"`, { env }); await execAsync(`${vtScriptPath} title "Test"`, { env });
expect.fail('Should have failed'); expect.fail('Should have failed');
} catch (error: any) { } catch (error) {
expect(error.code).toBeGreaterThan(0); expect((error as { code: number }).code).toBeGreaterThan(0);
expect(error.stderr).toContain('Session file not found'); expect((error as { stderr: string }).stderr).toContain('Session file not found');
} }
}); });

View file

@ -1,7 +1,8 @@
import { fixture } from '@open-wc/testing'; import { fixture } from '@open-wc/testing';
import { LitElement, type TemplateResult } from 'lit'; import { LitElement, type TemplateResult } from 'lit';
import { vi } from 'vitest'; 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'; import { createTestSession } from './test-factories';
/** /**
@ -195,34 +196,27 @@ export async function waitFor(
/** /**
* Creates mock session data for testing * 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 { export function createMockSession(overrides: Partial<Session> = {}): Session {
const baseSession = createTestSession({ // Convert SessionData properties to Session properties if needed
name: overrides.name, const overridesWithLegacy = overrides as Partial<Session> & {
command: overrides.cmdline, cmdline?: string[];
workingDir: overrides.cwd, cwd?: string;
pid: overrides.pid, started_at?: string;
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
}; };
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'); 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', () => { it('should handle empty session name', () => {
const cwd = '/home/user'; const cwd = '/home/user';
const command = ['vim']; const command = ['vim'];

View file

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