@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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
InputRenderable,
|
|
4
|
+
InputRenderableEvents,
|
|
5
|
+
SelectRenderable,
|
|
6
|
+
SelectRenderableEvents,
|
|
7
|
+
TextRenderable,
|
|
8
|
+
type SelectOption,
|
|
9
|
+
type CliRenderer,
|
|
10
|
+
t,
|
|
11
|
+
fg,
|
|
12
|
+
bold,
|
|
13
|
+
} from "@opentui/core";
|
|
14
|
+
import type { SessionEntry, SourceType } from "../import/types.ts";
|
|
15
|
+
import type { Theme } from "../theme.ts";
|
|
16
|
+
|
|
17
|
+
export type SessionFocusedHandler = (session: SessionEntry) => void;
|
|
18
|
+
|
|
19
|
+
function matchQuery(query: string, text: string): boolean {
|
|
20
|
+
return text.toLowerCase().includes(query.toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ConversationList {
|
|
24
|
+
readonly container: BoxRenderable;
|
|
25
|
+
readonly filterInput: InputRenderable;
|
|
26
|
+
readonly select: SelectRenderable;
|
|
27
|
+
private statusText: TextRenderable;
|
|
28
|
+
private allSessions: SessionEntry[] = [];
|
|
29
|
+
private visibleSessions: SessionEntry[] = [];
|
|
30
|
+
private selected: Set<string> = new Set();
|
|
31
|
+
private onSessionFocused: SessionFocusedHandler | null = null;
|
|
32
|
+
private filtering = false;
|
|
33
|
+
private filterInputFocused = false;
|
|
34
|
+
private sourceFilter: SourceType | "all" = "all";
|
|
35
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
private suppressNextSelection = false;
|
|
37
|
+
|
|
38
|
+
constructor(private ctx: CliRenderer, private theme: Theme) {
|
|
39
|
+
this.container = new BoxRenderable(ctx, {
|
|
40
|
+
id: "conversation-list",
|
|
41
|
+
flexDirection: "column",
|
|
42
|
+
flexGrow: 1,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.statusText = new TextRenderable(ctx, {
|
|
46
|
+
id: "conv-status",
|
|
47
|
+
content: "",
|
|
48
|
+
paddingLeft: 1,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
this.filterInput = new InputRenderable(ctx, {
|
|
52
|
+
id: "conv-filter",
|
|
53
|
+
width: 28,
|
|
54
|
+
placeholder: "Filter...",
|
|
55
|
+
});
|
|
56
|
+
this.filterInput.on(InputRenderableEvents.INPUT, () => {
|
|
57
|
+
this.rebuildOptions();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
this.select = new SelectRenderable(ctx, {
|
|
61
|
+
id: "conv-select",
|
|
62
|
+
flexGrow: 1,
|
|
63
|
+
options: [],
|
|
64
|
+
showDescription: true,
|
|
65
|
+
showScrollIndicator: true,
|
|
66
|
+
wrapSelection: true,
|
|
67
|
+
selectedBackgroundColor: this.theme.selection_bg,
|
|
68
|
+
selectedTextColor: this.theme.selection_fg,
|
|
69
|
+
selectedDescriptionColor: this.theme.selection_desc,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.container.add(this.statusText);
|
|
73
|
+
this.container.add(this.select);
|
|
74
|
+
|
|
75
|
+
this.select.on(
|
|
76
|
+
SelectRenderableEvents.SELECTION_CHANGED,
|
|
77
|
+
(_index: number, option: SelectOption) => {
|
|
78
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
79
|
+
if (this.suppressNextSelection) {
|
|
80
|
+
this.suppressNextSelection = false;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.debounceTimer = setTimeout(() => {
|
|
84
|
+
const session = this.visibleSessions.find(
|
|
85
|
+
(s) => s.meta.id === option.value,
|
|
86
|
+
);
|
|
87
|
+
if (session && this.onSessionFocused) {
|
|
88
|
+
this.onSessionFocused(session);
|
|
89
|
+
}
|
|
90
|
+
}, 400);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setOnSessionFocused(handler: SessionFocusedHandler): void {
|
|
96
|
+
this.onSessionFocused = handler;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setSourceFilter(source: SourceType | "all"): void {
|
|
100
|
+
this.sourceFilter = source;
|
|
101
|
+
this.rebuildOptions();
|
|
102
|
+
this.select.setSelectedIndex(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
showFilter(): void {
|
|
106
|
+
if (!this.filtering) {
|
|
107
|
+
this.filtering = true;
|
|
108
|
+
this.filterInputFocused = true;
|
|
109
|
+
this.container.add(this.filterInput, 0);
|
|
110
|
+
this.filterInput.focus();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
hideFilter(): void {
|
|
115
|
+
if (this.filtering) {
|
|
116
|
+
this.filtering = false;
|
|
117
|
+
this.filterInputFocused = false;
|
|
118
|
+
this.filterInput.value = "";
|
|
119
|
+
this.container.remove(this.filterInput.id);
|
|
120
|
+
this.rebuildOptions();
|
|
121
|
+
this.select.focus();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Is the filter input actively receiving keystrokes? */
|
|
126
|
+
isFilterInputFocused(): boolean {
|
|
127
|
+
return this.filtering && this.filterInputFocused;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Is a filter active (results are filtered)? */
|
|
131
|
+
isFiltering(): boolean {
|
|
132
|
+
return this.filtering;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Move focus from filter input to the filtered list (filter stays active) */
|
|
136
|
+
focusListFromFilter(): void {
|
|
137
|
+
this.filterInputFocused = false;
|
|
138
|
+
this.select.focus();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Move focus back to filter input */
|
|
142
|
+
focusFilterInput(): void {
|
|
143
|
+
this.filterInputFocused = true;
|
|
144
|
+
this.filterInput.focus();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
toggleSelection(): void {
|
|
148
|
+
const opt = this.select.getSelectedOption();
|
|
149
|
+
if (!opt) return;
|
|
150
|
+
|
|
151
|
+
const id = opt.value;
|
|
152
|
+
if (this.selected.has(id)) {
|
|
153
|
+
this.selected.delete(id);
|
|
154
|
+
} else {
|
|
155
|
+
this.selected.add(id);
|
|
156
|
+
}
|
|
157
|
+
this.rebuildOptions();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getSelectedSessions(): SessionEntry[] {
|
|
161
|
+
return this.allSessions.filter((s) => this.selected.has(s.meta.id));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
getSelectedCount(): number {
|
|
165
|
+
return this.selected.size;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getFocusedSession(): SessionEntry | undefined {
|
|
169
|
+
const opt = this.select.getSelectedOption();
|
|
170
|
+
if (!opt) return undefined;
|
|
171
|
+
return this.visibleSessions.find((s) => s.meta.id === opt.value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
update(sessions: SessionEntry[]): void {
|
|
175
|
+
this.allSessions = sessions;
|
|
176
|
+
this.selected.clear();
|
|
177
|
+
this.rebuildOptions();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private rebuildOptions(): void {
|
|
181
|
+
const query = this.filterInput.value.trim();
|
|
182
|
+
|
|
183
|
+
// Source filter
|
|
184
|
+
let filtered =
|
|
185
|
+
this.sourceFilter === "all"
|
|
186
|
+
? this.allSessions
|
|
187
|
+
: this.allSessions.filter((s) => s.meta.source === this.sourceFilter);
|
|
188
|
+
|
|
189
|
+
// Substring filter (matches title and project)
|
|
190
|
+
if (query) {
|
|
191
|
+
filtered = filtered.filter(
|
|
192
|
+
(s) =>
|
|
193
|
+
matchQuery(query, s.meta.title) ||
|
|
194
|
+
(s.meta.project && matchQuery(query, s.meta.project)),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.visibleSessions = filtered;
|
|
199
|
+
|
|
200
|
+
const filterInfo = query ? ` filter="${query}"` : "";
|
|
201
|
+
this.statusText.content =
|
|
202
|
+
t`${fg(this.theme.muted)(` ${filtered.length} sessions${filterInfo}`)}`;
|
|
203
|
+
|
|
204
|
+
this.select.options = filtered.map((s) => {
|
|
205
|
+
const check = this.selected.has(s.meta.id) ? "[x]" : "[ ]";
|
|
206
|
+
const date = s.meta.created_at.split("T")[0] ?? "";
|
|
207
|
+
const project = s.meta.project ? ` ${s.meta.project}` : "";
|
|
208
|
+
return {
|
|
209
|
+
name: `${check} ${s.meta.title}`,
|
|
210
|
+
description: `${date}${project}`,
|
|
211
|
+
value: s.meta.id,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getSourceFilter(): SourceType | "all" {
|
|
217
|
+
return this.sourceFilter;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
selectById(id: string): void {
|
|
221
|
+
const index = this.visibleSessions.findIndex((s) => s.meta.id === id);
|
|
222
|
+
if (index >= 0) {
|
|
223
|
+
// Suppress debounce callback — caller handles the load
|
|
224
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
225
|
+
this.suppressNextSelection = true;
|
|
226
|
+
this.select.setSelectedIndex(index);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
selectFirst(): void {
|
|
231
|
+
this.select.setSelectedIndex(0);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
selectLast(): void {
|
|
235
|
+
if (this.visibleSessions.length > 0) {
|
|
236
|
+
this.select.setSelectedIndex(this.visibleSessions.length - 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
focus(): void {
|
|
241
|
+
this.select.focus();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
ScrollBoxRenderable,
|
|
4
|
+
TextRenderable,
|
|
5
|
+
MarkdownRenderable,
|
|
6
|
+
SyntaxStyle,
|
|
7
|
+
parseColor,
|
|
8
|
+
type CliRenderer,
|
|
9
|
+
t,
|
|
10
|
+
bold,
|
|
11
|
+
fg,
|
|
12
|
+
} from "@opentui/core";
|
|
13
|
+
import type { SessionEntry } from "../import/types.ts";
|
|
14
|
+
import type { Theme } from "../theme.ts";
|
|
15
|
+
import { loadSessionMarkdownAsync } from "../import/loader.ts";
|
|
16
|
+
|
|
17
|
+
const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
|
|
18
|
+
const PREVIEW_LIMIT = 1500;
|
|
19
|
+
|
|
20
|
+
export class MessageView {
|
|
21
|
+
readonly outerBox: BoxRenderable;
|
|
22
|
+
private titleBar: TextRenderable;
|
|
23
|
+
private scrollBox: ScrollBoxRenderable;
|
|
24
|
+
private contentMarkdown: MarkdownRenderable;
|
|
25
|
+
private syntaxStyle: SyntaxStyle;
|
|
26
|
+
private currentEntry: SessionEntry | null = null;
|
|
27
|
+
private loadGen = 0;
|
|
28
|
+
private fullContent: string = "";
|
|
29
|
+
private isPreview = false;
|
|
30
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
private spinnerFrame = 0;
|
|
32
|
+
private loadFull = false;
|
|
33
|
+
|
|
34
|
+
constructor(private ctx: CliRenderer, private mainBox: BoxRenderable, private theme: Theme) {
|
|
35
|
+
this.syntaxStyle = SyntaxStyle.fromStyles({
|
|
36
|
+
default: { fg: parseColor(theme.foreground) },
|
|
37
|
+
"markup.heading": { fg: parseColor(theme.heading), bold: true },
|
|
38
|
+
"markup.heading.1": { fg: parseColor(theme.heading), bold: true },
|
|
39
|
+
"markup.heading.2": { fg: parseColor(theme.heading), bold: true },
|
|
40
|
+
"markup.heading.3": { fg: parseColor(theme.heading), bold: true },
|
|
41
|
+
"markup.strong": { fg: parseColor(theme.strong), bold: true },
|
|
42
|
+
"markup.italic": { fg: parseColor(theme.italic), italic: true },
|
|
43
|
+
"markup.raw": { fg: parseColor(theme.code) },
|
|
44
|
+
"markup.strikethrough": { dim: true },
|
|
45
|
+
"markup.link.label": { fg: parseColor(theme.link), underline: true },
|
|
46
|
+
"markup.link.url": { fg: parseColor(theme.link_url) },
|
|
47
|
+
"markup.link": { fg: parseColor(theme.muted) },
|
|
48
|
+
"markup.list": { fg: parseColor(theme.list) },
|
|
49
|
+
"punctuation.special": { fg: parseColor(theme.muted) },
|
|
50
|
+
conceal: { fg: parseColor(theme.muted) },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.outerBox = new BoxRenderable(ctx, {
|
|
54
|
+
id: "message-outer",
|
|
55
|
+
flexDirection: "column",
|
|
56
|
+
flexGrow: 1,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Title bar as a TextRenderable inside the box — no border title issues
|
|
60
|
+
this.titleBar = new TextRenderable(ctx, {
|
|
61
|
+
id: "message-title",
|
|
62
|
+
content: "",
|
|
63
|
+
paddingLeft: 1,
|
|
64
|
+
height: 1,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.scrollBox = new ScrollBoxRenderable(ctx, {
|
|
68
|
+
id: "message-scroll",
|
|
69
|
+
rootOptions: {
|
|
70
|
+
flexGrow: 1,
|
|
71
|
+
},
|
|
72
|
+
contentOptions: {
|
|
73
|
+
flexDirection: "column",
|
|
74
|
+
padding: 1,
|
|
75
|
+
},
|
|
76
|
+
viewportCulling: true,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.contentMarkdown = new MarkdownRenderable(ctx, {
|
|
80
|
+
id: "message-content",
|
|
81
|
+
content: "",
|
|
82
|
+
syntaxStyle: this.syntaxStyle,
|
|
83
|
+
conceal: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.scrollBox.add(this.contentMarkdown);
|
|
87
|
+
this.outerBox.add(this.titleBar);
|
|
88
|
+
this.outerBox.add(this.scrollBox);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
load(entry: SessionEntry): void {
|
|
92
|
+
this.currentEntry = entry;
|
|
93
|
+
const gen = ++this.loadGen;
|
|
94
|
+
|
|
95
|
+
// Dim content + start animated spinner in title bar
|
|
96
|
+
this.scrollBox.opacity = 0.3;
|
|
97
|
+
this.startSpinner(entry);
|
|
98
|
+
|
|
99
|
+
loadSessionMarkdownAsync(entry)
|
|
100
|
+
.then((md) => {
|
|
101
|
+
if (this.loadGen !== gen) return;
|
|
102
|
+
// Let spinner animate visibly before we block the main thread
|
|
103
|
+
return new Promise<string>((resolve) =>
|
|
104
|
+
setTimeout(() => resolve(md), 300),
|
|
105
|
+
);
|
|
106
|
+
})
|
|
107
|
+
.then((md) => {
|
|
108
|
+
if (!md || this.loadGen !== gen) return;
|
|
109
|
+
|
|
110
|
+
let content = md;
|
|
111
|
+
const fmEnd = content.indexOf("---", content.indexOf("---") + 3);
|
|
112
|
+
if (fmEnd !== -1) {
|
|
113
|
+
content = content.slice(fmEnd + 3).trimStart();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.fullContent = content;
|
|
117
|
+
|
|
118
|
+
// Only render a preview to keep sidebar navigation smooth (unless full requested)
|
|
119
|
+
if (!this.loadFull && content.length > PREVIEW_LIMIT) {
|
|
120
|
+
const cutoff = content.lastIndexOf("\n", PREVIEW_LIMIT);
|
|
121
|
+
const preview = content.slice(0, cutoff > 0 ? cutoff : PREVIEW_LIMIT);
|
|
122
|
+
this.contentMarkdown.content = preview + "\n\n---\n*Press Tab to view full content…*";
|
|
123
|
+
this.isPreview = true;
|
|
124
|
+
} else {
|
|
125
|
+
this.contentMarkdown.content = content;
|
|
126
|
+
this.isPreview = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Stop spinner + un-dim AFTER content is set
|
|
130
|
+
this.stopSpinner();
|
|
131
|
+
this.scrollBox.opacity = 1;
|
|
132
|
+
this.loadFull = false;
|
|
133
|
+
this.setTitle(entry.meta.title, entry.meta.source);
|
|
134
|
+
this.scrollBox.scrollTo(0);
|
|
135
|
+
})
|
|
136
|
+
.catch((err) => {
|
|
137
|
+
if (this.loadGen !== gen) return;
|
|
138
|
+
this.stopSpinner();
|
|
139
|
+
this.scrollBox.opacity = 1;
|
|
140
|
+
if (String(err) !== "Error: cancelled") {
|
|
141
|
+
this.contentMarkdown.content = `**Error:** ${err}`;
|
|
142
|
+
this.setTitle(entry.meta.title);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private setTitle(title: string, source?: string): void {
|
|
148
|
+
if (source) {
|
|
149
|
+
this.titleBar.content = t`${bold(fg(this.theme.title)(` ${title}`))} ${fg(this.theme.muted)(`(${source})`)}`;
|
|
150
|
+
} else {
|
|
151
|
+
this.titleBar.content = t`${bold(fg(this.theme.title)(` ${title}`))}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private startSpinner(entry: SessionEntry): void {
|
|
156
|
+
this.stopSpinner();
|
|
157
|
+
this.spinnerFrame = 0;
|
|
158
|
+
this.titleBar.content = t`${fg(this.theme.spinner)(` ${SPINNER_FRAMES[0]}`)} ${fg(this.theme.muted)(entry.meta.title)}`;
|
|
159
|
+
this.spinnerTimer = setInterval(() => {
|
|
160
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
161
|
+
if (this.currentEntry) {
|
|
162
|
+
this.titleBar.content = t`${fg(this.theme.spinner)(` ${SPINNER_FRAMES[this.spinnerFrame]}`)} ${fg(this.theme.muted)(this.currentEntry.meta.title)}`;
|
|
163
|
+
}
|
|
164
|
+
}, 80);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private stopSpinner(): void {
|
|
168
|
+
if (this.spinnerTimer) {
|
|
169
|
+
clearInterval(this.spinnerTimer);
|
|
170
|
+
this.spinnerTimer = null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
clear(): void {
|
|
175
|
+
this.currentEntry = null;
|
|
176
|
+
this.loadGen++;
|
|
177
|
+
this.stopSpinner();
|
|
178
|
+
this.titleBar.content = "";
|
|
179
|
+
this.mainBox.title = "";
|
|
180
|
+
this.contentMarkdown.content = "";
|
|
181
|
+
this.scrollBox.opacity = 1;
|
|
182
|
+
this.scrollBox.scrollTo(0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getCurrentEntry(): SessionEntry | null {
|
|
186
|
+
return this.currentEntry;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Expand truncated preview to full content (called when user focuses main panel) */
|
|
190
|
+
expandFull(): void {
|
|
191
|
+
if (this.isPreview && this.fullContent) {
|
|
192
|
+
this.scrollBox.opacity = 0.3;
|
|
193
|
+
if (this.currentEntry) {
|
|
194
|
+
this.startSpinner(this.currentEntry);
|
|
195
|
+
}
|
|
196
|
+
// Let spinner animate visibly, then set full content
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
this.contentMarkdown.content = this.fullContent;
|
|
199
|
+
this.isPreview = false;
|
|
200
|
+
this.stopSpinner();
|
|
201
|
+
this.scrollBox.opacity = 1;
|
|
202
|
+
if (this.currentEntry) {
|
|
203
|
+
this.setTitle(this.currentEntry.meta.title, this.currentEntry.meta.source);
|
|
204
|
+
}
|
|
205
|
+
}, 300);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Next load() will render full content instead of preview */
|
|
210
|
+
setLoadFull(full: boolean): void {
|
|
211
|
+
this.loadFull = full;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
get container(): ScrollBoxRenderable {
|
|
215
|
+
return this.scrollBox;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
scrollDown(): void {
|
|
219
|
+
this.scrollBox.scrollBy(1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
scrollUp(): void {
|
|
223
|
+
this.scrollBox.scrollBy(-1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
pageDown(): void {
|
|
227
|
+
this.scrollBox.scrollBy(10);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
pageUp(): void {
|
|
231
|
+
this.scrollBox.scrollBy(-10);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
scrollToTop(): void {
|
|
235
|
+
this.scrollBox.scrollTo(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
scrollToBottom(): void {
|
|
239
|
+
this.scrollBox.scrollTo(999999);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
InputRenderable,
|
|
4
|
+
InputRenderableEvents,
|
|
5
|
+
SelectRenderable,
|
|
6
|
+
SelectRenderableEvents,
|
|
7
|
+
TextRenderable,
|
|
8
|
+
type CliRenderer,
|
|
9
|
+
t,
|
|
10
|
+
fg,
|
|
11
|
+
bold,
|
|
12
|
+
} from "@opentui/core";
|
|
13
|
+
import type { SearchResult } from "../search/index.ts";
|
|
14
|
+
import type { Theme } from "../theme.ts";
|
|
15
|
+
|
|
16
|
+
export class SearchResultsView {
|
|
17
|
+
readonly container: BoxRenderable;
|
|
18
|
+
readonly searchInput: InputRenderable;
|
|
19
|
+
readonly select: SelectRenderable;
|
|
20
|
+
private statusText: TextRenderable;
|
|
21
|
+
private results: SearchResult[] = [];
|
|
22
|
+
private inputFocused = true;
|
|
23
|
+
private onResultSelected: ((result: SearchResult) => void) | null = null;
|
|
24
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
private onSearchQuery: ((query: string) => SearchResult[]) | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(private ctx: CliRenderer, private theme: Theme) {
|
|
28
|
+
this.container = new BoxRenderable(ctx, {
|
|
29
|
+
id: "search-results",
|
|
30
|
+
flexDirection: "column",
|
|
31
|
+
flexGrow: 1,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
this.searchInput = new InputRenderable(ctx, {
|
|
35
|
+
id: "search-input",
|
|
36
|
+
width: 40,
|
|
37
|
+
placeholder: "Search content...",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.statusText = new TextRenderable(ctx, {
|
|
41
|
+
id: "search-status",
|
|
42
|
+
content: "",
|
|
43
|
+
paddingLeft: 1,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.select = new SelectRenderable(ctx, {
|
|
47
|
+
id: "search-select",
|
|
48
|
+
flexGrow: 1,
|
|
49
|
+
options: [],
|
|
50
|
+
showDescription: true,
|
|
51
|
+
showScrollIndicator: true,
|
|
52
|
+
wrapSelection: true,
|
|
53
|
+
selectedBackgroundColor: this.theme.selection_bg,
|
|
54
|
+
selectedTextColor: this.theme.selection_fg,
|
|
55
|
+
selectedDescriptionColor: this.theme.selection_desc,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
this.container.add(this.searchInput);
|
|
59
|
+
this.container.add(this.statusText);
|
|
60
|
+
this.container.add(this.select);
|
|
61
|
+
|
|
62
|
+
this.searchInput.on(InputRenderableEvents.INPUT, () => {
|
|
63
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
64
|
+
this.debounceTimer = setTimeout(() => {
|
|
65
|
+
this.executeSearch();
|
|
66
|
+
}, 300);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.select.on(
|
|
70
|
+
SelectRenderableEvents.SELECTION_CHANGED,
|
|
71
|
+
(_index: number) => {
|
|
72
|
+
// Could preview on focus change
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setOnSearchQuery(handler: (query: string) => SearchResult[]): void {
|
|
78
|
+
this.onSearchQuery = handler;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setOnResultSelected(handler: (result: SearchResult) => void): void {
|
|
82
|
+
this.onResultSelected = handler;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private executeSearch(): void {
|
|
86
|
+
const query = this.searchInput.value.trim();
|
|
87
|
+
if (!query || !this.onSearchQuery) {
|
|
88
|
+
this.results = [];
|
|
89
|
+
this.select.options = [];
|
|
90
|
+
this.statusText.content = t`${fg(this.theme.muted)(" Type to search...")}`;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.results = this.onSearchQuery(query);
|
|
95
|
+
this.statusText.content = t`${fg(this.theme.muted)(` ${this.results.length} result(s)`)}`;
|
|
96
|
+
|
|
97
|
+
if (this.results.length === 0) {
|
|
98
|
+
this.select.options = [{ name: "No results", description: "", value: "__none__" }];
|
|
99
|
+
} else {
|
|
100
|
+
this.select.options = this.results.map((r) => ({
|
|
101
|
+
name: `${r.title}`,
|
|
102
|
+
description: `${r.snippet}`,
|
|
103
|
+
value: r.id,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
isInputFocused(): boolean {
|
|
109
|
+
return this.inputFocused;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
focusInput(): void {
|
|
113
|
+
this.inputFocused = true;
|
|
114
|
+
this.searchInput.focus();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
focusList(): void {
|
|
118
|
+
this.inputFocused = false;
|
|
119
|
+
this.select.focus();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getSelectedResult(): SearchResult | undefined {
|
|
123
|
+
const opt = this.select.getSelectedOption();
|
|
124
|
+
if (!opt || opt.value === "__none__") return undefined;
|
|
125
|
+
return this.results.find((r) => r.id === opt.value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
selectCurrentResult(): void {
|
|
129
|
+
const result = this.getSelectedResult();
|
|
130
|
+
if (result && this.onResultSelected) {
|
|
131
|
+
this.onResultSelected(result);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
reset(): void {
|
|
136
|
+
this.searchInput.value = "";
|
|
137
|
+
this.results = [];
|
|
138
|
+
this.select.options = [{ name: "", description: "", value: "__none__" }];
|
|
139
|
+
this.inputFocused = true;
|
|
140
|
+
this.statusText.content = t`${fg(this.theme.muted)(" Type to search...")}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
focus(): void {
|
|
144
|
+
this.focusInput();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SelectRenderable,
|
|
3
|
+
SelectRenderableEvents,
|
|
4
|
+
type SelectOption,
|
|
5
|
+
type CliRenderer,
|
|
6
|
+
} from "@opentui/core";
|
|
7
|
+
import type { SourceType } from "../import/types.ts";
|
|
8
|
+
import type { Theme } from "../theme.ts";
|
|
9
|
+
|
|
10
|
+
export type SourceChangedHandler = (source: SourceType | "all") => void;
|
|
11
|
+
|
|
12
|
+
const SOURCE_LABELS: Record<string, string> = {
|
|
13
|
+
all: "All Sources",
|
|
14
|
+
"claude-code": "Claude Code",
|
|
15
|
+
opencode: "OpenCode",
|
|
16
|
+
"claude-export": "Claude.ai Export",
|
|
17
|
+
memorizer: "Memorizer",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class SourcePicker {
|
|
21
|
+
readonly select: SelectRenderable;
|
|
22
|
+
private onSourceChanged: SourceChangedHandler | null = null;
|
|
23
|
+
private currentSource: SourceType | "all" = "all";
|
|
24
|
+
|
|
25
|
+
constructor(ctx: CliRenderer, private theme?: Theme) {
|
|
26
|
+
this.select = new SelectRenderable(ctx, {
|
|
27
|
+
id: "source-picker",
|
|
28
|
+
width: "100%" as any,
|
|
29
|
+
height: 6,
|
|
30
|
+
options: [
|
|
31
|
+
{ name: "All Sources (loading…)", description: "", value: "all" },
|
|
32
|
+
],
|
|
33
|
+
showDescription: false,
|
|
34
|
+
wrapSelection: true,
|
|
35
|
+
selectedBackgroundColor: theme?.selection_bg ?? "#264f78",
|
|
36
|
+
selectedTextColor: theme?.selection_fg ?? "#ffffff",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.select.on(
|
|
40
|
+
SelectRenderableEvents.SELECTION_CHANGED,
|
|
41
|
+
(_index: number, option: SelectOption) => {
|
|
42
|
+
this.currentSource = option.value as SourceType | "all";
|
|
43
|
+
if (this.onSourceChanged) {
|
|
44
|
+
this.onSourceChanged(this.currentSource);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setOnSourceChanged(handler: SourceChangedHandler): void {
|
|
51
|
+
this.onSourceChanged = handler;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getCurrentSource(): SourceType | "all" {
|
|
55
|
+
return this.currentSource;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
update(sourceCounts: Map<string, number>): void {
|
|
59
|
+
const options: SelectOption[] = [];
|
|
60
|
+
|
|
61
|
+
let total = 0;
|
|
62
|
+
for (const count of sourceCounts.values()) total += count;
|
|
63
|
+
|
|
64
|
+
options.push({
|
|
65
|
+
name: `${SOURCE_LABELS["all"]} (${total})`,
|
|
66
|
+
description: "",
|
|
67
|
+
value: "all",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const [src, label] of Object.entries(SOURCE_LABELS)) {
|
|
71
|
+
if (src === "all") continue;
|
|
72
|
+
const count = sourceCounts.get(src) ?? 0;
|
|
73
|
+
if (count === 0) continue;
|
|
74
|
+
options.push({
|
|
75
|
+
name: `${label} (${count})`,
|
|
76
|
+
description: "",
|
|
77
|
+
value: src,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.select.options = options;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
focus(): void {
|
|
85
|
+
this.select.focus();
|
|
86
|
+
}
|
|
87
|
+
}
|