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,125 @@
1
+ import type { StateManager } from "../state"
2
+ import type { OrphanListView } from "../views/orphan-list"
3
+ import type { ViewController, ControllerContext } from "./index"
4
+ import { ConfirmDialogController, type ConfirmDetails } from "./confirm-controller"
5
+ import { Action, ORPHAN_KEYBINDINGS, getHintsForView, type KeyBinding } from "../keybindings"
6
+ import { formatBytes, truncatePath } from "../../utils"
7
+
8
+ // ============================================================================
9
+ // Orphan Controller - Orphan Projects View
10
+ // ============================================================================
11
+
12
+ export class OrphanController implements ViewController {
13
+ constructor(
14
+ private view: OrphanListView,
15
+ private state: StateManager
16
+ ) {}
17
+
18
+ handleAction(action: Action, ctx: ControllerContext): boolean {
19
+ switch (action) {
20
+ case Action.EXPAND:
21
+ this.expandCurrentProject(ctx)
22
+ return true
23
+ case Action.COLLAPSE:
24
+ this.collapseCurrentProject(ctx)
25
+ return true
26
+ case Action.DELETE:
27
+ this.initiateDelete(ctx)
28
+ return true
29
+ case Action.HELP:
30
+ this.showHelp(ctx)
31
+ return true
32
+ case Action.BACK:
33
+ // No-op at root level
34
+ return true
35
+ default:
36
+ return false
37
+ }
38
+ }
39
+
40
+ getHelpText(): string {
41
+ return "j/k=nav, l=expand, h=collapse, d=delete, Tab=next view, q=quit"
42
+ }
43
+
44
+ getKeybindings(): KeyBinding[] {
45
+ return ORPHAN_KEYBINDINGS
46
+ }
47
+
48
+ onEnter(ctx: ControllerContext): void {
49
+ ctx.statusBar.setHints(getHintsForView("orphans"))
50
+ }
51
+
52
+ // --------------------------------------------------------------------------
53
+ // Private Methods
54
+ // --------------------------------------------------------------------------
55
+
56
+ private expandCurrentProject(ctx: ControllerContext): void {
57
+ const index = ctx.listContainer.getSelectedIndex()
58
+ const projectId = this.view.getProjectIdAt(index)
59
+
60
+ if (projectId && !this.state.isProjectExpanded(projectId)) {
61
+ this.state.expandProject(projectId)
62
+ }
63
+ }
64
+
65
+ private collapseCurrentProject(ctx: ControllerContext): void {
66
+ const index = ctx.listContainer.getSelectedIndex()
67
+ const projectId = this.view.getProjectIdAt(index)
68
+
69
+ if (projectId && this.state.isProjectExpanded(projectId)) {
70
+ this.state.collapseProject(projectId)
71
+ }
72
+ }
73
+
74
+ private initiateDelete(ctx: ControllerContext): void {
75
+ const currentIndex = ctx.listContainer.getSelectedIndex()
76
+ const item = this.view.getItemAt(currentIndex)
77
+ if (!item) return
78
+
79
+ const details: ConfirmDetails = item.type === "project"
80
+ ? {
81
+ title: "Delete Orphan Project",
82
+ fields: [
83
+ { label: "Path:", value: truncatePath(item.project.worktree, 40) },
84
+ { label: "Sessions:", value: String(item.project.sessionCount) },
85
+ { label: "Size:", value: formatBytes(item.project.totalSizeBytes) },
86
+ ],
87
+ onConfirm: async () => {
88
+ await this.executeDelete(ctx, [currentIndex])
89
+ },
90
+ }
91
+ : {
92
+ title: "Delete Session",
93
+ fields: [
94
+ { label: "Title:", value: item.session.title || item.session.slug || "Untitled" },
95
+ { label: "ID:", value: item.session.id.slice(0, 12) },
96
+ { label: "Project:", value: truncatePath(item.project.worktree, 35) },
97
+ { label: "Size:", value: formatBytes(item.session.sizeBytes) },
98
+ ],
99
+ onConfirm: async () => {
100
+ await this.executeDelete(ctx, [currentIndex])
101
+ },
102
+ }
103
+
104
+ ctx.pushController(new ConfirmDialogController(details))
105
+ }
106
+
107
+ private async executeDelete(ctx: ControllerContext, indices: number[]): Promise<void> {
108
+ this.state.setLoading(true)
109
+ ctx.header.setLoading("Deleting...")
110
+
111
+ try {
112
+ const result = await this.view.executeDelete(indices)
113
+ this.state.setStatus(`Deleted ${result.deletedCount} items, freed ${formatBytes(result.freedBytes)}`)
114
+ await ctx.loadData()
115
+ } catch (error) {
116
+ this.state.setStatus(`Error: ${error}`)
117
+ }
118
+
119
+ this.state.setLoading(false)
120
+ }
121
+
122
+ private showHelp(ctx: ControllerContext): void {
123
+ ctx.statusBar.setMessage(`Help: ${this.getHelpText()}`)
124
+ }
125
+ }
@@ -0,0 +1,113 @@
1
+ import type { ViewController, ControllerContext } from "./index"
2
+ import type { ProjectInfo, SessionInfo } from "../../types"
3
+ import type { DetailViewerData, DetailViewerItem } from "../components/detail-viewer"
4
+ import { Action, DETAIL_VIEWER_KEYBINDINGS, getHintsFromBindings, type KeyBinding } from "../keybindings"
5
+ import { getProjectStorageInfo } from "../../data/loader"
6
+ import { formatBytes, formatRelativeTime, truncatePath } from "../../utils"
7
+ import { SessionViewerController } from "./session-viewer-controller"
8
+
9
+ // ============================================================================
10
+ // Project Viewer Controller
11
+ // ============================================================================
12
+
13
+ export class ProjectViewerController implements ViewController {
14
+ private sessions: SessionInfo[] = []
15
+
16
+ constructor(private project: ProjectInfo) {
17
+ this.sessions = project.sessions
18
+ }
19
+
20
+ handleAction(action: Action, ctx: ControllerContext): boolean {
21
+ switch (action) {
22
+ case Action.SCROLL_UP:
23
+ ctx.detailViewer.scrollUp()
24
+ return true
25
+ case Action.SCROLL_DOWN:
26
+ ctx.detailViewer.scrollDown()
27
+ return true
28
+ case Action.SCROLL_TOP:
29
+ ctx.detailViewer.scrollToTop()
30
+ return true
31
+ case Action.SCROLL_BOTTOM:
32
+ ctx.detailViewer.scrollToBottom()
33
+ return true
34
+ case Action.ENTER:
35
+ this.viewSelectedSession(ctx)
36
+ return true
37
+ case Action.BACK:
38
+ ctx.popController()
39
+ return true
40
+ default:
41
+ return false
42
+ }
43
+ }
44
+
45
+ getHelpText(): string {
46
+ return "j/k=nav, Enter=view session, g/G=top/bottom, Esc/q=back"
47
+ }
48
+
49
+ getKeybindings(): KeyBinding[] {
50
+ return DETAIL_VIEWER_KEYBINDINGS
51
+ }
52
+
53
+ async onEnter(ctx: ControllerContext): Promise<void> {
54
+ // Show loading state
55
+ ctx.statusBar.setHints("Loading project details...")
56
+
57
+ // Load storage info
58
+ const storageInfo = await getProjectStorageInfo(this.project.id)
59
+
60
+ // Build detail viewer data
61
+ const data: DetailViewerData = {
62
+ title: `Project: ${truncatePath(this.project.worktree, 60)}`,
63
+ fields: [
64
+ { label: "Path:", value: this.project.worktree },
65
+ { label: "Sessions:", value: String(this.project.sessionCount) },
66
+ { label: "Size:", value: formatBytes(this.project.totalSizeBytes) },
67
+ { label: "Created:", value: formatRelativeTime(this.project.time.created) },
68
+ { label: "Updated:", value: formatRelativeTime(this.project.time.updated) },
69
+ { label: "Messages:", value: String(storageInfo.totalMessages) },
70
+ { label: "Parts:", value: String(storageInfo.totalParts) },
71
+ { label: "Status:", value: this.project.isOrphan ? "ORPHAN (directory missing)" : "Active" },
72
+ ],
73
+ itemsLabel: "Sessions:",
74
+ items: this.buildSessionItems(),
75
+ }
76
+
77
+ ctx.detailViewer.show(data)
78
+ ctx.statusBar.setHints(getHintsFromBindings(DETAIL_VIEWER_KEYBINDINGS))
79
+ }
80
+
81
+ onExit(ctx: ControllerContext): void {
82
+ ctx.detailViewer.hide()
83
+ }
84
+
85
+ // --------------------------------------------------------------------------
86
+ // Private Methods
87
+ // --------------------------------------------------------------------------
88
+
89
+ private buildSessionItems(): DetailViewerItem[] {
90
+ return this.sessions.map((session) => {
91
+ const title = session.title || session.slug || session.id.slice(0, 12)
92
+ const updated = formatRelativeTime(session.time.updated)
93
+ const messages = `${session.messageCount} messages`
94
+ const size = formatBytes(session.sizeBytes)
95
+
96
+ return {
97
+ name: title,
98
+ description: `${updated} | ${messages} | ${size}`,
99
+ value: session.id,
100
+ }
101
+ })
102
+ }
103
+
104
+ private viewSelectedSession(ctx: ControllerContext): void {
105
+ const selectedValue = ctx.detailViewer.getSelectedValue()
106
+ if (!selectedValue) return
107
+
108
+ const session = this.sessions.find((s) => s.id === selectedValue)
109
+ if (session) {
110
+ ctx.pushController(new SessionViewerController(session))
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,181 @@
1
+ import type { ViewController, ControllerContext } from "./index"
2
+ import type { SessionInfo, Message, Todo } from "../../types"
3
+ import type { DetailViewerData, DetailViewerItem, DetailViewerSection } from "../components/detail-viewer"
4
+ import { Action, DETAIL_VIEWER_KEYBINDINGS, getHintsFromBindings, type KeyBinding } from "../keybindings"
5
+ import { loadMessages, loadTodos } from "../../data/loader"
6
+ import { formatBytes, formatRelativeTime, truncatePath } from "../../utils"
7
+ import { MessageViewerController } from "./message-viewer-controller"
8
+
9
+ // ============================================================================
10
+ // Session Viewer Controller
11
+ // ============================================================================
12
+
13
+ export class SessionViewerController implements ViewController {
14
+ private messages: Message[] = []
15
+ private todos: Todo[] = []
16
+
17
+ constructor(private session: SessionInfo) {}
18
+
19
+ handleAction(action: Action, ctx: ControllerContext): boolean {
20
+ switch (action) {
21
+ case Action.SCROLL_UP:
22
+ ctx.detailViewer.scrollUp()
23
+ return true
24
+ case Action.SCROLL_DOWN:
25
+ ctx.detailViewer.scrollDown()
26
+ return true
27
+ case Action.SCROLL_TOP:
28
+ ctx.detailViewer.scrollToTop()
29
+ return true
30
+ case Action.SCROLL_BOTTOM:
31
+ ctx.detailViewer.scrollToBottom()
32
+ return true
33
+ case Action.ENTER:
34
+ this.viewSelectedMessage(ctx)
35
+ return true
36
+ case Action.BACK:
37
+ ctx.popController()
38
+ return true
39
+ default:
40
+ return false
41
+ }
42
+ }
43
+
44
+ getHelpText(): string {
45
+ return "j/k=nav, Enter=view message, g/G=top/bottom, Esc/q=back"
46
+ }
47
+
48
+ getKeybindings(): KeyBinding[] {
49
+ return DETAIL_VIEWER_KEYBINDINGS
50
+ }
51
+
52
+ async onEnter(ctx: ControllerContext): Promise<void> {
53
+ // Show loading state
54
+ ctx.statusBar.setHints("Loading session details...")
55
+
56
+ // Load messages and todos in parallel
57
+ const [messages, todos] = await Promise.all([
58
+ loadMessages(this.session.id),
59
+ loadTodos(this.session.id),
60
+ ])
61
+ this.messages = messages
62
+ this.todos = todos
63
+
64
+ // Calculate token totals
65
+ let totalInputTokens = 0
66
+ let totalOutputTokens = 0
67
+ let totalCost = 0
68
+
69
+ for (const msg of this.messages) {
70
+ if (msg.tokens) {
71
+ totalInputTokens += msg.tokens.input
72
+ totalOutputTokens += msg.tokens.output
73
+ }
74
+ if (msg.cost) {
75
+ totalCost += msg.cost
76
+ }
77
+ }
78
+
79
+ // Build detail viewer data
80
+ const title = this.session.title || this.session.slug || "Untitled Session"
81
+ const data: DetailViewerData = {
82
+ title: `Session: ${title}`,
83
+ fields: [
84
+ { label: "ID:", value: this.session.id },
85
+ { label: "Project:", value: truncatePath(this.session.projectWorktree, 50) },
86
+ { label: "Directory:", value: truncatePath(this.session.directory, 50) },
87
+ { label: "Messages:", value: String(this.messages.length) },
88
+ { label: "Size:", value: formatBytes(this.session.sizeBytes) },
89
+ { label: "Created:", value: formatRelativeTime(this.session.time.created) },
90
+ { label: "Updated:", value: formatRelativeTime(this.session.time.updated) },
91
+ { label: "Input tokens:", value: totalInputTokens.toLocaleString() },
92
+ { label: "Output tokens:", value: totalOutputTokens.toLocaleString() },
93
+ { label: "Total cost:", value: totalCost > 0 ? `$${totalCost.toFixed(4)}` : "N/A" },
94
+ { label: "Todos:", value: this.buildTodoSummary() },
95
+ { label: "Status:", value: this.session.isOrphan ? "ORPHAN" : "Active" },
96
+ ],
97
+ sections: this.buildTodoSection(),
98
+ itemsLabel: "Messages:",
99
+ items: this.buildMessageItems(),
100
+ }
101
+
102
+ ctx.detailViewer.show(data)
103
+ ctx.statusBar.setHints(getHintsFromBindings(DETAIL_VIEWER_KEYBINDINGS))
104
+ }
105
+
106
+ onExit(ctx: ControllerContext): void {
107
+ ctx.detailViewer.hide()
108
+ }
109
+
110
+ // --------------------------------------------------------------------------
111
+ // Private Methods
112
+ // --------------------------------------------------------------------------
113
+
114
+ private buildTodoSummary(): string {
115
+ if (this.todos.length === 0) return "None"
116
+
117
+ const completed = this.todos.filter((t) => t.status === "completed").length
118
+ const pending = this.todos.filter((t) => t.status === "pending").length
119
+ const inProgress = this.todos.filter((t) => t.status === "in_progress").length
120
+ const cancelled = this.todos.filter((t) => t.status === "cancelled").length
121
+
122
+ const parts: string[] = []
123
+ if (completed > 0) parts.push(`${completed} completed`)
124
+ if (inProgress > 0) parts.push(`${inProgress} in progress`)
125
+ if (pending > 0) parts.push(`${pending} pending`)
126
+ if (cancelled > 0) parts.push(`${cancelled} cancelled`)
127
+
128
+ return parts.join(", ")
129
+ }
130
+
131
+ private buildTodoSection(): DetailViewerSection[] {
132
+ if (this.todos.length === 0) return []
133
+
134
+ const statusIcon: Record<Todo["status"], string> = {
135
+ completed: "[COMPLETED]",
136
+ in_progress: "[IN_PROGRESS]",
137
+ pending: "[PENDING]",
138
+ cancelled: "[CANCELLED]",
139
+ }
140
+
141
+ const lines = this.todos.map((todo) => {
142
+ const priorityLabel = todo.priority !== "medium" ? ` (${todo.priority})` : ""
143
+ return `${statusIcon[todo.status]} ${todo.content}${priorityLabel}`
144
+ })
145
+
146
+ return [{ label: "Todos:", lines }]
147
+ }
148
+
149
+ private buildMessageItems(): DetailViewerItem[] {
150
+ return this.messages.map((msg, index) => {
151
+ const roleIcon = msg.role === "user" ? "[U]" : "[A]"
152
+ const time = formatRelativeTime(msg.time.created)
153
+ const model = msg.modelID || ""
154
+
155
+ // Token info for assistant messages
156
+ let tokenInfo = ""
157
+ if (msg.role === "assistant" && msg.tokens) {
158
+ tokenInfo = ` | ${msg.tokens.input}/${msg.tokens.output} tokens`
159
+ }
160
+
161
+ // Finish reason
162
+ const finish = msg.finish ? ` | ${msg.finish}` : ""
163
+
164
+ return {
165
+ name: `${index + 1}. ${roleIcon} ${msg.role === "user" ? "User message" : model}`,
166
+ description: `${time}${tokenInfo}${finish}`,
167
+ value: msg.id,
168
+ }
169
+ })
170
+ }
171
+
172
+ private viewSelectedMessage(ctx: ControllerContext): void {
173
+ const selectedValue = ctx.detailViewer.getSelectedValue()
174
+ if (!selectedValue) return
175
+
176
+ const message = this.messages.find((m) => m.id === selectedValue)
177
+ if (message) {
178
+ ctx.pushController(new MessageViewerController(message))
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,158 @@
1
+ import type { ViewType } from "../types"
2
+
3
+ // ============================================================================
4
+ // Keybinding Types
5
+ // ============================================================================
6
+
7
+ export interface KeyBinding {
8
+ keys: string[]
9
+ action: string
10
+ description: string
11
+ }
12
+
13
+ // ============================================================================
14
+ // Action Constants
15
+ // ============================================================================
16
+
17
+ export enum Action {
18
+ QUIT = "quit",
19
+ NEXT_VIEW = "next-view",
20
+ TOGGLE_SELECT = "toggle-select",
21
+ SELECT_ALL = "select-all",
22
+ DELETE = "delete",
23
+ DELETE_ALL = "delete-all",
24
+ EXPAND = "expand",
25
+ COLLAPSE = "collapse",
26
+ RELOAD = "reload",
27
+ HELP = "help",
28
+ BACK = "back",
29
+ CONFIRM_YES = "confirm-yes",
30
+ CONFIRM_NO = "confirm-no",
31
+ ENTER = "enter",
32
+ SCROLL_UP = "scroll-up",
33
+ SCROLL_DOWN = "scroll-down",
34
+ SCROLL_TOP = "scroll-top",
35
+ SCROLL_BOTTOM = "scroll-bottom",
36
+ }
37
+
38
+ // ============================================================================
39
+ // View-specific Keybindings
40
+ // ============================================================================
41
+
42
+ // Common keybindings for all views
43
+ const BASE_KEYBINDINGS: KeyBinding[] = [
44
+ { keys: ["d"], action: Action.DELETE, description: "d:delete" },
45
+ { keys: ["r"], action: Action.RELOAD, description: "r:reload" },
46
+ { keys: ["tab"], action: Action.NEXT_VIEW, description: "Tab:next view" },
47
+ { keys: ["?"], action: Action.HELP, description: "?:help" },
48
+ ]
49
+
50
+ // Main view: no selection, has expand/collapse
51
+ export const MAIN_KEYBINDINGS: KeyBinding[] = [
52
+ ...BASE_KEYBINDINGS,
53
+ { keys: ["l"], action: Action.EXPAND, description: "l:expand" },
54
+ { keys: ["h"], action: Action.COLLAPSE, description: "h:collapse" },
55
+ { keys: ["return"], action: Action.ENTER, description: "Enter:view" },
56
+ { keys: ["q"], action: Action.QUIT, description: "q:quit" },
57
+ { keys: ["escape"], action: Action.BACK, description: "" }, // hidden from hints
58
+ ]
59
+
60
+ // Orphan view: no selection, has expand/collapse
61
+ export const ORPHAN_KEYBINDINGS: KeyBinding[] = [
62
+ ...BASE_KEYBINDINGS,
63
+ { keys: ["l"], action: Action.EXPAND, description: "l:expand" },
64
+ { keys: ["h"], action: Action.COLLAPSE, description: "h:collapse" },
65
+ { keys: ["q"], action: Action.QUIT, description: "q:quit" },
66
+ { keys: ["escape"], action: Action.BACK, description: "" }, // hidden from hints
67
+ ]
68
+
69
+ // Log view: has selection (Space, a, D)
70
+ export const LOG_KEYBINDINGS: KeyBinding[] = [
71
+ ...BASE_KEYBINDINGS,
72
+ { keys: ["space"], action: Action.TOGGLE_SELECT, description: "Space:select" },
73
+ { keys: ["a"], action: Action.SELECT_ALL, description: "a:all" },
74
+ { keys: ["D"], action: Action.DELETE_ALL, description: "D:delete-all" },
75
+ { keys: ["return"], action: Action.ENTER, description: "Enter:view" },
76
+ { keys: ["q"], action: Action.QUIT, description: "q:quit" },
77
+ { keys: ["escape"], action: Action.BACK, description: "" }, // hidden from hints
78
+ ]
79
+
80
+ // Log viewer keybindings
81
+ export const LOG_VIEWER_KEYBINDINGS: KeyBinding[] = [
82
+ { keys: ["j"], action: Action.SCROLL_DOWN, description: "j:down" },
83
+ { keys: ["k"], action: Action.SCROLL_UP, description: "k:up" },
84
+ { keys: ["g"], action: Action.SCROLL_TOP, description: "g:top" },
85
+ { keys: ["G"], action: Action.SCROLL_BOTTOM, description: "G:bottom" },
86
+ { keys: ["escape", "q"], action: Action.BACK, description: "Esc/q:back" },
87
+ ]
88
+
89
+ // Detail viewer keybindings (project/session/message viewers)
90
+ export const DETAIL_VIEWER_KEYBINDINGS: KeyBinding[] = [
91
+ { keys: ["j"], action: Action.SCROLL_DOWN, description: "j:down" },
92
+ { keys: ["k"], action: Action.SCROLL_UP, description: "k:up" },
93
+ { keys: ["g"], action: Action.SCROLL_TOP, description: "g:top" },
94
+ { keys: ["G"], action: Action.SCROLL_BOTTOM, description: "G:bottom" },
95
+ { keys: ["return"], action: Action.ENTER, description: "Enter:view" },
96
+ { keys: ["escape", "q"], action: Action.BACK, description: "Esc/q:back" },
97
+ ]
98
+
99
+ export const VIEW_KEYBINDINGS: Record<ViewType, KeyBinding[]> = {
100
+ main: MAIN_KEYBINDINGS,
101
+ orphans: ORPHAN_KEYBINDINGS,
102
+ logs: LOG_KEYBINDINGS,
103
+ }
104
+
105
+ // ============================================================================
106
+ // Confirm Dialog Keybindings
107
+ // ============================================================================
108
+
109
+ export const CONFIRM_KEYBINDINGS: KeyBinding[] = [
110
+ { keys: ["y", "Y", "return"], action: Action.CONFIRM_YES, description: "y:confirm" },
111
+ { keys: ["n", "N", "escape"], action: Action.CONFIRM_NO, description: "n/Esc:cancel" },
112
+ ]
113
+
114
+ // ============================================================================
115
+ // Utility Functions
116
+ // ============================================================================
117
+
118
+ export function getHintsForView(view: ViewType): string {
119
+ const bindings = VIEW_KEYBINDINGS[view]
120
+ const hints = ["j/k:nav"]
121
+
122
+ for (const binding of bindings) {
123
+ if (binding.description && !hints.includes(binding.description)) {
124
+ hints.push(binding.description)
125
+ }
126
+ }
127
+
128
+ return hints.join(" ")
129
+ }
130
+
131
+ export function getHintsFromBindings(bindings: KeyBinding[]): string {
132
+ const hints: string[] = []
133
+
134
+ for (const binding of bindings) {
135
+ if (binding.description && !hints.includes(binding.description)) {
136
+ hints.push(binding.description)
137
+ }
138
+ }
139
+
140
+ return hints.join(" ")
141
+ }
142
+
143
+ export function findAction(key: string, bindings: KeyBinding[]): string | null {
144
+ for (const binding of bindings) {
145
+ if (binding.keys.includes(key)) {
146
+ return binding.action
147
+ }
148
+ }
149
+ return null
150
+ }
151
+
152
+ export function findActionForView(key: string, view: ViewType): string | null {
153
+ return findAction(key, VIEW_KEYBINDINGS[view])
154
+ }
155
+
156
+ export function findConfirmAction(key: string): string | null {
157
+ return findAction(key, CONFIRM_KEYBINDINGS)
158
+ }