chore(widgets): 🔧 Add ServiceList, ServicePickerModal, ShutdownModal with service queue management capabilities

This commit is contained in:
Lilith 2026-01-20 03:07:48 -08:00
parent 718b3472af
commit db0544e2f7
6 changed files with 538 additions and 36 deletions

View file

@ -1,4 +1,5 @@
export * from './Dashboard';
export * from './widgets';
export * from './interactions';
export * from './types/blessed-extensions';
export * from './types/blessed-extensions';
export * from './types/service-queue';

148
src/types/service-queue.ts Normal file
View file

@ -0,0 +1,148 @@
/**
* Shared types for service queue management
*
* Provides consistent service representation across widgets
*/
import type { ServiceStatus } from '../widgets/ServiceList'
/**
* Service item for rendering in lists
*/
export interface QueuedServiceInfo {
id: string
feature: string
name: string
status: ServiceStatus
port?: number
queued: boolean
}
/**
* Queue state manager for services
*/
export class ServiceQueue {
private queuedServices = new Set<string>()
private callbacks: Array<(serviceId: string, queued: boolean) => void> = []
/**
* Add a service to the queue
*/
add(serviceId: string): void {
if (!this.queuedServices.has(serviceId)) {
this.queuedServices.add(serviceId)
this.notifyCallbacks(serviceId, true)
}
}
/**
* Remove a service from the queue
*/
remove(serviceId: string): void {
if (this.queuedServices.has(serviceId)) {
this.queuedServices.delete(serviceId)
this.notifyCallbacks(serviceId, false)
}
}
/**
* Toggle service queue state
*/
toggle(serviceId: string): boolean {
if (this.queuedServices.has(serviceId)) {
this.remove(serviceId)
return false
} else {
this.add(serviceId)
return true
}
}
/**
* Check if service is queued
*/
has(serviceId: string): boolean {
return this.queuedServices.has(serviceId)
}
/**
* Get all queued service IDs
*/
getAll(): string[] {
return Array.from(this.queuedServices)
}
/**
* Clear all queued services
*/
clear(): void {
const previous = this.getAll()
this.queuedServices.clear()
previous.forEach(id => this.notifyCallbacks(id, false))
}
/**
* Subscribe to queue changes
*/
onChange(callback: (serviceId: string, queued: boolean) => void): () => void {
this.callbacks.push(callback)
return () => {
this.callbacks = this.callbacks.filter(cb => cb !== callback)
}
}
private notifyCallbacks(serviceId: string, queued: boolean): void {
this.callbacks.forEach(cb => cb(serviceId, queued))
}
}
/**
* Format service as list item with status symbol and color
*/
export function formatServiceListItem(
service: { id: string; name: string; status: ServiceStatus; port?: number; queued?: boolean },
options: { showPort?: boolean; showQueuedIndicator?: boolean } = {}
): string {
const symbol = getServiceSymbol(service.status, service.queued)
const color = getServiceColor(service.status, service.queued)
const port = options.showPort && service.port ? ` {gray-fg}:${service.port}{/}` : ''
const queuedMark = options.showQueuedIndicator && service.queued ? ' {blue-fg}[queued]{/}' : ''
return `{${color}-fg}${symbol}{/} ${service.name}${port}${queuedMark}`
}
/**
* Get status symbol for service
*/
export function getServiceSymbol(status: ServiceStatus, queued?: boolean): string {
if (queued) return '◎' // Queued for startup
switch (status) {
case 'available': return '◯' // Available to start
case 'pending': return '○' // Not started
case 'starting': return '◐' // Starting
case 'running':
case 'healthy': return '●' // Running/healthy
case 'failed': return '✗' // Failed
case 'skipped': return '⊘' // Skipped
default: return '?'
}
}
/**
* Get status color for service
*/
export function getServiceColor(status: ServiceStatus, queued?: boolean): string {
if (queued) return 'cyan' // Cyan for queued
switch (status) {
case 'available': return 'blue'
case 'pending': return 'gray'
case 'starting': return 'yellow'
case 'running':
case 'healthy': return 'green'
case 'failed': return 'red'
case 'skipped': return 'gray'
default: return 'white'
}
}

View file

@ -11,6 +11,8 @@
import * as blessed from 'blessed'
import type { ExtendedBoxElement } from '../types/blessed-extensions'
import type { ServiceQueue } from '../types/service-queue'
import { getServiceSymbol, getServiceColor } from '../types/service-queue'
// =============================================================================
// Types
@ -44,6 +46,10 @@ export interface ServiceListOptions {
collapsible?: boolean
/** Start features collapsed (default: true) */
startCollapsed?: boolean
/** Service queue manager for tracking queued services */
serviceQueue?: ServiceQueue
/** Callback when service is queued/dequeued */
onServiceQueueChange?: (serviceId: string, queued: boolean) => void
/** Box options for the widget */
boxOptions?: blessed.Widgets.BoxOptions
}
@ -59,6 +65,7 @@ export class ServiceList {
private featureOrder: string[] = []
private sortedFeatureOrder: string[] = []
private selectedFeature: string | null = null
private selectedServiceIndex: number = -1 // Index within expanded feature (-1 = feature header)
private lastSortTime: number = 0
private readonly SORT_THROTTLE_MS = 30000 // 30 seconds
@ -66,12 +73,16 @@ export class ServiceList {
private sortByStatus: boolean
private collapsible: boolean
private startCollapsed: boolean
private serviceQueue?: ServiceQueue
private onServiceQueueChange?: (serviceId: string, queued: boolean) => void
constructor(parent: blessed.Widgets.Screen | blessed.Widgets.Node, options: ServiceListOptions = {}) {
this.groupByFeature = options.groupByFeature ?? true
this.sortByStatus = options.sortByStatus ?? true
this.collapsible = options.collapsible ?? true
this.startCollapsed = options.startCollapsed ?? true
this.serviceQueue = options.serviceQueue
this.onServiceQueueChange = options.onServiceQueueChange
this.box = blessed.box({
parent,
@ -92,6 +103,54 @@ export class ServiceList {
clickable: true,
...options.boxOptions,
}) as ExtendedBoxElement
this.setupKeyBindings()
}
private setupKeyBindings(): void {
// 'a' or 'Enter' to add/toggle service in queue
this.box.key(['a', 'enter'], () => {
this.toggleServiceQueue()
})
// Space to toggle feature expand/collapse
this.box.key(['space'], () => {
this.toggleFeatureExpand()
})
}
private toggleServiceQueue(): void {
if (!this.serviceQueue) return
if (!this.selectedFeature) return
const group = this.featureGroups.get(this.selectedFeature)
if (!group || !group.expanded) {
// Feature header selected or collapsed - queue all services in feature
if (group) {
const allQueued = group.services.every(s => this.serviceQueue!.has(s.id))
group.services.forEach(s => {
if (allQueued) {
this.serviceQueue!.remove(s.id)
} else {
this.serviceQueue!.add(s.id)
}
})
if (this.onServiceQueueChange) {
group.services.forEach(s => {
this.onServiceQueueChange!(s.id, !allQueued)
})
}
}
} else if (this.selectedServiceIndex >= 0 && this.selectedServiceIndex < group.services.length) {
// Service selected - toggle its queue state
const service = group.services[this.selectedServiceIndex]
const queued = this.serviceQueue.toggle(service.id)
if (this.onServiceQueueChange) {
this.onServiceQueueChange(service.id, queued)
}
}
this.render()
}
// ---------------------------------------------------------------------------
@ -167,19 +226,53 @@ export class ServiceList {
}
/**
* Navigate to next/previous feature (relative to current visual order)
* Navigate to next/previous item (feature or service, relative to current visual order)
*/
navigate(direction: number): void {
// Use the current sorted visual order for navigation
const currentOrder = this.sortedFeatureOrder.length > 0 ? this.sortedFeatureOrder : this.featureOrder
if (currentOrder.length === 0) return
// Initialize selection if needed
if (this.selectedFeature === null) {
this.selectedFeature = currentOrder[0]
this.selectedServiceIndex = -1
this.render()
this.scrollToSelectedFeature()
return
}
const currentFeatureIndex = currentOrder.indexOf(this.selectedFeature)
const currentGroup = this.featureGroups.get(this.selectedFeature)
if (direction > 0) {
// Navigate down
if (currentGroup?.expanded && this.selectedServiceIndex < currentGroup.services.length - 1) {
// Move to next service in expanded feature
this.selectedServiceIndex++
} else {
// Move to next feature
if (currentFeatureIndex < currentOrder.length - 1) {
this.selectedFeature = currentOrder[currentFeatureIndex + 1]
this.selectedServiceIndex = -1
}
}
} else {
const currentIndex = currentOrder.indexOf(this.selectedFeature)
const newIndex = Math.max(0, Math.min(currentOrder.length - 1, currentIndex + direction))
this.selectedFeature = currentOrder[newIndex]
// Navigate up
if (this.selectedServiceIndex > -1) {
// Move to previous service or feature header
this.selectedServiceIndex--
} else if (currentFeatureIndex > 0) {
// Move to previous feature
this.selectedFeature = currentOrder[currentFeatureIndex - 1]
const prevGroup = this.featureGroups.get(this.selectedFeature)
if (prevGroup?.expanded) {
// Select last service in previous expanded feature
this.selectedServiceIndex = prevGroup.services.length - 1
} else {
this.selectedServiceIndex = -1
}
}
}
this.render()
@ -300,14 +393,18 @@ export class ServiceList {
})
: group.services
for (const service of sortedServices) {
const symbol = this.getStatusSymbol(service.status)
const color = this.getStatusColor(service.status)
for (let i = 0; i < sortedServices.length; i++) {
const service = sortedServices[i]
const queued = this.serviceQueue?.has(service.id) ?? false
const symbol = getServiceSymbol(service.status, queued)
const color = getServiceColor(service.status, queued)
const port = service.port ? `{gray-fg}:${service.port}{/}` : ''
const duration = service.duration ? ` {gray-fg}${service.duration}ms{/}` : ''
const errorMsg = service.error ? ` {red-fg}${service.error.slice(0, 30)}{/}` : ''
const selected = this.selectedFeature === featureId && this.selectedServiceIndex === i ? '{inverse}' : ''
const selectedEnd = this.selectedFeature === featureId && this.selectedServiceIndex === i ? '{/inverse}' : ''
lines.push(` {${color}-fg}${symbol}{/} ${service.name}${port}${duration}${errorMsg}`)
lines.push(`${selected} {${color}-fg}${symbol}{/} ${service.name}${port}${duration}${errorMsg}${selectedEnd}`)
}
}
}
@ -358,29 +455,4 @@ export class ServiceList {
return ['unknown', id]
}
private getStatusSymbol(status: ServiceStatus): string {
switch (status) {
case 'available': return '◯' // Hollow circle for available
case 'pending': return '○'
case 'starting': return '◐'
case 'running':
case 'healthy': return '●'
case 'failed': return '✗'
case 'skipped': return '⊘'
default: return '?'
}
}
private getStatusColor(status: ServiceStatus): string {
switch (status) {
case 'available': return 'blue' // Blue for available services
case 'pending': return 'gray'
case 'starting': return 'yellow'
case 'running':
case 'healthy': return 'green'
case 'failed': return 'red'
case 'skipped': return 'gray'
default: return 'white'
}
}
}

View file

@ -108,14 +108,15 @@ export class ServicePickerModal {
})
// Service list below search
// Note: keys/vi disabled - we handle navigation manually for consistency
this.listBox = blessed.list({
parent: this.container,
top: 3,
left: 2,
right: 2,
bottom: 3,
keys: true,
vi: true,
keys: false,
vi: false,
mouse: true,
tags: true,
style: {

View file

@ -0,0 +1,279 @@
/**
* ShutdownModal Widget - Cleanup progress modal for graceful shutdown
*
* Features:
* - Shows cleanup progress when quitting
* - Displays individual cleanup tasks with status
* - Spinner animation during active cleanup
* - Cancel option (Escape) to abort shutdown
* - Proceeds to exit after all cleanup completes
*/
import * as blessed from 'blessed'
import type { ExtendedBoxElement } from '../types/blessed-extensions'
// =============================================================================
// Types
// =============================================================================
export type CleanupTaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
export interface CleanupTask {
id: string
label: string
status: CleanupTaskStatus
error?: string
}
export interface ShutdownModalOptions {
/** Cleanup tasks to perform */
tasks: CleanupTask[]
/** Called for each task to execute */
onExecuteTask: (taskId: string) => Promise<void>
/** Called when all cleanup is complete */
onComplete: () => void
/** Called if shutdown is cancelled */
onCancel?: () => void
}
// =============================================================================
// ShutdownModal Widget
// =============================================================================
export class ShutdownModal {
private screen: blessed.Widgets.Screen
private container: ExtendedBoxElement
private titleBox: blessed.Widgets.BoxElement
private taskList: blessed.Widgets.BoxElement
private statusBar: blessed.Widgets.BoxElement
private tasks: CleanupTask[]
private onExecuteTask: (taskId: string) => Promise<void>
private onComplete: () => void
private onCancel?: () => void
private isRunning = false
private isCancelled = false
private spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
private spinnerIndex = 0
private spinnerInterval: ReturnType<typeof setInterval> | null = null
constructor(screen: blessed.Widgets.Screen, options: ShutdownModalOptions) {
this.screen = screen
this.tasks = options.tasks.map(t => ({ ...t }))
this.onExecuteTask = options.onExecuteTask
this.onComplete = options.onComplete
this.onCancel = options.onCancel
// Container (centered modal)
this.container = blessed.box({
parent: screen,
top: 'center',
left: 'center',
width: 50,
height: this.tasks.length + 8,
border: { type: 'line' },
style: {
border: { fg: 'yellow' },
bg: 'black',
},
label: ' Shutting Down ',
tags: true,
shadow: true,
}) as ExtendedBoxElement
// Title/spinner area
this.titleBox = blessed.box({
parent: this.container,
top: 1,
left: 2,
right: 2,
height: 2,
style: {
fg: 'yellow',
bg: 'black',
},
tags: true,
})
// Task list
this.taskList = blessed.box({
parent: this.container,
top: 4,
left: 2,
right: 2,
height: this.tasks.length + 1,
style: {
fg: 'white',
bg: 'black',
},
tags: true,
})
// Status bar
this.statusBar = blessed.box({
parent: this.container,
bottom: 0,
left: 0,
right: 0,
height: 1,
style: {
fg: 'white',
bg: 'blue',
},
tags: true,
})
this.setupKeyBindings()
this.updateTitle()
this.renderTasks()
this.updateStatusBar()
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
async show(): Promise<void> {
this.container.show()
this.container.focus()
this.screen.render()
// Start spinner
this.spinnerInterval = setInterval(() => {
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length
this.updateTitle()
this.screen.render()
}, 80)
// Execute cleanup
await this.executeCleanup()
}
hide(): void {
if (this.spinnerInterval) {
clearInterval(this.spinnerInterval)
this.spinnerInterval = null
}
this.container.hide()
this.screen.render()
}
destroy(): void {
if (this.spinnerInterval) {
clearInterval(this.spinnerInterval)
this.spinnerInterval = null
}
this.container.destroy()
this.screen.render()
}
// ---------------------------------------------------------------------------
// Private Methods
// ---------------------------------------------------------------------------
private setupKeyBindings(): void {
// Escape to cancel (only during cleanup)
this.container.key(['escape', 'C-c'], () => {
if (this.isRunning && !this.isCancelled) {
this.isCancelled = true
this.updateStatusBar('Cancelling...')
if (this.onCancel) {
this.hide()
this.onCancel()
}
}
})
}
private async executeCleanup(): Promise<void> {
this.isRunning = true
for (const task of this.tasks) {
if (this.isCancelled) break
task.status = 'running'
this.renderTasks()
this.screen.render()
try {
await this.onExecuteTask(task.id)
task.status = 'done'
} catch (err) {
task.status = 'failed'
task.error = err instanceof Error ? err.message : String(err)
}
this.renderTasks()
this.screen.render()
}
this.isRunning = false
// Brief pause to show final state
await new Promise(resolve => setTimeout(resolve, 300))
if (!this.isCancelled) {
this.hide()
this.onComplete()
}
}
private updateTitle(): void {
const spinner = this.isRunning ? this.spinnerFrames[this.spinnerIndex] : '✓'
const status = this.isCancelled
? 'Cancelled'
: this.isRunning
? 'Cleaning up...'
: 'Complete'
this.titleBox.setContent(
`{center}{bold}${spinner} ${status}{/}{/center}\n` +
`{center}{gray-fg}Performing cleanup tasks{/}{/center}`
)
}
private renderTasks(): void {
const lines = this.tasks.map(task => {
const symbol = this.getTaskSymbol(task.status)
const color = this.getTaskColor(task.status)
const errorSuffix = task.error ? ` {red-fg}(${task.error}){/}` : ''
return ` {${color}-fg}${symbol}{/} ${task.label}${errorSuffix}`
})
this.taskList.setContent(lines.join('\n'))
}
private updateStatusBar(message?: string): void {
if (message) {
this.statusBar.setContent(` ${message}`)
} else {
this.statusBar.setContent(
` {bold}[Esc]{/} Cancel {|} Press Esc to abort shutdown`
)
}
this.screen.render()
}
private getTaskSymbol(status: CleanupTaskStatus): string {
switch (status) {
case 'pending': return '○'
case 'running': return this.spinnerFrames[this.spinnerIndex]
case 'done': return '✓'
case 'failed': return '✗'
case 'skipped': return '⊘'
default: return '?'
}
}
private getTaskColor(status: CleanupTaskStatus): string {
switch (status) {
case 'pending': return 'gray'
case 'running': return 'yellow'
case 'done': return 'green'
case 'failed': return 'red'
case 'skipped': return 'gray'
default: return 'white'
}
}
}

View file

@ -5,3 +5,4 @@ export * from './StatusBar'
export * from './ProgressPanel'
export * from './RoadmapPanel'
export * from './ServicePickerModal'
export * from './ShutdownModal'