@alexzeitler/session-md 0.5.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/src/app.ts ADDED
@@ -0,0 +1,603 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ type CliRenderer,
5
+ type KeyEvent,
6
+ t,
7
+ fg,
8
+ bold,
9
+ } from "@opentui/core";
10
+ import type { Config } from "./config.ts";
11
+ import { addTarget } from "./config.ts";
12
+ import type { SessionEntry, SourceType } from "./import/types.ts";
13
+ import { SourcePicker } from "./components/SourcePicker.ts";
14
+ import { ConversationList } from "./components/ConversationList.ts";
15
+ import { MessageView } from "./components/MessageView.ts";
16
+ import { StatusBar, type FocusArea } from "./components/StatusBar.ts";
17
+ import { TargetPicker } from "./components/TargetPicker.ts";
18
+ import { SearchResultsView } from "./components/SearchResultsView.ts";
19
+ import { copySessionsToTarget } from "./file-ops.ts";
20
+ import type { SearchIndex } from "./search/index.ts";
21
+ import type { Theme } from "./theme.ts";
22
+
23
+ type AppState = "browse" | "target-picker" | "content-search";
24
+ type LeftFocus = "sources" | "sessions";
25
+
26
+ export class App {
27
+ private root!: BoxRenderable;
28
+ private body!: BoxRenderable;
29
+ private sidebarColumn!: BoxRenderable;
30
+ private sourcesBox!: BoxRenderable;
31
+ private sessionsBox!: BoxRenderable;
32
+ private mainBox!: BoxRenderable;
33
+
34
+ private sourcePicker!: SourcePicker;
35
+ private conversationList!: ConversationList;
36
+ private messageView!: MessageView;
37
+ private statusBar!: StatusBar;
38
+ private targetPicker!: TargetPicker;
39
+ private searchResultsView!: SearchResultsView;
40
+
41
+ private state: AppState = "browse";
42
+ private focusArea: FocusArea = "sidebar";
43
+ private leftFocus: LeftFocus = "sources";
44
+ private sessions: SessionEntry[] = [];
45
+ private pendingG = false;
46
+ private pendingGTimer: ReturnType<typeof setTimeout> | null = null;
47
+
48
+ constructor(
49
+ private renderer: CliRenderer,
50
+ private config: Config,
51
+ private searchIndex: SearchIndex,
52
+ private theme: Theme,
53
+ ) {}
54
+
55
+ async start(): Promise<void> {
56
+ this.buildLayout();
57
+ this.setupKeyboard();
58
+ this.updateStatusBar();
59
+ }
60
+
61
+ loadSessions(sessions: SessionEntry[]): void {
62
+ this.sessions = sessions;
63
+
64
+ // Compute source counts
65
+ const counts = new Map<string, number>();
66
+ for (const s of sessions) {
67
+ counts.set(s.meta.source, (counts.get(s.meta.source) ?? 0) + 1);
68
+ }
69
+ this.sourcePicker.update(counts);
70
+ this.conversationList.update(sessions);
71
+ this.updateStatusBar();
72
+ }
73
+
74
+ private buildLayout(): void {
75
+ const r = this.renderer;
76
+
77
+ this.root = new BoxRenderable(r, {
78
+ id: "root",
79
+ flexDirection: "column",
80
+ width: "100%" as any,
81
+ height: "100%" as any,
82
+ });
83
+ r.root.add(this.root);
84
+
85
+ const titleBar = new TextRenderable(r, {
86
+ id: "title-bar",
87
+ content: t`${bold(fg(this.theme.title)(" session-md"))} ${fg(this.theme.muted)(`v${require("../package.json").version}`)}`,
88
+ });
89
+
90
+ this.body = new BoxRenderable(r, {
91
+ id: "body",
92
+ flexDirection: "row",
93
+ flexGrow: 1,
94
+ width: "100%" as any,
95
+ });
96
+
97
+ // Sidebar column (no border, just a container)
98
+ this.sidebarColumn = new BoxRenderable(r, {
99
+ id: "sidebar-column",
100
+ width: 55,
101
+ flexDirection: "column",
102
+ });
103
+
104
+ // Sources box (top, fixed height)
105
+ this.sourcesBox = new BoxRenderable(r, {
106
+ id: "sources-box",
107
+ height: 8,
108
+ border: true,
109
+ borderStyle: "rounded",
110
+ borderColor: this.theme.border_active,
111
+ title: "Sources",
112
+ titleAlignment: "left",
113
+ flexDirection: "column",
114
+ });
115
+
116
+ // Sessions box (bottom, grows)
117
+ this.sessionsBox = new BoxRenderable(r, {
118
+ id: "sessions-box",
119
+ flexGrow: 1,
120
+ border: true,
121
+ borderStyle: "rounded",
122
+ borderColor: this.theme.border_inactive,
123
+ title: "Sessions",
124
+ titleAlignment: "left",
125
+ flexDirection: "column",
126
+ });
127
+
128
+ this.mainBox = new BoxRenderable(r, {
129
+ id: "main-box",
130
+ flexGrow: 1,
131
+ border: true,
132
+ borderStyle: "rounded",
133
+ borderColor: this.theme.border_inactive,
134
+ flexDirection: "column",
135
+ });
136
+
137
+ // Create components
138
+ this.sourcePicker = new SourcePicker(r, this.theme);
139
+ this.conversationList = new ConversationList(r, this.theme);
140
+ this.messageView = new MessageView(r, this.mainBox, this.theme);
141
+ this.statusBar = new StatusBar(r, this.theme);
142
+ this.targetPicker = new TargetPicker(r, this.theme);
143
+ this.searchResultsView = new SearchResultsView(r, this.theme);
144
+
145
+ // Wire up search
146
+ this.searchResultsView.setOnSearchQuery((query) => {
147
+ const source = this.conversationList.getSourceFilter();
148
+ return this.searchIndex.search(query, source);
149
+ });
150
+
151
+ this.searchResultsView.setOnResultSelected((result) => {
152
+ const session = this.sessions.find((s) => s.meta.id === result.id);
153
+ if (session) {
154
+ this.exitContentSearch(true);
155
+ this.messageView.setLoadFull(true);
156
+ this.messageView.load(session);
157
+ this.conversationList.selectById(result.id);
158
+ }
159
+ });
160
+
161
+ // Wire up callbacks
162
+ this.sourcePicker.setOnSourceChanged((source) => {
163
+ this.conversationList.setSourceFilter(source);
164
+ });
165
+
166
+ this.conversationList.setOnSessionFocused((session) => {
167
+ this.messageView.load(session);
168
+ });
169
+
170
+ this.targetPicker.onTargetSelected = (targetPath) => {
171
+ this.handleCopyToTarget(targetPath);
172
+ };
173
+
174
+ this.targetPicker.onNewTarget = (name, path) => {
175
+ this.handleNewTarget(name, path);
176
+ };
177
+
178
+ this.targetPicker.onCancel = () => {
179
+ this.exitTargetPicker();
180
+ };
181
+
182
+ // Assemble: sources box + sessions box in sidebar column
183
+ this.sourcesBox.add(this.sourcePicker.select);
184
+ this.sessionsBox.add(this.conversationList.container);
185
+
186
+ this.sidebarColumn.add(this.sourcesBox);
187
+ this.sidebarColumn.add(this.sessionsBox);
188
+
189
+ // Main panel
190
+ this.mainBox.add(this.messageView.outerBox);
191
+
192
+ this.body.add(this.sidebarColumn);
193
+ this.body.add(this.mainBox);
194
+
195
+ this.root.add(titleBar);
196
+ this.root.add(this.body);
197
+ this.root.add(this.statusBar.container);
198
+
199
+ this.setLeftFocus("sources");
200
+ }
201
+
202
+ private setupKeyboard(): void {
203
+ this.renderer.keyInput.on("keypress", (key: KeyEvent) => {
204
+ // Filter input is focused — intercept escape/return, let typing through
205
+ if (this.conversationList.isFilterInputFocused()) {
206
+ if (key.name === "escape") {
207
+ key.preventDefault();
208
+ this.conversationList.hideFilter();
209
+ this.updateStatusBar();
210
+ } else if (key.name === "return") {
211
+ key.preventDefault();
212
+ this.conversationList.focusListFromFilter();
213
+ this.updateStatusBar();
214
+ }
215
+ return;
216
+ }
217
+
218
+ // Filter active but list focused — Escape clears filter, / returns to input
219
+ if (this.conversationList.isFiltering()) {
220
+ if (key.name === "escape") {
221
+ key.preventDefault();
222
+ this.conversationList.hideFilter();
223
+ this.updateStatusBar();
224
+ return;
225
+ }
226
+ if (key.name === "slash" || key.name === "/" || (key.shift && key.name === "7")) {
227
+ key.preventDefault();
228
+ this.conversationList.focusFilterInput();
229
+ return;
230
+ }
231
+ // Fall through to normal session key handling (j/k, SPACE, c, etc.)
232
+ }
233
+
234
+ // Content search state
235
+ if (this.state === "content-search") {
236
+ if (this.searchResultsView.isInputFocused()) {
237
+ if (key.name === "escape") {
238
+ key.preventDefault();
239
+ this.exitContentSearch();
240
+ } else if (key.name === "return") {
241
+ key.preventDefault();
242
+ this.searchResultsView.focusList();
243
+ }
244
+ return;
245
+ }
246
+ // List is focused
247
+ if (key.name === "escape") {
248
+ key.preventDefault();
249
+ this.exitContentSearch();
250
+ return;
251
+ }
252
+ if (key.name === "slash" || key.name === "/" || (key.shift && key.name === "7")) {
253
+ key.preventDefault();
254
+ this.searchResultsView.focusInput();
255
+ return;
256
+ }
257
+ if (key.name === "return") {
258
+ key.preventDefault();
259
+ this.searchResultsView.selectCurrentResult();
260
+ return;
261
+ }
262
+ // Let SelectRenderable handle j/k
263
+ return;
264
+ }
265
+
266
+ // Target picker state
267
+ if (this.state === "target-picker") {
268
+ if (key.name === "escape") {
269
+ key.preventDefault();
270
+ if (this.targetPicker.isEnteringNew()) {
271
+ this.targetPicker.hideNewTargetInput();
272
+ } else {
273
+ this.exitTargetPicker();
274
+ }
275
+ }
276
+ return;
277
+ }
278
+
279
+ // Global shortcuts
280
+ if (key.name === "q" && !key.ctrl) {
281
+ key.preventDefault();
282
+ this.renderer.destroy();
283
+ process.exit(0);
284
+ }
285
+
286
+ if (key.name === "tab") {
287
+ key.preventDefault();
288
+ if (key.shift) {
289
+ this.cycleFocusBack();
290
+ } else {
291
+ this.cycleeFocus();
292
+ }
293
+ return;
294
+ }
295
+
296
+ // Sidebar: sources panel focused
297
+ if (this.focusArea === "sidebar" && this.leftFocus === "sources") {
298
+ if (key.name === "return") {
299
+ key.preventDefault();
300
+ this.setLeftFocus("sessions");
301
+ return;
302
+ }
303
+ if (key.name === "g" && !key.shift && !key.ctrl) {
304
+ key.preventDefault();
305
+ if (this.pendingG) {
306
+ this.clearPendingG();
307
+ } else {
308
+ this.startPendingG(() => {
309
+ this.enterContentSearch();
310
+ });
311
+ }
312
+ return;
313
+ }
314
+ // Let SelectRenderable handle j/k
315
+ return;
316
+ }
317
+
318
+ // Sidebar: sessions panel focused
319
+ if (this.focusArea === "sidebar" && this.leftFocus === "sessions") {
320
+ if (key.name === "d" && key.ctrl) {
321
+ key.preventDefault();
322
+ this.messageView.pageDown();
323
+ return;
324
+ }
325
+
326
+ if (key.name === "u" && key.ctrl) {
327
+ key.preventDefault();
328
+ this.messageView.pageUp();
329
+ return;
330
+ }
331
+
332
+ if (key.name === "slash" || key.name === "/" || (key.shift && key.name === "7")) {
333
+ key.preventDefault();
334
+ this.conversationList.showFilter();
335
+ this.updateStatusBar();
336
+ return;
337
+ }
338
+
339
+ if (key.name === "space") {
340
+ key.preventDefault();
341
+ this.conversationList.toggleSelection();
342
+ this.updateStatusBar();
343
+ return;
344
+ }
345
+
346
+ if (key.name === "c") {
347
+ key.preventDefault();
348
+ this.enterTargetPicker();
349
+ return;
350
+ }
351
+
352
+ if (key.name === "g" && key.shift) {
353
+ key.preventDefault();
354
+ this.conversationList.selectLast();
355
+ return;
356
+ }
357
+
358
+ if (key.name === "g" && !key.shift && !key.ctrl) {
359
+ key.preventDefault();
360
+ if (this.pendingG) {
361
+ this.clearPendingG();
362
+ this.conversationList.selectFirst();
363
+ } else {
364
+ this.startPendingG(() => {
365
+ this.enterContentSearch();
366
+ });
367
+ }
368
+ return;
369
+ }
370
+
371
+ // Let SelectRenderable handle j/k/Enter
372
+ return;
373
+ }
374
+
375
+ // Main panel focused
376
+ if (this.focusArea === "main") {
377
+ if (key.name === "escape") {
378
+ key.preventDefault();
379
+ this.focusArea = "sidebar";
380
+ this.setLeftFocus("sessions");
381
+ return;
382
+ }
383
+
384
+ if (key.name === "j") {
385
+ key.preventDefault();
386
+ this.messageView.scrollDown();
387
+ return;
388
+ }
389
+
390
+ if (key.name === "k") {
391
+ key.preventDefault();
392
+ this.messageView.scrollUp();
393
+ return;
394
+ }
395
+
396
+ if (key.name === "d" && key.ctrl) {
397
+ key.preventDefault();
398
+ this.messageView.pageDown();
399
+ return;
400
+ }
401
+
402
+ if (key.name === "u" && key.ctrl) {
403
+ key.preventDefault();
404
+ this.messageView.pageUp();
405
+ return;
406
+ }
407
+
408
+ if (key.name === "g" && key.shift) {
409
+ key.preventDefault();
410
+ this.messageView.scrollToBottom();
411
+ return;
412
+ }
413
+
414
+ if (key.name === "g" && !key.shift && !key.ctrl) {
415
+ key.preventDefault();
416
+ if (this.pendingG) {
417
+ this.clearPendingG();
418
+ this.messageView.scrollToTop();
419
+ } else {
420
+ this.startPendingG();
421
+ }
422
+ return;
423
+ }
424
+ }
425
+ });
426
+ }
427
+
428
+ private startPendingG(onTimeout?: () => void): void {
429
+ this.pendingG = true;
430
+ this.pendingGTimer = setTimeout(() => {
431
+ this.pendingG = false;
432
+ this.pendingGTimer = null;
433
+ onTimeout?.();
434
+ }, 300);
435
+ }
436
+
437
+ private clearPendingG(): void {
438
+ this.pendingG = false;
439
+ if (this.pendingGTimer) {
440
+ clearTimeout(this.pendingGTimer);
441
+ this.pendingGTimer = null;
442
+ }
443
+ }
444
+
445
+ private cycleeFocus(): void {
446
+ // Cycle forward: sources → sessions → main → sources
447
+ if (this.focusArea === "sidebar" && this.leftFocus === "sources") {
448
+ this.setLeftFocus("sessions");
449
+ } else if (this.focusArea === "sidebar" && this.leftFocus === "sessions") {
450
+ this.focusArea = "main";
451
+ this.sourcesBox.borderColor = this.theme.border_inactive;
452
+ this.sessionsBox.borderColor = this.theme.border_inactive;
453
+ this.mainBox.borderColor = this.theme.border_active;
454
+ this.messageView.expandFull();
455
+ this.messageView.container.focus();
456
+ this.updateStatusBar();
457
+ } else {
458
+ this.focusArea = "sidebar";
459
+ this.setLeftFocus("sources");
460
+ }
461
+ }
462
+
463
+ private cycleFocusBack(): void {
464
+ // Cycle backward: sources → main → sessions → sources
465
+ if (this.focusArea === "sidebar" && this.leftFocus === "sources") {
466
+ this.focusArea = "main";
467
+ this.sourcesBox.borderColor = this.theme.border_inactive;
468
+ this.sessionsBox.borderColor = this.theme.border_inactive;
469
+ this.mainBox.borderColor = this.theme.border_active;
470
+ this.messageView.expandFull();
471
+ this.messageView.container.focus();
472
+ this.updateStatusBar();
473
+ } else if (this.focusArea === "sidebar" && this.leftFocus === "sessions") {
474
+ this.setLeftFocus("sources");
475
+ } else {
476
+ this.focusArea = "sidebar";
477
+ this.setLeftFocus("sessions");
478
+ }
479
+ }
480
+
481
+ private setLeftFocus(area: LeftFocus): void {
482
+ this.focusArea = "sidebar";
483
+ this.leftFocus = area;
484
+ this.mainBox.borderColor = this.theme.border_inactive;
485
+
486
+ if (area === "sources") {
487
+ this.sourcesBox.borderColor = this.theme.border_active;
488
+ this.sessionsBox.borderColor = this.theme.border_inactive;
489
+ this.sourcePicker.focus();
490
+ } else {
491
+ this.sourcesBox.borderColor = this.theme.border_inactive;
492
+ this.sessionsBox.borderColor = this.theme.border_active;
493
+ this.conversationList.focus();
494
+ }
495
+ this.updateStatusBar();
496
+ }
497
+
498
+ private updateStatusBar(): void {
499
+ this.statusBar.update(
500
+ this.conversationList.getSelectedCount(),
501
+ this.sessions.length,
502
+ this.focusArea,
503
+ );
504
+ }
505
+
506
+ private enterTargetPicker(): void {
507
+ const selected = this.conversationList.getSelectedSessions();
508
+ if (selected.length === 0) {
509
+ this.statusBar.showError("No sessions selected (use SPACE to select)");
510
+ return;
511
+ }
512
+
513
+ this.state = "target-picker";
514
+
515
+ for (const child of this.mainBox.getChildren()) {
516
+ this.mainBox.remove(child.id);
517
+ }
518
+ this.mainBox.add(this.targetPicker.container);
519
+ this.targetPicker.show(this.config.targets, selected.length);
520
+ this.targetPicker.focus();
521
+ this.mainBox.borderColor = this.theme.border_active;
522
+ this.sourcesBox.borderColor = this.theme.border_inactive;
523
+ this.sessionsBox.borderColor = this.theme.border_inactive;
524
+ }
525
+
526
+ private exitTargetPicker(): void {
527
+ this.state = "browse";
528
+ this.targetPicker.reset();
529
+
530
+ for (const child of this.mainBox.getChildren()) {
531
+ this.mainBox.remove(child.id);
532
+ }
533
+ this.mainBox.add(this.messageView.outerBox);
534
+ this.setLeftFocus("sessions");
535
+ }
536
+
537
+ private async handleCopyToTarget(targetPath: string): Promise<void> {
538
+ const selected = this.conversationList.getSelectedSessions();
539
+ try {
540
+ await copySessionsToTarget(selected, targetPath);
541
+ this.statusBar.showInfo(`Copied ${selected.length} file(s) to ${targetPath}`);
542
+ } catch (err) {
543
+ this.statusBar.showError(`Copy failed: ${err}`);
544
+ }
545
+ this.exitTargetPicker();
546
+ }
547
+
548
+ private enterContentSearch(): void {
549
+ this.state = "content-search";
550
+
551
+ for (const child of this.mainBox.getChildren()) {
552
+ this.mainBox.remove(child.id);
553
+ }
554
+ this.mainBox.add(this.searchResultsView.container);
555
+ this.searchResultsView.reset();
556
+ this.searchResultsView.focus();
557
+ this.mainBox.borderColor = this.theme.border_active;
558
+ this.mainBox.title = "Content Search (g)";
559
+ this.sourcesBox.borderColor = this.theme.border_inactive;
560
+ this.sessionsBox.borderColor = this.theme.border_inactive;
561
+ }
562
+
563
+ private exitContentSearch(focusMain = false): void {
564
+ this.state = "browse";
565
+ this.searchResultsView.reset();
566
+
567
+ for (const child of this.mainBox.getChildren()) {
568
+ this.mainBox.remove(child.id);
569
+ }
570
+ this.mainBox.add(this.messageView.outerBox);
571
+ this.mainBox.title = "";
572
+
573
+ if (focusMain) {
574
+ this.focusArea = "main";
575
+ this.sourcesBox.borderColor = this.theme.border_inactive;
576
+ this.sessionsBox.borderColor = this.theme.border_inactive;
577
+ this.mainBox.borderColor = this.theme.border_active;
578
+ this.messageView.expandFull();
579
+ this.messageView.container.focus();
580
+ this.updateStatusBar();
581
+ } else {
582
+ this.setLeftFocus("sessions");
583
+ }
584
+ }
585
+
586
+ private async handleNewTarget(name: string, path: string): Promise<void> {
587
+ const expandedPath = path.startsWith("~/")
588
+ ? path.replace("~", require("os").homedir())
589
+ : path;
590
+
591
+ if (name) {
592
+ try {
593
+ await addTarget(name, path);
594
+ this.config.targets[name] = expandedPath;
595
+ } catch (err) {
596
+ this.statusBar.showError(`Failed to save target: ${err}`);
597
+ }
598
+ }
599
+
600
+ await this.handleCopyToTarget(expandedPath);
601
+ }
602
+
603
+ }