patchrelay 0.35.11 → 0.35.13

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 (52) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +19 -1
  4. package/dist/cli/commands/issues.js +18 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +160 -47
  7. package/dist/cli/formatters/text.js +51 -90
  8. package/dist/cli/help.js +15 -8
  9. package/dist/cli/index.js +3 -58
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +21 -12
  12. package/dist/cli/watch/HelpBar.js +3 -3
  13. package/dist/cli/watch/IssueDetailView.js +63 -130
  14. package/dist/cli/watch/IssueRow.js +82 -27
  15. package/dist/cli/watch/StatusBar.js +8 -4
  16. package/dist/cli/watch/detail-rows.js +589 -0
  17. package/dist/cli/watch/render-rich-text.js +226 -0
  18. package/dist/cli/watch/state-visualization.js +48 -23
  19. package/dist/cli/watch/timeline-builder.js +2 -1
  20. package/dist/cli/watch/use-detail-stream.js +10 -104
  21. package/dist/cli/watch/use-watch-stream.js +11 -102
  22. package/dist/cli/watch/watch-state.js +129 -56
  23. package/dist/codex-thread-utils.js +3 -0
  24. package/dist/db/migrations.js +239 -2
  25. package/dist/db.js +628 -39
  26. package/dist/github-app-token.js +7 -0
  27. package/dist/github-failure-context.js +44 -1
  28. package/dist/github-rollup.js +47 -0
  29. package/dist/github-webhook-handler.js +423 -52
  30. package/dist/github-webhooks.js +7 -0
  31. package/dist/http.js +12 -264
  32. package/dist/idle-reconciliation.js +268 -76
  33. package/dist/issue-query-service.js +221 -129
  34. package/dist/issue-session-events.js +151 -0
  35. package/dist/issue-session.js +99 -0
  36. package/dist/linear-client.js +39 -25
  37. package/dist/linear-session-reporting.js +12 -0
  38. package/dist/linear-session-sync.js +253 -24
  39. package/dist/linear-workflow.js +33 -0
  40. package/dist/merge-queue-protocol.js +0 -51
  41. package/dist/preflight.js +1 -4
  42. package/dist/queue-health-monitor.js +11 -7
  43. package/dist/run-orchestrator.js +1364 -147
  44. package/dist/run-reporting.js +5 -3
  45. package/dist/service.js +279 -102
  46. package/dist/status-note.js +56 -0
  47. package/dist/waiting-reason.js +65 -0
  48. package/dist/webhook-handler.js +270 -79
  49. package/package.json +3 -2
  50. package/dist/cli/commands/feed.js +0 -60
  51. package/dist/cli/watch/FeedView.js +0 -28
  52. package/dist/cli/watch/use-feed-stream.js +0 -92
@@ -15,7 +15,9 @@ export function formatInspect(result) {
15
15
  const lines = [
16
16
  header,
17
17
  value("Title", result.issue?.title),
18
- value("State", result.issue?.factoryState),
18
+ value("Session", result.issue?.sessionState),
19
+ value("Waiting reason", result.issue?.waitingReason ?? result.issue?.statusNote),
20
+ value("Debug stage", result.issue?.factoryState),
19
21
  result.activeRun ? value("Active run", `${result.activeRun.runType} (${result.activeRun.status})`) : undefined,
20
22
  result.latestRun && !result.activeRun ? value("Latest run", `${result.latestRun.runType} (${result.latestRun.status})`) : undefined,
21
23
  result.prNumber ? value("PR", `#${result.prNumber}${result.prReviewState ? ` [${result.prReviewState}]` : ""}`) : undefined,
@@ -35,39 +37,6 @@ export function formatLive(result) {
35
37
  ].filter(Boolean);
36
38
  return `${lines.join("\n")}\n`;
37
39
  }
38
- export function formatReport(result) {
39
- const sections = result.runs.map(({ run, report, summary }) => {
40
- const changedFiles = report?.fileChanges
41
- .map((entry) => (typeof entry.path === "string" ? entry.path : undefined))
42
- .filter(Boolean)
43
- .join(", ");
44
- const commands = report?.commands.map((command) => command.command).join(" | ");
45
- const tools = report?.toolCalls.map((tool) => `${tool.type}:${tool.name}`).join(", ");
46
- return [
47
- `${run.runType} #${run.id} ${run.status}`,
48
- value("Started", run.startedAt),
49
- value("Ended", run.endedAt),
50
- value("Thread", run.threadId),
51
- summary?.latestAssistantMessage ? value("Summary", truncateLine(String(summary.latestAssistantMessage))) : undefined,
52
- report?.assistantMessages.at(-1) ? value("Assistant conclusion", truncateLine(report.assistantMessages.at(-1))) : undefined,
53
- commands ? value("Commands", commands) : undefined,
54
- changedFiles ? value("Changed files", changedFiles) : undefined,
55
- tools ? value("Tool calls", tools) : undefined,
56
- ]
57
- .filter(Boolean)
58
- .join("\n");
59
- });
60
- return `${sections.join("\n\n")}\n`;
61
- }
62
- export function formatEvents(result) {
63
- const sections = result.events.map((event) => [
64
- `#${event.id} ${event.createdAt} ${event.method}`,
65
- value("Thread", event.threadId),
66
- value("Turn", event.turnId),
67
- event.parsedEvent ? JSON.stringify(event.parsedEvent, null, 2) : event.eventJson,
68
- ].join("\n"));
69
- return `${value("Run", result.run.id)}\n${value("Run type", result.run.runType)}\n\n${sections.join("\n\n")}\n`;
70
- }
71
40
  export function formatWorktree(result, cdOnly) {
72
41
  if (cdOnly) {
73
42
  return `${result.worktreePath}\n`;
@@ -106,70 +75,62 @@ export function formatRetry(result) {
106
75
  .filter(Boolean)
107
76
  .join("\n")}\n`;
108
77
  }
78
+ function formatTimestampRange(startedAt, endedAt) {
79
+ return endedAt ? `${startedAt} -> ${endedAt}` : `${startedAt} -> running`;
80
+ }
81
+ export function formatSessionHistory(result, buildOpenForThread) {
82
+ const lines = [
83
+ `${result.issue.issueKey ?? result.issue.linearIssueId}${result.issue.currentLinearState ? ` ${result.issue.currentLinearState}` : ""}`,
84
+ value("Worktree", result.worktreePath),
85
+ value("Current thread", result.currentThreadId),
86
+ ];
87
+ if (result.sessions.length === 0) {
88
+ lines.push("No recorded app-server sessions.");
89
+ return `${lines.join("\n")}\n`;
90
+ }
91
+ for (const session of result.sessions) {
92
+ lines.push("");
93
+ lines.push([
94
+ `run #${session.runId}`,
95
+ session.runType,
96
+ session.status,
97
+ formatTimestampRange(session.startedAt, session.endedAt),
98
+ session.isCurrentThread ? "current" : undefined,
99
+ ]
100
+ .filter(Boolean)
101
+ .join(" "));
102
+ lines.push(value("Thread", session.threadId));
103
+ if (session.parentThreadId) {
104
+ lines.push(value("Parent thread", session.parentThreadId));
105
+ }
106
+ if (session.turnId) {
107
+ lines.push(value("Turn", session.turnId));
108
+ }
109
+ lines.push(value("Events", session.eventCount));
110
+ if (session.summary) {
111
+ lines.push(value("Summary", truncateLine(session.summary)));
112
+ }
113
+ else if (session.failureReason) {
114
+ lines.push(value("Failure", truncateLine(session.failureReason)));
115
+ }
116
+ if (session.threadId && result.worktreePath && buildOpenForThread) {
117
+ const command = buildOpenForThread(session.threadId);
118
+ lines.push(value("Open", formatCommand(command.command, command.args)));
119
+ }
120
+ }
121
+ return `${lines.join("\n")}\n`;
122
+ }
109
123
  export function formatList(items) {
110
124
  return `${items
111
125
  .map((item) => [
112
126
  item.issueKey ?? "-",
113
127
  item.currentLinearState ?? "-",
114
- item.factoryState,
128
+ item.sessionState ?? "-",
129
+ item.waitingReason ?? "-",
115
130
  item.activeRunType ?? "-",
116
131
  item.latestRunType ? `${item.latestRunType}:${item.latestRunStatus ?? "-"}` : "-",
117
132
  item.updatedAt,
133
+ item.factoryState,
118
134
  ].join("\t"))
119
135
  .join("\n")}\n`;
120
136
  }
121
- function colorize(enabled, code, value) {
122
- return enabled ? `\u001B[${code}m${value}\u001B[0m` : value;
123
- }
124
- function formatFeedStatus(event, color) {
125
- const raw = event.status ?? event.kind;
126
- const label = raw.replaceAll("_", " ");
127
- const padded = label.padEnd(15);
128
- if (event.level === "error" || raw === "failed" || raw === "delivery_failed") {
129
- return colorize(color, "31", padded);
130
- }
131
- if (event.level === "warn" || raw === "ignored" || raw === "fallback" || raw === "handoff" || raw === "transition_suppressed") {
132
- return colorize(color, "33", padded);
133
- }
134
- if (raw === "running" || raw === "started" || raw === "delegated" || raw === "transition_chosen" || raw === "completed") {
135
- return colorize(color, "32", padded);
136
- }
137
- if (raw === "queued" || raw === "selected") {
138
- return colorize(color, "36", padded);
139
- }
140
- return colorize(color, "2", padded);
141
- }
142
- function formatFeedMeta(event, color) {
143
- const parts = [
144
- event.workflowId ? `workflow:${event.workflowId}` : undefined,
145
- event.stage ? `stage:${event.stage}` : undefined,
146
- event.nextStage ? `next:${event.nextStage}` : undefined,
147
- ].filter(Boolean);
148
- if (parts.length === 0) {
149
- return undefined;
150
- }
151
- return colorize(color, "2", `[${parts.join(" ")}]`);
152
- }
153
- export function formatOperatorFeedEvent(event, options) {
154
- const color = options?.color === true;
155
- const timestamp = new Date(event.at).toLocaleTimeString("en-GB", { hour12: false });
156
- const issue = event.issueKey ?? event.projectId ?? "-";
157
- const meta = formatFeedMeta(event, color);
158
- const line = [
159
- colorize(color, "2", timestamp),
160
- colorize(color, "1", issue.padEnd(10)),
161
- formatFeedStatus(event, color),
162
- event.summary,
163
- ...(meta ? [meta] : []),
164
- ].join(" ");
165
- if (!event.detail) {
166
- return `${line}\n`;
167
- }
168
- return `${line}\n${colorize(color, "2", ` ${truncateLine(event.detail)}`)}\n`;
169
- }
170
- export function formatOperatorFeed(result, options) {
171
- if (result.events.length === 0) {
172
- return "No feed events yet.\n";
173
- }
174
- return result.events.map((event) => formatOperatorFeedEvent(event, options)).join("");
175
- }
package/dist/cli/help.js CHANGED
@@ -36,14 +36,13 @@ export function rootHelpText() {
36
36
  " issue show <issueKey> [--json] Show the latest known issue state",
37
37
  " issue watch <issueKey> [--json] Follow the active run until it settles",
38
38
  " issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
39
+ " issue sessions <issueKey> [--json] Show recorded Codex app-server sessions for one issue",
39
40
  " service status [--json] Show systemd state and local health",
40
41
  " service logs [--lines <count>] [--json] Show recent service logs",
42
+ " serve Run the local PatchRelay service",
41
43
  "",
42
44
  "Operator commands:",
43
- " feed [--follow] [--limit <count>] [--issue <issueKey>] [--repo <id>] [--kind <kind>] [--stage <stage>] [--status <status>] [--workflow <id>] [--json]",
44
- " Show operator activity from the daemon",
45
- " dashboard [--issue <issueKey>] Open the TUI dashboard of issues and runs",
46
- " serve Run the local PatchRelay service",
45
+ " dashboard [--issue <issueKey>] Open the PatchRelay session dashboard",
47
46
  "",
48
47
  "Environment options:",
49
48
  " --help, -h Show help for the root command or current command group",
@@ -62,7 +61,6 @@ export function rootHelpText() {
62
61
  " patchrelay repo list",
63
62
  " patchrelay issue list --active",
64
63
  " patchrelay issue watch USE-54",
65
- " patchrelay dashboard",
66
64
  " patchrelay service status",
67
65
  " patchrelay version --json",
68
66
  "",
@@ -96,6 +94,10 @@ export function linearHelpText() {
96
94
  " patchrelay linear connect",
97
95
  " patchrelay linear list",
98
96
  " patchrelay linear sync usertold",
97
+ "",
98
+ "Compatibility aliases:",
99
+ " patchrelay connect Alias for `patchrelay linear connect`",
100
+ " patchrelay installations Alias for `patchrelay linear list`",
99
101
  ].join("\n");
100
102
  }
101
103
  export function repoHelpText() {
@@ -124,6 +126,11 @@ export function repoHelpText() {
124
126
  " patchrelay repo link krasnoperov/usertold --workspace usertold --team USE",
125
127
  " patchrelay repo show krasnoperov/usertold",
126
128
  " patchrelay repo sync",
129
+ "",
130
+ "Compatibility aliases:",
131
+ " patchrelay attach ... Alias for `patchrelay repo link ...`",
132
+ " patchrelay repos Alias for `patchrelay repo list`",
133
+ " patchrelay repos <repo> Alias for `patchrelay repo show <repo>`",
127
134
  ].join("\n");
128
135
  }
129
136
  export function issueHelpText() {
@@ -134,17 +141,17 @@ export function issueHelpText() {
134
141
  "Commands:",
135
142
  " show <issueKey> Show the latest known issue state",
136
143
  " list List tracked issues",
137
- " watch <issueKey> Follow the active run until it settles",
138
- " report <issueKey> Show finished run reports",
139
- " events <issueKey> Show raw thread events",
144
+ " watch <issueKey> Follow PatchRelay-owned activity until it settles",
140
145
  " path <issueKey> Print the issue worktree path",
141
146
  " open <issueKey> Open Codex in the issue worktree",
147
+ " sessions <issueKey> Show recorded Codex app-server sessions",
142
148
  " retry <issueKey> Requeue a run",
143
149
  "",
144
150
  "Examples:",
145
151
  " patchrelay issue list --active",
146
152
  " patchrelay issue show USE-54",
147
153
  " patchrelay issue watch USE-54",
154
+ " patchrelay issue sessions USE-54",
148
155
  ].join("\n");
149
156
  }
150
157
  export function serviceHelpText() {
package/dist/cli/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { getBuildInfo } from "../build-info.js";
3
3
  import { assertKnownFlags, hasHelpFlag, parseArgs, resolveCommand } from "./args.js";
4
- import { handleFeedCommand } from "./commands/feed.js";
5
4
  import { handleIssueCommand, } from "./commands/issues.js";
6
5
  import { handleLinearCommand } from "./commands/linear.js";
7
6
  import { handleRepoCommand } from "./commands/repo.js";
@@ -19,7 +18,6 @@ function getCommandConfigProfile(command) {
19
18
  case "service":
20
19
  return "doctor";
21
20
  case "linear":
22
- case "feed":
23
21
  case "dashboard":
24
22
  return "operator_cli";
25
23
  case "repo":
@@ -61,6 +59,9 @@ function validateFlags(command, commandArgs, parsed) {
61
59
  case "open":
62
60
  assertKnownFlags(parsed, "issue", ["print", "json"]);
63
61
  return;
62
+ case "sessions":
63
+ assertKnownFlags(parsed, "issue", ["json"]);
64
+ return;
64
65
  case "retry":
65
66
  assertKnownFlags(parsed, "issue", ["run-type", "reason", "json"]);
66
67
  return;
@@ -108,12 +109,6 @@ function validateFlags(command, commandArgs, parsed) {
108
109
  assertKnownFlags(parsed, "repo", []);
109
110
  return;
110
111
  }
111
- case "attach":
112
- case "repos":
113
- case "connect":
114
- case "installations":
115
- throw new CliUsageError(`${command} has been removed. Use \`patchrelay linear ...\` and \`patchrelay repo ...\` instead.`);
116
- return;
117
112
  case "service":
118
113
  if (commandArgs[0] === "install") {
119
114
  assertKnownFlags(parsed, "service", ["force", "write-only", "json"]);
@@ -133,9 +128,6 @@ function validateFlags(command, commandArgs, parsed) {
133
128
  }
134
129
  assertKnownFlags(parsed, "service", []);
135
130
  return;
136
- case "feed":
137
- assertKnownFlags(parsed, command, ["follow", "limit", "issue", "repo", "kind", "stage", "status", "workflow", "json"]);
138
- return;
139
131
  case "dashboard":
140
132
  assertKnownFlags(parsed, command, ["issue"]);
141
133
  return;
@@ -319,25 +311,6 @@ export async function runCli(argv, options) {
319
311
  runInteractive,
320
312
  });
321
313
  }
322
- if (command === "attach" || command === "repos" || command === "connect" || command === "installations") {
323
- writeOutput(stderr, `${command} has been removed. Use \`patchrelay linear ...\` and \`patchrelay repo ...\` instead.\n`);
324
- return 1;
325
- }
326
- if (command === "feed") {
327
- const operatorData = parsed.flags.get("follow") === true
328
- ? await ensureFeedFollowDataAccess(data, config)
329
- : await ensureFeedListDataAccess(data, config);
330
- if (!data) {
331
- data = operatorData;
332
- ownsData = true;
333
- }
334
- return await handleFeedCommand({
335
- parsed,
336
- json,
337
- stdout,
338
- data: operatorData,
339
- });
340
- }
341
314
  if (command === "dashboard") {
342
315
  const { handleWatchCommand } = await import("./commands/watch.js");
343
316
  return await handleWatchCommand({ config, parsed });
@@ -362,10 +335,6 @@ async function createCliDataAccess(config) {
362
335
  const { CliDataAccess } = await import("./data.js");
363
336
  return new CliDataAccess(config);
364
337
  }
365
- async function createCliOperatorDataAccess(config) {
366
- const { CliOperatorApiClient } = await import("./operator-client.js");
367
- return new CliOperatorApiClient(config);
368
- }
369
338
  async function ensureIssueDataAccess(data, config) {
370
339
  if (data) {
371
340
  if (isIssueDataAccess(data)) {
@@ -378,27 +347,3 @@ async function ensureIssueDataAccess(data, config) {
378
347
  function isIssueDataAccess(data) {
379
348
  return !!data && typeof data === "object" && "inspect" in data && typeof data.inspect === "function";
380
349
  }
381
- async function ensureFeedListDataAccess(data, config) {
382
- if (data) {
383
- if (hasFeedListDataAccess(data)) {
384
- return data;
385
- }
386
- throw new Error("The feed command requires listOperatorFeed() data access.");
387
- }
388
- return await createCliOperatorDataAccess(config);
389
- }
390
- async function ensureFeedFollowDataAccess(data, config) {
391
- if (data) {
392
- if (hasFeedFollowDataAccess(data)) {
393
- return data;
394
- }
395
- throw new Error("The feed --follow command requires followOperatorFeed() data access.");
396
- }
397
- return await createCliOperatorDataAccess(config);
398
- }
399
- function hasFeedListDataAccess(data) {
400
- return !!data && typeof data === "object" && "listOperatorFeed" in data && typeof data.listOperatorFeed === "function";
401
- }
402
- function hasFeedFollowDataAccess(data) {
403
- return !!data && typeof data === "object" && "followOperatorFeed" in data && typeof data.followOperatorFeed === "function";
404
- }
@@ -29,88 +29,6 @@ export class CliOperatorApiClient {
29
29
  async disconnectLinearWorkspace(workspace) {
30
30
  return await this.requestJson(`/api/linear/workspaces/${encodeURIComponent(workspace)}`, undefined, { method: "DELETE" });
31
31
  }
32
- async listOperatorFeed(options) {
33
- return await this.requestJson("/api/feed", {
34
- ...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
35
- ...(options?.issueKey ? { issue: options.issueKey } : {}),
36
- ...(options?.projectId ? { project: options.projectId } : {}),
37
- ...(options?.kind ? { kind: options.kind } : {}),
38
- ...(options?.stage ? { stage: options.stage } : {}),
39
- ...(options?.status ? { status: options.status } : {}),
40
- ...(options?.workflowId ? { workflow: options.workflowId } : {}),
41
- });
42
- }
43
- async followOperatorFeed(onEvent, options) {
44
- const url = new URL("/api/feed", this.getOperatorBaseUrl());
45
- url.searchParams.set("follow", "1");
46
- if (options?.limit && options.limit > 0) {
47
- url.searchParams.set("limit", String(options.limit));
48
- }
49
- if (options?.issueKey) {
50
- url.searchParams.set("issue", options.issueKey);
51
- }
52
- if (options?.projectId) {
53
- url.searchParams.set("project", options.projectId);
54
- }
55
- if (options?.kind) {
56
- url.searchParams.set("kind", options.kind);
57
- }
58
- if (options?.stage) {
59
- url.searchParams.set("stage", options.stage);
60
- }
61
- if (options?.status) {
62
- url.searchParams.set("status", options.status);
63
- }
64
- if (options?.workflowId) {
65
- url.searchParams.set("workflow", options.workflowId);
66
- }
67
- const response = await fetch(url, {
68
- method: "GET",
69
- headers: {
70
- accept: "text/event-stream",
71
- ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
72
- },
73
- });
74
- if (!response.ok || !response.body) {
75
- const body = await response.text().catch(() => "");
76
- const message = this.readErrorMessage(body);
77
- throw new Error(message ?? `Request failed: ${response.status}`);
78
- }
79
- const reader = response.body.getReader();
80
- const decoder = new TextDecoder();
81
- let buffer = "";
82
- let dataLines = [];
83
- while (true) {
84
- const { done, value } = await reader.read();
85
- if (done) {
86
- break;
87
- }
88
- buffer += decoder.decode(value, { stream: true });
89
- let newlineIndex = buffer.indexOf("\n");
90
- while (newlineIndex !== -1) {
91
- const rawLine = buffer.slice(0, newlineIndex);
92
- buffer = buffer.slice(newlineIndex + 1);
93
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
94
- if (!line) {
95
- if (dataLines.length > 0) {
96
- const parsed = JSON.parse(dataLines.join("\n"));
97
- onEvent(parsed);
98
- dataLines = [];
99
- }
100
- newlineIndex = buffer.indexOf("\n");
101
- continue;
102
- }
103
- if (line.startsWith(":")) {
104
- newlineIndex = buffer.indexOf("\n");
105
- continue;
106
- }
107
- if (line.startsWith("data:")) {
108
- dataLines.push(line.slice(5).trimStart());
109
- }
110
- newlineIndex = buffer.indexOf("\n");
111
- }
112
- }
113
- }
114
32
  getOperatorBaseUrl() {
115
33
  const host = this.normalizeLocalHost(this.config.server.bind);
116
34
  return `http://${host}:${this.config.server.port}/`;
@@ -4,10 +4,8 @@ import { Box, Text, useApp, useInput } from "ink";
4
4
  import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
5
5
  import { useWatchStream } from "./use-watch-stream.js";
6
6
  import { useDetailStream } from "./use-detail-stream.js";
7
- import { useFeedStream } from "./use-feed-stream.js";
8
7
  import { IssueListView } from "./IssueListView.js";
9
8
  import { IssueDetailView } from "./IssueDetailView.js";
10
- import { FeedView } from "./FeedView.js";
11
9
  async function postPrompt(baseUrl, issueKey, text, bearerToken) {
12
10
  const headers = { "content-type": "application/json" };
13
11
  if (bearerToken)
@@ -71,7 +69,6 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
71
69
  const [frozen, setFrozen] = useState(false);
72
70
  useWatchStream({ baseUrl, bearerToken, dispatch, active: !frozen });
73
71
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch, active: !frozen });
74
- useFeedStream({ baseUrl, bearerToken, active: state.view === "feed" && !frozen, dispatch });
75
72
  const [promptMode, setPromptMode] = useState(false);
76
73
  const [promptBuffer, setPromptBuffer] = useState("");
77
74
  const handleRetry = useCallback(() => {
@@ -154,9 +151,6 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
154
151
  else if (key.tab) {
155
152
  dispatch({ type: "cycle-filter" });
156
153
  }
157
- else if (input === "F" || input === "f") {
158
- dispatch({ type: "enter-feed" });
159
- }
160
154
  }
161
155
  else if (state.view === "detail") {
162
156
  if (key.escape || key.backspace || key.delete) {
@@ -187,17 +181,32 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
187
181
  dispatch({ type: "switch-detail-tab", tab: "timeline" });
188
182
  }
189
183
  else if (input === "j" || key.downArrow) {
190
- dispatch({ type: "detail-navigate", direction: "next", filtered });
184
+ dispatch({ type: "detail-scroll", delta: 1 });
191
185
  }
192
186
  else if (input === "k" || key.upArrow) {
187
+ dispatch({ type: "detail-scroll", delta: -1 });
188
+ }
189
+ else if (key.pageDown || (key.ctrl && input === "d")) {
190
+ dispatch({ type: "detail-page", direction: "down" });
191
+ }
192
+ else if (key.pageUp || (key.ctrl && input === "u")) {
193
+ dispatch({ type: "detail-page", direction: "up" });
194
+ }
195
+ else if (key.home) {
196
+ dispatch({ type: "detail-jump", target: "start" });
197
+ }
198
+ else if (key.end) {
199
+ dispatch({ type: "detail-jump", target: "end" });
200
+ }
201
+ else if (input === "[" || key.leftArrow) {
193
202
  dispatch({ type: "detail-navigate", direction: "prev", filtered });
194
203
  }
195
- }
196
- else if (state.view === "feed") {
197
- if (key.escape || key.backspace || key.delete) {
198
- dispatch({ type: "exit-feed" });
204
+ else if (input === "]" || key.rightArrow) {
205
+ dispatch({ type: "detail-navigate", direction: "next", filtered });
199
206
  }
200
207
  }
201
208
  });
202
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: "Operator Feed" })] }), _jsx(FeedView, { events: state.feedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt })] })) }));
209
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, unreadBelow: state.detailUnreadBelow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, reservedRows: 1 + ((promptMode || promptStatus) ? 1 : 0), onLayoutChange: (viewportRows, contentRows) => {
210
+ dispatch({ type: "detail-layout-updated", viewportRows, contentRows });
211
+ } }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : null }));
203
212
  }
@@ -4,15 +4,15 @@ export function HelpBar({ view, follow, detailTab }) {
4
4
  let text;
5
5
  if (view === "detail") {
6
6
  const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
7
- text = [tabHint, `f: follow ${follow ? "on" : "off"}`, "p: prompt", "s: stop", "r: retry"]
7
+ text = [tabHint, "j/k: scroll", "PgUp/PgDn: page", "[ ]: issue", "Home/End: jump", `f: live ${follow ? "on" : "off"}`, "p: prompt", "s: stop", "r: retry"]
8
8
  .filter(Boolean)
9
9
  .join(" ");
10
10
  }
11
11
  else if (view === "feed") {
12
- text = "";
12
+ text = "Legacy feed view Esc: back";
13
13
  }
14
14
  else {
15
- text = "F: feed Tab: filter";
15
+ text = "Enter: detail Tab: filter";
16
16
  }
17
17
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
18
18
  }