@screenpipe-ui/tui 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.
Files changed (2) hide show
  1. package/dist/index.js +431 -0
  2. package/package.json +33 -0
package/dist/index.js ADDED
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // src/index.tsx
4
+ import "react";
5
+ import { render } from "ink";
6
+
7
+ // src/app.tsx
8
+ import { useState as useState4, useMemo } from "react";
9
+ import { Box as Box6, Text as Text6, useApp, useInput as useInput4 } from "ink";
10
+ import { createClient } from "@screenpipe-ui/core";
11
+
12
+ // src/views/search-view.tsx
13
+ import { useState, useEffect } from "react";
14
+ import { Box as Box2, Text as Text2, useInput } from "ink";
15
+ import TextInput from "ink-text-input";
16
+ import Spinner from "ink-spinner";
17
+ import { useSearch } from "@screenpipe-ui/react";
18
+
19
+ // src/components/result-item.tsx
20
+ import "react";
21
+ import { Box, Text } from "ink";
22
+ import {
23
+ contentPreview,
24
+ contentTypeLabel,
25
+ timeAgo,
26
+ getContentAppName,
27
+ getContentTimestamp
28
+ } from "@screenpipe-ui/core";
29
+ import { jsx, jsxs } from "react/jsx-runtime";
30
+ function ResultItem({ item, selected }) {
31
+ const label = contentTypeLabel(item);
32
+ const app = getContentAppName(item);
33
+ const ts = getContentTimestamp(item);
34
+ const ago = timeAgo(ts);
35
+ const preview = contentPreview(item, 48);
36
+ const typeColor = item.type === "OCR" ? "blue" : item.type === "Audio" ? "green" : "magenta";
37
+ return /* @__PURE__ */ jsxs(Box, { gap: 1, children: [
38
+ /* @__PURE__ */ jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? ">" : " " }),
39
+ /* @__PURE__ */ jsx(Text, { color: typeColor, bold: true, children: label.padEnd(6) }),
40
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: ago.padEnd(8) }),
41
+ /* @__PURE__ */ jsx(Text, { color: "white", bold: true, children: (app ?? "").slice(0, 12).padEnd(12) }),
42
+ /* @__PURE__ */ jsx(Text, { color: selected ? "white" : void 0, dimColor: !selected, children: preview })
43
+ ] });
44
+ }
45
+
46
+ // src/views/search-view.tsx
47
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
48
+ function SearchView({ client }) {
49
+ const {
50
+ query,
51
+ results,
52
+ pagination,
53
+ loading,
54
+ error,
55
+ search,
56
+ nextPage,
57
+ prevPage,
58
+ setQuery,
59
+ setContentType,
60
+ contentType
61
+ } = useSearch(client);
62
+ const [inputFocused, setInputFocused] = useState(true);
63
+ const [selectedIndex, setSelectedIndex] = useState(0);
64
+ const [inputValue, setInputValue] = useState("");
65
+ useEffect(() => {
66
+ search();
67
+ }, []);
68
+ useInput((input, key) => {
69
+ if (inputFocused) {
70
+ if (key.return) {
71
+ setQuery(inputValue);
72
+ search(inputValue);
73
+ setInputFocused(false);
74
+ setSelectedIndex(0);
75
+ }
76
+ if (key.escape) {
77
+ setInputFocused(false);
78
+ }
79
+ return;
80
+ }
81
+ if (input === "/") {
82
+ setInputFocused(true);
83
+ return;
84
+ }
85
+ if (input === "j") {
86
+ setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
87
+ }
88
+ if (input === "k") {
89
+ setSelectedIndex((i) => Math.max(i - 1, 0));
90
+ }
91
+ if (input === "n") {
92
+ nextPage();
93
+ setSelectedIndex(0);
94
+ }
95
+ if (input === "p") {
96
+ prevPage();
97
+ setSelectedIndex(0);
98
+ }
99
+ if (input === "t") {
100
+ const types = ["all", "ocr", "audio", "ui"];
101
+ const idx = types.indexOf(contentType);
102
+ setContentType(types[(idx + 1) % types.length]);
103
+ }
104
+ });
105
+ const page = pagination.total > 0 ? Math.floor(pagination.offset / pagination.limit) + 1 : 0;
106
+ const totalPages = pagination.total > 0 ? Math.ceil(pagination.total / pagination.limit) : 0;
107
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
108
+ /* @__PURE__ */ jsxs2(Box2, { gap: 1, children: [
109
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", bold: true, children: ">" }),
110
+ inputFocused ? /* @__PURE__ */ jsx2(
111
+ TextInput,
112
+ {
113
+ value: inputValue,
114
+ onChange: setInputValue,
115
+ placeholder: "search screenpipe..."
116
+ }
117
+ ) : /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: inputValue || query || "press / to search" }),
118
+ /* @__PURE__ */ jsx2(Box2, { flexGrow: 1 }),
119
+ /* @__PURE__ */ jsxs2(Text2, { color: "magenta", bold: true, children: [
120
+ "[",
121
+ contentType,
122
+ "]"
123
+ ] }),
124
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "t: type" })
125
+ ] }),
126
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u2500".repeat(76) }) }),
127
+ loading && /* @__PURE__ */ jsxs2(Box2, { gap: 1, children: [
128
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: /* @__PURE__ */ jsx2(Spinner, { type: "dots" }) }),
129
+ /* @__PURE__ */ jsx2(Text2, { children: "Searching..." })
130
+ ] }),
131
+ error && /* @__PURE__ */ jsx2(Box2, { children: /* @__PURE__ */ jsxs2(Text2, { color: "red", bold: true, children: [
132
+ "Error: ",
133
+ error
134
+ ] }) }),
135
+ !loading && results.length === 0 && !error && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "No results. Try a different query or press / to search." }),
136
+ !loading && results.map((item, idx) => /* @__PURE__ */ jsx2(
137
+ ResultItem,
138
+ {
139
+ item,
140
+ selected: idx === selectedIndex
141
+ },
142
+ idx
143
+ )),
144
+ pagination.total > 0 && /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, gap: 2, children: [
145
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
146
+ pagination.total,
147
+ " results | page ",
148
+ page,
149
+ "/",
150
+ totalPages
151
+ ] }),
152
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "n: next | p: prev | j/k: navigate" })
153
+ ] })
154
+ ] });
155
+ }
156
+
157
+ // src/views/timeline-view.tsx
158
+ import { useState as useState2, useEffect as useEffect2 } from "react";
159
+ import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
160
+ import Spinner2 from "ink-spinner";
161
+ import {
162
+ contentPreview as contentPreview2,
163
+ contentTypeLabel as contentTypeLabel2,
164
+ formatTime,
165
+ getContentAppName as getContentAppName2,
166
+ getContentTimestamp as getContentTimestamp2
167
+ } from "@screenpipe-ui/core";
168
+ import { useTimeline } from "@screenpipe-ui/react";
169
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
170
+ function groupByHour(items) {
171
+ const groups = /* @__PURE__ */ new Map();
172
+ for (const item of items) {
173
+ const ts = getContentTimestamp2(item);
174
+ const date = new Date(ts);
175
+ const hourKey = `${date.toLocaleDateString(void 0, {
176
+ month: "short",
177
+ day: "numeric"
178
+ })} ${date.getHours().toString().padStart(2, "0")}:00`;
179
+ const existing = groups.get(hourKey) ?? [];
180
+ existing.push(item);
181
+ groups.set(hourKey, existing);
182
+ }
183
+ return Array.from(groups.entries()).map(([hour, items2]) => ({
184
+ hour,
185
+ items: items2
186
+ }));
187
+ }
188
+ function TimelineView({ client }) {
189
+ const { items, loading, error, load, startTime, endTime } = useTimeline(client);
190
+ const [scrollOffset, setScrollOffset] = useState2(0);
191
+ useEffect2(() => {
192
+ load();
193
+ }, []);
194
+ useInput2((input) => {
195
+ if (input === "j") {
196
+ setScrollOffset((o) => Math.min(o + 1, Math.max(0, items.length - 10)));
197
+ }
198
+ if (input === "k") {
199
+ setScrollOffset((o) => Math.max(o - 1, 0));
200
+ }
201
+ if (input === "r") {
202
+ load();
203
+ setScrollOffset(0);
204
+ }
205
+ });
206
+ const groups = groupByHour(items);
207
+ const flatRows = [];
208
+ let globalIdx = 0;
209
+ for (const group of groups) {
210
+ flatRows.push({ type: "header", text: group.hour });
211
+ for (const item of group.items) {
212
+ flatRows.push({ type: "item", item, idx: globalIdx });
213
+ globalIdx++;
214
+ }
215
+ }
216
+ const visibleRows = flatRows.slice(scrollOffset, scrollOffset + 20);
217
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
218
+ /* @__PURE__ */ jsxs3(Box3, { gap: 2, marginBottom: 1, children: [
219
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", bold: true, children: "Timeline" }),
220
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
221
+ new Date(startTime).toLocaleDateString(),
222
+ " -",
223
+ " ",
224
+ new Date(endTime).toLocaleTimeString()
225
+ ] }),
226
+ /* @__PURE__ */ jsx3(Box3, { flexGrow: 1 }),
227
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
228
+ items.length,
229
+ " items | r: reload | j/k: scroll"
230
+ ] })
231
+ ] }),
232
+ /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "\u2500".repeat(76) }) }),
233
+ loading && /* @__PURE__ */ jsxs3(Box3, { gap: 1, children: [
234
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: /* @__PURE__ */ jsx3(Spinner2, { type: "dots" }) }),
235
+ /* @__PURE__ */ jsx3(Text3, { children: "Loading timeline..." })
236
+ ] }),
237
+ error && /* @__PURE__ */ jsxs3(Text3, { color: "red", bold: true, children: [
238
+ "Error: ",
239
+ error
240
+ ] }),
241
+ !loading && items.length === 0 && !error && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No timeline items for today. Press r to reload." }),
242
+ !loading && visibleRows.map((row, i) => {
243
+ if (row.type === "header") {
244
+ return /* @__PURE__ */ jsx3(Box3, { marginTop: i > 0 ? 1 : 0, children: /* @__PURE__ */ jsx3(Text3, { color: "yellow", bold: true, children: row.text }) }, `h-${i}`);
245
+ }
246
+ const label = contentTypeLabel2(row.item);
247
+ const app = getContentAppName2(row.item);
248
+ const time = formatTime(getContentTimestamp2(row.item));
249
+ const preview = contentPreview2(row.item, 50);
250
+ const typeColor = row.item.type === "OCR" ? "blue" : row.item.type === "Audio" ? "green" : "magenta";
251
+ return /* @__PURE__ */ jsxs3(Box3, { gap: 1, children: [
252
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: time }),
253
+ /* @__PURE__ */ jsx3(Text3, { color: typeColor, bold: true, children: label.padEnd(6) }),
254
+ /* @__PURE__ */ jsx3(Text3, { color: "white", bold: true, children: (app ?? "").slice(0, 12).padEnd(12) }),
255
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: preview })
256
+ ] }, `i-${i}`);
257
+ }),
258
+ flatRows.length > 20 && /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
259
+ "Showing ",
260
+ scrollOffset + 1,
261
+ "-",
262
+ Math.min(scrollOffset + 20, flatRows.length),
263
+ " of ",
264
+ flatRows.length,
265
+ " ",
266
+ "rows"
267
+ ] }) })
268
+ ] });
269
+ }
270
+
271
+ // src/views/meetings-view.tsx
272
+ import { useState as useState3, useEffect as useEffect3 } from "react";
273
+ import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
274
+ import Spinner3 from "ink-spinner";
275
+ import {
276
+ formatTime as formatTime2,
277
+ getContentAppName as getContentAppName3,
278
+ getContentText,
279
+ getContentTimestamp as getContentTimestamp3,
280
+ truncate
281
+ } from "@screenpipe-ui/core";
282
+ import { useTimeline as useTimeline2 } from "@screenpipe-ui/react";
283
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
284
+ function MeetingsView({ client }) {
285
+ const { items, loading, error, load } = useTimeline2(client);
286
+ const [selectedIndex, setSelectedIndex] = useState3(0);
287
+ const [expandedIndex, setExpandedIndex] = useState3(null);
288
+ const audioItems = items.filter((item) => item.type === "Audio");
289
+ useEffect3(() => {
290
+ load();
291
+ }, []);
292
+ useInput3((input, key) => {
293
+ if (input === "j") {
294
+ setSelectedIndex((i) => Math.min(i + 1, audioItems.length - 1));
295
+ }
296
+ if (input === "k") {
297
+ setSelectedIndex((i) => Math.max(i - 1, 0));
298
+ }
299
+ if (key.return) {
300
+ setExpandedIndex(
301
+ (prev) => prev === selectedIndex ? null : selectedIndex
302
+ );
303
+ }
304
+ if (input === "r") {
305
+ load();
306
+ setSelectedIndex(0);
307
+ setExpandedIndex(null);
308
+ }
309
+ });
310
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
311
+ /* @__PURE__ */ jsxs4(Box4, { gap: 2, marginBottom: 1, children: [
312
+ /* @__PURE__ */ jsx4(Text4, { color: "green", bold: true, children: "Meetings / Audio" }),
313
+ /* @__PURE__ */ jsx4(Box4, { flexGrow: 1 }),
314
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
315
+ audioItems.length,
316
+ " recordings | r: reload | j/k: navigate | Enter: expand"
317
+ ] })
318
+ ] }),
319
+ /* @__PURE__ */ jsx4(Box4, { marginBottom: 1, children: /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "\u2500".repeat(76) }) }),
320
+ loading && /* @__PURE__ */ jsxs4(Box4, { gap: 1, children: [
321
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: /* @__PURE__ */ jsx4(Spinner3, { type: "dots" }) }),
322
+ /* @__PURE__ */ jsx4(Text4, { children: "Loading audio items..." })
323
+ ] }),
324
+ error && /* @__PURE__ */ jsxs4(Text4, { color: "red", bold: true, children: [
325
+ "Error: ",
326
+ error
327
+ ] }),
328
+ !loading && audioItems.length === 0 && !error && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "No audio recordings found. Press r to reload." }),
329
+ !loading && audioItems.map((item, idx) => {
330
+ const isSelected = idx === selectedIndex;
331
+ const isExpanded = idx === expandedIndex;
332
+ const time = formatTime2(getContentTimestamp3(item));
333
+ const device = getContentAppName3(item);
334
+ const text = getContentText(item);
335
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
336
+ /* @__PURE__ */ jsxs4(Box4, { gap: 1, children: [
337
+ /* @__PURE__ */ jsx4(Text4, { color: isSelected ? "cyan" : "gray", children: isSelected ? ">" : " " }),
338
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", children: time }),
339
+ /* @__PURE__ */ jsx4(Text4, { color: "green", bold: true, children: (device ?? "mic").slice(0, 16).padEnd(16) }),
340
+ /* @__PURE__ */ jsx4(Text4, { color: isSelected ? "white" : void 0, dimColor: !isSelected, children: truncate(text.replace(/\n/g, " "), 50) })
341
+ ] }),
342
+ isExpanded && /* @__PURE__ */ jsx4(
343
+ Box4,
344
+ {
345
+ marginLeft: 4,
346
+ marginTop: 0,
347
+ marginBottom: 1,
348
+ borderStyle: "round",
349
+ borderColor: "green",
350
+ paddingX: 1,
351
+ flexDirection: "column",
352
+ children: /* @__PURE__ */ jsx4(Text4, { color: "white", children: text })
353
+ }
354
+ )
355
+ ] }, idx);
356
+ })
357
+ ] });
358
+ }
359
+
360
+ // src/components/status-bar.tsx
361
+ import { useEffect as useEffect4 } from "react";
362
+ import { Box as Box5, Text as Text5 } from "ink";
363
+ import { useHealth } from "@screenpipe-ui/react";
364
+ import { Fragment, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
365
+ function StatusBar({ client }) {
366
+ const { health, loading, error, check } = useHealth(client);
367
+ useEffect4(() => {
368
+ check();
369
+ const interval = setInterval(check, 15e3);
370
+ return () => clearInterval(interval);
371
+ }, []);
372
+ const statusColor = error ? "red" : health?.status === "ok" ? "green" : "yellow";
373
+ const statusText = loading ? "checking..." : error ? "disconnected" : health ? `${health.status} | frame: ${health.frame_status} | audio: ${health.audio_status}` : "unknown";
374
+ return /* @__PURE__ */ jsxs5(Fragment, { children: [
375
+ /* @__PURE__ */ jsx5(Box5, { paddingX: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "gray", children: "\u2500".repeat(78) }) }),
376
+ /* @__PURE__ */ jsxs5(Box5, { paddingX: 1, gap: 1, children: [
377
+ /* @__PURE__ */ jsx5(Text5, { color: statusColor, bold: true, children: "\u25CF" }),
378
+ /* @__PURE__ */ jsx5(Text5, { color: statusColor, children: statusText }),
379
+ /* @__PURE__ */ jsx5(Box5, { flexGrow: 1 }),
380
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "screenpipe-tui" })
381
+ ] })
382
+ ] });
383
+ }
384
+
385
+ // src/app.tsx
386
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
387
+ var TABS = ["Search", "Timeline", "Meetings"];
388
+ function App() {
389
+ const app = useApp();
390
+ const client = useMemo(() => createClient(), []);
391
+ const [activeTab, setActiveTab] = useState4("Search");
392
+ useInput4((input, key) => {
393
+ if (input === "q" && !key.ctrl) {
394
+ app.exit();
395
+ }
396
+ if (key.tab) {
397
+ setActiveTab((prev) => {
398
+ const idx = TABS.indexOf(prev);
399
+ return TABS[(idx + 1) % TABS.length];
400
+ });
401
+ }
402
+ });
403
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", width: "100%", children: [
404
+ /* @__PURE__ */ jsxs6(Box6, { paddingX: 1, gap: 1, children: [
405
+ TABS.map((tab) => /* @__PURE__ */ jsx6(
406
+ Text6,
407
+ {
408
+ bold: activeTab === tab,
409
+ color: activeTab === tab ? "cyan" : "gray",
410
+ inverse: activeTab === tab,
411
+ children: ` ${tab} `
412
+ },
413
+ tab
414
+ )),
415
+ /* @__PURE__ */ jsx6(Box6, { flexGrow: 1 }),
416
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Tab: switch | q: quit" })
417
+ ] }),
418
+ /* @__PURE__ */ jsx6(Box6, { paddingX: 1, children: /* @__PURE__ */ jsx6(Text6, { color: "gray", children: "\u2500".repeat(78) }) }),
419
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [
420
+ activeTab === "Search" && /* @__PURE__ */ jsx6(SearchView, { client }),
421
+ activeTab === "Timeline" && /* @__PURE__ */ jsx6(TimelineView, { client }),
422
+ activeTab === "Meetings" && /* @__PURE__ */ jsx6(MeetingsView, { client })
423
+ ] }),
424
+ /* @__PURE__ */ jsx6(StatusBar, { client })
425
+ ] });
426
+ }
427
+
428
+ // src/index.tsx
429
+ import { jsx as jsx7 } from "react/jsx-runtime";
430
+ var { waitUntilExit } = render(/* @__PURE__ */ jsx7(App, {}));
431
+ await waitUntilExit();
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@screenpipe-ui/tui",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "bin": {
7
+ "screenpipe-tui": "./dist/index.js"
8
+ },
9
+ "files": ["dist"],
10
+ "scripts": {
11
+ "test": "bun test",
12
+ "dev": "bun run src/index.tsx",
13
+ "build": "tsup src/index.tsx --format esm --target node18 --clean && chmod +x dist/index.js"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "dependencies": {
19
+ "@screenpipe-ui/core": "workspace:*",
20
+ "@screenpipe-ui/react": "workspace:*",
21
+ "ink": "^5.1.0",
22
+ "ink-text-input": "^6.0.0",
23
+ "ink-spinner": "^5.0.0",
24
+ "react": "^18.3.1"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "^1.1.14",
28
+ "@types/react": "^18.3.0",
29
+ "ink-testing-library": "^4.0.0",
30
+ "tsup": "^8.0.0",
31
+ "typescript": "^5.3.3"
32
+ }
33
+ }