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.
@@ -0,0 +1,62 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+ import { APP_NAME, APP_VERSION } from "../../config"
7
+
8
+ // ============================================================================
9
+ // Header Component
10
+ // ============================================================================
11
+
12
+ export class Header {
13
+ private box: BoxRenderable
14
+ private text: TextRenderable
15
+
16
+ constructor(renderer: CliRenderer) {
17
+ this.box = new BoxRenderable(renderer, {
18
+ id: "header-box",
19
+ width: "100%",
20
+ height: 3,
21
+ borderStyle: "single",
22
+ borderColor: "#666666",
23
+ padding: 1,
24
+ })
25
+
26
+ this.text = new TextRenderable(renderer, {
27
+ id: "header-text",
28
+ content: `${APP_NAME} v${APP_VERSION}`,
29
+ fg: "#FFFFFF",
30
+ })
31
+
32
+ this.box.add(this.text)
33
+ }
34
+
35
+ /**
36
+ * Get the root renderable for this component
37
+ */
38
+ getRenderable(): BoxRenderable {
39
+ return this.box
40
+ }
41
+
42
+ /**
43
+ * Set counts (total items and selected)
44
+ */
45
+ setCounts(totalCount: number, selectedCount: number): void {
46
+ let text = `${APP_NAME} v${APP_VERSION}`
47
+ if (totalCount > 0) {
48
+ text += ` | ${totalCount} items`
49
+ if (selectedCount > 0) {
50
+ text += `, ${selectedCount} selected`
51
+ }
52
+ }
53
+ this.text.content = text
54
+ }
55
+
56
+ /**
57
+ * Set loading state with message
58
+ */
59
+ setLoading(message: string = "Loading..."): void {
60
+ this.text.content = `${APP_NAME} v${APP_VERSION} - ${message}`
61
+ }
62
+ }
@@ -0,0 +1,8 @@
1
+ export { Header } from "./header"
2
+ export { TabBar } from "./tabbar"
3
+ export { StatusBar } from "./status-bar"
4
+ export { ConfirmDialog } from "./confirm-dialog"
5
+ export { ListContainer } from "./list-container"
6
+ export { LogViewer } from "./log-viewer"
7
+ export { DetailViewer } from "./detail-viewer"
8
+ export type { DetailViewerItem, DetailViewerData } from "./detail-viewer"
@@ -0,0 +1,97 @@
1
+ import {
2
+ BoxRenderable,
3
+ SelectRenderable,
4
+ TextRenderable,
5
+ type CliRenderer,
6
+ type SelectOption,
7
+ } from "@opentui/core"
8
+
9
+ // ============================================================================
10
+ // List Container Component
11
+ // ============================================================================
12
+
13
+ export class ListContainer {
14
+ private box: BoxRenderable
15
+ private select: SelectRenderable
16
+ private emptyText: TextRenderable
17
+
18
+ constructor(renderer: CliRenderer) {
19
+ this.box = new BoxRenderable(renderer, {
20
+ id: "list-box",
21
+ width: "100%",
22
+ flexGrow: 1,
23
+ borderStyle: "single",
24
+ borderColor: "#444444",
25
+ overflow: "hidden",
26
+ })
27
+
28
+ this.select = new SelectRenderable(renderer, {
29
+ id: "select-list",
30
+ width: "100%",
31
+ height: "100%",
32
+ options: [],
33
+ showDescription: true,
34
+ wrapSelection: true,
35
+ })
36
+
37
+ this.emptyText = new TextRenderable(renderer, {
38
+ id: "empty-text",
39
+ content: " No items",
40
+ fg: "#666666",
41
+ })
42
+ this.emptyText.visible = false
43
+
44
+ this.box.add(this.select)
45
+ this.box.add(this.emptyText)
46
+ }
47
+
48
+ /**
49
+ * Get the root renderable for this component
50
+ */
51
+ getRenderable(): BoxRenderable {
52
+ return this.box
53
+ }
54
+
55
+ /**
56
+ * Set the options to display in the list
57
+ */
58
+ setOptions(options: SelectOption[]): void {
59
+ if (options.length === 0) {
60
+ this.select.visible = false
61
+ this.emptyText.visible = true
62
+ } else {
63
+ this.select.visible = true
64
+ this.emptyText.visible = false
65
+ this.select.options = options
66
+ this.select.focus() // Re-focus after making visible
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Get the currently selected index
72
+ */
73
+ getSelectedIndex(): number {
74
+ return this.select.getSelectedIndex()
75
+ }
76
+
77
+ /**
78
+ * Set the selected index
79
+ */
80
+ setSelectedIndex(index: number): void {
81
+ this.select.selectedIndex = index
82
+ }
83
+
84
+ /**
85
+ * Focus the list for keyboard input
86
+ */
87
+ focus(): void {
88
+ this.select.focus()
89
+ }
90
+
91
+ /**
92
+ * Get the number of options
93
+ */
94
+ getOptionCount(): number {
95
+ return this.select.options.length
96
+ }
97
+ }
@@ -0,0 +1,217 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+
7
+ // ============================================================================
8
+ // Log Viewer Component
9
+ // ============================================================================
10
+
11
+ export class LogViewer {
12
+ private box: BoxRenderable
13
+ private titleText: TextRenderable
14
+ private contentText: TextRenderable
15
+ private hintsText: TextRenderable
16
+
17
+ private lines: string[] = []
18
+ private scrollOffset = 0
19
+ private viewportHeight = 0
20
+ private contentWidth = 0
21
+
22
+ constructor(private renderer: CliRenderer) {
23
+ // Full-screen overlay box
24
+ this.box = new BoxRenderable(renderer, {
25
+ id: "log-viewer-box",
26
+ width: "100%",
27
+ height: "100%",
28
+ position: "absolute",
29
+ left: 0,
30
+ top: 0,
31
+ borderStyle: "double",
32
+ borderColor: "#FF6600",
33
+ backgroundColor: "#1a1a1a",
34
+ visible: false,
35
+ padding: 1,
36
+ zIndex: 300,
37
+ flexDirection: "column",
38
+ })
39
+
40
+ // Title bar
41
+ this.titleText = new TextRenderable(renderer, {
42
+ id: "log-viewer-title",
43
+ content: "",
44
+ fg: "#FF6600",
45
+ })
46
+
47
+ // Scrollable content area
48
+ this.contentText = new TextRenderable(renderer, {
49
+ id: "log-viewer-content",
50
+ content: "",
51
+ fg: "#FFFFFF",
52
+ flexGrow: 1,
53
+ })
54
+
55
+ // Hints at the bottom
56
+ this.hintsText = new TextRenderable(renderer, {
57
+ id: "log-viewer-hints",
58
+ content: "j/k:scroll g/G:top/bottom Esc/q:back",
59
+ fg: "#888888",
60
+ })
61
+
62
+ this.box.add(this.titleText)
63
+ this.box.add(this.contentText)
64
+ this.box.add(this.hintsText)
65
+ }
66
+
67
+ /**
68
+ * Get the root renderable for this component
69
+ */
70
+ getRenderable(): BoxRenderable {
71
+ return this.box
72
+ }
73
+
74
+ /**
75
+ * Show the log viewer with the given content
76
+ */
77
+ show(filename: string, content: string): void {
78
+ this.titleText.content = `Log: ${filename}`
79
+
80
+ // Calculate viewport dimensions (accounting for border, padding, title, hints)
81
+ // Box has padding=1 (top+bottom), border=1 (top+bottom), title=1, hints=1
82
+ const terminalHeight = this.renderer.terminalHeight
83
+ const terminalWidth = this.renderer.terminalWidth
84
+ this.viewportHeight = terminalHeight - 8 // border(2) + padding(2) + title(1) + hints(1) + some margin
85
+ this.contentWidth = terminalWidth - 6 // border(2) + padding(2) + line number gutter(~6)
86
+
87
+ // Split and wrap lines
88
+ this.lines = this.wrapContent(content)
89
+ this.scrollOffset = 0
90
+
91
+ this.updateContent()
92
+ this.box.visible = true
93
+ }
94
+
95
+ /**
96
+ * Hide the log viewer
97
+ */
98
+ hide(): void {
99
+ this.box.visible = false
100
+ this.lines = []
101
+ this.scrollOffset = 0
102
+ }
103
+
104
+ /**
105
+ * Check if the viewer is currently visible
106
+ */
107
+ isVisible(): boolean {
108
+ return this.box.visible
109
+ }
110
+
111
+ /**
112
+ * Scroll up by one line
113
+ */
114
+ scrollUp(): void {
115
+ if (this.scrollOffset > 0) {
116
+ this.scrollOffset--
117
+ this.updateContent()
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Scroll down by one line
123
+ */
124
+ scrollDown(): void {
125
+ const maxOffset = Math.max(0, this.lines.length - this.viewportHeight)
126
+ if (this.scrollOffset < maxOffset) {
127
+ this.scrollOffset++
128
+ this.updateContent()
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Scroll to the top
134
+ */
135
+ scrollToTop(): void {
136
+ this.scrollOffset = 0
137
+ this.updateContent()
138
+ }
139
+
140
+ /**
141
+ * Scroll to the bottom
142
+ */
143
+ scrollToBottom(): void {
144
+ this.scrollOffset = Math.max(0, this.lines.length - this.viewportHeight)
145
+ this.updateContent()
146
+ }
147
+
148
+ // --------------------------------------------------------------------------
149
+ // Private Methods
150
+ // --------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Wrap long lines and add line numbers
154
+ */
155
+ private wrapContent(content: string): string[] {
156
+ const rawLines = content.split("\n")
157
+ const wrappedLines: string[] = []
158
+
159
+ // Calculate the gutter width based on total line count
160
+ const gutterWidth = Math.max(4, String(rawLines.length).length + 1)
161
+ const maxLineWidth = Math.max(20, this.contentWidth - gutterWidth - 2) // -2 for " | " separator
162
+
163
+ for (let i = 0; i < rawLines.length; i++) {
164
+ const lineNum = i + 1
165
+ const rawLine = rawLines[i]
166
+
167
+ if (rawLine.length <= maxLineWidth) {
168
+ // Line fits, add with line number
169
+ const lineNumStr = String(lineNum).padStart(gutterWidth)
170
+ wrappedLines.push(`${lineNumStr} | ${rawLine}`)
171
+ } else {
172
+ // Line needs wrapping
173
+ let remaining = rawLine
174
+ let isFirstSegment = true
175
+
176
+ while (remaining.length > 0) {
177
+ const segment = remaining.slice(0, maxLineWidth)
178
+ remaining = remaining.slice(maxLineWidth)
179
+
180
+ if (isFirstSegment) {
181
+ const lineNumStr = String(lineNum).padStart(gutterWidth)
182
+ wrappedLines.push(`${lineNumStr} | ${segment}`)
183
+ isFirstSegment = false
184
+ } else {
185
+ // Continuation lines show empty gutter
186
+ const emptyGutter = " ".repeat(gutterWidth)
187
+ wrappedLines.push(`${emptyGutter} | ${segment}`)
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ return wrappedLines
194
+ }
195
+
196
+ /**
197
+ * Update the visible content based on scroll offset
198
+ */
199
+ private updateContent(): void {
200
+ const visibleLines = this.lines.slice(
201
+ this.scrollOffset,
202
+ this.scrollOffset + this.viewportHeight
203
+ )
204
+
205
+ this.contentText.content = visibleLines.join("\n")
206
+
207
+ // Update hints with scroll position
208
+ const totalLines = this.lines.length
209
+ const currentLine = this.scrollOffset + 1
210
+ const endLine = Math.min(this.scrollOffset + this.viewportHeight, totalLines)
211
+ const scrollInfo = totalLines > this.viewportHeight
212
+ ? ` [${currentLine}-${endLine}/${totalLines}]`
213
+ : ""
214
+
215
+ this.hintsText.content = `j/k:scroll g/G:top/bottom Esc/q:back${scrollInfo}`
216
+ }
217
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+
7
+ // ============================================================================
8
+ // Status Bar Component
9
+ // ============================================================================
10
+
11
+ export class StatusBar {
12
+ private box: BoxRenderable
13
+ private text: TextRenderable
14
+ private statusTimeout: ReturnType<typeof setTimeout> | null = null
15
+ private currentHints: string = ""
16
+ private currentMessage: string = ""
17
+
18
+ constructor(renderer: CliRenderer) {
19
+ this.box = new BoxRenderable(renderer, {
20
+ id: "status-box",
21
+ width: "100%",
22
+ height: 3,
23
+ borderStyle: "single",
24
+ borderColor: "#666666",
25
+ padding: 1,
26
+ })
27
+
28
+ this.text = new TextRenderable(renderer, {
29
+ id: "status-text",
30
+ content: "",
31
+ fg: "#888888",
32
+ })
33
+
34
+ this.box.add(this.text)
35
+ }
36
+
37
+ /**
38
+ * Get the root renderable for this component
39
+ */
40
+ getRenderable(): BoxRenderable {
41
+ return this.box
42
+ }
43
+
44
+ /**
45
+ * Set the keybinding hints for the current view
46
+ */
47
+ setHints(hints: string): void {
48
+ this.currentHints = hints
49
+ this.updateDisplay()
50
+ }
51
+
52
+ /**
53
+ * Set a temporary status message
54
+ * @param message The message to display
55
+ * @param duration Duration in ms before clearing (default: 3000)
56
+ */
57
+ setMessage(message: string, duration: number = 3000): void {
58
+ // Clear any existing timeout
59
+ if (this.statusTimeout) {
60
+ clearTimeout(this.statusTimeout)
61
+ this.statusTimeout = null
62
+ }
63
+
64
+ this.currentMessage = message
65
+ this.updateDisplay()
66
+
67
+ // Auto-clear message after duration
68
+ if (duration > 0) {
69
+ this.statusTimeout = setTimeout(() => {
70
+ this.currentMessage = ""
71
+ this.updateDisplay()
72
+ this.statusTimeout = null
73
+ }, duration)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Clear the status message
79
+ */
80
+ clearMessage(): void {
81
+ if (this.statusTimeout) {
82
+ clearTimeout(this.statusTimeout)
83
+ this.statusTimeout = null
84
+ }
85
+ this.currentMessage = ""
86
+ this.updateDisplay()
87
+ }
88
+
89
+ /**
90
+ * Update the display text
91
+ */
92
+ private updateDisplay(): void {
93
+ if (this.currentMessage) {
94
+ this.text.content = `${this.currentMessage} | ${this.currentHints}`
95
+ } else {
96
+ this.text.content = this.currentHints
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ } from "@opentui/core"
6
+ import type { ViewType } from "../../types"
7
+
8
+ // ============================================================================
9
+ // Tab Configuration
10
+ // ============================================================================
11
+
12
+ interface TabConfig {
13
+ id: ViewType
14
+ label: string
15
+ }
16
+
17
+ const TABS: TabConfig[] = [
18
+ { id: "main", label: "Projects" },
19
+ { id: "orphans", label: "Orphans" },
20
+ { id: "logs", label: "Logs" },
21
+ ]
22
+
23
+ // ============================================================================
24
+ // TabBar Component
25
+ // ============================================================================
26
+
27
+ export class TabBar {
28
+ private box: BoxRenderable
29
+ private text: TextRenderable
30
+ private activeView: ViewType = "main"
31
+
32
+ constructor(renderer: CliRenderer) {
33
+ this.box = new BoxRenderable(renderer, {
34
+ id: "tabbar-box",
35
+ width: "100%",
36
+ height: 3,
37
+ borderStyle: "single",
38
+ borderColor: "#444444",
39
+ padding: 1,
40
+ })
41
+
42
+ this.text = new TextRenderable(renderer, {
43
+ id: "tabbar-text",
44
+ content: this.buildTabText("main"),
45
+ fg: "#FFFFFF",
46
+ })
47
+
48
+ this.box.add(this.text)
49
+ }
50
+
51
+ /**
52
+ * Get the root renderable for this component
53
+ */
54
+ getRenderable(): BoxRenderable {
55
+ return this.box
56
+ }
57
+
58
+ /**
59
+ * Set the active tab and update display
60
+ */
61
+ setActiveTab(view: ViewType): void {
62
+ if (this.activeView !== view) {
63
+ this.activeView = view
64
+ this.text.content = this.buildTabText(view)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Build the tab text with the active tab bracketed
70
+ */
71
+ private buildTabText(activeView: ViewType): string {
72
+ return TABS.map((tab) => {
73
+ if (tab.id === activeView) {
74
+ return `[${tab.label}]`
75
+ }
76
+ return ` ${tab.label} `
77
+ }).join(" ")
78
+ }
79
+ }
@@ -0,0 +1,57 @@
1
+ import type { ViewController, ControllerContext } from "./index"
2
+ import { Action, CONFIRM_KEYBINDINGS, type KeyBinding } from "../keybindings"
3
+
4
+ // ============================================================================
5
+ // Confirm Dialog Types
6
+ // ============================================================================
7
+
8
+ export interface ConfirmField {
9
+ label: string
10
+ value: string
11
+ }
12
+
13
+ export interface ConfirmDetails {
14
+ title: string // e.g., "Delete Project", "Delete Session"
15
+ fields: ConfirmField[] // e.g., [{ label: "Path:", value: "~/foo" }]
16
+ onConfirm: () => Promise<void>
17
+ }
18
+
19
+ // ============================================================================
20
+ // Confirm Dialog Controller
21
+ // ============================================================================
22
+
23
+ export class ConfirmDialogController implements ViewController {
24
+ constructor(private details: ConfirmDetails) {}
25
+
26
+ onEnter(ctx: ControllerContext): void {
27
+ ctx.confirmDialog.showDetails(this.details.title, this.details.fields)
28
+ ctx.statusBar.setHints("y:confirm n/Esc:cancel")
29
+ }
30
+
31
+ onExit(ctx: ControllerContext): void {
32
+ ctx.confirmDialog.hide()
33
+ }
34
+
35
+ handleAction(action: Action, ctx: ControllerContext): boolean {
36
+ switch (action) {
37
+ case Action.CONFIRM_YES:
38
+ ctx.popController()
39
+ this.details.onConfirm()
40
+ return true
41
+ case Action.CONFIRM_NO:
42
+ case Action.BACK:
43
+ ctx.popController()
44
+ return true
45
+ default:
46
+ return false
47
+ }
48
+ }
49
+
50
+ getHelpText(): string {
51
+ return "y:confirm n/Esc:cancel"
52
+ }
53
+
54
+ getKeybindings(): KeyBinding[] {
55
+ return CONFIRM_KEYBINDINGS
56
+ }
57
+ }