opencode-session 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +46 -0
- package/src/config.ts +29 -0
- package/src/data/loader.ts +495 -0
- package/src/data/manager.ts +312 -0
- package/src/index.ts +56 -0
- package/src/types.ts +173 -0
- package/src/ui/app.ts +245 -0
- package/src/ui/components/confirm-dialog.ts +117 -0
- package/src/ui/components/detail-viewer.ts +307 -0
- package/src/ui/components/header.ts +62 -0
- package/src/ui/components/index.ts +8 -0
- package/src/ui/components/list-container.ts +97 -0
- package/src/ui/components/log-viewer.ts +217 -0
- package/src/ui/components/status-bar.ts +99 -0
- package/src/ui/components/tabbar.ts +79 -0
- package/src/ui/controllers/confirm-controller.ts +57 -0
- package/src/ui/controllers/index.ts +92 -0
- package/src/ui/controllers/log-controller.ts +173 -0
- package/src/ui/controllers/log-viewer-controller.ts +52 -0
- package/src/ui/controllers/main-controller.ts +142 -0
- package/src/ui/controllers/message-viewer-controller.ts +176 -0
- package/src/ui/controllers/orphan-controller.ts +125 -0
- package/src/ui/controllers/project-viewer-controller.ts +113 -0
- package/src/ui/controllers/session-viewer-controller.ts +181 -0
- package/src/ui/keybindings.ts +158 -0
- package/src/ui/state.ts +299 -0
- package/src/ui/views/base-view.ts +92 -0
- package/src/ui/views/index.ts +45 -0
- package/src/ui/views/log-list.ts +81 -0
- package/src/ui/views/main-view.ts +242 -0
- package/src/ui/views/orphan-list.ts +241 -0
- package/src/utils.ts +118 -0
package/src/ui/app.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCliRenderer,
|
|
3
|
+
BoxRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
type KeyEvent,
|
|
6
|
+
} from "@opentui/core"
|
|
7
|
+
import { loadAllData } from "../data/loader"
|
|
8
|
+
import { StateManager, StateEvent } from "./state"
|
|
9
|
+
import { Action, findAction, getHintsForView } from "./keybindings"
|
|
10
|
+
import { Header, TabBar, StatusBar, ConfirmDialog, ListContainer, LogViewer, DetailViewer } from "./components"
|
|
11
|
+
import { createViews, getView, type ViewMap } from "./views"
|
|
12
|
+
import { createController, type ViewController, type ControllerContext } from "./controllers"
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Main App Class
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
export class App {
|
|
19
|
+
private renderer!: CliRenderer
|
|
20
|
+
private state: StateManager
|
|
21
|
+
private views!: ViewMap
|
|
22
|
+
|
|
23
|
+
// UI Components
|
|
24
|
+
private mainContainer!: BoxRenderable
|
|
25
|
+
private header!: Header
|
|
26
|
+
private tabBar!: TabBar
|
|
27
|
+
private listContainer!: ListContainer
|
|
28
|
+
private statusBar!: StatusBar
|
|
29
|
+
private confirmDialog!: ConfirmDialog
|
|
30
|
+
private logViewer!: LogViewer
|
|
31
|
+
private detailViewer!: DetailViewer
|
|
32
|
+
|
|
33
|
+
// Controller Stack
|
|
34
|
+
private controllerStack: ViewController[] = []
|
|
35
|
+
|
|
36
|
+
constructor() {
|
|
37
|
+
this.state = new StateManager()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async init(): Promise<void> {
|
|
41
|
+
this.renderer = await createCliRenderer({
|
|
42
|
+
exitOnCtrlC: true,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Initialize views
|
|
46
|
+
this.views = createViews(this.state)
|
|
47
|
+
|
|
48
|
+
// Create UI structure
|
|
49
|
+
this.createUI()
|
|
50
|
+
|
|
51
|
+
// Set up event listeners
|
|
52
|
+
this.setupEventListeners()
|
|
53
|
+
|
|
54
|
+
// Set up keyboard handlers
|
|
55
|
+
this.setupKeyboardHandlers()
|
|
56
|
+
|
|
57
|
+
// Load data
|
|
58
|
+
await this.loadData()
|
|
59
|
+
|
|
60
|
+
// Push initial controller onto stack
|
|
61
|
+
this.pushInitialController()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private createUI(): void {
|
|
65
|
+
// Main container
|
|
66
|
+
this.mainContainer = new BoxRenderable(this.renderer, {
|
|
67
|
+
id: "main-container",
|
|
68
|
+
width: "100%",
|
|
69
|
+
height: "100%",
|
|
70
|
+
flexDirection: "column",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Create components
|
|
74
|
+
this.header = new Header(this.renderer)
|
|
75
|
+
this.tabBar = new TabBar(this.renderer)
|
|
76
|
+
this.listContainer = new ListContainer(this.renderer)
|
|
77
|
+
this.statusBar = new StatusBar(this.renderer)
|
|
78
|
+
this.confirmDialog = new ConfirmDialog(this.renderer)
|
|
79
|
+
this.logViewer = new LogViewer(this.renderer)
|
|
80
|
+
this.detailViewer = new DetailViewer(this.renderer)
|
|
81
|
+
|
|
82
|
+
// Build hierarchy
|
|
83
|
+
this.mainContainer.add(this.header.getRenderable())
|
|
84
|
+
this.mainContainer.add(this.tabBar.getRenderable())
|
|
85
|
+
this.mainContainer.add(this.listContainer.getRenderable())
|
|
86
|
+
this.mainContainer.add(this.statusBar.getRenderable())
|
|
87
|
+
this.renderer.root.add(this.mainContainer)
|
|
88
|
+
this.renderer.root.add(this.confirmDialog.getRenderable())
|
|
89
|
+
this.renderer.root.add(this.logViewer.getRenderable())
|
|
90
|
+
this.renderer.root.add(this.detailViewer.getRenderable())
|
|
91
|
+
|
|
92
|
+
// Set initial state
|
|
93
|
+
this.header.setLoading()
|
|
94
|
+
this.listContainer.focus()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private setupEventListeners(): void {
|
|
98
|
+
// Reset cursor on view/data change
|
|
99
|
+
this.state.on(StateEvent.VIEW_CHANGED, () => {
|
|
100
|
+
// Replace base controller when view changes
|
|
101
|
+
this.controllerStack[0] = createController(this.state.view, this.views, this.state)
|
|
102
|
+
this.updateView(true)
|
|
103
|
+
})
|
|
104
|
+
this.state.on(StateEvent.DATA_LOADED, () => this.updateView(true))
|
|
105
|
+
// Preserve cursor on selection/expand change
|
|
106
|
+
this.state.on(StateEvent.SELECTION_CHANGED, () => this.updateView(false))
|
|
107
|
+
this.state.on(StateEvent.EXPAND_CHANGED, () => this.updateView(false))
|
|
108
|
+
this.state.on(StateEvent.STATUS_CHANGED, (msg: string) => {
|
|
109
|
+
this.statusBar.setMessage(msg)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private setupKeyboardHandlers(): void {
|
|
114
|
+
this.renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
|
115
|
+
if (this.state.isLoading) return
|
|
116
|
+
this.handleKeys(key)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private handleKeys(key: KeyEvent): void {
|
|
121
|
+
// Use sequence for printable chars (preserves case like 'G'), otherwise use name for special keys
|
|
122
|
+
// Check if sequence is a printable character (not control chars like \r, \t, \x1b)
|
|
123
|
+
// Note: > 32 excludes space (charCode 32) so it uses key.name "space" instead
|
|
124
|
+
const isPrintable = key.sequence?.length === 1 && key.sequence.charCodeAt(0) > 32
|
|
125
|
+
const keyName = isPrintable ? key.sequence : (key.name ?? key.sequence)
|
|
126
|
+
const topController = this.getTopController()
|
|
127
|
+
|
|
128
|
+
// Find action using top controller's keybindings
|
|
129
|
+
const action = findAction(keyName, topController.getKeybindings())
|
|
130
|
+
if (!action) return
|
|
131
|
+
|
|
132
|
+
// Global actions only at root level (stack depth == 1)
|
|
133
|
+
if (this.controllerStack.length === 1) {
|
|
134
|
+
switch (action) {
|
|
135
|
+
case Action.QUIT:
|
|
136
|
+
this.quit()
|
|
137
|
+
return
|
|
138
|
+
case Action.NEXT_VIEW:
|
|
139
|
+
this.switchView()
|
|
140
|
+
return
|
|
141
|
+
case Action.RELOAD:
|
|
142
|
+
this.loadData()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Delegate to top controller
|
|
148
|
+
topController.handleAction(action as Action, this.createContext())
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --------------------------------------------------------------------------
|
|
152
|
+
// Controller Stack Management
|
|
153
|
+
// --------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
private pushInitialController(): void {
|
|
156
|
+
const controller = createController(this.state.view, this.views, this.state)
|
|
157
|
+
this.controllerStack.push(controller)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private getTopController(): ViewController {
|
|
161
|
+
return this.controllerStack[this.controllerStack.length - 1]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private pushController(controller: ViewController): void {
|
|
165
|
+
this.controllerStack.push(controller)
|
|
166
|
+
controller.onEnter?.(this.createContext())
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private popController(): void {
|
|
170
|
+
if (this.controllerStack.length > 1) {
|
|
171
|
+
const popped = this.controllerStack.pop()!
|
|
172
|
+
popped.onExit?.(this.createContext())
|
|
173
|
+
|
|
174
|
+
// Re-enter the new top controller to restore its view
|
|
175
|
+
const newTop = this.getTopController()
|
|
176
|
+
newTop.onEnter?.(this.createContext())
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private switchView(): void {
|
|
181
|
+
// Pop all subviews first (cancel any open dialogs)
|
|
182
|
+
while (this.controllerStack.length > 1) {
|
|
183
|
+
const popped = this.controllerStack.pop()!
|
|
184
|
+
popped.onExit?.(this.createContext())
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Switch to next view (this triggers VIEW_CHANGED event which updates stack[0])
|
|
188
|
+
this.state.nextView()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private createContext(): ControllerContext {
|
|
192
|
+
return {
|
|
193
|
+
state: this.state,
|
|
194
|
+
listContainer: this.listContainer,
|
|
195
|
+
confirmDialog: this.confirmDialog,
|
|
196
|
+
logViewer: this.logViewer,
|
|
197
|
+
detailViewer: this.detailViewer,
|
|
198
|
+
header: this.header,
|
|
199
|
+
statusBar: this.statusBar,
|
|
200
|
+
loadData: () => this.loadData(),
|
|
201
|
+
pushController: (c) => this.pushController(c),
|
|
202
|
+
popController: () => this.popController(),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --------------------------------------------------------------------------
|
|
207
|
+
// Data & View Management
|
|
208
|
+
// --------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
private async loadData(): Promise<void> {
|
|
211
|
+
this.state.setLoading(true)
|
|
212
|
+
this.header.setLoading()
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const data = await loadAllData()
|
|
216
|
+
this.state.setData(data)
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.state.setStatus(`Error loading data: ${error}`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.state.setLoading(false)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private updateView(resetCursor: boolean = true): void {
|
|
225
|
+
const view = getView(this.views, this.state.view)
|
|
226
|
+
const options = view.getOptions(this.state.selectedIndices)
|
|
227
|
+
|
|
228
|
+
this.listContainer.setOptions(options)
|
|
229
|
+
if (resetCursor) {
|
|
230
|
+
this.listContainer.setSelectedIndex(0)
|
|
231
|
+
}
|
|
232
|
+
this.tabBar.setActiveTab(this.state.view)
|
|
233
|
+
this.header.setCounts(view.getItemCount(), this.state.selectedCount)
|
|
234
|
+
this.statusBar.setHints(getHintsForView(this.state.view))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private quit(): void {
|
|
238
|
+
this.renderer.destroy()
|
|
239
|
+
process.exit(0)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
run(): void {
|
|
243
|
+
// The renderer handles the event loop
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
} from "@opentui/core"
|
|
6
|
+
import type { ConfirmField } from "../controllers/confirm-controller"
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Confirm Dialog Component
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export class ConfirmDialog {
|
|
13
|
+
private box: BoxRenderable
|
|
14
|
+
private text: TextRenderable
|
|
15
|
+
private action: (() => Promise<void>) | null = null
|
|
16
|
+
|
|
17
|
+
constructor(renderer: CliRenderer) {
|
|
18
|
+
this.box = new BoxRenderable(renderer, {
|
|
19
|
+
id: "confirm-box",
|
|
20
|
+
width: 60,
|
|
21
|
+
height: 5,
|
|
22
|
+
position: "absolute",
|
|
23
|
+
left: 10,
|
|
24
|
+
top: 10,
|
|
25
|
+
borderStyle: "double",
|
|
26
|
+
borderColor: "#FF6600",
|
|
27
|
+
backgroundColor: "#1a1a1a",
|
|
28
|
+
visible: false,
|
|
29
|
+
padding: 1,
|
|
30
|
+
zIndex: 100,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
this.text = new TextRenderable(renderer, {
|
|
34
|
+
id: "confirm-text",
|
|
35
|
+
content: "",
|
|
36
|
+
fg: "#FFFFFF",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
this.box.add(this.text)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the root renderable for this component
|
|
44
|
+
*/
|
|
45
|
+
getRenderable(): BoxRenderable {
|
|
46
|
+
return this.box
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Show the confirmation dialog with a simple message
|
|
51
|
+
* @param message The confirmation message to display
|
|
52
|
+
* @param action The action to execute on confirmation
|
|
53
|
+
*/
|
|
54
|
+
show(message: string, action: () => Promise<void>): void {
|
|
55
|
+
this.text.content = message
|
|
56
|
+
this.action = action
|
|
57
|
+
this.box.height = 5
|
|
58
|
+
this.box.visible = true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Show the confirmation dialog with detailed information
|
|
63
|
+
* @param title The dialog title
|
|
64
|
+
* @param fields Key-value fields to display
|
|
65
|
+
*/
|
|
66
|
+
showDetails(title: string, fields: ConfirmField[]): void {
|
|
67
|
+
// Build content with title and fields
|
|
68
|
+
let content = `${title}\n\n`
|
|
69
|
+
|
|
70
|
+
for (const field of fields) {
|
|
71
|
+
content += `${field.label.padEnd(12)} ${field.value}\n`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
content += `\nPress y to confirm, n/Esc to cancel`
|
|
75
|
+
|
|
76
|
+
this.text.content = content
|
|
77
|
+
this.action = null // Action is handled by controller now
|
|
78
|
+
this.box.height = 6 + fields.length + 2 // title + blank + fields + blank + hint
|
|
79
|
+
this.box.visible = true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Hide the confirmation dialog
|
|
84
|
+
*/
|
|
85
|
+
hide(): void {
|
|
86
|
+
this.box.visible = false
|
|
87
|
+
this.text.content = ""
|
|
88
|
+
this.action = null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if the dialog is currently visible
|
|
93
|
+
*/
|
|
94
|
+
isVisible(): boolean {
|
|
95
|
+
return this.box.visible
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Execute the confirmed action and hide the dialog
|
|
100
|
+
* @deprecated Use ConfirmDialogController instead
|
|
101
|
+
*/
|
|
102
|
+
async confirm(): Promise<void> {
|
|
103
|
+
const action = this.action
|
|
104
|
+
this.hide()
|
|
105
|
+
if (action) {
|
|
106
|
+
await action()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Cancel and hide the dialog without executing the action
|
|
112
|
+
* @deprecated Use ConfirmDialogController instead
|
|
113
|
+
*/
|
|
114
|
+
cancel(): void {
|
|
115
|
+
this.hide()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
} from "@opentui/core"
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Detail Viewer Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export interface DetailViewerItem {
|
|
12
|
+
name: string
|
|
13
|
+
description?: string
|
|
14
|
+
value: string // For identifying item on Enter
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DetailViewerSection {
|
|
18
|
+
label: string // e.g., "Todos:"
|
|
19
|
+
lines: string[] // Non-selectable text lines
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DetailViewerData {
|
|
23
|
+
title: string
|
|
24
|
+
fields: { label: string; value: string }[]
|
|
25
|
+
sections?: DetailViewerSection[] // Optional non-selectable sections before items
|
|
26
|
+
items: DetailViewerItem[]
|
|
27
|
+
itemsLabel?: string // e.g., "Sessions:", "Messages:"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Detail Viewer Component
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
export class DetailViewer {
|
|
35
|
+
private box: BoxRenderable
|
|
36
|
+
private titleText: TextRenderable
|
|
37
|
+
private fieldsText: TextRenderable
|
|
38
|
+
private sectionsText: TextRenderable
|
|
39
|
+
private itemsLabelText: TextRenderable
|
|
40
|
+
private itemsText: TextRenderable
|
|
41
|
+
private hintsText: TextRenderable
|
|
42
|
+
|
|
43
|
+
private items: DetailViewerItem[] = []
|
|
44
|
+
private selectedIndex = 0
|
|
45
|
+
private scrollOffset = 0
|
|
46
|
+
private itemsViewportHeight = 0
|
|
47
|
+
private contentWidth = 0
|
|
48
|
+
|
|
49
|
+
constructor(private renderer: CliRenderer) {
|
|
50
|
+
// Full-screen overlay box
|
|
51
|
+
this.box = new BoxRenderable(renderer, {
|
|
52
|
+
id: "detail-viewer-box",
|
|
53
|
+
width: "100%",
|
|
54
|
+
height: "100%",
|
|
55
|
+
position: "absolute",
|
|
56
|
+
left: 0,
|
|
57
|
+
top: 0,
|
|
58
|
+
borderStyle: "double",
|
|
59
|
+
borderColor: "#FF6600",
|
|
60
|
+
backgroundColor: "#1a1a1a",
|
|
61
|
+
visible: false,
|
|
62
|
+
padding: 1,
|
|
63
|
+
zIndex: 200,
|
|
64
|
+
flexDirection: "column",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Title
|
|
68
|
+
this.titleText = new TextRenderable(renderer, {
|
|
69
|
+
id: "detail-viewer-title",
|
|
70
|
+
content: "",
|
|
71
|
+
fg: "#FF6600",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Metadata fields
|
|
75
|
+
this.fieldsText = new TextRenderable(renderer, {
|
|
76
|
+
id: "detail-viewer-fields",
|
|
77
|
+
content: "",
|
|
78
|
+
fg: "#CCCCCC",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// Non-selectable sections (e.g., Todos)
|
|
82
|
+
this.sectionsText = new TextRenderable(renderer, {
|
|
83
|
+
id: "detail-viewer-sections",
|
|
84
|
+
content: "",
|
|
85
|
+
fg: "#AAAAAA",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Items section label
|
|
89
|
+
this.itemsLabelText = new TextRenderable(renderer, {
|
|
90
|
+
id: "detail-viewer-items-label",
|
|
91
|
+
content: "",
|
|
92
|
+
fg: "#FF6600",
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Scrollable items area
|
|
96
|
+
this.itemsText = new TextRenderable(renderer, {
|
|
97
|
+
id: "detail-viewer-items",
|
|
98
|
+
content: "",
|
|
99
|
+
fg: "#FFFFFF",
|
|
100
|
+
flexGrow: 1,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Hints at the bottom
|
|
104
|
+
this.hintsText = new TextRenderable(renderer, {
|
|
105
|
+
id: "detail-viewer-hints",
|
|
106
|
+
content: "",
|
|
107
|
+
fg: "#888888",
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
this.box.add(this.titleText)
|
|
111
|
+
this.box.add(this.fieldsText)
|
|
112
|
+
this.box.add(this.sectionsText)
|
|
113
|
+
this.box.add(this.itemsLabelText)
|
|
114
|
+
this.box.add(this.itemsText)
|
|
115
|
+
this.box.add(this.hintsText)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get the root renderable for this component
|
|
120
|
+
*/
|
|
121
|
+
getRenderable(): BoxRenderable {
|
|
122
|
+
return this.box
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Show the detail viewer with data
|
|
127
|
+
*/
|
|
128
|
+
show(data: DetailViewerData): void {
|
|
129
|
+
this.titleText.content = data.title
|
|
130
|
+
|
|
131
|
+
// Build fields content
|
|
132
|
+
const fieldsContent = data.fields
|
|
133
|
+
.map((f) => `${f.label.padEnd(14)} ${f.value}`)
|
|
134
|
+
.join("\n")
|
|
135
|
+
this.fieldsText.content = fieldsContent
|
|
136
|
+
|
|
137
|
+
// Build sections content (non-selectable)
|
|
138
|
+
let sectionsContent = ""
|
|
139
|
+
let sectionsHeight = 0
|
|
140
|
+
if (data.sections && data.sections.length > 0) {
|
|
141
|
+
const sectionParts: string[] = []
|
|
142
|
+
for (const section of data.sections) {
|
|
143
|
+
sectionParts.push(`\n${section.label}`)
|
|
144
|
+
sectionsHeight += 2 // label + blank line
|
|
145
|
+
for (const line of section.lines) {
|
|
146
|
+
sectionParts.push(` ${line}`)
|
|
147
|
+
sectionsHeight += 1
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
sectionsContent = sectionParts.join("\n")
|
|
151
|
+
}
|
|
152
|
+
this.sectionsText.content = sectionsContent
|
|
153
|
+
|
|
154
|
+
// Items label
|
|
155
|
+
this.itemsLabelText.content = data.itemsLabel ? `\n${data.itemsLabel}` : ""
|
|
156
|
+
|
|
157
|
+
// Store items and reset selection
|
|
158
|
+
this.items = data.items
|
|
159
|
+
this.selectedIndex = 0
|
|
160
|
+
this.scrollOffset = 0
|
|
161
|
+
|
|
162
|
+
// Calculate dimensions
|
|
163
|
+
const terminalHeight = this.renderer.terminalHeight
|
|
164
|
+
const terminalWidth = this.renderer.terminalWidth
|
|
165
|
+
// Account for: border(2) + padding(2) + title(1) + fields + sections + itemsLabel(1) + hints(1) + margins
|
|
166
|
+
const fieldsHeight = data.fields.length + 1
|
|
167
|
+
const labelHeight = data.itemsLabel ? 2 : 0
|
|
168
|
+
this.itemsViewportHeight = Math.max(3, terminalHeight - 10 - fieldsHeight - sectionsHeight - labelHeight)
|
|
169
|
+
this.contentWidth = terminalWidth - 6 // border(2) + padding(2) + margin
|
|
170
|
+
|
|
171
|
+
this.updateContent()
|
|
172
|
+
this.box.visible = true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Hide the detail viewer
|
|
177
|
+
*/
|
|
178
|
+
hide(): void {
|
|
179
|
+
this.box.visible = false
|
|
180
|
+
this.items = []
|
|
181
|
+
this.selectedIndex = 0
|
|
182
|
+
this.scrollOffset = 0
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check if the viewer is currently visible
|
|
187
|
+
*/
|
|
188
|
+
isVisible(): boolean {
|
|
189
|
+
return this.box.visible
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get the currently selected index
|
|
194
|
+
*/
|
|
195
|
+
getSelectedIndex(): number {
|
|
196
|
+
return this.selectedIndex
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get the value of the selected item
|
|
201
|
+
*/
|
|
202
|
+
getSelectedValue(): string | undefined {
|
|
203
|
+
return this.items[this.selectedIndex]?.value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Move selection up
|
|
208
|
+
*/
|
|
209
|
+
scrollUp(): void {
|
|
210
|
+
if (this.selectedIndex > 0) {
|
|
211
|
+
this.selectedIndex--
|
|
212
|
+
// Adjust scroll if selection goes above viewport
|
|
213
|
+
if (this.selectedIndex < this.scrollOffset) {
|
|
214
|
+
this.scrollOffset = this.selectedIndex
|
|
215
|
+
}
|
|
216
|
+
this.updateContent()
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Move selection down
|
|
222
|
+
*/
|
|
223
|
+
scrollDown(): void {
|
|
224
|
+
if (this.selectedIndex < this.items.length - 1) {
|
|
225
|
+
this.selectedIndex++
|
|
226
|
+
// Adjust scroll if selection goes below viewport
|
|
227
|
+
// Each item takes 2 lines (name + description)
|
|
228
|
+
const visibleItems = Math.floor(this.itemsViewportHeight / 2)
|
|
229
|
+
if (this.selectedIndex >= this.scrollOffset + visibleItems) {
|
|
230
|
+
this.scrollOffset = this.selectedIndex - visibleItems + 1
|
|
231
|
+
}
|
|
232
|
+
this.updateContent()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Jump to first item
|
|
238
|
+
*/
|
|
239
|
+
scrollToTop(): void {
|
|
240
|
+
this.selectedIndex = 0
|
|
241
|
+
this.scrollOffset = 0
|
|
242
|
+
this.updateContent()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Jump to last item
|
|
247
|
+
*/
|
|
248
|
+
scrollToBottom(): void {
|
|
249
|
+
this.selectedIndex = Math.max(0, this.items.length - 1)
|
|
250
|
+
const visibleItems = Math.floor(this.itemsViewportHeight / 2)
|
|
251
|
+
this.scrollOffset = Math.max(0, this.items.length - visibleItems)
|
|
252
|
+
this.updateContent()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --------------------------------------------------------------------------
|
|
256
|
+
// Private Methods
|
|
257
|
+
// --------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Update the visible content
|
|
261
|
+
*/
|
|
262
|
+
private updateContent(): void {
|
|
263
|
+
if (this.items.length === 0) {
|
|
264
|
+
this.itemsText.content = " (no items)"
|
|
265
|
+
this.hintsText.content = "Esc/q:back"
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Calculate visible items (each item is 2 lines)
|
|
270
|
+
const visibleItems = Math.floor(this.itemsViewportHeight / 2)
|
|
271
|
+
const endIndex = Math.min(this.scrollOffset + visibleItems, this.items.length)
|
|
272
|
+
|
|
273
|
+
const lines: string[] = []
|
|
274
|
+
for (let i = this.scrollOffset; i < endIndex; i++) {
|
|
275
|
+
const item = this.items[i]
|
|
276
|
+
const isSelected = i === this.selectedIndex
|
|
277
|
+
const prefix = isSelected ? "> " : " "
|
|
278
|
+
|
|
279
|
+
// Truncate name if too long
|
|
280
|
+
const maxNameLen = this.contentWidth - 4
|
|
281
|
+
const name = item.name.length > maxNameLen
|
|
282
|
+
? item.name.slice(0, maxNameLen - 3) + "..."
|
|
283
|
+
: item.name
|
|
284
|
+
|
|
285
|
+
lines.push(`${prefix}${name}`)
|
|
286
|
+
|
|
287
|
+
// Description line (indented)
|
|
288
|
+
if (item.description) {
|
|
289
|
+
const maxDescLen = this.contentWidth - 6
|
|
290
|
+
const desc = item.description.length > maxDescLen
|
|
291
|
+
? item.description.slice(0, maxDescLen - 3) + "..."
|
|
292
|
+
: item.description
|
|
293
|
+
lines.push(` ${desc}`)
|
|
294
|
+
} else {
|
|
295
|
+
lines.push("")
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.itemsText.content = lines.join("\n")
|
|
300
|
+
|
|
301
|
+
// Update hints with position
|
|
302
|
+
const totalItems = this.items.length
|
|
303
|
+
const current = this.selectedIndex + 1
|
|
304
|
+
const scrollInfo = totalItems > visibleItems ? ` [${current}/${totalItems}]` : ""
|
|
305
|
+
this.hintsText.content = `j/k:nav Enter:view g/G:top/bottom Esc/q:back${scrollInfo}`
|
|
306
|
+
}
|
|
307
|
+
}
|