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