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,92 @@
1
+ import type { ViewType } from "../../types"
2
+ import type { StateManager } from "../state"
3
+ import type { ListContainer } from "../components/list-container"
4
+ import type { ConfirmDialog } from "../components/confirm-dialog"
5
+ import type { LogViewer } from "../components/log-viewer"
6
+ import type { DetailViewer } from "../components/detail-viewer"
7
+ import type { Header } from "../components/header"
8
+ import type { StatusBar } from "../components/status-bar"
9
+ import type { ViewMap } from "../views"
10
+ import { Action, type KeyBinding } from "../keybindings"
11
+ import { MainController } from "./main-controller"
12
+ import { OrphanController } from "./orphan-controller"
13
+ import { LogController } from "./log-controller"
14
+
15
+ // ============================================================================
16
+ // Controller Interface and Types
17
+ // ============================================================================
18
+
19
+ export interface ControllerContext {
20
+ state: StateManager
21
+ listContainer: ListContainer
22
+ confirmDialog: ConfirmDialog
23
+ logViewer: LogViewer
24
+ detailViewer: DetailViewer
25
+ header: Header
26
+ statusBar: StatusBar
27
+ loadData: () => Promise<void>
28
+
29
+ // Stack navigation
30
+ pushController: (controller: ViewController) => void
31
+ popController: () => void
32
+ }
33
+
34
+ export interface ViewController {
35
+ /**
36
+ * Handle an action for this view
37
+ * @returns true if the action was handled, false otherwise
38
+ */
39
+ handleAction(action: Action, context: ControllerContext): boolean
40
+
41
+ /**
42
+ * Get help text for this view's keybindings
43
+ */
44
+ getHelpText(): string
45
+
46
+ /**
47
+ * Get the keybindings for this controller
48
+ */
49
+ getKeybindings(): KeyBinding[]
50
+
51
+ /**
52
+ * Called when this controller becomes active (pushed onto stack or becomes top)
53
+ */
54
+ onEnter?(context: ControllerContext): void
55
+
56
+ /**
57
+ * Called when this controller is deactivated (popped from stack)
58
+ */
59
+ onExit?(context: ControllerContext): void
60
+ }
61
+
62
+ // ============================================================================
63
+ // Controller Factory
64
+ // ============================================================================
65
+
66
+ export function createController(
67
+ viewType: ViewType,
68
+ views: ViewMap,
69
+ state: StateManager
70
+ ): ViewController {
71
+ switch (viewType) {
72
+ case "main":
73
+ return new MainController(views.main, state)
74
+ case "orphans":
75
+ return new OrphanController(views.orphans, state)
76
+ case "logs":
77
+ return new LogController(views.logs, state)
78
+ }
79
+ }
80
+
81
+ // ============================================================================
82
+ // Exports
83
+ // ============================================================================
84
+
85
+ export { MainController } from "./main-controller"
86
+ export { OrphanController } from "./orphan-controller"
87
+ export { LogController } from "./log-controller"
88
+ export { ConfirmDialogController, type ConfirmDetails, type ConfirmField } from "./confirm-controller"
89
+ export { LogViewerController } from "./log-viewer-controller"
90
+ export { ProjectViewerController } from "./project-viewer-controller"
91
+ export { SessionViewerController } from "./session-viewer-controller"
92
+ export { MessageViewerController } from "./message-viewer-controller"
@@ -0,0 +1,173 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import type { StateManager } from "../state"
3
+ import type { LogListView } from "../views/log-list"
4
+ import type { ViewController, ControllerContext } from "./index"
5
+ import { ConfirmDialogController, type ConfirmDetails } from "./confirm-controller"
6
+ import { LogViewerController } from "./log-viewer-controller"
7
+ import { Action, LOG_KEYBINDINGS, getHintsForView, type KeyBinding } from "../keybindings"
8
+ import { formatBytes } from "../../utils"
9
+
10
+ // ============================================================================
11
+ // Log Controller - Log Files View
12
+ // ============================================================================
13
+
14
+ export class LogController implements ViewController {
15
+ constructor(
16
+ private view: LogListView,
17
+ private state: StateManager
18
+ ) {}
19
+
20
+ handleAction(action: Action, ctx: ControllerContext): boolean {
21
+ switch (action) {
22
+ case Action.TOGGLE_SELECT:
23
+ this.toggleSelection(ctx)
24
+ return true
25
+ case Action.SELECT_ALL:
26
+ this.selectAll()
27
+ return true
28
+ case Action.DELETE:
29
+ this.initiateDelete(ctx)
30
+ return true
31
+ case Action.DELETE_ALL:
32
+ this.initiateDeleteAll(ctx)
33
+ return true
34
+ case Action.ENTER:
35
+ this.viewLog(ctx)
36
+ return true
37
+ case Action.HELP:
38
+ this.showHelp(ctx)
39
+ return true
40
+ case Action.BACK:
41
+ // No-op at root level
42
+ return true
43
+ default:
44
+ return false
45
+ }
46
+ }
47
+
48
+ getHelpText(): string {
49
+ return "j/k=nav, Space=select, a=all, d=delete, D=delete-all, Tab=next view, q=quit"
50
+ }
51
+
52
+ getKeybindings(): KeyBinding[] {
53
+ return LOG_KEYBINDINGS
54
+ }
55
+
56
+ onEnter(ctx: ControllerContext): void {
57
+ ctx.statusBar.setHints(getHintsForView("logs"))
58
+ }
59
+
60
+ // --------------------------------------------------------------------------
61
+ // Private Methods
62
+ // --------------------------------------------------------------------------
63
+
64
+ private toggleSelection(ctx: ControllerContext): void {
65
+ const index = ctx.listContainer.getSelectedIndex()
66
+ this.state.toggleSelection(index)
67
+ }
68
+
69
+ private selectAll(): void {
70
+ this.state.selectAll(this.view.getItemCount())
71
+ }
72
+
73
+ private initiateDelete(ctx: ControllerContext): void {
74
+ const currentIndex = ctx.listContainer.getSelectedIndex()
75
+ const indices = this.state.getSelectedOrCurrentIndices(currentIndex)
76
+
77
+ if (indices.length === 0) {
78
+ this.state.setStatus("Nothing selected")
79
+ return
80
+ }
81
+
82
+ const logs = this.view.getItems()
83
+ const totalSize = this.view.getTotalSize(indices)
84
+
85
+ // Build details based on selection count
86
+ const details: ConfirmDetails = indices.length === 1
87
+ ? {
88
+ title: "Delete Log File",
89
+ fields: [
90
+ { label: "Filename:", value: logs[indices[0]].filename },
91
+ { label: "Size:", value: formatBytes(logs[indices[0]].sizeBytes) },
92
+ ],
93
+ onConfirm: async () => {
94
+ await this.executeDelete(ctx, indices)
95
+ },
96
+ }
97
+ : {
98
+ title: `Delete ${indices.length} Log Files`,
99
+ fields: [
100
+ { label: "Count:", value: String(indices.length) },
101
+ { label: "Total Size:", value: formatBytes(totalSize) },
102
+ ],
103
+ onConfirm: async () => {
104
+ await this.executeDelete(ctx, indices)
105
+ },
106
+ }
107
+
108
+ ctx.pushController(new ConfirmDialogController(details))
109
+ }
110
+
111
+ private initiateDeleteAll(ctx: ControllerContext): void {
112
+ const itemCount = this.view.getItemCount()
113
+
114
+ if (itemCount === 0) {
115
+ this.state.setStatus("Nothing to delete")
116
+ return
117
+ }
118
+
119
+ const allIndices = Array.from({ length: itemCount }, (_, i) => i)
120
+ const totalSize = this.view.getTotalSize(allIndices)
121
+
122
+ const details: ConfirmDetails = {
123
+ title: `Delete ALL ${itemCount} Log Files`,
124
+ fields: [
125
+ { label: "Count:", value: String(itemCount) },
126
+ { label: "Total Size:", value: formatBytes(totalSize) },
127
+ ],
128
+ onConfirm: async () => {
129
+ await this.executeDelete(ctx, allIndices)
130
+ },
131
+ }
132
+
133
+ ctx.pushController(new ConfirmDialogController(details))
134
+ }
135
+
136
+ private async executeDelete(ctx: ControllerContext, indices: number[]): Promise<void> {
137
+ this.state.setLoading(true)
138
+ ctx.header.setLoading("Deleting...")
139
+
140
+ try {
141
+ const result = await this.view.executeDelete(indices)
142
+ this.state.setStatus(`Deleted ${result.deletedCount} items, freed ${formatBytes(result.freedBytes)}`)
143
+ await ctx.loadData()
144
+ } catch (error) {
145
+ this.state.setStatus(`Error: ${error}`)
146
+ }
147
+
148
+ this.state.setLoading(false)
149
+ }
150
+
151
+ private showHelp(ctx: ControllerContext): void {
152
+ ctx.statusBar.setMessage(`Help: ${this.getHelpText()}`)
153
+ }
154
+
155
+ private async viewLog(ctx: ControllerContext): Promise<void> {
156
+ const currentIndex = ctx.listContainer.getSelectedIndex()
157
+ const logs = this.view.getItems()
158
+
159
+ if (currentIndex < 0 || currentIndex >= logs.length) {
160
+ this.state.setStatus("No log file selected")
161
+ return
162
+ }
163
+
164
+ const log = logs[currentIndex]
165
+
166
+ try {
167
+ const content = await readFile(log.path, "utf-8")
168
+ ctx.pushController(new LogViewerController(log.filename, content))
169
+ } catch (error) {
170
+ this.state.setStatus(`Error reading log: ${error}`)
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,52 @@
1
+ import type { ViewController, ControllerContext } from "./index"
2
+ import { Action, LOG_VIEWER_KEYBINDINGS, getHintsFromBindings, type KeyBinding } from "../keybindings"
3
+
4
+ // ============================================================================
5
+ // Log Viewer Controller
6
+ // ============================================================================
7
+
8
+ export class LogViewerController implements ViewController {
9
+ constructor(
10
+ private filename: string,
11
+ private content: string
12
+ ) {}
13
+
14
+ handleAction(action: Action, ctx: ControllerContext): boolean {
15
+ switch (action) {
16
+ case Action.SCROLL_UP:
17
+ ctx.logViewer.scrollUp()
18
+ return true
19
+ case Action.SCROLL_DOWN:
20
+ ctx.logViewer.scrollDown()
21
+ return true
22
+ case Action.SCROLL_TOP:
23
+ ctx.logViewer.scrollToTop()
24
+ return true
25
+ case Action.SCROLL_BOTTOM:
26
+ ctx.logViewer.scrollToBottom()
27
+ return true
28
+ case Action.BACK:
29
+ ctx.popController()
30
+ return true
31
+ default:
32
+ return false
33
+ }
34
+ }
35
+
36
+ getHelpText(): string {
37
+ return "j/k=scroll, g=top, G=bottom, Esc/q=back"
38
+ }
39
+
40
+ getKeybindings(): KeyBinding[] {
41
+ return LOG_VIEWER_KEYBINDINGS
42
+ }
43
+
44
+ onEnter(ctx: ControllerContext): void {
45
+ ctx.logViewer.show(this.filename, this.content)
46
+ ctx.statusBar.setHints(getHintsFromBindings(LOG_VIEWER_KEYBINDINGS))
47
+ }
48
+
49
+ onExit(ctx: ControllerContext): void {
50
+ ctx.logViewer.hide()
51
+ }
52
+ }
@@ -0,0 +1,142 @@
1
+ import type { StateManager } from "../state"
2
+ import type { MainView } from "../views/main-view"
3
+ import type { ViewController, ControllerContext } from "./index"
4
+ import { ConfirmDialogController, type ConfirmDetails } from "./confirm-controller"
5
+ import { ProjectViewerController } from "./project-viewer-controller"
6
+ import { SessionViewerController } from "./session-viewer-controller"
7
+ import { Action, MAIN_KEYBINDINGS, getHintsForView, type KeyBinding } from "../keybindings"
8
+ import { formatBytes, truncatePath } from "../../utils"
9
+
10
+ // ============================================================================
11
+ // Main Controller - Projects & Sessions View
12
+ // ============================================================================
13
+
14
+ export class MainController implements ViewController {
15
+ constructor(
16
+ private view: MainView,
17
+ private state: StateManager
18
+ ) {}
19
+
20
+ handleAction(action: Action, ctx: ControllerContext): boolean {
21
+ switch (action) {
22
+ case Action.EXPAND:
23
+ this.expandCurrentProject(ctx)
24
+ return true
25
+ case Action.COLLAPSE:
26
+ this.collapseCurrentProject(ctx)
27
+ return true
28
+ case Action.ENTER:
29
+ this.viewCurrentItem(ctx)
30
+ return true
31
+ case Action.DELETE:
32
+ this.initiateDelete(ctx)
33
+ return true
34
+ case Action.HELP:
35
+ this.showHelp(ctx)
36
+ return true
37
+ case Action.BACK:
38
+ // No-op at root level
39
+ return true
40
+ default:
41
+ return false
42
+ }
43
+ }
44
+
45
+ getHelpText(): string {
46
+ return "j/k=nav, Enter=view, l=expand, h=collapse, d=delete, Tab=next view, q=quit"
47
+ }
48
+
49
+ getKeybindings(): KeyBinding[] {
50
+ return MAIN_KEYBINDINGS
51
+ }
52
+
53
+ onEnter(ctx: ControllerContext): void {
54
+ ctx.statusBar.setHints(getHintsForView("main"))
55
+ }
56
+
57
+ // --------------------------------------------------------------------------
58
+ // Private Methods
59
+ // --------------------------------------------------------------------------
60
+
61
+ private expandCurrentProject(ctx: ControllerContext): void {
62
+ const index = ctx.listContainer.getSelectedIndex()
63
+ const projectId = this.view.getProjectIdAt(index)
64
+
65
+ if (projectId && !this.state.isProjectExpanded(projectId)) {
66
+ this.state.expandProject(projectId)
67
+ }
68
+ }
69
+
70
+ private collapseCurrentProject(ctx: ControllerContext): void {
71
+ const index = ctx.listContainer.getSelectedIndex()
72
+ const projectId = this.view.getProjectIdAt(index)
73
+
74
+ if (projectId && this.state.isProjectExpanded(projectId)) {
75
+ this.state.collapseProject(projectId)
76
+ }
77
+ }
78
+
79
+ private initiateDelete(ctx: ControllerContext): void {
80
+ const currentIndex = ctx.listContainer.getSelectedIndex()
81
+ const item = this.view.getItemAt(currentIndex)
82
+ if (!item) return
83
+
84
+ const details: ConfirmDetails = item.type === "project"
85
+ ? {
86
+ title: "Delete Project",
87
+ fields: [
88
+ { label: "Path:", value: truncatePath(item.project.worktree, 40) },
89
+ { label: "Sessions:", value: String(item.project.sessionCount) },
90
+ { label: "Size:", value: formatBytes(item.project.totalSizeBytes) },
91
+ ],
92
+ onConfirm: async () => {
93
+ await this.executeDelete(ctx, [currentIndex])
94
+ },
95
+ }
96
+ : {
97
+ title: "Delete Session",
98
+ fields: [
99
+ { label: "Title:", value: item.session.title || item.session.slug || "Untitled" },
100
+ { label: "ID:", value: item.session.id.slice(0, 12) },
101
+ { label: "Project:", value: truncatePath(item.project.worktree, 35) },
102
+ { label: "Size:", value: formatBytes(item.session.sizeBytes) },
103
+ ],
104
+ onConfirm: async () => {
105
+ await this.executeDelete(ctx, [currentIndex])
106
+ },
107
+ }
108
+
109
+ ctx.pushController(new ConfirmDialogController(details))
110
+ }
111
+
112
+ private async executeDelete(ctx: ControllerContext, indices: number[]): Promise<void> {
113
+ this.state.setLoading(true)
114
+ ctx.header.setLoading("Deleting...")
115
+
116
+ try {
117
+ const result = await this.view.executeDelete(indices)
118
+ this.state.setStatus(`Deleted ${result.deletedCount} items, freed ${formatBytes(result.freedBytes)}`)
119
+ await ctx.loadData()
120
+ } catch (error) {
121
+ this.state.setStatus(`Error: ${error}`)
122
+ }
123
+
124
+ this.state.setLoading(false)
125
+ }
126
+
127
+ private showHelp(ctx: ControllerContext): void {
128
+ ctx.statusBar.setMessage(`Help: ${this.getHelpText()}`)
129
+ }
130
+
131
+ private viewCurrentItem(ctx: ControllerContext): void {
132
+ const currentIndex = ctx.listContainer.getSelectedIndex()
133
+ const item = this.view.getItemAt(currentIndex)
134
+ if (!item) return
135
+
136
+ if (item.type === "project") {
137
+ ctx.pushController(new ProjectViewerController(item.project))
138
+ } else {
139
+ ctx.pushController(new SessionViewerController(item.session))
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,176 @@
1
+ import type { ViewController, ControllerContext } from "./index"
2
+ import type { Message, Part } from "../../types"
3
+ import { Action, LOG_VIEWER_KEYBINDINGS, getHintsFromBindings, type KeyBinding } from "../keybindings"
4
+ import { loadParts } from "../../data/loader"
5
+ import { formatRelativeTime } from "../../utils"
6
+
7
+ // ============================================================================
8
+ // Message Viewer Controller
9
+ // ============================================================================
10
+
11
+ export class MessageViewerController implements ViewController {
12
+ private parts: Part[] = []
13
+
14
+ constructor(private message: Message) {}
15
+
16
+ handleAction(action: Action, ctx: ControllerContext): boolean {
17
+ switch (action) {
18
+ case Action.SCROLL_UP:
19
+ ctx.logViewer.scrollUp()
20
+ return true
21
+ case Action.SCROLL_DOWN:
22
+ ctx.logViewer.scrollDown()
23
+ return true
24
+ case Action.SCROLL_TOP:
25
+ ctx.logViewer.scrollToTop()
26
+ return true
27
+ case Action.SCROLL_BOTTOM:
28
+ ctx.logViewer.scrollToBottom()
29
+ return true
30
+ case Action.BACK:
31
+ ctx.popController()
32
+ return true
33
+ default:
34
+ return false
35
+ }
36
+ }
37
+
38
+ getHelpText(): string {
39
+ return "j/k=scroll, g/G=top/bottom, Esc/q=back"
40
+ }
41
+
42
+ getKeybindings(): KeyBinding[] {
43
+ return LOG_VIEWER_KEYBINDINGS
44
+ }
45
+
46
+ async onEnter(ctx: ControllerContext): Promise<void> {
47
+ // Show loading state
48
+ ctx.statusBar.setHints("Loading message content...")
49
+
50
+ // Load parts
51
+ this.parts = await loadParts(this.message.id)
52
+
53
+ // Build content
54
+ const content = this.buildContent()
55
+ const title = `Message: ${this.message.role} (${this.message.id.slice(0, 16)}...)`
56
+
57
+ ctx.logViewer.show(title, content)
58
+ ctx.statusBar.setHints(getHintsFromBindings(LOG_VIEWER_KEYBINDINGS))
59
+ }
60
+
61
+ onExit(ctx: ControllerContext): void {
62
+ ctx.logViewer.hide()
63
+ }
64
+
65
+ // --------------------------------------------------------------------------
66
+ // Private Methods
67
+ // --------------------------------------------------------------------------
68
+
69
+ private buildContent(): string {
70
+ const lines: string[] = []
71
+
72
+ // Message metadata header
73
+ lines.push("=" .repeat(70))
74
+ lines.push(`Role: ${this.message.role}`)
75
+ lines.push(`ID: ${this.message.id}`)
76
+ lines.push(`Created: ${formatRelativeTime(this.message.time.created)}`)
77
+ if (this.message.time.completed) {
78
+ lines.push(`Completed: ${formatRelativeTime(this.message.time.completed)}`)
79
+ }
80
+ if (this.message.modelID) {
81
+ lines.push(`Model: ${this.message.modelID}`)
82
+ }
83
+ if (this.message.providerID) {
84
+ lines.push(`Provider: ${this.message.providerID}`)
85
+ }
86
+ if (this.message.tokens) {
87
+ lines.push(`Tokens: ${this.message.tokens.input} in / ${this.message.tokens.output} out`)
88
+ if (this.message.tokens.reasoning > 0) {
89
+ lines.push(`Reasoning: ${this.message.tokens.reasoning}`)
90
+ }
91
+ }
92
+ if (this.message.cost) {
93
+ lines.push(`Cost: $${this.message.cost.toFixed(4)}`)
94
+ }
95
+ if (this.message.finish) {
96
+ lines.push(`Finish: ${this.message.finish}`)
97
+ }
98
+ lines.push("=" .repeat(70))
99
+ lines.push("")
100
+
101
+ // Parts
102
+ if (this.parts.length === 0) {
103
+ lines.push("(No parts found)")
104
+ } else {
105
+ for (const part of this.parts) {
106
+ lines.push(...this.formatPart(part))
107
+ lines.push("")
108
+ }
109
+ }
110
+
111
+ return lines.join("\n")
112
+ }
113
+
114
+ private formatPart(part: Part): string[] {
115
+ const lines: string[] = []
116
+
117
+ switch (part.type) {
118
+ case "text":
119
+ lines.push("-".repeat(40))
120
+ lines.push("[TEXT]")
121
+ lines.push("-".repeat(40))
122
+ if (part.text) {
123
+ lines.push(part.text)
124
+ }
125
+ break
126
+
127
+ case "tool":
128
+ lines.push("-".repeat(40))
129
+ lines.push(`[TOOL: ${part.tool || "unknown"}]`)
130
+ lines.push("-".repeat(40))
131
+ if (part.state) {
132
+ // Show status
133
+ if (part.state.status) {
134
+ lines.push(`Status: ${part.state.status}`)
135
+ }
136
+ // Show title/description
137
+ if (part.state.title) {
138
+ lines.push(`Title: ${part.state.title}`)
139
+ }
140
+ // Show input
141
+ if (part.state.input) {
142
+ lines.push("")
143
+ lines.push("Input:")
144
+ lines.push(JSON.stringify(part.state.input, null, 2))
145
+ }
146
+ // Show output (truncated if very long)
147
+ if (part.state.output) {
148
+ lines.push("")
149
+ lines.push("Output:")
150
+ const output = String(part.state.output)
151
+ if (output.length > 2000) {
152
+ lines.push(output.slice(0, 2000))
153
+ lines.push(`... (truncated, ${output.length} chars total)`)
154
+ } else {
155
+ lines.push(output)
156
+ }
157
+ }
158
+ }
159
+ break
160
+
161
+ case "step-start":
162
+ lines.push("[STEP START]")
163
+ break
164
+
165
+ case "step-finish":
166
+ lines.push("[STEP FINISH]")
167
+ break
168
+
169
+ default:
170
+ lines.push(`[${part.type}]`)
171
+ break
172
+ }
173
+
174
+ return lines
175
+ }
176
+ }