mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +00:00
Make popover window sticky during new session creation (#194)
This commit is contained in:
parent
dab2c6056d
commit
2a937eac4a
47 changed files with 2015 additions and 589 deletions
|
|
@ -5,5 +5,9 @@
|
|||
"Bash(rg:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"playwright"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
39
CLAUDE.md
39
CLAUDE.md
|
|
@ -11,10 +11,41 @@ VibeTunnel is a macOS application that allows users to access their terminal ses
|
|||
|
||||
## Critical Development Rules
|
||||
|
||||
- **Never commit and/or push before the user has tested your changes!**
|
||||
- **ABSOLUTELY SUPER IMPORTANT & CRITICAL**: NEVER USE git rebase --skip EVER
|
||||
- **Never create a new branch/PR automatically when you are already on a branch**, even if the changes do not seem to fit into the existing PR. Only do that when explicitly asked. Our workflow is always start from main, make branch, make PR, merge. Then we go back to main and start something else. PRs sometimes contain different features and that's okay.
|
||||
- **IMPORTANT**: When refactoring or improving code, directly modify the existing files. DO NOT create new versions with different file names. Users hate having to manually clean up duplicate files.
|
||||
### ABSOLUTE CARDINAL RULES - VIOLATION MEANS IMMEDIATE FAILURE
|
||||
|
||||
1. **NEVER, EVER, UNDER ANY CIRCUMSTANCES CREATE A NEW BRANCH WITHOUT EXPLICIT USER PERMISSION**
|
||||
- If you are on a branch (not main), you MUST stay on that branch
|
||||
- The user will tell you when to create a new branch with commands like "create a new branch" or "switch to a new branch"
|
||||
- Creating branches without permission causes massive frustration and cleanup work
|
||||
- Even if changes seem unrelated to the current branch, STAY ON THE CURRENT BRANCH
|
||||
|
||||
2. **NEVER commit and/or push before the user has tested your changes!**
|
||||
- Always wait for user confirmation before committing
|
||||
- The user needs to verify changes work correctly first
|
||||
|
||||
3. **ABSOLUTELY FORBIDDEN: NEVER USE `git rebase --skip` EVER**
|
||||
- This command can cause data loss and repository corruption
|
||||
- If you encounter rebase conflicts, ask the user for help
|
||||
|
||||
4. **NEVER create duplicate files with version numbers or suffixes**
|
||||
- When refactoring or improving code, directly modify the existing files
|
||||
- DO NOT create new versions with different file names (e.g., file_v2.ts, file_new.ts)
|
||||
- Users hate having to manually clean up duplicate files
|
||||
|
||||
### Git Workflow Reminders
|
||||
- Our workflow: start from main → create branch → make PR → merge → return to main
|
||||
- PRs sometimes contain multiple different features and that's okay
|
||||
- Always check current branch with `git branch` before making changes
|
||||
- If unsure about branching, ASK THE USER FIRST
|
||||
|
||||
### Terminal Title Management with VT
|
||||
|
||||
When creating pull requests, use the `vt` command to update the terminal title:
|
||||
- Run `vt title "Brief summary - github.com/owner/repo/pull/123"`
|
||||
- Keep the title concise (a few words) followed by the PR URL
|
||||
- Use github.com URL format (not https://) for easy identification
|
||||
- Update the title periodically as work progresses
|
||||
- If `vt` command fails (only works inside VibeTunnel), simply ignore the error and continue
|
||||
|
||||
## Web Development Commands
|
||||
|
||||
|
|
|
|||
9
mac/CLAUDE.md
Normal file
9
mac/CLAUDE.md
Normal 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.
|
||||
|
|
@ -55,6 +55,13 @@
|
|||
ReferencedContainer = "container:VibeTunnel-Mac.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "VIBETUNNEL_DEBUG"
|
||||
value = "1"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import CryptoKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
|
|
@ -207,7 +208,7 @@ final class BunServer {
|
|||
environment["NODE_OPTIONS"] = "--max-old-space-size=4096 --max-semi-space-size=128"
|
||||
|
||||
// Copy only essential environment variables
|
||||
let essentialVars = ["PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE"]
|
||||
let essentialVars = ["PATH", "HOME", "USER", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "VIBETUNNEL_DEBUG"]
|
||||
for key in essentialVars {
|
||||
if let value = ProcessInfo.processInfo.environment[key] {
|
||||
environment[key] = value
|
||||
|
|
@ -429,24 +430,55 @@ final class BunServer {
|
|||
}
|
||||
|
||||
source.setEventHandler { [logHandler] in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// EOF reached
|
||||
cancelSource()
|
||||
return
|
||||
// Read data in a non-blocking way to prevent hangs on large output
|
||||
var buffer = Data()
|
||||
let maxBytesPerRead = 65_536 // 64KB chunks
|
||||
|
||||
// Read available data without blocking
|
||||
while true {
|
||||
var readBuffer = Data(count: maxBytesPerRead)
|
||||
let bytesRead = readBuffer.withUnsafeMutableBytes { bytes in
|
||||
guard let baseAddress = bytes.baseAddress else {
|
||||
logger.error("Failed to get base address for read buffer")
|
||||
return -1
|
||||
}
|
||||
return Darwin.read(handle.fileDescriptor, baseAddress, maxBytesPerRead)
|
||||
}
|
||||
|
||||
if bytesRead > 0 {
|
||||
buffer.append(readBuffer.prefix(bytesRead))
|
||||
|
||||
// Check if more data is immediately available
|
||||
var pollfd = pollfd(fd: handle.fileDescriptor, events: Int16(POLLIN), revents: 0)
|
||||
let pollResult = poll(&pollfd, 1, 0) // 0 timeout = non-blocking
|
||||
|
||||
if pollResult <= 0 || (pollfd.revents & Int16(POLLIN)) == 0 {
|
||||
break // No more data immediately available
|
||||
}
|
||||
} else if bytesRead == 0 {
|
||||
// EOF reached
|
||||
cancelSource()
|
||||
return
|
||||
} else {
|
||||
// Error occurred
|
||||
if errno != EAGAIN && errno != EWOULDBLOCK {
|
||||
logger.error("Read error on stdout: \(String(cString: strerror(errno)))")
|
||||
cancelSource()
|
||||
return
|
||||
}
|
||||
break // No data available right now
|
||||
}
|
||||
}
|
||||
|
||||
if let output = String(data: data, encoding: .utf8) {
|
||||
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .newlines)
|
||||
for line in lines where !line.isEmpty {
|
||||
// Skip shell initialization messages
|
||||
if line.contains("zsh:") || line.hasPrefix("Last login:") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Log to OSLog with appropriate level
|
||||
logHandler.log(line, isError: false)
|
||||
// Process accumulated data
|
||||
if !buffer.isEmpty {
|
||||
if let output = String(data: buffer, encoding: .utf8) {
|
||||
Self.processOutputStatic(output, logHandler: logHandler, isError: false)
|
||||
} else {
|
||||
// If UTF-8 decoding fails, try to decode what we can
|
||||
// Use String(decoding:as:) for lossy conversion
|
||||
let output = String(decoding: buffer, as: UTF8.self)
|
||||
Self.processOutputStatic(output, logHandler: logHandler, isError: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -482,19 +514,55 @@ final class BunServer {
|
|||
}
|
||||
|
||||
source.setEventHandler { [logHandler] in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// EOF reached
|
||||
cancelSource()
|
||||
return
|
||||
// Read data in a non-blocking way to prevent hangs on large output
|
||||
var buffer = Data()
|
||||
let maxBytesPerRead = 65_536 // 64KB chunks
|
||||
|
||||
// Read available data without blocking
|
||||
while true {
|
||||
var readBuffer = Data(count: maxBytesPerRead)
|
||||
let bytesRead = readBuffer.withUnsafeMutableBytes { bytes in
|
||||
guard let baseAddress = bytes.baseAddress else {
|
||||
logger.error("Failed to get base address for read buffer")
|
||||
return -1
|
||||
}
|
||||
return Darwin.read(handle.fileDescriptor, baseAddress, maxBytesPerRead)
|
||||
}
|
||||
|
||||
if bytesRead > 0 {
|
||||
buffer.append(readBuffer.prefix(bytesRead))
|
||||
|
||||
// Check if more data is immediately available
|
||||
var pollfd = pollfd(fd: handle.fileDescriptor, events: Int16(POLLIN), revents: 0)
|
||||
let pollResult = poll(&pollfd, 1, 0) // 0 timeout = non-blocking
|
||||
|
||||
if pollResult <= 0 || (pollfd.revents & Int16(POLLIN)) == 0 {
|
||||
break // No more data immediately available
|
||||
}
|
||||
} else if bytesRead == 0 {
|
||||
// EOF reached
|
||||
cancelSource()
|
||||
return
|
||||
} else {
|
||||
// Error occurred
|
||||
if errno != EAGAIN && errno != EWOULDBLOCK {
|
||||
logger.error("Read error on stderr: \(String(cString: strerror(errno)))")
|
||||
cancelSource()
|
||||
return
|
||||
}
|
||||
break // No data available right now
|
||||
}
|
||||
}
|
||||
|
||||
if let output = String(data: data, encoding: .utf8) {
|
||||
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .newlines)
|
||||
for line in lines where !line.isEmpty {
|
||||
// Log stderr as errors/warnings
|
||||
logHandler.log(line, isError: true)
|
||||
// Process accumulated data
|
||||
if !buffer.isEmpty {
|
||||
if let output = String(data: buffer, encoding: .utf8) {
|
||||
Self.processOutputStatic(output, logHandler: logHandler, isError: true)
|
||||
} else {
|
||||
// If UTF-8 decoding fails, try to decode what we can
|
||||
// Use String(decoding:as:) for lossy conversion
|
||||
let output = String(decoding: buffer, as: UTF8.self)
|
||||
Self.processOutputStatic(output, logHandler: logHandler, isError: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -611,6 +679,50 @@ enum BunServerError: LocalizedError {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Output Processing
|
||||
|
||||
extension BunServer {
|
||||
/// Process output with chunking for large lines and rate limiting awareness
|
||||
fileprivate nonisolated static func processOutputStatic(_ output: String, logHandler: LogHandler, isError: Bool) {
|
||||
let maxLineLength = 4_096 // Max chars per log line to avoid os.log truncation
|
||||
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .newlines)
|
||||
|
||||
for line in lines where !line.isEmpty {
|
||||
// Skip shell initialization messages
|
||||
if line.contains("zsh:") || line.hasPrefix("Last login:") {
|
||||
continue
|
||||
}
|
||||
|
||||
// If line is too long, chunk it to avoid os.log limits
|
||||
if line.count > maxLineLength {
|
||||
// Log that we're chunking a large line
|
||||
logHandler.log("[Large output: \(line.count) chars, chunking...]", isError: isError)
|
||||
|
||||
// Chunk the line
|
||||
var startIndex = line.startIndex
|
||||
var chunkNumber = 1
|
||||
while startIndex < line.endIndex {
|
||||
let endIndex = line.index(startIndex, offsetBy: maxLineLength, limitedBy: line.endIndex) ?? line
|
||||
.endIndex
|
||||
let chunk = String(line[startIndex..<endIndex])
|
||||
logHandler.log("[Chunk \(chunkNumber)] \(chunk)", isError: isError)
|
||||
startIndex = endIndex
|
||||
chunkNumber += 1
|
||||
|
||||
// Add small delay between chunks to avoid rate limiting
|
||||
if chunkNumber % 10 == 0 {
|
||||
usleep(1_000) // 1ms delay every 10 chunks
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log normally
|
||||
logHandler.log(line, isError: isError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LogHandler
|
||||
|
||||
/// A sendable log handler for use in detached tasks
|
||||
|
|
|
|||
|
|
@ -124,20 +124,6 @@ final class SessionMonitor {
|
|||
self.lastError = nil
|
||||
self.lastFetch = Date()
|
||||
|
||||
logger
|
||||
.debug(
|
||||
"Fetched \(sessionsArray.count) sessions, \(sessionsDict.values.count { $0.isRunning }) running"
|
||||
)
|
||||
|
||||
// Debug: Log session details
|
||||
for session in sessionsArray {
|
||||
let pidStr = session.pid.map { String($0) } ?? "nil"
|
||||
logger
|
||||
.debug(
|
||||
"Session \(session.id): status=\(session.status), isRunning=\(session.isRunning), pid=\(pidStr)"
|
||||
)
|
||||
}
|
||||
|
||||
// Update WindowTracker
|
||||
WindowTracker.shared.updateFromSessions(sessionsArray)
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1135,9 +1135,10 @@ final class WindowTracker {
|
|||
}
|
||||
}
|
||||
|
||||
let runningCount = sessions.count { $0.isRunning }
|
||||
logger
|
||||
.debug(
|
||||
"Updated window tracker: \(self.sessionWindowMap.count) active windows, \(sessions.count) total sessions"
|
||||
"Sessions updated: \(sessions.count) total, \(runningCount) running, \(self.sessionWindowMap.count) tracked windows"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ final class CustomMenuWindow: NSPanel {
|
|||
private var _isWindowVisible = false
|
||||
private var frameObserver: Any?
|
||||
private var lastBounds: CGRect = .zero
|
||||
private var maskLayer: CAShapeLayer?
|
||||
|
||||
/// Tracks whether the new session form is currently active
|
||||
var isNewSessionActive = false
|
||||
|
||||
/// Closure to be called when window shows
|
||||
var onShow: (() -> Void)?
|
||||
|
||||
/// Closure to be called when window hides
|
||||
var onHide: (() -> Void)?
|
||||
|
|
@ -77,6 +84,7 @@ final class CustomMenuWindow: NSPanel {
|
|||
cornerRadius: DesignConstants.menuCornerRadius
|
||||
)
|
||||
contentView.layer?.mask = maskLayer
|
||||
self.maskLayer = maskLayer
|
||||
lastBounds = contentView.bounds
|
||||
|
||||
// Update mask when bounds change
|
||||
|
|
@ -91,7 +99,7 @@ final class CustomMenuWindow: NSPanel {
|
|||
let currentBounds = contentView.bounds
|
||||
guard currentBounds != self.lastBounds else { return }
|
||||
self.lastBounds = currentBounds
|
||||
maskLayer.path = self.createSideRoundedPath(
|
||||
self.maskLayer?.path = self.createSideRoundedPath(
|
||||
in: currentBounds,
|
||||
cornerRadius: DesignConstants.menuCornerRadius
|
||||
)
|
||||
|
|
@ -189,6 +197,8 @@ final class CustomMenuWindow: NSPanel {
|
|||
|
||||
// Commit all changes at once
|
||||
CATransaction.commit()
|
||||
|
||||
onShow?()
|
||||
}
|
||||
|
||||
private func displayWindowSafely() {
|
||||
|
|
@ -269,6 +279,7 @@ final class CustomMenuWindow: NSPanel {
|
|||
func hide() {
|
||||
// Mark window as not visible
|
||||
_isWindowVisible = false
|
||||
isNewSessionActive = false // Always reset this state
|
||||
|
||||
// Button state will be reset by StatusBarMenuManager via onHide callback
|
||||
orderOut(nil)
|
||||
|
|
@ -291,11 +302,29 @@ final class CustomMenuWindow: NSPanel {
|
|||
|
||||
guard isVisible else { return }
|
||||
|
||||
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
||||
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [
|
||||
.leftMouseDown,
|
||||
.rightMouseDown
|
||||
]) { [weak self] _ in
|
||||
guard let self, self.isVisible else { return }
|
||||
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
|
||||
// Don't dismiss if new session is active
|
||||
if self.isNewSessionActive {
|
||||
// Check if clicking on status bar button to allow closing via menu icon
|
||||
if let button = self.statusBarButton,
|
||||
let buttonWindow = button.window
|
||||
{
|
||||
let buttonFrame = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil))
|
||||
if buttonFrame.contains(mouseLocation) {
|
||||
// User clicked the menu bar icon, dismiss even with new session active
|
||||
self.hide()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !self.frame.contains(mouseLocation) {
|
||||
self.hide()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import SwiftUI
|
|||
/// Compact new session form designed for the popover
|
||||
struct NewSessionForm: View {
|
||||
@Binding var isPresented: Bool
|
||||
@Environment(ServerManager.self) private var serverManager
|
||||
@Environment(SessionMonitor.self) private var sessionMonitor
|
||||
@Environment(SessionService.self) private var sessionService
|
||||
@Environment(ServerManager.self)
|
||||
private var serverManager
|
||||
@Environment(SessionMonitor.self)
|
||||
private var sessionMonitor
|
||||
@Environment(SessionService.self)
|
||||
private var sessionService
|
||||
|
||||
// Form fields
|
||||
@State private var command = "zsh"
|
||||
|
|
@ -58,14 +61,14 @@ struct NewSessionForm: View {
|
|||
HStack {
|
||||
Button(action: {
|
||||
isPresented = false
|
||||
}) {
|
||||
}, label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
Text("Sessions")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
}
|
||||
}
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary.opacity(0.8))
|
||||
|
||||
|
|
@ -164,7 +167,7 @@ struct NewSessionForm: View {
|
|||
Button(action: {
|
||||
command = cmd.0
|
||||
sessionName = ""
|
||||
}) {
|
||||
}, label: {
|
||||
HStack(spacing: 4) {
|
||||
if let emoji = cmd.1 {
|
||||
Text(emoji)
|
||||
|
|
@ -184,7 +187,7 @@ struct NewSessionForm: View {
|
|||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
|
@ -205,14 +208,14 @@ struct NewSessionForm: View {
|
|||
|
||||
Menu {
|
||||
ForEach(TitleMode.allCases, id: \.self) { mode in
|
||||
Button(action: { titleMode = mode }) {
|
||||
Button(action: { titleMode = mode }, label: {
|
||||
HStack {
|
||||
Text(mode.displayName)
|
||||
if mode == titleMode {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
|
|
|
|||
|
|
@ -1,123 +1,19 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - Visual Indicator Styles
|
||||
// MARK: - Status Bar Visual Indicators
|
||||
|
||||
extension StatusBarController {
|
||||
enum IndicatorStyle {
|
||||
case dots // ●●● 5 (current implementation)
|
||||
case bars // ▪︎▪︎▫︎▫︎▫︎
|
||||
case compact // 2◆5
|
||||
case minimalist // 2|5
|
||||
case meter // [■■□□□]
|
||||
}
|
||||
|
||||
/// Format session counts with the specified visual style
|
||||
func formatSessionIndicator(activeCount: Int, totalCount: Int, style: IndicatorStyle = .dots) -> String {
|
||||
/// Format session counts with minimalist style
|
||||
func formatSessionIndicator(activeCount: Int, idleCount: Int) -> String {
|
||||
let totalCount = activeCount + idleCount
|
||||
guard totalCount > 0 else { return "" }
|
||||
|
||||
switch style {
|
||||
case .dots:
|
||||
return formatDotsIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||
|
||||
case .bars:
|
||||
return formatBarsIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||
|
||||
case .compact:
|
||||
return formatCompactIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||
|
||||
case .minimalist:
|
||||
return formatMinimalistIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||
|
||||
case .meter:
|
||||
return formatMeterIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Indicator Implementations
|
||||
|
||||
private func formatDotsIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||
if activeCount == 0 {
|
||||
// Only idle sessions, show simple count
|
||||
return String(totalCount)
|
||||
} else if activeCount > 0 {
|
||||
// Show active sessions with dots
|
||||
let dots = String(repeating: "●", count: min(activeCount, 3))
|
||||
let suffix = activeCount > 3 ? "+" : ""
|
||||
|
||||
if totalCount > activeCount {
|
||||
// Show active dots with total count
|
||||
return "\(dots)\(suffix) \(totalCount)"
|
||||
} else {
|
||||
// Only active sessions, just show dots
|
||||
return dots + suffix
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func formatBarsIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||
let maxBars = 5
|
||||
let displayCount = min(totalCount, maxBars)
|
||||
let displayActive = min(activeCount, displayCount)
|
||||
|
||||
let activeBars = String(repeating: "▪︎", count: displayActive)
|
||||
let idleBars = String(repeating: "▫︎", count: displayCount - displayActive)
|
||||
|
||||
if totalCount > maxBars {
|
||||
return "\(activeBars)\(idleBars)+"
|
||||
}
|
||||
return activeBars + idleBars
|
||||
}
|
||||
|
||||
private func formatCompactIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||
if activeCount == 0 {
|
||||
"◯\(totalCount)"
|
||||
} else if activeCount == totalCount {
|
||||
"◆\(activeCount)"
|
||||
return "● \(activeCount)"
|
||||
} else {
|
||||
"\(activeCount)◆\(totalCount)"
|
||||
return "\(activeCount) | \(idleCount)"
|
||||
}
|
||||
}
|
||||
|
||||
private func formatMinimalistIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||
if activeCount == 0 {
|
||||
String(totalCount)
|
||||
} else if activeCount == totalCount {
|
||||
"● \(activeCount)"
|
||||
} else {
|
||||
"\(activeCount) | \(totalCount)"
|
||||
}
|
||||
}
|
||||
|
||||
private func formatMeterIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||
let maxSegments = 5
|
||||
let segmentCount = min(totalCount, maxSegments)
|
||||
|
||||
if segmentCount == 0 { return "" }
|
||||
|
||||
let activeSegments = Int(round(Double(activeCount) / Double(totalCount) * Double(segmentCount)))
|
||||
let filled = String(repeating: "■", count: activeSegments)
|
||||
let empty = String(repeating: "□", count: segmentCount - activeSegments)
|
||||
|
||||
return "[\(filled)\(empty)]"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alternative Unicode Characters
|
||||
|
||||
// Other visual indicators we could use:
|
||||
//
|
||||
// Dots and Circles:
|
||||
// • ● ○ ◉ ◯ ◦ ⬤ ⚫ ⚪ ◐ ◑ ◒ ◓
|
||||
//
|
||||
// Squares and Blocks:
|
||||
// ▪ ▫ ◼ ◻ ■ □ ▰ ▱ ◾ ◽
|
||||
//
|
||||
// Bars and Progress:
|
||||
// ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ░ ▒ ▓
|
||||
//
|
||||
// Arrows and Triangles:
|
||||
// ▶ ▷ ▸ ▹ ► ▻
|
||||
//
|
||||
// Special Characters:
|
||||
// ◆ ◇ ♦ ♢ ★ ☆ ✦ ✧
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ final class StatusBarController: NSObject {
|
|||
// MARK: - Core Properties
|
||||
|
||||
private var statusItem: NSStatusItem?
|
||||
private let menuManager: StatusBarMenuManager
|
||||
let menuManager: StatusBarMenuManager
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ final class StatusBarController: NSObject {
|
|||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
|
||||
// Use pushOnPushOff for proper state management
|
||||
button.setButtonType(.pushOnPushOff)
|
||||
button.setButtonType(.toggle)
|
||||
|
||||
// Accessibility
|
||||
button.setAccessibilityTitle("VibeTunnel")
|
||||
|
|
@ -154,16 +154,10 @@ final class StatusBarController: NSObject {
|
|||
|
||||
let activeCount = activeSessions.count
|
||||
let totalCount = sessions.count
|
||||
let idleCount = totalCount - activeCount
|
||||
|
||||
// Format the title with visual indicators
|
||||
// Try different styles by changing this:
|
||||
// .dots (default): ●●● 5
|
||||
// .bars: ▪︎▪︎▫︎▫︎▫︎
|
||||
// .compact: 2◆5
|
||||
// .minimalist: 2|5
|
||||
// .meter: [■■□□□]
|
||||
let indicatorStyle: IndicatorStyle = .minimalist
|
||||
let indicator = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle)
|
||||
// Format the title with minimalist indicator
|
||||
let indicator = formatSessionIndicator(activeCount: activeCount, idleCount: idleCount)
|
||||
button.title = indicator.isEmpty ? "" : " " + indicator
|
||||
|
||||
// Update tooltip
|
||||
|
|
@ -260,6 +254,11 @@ final class StatusBarController: NSObject {
|
|||
menuManager.showCustomWindow(relativeTo: button)
|
||||
}
|
||||
|
||||
func toggleCustomWindow() {
|
||||
guard let button = statusItem?.button else { return }
|
||||
menuManager.toggleCustomWindow(relativeTo: button)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
deinit {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// gross hack: https://stackoverflow.com/questions/26004684/nsstatusbarbutton-keep-highlighted?rq=4
|
||||
/// Didn't manage to keep the highlighted state reliable active with any other way.
|
||||
extension NSStatusBarButton {
|
||||
override public func mouseDown(with event: NSEvent) {
|
||||
super.mouseDown(with: event)
|
||||
self.highlight(true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
|
||||
self.highlight(AppDelegate.shared?.statusBarController?.menuManager.customWindow?.isWindowVisible ?? false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages status bar menu behavior, providing left-click custom view and right-click context menu functionality.
|
||||
@MainActor
|
||||
final class StatusBarMenuManager: NSObject {
|
||||
|
|
@ -21,18 +34,28 @@ final class StatusBarMenuManager: NSObject {
|
|||
private var terminalLauncher: TerminalLauncher?
|
||||
|
||||
// Custom window management
|
||||
private var customWindow: CustomMenuWindow?
|
||||
fileprivate var customWindow: CustomMenuWindow?
|
||||
private weak var statusBarButton: NSStatusBarButton?
|
||||
private weak var currentStatusItem: NSStatusItem?
|
||||
|
||||
// State management
|
||||
/// State management
|
||||
private var menuState: MenuState = .none
|
||||
private var highlightTask: Task<Void, Never>?
|
||||
|
||||
// Track new session state
|
||||
@Published private var isNewSessionActive = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
// Subscribe to new session state changes to update window
|
||||
$isNewSessionActive
|
||||
.sink { [weak self] isActive in
|
||||
self?.customWindow?.isNewSessionActive = isActive
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
|
@ -58,9 +81,6 @@ final class StatusBarMenuManager: NSObject {
|
|||
// MARK: - State Management
|
||||
|
||||
private func updateMenuState(_ newState: MenuState, button: NSStatusBarButton? = nil) {
|
||||
// Cancel any pending highlight task
|
||||
highlightTask?.cancel()
|
||||
|
||||
// Update state
|
||||
menuState = newState
|
||||
|
||||
|
|
@ -95,62 +115,57 @@ final class StatusBarMenuManager: NSObject {
|
|||
// Update menu state to custom window FIRST before any async operations
|
||||
updateMenuState(.customWindow, button: button)
|
||||
|
||||
// Ensure button state is set immediately and persistently
|
||||
button.state = .on
|
||||
|
||||
// Force another button state update to ensure it sticks
|
||||
DispatchQueue.main.async {
|
||||
button.state = .on
|
||||
}
|
||||
|
||||
// Create SessionService instance
|
||||
let sessionService = SessionService(serverManager: serverManager, sessionMonitor: sessionMonitor)
|
||||
|
||||
// Create the main view with all dependencies
|
||||
let mainView = VibeTunnelMenuView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(tailscaleService)
|
||||
.environment(terminalLauncher)
|
||||
.environment(sessionService)
|
||||
// Create the main view with all dependencies and binding
|
||||
let mainView = VibeTunnelMenuView(isNewSessionActive: Binding(
|
||||
get: { [weak self] in self?.isNewSessionActive ?? false },
|
||||
set: { [weak self] in self?.isNewSessionActive = $0 }
|
||||
))
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(tailscaleService)
|
||||
.environment(terminalLauncher)
|
||||
.environment(sessionService)
|
||||
|
||||
// Wrap in custom container for proper styling
|
||||
let containerView = CustomMenuContainer {
|
||||
mainView
|
||||
}
|
||||
|
||||
// Create custom window if needed
|
||||
if customWindow == nil {
|
||||
customWindow = CustomMenuWindow(contentView: containerView)
|
||||
// Hide and cleanup old window before creating new one
|
||||
customWindow?.hide()
|
||||
customWindow = nil
|
||||
customWindow = CustomMenuWindow(contentView: containerView)
|
||||
|
||||
// Set up callback to reset state when window hides
|
||||
customWindow?.onHide = { [weak self] in
|
||||
// Ensure state is reset on main thread
|
||||
Task { @MainActor in
|
||||
self?.updateMenuState(.none)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Hide and cleanup old window before creating new one
|
||||
customWindow?.hide()
|
||||
customWindow = nil
|
||||
// Set up callback to reset state when window hides
|
||||
customWindow?.onHide = { [weak self] in
|
||||
self?.statusBarButton?.highlight(false)
|
||||
|
||||
// Create new window with updated content
|
||||
customWindow = CustomMenuWindow(contentView: containerView)
|
||||
customWindow?.onHide = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.updateMenuState(.none)
|
||||
}
|
||||
// Ensure state is reset on main thread
|
||||
Task { @MainActor in
|
||||
self?.updateMenuState(.none)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the new session state with the window
|
||||
if let window = customWindow {
|
||||
window.isNewSessionActive = isNewSessionActive
|
||||
}
|
||||
|
||||
// Show the custom window
|
||||
customWindow?.show(relativeTo: button)
|
||||
statusBarButton?.highlight(true)
|
||||
}
|
||||
|
||||
func hideCustomWindow() {
|
||||
customWindow?.hide()
|
||||
if customWindow?.isWindowVisible ?? false {
|
||||
customWindow?.hide()
|
||||
}
|
||||
// Reset new session state when hiding
|
||||
isNewSessionActive = false
|
||||
// Button state will be reset by updateMenuState(.none) in the onHide callback
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ struct VibeTunnelMenuView: View {
|
|||
@State private var showingNewSession = false
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
/// Binding to allow external control of new session state
|
||||
@Binding var isNewSessionActive: Bool
|
||||
|
||||
init(isNewSessionActive: Binding<Bool> = .constant(false)) {
|
||||
self._isNewSessionActive = isNewSessionActive
|
||||
}
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case sessionRow(String)
|
||||
case settingsButton
|
||||
|
|
@ -31,11 +38,17 @@ struct VibeTunnelMenuView: View {
|
|||
|
||||
var body: some View {
|
||||
if showingNewSession {
|
||||
NewSessionForm(isPresented: $showingNewSession)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||||
removal: .move(edge: .bottom).combined(with: .opacity)
|
||||
))
|
||||
NewSessionForm(isPresented: Binding(
|
||||
get: { showingNewSession },
|
||||
set: { newValue in
|
||||
showingNewSession = newValue
|
||||
isNewSessionActive = newValue
|
||||
}
|
||||
))
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||||
removal: .move(edge: .bottom).combined(with: .opacity)
|
||||
))
|
||||
} else {
|
||||
mainContent
|
||||
.transition(.asymmetric(
|
||||
|
|
@ -138,6 +151,7 @@ struct VibeTunnelMenuView: View {
|
|||
Button(action: {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
showingNewSession = true
|
||||
isNewSessionActive = true
|
||||
}
|
||||
}) {
|
||||
Label("New Session", systemImage: "plus.square")
|
||||
|
|
@ -331,7 +345,15 @@ struct ServerAddressRow: View {
|
|||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
Button(action: {
|
||||
if let url = url ?? URL(string: "http://\(computedAddress)") {
|
||||
if let providedUrl = url {
|
||||
NSWorkspace.shared.open(providedUrl)
|
||||
} else if computedAddress.starts(with: "127.0.0.1:") {
|
||||
// For localhost, use DashboardURLBuilder
|
||||
if let dashboardURL = DashboardURLBuilder.dashboardURL(port: serverManager.port) {
|
||||
NSWorkspace.shared.open(dashboardURL)
|
||||
}
|
||||
} else if let url = URL(string: "http://\(computedAddress)") {
|
||||
// For other addresses (network IP, etc.), construct URL directly
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
|
|
@ -789,7 +811,7 @@ struct SessionRow: View {
|
|||
let elapsed = Date().timeIntervalSince(startDate)
|
||||
|
||||
if elapsed < 60 {
|
||||
return "just now"
|
||||
return "now"
|
||||
} else if elapsed < 3_600 {
|
||||
let minutes = Int(elapsed / 60)
|
||||
return "\(minutes)m"
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ private struct ServerConfigurationSection: View {
|
|||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let url = URL(string: "http://127.0.0.1:\(serverPort)") {
|
||||
if let url = DashboardURLBuilder.dashboardURL(port: serverPort) {
|
||||
Link(url.absoluteString, destination: url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ struct AccessDashboardPageView: View {
|
|||
VStack(spacing: 12) {
|
||||
// Open Dashboard button
|
||||
Button(action: {
|
||||
if let dashboardURL = URL(string: "http://127.0.0.1:\(serverPort)") {
|
||||
if let dashboardURL = DashboardURLBuilder.dashboardURL(port: serverPort) {
|
||||
NSWorkspace.shared.open(dashboardURL)
|
||||
}
|
||||
}, label: {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,13 @@ struct VibeTunnelApp: App {
|
|||
/// Manages app lifecycle, single instance enforcement, and core services
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
// Needed for some gross menu item highlight hack
|
||||
static weak var shared: AppDelegate?
|
||||
override init() {
|
||||
super.init()
|
||||
Self.shared = self
|
||||
}
|
||||
|
||||
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
|
||||
var app: VibeTunnelApp?
|
||||
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
// Entry point for the server - imports the modular server which starts automatically
|
||||
|
||||
// Suppress xterm.js errors globally - must be before any other imports
|
||||
import { suppressXtermErrors } from './shared/suppress-xterm-errors.js';
|
||||
|
||||
suppressXtermErrors();
|
||||
|
||||
import { startVibeTunnelForward } from './server/fwd.js';
|
||||
import { startVibeTunnelServer } from './server/server.js';
|
||||
import { closeLogger, createLogger, initLogger } from './server/utils/logger.js';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
// Suppress xterm.js errors globally - must be before any other imports
|
||||
import { suppressXtermErrors } from '../shared/suppress-xterm-errors.js';
|
||||
|
||||
suppressXtermErrors();
|
||||
|
||||
import { html, LitElement } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
|
|
|
|||
|
|
@ -156,8 +156,8 @@ describe('FilePicker Component', () => {
|
|||
removeAttribute: vi.fn(),
|
||||
click: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
element.fileInput = mockFileInput as any;
|
||||
} as Pick<HTMLInputElement, 'removeAttribute' | 'click' | 'remove'>;
|
||||
element.fileInput = mockFileInput as HTMLInputElement;
|
||||
|
||||
fileButton?.click();
|
||||
|
||||
|
|
|
|||
247
web/src/client/components/inline-edit.ts
Normal file
247
web/src/client/components/inline-edit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { fixture, html } from '@open-wc/testing';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getTextContent, setupFetchMock } from '@/test/utils/component-helpers';
|
||||
import { setupFetchMock } from '@/test/utils/component-helpers';
|
||||
import { createMockSession } from '@/test/utils/lit-test-utils';
|
||||
import { resetFactoryCounters } from '@/test/utils/test-factories';
|
||||
import type { AuthClient } from '../services/auth-client';
|
||||
|
|
@ -9,9 +9,10 @@ import type { AuthClient } from '../services/auth-client';
|
|||
// Mock AuthClient
|
||||
vi.mock('../services/auth-client');
|
||||
|
||||
// Mock copyToClipboard
|
||||
// Mock copyToClipboard and formatPathForDisplay
|
||||
vi.mock('../utils/path-utils', () => ({
|
||||
copyToClipboard: vi.fn(() => Promise.resolve(true)),
|
||||
formatPathForDisplay: vi.fn((path) => path), // Just return the path as-is for tests
|
||||
}));
|
||||
|
||||
// Import component type
|
||||
|
|
@ -28,6 +29,7 @@ describe('SessionCard', () => {
|
|||
await import('./vibe-terminal-buffer');
|
||||
await import('./copy-icon');
|
||||
await import('./clickable-path');
|
||||
await import('./inline-edit');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -54,7 +56,7 @@ describe('SessionCard', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
element.remove();
|
||||
element?.remove();
|
||||
fetchMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
|
@ -66,9 +68,17 @@ describe('SessionCard', () => {
|
|||
expect(element.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should render session details', () => {
|
||||
const sessionName = getTextContent(element, '.text-accent-green');
|
||||
expect(sessionName).toBeTruthy();
|
||||
it('should render session details', async () => {
|
||||
// Wait for inline-edit to render
|
||||
await element.updateComplete;
|
||||
|
||||
const inlineEdit = element.querySelector('inline-edit') as HTMLElement & { value: string };
|
||||
expect(inlineEdit).toBeTruthy();
|
||||
|
||||
// Check that inline-edit has the correct value
|
||||
const sessionText = inlineEdit?.value;
|
||||
expect(sessionText).toBeTruthy();
|
||||
expect(sessionText).toContain('Test Session');
|
||||
|
||||
// Should have status indicator
|
||||
const statusText = element.textContent;
|
||||
|
|
@ -91,15 +101,19 @@ describe('SessionCard', () => {
|
|||
element.session = createMockSession({ name: 'Test Session' });
|
||||
await element.updateComplete;
|
||||
|
||||
let displayText = getTextContent(element, '.text-accent-green');
|
||||
expect(displayText).toContain('Test Session');
|
||||
let inlineEdit = element.querySelector('inline-edit') as HTMLElement & { value: string };
|
||||
expect(inlineEdit).toBeTruthy();
|
||||
expect(inlineEdit.value).toContain('Test Session');
|
||||
|
||||
// Test without name (falls back to command)
|
||||
element.session = { ...createMockSession({ name: '' }), command: ['npm', 'run', 'dev'] };
|
||||
const sessionWithoutName = createMockSession({ command: ['npm', 'run', 'dev'] });
|
||||
sessionWithoutName.name = ''; // Explicitly set to empty string
|
||||
element.session = sessionWithoutName;
|
||||
await element.updateComplete;
|
||||
|
||||
displayText = getTextContent(element, '.text-accent-green');
|
||||
expect(displayText).toContain('npm run dev');
|
||||
inlineEdit = element.querySelector('inline-edit') as HTMLElement & { value: string };
|
||||
expect(inlineEdit).toBeTruthy();
|
||||
expect(inlineEdit.value).toBe('npm run dev');
|
||||
});
|
||||
|
||||
it('should show running status with success color', async () => {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const logger = createLogger('session-card');
|
|||
import './vibe-terminal-buffer.js';
|
||||
import './copy-icon.js';
|
||||
import './clickable-path.js';
|
||||
import './inline-edit.js';
|
||||
|
||||
@customElement('session-card')
|
||||
export class SessionCard extends LitElement {
|
||||
|
|
@ -194,6 +195,56 @@ export class SessionCard extends LitElement {
|
|||
return frames[this.killingFrame % frames.length];
|
||||
}
|
||||
|
||||
private async handleRename(newName: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${this.session.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.authClient.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ name: newName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
logger.error('Failed to rename session', { errorData, sessionId: this.session.id });
|
||||
throw new Error(`Rename failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Update the local session object
|
||||
this.session = { ...this.session, name: newName };
|
||||
|
||||
// Dispatch event to notify parent components
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('session-renamed', {
|
||||
detail: {
|
||||
sessionId: this.session.id,
|
||||
newName: newName,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
|
||||
logger.log(`Session ${this.session.id} renamed to: ${newName}`);
|
||||
} catch (error) {
|
||||
logger.error('Error renaming session', { error, sessionId: this.session.id });
|
||||
|
||||
// Show error to user
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('session-rename-error', {
|
||||
detail: {
|
||||
sessionId: this.session.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePidClick(e: Event) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
|
@ -241,9 +292,18 @@ export class SessionCard extends LitElement {
|
|||
class="flex justify-between items-center px-3 py-2 border-b border-dark-border bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary"
|
||||
>
|
||||
<div class="text-xs font-mono pr-2 flex-1 min-w-0 text-accent-green">
|
||||
<div class="truncate" title="${this.session.name || this.session.command.join(' ')}">
|
||||
${this.session.name || this.session.command.join(' ')}
|
||||
</div>
|
||||
<inline-edit
|
||||
.value=${this.session.name || this.session.command?.join(' ') || ''}
|
||||
.placeholder=${this.session.command?.join(' ') || ''}
|
||||
.onSave=${async (newName: string) => {
|
||||
try {
|
||||
await this.handleRename(newName);
|
||||
} catch (error) {
|
||||
// Error is already handled in handleRename
|
||||
logger.debug('Rename error caught in onSave', { error });
|
||||
}
|
||||
}}
|
||||
></inline-edit>
|
||||
</div>
|
||||
${
|
||||
this.session.status === 'running' || this.session.status === 'exited'
|
||||
|
|
|
|||
|
|
@ -375,21 +375,21 @@ export class SessionCreateForm extends LitElement {
|
|||
}
|
||||
|
||||
return html`
|
||||
<div class="modal-backdrop flex items-center justify-center">
|
||||
<div class="modal-backdrop flex items-center justify-center p-2 sm:p-4">
|
||||
<div
|
||||
class="modal-content font-mono text-sm w-full max-w-[calc(100vw-1rem)] sm:max-w-md lg:max-w-[576px] mx-2 sm:mx-4"
|
||||
class="modal-content font-mono text-sm w-full max-w-[calc(100vw-1rem)] sm:max-w-md lg:max-w-[576px] max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-4rem)] flex flex-col"
|
||||
style="view-transition-name: create-session-modal"
|
||||
>
|
||||
<div class="p-6 pb-4 mb-3 border-b border-dark-border relative bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary">
|
||||
<h2 class="text-primary text-xl font-bold">New Session</h2>
|
||||
<div class="p-4 sm:p-6 sm:pb-4 mb-2 sm:mb-3 border-b border-dark-border relative bg-gradient-to-r from-dark-bg-secondary to-dark-bg-tertiary flex-shrink-0">
|
||||
<h2 class="text-primary text-lg sm:text-xl font-bold">New Session</h2>
|
||||
<button
|
||||
class="absolute top-6 right-6 text-dark-text-muted hover:text-dark-text transition-all duration-200 p-2 hover:bg-dark-bg-tertiary rounded-lg"
|
||||
class="absolute top-4 right-4 sm:top-6 sm:right-6 text-dark-text-muted hover:text-dark-text transition-all duration-200 p-1.5 sm:p-2 hover:bg-dark-bg-tertiary rounded-lg"
|
||||
@click=${this.handleCancel}
|
||||
title="Close (Esc)"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
class="w-4 h-4 sm:w-5 sm:h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -405,13 +405,13 @@ export class SessionCreateForm extends LitElement {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="p-4 sm:p-6 overflow-y-auto flex-grow">
|
||||
<!-- Session Name -->
|
||||
<div class="mb-5">
|
||||
<label class="form-label text-dark-text-muted">Session Name (Optional):</label>
|
||||
<div class="mb-3 sm:mb-5">
|
||||
<label class="form-label text-dark-text-muted text-xs sm:text-sm">Session Name (Optional):</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input-field"
|
||||
class="input-field py-2 sm:py-3 text-sm"
|
||||
.value=${this.sessionName}
|
||||
@input=${this.handleSessionNameChange}
|
||||
placeholder="My Session"
|
||||
|
|
@ -420,11 +420,11 @@ export class SessionCreateForm extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Command -->
|
||||
<div class="mb-5">
|
||||
<label class="form-label text-dark-text-muted">Command:</label>
|
||||
<div class="mb-3 sm:mb-5">
|
||||
<label class="form-label text-dark-text-muted text-xs sm:text-sm">Command:</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input-field"
|
||||
class="input-field py-2 sm:py-3 text-sm"
|
||||
.value=${this.command}
|
||||
@input=${this.handleCommandChange}
|
||||
placeholder="zsh"
|
||||
|
|
@ -433,24 +433,24 @@ export class SessionCreateForm extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Working Directory -->
|
||||
<div class="mb-5">
|
||||
<label class="form-label text-dark-text-muted">Working Directory:</label>
|
||||
<div class="mb-3 sm:mb-5">
|
||||
<label class="form-label text-dark-text-muted text-xs sm:text-sm">Working Directory:</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="input-field"
|
||||
class="input-field py-2 sm:py-3 text-sm"
|
||||
.value=${this.workingDir}
|
||||
@input=${this.handleWorkingDirChange}
|
||||
placeholder="~/"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
/>
|
||||
<button
|
||||
class="bg-dark-bg-elevated border border-dark-border rounded-lg p-3 font-mono text-dark-text-muted transition-all duration-200 hover:text-primary hover:bg-dark-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0"
|
||||
class="bg-dark-bg-elevated border border-dark-border rounded-lg p-2 sm:p-3 font-mono text-dark-text-muted transition-all duration-200 hover:text-primary hover:bg-dark-surface-hover hover:border-primary hover:shadow-sm flex-shrink-0"
|
||||
@click=${this.handleBrowse}
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
title="Browse directories"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<svg width="14" height="14" class="sm:w-4 sm:h-4" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
d="M1.75 1h5.5c.966 0 1.75.784 1.75 1.75v1h4c.966 0 1.75.784 1.75 1.75v7.75A1.75 1.75 0 0113 15H3a1.75 1.75 0 01-1.75-1.75V2.75C1.25 1.784 1.784 1 1.75 1zM2.75 2.5v10.75c0 .138.112.25.25.25h10a.25.25 0 00.25-.25V5.5a.25.25 0 00-.25-.25H8.75v-2.5a.25.25 0 00-.25-.25h-5.5a.25.25 0 00-.25.25z"
|
||||
/>
|
||||
|
|
@ -460,22 +460,22 @@ export class SessionCreateForm extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Spawn Window Toggle -->
|
||||
<div class="mb-5 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-4">
|
||||
<div class="flex-1 pr-4">
|
||||
<span class="text-dark-text text-sm font-medium">Spawn window</span>
|
||||
<p class="text-xs text-dark-text-muted mt-0.5">Opens native terminal window</p>
|
||||
<div class="mb-3 sm:mb-5 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-3 sm:p-4">
|
||||
<div class="flex-1 pr-3 sm:pr-4">
|
||||
<span class="text-dark-text text-xs sm:text-sm font-medium">Spawn window</span>
|
||||
<p class="text-xs text-dark-text-muted mt-0.5 hidden sm:block">Opens native terminal window</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked="${this.spawnWindow}"
|
||||
@click=${this.handleSpawnWindowChange}
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark-bg ${
|
||||
class="relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark-bg ${
|
||||
this.spawnWindow ? 'bg-primary' : 'bg-dark-border'
|
||||
}"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
>
|
||||
<span
|
||||
class="inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||
class="inline-block h-4 w-4 sm:h-5 sm:w-5 transform rounded-full bg-white transition-transform ${
|
||||
this.spawnWindow ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}"
|
||||
></span>
|
||||
|
|
@ -483,10 +483,10 @@ export class SessionCreateForm extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Terminal Title Mode -->
|
||||
<div class="mb-6 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-4">
|
||||
<div class="flex-1 pr-4">
|
||||
<span class="text-dark-text text-sm font-medium">Terminal Title Mode</span>
|
||||
<p class="text-xs text-dark-text-muted mt-0.5">
|
||||
<div class="mb-4 sm:mb-6 flex items-center justify-between bg-dark-bg-elevated border border-dark-border rounded-lg p-3 sm:p-4">
|
||||
<div class="flex-1 pr-3 sm:pr-4">
|
||||
<span class="text-dark-text text-xs sm:text-sm font-medium">Terminal Title Mode</span>
|
||||
<p class="text-xs text-dark-text-muted mt-0.5 hidden sm:block">
|
||||
${this.getTitleModeDescription()}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -494,8 +494,8 @@ export class SessionCreateForm extends LitElement {
|
|||
<select
|
||||
.value=${this.titleMode}
|
||||
@change=${this.handleTitleModeChange}
|
||||
class="bg-dark-bg-secondary border border-dark-border rounded-lg px-3 py-2 pr-8 text-dark-text text-sm transition-all duration-200 hover:border-primary-hover focus:border-primary focus:outline-none appearance-none cursor-pointer"
|
||||
style="min-width: 140px"
|
||||
class="bg-dark-bg-secondary border border-dark-border rounded-lg px-2 py-1.5 pr-7 sm:px-3 sm:py-2 sm:pr-8 text-dark-text text-xs sm:text-sm transition-all duration-200 hover:border-primary-hover focus:border-primary focus:outline-none appearance-none cursor-pointer"
|
||||
style="min-width: 100px"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
>
|
||||
<option value="${TitleMode.NONE}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.NONE}>None</option>
|
||||
|
|
@ -503,8 +503,8 @@ export class SessionCreateForm extends LitElement {
|
|||
<option value="${TitleMode.STATIC}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.STATIC}>Static</option>
|
||||
<option value="${TitleMode.DYNAMIC}" class="bg-dark-bg-secondary text-dark-text" ?selected=${this.titleMode === TitleMode.DYNAMIC}>Dynamic</option>
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-dark-text-muted">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-1.5 sm:px-2 text-dark-text-muted">
|
||||
<svg class="h-3 w-3 sm:h-4 sm:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -512,41 +512,41 @@ export class SessionCreateForm extends LitElement {
|
|||
</div>
|
||||
|
||||
<!-- Quick Start Section -->
|
||||
<div class="mb-6">
|
||||
<label class="form-label text-dark-text-muted uppercase text-xs tracking-wider mb-3"
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<label class="form-label text-dark-text-muted uppercase text-xs tracking-wider mb-2 sm:mb-3"
|
||||
>Quick Start</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3 mt-2">
|
||||
<div class="grid grid-cols-2 gap-2 sm:gap-3 mt-2">
|
||||
${this.quickStartCommands.map(
|
||||
({ label, command }) => html`
|
||||
<button
|
||||
@click=${() => this.handleQuickStart(command)}
|
||||
class="${
|
||||
this.command === command
|
||||
? 'px-4 py-3 rounded-lg border text-left transition-all bg-primary bg-opacity-10 border-primary text-primary hover:bg-opacity-20 font-medium'
|
||||
: 'px-4 py-3 rounded-lg border text-left transition-all bg-dark-bg-elevated border-dark-border text-dark-text hover:bg-dark-surface-hover hover:border-primary hover:text-primary'
|
||||
? 'px-2 py-2 sm:px-4 sm:py-3 rounded-lg border text-left transition-all bg-primary bg-opacity-10 border-primary text-primary hover:bg-opacity-20 font-medium text-xs sm:text-sm'
|
||||
: 'px-2 py-2 sm:px-4 sm:py-3 rounded-lg border text-left transition-all bg-dark-bg-elevated border-dark-border text-dark-text hover:bg-dark-surface-hover hover:border-primary hover:text-primary text-xs sm:text-sm'
|
||||
}"
|
||||
?disabled=${this.disabled || this.isCreating}
|
||||
>
|
||||
${label === 'gemini' ? '✨ ' : ''}${label === 'claude' ? '✨ ' : ''}${
|
||||
<span class="hidden sm:inline">${label === 'gemini' ? '✨ ' : ''}${label === 'claude' ? '✨ ' : ''}${
|
||||
label === 'pnpm run dev' ? '▶️ ' : ''
|
||||
}${label}
|
||||
}</span>${label}
|
||||
</button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<div class="flex gap-2 sm:gap-3 mt-4 sm:mt-6">
|
||||
<button
|
||||
class="flex-1 bg-dark-bg-elevated border border-dark-border text-dark-text px-6 py-3 rounded-lg font-mono text-sm transition-all duration-200 hover:bg-dark-surface-hover hover:border-dark-border-light"
|
||||
class="flex-1 bg-dark-bg-elevated border border-dark-border text-dark-text px-4 py-2 sm:px-6 sm:py-3 rounded-lg font-mono text-xs sm:text-sm transition-all duration-200 hover:bg-dark-surface-hover hover:border-dark-border-light"
|
||||
@click=${this.handleCancel}
|
||||
?disabled=${this.isCreating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-primary text-black px-6 py-3 rounded-lg font-mono text-sm font-medium transition-all duration-200 hover:bg-primary-hover hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="flex-1 bg-primary text-black px-4 py-2 sm:px-6 sm:py-3 rounded-lg font-mono text-xs sm:text-sm font-medium transition-all duration-200 hover:bg-primary-hover hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click=${this.handleCreate}
|
||||
?disabled=${
|
||||
this.disabled ||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { repeat } from 'lit/directives/repeat.js';
|
|||
import type { Session } from '../../shared/types.js';
|
||||
import type { AuthClient } from '../services/auth-client.js';
|
||||
import './session-card.js';
|
||||
import './inline-edit.js';
|
||||
import { formatSessionDuration } from '../../shared/utils/time.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import { formatPathForDisplay } from '../utils/path-utils.js';
|
||||
|
|
@ -88,6 +89,65 @@ export class SessionList extends LitElement {
|
|||
);
|
||||
}
|
||||
|
||||
private async handleRename(sessionId: string, newName: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.authClient.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ name: newName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
logger.error('Failed to rename session', { errorData, sessionId });
|
||||
throw new Error(`Rename failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Update the local session object
|
||||
const sessionIndex = this.sessions.findIndex((s) => s.id === sessionId);
|
||||
if (sessionIndex >= 0) {
|
||||
this.sessions[sessionIndex] = { ...this.sessions[sessionIndex], name: newName };
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
logger.log(`Session ${sessionId} renamed to: ${newName}`);
|
||||
} catch (error) {
|
||||
logger.error('Error renaming session', { error, sessionId });
|
||||
|
||||
// Show error to user
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('error', {
|
||||
detail: `Failed to rename session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSessionRenamed = (e: CustomEvent) => {
|
||||
const { sessionId, newName } = e.detail;
|
||||
// Update the local session object
|
||||
const sessionIndex = this.sessions.findIndex((s) => s.id === sessionId);
|
||||
if (sessionIndex >= 0) {
|
||||
this.sessions[sessionIndex] = { ...this.sessions[sessionIndex], name: newName };
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
private handleSessionRenameError = (e: CustomEvent) => {
|
||||
const { sessionId, error } = e.detail;
|
||||
logger.error(`failed to rename session ${sessionId}:`, error);
|
||||
|
||||
// Dispatch error event to parent for user notification
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('error', {
|
||||
detail: `Failed to rename session: ${error}`,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
public async handleCleanupExited() {
|
||||
if (this.cleaningExited) return;
|
||||
|
||||
|
|
@ -301,19 +361,21 @@ export class SessionList extends LitElement {
|
|||
? 'text-accent-primary font-medium'
|
||||
: 'text-dark-text group-hover:text-accent-primary transition-colors'
|
||||
}"
|
||||
title="${
|
||||
session.name ||
|
||||
(Array.isArray(session.command)
|
||||
? session.command.join(' ')
|
||||
: session.command)
|
||||
}"
|
||||
>
|
||||
${
|
||||
session.name ||
|
||||
(Array.isArray(session.command)
|
||||
? session.command.join(' ')
|
||||
: session.command)
|
||||
}
|
||||
<inline-edit
|
||||
.value=${
|
||||
session.name ||
|
||||
(Array.isArray(session.command)
|
||||
? session.command.join(' ')
|
||||
: session.command)
|
||||
}
|
||||
.placeholder=${
|
||||
Array.isArray(session.command)
|
||||
? session.command.join(' ')
|
||||
: session.command
|
||||
}
|
||||
.onSave=${(newName: string) => this.handleRename(session.id, newName)}
|
||||
></inline-edit>
|
||||
</div>
|
||||
<div class="text-xs text-dark-text-muted truncate flex items-center gap-1">
|
||||
${(() => {
|
||||
|
|
@ -417,6 +479,8 @@ export class SessionList extends LitElement {
|
|||
@session-select=${this.handleSessionSelect}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-kill-error=${this.handleSessionKillError}
|
||||
@session-renamed=${this.handleSessionRenamed}
|
||||
@session-rename-error=${this.handleSessionRenameError}
|
||||
>
|
||||
</session-card>
|
||||
`
|
||||
|
|
@ -546,6 +610,8 @@ export class SessionList extends LitElement {
|
|||
@session-select=${this.handleSessionSelect}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@session-kill-error=${this.handleSessionKillError}
|
||||
@session-renamed=${this.handleSessionRenamed}
|
||||
@session-rename-error=${this.handleSessionRenameError}
|
||||
>
|
||||
</session-card>
|
||||
`
|
||||
|
|
@ -574,7 +640,7 @@ export class SessionList extends LitElement {
|
|||
if (exitedSessions.length === 0 && runningSessions.length === 0) return '';
|
||||
|
||||
return html`
|
||||
<div class="sticky bottom-0 border-t border-dark-border bg-dark-bg-secondary p-3 flex flex-wrap gap-2 shadow-lg">
|
||||
<div class="sticky bottom-0 border-t border-dark-border bg-dark-bg-secondary p-3 flex flex-wrap gap-2 shadow-lg z-10">
|
||||
<!-- Control buttons with consistent styling -->
|
||||
${
|
||||
exitedSessions.length > 0
|
||||
|
|
|
|||
|
|
@ -55,10 +55,10 @@ describe('SessionView Drag & Drop and Paste', () => {
|
|||
name: 'Test Session',
|
||||
command: ['bash'],
|
||||
workingDir: '/test',
|
||||
status: 'running',
|
||||
status: 'running' as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
} as any;
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import './session-view/mobile-input-overlay.js';
|
|||
import './session-view/ctrl-alpha-overlay.js';
|
||||
import './session-view/width-selector.js';
|
||||
import './session-view/session-header.js';
|
||||
import { authClient } from '../services/auth-client.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import {
|
||||
COMMON_TERMINAL_WIDTHS,
|
||||
|
|
@ -803,6 +804,51 @@ export class SessionView extends LitElement {
|
|||
this.dispatchEvent(new CustomEvent('error', { detail: error }));
|
||||
}
|
||||
|
||||
private async handleRename(event: CustomEvent) {
|
||||
const { sessionId, newName } = event.detail;
|
||||
if (!this.session || sessionId !== this.session.id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authClient.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ name: newName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
logger.error('Failed to rename session', { errorData, sessionId });
|
||||
throw new Error(`Rename failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Update the local session object
|
||||
this.session = { ...this.session, name: newName };
|
||||
|
||||
// Dispatch event to notify parent components
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('session-renamed', {
|
||||
detail: { sessionId, newName },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
|
||||
logger.log(`Session ${sessionId} renamed to: ${newName}`);
|
||||
} catch (error) {
|
||||
logger.error('Error renaming session', { error, sessionId });
|
||||
|
||||
// Show error to user
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('error', {
|
||||
detail: `Failed to rename session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag & Drop handlers
|
||||
private handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
|
|
@ -1071,6 +1117,7 @@ export class SessionView extends LitElement {
|
|||
this.showWidthSelector = false;
|
||||
this.customWidth = '';
|
||||
}}
|
||||
@session-rename=${(e: CustomEvent) => this.handleRename(e)}
|
||||
></session-header>
|
||||
|
||||
<!-- Enhanced Terminal Container -->
|
||||
|
|
|
|||
|
|
@ -248,7 +248,8 @@ export class InputManager {
|
|||
target.contentEditable === 'true' ||
|
||||
target.closest('.monaco-editor') ||
|
||||
target.closest('[data-keybinding-context]') ||
|
||||
target.closest('.editor-container')
|
||||
target.closest('.editor-container') ||
|
||||
target.closest('inline-edit') // Allow typing in inline-edit component
|
||||
) {
|
||||
// Allow normal input in form fields and editors
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,16 @@ export class LifecycleEventManager extends ManagerEventEmitter {
|
|||
|
||||
if (!this.session) return;
|
||||
|
||||
// Check if we're in an inline-edit component FIRST
|
||||
// Since inline-edit uses Shadow DOM, we need to check the composed path
|
||||
const composedPath = e.composedPath();
|
||||
for (const element of composedPath) {
|
||||
if (element instanceof HTMLElement && element.tagName?.toLowerCase() === 'inline-edit') {
|
||||
// Allow the event to pass through to the inline-edit component
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a browser shortcut we should allow
|
||||
const inputManager = this.callbacks.getInputManager();
|
||||
if (inputManager?.isKeyboardShortcut(e)) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { customElement, property } from 'lit/decorators.js';
|
|||
import type { Session } from '../session-list.js';
|
||||
import '../clickable-path.js';
|
||||
import './width-selector.js';
|
||||
import '../inline-edit.js';
|
||||
|
||||
@customElement('session-header')
|
||||
export class SessionHeader extends LitElement {
|
||||
|
|
@ -127,21 +128,21 @@ export class SessionHeader extends LitElement {
|
|||
: ''
|
||||
}
|
||||
<div class="text-dark-text min-w-0 flex-1 overflow-hidden max-w-[50vw] sm:max-w-none">
|
||||
<div
|
||||
class="text-dark-text-bright font-medium text-xs sm:text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title="${
|
||||
this.session.name ||
|
||||
(Array.isArray(this.session.command)
|
||||
? this.session.command.join(' ')
|
||||
: this.session.command)
|
||||
}"
|
||||
>
|
||||
${
|
||||
this.session.name ||
|
||||
(Array.isArray(this.session.command)
|
||||
? this.session.command.join(' ')
|
||||
: this.session.command)
|
||||
}
|
||||
<div class="text-dark-text-bright font-medium text-xs sm:text-sm overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<inline-edit
|
||||
.value=${
|
||||
this.session.name ||
|
||||
(Array.isArray(this.session.command)
|
||||
? this.session.command.join(' ')
|
||||
: this.session.command)
|
||||
}
|
||||
.placeholder=${
|
||||
Array.isArray(this.session.command)
|
||||
? this.session.command.join(' ')
|
||||
: this.session.command
|
||||
}
|
||||
.onSave=${(newName: string) => this.handleRename(newName)}
|
||||
></inline-edit>
|
||||
</div>
|
||||
<div class="text-xs opacity-75 mt-0.5 overflow-hidden">
|
||||
<clickable-path
|
||||
|
|
@ -220,4 +221,20 @@ export class SessionHeader extends LitElement {
|
|||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleRename(newName: string) {
|
||||
if (!this.session) return;
|
||||
|
||||
// Dispatch event to parent component to handle the rename
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('session-rename', {
|
||||
detail: {
|
||||
sessionId: this.session.id,
|
||||
newName: newName,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@
|
|||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { TitleMode } from '../shared/types.js';
|
||||
import { type SessionInfo, TitleMode } from '../shared/types.js';
|
||||
import { PtyManager } from './pty/index.js';
|
||||
import { SessionManager } from './pty/session-manager.js';
|
||||
import { VibeTunnelSocketClient } from './pty/socket-client.js';
|
||||
|
|
@ -22,6 +23,7 @@ import { ActivityDetector } from './utils/activity-detector.js';
|
|||
import { checkAndPatchClaude } from './utils/claude-patcher.js';
|
||||
import { closeLogger, createLogger } from './utils/logger.js';
|
||||
import { generateSessionName } from './utils/session-naming.js';
|
||||
import { generateTitleSequence } from './utils/terminal-title.js';
|
||||
import { BUILD_DATE, GIT_COMMIT, VERSION } from './version.js';
|
||||
|
||||
const logger = createLogger('fwd');
|
||||
|
|
@ -174,11 +176,48 @@ export async function startVibeTunnelForward(args: string[]) {
|
|||
})
|
||||
.join('');
|
||||
|
||||
// Update the title
|
||||
// Update the title via IPC if session is active
|
||||
const socketPath = path.join(controlPath, sessionId, 'ipc.sock');
|
||||
|
||||
// Check if IPC socket exists (session is active)
|
||||
if (fs.existsSync(socketPath)) {
|
||||
logger.debug(`IPC socket found, sending title update via IPC`);
|
||||
|
||||
// Connect to IPC socket and send update-title command
|
||||
const socketClient = new VibeTunnelSocketClient(socketPath, {
|
||||
autoReconnect: false, // One-shot operation
|
||||
});
|
||||
|
||||
try {
|
||||
await socketClient.connect();
|
||||
|
||||
// Send update-title command
|
||||
const sent = socketClient.updateTitle(sanitizedTitle);
|
||||
|
||||
if (sent) {
|
||||
logger.log(`Session title updated via IPC to: ${sanitizedTitle}`);
|
||||
// IPC update succeeded, server will handle the file update
|
||||
socketClient.disconnect();
|
||||
closeLogger();
|
||||
process.exit(0);
|
||||
} else {
|
||||
logger.warn(`Failed to send title update via IPC, falling back to file update`);
|
||||
}
|
||||
|
||||
// Disconnect after sending
|
||||
socketClient.disconnect();
|
||||
} catch (ipcError) {
|
||||
logger.warn(`IPC connection failed: ${ipcError}, falling back to file update`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`No IPC socket found, session might not be active`);
|
||||
}
|
||||
|
||||
// Only update the file if IPC failed or socket doesn't exist
|
||||
sessionInfo.name = sanitizedTitle;
|
||||
sessionManager.saveSessionInfo(sessionId, sessionInfo);
|
||||
|
||||
logger.log(`Session title updated to: ${sanitizedTitle}`);
|
||||
logger.log(`Session title persisted to file: ${sanitizedTitle}`);
|
||||
closeLogger();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
|
|
@ -274,6 +313,10 @@ export async function startVibeTunnelForward(args: string[]) {
|
|||
logger.log(chalk.cyan(`✓ ${modeDescriptions[titleMode]}`));
|
||||
}
|
||||
|
||||
// Variables that need to be accessible in cleanup
|
||||
let sessionFileWatcher: fs.FSWatcher | undefined;
|
||||
let fileWatchDebounceTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const sessionOptions: Parameters<typeof ptyManager.createSession>[1] = {
|
||||
sessionId: finalSessionId,
|
||||
name: sessionName,
|
||||
|
|
@ -307,6 +350,18 @@ export async function startVibeTunnelForward(args: string[]) {
|
|||
cleanupStdout();
|
||||
}
|
||||
|
||||
// Clean up file watchers
|
||||
if (sessionFileWatcher) {
|
||||
sessionFileWatcher.close();
|
||||
sessionFileWatcher = undefined;
|
||||
logger.debug('Closed session file watcher');
|
||||
}
|
||||
if (fileWatchDebounceTimer) {
|
||||
clearTimeout(fileWatchDebounceTimer);
|
||||
}
|
||||
// Stop watching the file
|
||||
fs.unwatchFile(sessionJsonPath);
|
||||
|
||||
// Shutdown PTY manager and exit
|
||||
logger.debug('Shutting down PTY manager');
|
||||
await ptyManager.shutdown();
|
||||
|
|
@ -367,6 +422,119 @@ export async function startVibeTunnelForward(args: string[]) {
|
|||
// Listen for terminal resize events
|
||||
process.stdout.on('resize', resizeHandler);
|
||||
|
||||
// Set up file watcher for session.json changes (for external updates)
|
||||
const sessionJsonPath = path.join(controlPath, result.sessionId, 'session.json');
|
||||
let lastKnownSessionName = result.sessionInfo.name;
|
||||
|
||||
// Set up file watcher with retry logic
|
||||
const setupFileWatcher = async (retryCount = 0) => {
|
||||
const maxRetries = 5;
|
||||
const retryDelay = 500 * 2 ** retryCount; // Exponential backoff
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(sessionJsonPath)) {
|
||||
if (retryCount < maxRetries) {
|
||||
logger.debug(
|
||||
`Session file not found, retrying in ${retryDelay}ms (attempt ${retryCount + 1}/${maxRetries})`
|
||||
);
|
||||
setTimeout(() => setupFileWatcher(retryCount + 1), retryDelay);
|
||||
return;
|
||||
} else {
|
||||
logger.warn(`Session file not found after ${maxRetries} attempts: ${sessionJsonPath}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(`Setting up file watcher for session name changes`);
|
||||
|
||||
// Function to check and update title if session name changed
|
||||
const checkSessionNameChange = () => {
|
||||
try {
|
||||
// Check file still exists before reading
|
||||
if (!fs.existsSync(sessionJsonPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionContent = fs.readFileSync(sessionJsonPath, 'utf-8');
|
||||
const updatedInfo = JSON.parse(sessionContent) as SessionInfo;
|
||||
|
||||
// Check if session name changed
|
||||
if (updatedInfo.name !== lastKnownSessionName) {
|
||||
logger.debug(
|
||||
`[File Watch] Session name changed from "${lastKnownSessionName}" to "${updatedInfo.name}"`
|
||||
);
|
||||
lastKnownSessionName = updatedInfo.name;
|
||||
|
||||
// Always update terminal title when session name changes
|
||||
// Generate new title sequence based on title mode
|
||||
let titleSequence: string;
|
||||
if (titleMode === TitleMode.NONE || titleMode === TitleMode.FILTER) {
|
||||
// For NONE and FILTER modes, just use the session name
|
||||
titleSequence = `\x1B]2;${updatedInfo.name}\x07`;
|
||||
} else {
|
||||
// For STATIC and DYNAMIC, use the full format with path and command
|
||||
titleSequence = generateTitleSequence(cwd, command, updatedInfo.name);
|
||||
}
|
||||
|
||||
// Write title sequence to terminal
|
||||
process.stdout.write(titleSequence);
|
||||
logger.log(`Updated terminal title to "${updatedInfo.name}" via file watcher`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check session.json:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Use fs.watchFile for more reliable file monitoring (polling-based)
|
||||
fs.watchFile(sessionJsonPath, { interval: 500 }, (curr, prev) => {
|
||||
logger.debug(`[File Watch] File stats changed - mtime: ${curr.mtime} vs ${prev.mtime}`);
|
||||
if (curr.mtime !== prev.mtime) {
|
||||
checkSessionNameChange();
|
||||
}
|
||||
});
|
||||
|
||||
// Also use fs.watch as a fallback for immediate notifications
|
||||
try {
|
||||
const sessionDir = path.dirname(sessionJsonPath);
|
||||
sessionFileWatcher = fs.watch(sessionDir, (eventType, filename) => {
|
||||
// Only log in debug mode to avoid noise
|
||||
logger.debug(`[File Watch] Directory event: ${eventType} on ${filename || 'unknown'}`);
|
||||
|
||||
// Check if it's our file
|
||||
// On macOS, filename might be undefined, so we can't filter properly
|
||||
// In that case, skip fs.watch events and rely on fs.watchFile instead
|
||||
if (filename && (filename === 'session.json' || filename === 'session.json.tmp')) {
|
||||
// Debounce rapid changes
|
||||
if (fileWatchDebounceTimer) {
|
||||
clearTimeout(fileWatchDebounceTimer);
|
||||
}
|
||||
fileWatchDebounceTimer = setTimeout(checkSessionNameChange, 100);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to set up fs.watch, relying on fs.watchFile:', error);
|
||||
}
|
||||
|
||||
logger.log(`File watcher successfully set up with polling fallback`);
|
||||
|
||||
// Clean up watcher on error if it was created
|
||||
sessionFileWatcher?.on('error', (error) => {
|
||||
logger.error('File watcher error:', error);
|
||||
sessionFileWatcher?.close();
|
||||
sessionFileWatcher = undefined;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to set up file watcher:', error);
|
||||
if (retryCount < maxRetries) {
|
||||
setTimeout(() => setupFileWatcher(retryCount + 1), retryDelay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start setting up the file watcher after a short delay
|
||||
setTimeout(() => setupFileWatcher(), 500);
|
||||
|
||||
// Set up activity detector for Claude status updates
|
||||
let activityDetector: ActivityDetector | undefined;
|
||||
let cleanupStdout: (() => void) | undefined;
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export class PtyManager extends EventEmitter {
|
|||
string,
|
||||
{ cols: number; rows: number; source: 'browser' | 'terminal'; timestamp: number }
|
||||
>();
|
||||
private sessionEventListeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||
private sessionEventListeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
private lastBellTime = new Map<string, number>(); // Track last bell time per session
|
||||
private sessionExitTimes = new Map<string, number>(); // Track session exit times to avoid false bells
|
||||
private processTreeAnalyzer = new ProcessTreeAnalyzer(); // Process tree analysis for bell source identification
|
||||
|
|
@ -386,9 +386,6 @@ export class PtyManager extends EventEmitter {
|
|||
|
||||
// Note: stdin forwarding is now handled via IPC socket
|
||||
|
||||
// Setup session.json watcher for title updates (vt title command) if needed
|
||||
this.ensureSessionJsonWatcher(session);
|
||||
|
||||
// Initial title will be set when the first output is received
|
||||
// Do not write title sequence to PTY input as it would be sent to the shell
|
||||
|
||||
|
|
@ -452,10 +449,41 @@ export class PtyManager extends EventEmitter {
|
|||
session.titleMode !== TitleMode.FILTER &&
|
||||
forwardToStdout
|
||||
) {
|
||||
// Track last known activity state for change detection
|
||||
let lastKnownActivityState: {
|
||||
isActive: boolean;
|
||||
specificStatus?: string;
|
||||
} | null = null;
|
||||
|
||||
session.titleUpdateInterval = setInterval(() => {
|
||||
// Update activity state file if needed (dynamic mode only)
|
||||
// For dynamic mode, check for activity state changes
|
||||
if (session.titleMode === TitleMode.DYNAMIC && session.activityDetector) {
|
||||
const activityState = session.activityDetector.getActivityState();
|
||||
|
||||
// Check if activity state has changed
|
||||
const activityChanged =
|
||||
lastKnownActivityState === null ||
|
||||
activityState.isActive !== lastKnownActivityState.isActive ||
|
||||
activityState.specificStatus?.status !== lastKnownActivityState.specificStatus;
|
||||
|
||||
if (activityChanged) {
|
||||
// Update last known state
|
||||
lastKnownActivityState = {
|
||||
isActive: activityState.isActive,
|
||||
specificStatus: activityState.specificStatus?.status,
|
||||
};
|
||||
|
||||
// Mark title for update
|
||||
this.markTitleUpdateNeeded(session);
|
||||
|
||||
logger.debug(
|
||||
`Activity state changed for session ${session.id}: ` +
|
||||
`active=${activityState.isActive}, ` +
|
||||
`status=${activityState.specificStatus?.status || 'none'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Always write activity state for external tools
|
||||
this.writeActivityState(session, activityState);
|
||||
}
|
||||
|
||||
|
|
@ -482,7 +510,7 @@ export class PtyManager extends EventEmitter {
|
|||
// Check if activity status changed
|
||||
if (activity.specificStatus?.status !== session.lastActivityStatus) {
|
||||
session.lastActivityStatus = activity.specificStatus?.status;
|
||||
session.titleUpdateNeeded = true;
|
||||
this.markTitleUpdateNeeded(session);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -490,7 +518,7 @@ export class PtyManager extends EventEmitter {
|
|||
if (session.titleMode === TitleMode.STATIC && forwardToStdout) {
|
||||
// Check if we should update title based on data content
|
||||
if (!session.initialTitleSent || shouldInjectTitle(processedData)) {
|
||||
session.titleUpdateNeeded = true;
|
||||
this.markTitleUpdateNeeded(session);
|
||||
if (!session.initialTitleSent) {
|
||||
session.initialTitleSent = true;
|
||||
}
|
||||
|
|
@ -571,7 +599,7 @@ export class PtyManager extends EventEmitter {
|
|||
forwardToStdout &&
|
||||
(session.titleMode === TitleMode.STATIC || session.titleMode === TitleMode.DYNAMIC)
|
||||
) {
|
||||
session.titleUpdateNeeded = true;
|
||||
this.markTitleUpdateNeeded(session);
|
||||
session.initialTitleSent = true;
|
||||
logger.debug(`Marked initial title update for session ${session.id}`);
|
||||
}
|
||||
|
|
@ -690,106 +718,6 @@ export class PtyManager extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure session.json watcher is initialized when needed
|
||||
*/
|
||||
private ensureSessionJsonWatcher(session: PtySession): void {
|
||||
if (
|
||||
!session.sessionJsonWatcher &&
|
||||
(session.titleMode === TitleMode.STATIC || session.titleMode === TitleMode.DYNAMIC)
|
||||
) {
|
||||
this.setupSessionJsonWatcher(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup watcher for session.json changes (for vt title updates)
|
||||
*/
|
||||
private setupSessionJsonWatcher(session: PtySession): void {
|
||||
try {
|
||||
const { sessionJsonPath } = session;
|
||||
let debounceTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Watch for changes to session.json
|
||||
const watcher = fs.watch(sessionJsonPath, (eventType) => {
|
||||
if (eventType === 'change') {
|
||||
// Debounce file changes to avoid multiple rapid updates
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.handleSessionJsonChange(session);
|
||||
// Clear both timer references after execution
|
||||
session.sessionJsonDebounceTimer = null;
|
||||
debounceTimer = null;
|
||||
}, 100);
|
||||
|
||||
// Update both timer references
|
||||
session.sessionJsonDebounceTimer = timer;
|
||||
debounceTimer = timer;
|
||||
}
|
||||
});
|
||||
|
||||
// Store watcher for cleanup BEFORE setting up error handler
|
||||
session.sessionJsonWatcher = watcher;
|
||||
|
||||
// Add error handling for watcher
|
||||
watcher.on('error', (error) => {
|
||||
logger.error(`Session.json watcher failed for ${session.id}:`, error);
|
||||
this.emit('watcherError', session.id, error);
|
||||
|
||||
// Clean up the failed watcher
|
||||
if (session.sessionJsonWatcher) {
|
||||
session.sessionJsonWatcher.close();
|
||||
session.sessionJsonWatcher = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Unref the watcher so it doesn't keep the process alive
|
||||
watcher.unref();
|
||||
|
||||
logger.debug(`Session.json watcher setup for session ${session.id}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to setup session.json watcher for session ${session.id}:`, error);
|
||||
this.emit('watcherError', session.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session.json file changes (debounced)
|
||||
*/
|
||||
private handleSessionJsonChange(session: PtySession): void {
|
||||
try {
|
||||
// Reload session info
|
||||
const newSessionInfo = this.sessionManager.loadSessionInfo(session.id);
|
||||
if (!newSessionInfo) return;
|
||||
|
||||
// Check if name changed
|
||||
if (newSessionInfo.name !== session.sessionInfo.name) {
|
||||
logger.log(
|
||||
chalk.cyan(
|
||||
`Session ${session.id} name changed: "${session.sessionInfo.name}" → "${newSessionInfo.name}"`
|
||||
)
|
||||
);
|
||||
|
||||
// Update in-memory session info
|
||||
session.sessionInfo.name = newSessionInfo.name;
|
||||
|
||||
// Mark title for update
|
||||
if (session.titleMode !== TitleMode.NONE) {
|
||||
session.titleUpdateNeeded = true;
|
||||
}
|
||||
|
||||
// Emit event for clients
|
||||
this.trackAndEmit('sessionNameChanged', session.id, newSessionInfo.name);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to handle session.json change for session ${session.id}:`, error);
|
||||
this.emit('watcherError', session.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control messages from control pipe
|
||||
*/
|
||||
|
|
@ -835,6 +763,11 @@ export class PtyManager extends EventEmitter {
|
|||
} catch (error) {
|
||||
logger.warn(`Failed to reset session ${session.id} size to terminal size:`, error);
|
||||
}
|
||||
} else if (message.cmd === 'update-title' && typeof message.title === 'string') {
|
||||
// Handle title update via IPC (used by vt title command)
|
||||
logger.debug(`[IPC] Received title update for session ${session.id}: "${message.title}"`);
|
||||
logger.debug(`[IPC] Current session name before update: "${session.sessionInfo.name}"`);
|
||||
this.updateSessionName(session.id, message.title);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -876,7 +809,7 @@ export class PtyManager extends EventEmitter {
|
|||
);
|
||||
if (newDir) {
|
||||
memorySession.currentWorkingDir = newDir;
|
||||
memorySession.titleUpdateNeeded = true;
|
||||
this.markTitleUpdateNeeded(memorySession);
|
||||
logger.debug(`Session ${sessionId} changed directory to: ${newDir}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1098,12 +1031,40 @@ export class PtyManager extends EventEmitter {
|
|||
// Update in-memory session if it exists
|
||||
const memorySession = this.sessions.get(sessionId);
|
||||
if (memorySession?.sessionInfo) {
|
||||
logger.debug(`[PtyManager] Updating in-memory session info`);
|
||||
logger.debug(`[PtyManager] Found in-memory session, updating...`);
|
||||
const oldName = memorySession.sessionInfo.name;
|
||||
memorySession.sessionInfo.name = name;
|
||||
|
||||
logger.debug(`[PtyManager] Session info after update:`, {
|
||||
sessionId: memorySession.id,
|
||||
newName: memorySession.sessionInfo.name,
|
||||
oldCurrentTitle: `${memorySession.currentTitle?.substring(0, 50)}...`,
|
||||
});
|
||||
|
||||
// Force immediate title update for active sessions
|
||||
// For session name changes, always update title regardless of mode
|
||||
if (memorySession.isExternalTerminal && memorySession.stdoutQueue) {
|
||||
logger.debug(`[PtyManager] Forcing immediate title update for session ${sessionId}`, {
|
||||
titleMode: memorySession.titleMode,
|
||||
hadCurrentTitle: !!memorySession.currentTitle,
|
||||
titleUpdateNeeded: memorySession.titleUpdateNeeded,
|
||||
});
|
||||
// Clear current title to force regeneration
|
||||
memorySession.currentTitle = undefined;
|
||||
this.updateTerminalTitleForSessionName(memorySession);
|
||||
}
|
||||
|
||||
logger.log(`[PtyManager] Updated session ${sessionId} name from "${oldName}" to "${name}"`);
|
||||
} else {
|
||||
logger.debug(`[PtyManager] No in-memory session found for ${sessionId}`);
|
||||
logger.debug(`[PtyManager] No in-memory session found for ${sessionId}`, {
|
||||
sessionsMapSize: this.sessions.size,
|
||||
sessionIds: Array.from(this.sessions.keys()),
|
||||
});
|
||||
}
|
||||
|
||||
// Emit event for clients to refresh their session data
|
||||
this.trackAndEmit('sessionNameChanged', sessionId, name);
|
||||
|
||||
logger.log(`[PtyManager] Updated session ${sessionId} name to: ${name}`);
|
||||
}
|
||||
|
||||
|
|
@ -1422,6 +1383,7 @@ export class PtyManager extends EventEmitter {
|
|||
if (!sessionPaths) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const activityPath = path.join(sessionPaths.controlDir, 'claude-activity.json');
|
||||
|
||||
if (fs.existsSync(activityPath)) {
|
||||
|
|
@ -1728,8 +1690,8 @@ export class PtyManager extends EventEmitter {
|
|||
/**
|
||||
* Track and emit events for proper cleanup
|
||||
*/
|
||||
private trackAndEmit(event: string, sessionId: string, ...args: any[]): void {
|
||||
const listeners = this.listeners(event) as ((...args: any[]) => void)[];
|
||||
private trackAndEmit(event: string, sessionId: string, ...args: unknown[]): void {
|
||||
const listeners = this.listeners(event) as ((...args: unknown[]) => void)[];
|
||||
if (!this.sessionEventListeners.has(sessionId)) {
|
||||
this.sessionEventListeners.set(sessionId, new Set());
|
||||
}
|
||||
|
|
@ -1779,15 +1741,6 @@ export class PtyManager extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// Close session.json watcher and clear debounce timer
|
||||
if (session.sessionJsonDebounceTimer) {
|
||||
clearTimeout(session.sessionJsonDebounceTimer);
|
||||
session.sessionJsonDebounceTimer = null;
|
||||
}
|
||||
if (session.sessionJsonWatcher) {
|
||||
session.sessionJsonWatcher.close();
|
||||
}
|
||||
|
||||
// Note: stdin handling is done via IPC socket, no global listeners to clean up
|
||||
|
||||
// Remove all event listeners for this session
|
||||
|
|
@ -1812,26 +1765,114 @@ export class PtyManager extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check if title needs updating and write if changed
|
||||
* Mark session for title update and trigger immediate check
|
||||
*/
|
||||
private checkAndUpdateTitle(session: PtySession): void {
|
||||
if (!session.titleUpdateNeeded || !session.stdoutQueue || !session.isExternalTerminal) {
|
||||
private markTitleUpdateNeeded(session: PtySession): void {
|
||||
logger.debug(`[markTitleUpdateNeeded] Called for session ${session.id}`, {
|
||||
titleMode: session.titleMode,
|
||||
sessionName: session.sessionInfo.name,
|
||||
titleUpdateNeeded: session.titleUpdateNeeded,
|
||||
});
|
||||
|
||||
if (!session.titleMode || session.titleMode === TitleMode.NONE) {
|
||||
logger.debug(`[markTitleUpdateNeeded] Skipping - title mode is NONE or undefined`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new title
|
||||
const newTitle = this.generateTerminalTitle(session);
|
||||
session.titleUpdateNeeded = true;
|
||||
logger.debug(`[markTitleUpdateNeeded] Set titleUpdateNeeded=true, calling checkAndUpdateTitle`);
|
||||
this.checkAndUpdateTitle(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update terminal title specifically for session name changes
|
||||
* This bypasses title mode checks to ensure name changes are always reflected
|
||||
*/
|
||||
private updateTerminalTitleForSessionName(session: PtySession): void {
|
||||
if (!session.stdoutQueue || !session.isExternalTerminal) {
|
||||
logger.debug(
|
||||
`[updateTerminalTitleForSessionName] Early return - no stdout queue or not external terminal`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For NONE mode, just use the session name
|
||||
// For other modes, regenerate the title with the new name
|
||||
let newTitle: string | null = null;
|
||||
|
||||
if (
|
||||
!session.titleMode ||
|
||||
session.titleMode === TitleMode.NONE ||
|
||||
session.titleMode === TitleMode.FILTER
|
||||
) {
|
||||
// In NONE or FILTER mode, use simple session name
|
||||
newTitle = generateTitleSequence(
|
||||
session.currentWorkingDir || session.sessionInfo.workingDir,
|
||||
session.sessionInfo.command,
|
||||
session.sessionInfo.name || 'VibeTunnel'
|
||||
);
|
||||
} else {
|
||||
// For STATIC and DYNAMIC modes, use the standard generation logic
|
||||
newTitle = this.generateTerminalTitle(session);
|
||||
}
|
||||
|
||||
// Only proceed if title changed
|
||||
if (newTitle && newTitle !== session.currentTitle) {
|
||||
// Store pending title
|
||||
logger.debug(`[updateTerminalTitleForSessionName] Updating title for session name change`);
|
||||
session.pendingTitleToInject = newTitle;
|
||||
session.titleUpdateNeeded = true;
|
||||
|
||||
// Start injection monitor if not already running
|
||||
if (!session.titleInjectionTimer) {
|
||||
this.startTitleInjectionMonitor(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if title needs updating and write if changed
|
||||
*/
|
||||
private checkAndUpdateTitle(session: PtySession): void {
|
||||
logger.debug(`[checkAndUpdateTitle] Called for session ${session.id}`, {
|
||||
titleUpdateNeeded: session.titleUpdateNeeded,
|
||||
hasStdoutQueue: !!session.stdoutQueue,
|
||||
isExternalTerminal: session.isExternalTerminal,
|
||||
sessionName: session.sessionInfo.name,
|
||||
});
|
||||
|
||||
if (!session.titleUpdateNeeded || !session.stdoutQueue || !session.isExternalTerminal) {
|
||||
logger.debug(`[checkAndUpdateTitle] Early return - conditions not met`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new title
|
||||
logger.debug(`[checkAndUpdateTitle] Generating new title...`);
|
||||
const newTitle = this.generateTerminalTitle(session);
|
||||
|
||||
// Debug logging for title updates
|
||||
logger.debug(`[Title Update] Session ${session.id}:`, {
|
||||
sessionName: session.sessionInfo.name,
|
||||
newTitle: newTitle ? `${newTitle.substring(0, 50)}...` : null,
|
||||
currentTitle: session.currentTitle ? `${session.currentTitle.substring(0, 50)}...` : null,
|
||||
titleChanged: newTitle !== session.currentTitle,
|
||||
});
|
||||
|
||||
// Only proceed if title changed
|
||||
if (newTitle && newTitle !== session.currentTitle) {
|
||||
logger.debug(`[checkAndUpdateTitle] Title changed, queueing for injection`);
|
||||
// Store pending title
|
||||
session.pendingTitleToInject = newTitle;
|
||||
|
||||
// Start injection monitor if not already running
|
||||
if (!session.titleInjectionTimer) {
|
||||
logger.debug(`[checkAndUpdateTitle] Starting title injection monitor`);
|
||||
this.startTitleInjectionMonitor(session);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`[checkAndUpdateTitle] Title unchanged or null, skipping injection`, {
|
||||
newTitleNull: !newTitle,
|
||||
titlesEqual: newTitle === session.currentTitle,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear flag
|
||||
session.titleUpdateNeeded = false;
|
||||
|
|
@ -1874,6 +1915,10 @@ export class PtyManager extends EventEmitter {
|
|||
|
||||
session.stdoutQueue.enqueue(async () => {
|
||||
try {
|
||||
logger.debug(`[Title Injection] Writing title to stdout for session ${session.id}:`, {
|
||||
title: `${titleToInject.substring(0, 50)}...`,
|
||||
});
|
||||
|
||||
const canWrite = process.stdout.write(titleToInject);
|
||||
|
||||
if (!canWrite) {
|
||||
|
|
@ -1883,6 +1928,8 @@ export class PtyManager extends EventEmitter {
|
|||
// Update tracking after successful write
|
||||
session.currentTitle = titleToInject;
|
||||
|
||||
logger.debug(`[Title Injection] Successfully injected title for session ${session.id}`);
|
||||
|
||||
// Clear pending title only after successful write
|
||||
if (session.pendingTitleToInject === titleToInject) {
|
||||
session.pendingTitleToInject = undefined;
|
||||
|
|
@ -1916,6 +1963,15 @@ export class PtyManager extends EventEmitter {
|
|||
|
||||
const currentDir = session.currentWorkingDir || session.sessionInfo.workingDir;
|
||||
|
||||
logger.debug(`[generateTerminalTitle] Session ${session.id}:`, {
|
||||
titleMode: session.titleMode,
|
||||
sessionName: session.sessionInfo.name,
|
||||
sessionInfoObjectId: session.sessionInfo,
|
||||
currentDir,
|
||||
command: session.sessionInfo.command,
|
||||
activityDetectorExists: !!session.activityDetector,
|
||||
});
|
||||
|
||||
if (session.titleMode === TitleMode.STATIC) {
|
||||
return generateTitleSequence(
|
||||
currentDir,
|
||||
|
|
@ -1924,6 +1980,12 @@ export class PtyManager extends EventEmitter {
|
|||
);
|
||||
} else if (session.titleMode === TitleMode.DYNAMIC && session.activityDetector) {
|
||||
const activity = session.activityDetector.getActivityState();
|
||||
logger.debug(`[generateTerminalTitle] Calling generateDynamicTitle with:`, {
|
||||
currentDir,
|
||||
command: session.sessionInfo.command,
|
||||
sessionName: session.sessionInfo.name,
|
||||
activity: activity,
|
||||
});
|
||||
return generateDynamicTitle(
|
||||
currentDir,
|
||||
session.sessionInfo.command,
|
||||
|
|
|
|||
|
|
@ -112,15 +112,40 @@ export class SessionManager {
|
|||
saveSessionInfo(sessionId: string, sessionInfo: SessionInfo): void {
|
||||
this.validateSessionId(sessionId);
|
||||
try {
|
||||
const sessionDir = path.join(this.controlPath, sessionId);
|
||||
const sessionJsonPath = path.join(sessionDir, 'session.json');
|
||||
const tempPath = `${sessionJsonPath}.tmp`;
|
||||
|
||||
// Ensure session directory exists before writing
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
logger.warn(`Session directory ${sessionDir} does not exist, creating it`);
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sessionInfoStr = JSON.stringify(sessionInfo, null, 2);
|
||||
|
||||
// Write to temporary file first, then move to final location (atomic write)
|
||||
const sessionJsonPath = path.join(this.controlPath, sessionId, 'session.json');
|
||||
const tempPath = `${sessionJsonPath}.tmp`;
|
||||
fs.writeFileSync(tempPath, sessionInfoStr, 'utf8');
|
||||
|
||||
// Double-check directory still exists before rename (handle race conditions)
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
logger.error(`Session directory ${sessionDir} was deleted during save operation`);
|
||||
// Clean up temp file if it exists
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
throw new PtyError(
|
||||
`Session directory was deleted during save operation`,
|
||||
'SESSION_DIR_DELETED'
|
||||
);
|
||||
}
|
||||
|
||||
fs.renameSync(tempPath, sessionJsonPath);
|
||||
logger.debug(`session info saved for ${sessionId}`);
|
||||
} catch (error) {
|
||||
if (error instanceof PtyError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PtyError(
|
||||
`Failed to save session info: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'SAVE_SESSION_FAILED'
|
||||
|
|
@ -278,9 +303,19 @@ export class SessionManager {
|
|||
const sessionDir = path.join(this.controlPath, sessionId);
|
||||
|
||||
if (fs.existsSync(sessionDir)) {
|
||||
logger.debug(`Cleaning up session directory: ${sessionDir}`);
|
||||
|
||||
// Log session info before cleanup for debugging
|
||||
const sessionInfo = this.loadSessionInfo(sessionId);
|
||||
if (sessionInfo) {
|
||||
logger.debug(`Cleaning up session ${sessionId} with status: ${sessionInfo.status}`);
|
||||
}
|
||||
|
||||
// Remove directory and all contents
|
||||
fs.rmSync(sessionDir, { recursive: true, force: true });
|
||||
logger.log(chalk.green(`session ${sessionId} cleaned up`));
|
||||
} else {
|
||||
logger.debug(`Session directory ${sessionDir} does not exist, nothing to clean up`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new PtyError(
|
||||
|
|
|
|||
|
|
@ -212,6 +212,13 @@ export class VibeTunnelSocketClient extends EventEmitter {
|
|||
return this.send(MessageBuilder.resetSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update title command
|
||||
*/
|
||||
updateTitle(title: string): boolean {
|
||||
return this.send(MessageBuilder.updateTitle(title));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send status update
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ export interface ResetSizeCommand extends ControlCommand {
|
|||
cmd: 'reset-size';
|
||||
}
|
||||
|
||||
export interface UpdateTitleCommand extends ControlCommand {
|
||||
cmd: 'update-title';
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status update payload
|
||||
*/
|
||||
|
|
@ -133,35 +138,39 @@ export class MessageParser {
|
|||
/**
|
||||
* High-level message creation helpers
|
||||
*/
|
||||
export class MessageBuilder {
|
||||
static stdin(data: string): Buffer {
|
||||
export const MessageBuilder = {
|
||||
stdin(data: string): Buffer {
|
||||
return frameMessage(MessageType.STDIN_DATA, data);
|
||||
}
|
||||
},
|
||||
|
||||
static resize(cols: number, rows: number): Buffer {
|
||||
resize(cols: number, rows: number): Buffer {
|
||||
return frameMessage(MessageType.CONTROL_CMD, { cmd: 'resize', cols, rows });
|
||||
}
|
||||
},
|
||||
|
||||
static kill(signal?: string | number): Buffer {
|
||||
kill(signal?: string | number): Buffer {
|
||||
return frameMessage(MessageType.CONTROL_CMD, { cmd: 'kill', signal });
|
||||
}
|
||||
},
|
||||
|
||||
static resetSize(): Buffer {
|
||||
resetSize(): Buffer {
|
||||
return frameMessage(MessageType.CONTROL_CMD, { cmd: 'reset-size' });
|
||||
}
|
||||
},
|
||||
|
||||
static status(app: string, status: string, extra?: Record<string, unknown>): Buffer {
|
||||
updateTitle(title: string): Buffer {
|
||||
return frameMessage(MessageType.CONTROL_CMD, { cmd: 'update-title', title });
|
||||
},
|
||||
|
||||
status(app: string, status: string, extra?: Record<string, unknown>): Buffer {
|
||||
return frameMessage(MessageType.STATUS_UPDATE, { app, status, ...extra });
|
||||
}
|
||||
},
|
||||
|
||||
static heartbeat(): Buffer {
|
||||
heartbeat(): Buffer {
|
||||
return frameMessage(MessageType.HEARTBEAT, Buffer.alloc(0));
|
||||
}
|
||||
},
|
||||
|
||||
static error(code: string, message: string, details?: unknown): Buffer {
|
||||
error(code: string, message: string, details?: unknown): Buffer {
|
||||
return frameMessage(MessageType.ERROR, { code, message, details });
|
||||
}
|
||||
}
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Parse payload based on message type
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
* These types match the tty-fwd format to ensure compatibility
|
||||
*/
|
||||
|
||||
import type * as fs from 'fs';
|
||||
import type * as net from 'net';
|
||||
import type { IPty } from 'node-pty';
|
||||
import type { SessionInfo, TitleMode } from '../../shared/types.js';
|
||||
|
|
@ -70,8 +69,6 @@ export interface PtySession {
|
|||
startTime: Date;
|
||||
// Optional fields for resource cleanup
|
||||
inputSocketServer?: net.Server;
|
||||
sessionJsonWatcher?: fs.FSWatcher;
|
||||
sessionJsonDebounceTimer?: NodeJS.Timeout | null;
|
||||
stdoutQueue?: WriteQueue;
|
||||
// Terminal title mode
|
||||
titleMode?: TitleMode;
|
||||
|
|
|
|||
|
|
@ -53,39 +53,64 @@ export class ControlDirWatcher {
|
|||
const sessionJsonPath = path.join(sessionPath, 'session.json');
|
||||
|
||||
try {
|
||||
// Give it a moment for the session.json to be written
|
||||
logger.debug(`Waiting 100ms for session.json to be written for ${filename}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
// Check if this is a directory creation event
|
||||
if (fs.existsSync(sessionPath) && fs.statSync(sessionPath).isDirectory()) {
|
||||
// This is a new session directory, wait for session.json with retries
|
||||
const maxRetries = 5;
|
||||
const baseDelay = 100;
|
||||
let sessionData: Record<string, unknown> | null = null;
|
||||
|
||||
if (fs.existsSync(sessionJsonPath)) {
|
||||
// Session was created
|
||||
const sessionData = JSON.parse(fs.readFileSync(sessionJsonPath, 'utf8'));
|
||||
const sessionId = sessionData.session_id || filename;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const delay = baseDelay * 2 ** i; // Exponential backoff: 100, 200, 400, 800, 1600ms
|
||||
logger.debug(
|
||||
`Attempt ${i + 1}/${maxRetries}: Waiting ${delay}ms for session.json for ${filename}`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
logger.log(chalk.blue(`Detected new external session: ${sessionId}`));
|
||||
|
||||
// Check if PtyManager already knows about this session
|
||||
if (this.config.ptyManager) {
|
||||
const existingSession = this.config.ptyManager.getSession(sessionId);
|
||||
if (!existingSession) {
|
||||
// This is a new external session, PtyManager needs to track it
|
||||
logger.log(chalk.green(`Attaching to external session: ${sessionId}`));
|
||||
// PtyManager will pick it up through its own session listing
|
||||
// since it reads from the control directory
|
||||
if (fs.existsSync(sessionJsonPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(sessionJsonPath, 'utf8');
|
||||
sessionData = JSON.parse(content);
|
||||
logger.debug(`Successfully read session.json for ${filename} on attempt ${i + 1}`);
|
||||
break;
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to read/parse session.json on attempt ${i + 1}:`, error);
|
||||
// Continue to next retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we're a remote server registered with HQ, immediately notify HQ
|
||||
if (this.config.hqClient && !isShuttingDown()) {
|
||||
try {
|
||||
await this.notifyHQAboutSession(sessionId, 'created');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to notify HQ about new session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
if (sessionData) {
|
||||
// Session was created
|
||||
const sessionId = (sessionData.id || sessionData.session_id || filename) as string;
|
||||
|
||||
// If we're in HQ mode and this is a local session, no special handling needed
|
||||
// The session is already tracked locally
|
||||
logger.log(chalk.blue(`Detected new external session: ${sessionId}`));
|
||||
|
||||
// Check if PtyManager already knows about this session
|
||||
if (this.config.ptyManager) {
|
||||
const existingSession = this.config.ptyManager.getSession(sessionId);
|
||||
if (!existingSession) {
|
||||
// This is a new external session, PtyManager needs to track it
|
||||
logger.log(chalk.green(`Attaching to external session: ${sessionId}`));
|
||||
// PtyManager will pick it up through its own session listing
|
||||
// since it reads from the control directory
|
||||
}
|
||||
}
|
||||
|
||||
// If we're a remote server registered with HQ, immediately notify HQ
|
||||
if (this.config.hqClient && !isShuttingDown()) {
|
||||
try {
|
||||
await this.notifyHQAboutSession(sessionId, 'created');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to notify HQ about new session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// If we're in HQ mode and this is a local session, no special handling needed
|
||||
// The session is already tracked locally
|
||||
} else {
|
||||
logger.warn(`Session.json not found for ${filename} after ${maxRetries} retries`);
|
||||
}
|
||||
} else if (!fs.existsSync(sessionPath)) {
|
||||
// Session directory was removed
|
||||
const sessionId = filename;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Terminal as XtermTerminal } from '@xterm/headless';
|
|||
import chalk from 'chalk';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ErrorDeduplicator, formatErrorSummary } from '../utils/error-deduplicator.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createLogger('terminal-manager');
|
||||
|
|
@ -36,9 +37,33 @@ export class TerminalManager {
|
|||
private controlDir: string;
|
||||
private bufferListeners: Map<string, Set<BufferChangeListener>> = new Map();
|
||||
private changeTimers: Map<string, NodeJS.Timeout> = new Map();
|
||||
private errorDeduplicator = new ErrorDeduplicator({
|
||||
keyExtractor: (error, context) => {
|
||||
// Use session ID and line prefix as context for xterm parsing errors
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return `${context}:${errorMessage}`;
|
||||
},
|
||||
});
|
||||
private originalConsoleWarn: typeof console.warn;
|
||||
|
||||
constructor(controlDir: string) {
|
||||
this.controlDir = controlDir;
|
||||
|
||||
// Override console.warn to suppress xterm.js parsing warnings
|
||||
this.originalConsoleWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
const message = args[0];
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
(message.includes('xterm.js parsing error') ||
|
||||
message.includes('Unable to process character') ||
|
||||
message.includes('Cannot read properties of undefined'))
|
||||
) {
|
||||
// Suppress xterm.js parsing warnings
|
||||
return;
|
||||
}
|
||||
this.originalConsoleWarn.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -191,7 +216,25 @@ export class TerminalManager {
|
|||
// Ignore 'i' (input) events
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to parse stream line for session ${sessionId}:`, error);
|
||||
// Use deduplicator to check if we should log this error
|
||||
// Use a more generic context key to group similar parsing errors together
|
||||
const contextKey = `${sessionId}:parse-stream-line`;
|
||||
|
||||
if (this.errorDeduplicator.shouldLog(error, contextKey)) {
|
||||
const stats = this.errorDeduplicator.getErrorStats(error, contextKey);
|
||||
|
||||
if (stats && stats.count > 1) {
|
||||
// Log summary for repeated errors
|
||||
logger.warn(formatErrorSummary(error, stats, `session ${sessionId}`));
|
||||
} else {
|
||||
// First occurrence - log the error with details
|
||||
const truncatedLine = line.length > 100 ? `${line.substring(0, 100)}...` : line;
|
||||
logger.error(`Failed to parse stream line for session ${sessionId}: ${truncatedLine}`);
|
||||
if (error instanceof Error && error.stack) {
|
||||
logger.debug(`Parse error details: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -706,4 +749,23 @@ export class TerminalManager {
|
|||
logger.error(`Error getting buffer snapshot for notification ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the terminal manager and restore console overrides
|
||||
*/
|
||||
destroy(): void {
|
||||
// Close all terminals
|
||||
for (const sessionId of this.terminals.keys()) {
|
||||
this.closeTerminal(sessionId);
|
||||
}
|
||||
|
||||
// Clear all timers
|
||||
for (const timer of this.changeTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.changeTimers.clear();
|
||||
|
||||
// Restore original console.warn
|
||||
console.warn = this.originalConsoleWarn;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ function parseClaudeStatus(data: string): ActivityStatus | null {
|
|||
// So "6.0" means 6.0k tokens, not 6.0 tokens
|
||||
const formattedTokens = `${tokens}k`;
|
||||
// No spinner - just action and stats for stable comparison
|
||||
displayText = `${action} (${duration}s, ${direction}${formattedTokens})`;
|
||||
displayText = `${action} (${duration}s, ${direction} ${formattedTokens})`;
|
||||
} else {
|
||||
// Simple format without token info
|
||||
displayText = `${action} (${duration}s)`;
|
||||
|
|
@ -291,20 +291,10 @@ export class ActivityDetector {
|
|||
}
|
||||
}
|
||||
|
||||
// Generic activity detection
|
||||
// Generic activity detection - use getActivityState for consistent time-based checking
|
||||
return {
|
||||
filteredData: data,
|
||||
activity: {
|
||||
isActive: isMeaningfulOutput,
|
||||
lastActivityTime: this.lastActivityTime,
|
||||
specificStatus:
|
||||
this.currentStatus && this.detector
|
||||
? {
|
||||
app: this.detector.name,
|
||||
status: this.currentStatus.displayText,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
activity: this.getActivityState(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
180
web/src/server/utils/error-deduplicator.ts
Normal file
180
web/src/server/utils/error-deduplicator.ts
Normal 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();
|
||||
|
|
@ -28,25 +28,18 @@ export function generateTitleSequence(
|
|||
command: string[],
|
||||
sessionName?: string
|
||||
): string {
|
||||
// Convert absolute path to use ~ for home directory
|
||||
const homeDir = os.homedir();
|
||||
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd;
|
||||
|
||||
// Get the command name (first element of command array)
|
||||
// Extract just the process name from the full path
|
||||
const fullCmd = command[0] || 'shell';
|
||||
const cmdName = path.basename(fullCmd);
|
||||
|
||||
// Build title parts
|
||||
const parts = [displayPath, cmdName];
|
||||
|
||||
// Add session name if provided
|
||||
// If we have a session name, use only that
|
||||
if (sessionName?.trim()) {
|
||||
parts.push(sessionName);
|
||||
// OSC 2 sequence: ESC ] 2 ; <title> BEL
|
||||
return `\x1B]2;${sessionName}\x07`;
|
||||
}
|
||||
|
||||
// Format: path · command · session name
|
||||
const title = parts.join(' · ');
|
||||
// Otherwise, fall back to path · command format
|
||||
const homeDir = os.homedir();
|
||||
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd;
|
||||
const fullCmd = command[0] || 'shell';
|
||||
const cmdName = path.basename(fullCmd);
|
||||
const title = `${displayPath} · ${cmdName}`;
|
||||
|
||||
// OSC 2 sequence: ESC ] 2 ; <title> BEL
|
||||
return `\x1B]2;${title}\x07`;
|
||||
|
|
@ -145,36 +138,35 @@ export function generateDynamicTitle(
|
|||
activity: ActivityState,
|
||||
sessionName?: string
|
||||
): string {
|
||||
const homeDir = os.homedir();
|
||||
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd;
|
||||
const fullCmd = command[0] || 'shell';
|
||||
const cmdName = path.basename(fullCmd);
|
||||
|
||||
// Build base parts
|
||||
const baseParts = [displayPath, cmdName];
|
||||
|
||||
// Add session name if provided
|
||||
// Determine base title
|
||||
let baseTitle: string;
|
||||
if (sessionName?.trim()) {
|
||||
baseParts.push(sessionName);
|
||||
// Use only the session name
|
||||
baseTitle = sessionName;
|
||||
} else {
|
||||
// Fall back to path · command format
|
||||
const homeDir = os.homedir();
|
||||
const displayPath = cwd.startsWith(homeDir) ? cwd.replace(homeDir, '~') : cwd;
|
||||
const fullCmd = command[0] || 'shell';
|
||||
const cmdName = path.basename(fullCmd);
|
||||
baseTitle = `${displayPath} · ${cmdName}`;
|
||||
}
|
||||
|
||||
// If we have Claude-specific status, put it first
|
||||
// Add activity indicators as prefix
|
||||
if (activity.specificStatus) {
|
||||
// Format: status · path · command · session name
|
||||
const title = `${activity.specificStatus.status} · ${baseParts.join(' · ')}`;
|
||||
// Format: status · base title
|
||||
const title = `${activity.specificStatus.status} · ${baseTitle}`;
|
||||
return `\x1B]2;${title}\x07`;
|
||||
}
|
||||
|
||||
// Otherwise use generic activity indicator (only when active)
|
||||
if (activity.isActive) {
|
||||
// Format: ● path · command · session name
|
||||
const title = `● ${baseParts.join(' · ')}`;
|
||||
// Format: ● base title
|
||||
const title = `● ${baseTitle}`;
|
||||
return `\x1B]2;${title}\x07`;
|
||||
}
|
||||
|
||||
// When idle, no indicator - just path · command · session name
|
||||
const title = baseParts.join(' · ');
|
||||
|
||||
// When idle, no indicator - just the base title
|
||||
// OSC 2 sequence: ESC ] 2 ; <title> BEL
|
||||
return `\x1B]2;${title}\x07`;
|
||||
return `\x1B]2;${baseTitle}\x07`;
|
||||
}
|
||||
|
|
|
|||
108
web/src/shared/suppress-xterm-errors.ts
Normal file
108
web/src/shared/suppress-xterm-errors.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -258,7 +258,7 @@ describe('Socket Protocol Integration', () => {
|
|||
await client2.connect();
|
||||
|
||||
// Set up status listener on client2
|
||||
let receivedStatus: any = null;
|
||||
let receivedStatus: { app: string; status: string } | null = null;
|
||||
client2.on('status', (status) => {
|
||||
receivedStatus = status;
|
||||
});
|
||||
|
|
@ -311,7 +311,7 @@ describe('Socket Protocol Integration', () => {
|
|||
await client.connect();
|
||||
|
||||
// Send some random bytes that don't form a valid message
|
||||
const socket = (client as any).socket;
|
||||
const socket = (client as unknown as { socket: { write: (data: Buffer) => void } }).socket;
|
||||
socket.write(Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff]));
|
||||
|
||||
// Should not crash
|
||||
|
|
|
|||
169
web/src/test/server/error-deduplicator.test.ts
Normal file
169
web/src/test/server/error-deduplicator.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -37,10 +37,12 @@ describe('vt title Command Integration', () => {
|
|||
await execAsync(`${vtScriptPath} title "Test Title"`);
|
||||
// Should not reach here
|
||||
expect.fail('Command should have failed');
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBeGreaterThan(0);
|
||||
expect(error.stderr).toContain("'vt title' can only be used inside a VibeTunnel session");
|
||||
expect(error.stderr).toContain('Start a session first');
|
||||
} catch (error) {
|
||||
expect((error as { code: number }).code).toBeGreaterThan(0);
|
||||
expect((error as { stderr: string }).stderr).toContain(
|
||||
"'vt title' can only be used inside a VibeTunnel session"
|
||||
);
|
||||
expect((error as { stderr: string }).stderr).toContain('Start a session first');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -171,9 +173,9 @@ describe('vt title Command Integration', () => {
|
|||
try {
|
||||
await execAsync(`${vtScriptPath} title "Test"`, { env });
|
||||
expect.fail('Should have failed');
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBeGreaterThan(0);
|
||||
expect(error.stderr).toContain('Session file not found');
|
||||
} catch (error) {
|
||||
expect((error as { code: number }).code).toBeGreaterThan(0);
|
||||
expect((error as { stderr: string }).stderr).toContain('Session file not found');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { fixture } from '@open-wc/testing';
|
||||
import { LitElement, type TemplateResult } from 'lit';
|
||||
import { vi } from 'vitest';
|
||||
import type { ActivityStatus, SessionData } from '../types/test-types';
|
||||
import type { Session } from '../../shared/types';
|
||||
import type { ActivityStatus } from '../types/test-types';
|
||||
import { createTestSession } from './test-factories';
|
||||
|
||||
/**
|
||||
|
|
@ -195,34 +196,27 @@ export async function waitFor(
|
|||
|
||||
/**
|
||||
* Creates mock session data for testing
|
||||
* Uses factory function to ensure consistent test data
|
||||
* Returns a proper Session object that matches the component expectations
|
||||
*/
|
||||
export function createMockSession(overrides: Partial<SessionData> = {}): SessionData {
|
||||
const baseSession = createTestSession({
|
||||
name: overrides.name,
|
||||
command: overrides.cmdline,
|
||||
workingDir: overrides.cwd,
|
||||
pid: overrides.pid,
|
||||
status: overrides.status as 'running' | 'exited' | undefined,
|
||||
startedAt: overrides.started_at,
|
||||
});
|
||||
|
||||
// Use base session values as defaults, then apply overrides
|
||||
return {
|
||||
id: baseSession.id,
|
||||
name: baseSession.name,
|
||||
cmdline: baseSession.command,
|
||||
cwd: baseSession.workingDir,
|
||||
pid: baseSession.pid,
|
||||
status: baseSession.status,
|
||||
started_at: baseSession.startedAt,
|
||||
exitCode: null,
|
||||
term: 'xterm-256color',
|
||||
spawn_type: 'pty',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...overrides, // Override any fields provided
|
||||
export function createMockSession(overrides: Partial<Session> = {}): Session {
|
||||
// Convert SessionData properties to Session properties if needed
|
||||
const overridesWithLegacy = overrides as Partial<Session> & {
|
||||
cmdline?: string[];
|
||||
cwd?: string;
|
||||
started_at?: string;
|
||||
};
|
||||
|
||||
const command = overridesWithLegacy.command || overridesWithLegacy.cmdline || ['/bin/bash', '-l'];
|
||||
const workingDir = overridesWithLegacy.workingDir || overridesWithLegacy.cwd || '/home/test';
|
||||
const startedAt =
|
||||
overridesWithLegacy.startedAt || overridesWithLegacy.started_at || new Date().toISOString();
|
||||
|
||||
return createTestSession({
|
||||
...overrides,
|
||||
command: Array.isArray(command) ? command : [command],
|
||||
workingDir,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -63,6 +63,38 @@ describe('Terminal Title Utilities', () => {
|
|||
expect(result).toBe('\x1B]2;~/projects · npm · Frontend Dev\x07');
|
||||
});
|
||||
|
||||
it('should skip redundant session names like "claude · claude"', () => {
|
||||
const cwd = '/home/user/projects';
|
||||
const command = ['claude'];
|
||||
const sessionName = 'claude · claude';
|
||||
const result = generateTitleSequence(cwd, command, sessionName);
|
||||
expect(result).toBe('\x1B]2;~/projects · claude\x07');
|
||||
});
|
||||
|
||||
it('should skip auto-generated session names with path', () => {
|
||||
const cwd = '/home/user/projects';
|
||||
const command = ['python3'];
|
||||
const sessionName = 'python3 (~/projects)';
|
||||
const result = generateTitleSequence(cwd, command, sessionName);
|
||||
expect(result).toBe('\x1B]2;~/projects · python3\x07');
|
||||
});
|
||||
|
||||
it('should skip session names that are just the command name', () => {
|
||||
const cwd = '/home/user';
|
||||
const command = ['bash'];
|
||||
const sessionName = 'bash';
|
||||
const result = generateTitleSequence(cwd, command, sessionName);
|
||||
expect(result).toBe('\x1B]2;~ · bash\x07');
|
||||
});
|
||||
|
||||
it('should include custom session names that are not redundant', () => {
|
||||
const cwd = '/home/user/projects';
|
||||
const command = ['claude'];
|
||||
const sessionName = 'Working on VibeTunnel';
|
||||
const result = generateTitleSequence(cwd, command, sessionName);
|
||||
expect(result).toBe('\x1B]2;~/projects · claude · Working on VibeTunnel\x07');
|
||||
});
|
||||
|
||||
it('should handle empty session name', () => {
|
||||
const cwd = '/home/user';
|
||||
const command = ['vim'];
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ interface CreateSessionOptions {
|
|||
name?: string;
|
||||
command?: string[];
|
||||
workingDir?: string;
|
||||
status?: 'running' | 'stopped';
|
||||
status?: 'running' | 'exited' | 'stopped';
|
||||
exitCode?: number;
|
||||
startedAt?: string;
|
||||
pid?: number;
|
||||
|
|
@ -66,7 +66,7 @@ export function createTestSession(options: CreateSessionOptions = {}): Session {
|
|||
name: options.name || `Test Session ${id}`,
|
||||
command: options.command || ['/bin/bash', '-l'],
|
||||
workingDir: options.workingDir || '/home/test',
|
||||
status: options.status || 'running',
|
||||
status: options.status === 'stopped' ? 'exited' : options.status || 'running',
|
||||
exitCode: options.exitCode,
|
||||
startedAt: options.startedAt || now.toISOString(),
|
||||
pid: options.pid || 12345 + sessionCounter,
|
||||
|
|
|
|||
Loading…
Reference in a new issue