@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/LICENSE +21 -0
- package/README.md +187 -0
- package/package.json +20 -0
- package/session-md +49 -0
- package/src/app.ts +603 -0
- package/src/components/ConversationList.ts +243 -0
- package/src/components/MessageView.ts +241 -0
- package/src/components/SearchResultsView.ts +146 -0
- package/src/components/SourcePicker.ts +87 -0
- package/src/components/StatusBar.ts +70 -0
- package/src/components/TargetPicker.ts +174 -0
- package/src/config.ts +85 -0
- package/src/file-ops.ts +23 -0
- package/src/import/claude-code-to-md.ts +184 -0
- package/src/import/claude-export-to-md.ts +122 -0
- package/src/import/loader.ts +86 -0
- package/src/import/memorizer-to-md.ts +117 -0
- package/src/import/opencode-to-md.ts +176 -0
- package/src/import/parse-worker.ts +28 -0
- package/src/import/types.ts +56 -0
- package/src/index.ts +282 -0
- package/src/mcp/http.ts +264 -0
- package/src/mcp/server.ts +330 -0
- package/src/search/index.ts +235 -0
- package/src/search/plaintext.ts +47 -0
- package/src/theme.ts +111 -0
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
|
+
}
|