@telepat/snoopy 0.1.4 → 0.1.8

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 (103) hide show
  1. package/README.md +36 -24
  2. package/dist/src/cli/commands/doctor.js +5 -1
  3. package/dist/src/cli/commands/export.d.ts +8 -1
  4. package/dist/src/cli/commands/export.js +33 -7
  5. package/dist/src/cli/commands/job.d.ts +2 -2
  6. package/dist/src/cli/commands/job.js +94 -30
  7. package/dist/src/cli/commands/results.d.ts +1 -0
  8. package/dist/src/cli/commands/results.js +118 -0
  9. package/dist/src/cli/commands/startup.js +5 -2
  10. package/dist/src/cli/index.js +17 -6
  11. package/dist/src/cli/ui/consoleUi.js +0 -3
  12. package/dist/src/services/db/repositories/jobsRepo.d.ts +15 -0
  13. package/dist/src/services/db/repositories/jobsRepo.js +21 -14
  14. package/dist/src/services/db/repositories/runsRepo.d.ts +8 -0
  15. package/dist/src/services/db/repositories/runsRepo.js +64 -54
  16. package/dist/src/services/db/repositories/scanItemsRepo.d.ts +45 -1
  17. package/dist/src/services/db/repositories/scanItemsRepo.js +184 -28
  18. package/dist/src/services/db/sqlite.js +18 -0
  19. package/dist/src/services/export/csvResults.d.ts +1 -1
  20. package/dist/src/services/export/csvResults.js +3 -2
  21. package/dist/src/services/export/fileNaming.d.ts +2 -0
  22. package/dist/src/services/export/fileNaming.js +10 -0
  23. package/dist/src/services/export/jsonResults.d.ts +10 -0
  24. package/dist/src/services/export/jsonResults.js +16 -0
  25. package/dist/src/services/openrouter/client.d.ts +1 -0
  26. package/dist/src/services/openrouter/client.js +36 -5
  27. package/dist/src/services/scheduler/jobRunner.js +10 -1
  28. package/dist/src/services/startup/index.js +16 -28
  29. package/dist/src/services/startup/windowsTaskScheduler.d.ts +1 -0
  30. package/dist/src/services/startup/windowsTaskScheduler.js +24 -4
  31. package/dist/src/ui/components/JobsTable.d.ts +8 -0
  32. package/dist/src/ui/components/JobsTable.js +78 -0
  33. package/dist/src/ui/components/ResultsViewer.d.ts +11 -0
  34. package/dist/src/ui/components/ResultsViewer.js +81 -0
  35. package/dist/src/ui/components/RunsTable.d.ts +9 -0
  36. package/dist/src/ui/components/RunsTable.js +99 -0
  37. package/dist/src/ui/components/jobsTableModel.d.ts +15 -0
  38. package/dist/src/ui/components/jobsTableModel.js +79 -0
  39. package/dist/src/ui/components/resultsViewerModel.d.ts +14 -0
  40. package/dist/src/ui/components/resultsViewerModel.js +122 -0
  41. package/dist/src/ui/components/runsTableModel.d.ts +28 -0
  42. package/dist/src/ui/components/runsTableModel.js +109 -0
  43. package/package.json +5 -2
  44. package/dist/src/cli/commands/analytics.js.map +0 -1
  45. package/dist/src/cli/commands/daemon.js.map +0 -1
  46. package/dist/src/cli/commands/doctor.js.map +0 -1
  47. package/dist/src/cli/commands/errors.js.map +0 -1
  48. package/dist/src/cli/commands/export.js.map +0 -1
  49. package/dist/src/cli/commands/job.js.map +0 -1
  50. package/dist/src/cli/commands/logs.js.map +0 -1
  51. package/dist/src/cli/commands/selection.js.map +0 -1
  52. package/dist/src/cli/commands/settings.js.map +0 -1
  53. package/dist/src/cli/commands/startup.js.map +0 -1
  54. package/dist/src/cli/flows/jobAddFlow.js.map +0 -1
  55. package/dist/src/cli/flows/settingsFlow.js.map +0 -1
  56. package/dist/src/cli/flows/settingsFlowModel.js.map +0 -1
  57. package/dist/src/cli/index.js.map +0 -1
  58. package/dist/src/cli/ui/consoleUi.js.map +0 -1
  59. package/dist/src/cli/ui/time.js.map +0 -1
  60. package/dist/src/index.js.map +0 -1
  61. package/dist/src/scripts/e2eSmoke.d.ts +0 -1
  62. package/dist/src/scripts/e2eSmoke.js +0 -102
  63. package/dist/src/scripts/e2eSmoke.js.map +0 -1
  64. package/dist/src/services/analytics/analyticsService.js.map +0 -1
  65. package/dist/src/services/daemonControl.js.map +0 -1
  66. package/dist/src/services/db/repositories/jobsRepo.js.map +0 -1
  67. package/dist/src/services/db/repositories/runsRepo.js.map +0 -1
  68. package/dist/src/services/db/repositories/scanItemsRepo.js.map +0 -1
  69. package/dist/src/services/db/repositories/settingsRepo.js.map +0 -1
  70. package/dist/src/services/db/sqlite.js.map +0 -1
  71. package/dist/src/services/export/csvResults.js.map +0 -1
  72. package/dist/src/services/logging/logReader.js.map +0 -1
  73. package/dist/src/services/logging/logRotation.js.map +0 -1
  74. package/dist/src/services/logging/runLogger.js.map +0 -1
  75. package/dist/src/services/openrouter/client.js.map +0 -1
  76. package/dist/src/services/openrouter/prompts.js.map +0 -1
  77. package/dist/src/services/reddit/client.js.map +0 -1
  78. package/dist/src/services/scheduler/cronScheduler.js.map +0 -1
  79. package/dist/src/services/scheduler/jobRunner.js.map +0 -1
  80. package/dist/src/services/scheduler/jobRunnerStub.js.map +0 -1
  81. package/dist/src/services/security/secretStore.js.map +0 -1
  82. package/dist/src/services/startup/index.js.map +0 -1
  83. package/dist/src/services/startup/linuxCronFallback.js.map +0 -1
  84. package/dist/src/services/startup/linuxSystemd.js.map +0 -1
  85. package/dist/src/services/startup/macosLaunchd.js.map +0 -1
  86. package/dist/src/services/startup/windowsRunFallback.d.ts +0 -2
  87. package/dist/src/services/startup/windowsRunFallback.js +0 -17
  88. package/dist/src/services/startup/windowsRunFallback.js.map +0 -1
  89. package/dist/src/services/startup/windowsTaskScheduler.js.map +0 -1
  90. package/dist/src/types/job.js.map +0 -1
  91. package/dist/src/types/settings.js.map +0 -1
  92. package/dist/src/ui/components/AppFrame.js.map +0 -1
  93. package/dist/src/ui/components/CliHeader.js.map +0 -1
  94. package/dist/src/ui/components/SubredditMultiSelect.js.map +0 -1
  95. package/dist/src/ui/components/TextPrompt.js.map +0 -1
  96. package/dist/src/ui/components/YesNoSelector.js.map +0 -1
  97. package/dist/src/ui/components/subredditOptions.js.map +0 -1
  98. package/dist/src/ui/components/yesNoSelectorModel.js.map +0 -1
  99. package/dist/src/ui/theme.js.map +0 -1
  100. package/dist/src/utils/logger.js.map +0 -1
  101. package/dist/src/utils/notify.js.map +0 -1
  102. package/dist/src/utils/paths.js.map +0 -1
  103. package/dist/src/utils/scanLogFormatting.js.map +0 -1
@@ -16,6 +16,37 @@ const qualifySchema = z.object({
16
16
  qualified: z.boolean(),
17
17
  reason: z.string().min(1).max(80)
18
18
  });
19
+ function toAlphaLabel(index) {
20
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
21
+ let value = index;
22
+ let label = '';
23
+ do {
24
+ label = `${alphabet[value % 26]}${label}`;
25
+ value = Math.floor(value / 26) - 1;
26
+ } while (value >= 0);
27
+ return label;
28
+ }
29
+ function buildAnonymizedThreadLines(thread, targetAuthor) {
30
+ const authorLabels = new Map();
31
+ let nextLabelIndex = 0;
32
+ return thread.map((comment) => {
33
+ const author = comment.author || '[deleted]';
34
+ let displayAuthor = author;
35
+ if (author !== targetAuthor) {
36
+ const existingLabel = authorLabels.get(author);
37
+ if (existingLabel) {
38
+ displayAuthor = existingLabel;
39
+ }
40
+ else {
41
+ const generatedLabel = `User ${toAlphaLabel(nextLabelIndex)}`;
42
+ nextLabelIndex += 1;
43
+ authorLabels.set(author, generatedLabel);
44
+ displayAuthor = generatedLabel;
45
+ }
46
+ }
47
+ return `- (${displayAuthor}) ${comment.body}`;
48
+ });
49
+ }
19
50
  const QUALIFICATION_TOOL_NAME = 'submit_qualification';
20
51
  const QUALIFICATION_TOOL = {
21
52
  type: 'function',
@@ -215,17 +246,17 @@ export class OpenRouterClient {
215
246
  return this.runQualification(input, userMessage);
216
247
  }
217
248
  async qualifyCommentThread(input) {
218
- const threadText = input.thread
219
- .map((comment) => `${comment.author}: ${comment.body}`)
220
- .join('\n');
249
+ const threadText = buildAnonymizedThreadLines(input.thread, input.targetAuthor).join('\n');
221
250
  const userMessage = [
222
251
  `Post title: ${input.postTitle}`,
223
252
  '',
253
+ `Post body: ${input.postBody}`,
254
+ '',
224
255
  'Comment thread (chronological):',
225
256
  threadText,
226
257
  '',
227
- `Important: decide qualification based on the most recent comment by ${input.targetAuthor}.`,
228
- 'Use other thread context only for interpretation.'
258
+ `Important: QUALIFY ONLY the final comment line authored by ${input.targetAuthor}.`,
259
+ 'Treat all earlier lines as context only and do not qualify them.'
229
260
  ].join('\n');
230
261
  return this.runQualification(input, userMessage);
231
262
  }
@@ -308,6 +308,7 @@ export class JobRunner {
308
308
  modelSettings,
309
309
  qualificationPrompt: job.qualificationPrompt,
310
310
  postTitle: post.title,
311
+ postBody: post.body,
311
312
  targetAuthor: author,
312
313
  thread
313
314
  });
@@ -334,7 +335,15 @@ export class JobRunner {
334
335
  promptTokens: result.promptTokens,
335
336
  completionTokens: result.completionTokens,
336
337
  estimatedCostUsd: this.estimateCost(result.promptTokens, result.completionTokens),
337
- qualificationReason: result.reason
338
+ qualificationReason: result.reason,
339
+ commentThreadNodes: thread.map((threadComment, index) => ({
340
+ redditCommentId: threadComment.id,
341
+ parentRedditCommentId: index === 0 ? null : thread[index - 1]?.id ?? null,
342
+ author: threadComment.author,
343
+ body: threadComment.body,
344
+ depth: index,
345
+ isTarget: index === thread.length - 1
346
+ }))
338
347
  });
339
348
  runStats.itemsNew += 1;
340
349
  if (result.qualified) {
@@ -5,8 +5,7 @@ import { execSync } from 'node:child_process';
5
5
  import { hasSystemdUser, installLinuxSystemd, uninstallLinuxSystemd } from './linuxSystemd.js';
6
6
  import { installLinuxCronFallback, uninstallLinuxCronFallback } from './linuxCronFallback.js';
7
7
  import { installMacStartup, uninstallMacStartup } from './macosLaunchd.js';
8
- import { installWindowsRunFallback, uninstallWindowsRunFallback } from './windowsRunFallback.js';
9
- import { installWindowsTask, uninstallWindowsTask } from './windowsTaskScheduler.js';
8
+ import { hasWindowsTaskInstalled, installWindowsTask, uninstallWindowsTask } from './windowsTaskScheduler.js';
10
9
  export function installStartup(commandPath) {
11
10
  if (process.platform === 'darwin') {
12
11
  const detail = installMacStartup(commandPath);
@@ -21,13 +20,19 @@ export function installStartup(commandPath) {
21
20
  return { success: true, method: 'cron-@reboot', detail: 'crontab entry installed' };
22
21
  }
23
22
  if (process.platform === 'win32') {
23
+ // On Windows we intentionally use a single explicit method.
24
+ // If task creation fails, we do not silently fall back to registry persistence.
24
25
  try {
25
26
  installWindowsTask(commandPath);
26
27
  return { success: true, method: 'task-scheduler', detail: 'Task Scheduler job created' };
27
28
  }
28
- catch {
29
- installWindowsRunFallback(commandPath);
30
- return { success: true, method: 'registry-run', detail: 'Registry startup entry created' };
29
+ catch (error) {
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ return {
32
+ success: false,
33
+ method: 'task-scheduler',
34
+ detail: `Task Scheduler startup setup failed: ${message}`
35
+ };
31
36
  }
32
37
  }
33
38
  return { success: false, method: 'unsupported', detail: 'Unsupported platform' };
@@ -42,7 +47,6 @@ export function uninstallStartup() {
42
47
  }
43
48
  if (process.platform === 'win32') {
44
49
  uninstallWindowsTask();
45
- uninstallWindowsRunFallback();
46
50
  }
47
51
  }
48
52
  export function getStartupStatus() {
@@ -82,34 +86,18 @@ export function getStartupStatus() {
82
86
  }
83
87
  }
84
88
  if (process.platform === 'win32') {
85
- try {
86
- execSync('schtasks /query /tn "Snoopy\\Daemon"', { shell: 'cmd.exe', stdio: 'ignore' });
89
+ if (hasWindowsTaskInstalled()) {
87
90
  return {
88
91
  enabled: true,
89
92
  method: 'task-scheduler',
90
93
  detail: 'Scheduled task exists'
91
94
  };
92
95
  }
93
- catch {
94
- try {
95
- execSync('reg query "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v "SnoopyDaemon"', {
96
- shell: 'cmd.exe',
97
- stdio: 'ignore'
98
- });
99
- return {
100
- enabled: true,
101
- method: 'registry-run',
102
- detail: 'Registry startup entry exists'
103
- };
104
- }
105
- catch {
106
- return {
107
- enabled: false,
108
- method: 'task-scheduler',
109
- detail: 'No startup registration found'
110
- };
111
- }
112
- }
96
+ return {
97
+ enabled: false,
98
+ method: 'task-scheduler',
99
+ detail: 'No startup registration found'
100
+ };
113
101
  }
114
102
  return {
115
103
  enabled: false,
@@ -1,2 +1,3 @@
1
1
  export declare function installWindowsTask(commandPath: string): void;
2
2
  export declare function uninstallWindowsTask(): void;
3
+ export declare function hasWindowsTaskInstalled(): boolean;
@@ -1,16 +1,36 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
  const TASK_NAME = 'Snoopy\\Daemon';
3
+ function validateWindowsStartupCommandPath(commandPath) {
4
+ const trimmedPath = commandPath.trim();
5
+ const isAbsoluteWindowsPath = /^[a-zA-Z]:[\\/]/.test(trimmedPath);
6
+ if (!trimmedPath || !isAbsoluteWindowsPath || /["\r\n]/.test(trimmedPath)) {
7
+ throw new Error('Startup command path must be an absolute Windows path without quotes or newlines.');
8
+ }
9
+ }
3
10
  export function installWindowsTask(commandPath) {
11
+ // Startup persistence is explicit user opt-in through startup commands.
12
+ // Use execFileSync argument arrays to avoid cmd.exe parsing/injection risks.
13
+ validateWindowsStartupCommandPath(commandPath);
4
14
  const runTarget = `"${commandPath}" daemon run`;
5
- const command = `schtasks /create /tn "${TASK_NAME}" /tr ${JSON.stringify(runTarget)} /sc onlogon /f`;
6
- execSync(command, { shell: 'cmd.exe' });
15
+ execFileSync('schtasks', ['/create', '/tn', TASK_NAME, '/tr', runTarget, '/sc', 'onlogon', '/f'], {
16
+ stdio: 'pipe'
17
+ });
7
18
  }
8
19
  export function uninstallWindowsTask() {
9
20
  try {
10
- execSync(`schtasks /delete /tn "${TASK_NAME}" /f`, { shell: 'cmd.exe' });
21
+ execFileSync('schtasks', ['/delete', '/tn', TASK_NAME, '/f'], { stdio: 'pipe' });
11
22
  }
12
23
  catch {
13
24
  // Ignore when task does not exist.
14
25
  }
15
26
  }
27
+ export function hasWindowsTaskInstalled() {
28
+ try {
29
+ execFileSync('schtasks', ['/query', '/tn', TASK_NAME], { stdio: 'ignore' });
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
16
36
  //# sourceMappingURL=windowsTaskScheduler.js.map
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import type { JobSummaryRow } from '../../services/db/repositories/jobsRepo.js';
3
+ interface JobsTableProps {
4
+ jobs: JobSummaryRow[];
5
+ onExit: () => void;
6
+ }
7
+ export declare function JobsTable({ jobs, onExit }: JobsTableProps): React.JSX.Element;
8
+ export {};
@@ -0,0 +1,78 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { uiTheme } from '../theme.js';
5
+ import { AppFrame, Panel } from './AppFrame.js';
6
+ import { buildJobDetailLines, computeJobColumnWidths, computeScrollWindow, formatJobHeaderRow, formatJobTableRow } from './jobsTableModel.js';
7
+ const WINDOW_SIZE = 10;
8
+ const COL_SEP = ' ';
9
+ export function JobsTable({ jobs, onExit }) {
10
+ const { stdout } = useStdout();
11
+ const terminalRows = stdout?.rows ?? 24;
12
+ const maxWindow = Math.max(1, Math.min(WINDOW_SIZE, terminalRows - 8));
13
+ const [view, setView] = useState('list');
14
+ const [cursor, setCursor] = useState(0);
15
+ const [scrollTop, setScrollTop] = useState(0);
16
+ const widths = computeJobColumnWidths(jobs);
17
+ const header = formatJobHeaderRow(widths);
18
+ const { scrollTop: nextScrollTop, visibleStart, visibleEnd } = computeScrollWindow(cursor, scrollTop, jobs.length, maxWindow);
19
+ if (nextScrollTop !== scrollTop) {
20
+ setScrollTop(nextScrollTop);
21
+ }
22
+ const visibleJobs = jobs.slice(visibleStart, visibleEnd);
23
+ const aboveCount = visibleStart;
24
+ const belowCount = jobs.length - visibleEnd;
25
+ useInput((input, key) => {
26
+ if (view === 'list') {
27
+ if (key.upArrow) {
28
+ const next = Math.max(0, cursor - 1);
29
+ setCursor(next);
30
+ setScrollTop(computeScrollWindow(next, scrollTop, jobs.length, maxWindow).scrollTop);
31
+ return;
32
+ }
33
+ if (key.downArrow) {
34
+ const next = Math.min(jobs.length - 1, cursor + 1);
35
+ setCursor(next);
36
+ setScrollTop(computeScrollWindow(next, scrollTop, jobs.length, maxWindow).scrollTop);
37
+ return;
38
+ }
39
+ if (key.return) {
40
+ setView('detail');
41
+ return;
42
+ }
43
+ if (input === 'q' || input === 'Q') {
44
+ onExit();
45
+ }
46
+ return;
47
+ }
48
+ // detail view
49
+ if (key.escape || key.leftArrow) {
50
+ setView('list');
51
+ return;
52
+ }
53
+ if (input === 'q' || input === 'Q') {
54
+ onExit();
55
+ }
56
+ });
57
+ if (view === 'detail') {
58
+ const job = jobs[cursor];
59
+ if (!job)
60
+ return _jsx(_Fragment, {});
61
+ const detailLines = buildJobDetailLines(job);
62
+ return (_jsx(AppFrame, { subtitle: "Jobs", statusText: `${cursor + 1} / ${jobs.length}`, statusTone: "info", hints: ['← back', 'q quit'], children: _jsx(Panel, { title: job.jobName, children: detailLines.map((line) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: uiTheme.ink.info, children: line.label.padEnd(12) }), _jsx(Text, { color: uiTheme.ink.textPrimary, children: line.value })] }, line.label))) }) }));
63
+ }
64
+ return (_jsx(AppFrame, { subtitle: "Jobs", statusText: `${cursor + 1} / ${jobs.length}`, statusTone: "info", hints: ['↑↓ navigate', '↵ details', 'q quit'], children: _jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Box, { flexDirection: "row", children: header.map((cell, i) => (_jsxs(Text, { bold: true, color: uiTheme.ink.accent, children: [i > 0 ? COL_SEP : '', cell] }, i))) }), aboveCount > 0 && (_jsxs(Text, { color: uiTheme.ink.textMuted, children: [" \u2191 ", aboveCount, " more"] })), visibleJobs.map((job, idx) => {
65
+ const absoluteIndex = visibleStart + idx;
66
+ const isSelected = absoluteIndex === cursor;
67
+ const cells = formatJobTableRow(job, widths);
68
+ return (_jsx(Box, { flexDirection: "row", children: cells.map((cell, i) => {
69
+ const color = isSelected
70
+ ? uiTheme.ink.focus
71
+ : i === 0
72
+ ? uiTheme.ink.textPrimary
73
+ : uiTheme.ink.textMuted;
74
+ return (_jsxs(Text, { color: color, inverse: isSelected && i === 0, bold: isSelected && i === 0, children: [i > 0 ? COL_SEP : '', cell] }, i));
75
+ }) }, job.jobId));
76
+ }), belowCount > 0 && (_jsxs(Text, { color: uiTheme.ink.textMuted, children: [" \u2193 ", belowCount, " more"] }))] }) }));
77
+ }
78
+ //# sourceMappingURL=JobsTable.js.map
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import { type ResultsViewerItem } from './resultsViewerModel.js';
3
+ interface ResultsViewerProps {
4
+ jobName: string;
5
+ jobSlug: string;
6
+ totalItems: number;
7
+ getItemAt: (index: number) => ResultsViewerItem | null;
8
+ onExit: () => void;
9
+ }
10
+ export declare function ResultsViewer({ jobName, jobSlug, totalItems, getItemAt, onExit }: ResultsViewerProps): React.JSX.Element;
11
+ export {};
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useMemo, useState } from 'react';
3
+ import { Text, useInput, useStdout } from 'ink';
4
+ import { uiTheme } from '../theme.js';
5
+ import { AppFrame, Panel } from './AppFrame.js';
6
+ import { buildScrollableResultLines, nextContentScrollTop, nextItemIndex } from './resultsViewerModel.js';
7
+ export function ResultsViewer({ jobName, jobSlug, totalItems, getItemAt, onExit }) {
8
+ const { stdout } = useStdout();
9
+ const terminalRows = stdout?.rows ?? 24;
10
+ const terminalCols = stdout?.columns ?? 80;
11
+ const [cursor, setCursor] = useState(0);
12
+ const [contentScrollTop, setContentScrollTop] = useState(0);
13
+ const item = useMemo(() => getItemAt(cursor), [cursor, getItemAt]);
14
+ const resultLines = useMemo(() => {
15
+ if (!item) {
16
+ return [];
17
+ }
18
+ return buildScrollableResultLines(item, Math.max(20, terminalCols - 8));
19
+ }, [item, terminalCols]);
20
+ // Most of the terminal height is allocated to one scrollable result pane.
21
+ const contentWindowSize = Math.max(5, terminalRows - 10);
22
+ const visibleContent = resultLines.slice(contentScrollTop, contentScrollTop + contentWindowSize);
23
+ const aboveCount = contentScrollTop;
24
+ const belowCount = Math.max(0, resultLines.length - (contentScrollTop + contentWindowSize));
25
+ const metadataStartIndex = resultLines.indexOf('Metadata');
26
+ useInput((input, key) => {
27
+ if (key.leftArrow) {
28
+ const next = nextItemIndex(cursor, 'left', totalItems);
29
+ if (next !== cursor) {
30
+ setCursor(next);
31
+ setContentScrollTop(0);
32
+ }
33
+ return;
34
+ }
35
+ if (key.rightArrow) {
36
+ const next = nextItemIndex(cursor, 'right', totalItems);
37
+ if (next !== cursor) {
38
+ setCursor(next);
39
+ setContentScrollTop(0);
40
+ }
41
+ return;
42
+ }
43
+ if (key.upArrow) {
44
+ setContentScrollTop((prev) => nextContentScrollTop(prev, 'up', resultLines.length, contentWindowSize));
45
+ return;
46
+ }
47
+ if (key.downArrow) {
48
+ setContentScrollTop((prev) => nextContentScrollTop(prev, 'down', resultLines.length, contentWindowSize));
49
+ return;
50
+ }
51
+ if (key.escape || input === 'q' || input === 'Q') {
52
+ onExit();
53
+ }
54
+ });
55
+ if (!item) {
56
+ return (_jsx(AppFrame, { subtitle: "Results", statusText: "No items", statusTone: "warning", hints: ['q quit'], children: _jsx(Panel, { title: "No Results", children: _jsx(Text, { color: uiTheme.ink.warning, children: "No results available." }) }) }));
57
+ }
58
+ const qualificationMark = item.qualified ? '✓' : 'x';
59
+ const qualificationText = item.qualified ? 'Qualified' : 'Not Qualified';
60
+ const qualificationColor = item.qualified ? uiTheme.ink.success : uiTheme.ink.danger;
61
+ const contentLinkLabel = item.type === 'comment' ? 'Comment URL' : 'Post URL';
62
+ const renderLine = (line, absoluteIndex) => {
63
+ const isMetadataLine = metadataStartIndex !== -1 && absoluteIndex > metadataStartIndex;
64
+ if (isMetadataLine) {
65
+ const match = line.match(/^([^:]+):(\s*)(.*)$/);
66
+ if (match) {
67
+ const [, label, gap, value] = match;
68
+ return (_jsxs(Text, { color: uiTheme.ink.textPrimary, children: [_jsx(Text, { color: uiTheme.ink.info, children: `${label}:` }), `${gap}${value}`] }));
69
+ }
70
+ if (line.startsWith(' ')) {
71
+ return _jsx(Text, { color: uiTheme.ink.textMuted, children: line });
72
+ }
73
+ }
74
+ if (line === 'Metadata' || line === 'Title' || line === 'Body' || line === 'Thread (root -> target)' || line === 'Target Comment Body') {
75
+ return (_jsx(Text, { color: uiTheme.ink.accent, bold: true, children: line }));
76
+ }
77
+ return _jsx(Text, { color: uiTheme.ink.textPrimary, children: line });
78
+ };
79
+ return (_jsxs(AppFrame, { subtitle: "Results", description: `${jobName} (${jobSlug})`, statusText: `${cursor + 1} / ${totalItems}`, statusTone: "info", hints: ['←→ result', '↑↓ scroll', 'q quit'], children: [_jsx(Text, { color: qualificationColor, children: `${qualificationMark} ${qualificationText}` }), _jsxs(Text, { color: uiTheme.ink.info, children: [`${contentLinkLabel}: `, _jsx(Text, { color: uiTheme.ink.textPrimary, children: item.url })] }), _jsxs(Panel, { title: "Result", children: [aboveCount > 0 ? _jsxs(Text, { color: uiTheme.ink.textMuted, children: ["\u2191 ", aboveCount, " more"] }) : null, visibleContent.map((line, index) => (_jsx(React.Fragment, { children: renderLine(line, contentScrollTop + index) }, `${index}:${line.slice(0, 24)}`))), belowCount > 0 ? _jsxs(Text, { color: uiTheme.ink.textMuted, children: ["\u2193 ", belowCount, " more"] }) : null] })] }));
80
+ }
81
+ //# sourceMappingURL=ResultsViewer.js.map
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import type { RunRow } from '../../services/db/repositories/runsRepo.js';
3
+ interface RunsTableProps {
4
+ totalRuns: number;
5
+ getRunAt: (index: number) => RunRow | null;
6
+ onExit: () => void;
7
+ }
8
+ export declare function RunsTable({ totalRuns, getRunAt, onExit }: RunsTableProps): React.JSX.Element;
9
+ export {};
@@ -0,0 +1,99 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { formatRunDisplayTimestamp } from '../../cli/ui/time.js';
5
+ import { uiTheme } from '../theme.js';
6
+ import { AppFrame, Panel } from './AppFrame.js';
7
+ import { buildDetailLines, computeColumnWidths, computeScrollWindow, formatHeaderRow, formatTableRow } from './runsTableModel.js';
8
+ const WINDOW_SIZE = 10;
9
+ const COL_SEP = ' ';
10
+ export function RunsTable({ totalRuns, getRunAt, onExit }) {
11
+ const { stdout } = useStdout();
12
+ const terminalRows = stdout?.rows ?? 24;
13
+ // Respect terminal height: cap window so we leave room for header + footer
14
+ const maxWindow = Math.max(1, Math.min(WINDOW_SIZE, terminalRows - 8));
15
+ const [view, setView] = useState('list');
16
+ const [cursor, setCursor] = useState(0);
17
+ const [scrollTop, setScrollTop] = useState(0);
18
+ const currentRun = getRunAt(cursor);
19
+ const { scrollTop: nextScrollTop, visibleStart, visibleEnd } = computeScrollWindow(cursor, scrollTop, totalRuns, maxWindow);
20
+ const visibleRuns = [];
21
+ for (let index = visibleStart; index < visibleEnd; index += 1) {
22
+ const run = getRunAt(index);
23
+ if (run) {
24
+ visibleRuns.push(run);
25
+ }
26
+ }
27
+ const widthSample = currentRun ? [currentRun, ...visibleRuns] : visibleRuns;
28
+ const widths = computeColumnWidths(widthSample);
29
+ const header = formatHeaderRow(widths);
30
+ if (nextScrollTop !== scrollTop) {
31
+ setScrollTop(nextScrollTop);
32
+ }
33
+ const aboveCount = visibleStart;
34
+ const belowCount = totalRuns - visibleEnd;
35
+ useInput((input, key) => {
36
+ if (view === 'list') {
37
+ if (key.upArrow) {
38
+ const next = Math.max(0, cursor - 1);
39
+ setCursor(next);
40
+ const win = computeScrollWindow(next, scrollTop, totalRuns, maxWindow);
41
+ setScrollTop(win.scrollTop);
42
+ return;
43
+ }
44
+ if (key.downArrow) {
45
+ const next = Math.min(totalRuns - 1, cursor + 1);
46
+ setCursor(next);
47
+ const win = computeScrollWindow(next, scrollTop, totalRuns, maxWindow);
48
+ setScrollTop(win.scrollTop);
49
+ return;
50
+ }
51
+ if (key.return) {
52
+ setView('detail');
53
+ return;
54
+ }
55
+ if (input === 'q' || input === 'Q') {
56
+ onExit();
57
+ }
58
+ return;
59
+ }
60
+ // detail view
61
+ if (key.escape || key.leftArrow || input === 'q' || input === 'Q') {
62
+ if (key.escape || key.leftArrow) {
63
+ setView('list');
64
+ }
65
+ else {
66
+ onExit();
67
+ }
68
+ }
69
+ });
70
+ if (view === 'detail') {
71
+ const run = currentRun;
72
+ if (!run)
73
+ return _jsx(_Fragment, {});
74
+ const detailLines = buildDetailLines(run);
75
+ const title = `${formatRunDisplayTimestamp(run)} — ${run.jobName ?? run.jobId}`;
76
+ return (_jsx(AppFrame, { subtitle: "Run History", statusText: `${cursor + 1} / ${totalRuns}`, statusTone: "info", hints: ['Esc / ← back', 'q quit'], children: _jsx(Panel, { title: title, children: detailLines.map((line) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: uiTheme.ink.info, children: line.label.padEnd(12) }), _jsx(Text, { color: uiTheme.ink.textPrimary, children: line.value })] }, line.label))) }) }));
77
+ }
78
+ return (_jsx(AppFrame, { subtitle: "Run History", statusText: `${cursor + 1} / ${totalRuns}`, statusTone: "info", hints: ['↑↓ navigate', '↵ details', 'q quit'], children: _jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Box, { flexDirection: "row", children: header.map((cell, i) => (_jsxs(Text, { bold: true, color: uiTheme.ink.accent, children: [i > 0 ? COL_SEP : '', cell] }, i))) }), aboveCount > 0 && (_jsxs(Text, { color: uiTheme.ink.textMuted, children: [" \u2191 ", aboveCount, " more"] })), visibleRuns.map((run, idx) => {
79
+ const absoluteIndex = visibleStart + idx;
80
+ const isSelected = absoluteIndex === cursor;
81
+ const cells = formatTableRow(run, widths);
82
+ const statusColor = run.status === 'completed'
83
+ ? uiTheme.ink.success
84
+ : run.status === 'failed'
85
+ ? uiTheme.ink.danger
86
+ : uiTheme.ink.textMuted;
87
+ return (_jsx(Box, { flexDirection: "row", children: cells.map((cell, i) => {
88
+ const color = isSelected
89
+ ? uiTheme.ink.focus
90
+ : i === 5 // status column
91
+ ? statusColor
92
+ : i === 0
93
+ ? uiTheme.ink.textPrimary
94
+ : uiTheme.ink.textMuted;
95
+ return (_jsxs(Text, { color: color, inverse: isSelected && i === 0, bold: isSelected && i === 0, children: [i > 0 ? COL_SEP : '', cell] }, i));
96
+ }) }, run.id));
97
+ }), belowCount > 0 && (_jsxs(Text, { color: uiTheme.ink.textMuted, children: [" \u2193 ", belowCount, " more"] }))] }) }));
98
+ }
99
+ //# sourceMappingURL=RunsTable.js.map
@@ -0,0 +1,15 @@
1
+ import type { JobSummaryRow } from '../../services/db/repositories/jobsRepo.js';
2
+ import type { DetailLine } from './runsTableModel.js';
3
+ export { computeScrollWindow } from './runsTableModel.js';
4
+ export type { DetailLine };
5
+ export interface JobColumnWidths {
6
+ slug: number;
7
+ lastRun: number;
8
+ scanned: number;
9
+ qualified: number;
10
+ cost: number;
11
+ }
12
+ export declare function computeJobColumnWidths(rows: JobSummaryRow[]): JobColumnWidths;
13
+ export declare function formatJobTableRow(row: JobSummaryRow, widths: JobColumnWidths): string[];
14
+ export declare function formatJobHeaderRow(widths: JobColumnWidths): string[];
15
+ export declare function buildJobDetailLines(row: JobSummaryRow): DetailLine[];
@@ -0,0 +1,79 @@
1
+ import { formatLocalTimestamp } from '../../cli/ui/time.js';
2
+ export { computeScrollWindow } from './runsTableModel.js';
3
+ const COL_MIN = { slug: 8, lastRun: 10, scanned: 7, qualified: 9, cost: 9 };
4
+ const COL_MAX = { slug: 32, lastRun: 22, scanned: 7, qualified: 9, cost: 12 };
5
+ function pad(value, width) {
6
+ if (value.length >= width)
7
+ return value.slice(0, width);
8
+ return value + ' '.repeat(width - value.length);
9
+ }
10
+ export function computeJobColumnWidths(rows) {
11
+ const clamp = (v, key) => Math.min(COL_MAX[key], Math.max(COL_MIN[key], v));
12
+ let slug = COL_MIN.slug;
13
+ let lastRun = COL_MIN.lastRun;
14
+ for (const row of rows) {
15
+ const slugLen = row.jobSlug.length;
16
+ if (slugLen > slug)
17
+ slug = slugLen;
18
+ const dateLen = row.lastRunAt ? formatLocalTimestamp(row.lastRunAt).length : 5; // 'never'
19
+ if (dateLen > lastRun)
20
+ lastRun = dateLen;
21
+ }
22
+ return {
23
+ slug: clamp(slug, 'slug'),
24
+ lastRun: clamp(lastRun, 'lastRun'),
25
+ scanned: COL_MIN.scanned,
26
+ qualified: COL_MIN.qualified,
27
+ cost: COL_MIN.cost
28
+ };
29
+ }
30
+ function formatCost(totalCostUsd) {
31
+ if (totalCostUsd === 0)
32
+ return '-';
33
+ return `$${totalCostUsd.toFixed(6)}`;
34
+ }
35
+ export function formatJobTableRow(row, widths) {
36
+ const lastRun = row.lastRunAt ? formatLocalTimestamp(row.lastRunAt) : 'never';
37
+ const cost = formatCost(row.totalCostUsd);
38
+ return [
39
+ pad(row.jobSlug, widths.slug),
40
+ pad(lastRun, widths.lastRun),
41
+ pad(String(row.totalScanned), widths.scanned),
42
+ pad(String(row.totalQualified), widths.qualified),
43
+ pad(cost, widths.cost)
44
+ ];
45
+ }
46
+ export function formatJobHeaderRow(widths) {
47
+ return [
48
+ pad('Job Slug', widths.slug),
49
+ pad('Last Run', widths.lastRun),
50
+ pad('Scanned', widths.scanned),
51
+ pad('Qualified', widths.qualified),
52
+ pad('Cost', widths.cost)
53
+ ];
54
+ }
55
+ // ── Detail view ───────────────────────────────────────────────────────────────
56
+ export function buildJobDetailLines(row) {
57
+ let subreddits = '-';
58
+ try {
59
+ const parsed = JSON.parse(row.subredditsJson);
60
+ subreddits = parsed.map((s) => `r/${s}`).join(', ');
61
+ }
62
+ catch {
63
+ subreddits = row.subredditsJson;
64
+ }
65
+ return [
66
+ { label: 'Slug', value: row.jobSlug },
67
+ { label: 'Name', value: row.jobName },
68
+ { label: 'Enabled', value: row.enabled !== 0 ? 'Yes' : 'No' },
69
+ { label: 'Description', value: row.description },
70
+ { label: 'Subreddits', value: subreddits },
71
+ { label: 'Schedule', value: row.scheduleCron },
72
+ { label: 'Runs', value: String(row.runCount) },
73
+ { label: 'Last Run', value: row.lastRunAt ? formatLocalTimestamp(row.lastRunAt) : 'never' },
74
+ { label: 'Scanned', value: String(row.totalScanned) },
75
+ { label: 'Qualified', value: String(row.totalQualified) },
76
+ { label: 'Cost', value: formatCost(row.totalCostUsd) }
77
+ ];
78
+ }
79
+ //# sourceMappingURL=jobsTableModel.js.map
@@ -0,0 +1,14 @@
1
+ import type { CommentThreadNodeRow, ScanItemRow } from '../../services/db/repositories/scanItemsRepo.js';
2
+ export interface ResultsViewerItem extends ScanItemRow {
3
+ commentThreadNodes: CommentThreadNodeRow[];
4
+ }
5
+ export interface DetailLine {
6
+ label: string;
7
+ value: string;
8
+ }
9
+ export declare function wrapTextBlock(value: string, width: number): string[];
10
+ export declare function buildMetadataLines(item: ResultsViewerItem): DetailLine[];
11
+ export declare function buildContentLines(item: ResultsViewerItem, width: number): string[];
12
+ export declare function buildScrollableResultLines(item: ResultsViewerItem, width: number): string[];
13
+ export declare function nextItemIndex(current: number, direction: 'left' | 'right', total: number): number;
14
+ export declare function nextContentScrollTop(current: number, direction: 'up' | 'down', totalLines: number, windowSize: number): number;