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