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/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
+ }