@mingchuno/agent-workflows 0.1.0 → 0.2.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 (70) hide show
  1. package/README.md +32 -6
  2. package/dist/src/adapters/agents.js +6 -3
  3. package/dist/src/adapters/hosting.js +16 -10
  4. package/dist/src/adapters/sdk-protocol.d.ts +3 -3
  5. package/dist/src/adapters/sdk-protocol.js +9 -7
  6. package/dist/src/cli.d.ts +1 -1
  7. package/dist/src/cli.js +40 -19
  8. package/dist/src/config.d.ts +22 -22
  9. package/dist/src/config.js +31 -26
  10. package/dist/src/defaults.d.ts +2 -0
  11. package/dist/src/defaults.js +2 -0
  12. package/dist/src/domain.d.ts +24 -4
  13. package/dist/src/domain.js +10 -3
  14. package/dist/src/evidence.d.ts +54 -0
  15. package/dist/src/evidence.js +214 -0
  16. package/dist/src/index.d.ts +1 -0
  17. package/dist/src/index.js +1 -0
  18. package/dist/src/invocation.d.ts +24 -0
  19. package/dist/src/invocation.js +163 -0
  20. package/dist/src/operations.d.ts +7 -2
  21. package/dist/src/operations.js +76 -134
  22. package/dist/src/prompts.d.ts +28 -0
  23. package/dist/src/prompts.js +63 -0
  24. package/dist/src/recovery.d.ts +19 -0
  25. package/dist/src/recovery.js +99 -0
  26. package/dist/src/runner.d.ts +5 -0
  27. package/dist/src/runner.js +145 -18
  28. package/dist/src/runtime/process.d.ts +2 -0
  29. package/dist/src/runtime/process.js +41 -12
  30. package/dist/src/store.d.ts +21 -2
  31. package/dist/src/store.js +122 -1
  32. package/dist/src/tui/actions.d.ts +16 -0
  33. package/dist/src/tui/actions.js +23 -0
  34. package/dist/src/tui/constants.d.ts +6 -0
  35. package/dist/src/tui/constants.js +3 -0
  36. package/dist/src/{tui-data.d.ts → tui/data.d.ts} +8 -6
  37. package/dist/src/tui/data.js +141 -0
  38. package/dist/src/tui/dialogs.d.ts +17 -0
  39. package/dist/src/tui/dialogs.js +149 -0
  40. package/dist/src/tui/format.d.ts +7 -0
  41. package/dist/src/tui/format.js +62 -0
  42. package/dist/src/tui/index.d.ts +2 -0
  43. package/dist/src/tui/index.js +1 -0
  44. package/dist/src/tui/layout.d.ts +25 -0
  45. package/dist/src/tui/layout.js +36 -0
  46. package/dist/src/tui/log-file.d.ts +26 -0
  47. package/dist/src/tui/log-file.js +156 -0
  48. package/dist/src/tui/log.d.ts +11 -0
  49. package/dist/src/tui/log.js +90 -0
  50. package/dist/src/tui/monitor.d.ts +8 -0
  51. package/dist/src/tui/monitor.js +222 -0
  52. package/dist/src/tui/text.d.ts +3 -0
  53. package/dist/src/tui/text.js +10 -0
  54. package/dist/src/tui/use-log-controller.d.ts +27 -0
  55. package/dist/src/tui/use-log-controller.js +192 -0
  56. package/dist/src/tui/views.d.ts +17 -0
  57. package/dist/src/tui/views.js +97 -0
  58. package/docs/api.md +119 -6
  59. package/docs/architecture.md +21 -4
  60. package/docs/configuration.md +137 -5
  61. package/docs/database.md +7 -0
  62. package/docs/operations.md +117 -2
  63. package/docs/providers.md +58 -2
  64. package/docs/releases.md +34 -79
  65. package/examples/config.ts +2 -2
  66. package/package.json +4 -2
  67. package/dist/src/tui-data.js +0 -89
  68. package/dist/src/tui.d.ts +0 -5
  69. package/dist/src/tui.js +0 -69
  70. package/docs/acceptance.md +0 -35
@@ -0,0 +1,90 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { useState } from "react";
4
+ import { HelpDialog } from "./dialogs.js";
5
+ import { cells, colorFor } from "./format.js";
6
+ import { presentLogLine } from "./log-file.js";
7
+ import { matchIndex } from "./text.js";
8
+ import { useLogController } from "./use-log-controller.js";
9
+ const horizontalPanColumns = 20;
10
+ export function LogViewer({ sources, initial, columns, rows, onBack, }) {
11
+ const [index, setIndex] = useState(initial);
12
+ const source = sources[index] ?? sources[0];
13
+ if (!source)
14
+ return _jsx(Text, { children: "No logs recorded. Esc back" });
15
+ return (_jsx(LogScreen, { source: source, columns: columns, rows: rows, onBack: onBack, onNext: () => setIndex((value) => (value + 1) % sources.length), multiple: sources.length > 1 }, source.path));
16
+ }
17
+ function Highlight({ text, query }) {
18
+ const at = query ? matchIndex(text, query) : -1;
19
+ if (at < 0)
20
+ return _jsx(Text, { children: text });
21
+ return (_jsxs(Text, { children: [text.slice(0, at), _jsx(Text, { inverse: true, bold: true, children: text.slice(at, at + query.length) }), text.slice(at + query.length)] }));
22
+ }
23
+ function LogScreen({ source, columns, rows, onBack, onNext, multiple, }) {
24
+ const log = useLogController(source.path, columns, rows);
25
+ const { height, following, horizontal, raw, lines, total, message, editing, draft, query, searching, position, } = log;
26
+ const [help, setHelp] = useState(false);
27
+ useInput((input, key) => {
28
+ if (key.ctrl || key.meta || key.eventType === "release")
29
+ return;
30
+ if (help)
31
+ return;
32
+ if (key.escape) {
33
+ if (!log.dismissSearch())
34
+ onBack();
35
+ return;
36
+ }
37
+ if (editing) {
38
+ if (key.return)
39
+ log.applySearch();
40
+ else if (key.backspace || key.delete)
41
+ log.eraseDraft();
42
+ else if (!key.ctrl &&
43
+ !key.meta &&
44
+ !key.upArrow &&
45
+ !key.downArrow &&
46
+ !key.leftArrow &&
47
+ !key.rightArrow &&
48
+ !key.tab)
49
+ log.appendDraft(input);
50
+ return;
51
+ }
52
+ if (input === "/")
53
+ log.beginSearch();
54
+ else if (input === "n" || input === "N")
55
+ log.repeatSearch(input === "n" ? 1 : -1);
56
+ else if (input === "f")
57
+ log.follow();
58
+ else if (input === "R")
59
+ log.toggleRaw();
60
+ else if (key.tab && multiple)
61
+ onNext();
62
+ else if (input === "?")
63
+ setHelp(true);
64
+ else if (key.upArrow || input === "k")
65
+ log.scroll(-1);
66
+ else if (key.downArrow || input === "j")
67
+ log.scroll(1);
68
+ else if (key.pageUp)
69
+ log.scroll(-height);
70
+ else if (key.pageDown || input === " ")
71
+ log.scroll(height);
72
+ else if (key.home || input === "g")
73
+ log.firstPage();
74
+ else if (key.end || input === "G")
75
+ log.lastPage();
76
+ else if (key.leftArrow)
77
+ log.pan(-horizontalPanColumns);
78
+ else if (key.rightArrow)
79
+ log.pan(horizontalPanColumns);
80
+ });
81
+ if (help)
82
+ return (_jsx(HelpDialog, { columns: columns, rows: rows, initialPage: 2, onClose: () => setHelp(false) }));
83
+ return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsx(Text, { bold: true, color: colorFor("running"), wrap: "truncate", children: cells(`Logs · ${source.label}`, columns) }), _jsx(Text, { wrap: "truncate", children: cells(source.path, columns) }), _jsxs(Text, { children: [following ? "LIVE FOLLOW" : "SCROLLING", " \u00B7 ", raw ? "Raw" : "Readable", " \u00B7 lines ", total ? position + 1 : 0, "\u2013", Math.min(total, position + height), "/", total, searching ? " · Searching…" : ""] }), _jsx(Box, { height: height, flexDirection: "column", overflow: "hidden", children: lines.map((line, index) => (_jsx(Text, { wrap: "truncate", children: _jsx(Highlight, { text: cells(presentLogLine(line, raw), columns, horizontal), query: query }) }, `${position + index}`))) }), _jsx(Text, { wrap: "truncate", children: cells(editing
84
+ ? `/${draft}▏`
85
+ : query
86
+ ? `Search: ${query}${message ? ` · ${message}` : ""}`
87
+ : message, columns) }), _jsx(Text, { color: colorFor("running"), wrap: "truncate", children: editing
88
+ ? "Enter search · Esc cancel"
89
+ : `/ search · n/N match · f follow · R ${raw ? "readable" : "raw"} · ${multiple ? "Tab log · " : ""}Esc back · ? help` }), _jsx(Text, { dimColor: true, wrap: "truncate", children: cells("↑↓ scroll · PgUp/PgDn page · ←→ pan · workflow continues in background", columns) })] }));
90
+ }
@@ -0,0 +1,8 @@
1
+ import { type MonitorSource } from "./data.js";
2
+ export declare function Monitor({ source, size, }: {
3
+ source: MonitorSource;
4
+ size?: {
5
+ columns: number;
6
+ rows: number;
7
+ };
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,222 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput, useWindowSize } from "ink";
3
+ import { useEffect, useState } from "react";
4
+ import { actionAvailability } from "./actions.js";
5
+ import { minimumTerminalSize } from "./constants.js";
6
+ import { useMonitorData } from "./data.js";
7
+ import { ConfirmDialog, HelpDialog } from "./dialogs.js";
8
+ import { cells, colorFor, wrapLines } from "./format.js";
9
+ import { monitorLayout } from "./layout.js";
10
+ import { LogViewer } from "./log.js";
11
+ import { detailLines, Lines, RunList, summaryLines } from "./views.js";
12
+ const focuses = ["runs", "summary", "sessions"];
13
+ const statusRefreshIntervalMs = 1_000;
14
+ const actionDescriptions = {
15
+ stop: "Cancel this run and wait for its active local work to stop.",
16
+ retry: "Create a new run and branch; execute the workflow again.",
17
+ recover: "Continue the failed publication step using completed work.",
18
+ };
19
+ export function Monitor({ source, size, }) {
20
+ const window = useWindowSize();
21
+ const { columns, rows } = size ?? window;
22
+ const { exit } = useApp();
23
+ const [selection, setSelection] = useState({});
24
+ const data = useMonitorData(source, selection);
25
+ const { projects, project, projectRuns, run, sessions, events } = data;
26
+ const [focus, setFocus] = useState("runs");
27
+ const [screen, setScreen] = useState("dashboard");
28
+ const [sessionId, setSessionId] = useState();
29
+ const [stepSequence, setStepSequence] = useState();
30
+ const [offset, setOffset] = useState(0);
31
+ const [helpOpen, setHelpOpen] = useState(false);
32
+ const [confirmation, setConfirmation] = useState();
33
+ const [logs, setLogs] = useState();
34
+ const [now, setNow] = useState(Date.now());
35
+ const layout = monitorLayout(columns, rows);
36
+ const { wide, height, paneWidth, summaryWidth } = layout;
37
+ const session = sessions.find((item) => item.id === sessionId) ?? sessions[0];
38
+ const event = events.find((item) => item.sequence === stepSequence) ?? events.at(-1);
39
+ useEffect(() => {
40
+ const timer = setInterval(() => setNow(Date.now()), statusRefreshIntervalMs);
41
+ return () => clearInterval(timer);
42
+ }, []);
43
+ useEffect(() => {
44
+ // Pin default selections by identity before incoming rows can reorder them.
45
+ setSelection((current) => current.projectId === project?.id && current.runId === run?.id
46
+ ? current
47
+ : { projectId: project?.id, runId: run?.id });
48
+ }, [project?.id, run?.id]);
49
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset navigation when the selected run identity changes.
50
+ useEffect(() => {
51
+ setSessionId(undefined);
52
+ setStepSequence(undefined);
53
+ setOffset(0);
54
+ setConfirmation(undefined);
55
+ }, [run?.id]);
56
+ const selectProject = (delta) => {
57
+ const index = projects.findIndex((item) => item.id === project?.id);
58
+ const next = projects[Math.max(0, Math.min(projects.length - 1, index + delta))];
59
+ setSelection({ projectId: next?.id });
60
+ setFocus("runs");
61
+ setOffset(0);
62
+ };
63
+ const openAgentLog = () => {
64
+ if (!sessions.length)
65
+ return;
66
+ setLogs({
67
+ sources: sessions.map((item) => ({
68
+ path: item.log,
69
+ label: `${item.step} · invocation ${item.attempt}`,
70
+ })),
71
+ initial: Math.max(0, sessions.findIndex((item) => item.id === session?.id)),
72
+ });
73
+ };
74
+ const { recoveryReason, available } = actionAvailability({
75
+ run,
76
+ project,
77
+ projectRuns,
78
+ pending: Boolean(data.pending),
79
+ });
80
+ useInput((input, key) => {
81
+ if (key.ctrl || key.meta || key.eventType === "release")
82
+ return;
83
+ const terminalIsLargeEnough = columns >= minimumTerminalSize.columns &&
84
+ rows >= minimumTerminalSize.rows;
85
+ if (logs && terminalIsLargeEnough)
86
+ return;
87
+ if (terminalIsLargeEnough && (confirmation || helpOpen))
88
+ return;
89
+ if (input === "q") {
90
+ exit();
91
+ return;
92
+ }
93
+ if (!terminalIsLargeEnough)
94
+ return;
95
+ if (key.escape) {
96
+ setScreen("dashboard");
97
+ setFocus("runs");
98
+ setOffset(0);
99
+ return;
100
+ }
101
+ if (input === "?") {
102
+ setHelpOpen(true);
103
+ return;
104
+ }
105
+ if (key.tab && screen === "dashboard") {
106
+ setFocus(focuses[(focuses.indexOf(focus) + (key.shift ? 2 : 1)) % 3]);
107
+ setOffset(0);
108
+ }
109
+ if (input === "a") {
110
+ setScreen("dashboard");
111
+ setFocus("sessions");
112
+ }
113
+ if (key.leftArrow && screen === "dashboard")
114
+ selectProject(-1);
115
+ if (key.rightArrow && screen === "dashboard")
116
+ selectProject(1);
117
+ if (key.return && run) {
118
+ setScreen("details");
119
+ setOffset(0);
120
+ }
121
+ if (input === "l")
122
+ openAgentLog();
123
+ if (input === "v" && run?.validation?.length)
124
+ setLogs({
125
+ sources: run.validation.map((item) => ({
126
+ path: item.log,
127
+ label: `${item.command} · exit ${item.exitCode}`,
128
+ })),
129
+ initial: 0,
130
+ });
131
+ if (input === "[" || input === "]") {
132
+ const index = events.findIndex((item) => item.sequence === event?.sequence);
133
+ setStepSequence(events[Math.max(0, Math.min(events.length - 1, index + (input === "]" ? 1 : -1)))]?.sequence);
134
+ }
135
+ if (key.end)
136
+ setStepSequence(undefined);
137
+ if (key.upArrow || key.downArrow || key.pageUp || key.pageDown) {
138
+ const delta = key.upArrow || key.pageUp ? -1 : 1;
139
+ if (screen === "details" || focus === "summary") {
140
+ const lines = run
141
+ ? screen === "details"
142
+ ? detailLines(run, sessions, now, recoveryReason)
143
+ : summaryLines(run, now, event)
144
+ : [];
145
+ const viewport = screen === "details" ? layout.details : layout.summary;
146
+ setOffset((value) => Math.max(0, Math.min(wrapLines(lines, viewport.width).length - viewport.height, value +
147
+ delta *
148
+ (key.pageUp || key.pageDown ? layout.details.height : 1))));
149
+ }
150
+ else if (focus === "sessions") {
151
+ const index = sessions.findIndex((item) => item.id === session?.id);
152
+ setSessionId(sessions[Math.max(0, Math.min(sessions.length - 1, index + delta))]
153
+ ?.id);
154
+ }
155
+ else {
156
+ const index = projectRuns.findIndex((item) => item.id === run?.id);
157
+ setSelection({
158
+ projectId: project?.id,
159
+ runId: projectRuns[Math.max(0, Math.min(projectRuns.length - 1, index + delta))]?.id,
160
+ });
161
+ }
162
+ }
163
+ if (input === "p" && project && !data.pending)
164
+ void data.action(project.paused ? "resume" : "pause", project.id);
165
+ const kind = input === "s"
166
+ ? "stop"
167
+ : input === "r"
168
+ ? "retry"
169
+ : input === "c"
170
+ ? "recover"
171
+ : undefined;
172
+ if (kind && run && available[kind])
173
+ setConfirmation({
174
+ kind,
175
+ runId: run.id,
176
+ title: `#${run.issue.number} ${run.issue.title}`,
177
+ });
178
+ });
179
+ if (columns < minimumTerminalSize.columns || rows < minimumTerminalSize.rows)
180
+ return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsx(Text, { children: cells(`Resize terminal to at least ${minimumTerminalSize.columns}×${minimumTerminalSize.rows}. q closes monitor.`, columns) }) }));
181
+ if (helpOpen)
182
+ return (_jsx(HelpDialog, { columns: columns, rows: rows, onClose: () => setHelpOpen(false) }));
183
+ if (confirmation)
184
+ return (_jsx(ConfirmDialog, { columns: columns, rows: rows, title: `Confirm ${confirmation.kind}`, subject: confirmation.title, description: actionDescriptions[confirmation.kind], available: run?.id === confirmation.runId && available[confirmation.kind], onCancel: () => setConfirmation(undefined), onConfirm: () => {
185
+ void data.action(confirmation.kind, confirmation.runId);
186
+ setConfirmation(undefined);
187
+ } }));
188
+ if (logs)
189
+ return (_jsx(LogViewer, { ...logs, columns: columns, rows: rows, onBack: () => setLogs(undefined) }));
190
+ const status = `${data.connection}${data.lastUpdated ? ` · refreshed ${Math.max(0, Math.floor((now - data.lastUpdated) / statusRefreshIntervalMs))}s ago` : ""}`;
191
+ const sessionIndex = Math.max(0, sessions.findIndex((item) => item.id === session?.id));
192
+ const sessionLines = sessions.length
193
+ ? sessions
194
+ .slice(Math.max(0, sessionIndex - 1), sessionIndex + 3)
195
+ .map((item) => `${item.id === session?.id ? ">" : " "} ${item.step} · invocation ${item.attempt} · ${item.outcome}`)
196
+ : ["No agent sessions recorded"];
197
+ const controls = [
198
+ sessions.length ? "l log" : "",
199
+ run?.validation?.length ? "v validation" : "",
200
+ project ? (project.paused ? "p resume" : "p pause") : "",
201
+ available.stop ? "s stop" : "",
202
+ available.retry ? "r retry" : "",
203
+ available.recover ? "c recover" : "",
204
+ "? help",
205
+ "q close",
206
+ ]
207
+ .filter(Boolean)
208
+ .join(" · ");
209
+ return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsx(Text, { bold: true, color: colorFor("running"), wrap: "truncate", children: cells(`Agent Workflows · ${screen === "dashboard" ? "Monitor" : "Run details"}`, columns) }), _jsx(Text, { wrap: "truncate", children: cells(project
210
+ ? `${project.id} · intake ${project.paused ? "paused" : "enabled"} · ${projectRuns.filter((item) => item.outcome === "queued").length} queued${project.blocked ? ` · BLOCKED: ${project.blocked}` : ""}`
211
+ : "No projects registered. Start the runner to populate this view.", columns) }), _jsx(Box, { height: height, flexDirection: "row", overflow: "hidden", children: screen === "details" ? (_jsxs(Box, { borderStyle: "round", width: columns, height: height, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Details \u00B7 \u2191\u2193 scroll \u00B7 Esc back" }), _jsx(Lines, { lines: run
212
+ ? detailLines(run, sessions, now, recoveryReason)
213
+ : ["Run no longer available"], width: layout.details.width, height: layout.details.height, offset: offset })] })) : (_jsxs(_Fragment, { children: [(wide || focus === "runs") && (_jsxs(Box, { width: paneWidth, height: height, borderStyle: "round", borderColor: focus === "runs" ? colorFor("running") : undefined, flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, children: [focus === "runs" ? "> " : "", "Runs \u00B7 ", projectRuns.length] }), _jsx(RunList, { runs: projectRuns, selected: run?.id, width: layout.runs.width, height: layout.runs.height, now: now })] })), (wide || focus !== "runs") && (_jsxs(Box, { width: summaryWidth, height: height, flexDirection: "column", children: [(wide || focus === "summary") && (_jsxs(Box, { width: summaryWidth, height: layout.summaryPanelHeight, borderStyle: "round", borderColor: focus === "summary" ? colorFor("running") : undefined, paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [focus === "summary" ? "> " : "", "Summary \u00B7 Enter details"] }), _jsx(Lines, { lines: run
214
+ ? summaryLines(run, now, event)
215
+ : ["Select a run to inspect progress"], width: layout.summary.width, height: layout.summary.height, offset: offset })] })), (wide || focus === "sessions") && (_jsxs(Box, { width: summaryWidth, height: layout.sessionsPanelHeight, borderStyle: "round", borderColor: focus === "sessions" ? colorFor("running") : undefined, paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [focus === "sessions" ? "> " : "", "Agent sessions \u00B7", " ", sessions.length, " \u00B7 l log"] }), _jsx(Lines, { lines: sessionLines, width: layout.sessions.width, height: layout.sessions.height })] }))] }))] })) }), _jsx(Text, { wrap: "truncate", color: data.connection.startsWith("Connection error")
216
+ ? colorFor("failed")
217
+ : undefined, children: cells(data.message || status, columns) }), _jsx(Text, { dimColor: true, wrap: "truncate", children: cells(data.message
218
+ ? status
219
+ : "Runner liveness unverified · closing monitor leaves workflows running", columns) }), _jsx(Text, { color: colorFor("running"), wrap: "truncate", children: cells(screen === "dashboard"
220
+ ? "Tab pane · ↑↓ select/scroll · ←→ project · Enter details"
221
+ : "↑↓ scroll · PgUp/PgDn page · Esc back", columns) }), _jsx(Text, { wrap: "truncate", children: cells(controls, columns) })] }));
222
+ }
@@ -0,0 +1,3 @@
1
+ export declare function terminalText(text: string): string;
2
+ /** Literal smart-case match, returning a UTF-16 offset for string slicing. */
3
+ export declare function matchIndex(text: string, query: string): number;
@@ -0,0 +1,10 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ export function terminalText(text) {
3
+ return stripVTControlCharacters(text).replace(
4
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: remove control bytes from untrusted terminal output.
5
+ /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
6
+ }
7
+ /** Literal smart-case match, returning a UTF-16 offset for string slicing. */
8
+ export function matchIndex(text, query) {
9
+ return (query === query.toLowerCase() ? text.toLowerCase() : text).indexOf(query);
10
+ }
@@ -0,0 +1,27 @@
1
+ /** Owned by one keyed log screen; unmount cancels polling and search. */
2
+ export declare function useLogController(path: string, columns: number, rows: number): {
3
+ height: number;
4
+ following: boolean;
5
+ horizontal: number;
6
+ raw: boolean;
7
+ lines: string[];
8
+ total: number;
9
+ message: string;
10
+ editing: boolean;
11
+ draft: string;
12
+ query: string;
13
+ searching: boolean;
14
+ position: number;
15
+ scroll: (delta: number) => void;
16
+ dismissSearch(): boolean;
17
+ beginSearch(): void;
18
+ applySearch(): void;
19
+ repeatSearch(direction: 1 | -1): void;
20
+ eraseDraft(): void;
21
+ appendDraft(input: string): void;
22
+ follow(): void;
23
+ toggleRaw(): void;
24
+ firstPage(): void;
25
+ lastPage(): void;
26
+ pan(delta: number): void;
27
+ };
@@ -0,0 +1,192 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import stringWidth from "string-width";
3
+ import { screenChromeRows, tuiRefreshIntervalMs } from "./constants.js";
4
+ import { LogFile, presentLogLine } from "./log-file.js";
5
+ import { matchIndex, terminalText } from "./text.js";
6
+ /** Owned by one keyed log screen; unmount cancels polling and search. */
7
+ export function useLogController(path, columns, rows) {
8
+ const file = useMemo(() => new LogFile(path), [path]);
9
+ const height = Math.max(1, rows - screenChromeRows);
10
+ const [following, setFollowing] = useState(true);
11
+ const [top, setTop] = useState(0);
12
+ const [horizontal, setHorizontal] = useState(0);
13
+ const [raw, setRaw] = useState(false);
14
+ const [lines, setLines] = useState([]);
15
+ const [total, setTotal] = useState(0);
16
+ const [message, setMessage] = useState("Loading log…");
17
+ const [editing, setEditing] = useState(false);
18
+ const [draft, setDraft] = useState("");
19
+ const [query, setQuery] = useState("");
20
+ const [searching, setSearching] = useState(false);
21
+ const searchController = useRef(undefined);
22
+ const position = useRef(0);
23
+ useEffect(() => () => searchController.current?.abort(), []);
24
+ useEffect(() => {
25
+ const controller = new AbortController();
26
+ let busy = false;
27
+ const update = async () => {
28
+ if (busy)
29
+ return;
30
+ busy = true;
31
+ try {
32
+ await file.refresh(controller.signal);
33
+ const nextTop = following
34
+ ? Math.max(0, file.count - height)
35
+ : Math.min(top, Math.max(0, file.count - 1));
36
+ const page = await file.page(nextTop, height, controller.signal);
37
+ if (controller.signal.aborted)
38
+ return;
39
+ position.current = nextTop;
40
+ if (!following && nextTop !== top)
41
+ setTop(nextTop);
42
+ setTotal(file.count);
43
+ setLines(page);
44
+ setMessage((current) => !file.count
45
+ ? "No output yet; waiting for log data"
46
+ : /^(Loading log|Log unavailable|No output yet)/.test(current)
47
+ ? ""
48
+ : current);
49
+ }
50
+ catch (error) {
51
+ if (!controller.signal.aborted) {
52
+ setLines([]);
53
+ setMessage(`Log unavailable: ${String(error)}`);
54
+ }
55
+ }
56
+ finally {
57
+ busy = false;
58
+ }
59
+ };
60
+ void update();
61
+ const timer = setInterval(() => void update(), tuiRefreshIntervalMs);
62
+ return () => {
63
+ controller.abort();
64
+ clearInterval(timer);
65
+ };
66
+ }, [file, following, top, height]);
67
+ const search = async (value, direction, first = false) => {
68
+ if (!value)
69
+ return;
70
+ searchController.current?.abort();
71
+ const controller = new AbortController();
72
+ searchController.current = controller;
73
+ setFollowing(false);
74
+ setQuery(value);
75
+ setSearching(true);
76
+ try {
77
+ const match = await file.search({
78
+ query: value,
79
+ from: first ? position.current - 1 : position.current,
80
+ direction,
81
+ raw,
82
+ signal: controller.signal,
83
+ });
84
+ if (controller.signal.aborted)
85
+ return;
86
+ if (match === undefined)
87
+ setMessage(`No matches: ${value}`);
88
+ else {
89
+ position.current = match;
90
+ setTop(match);
91
+ const [line] = await file.page(match, 1, controller.signal);
92
+ if (controller.signal.aborted)
93
+ return;
94
+ const text = presentLogLine(line ?? "", raw);
95
+ const at = matchIndex(text, value);
96
+ setHorizontal(stringWidth(text.slice(0, at + value.length)) > columns
97
+ ? Math.max(0, stringWidth(text.slice(0, at)) - 8)
98
+ : 0);
99
+ setMessage(`Match on line ${match + 1} (wraps at file boundary)`);
100
+ }
101
+ }
102
+ catch (error) {
103
+ if (!controller.signal.aborted)
104
+ setMessage(`Search failed: ${String(error)}`);
105
+ }
106
+ finally {
107
+ if (!controller.signal.aborted)
108
+ setSearching(false);
109
+ }
110
+ };
111
+ const scroll = (delta) => {
112
+ setFollowing(false);
113
+ const next = Math.max(0, Math.min(Math.max(0, total - 1), position.current + delta));
114
+ position.current = next;
115
+ setTop(next);
116
+ };
117
+ const cancelSearch = () => {
118
+ searchController.current?.abort();
119
+ setSearching(false);
120
+ };
121
+ return {
122
+ height,
123
+ following,
124
+ horizontal,
125
+ raw,
126
+ lines,
127
+ total,
128
+ message,
129
+ editing,
130
+ draft,
131
+ query,
132
+ searching,
133
+ position: position.current,
134
+ scroll,
135
+ dismissSearch() {
136
+ cancelSearch();
137
+ if (!editing && !query)
138
+ return false;
139
+ setEditing(false);
140
+ setQuery("");
141
+ setDraft("");
142
+ return true;
143
+ },
144
+ beginSearch() {
145
+ setFollowing(false);
146
+ setTop(position.current);
147
+ setDraft("");
148
+ setEditing(true);
149
+ },
150
+ applySearch() {
151
+ setEditing(false);
152
+ void search(draft, 1, true);
153
+ },
154
+ repeatSearch(direction) {
155
+ void search(query, direction);
156
+ },
157
+ eraseDraft() {
158
+ setDraft((value) => Array.from(value).slice(0, -1).join(""));
159
+ },
160
+ appendDraft(input) {
161
+ setDraft((value) => value +
162
+ terminalText(input)
163
+ .replaceAll("\n", "")
164
+ .replaceAll("\r", "")
165
+ .replaceAll("\t", ""));
166
+ },
167
+ follow() {
168
+ cancelSearch();
169
+ setQuery("");
170
+ setFollowing(true);
171
+ setHorizontal(0);
172
+ },
173
+ toggleRaw() {
174
+ cancelSearch();
175
+ setRaw(!raw);
176
+ setQuery("");
177
+ },
178
+ firstPage() {
179
+ setFollowing(false);
180
+ position.current = 0;
181
+ setTop(0);
182
+ },
183
+ lastPage() {
184
+ setFollowing(false);
185
+ position.current = Math.max(0, total - height);
186
+ setTop(position.current);
187
+ },
188
+ pan(delta) {
189
+ setHorizontal((value) => Math.max(0, value + delta));
190
+ },
191
+ };
192
+ }
@@ -0,0 +1,17 @@
1
+ import type { RunRecord } from "../domain.js";
2
+ import type { EventRecord, InvocationRecord } from "../store.js";
3
+ export declare function summaryLines(run: RunRecord, now: number, event?: EventRecord): string[];
4
+ export declare function detailLines(run: RunRecord, sessions: InvocationRecord[], now: number, recoveryReason?: string | undefined): string[];
5
+ export declare function Lines({ lines, width, height, offset, }: {
6
+ lines: string[];
7
+ width: number;
8
+ height: number;
9
+ offset?: number;
10
+ }): import("react").JSX.Element;
11
+ export declare function RunList({ runs, selected, width, height, now, }: {
12
+ runs: RunRecord[];
13
+ selected?: string;
14
+ width: number;
15
+ height: number;
16
+ now: number;
17
+ }): import("react").JSX.Element;