parallel-codex-tui 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,23 @@ A standalone TypeScript TUI wrapper for routed parallel coding workflows. It kee
4
4
 
5
5
  Built with Codex-assisted development.
6
6
 
7
+ ## Current Release
8
+
9
+ `v0.1.7` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.1.7) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.1.7). It keeps terminal scrolling and copying available at the same time without requiring Shift: the outer TUI explicitly disables application mouse tracking, enters the alternate screen, and enables alternate scroll. Kitty and Zellij therefore keep ordinary left-drag selection while translating the wheel into cursor-scroll input for the active TUI view. `Ctrl+Y` remains an additional fallback that copies the visible chat, rendered Worker log, native Agent screen, or structured overview without changing terminal modes. Unicode text and Diff indentation are preserved.
10
+
11
+ The release also includes the stability, collaboration, session-management, Provider, TUI, and release work completed in `v0.1.5`:
12
+
13
+ - Runtime preflight checks Codex, Claude, named third-party Providers, proxy reachability, workspace access, CLI capabilities, and native attach policy before work starts.
14
+ - Judge produces validated requirements, acceptance criteria, and a dependency-aware Feature plan. Actor/Critic pairs exchange checked JSONL findings and replies across revision rounds, followed by Wave Critic and final Judge acceptance.
15
+ - Tasks retain every Turn, Worker log, native session binding, model snapshot, and collaboration artifact across follow-ups and restarts. Sessions can be inspected, continued, forked, renamed, archived, exported, restored, or deleted from the TUI.
16
+ - Named Worker Providers support Codex-compatible, Claude-compatible, OpenAI-compatible, Anthropic-compatible, and custom generic commands with independent role, model, environment, permission, resume, and interactive settings.
17
+ - Worker overview, Feature board, collaboration timeline, status details, rendered Markdown/Diff/error logs, Unicode search, keyboard navigation, mouse scrolling, and configurable themes share one terminal UI system.
18
+ - SQLite migrations and recovery backups, owned-process cleanup, ten-turn Worker-history recovery, packaged CLI installation, and cross-platform CI are covered by automated tests.
19
+
20
+ Release acceptance included a real three-Feature Tetris task with parallel Actor/Critic waves, final integration review, 43 project tests, and a clean build. The repository suite passed 1,241 tests across 119 files, including PTY coverage for alternate-scroll delivery with every application mouse mode disabled. A local Zellij 0.44 probe confirmed that one wheel event becomes three cursor-up events while a left-button drag sends no mouse bytes to the app. The npm package was independently installed and returned `parallel-codex-tui 0.1.7`.
21
+
22
+ Real Provider probes still depend on valid local CLI credentials. In particular, authenticate the Claude CLI before selecting a Claude-compatible Worker, then run `parallel-codex-tui --doctor --probe-agents` to prove fresh and resumed calls on that machine.
23
+
7
24
  ## Requirements
8
25
 
9
26
  - Node.js 24.15+.
@@ -165,7 +182,7 @@ The doctor output includes `preview:` and `semantic:` ANSI swatch rows so you ca
165
182
  - Simple requests stay in the main TUI flow and do not create Judge, Actor, or Critic workers.
166
183
  - Consecutive simple requests reuse the main worker's native session across app restarts when the CLI exposes a session id.
167
184
  - The workspace chat transcript is appended to `.parallel-codex/sessions/main/chat.jsonl`; startup restores the latest 200 valid messages and skips isolated corrupt rows. The shared tail reader reads JSONL backward in bounded chunks, so a long-running chat does not require loading its lifetime transcript and a partial or oversized final row cannot hide earlier valid messages.
168
- - Chat drafts support Unicode-safe Left/Right, Home/End, Backspace, and Delete editing; Up/Down recalls persisted user requests and returns to the exact unsent draft. Long input stays on one row with the visible window centered around the logical cursor.
185
+ - Chat drafts support Unicode-safe Left/Right, Home/End, Backspace, and Delete editing; Ctrl+Up/Down recalls persisted user requests and returns to the exact unsent draft, while plain Up/Down remains available to alternate-scroll wheel input. Long input stays on one row with the visible window centered around the logical cursor.
169
186
  - Bracketed multiline paste stays in one draft instead of submitting the first line; logical line breaks and tabs appear as `↵` and `⇥` in the single-row input until the complete request is submitted.
170
187
  - Complex requests create a session under `.parallel-codex/sessions/`.
171
188
  - Task lifecycle is a checked state machine. A task enters `routed` only after the turn and route files are durable, and enters `ready_for_pair` only after Judge artifacts validate; invalid phase skips are rejected before metadata or event projections change. Persisted follow-up turns that still carry an older `done` state are recovered as cancelled and retryable on startup instead of being hidden as completed.
@@ -412,7 +429,7 @@ Keep `[router.codex]` on `read-only`; routing only classifies requests and does
412
429
 
413
430
  The process adapter sends each role prompt to stdin and records stdout/stderr in `output.log`. Worker success is separate from the operating-system exit code: a total, first-output, idle, or stdin watchdog failure remains failed even when the terminated CLI handles `SIGTERM` and exits with code `0`, so the next role never starts from a timed-out checkpoint. `firstOutputTimeoutMs` owns silent startup, `idleTimeoutMs` starts only after real stdout/stderr activity, and `timeoutMs` remains the hard ceiling; equal or longer secondary watchdogs do not race the total deadline.
414
431
 
415
- In chat, scroll long conversation history with the mouse wheel or PageUp/PageDown; sending a new message returns to the latest reply. After a complex task completes, use the same controls to move through its structured result and press `Ctrl+D` to toggle details. Press `Ctrl+N` to preserve the current session and start a new task, `Ctrl+W` to open worker logs, and `Tab` to cycle the selected worker when workers exist. `Ctrl+S` opens the same status detail view from chat, logs, Worker overview, or an embedded native session; it keeps the footer compact while exposing the complete route, reason, selected Provider/model, phase, activity timestamp, and native session. Press `Ctrl+S` or `Esc` to return without interrupting the underlying Worker. While a request is running, press `Esc` to stop it. After a failed or cancelled task, press `Ctrl+R` to retry the same turn or `Ctrl+N` to start independently. In worker-log views, scroll with the mouse wheel or PageUp/PageDown, press `Tab` to cycle workers, `Ctrl+N` to start a new task, and `Esc` to return to chat.
432
+ In chat, scroll long conversation history with the mouse wheel, plain Up/Down, or PageUp/PageDown; sending a new message returns to the latest reply. The outer TUI never enables application mouse tracking, so ordinary left-drag selection and system copy work without Shift while alternate-scroll keeps the wheel active in chat and Worker logs. Use Ctrl+Up/Down for persisted request history. `Ctrl+Y` also copies the current visible chat, rendered Worker log, native Agent screen, or structured overview without changing terminal modes. macOS uses `pbcopy`; Linux uses `wl-copy`, `xclip`, or `xsel` when available and falls back to OSC 52. After a complex task completes, use the same controls to move through its structured result and press `Ctrl+D` to toggle details. Press `Ctrl+N` to preserve the current session and start a new task, `Ctrl+W` to open worker logs, and `Tab` to cycle the selected worker when workers exist. `Ctrl+S` opens the same status detail view from chat, logs, Worker overview, or an embedded native session; it keeps the footer compact while exposing the complete route, reason, selected Provider/model, phase, activity timestamp, and native session. Press `Ctrl+S` or `Esc` to return without interrupting the underlying Worker. While a request is running, press `Esc` to stop it. After a failed or cancelled task, press `Ctrl+R` to retry the same turn or `Ctrl+N` to start independently. In worker-log views, scroll with the mouse wheel, Up/Down, or PageUp/PageDown, press `Tab` to cycle workers, `Ctrl+N` to start a new task, and `Esc` to return to chat.
416
433
 
417
434
  `Ctrl+B` opens a live Worker overview without replacing the `Ctrl+W` log shortcut. It summarizes every Judge, Actor, and Critic by turn, role, named Provider, persisted model, state, phase, latest summary, and native-session availability. The selected active worker gets a live activity line showing its first-output deadline while starting and its idle deadline after output begins. The line stays muted while healthy, changes to warning for the final 20% and danger once overdue. Up/Down, PageUp/PageDown, the mouse wheel, or `Tab` changes the selected worker; Enter or `Ctrl+W` opens its rendered log, `Ctrl+O` attaches to its native session, and `Esc` returns to the originating chat or log view. Router diagnostics and the project picker also return to the overview when opened from there.
418
435
 
@@ -434,12 +451,12 @@ If a native resume fails because the underlying CLI reports that its context win
434
451
 
435
452
  ## Release
436
453
 
437
- GitHub Actions runs CI on Ubuntu with Node.js 24.15 and 26, plus macOS with Node.js 26, for pushes and pull requests to `main`. Every job also packs the release artifact, installs that tarball into a clean global prefix, and executes the installed `--version` and `--help` entrypoints.
454
+ GitHub Actions runs CI on Ubuntu with Node.js 24.15 and 26, plus macOS with Node.js 24.15, for pushes and pull requests to `main`. Every job also packs the release artifact, installs that tarball into a clean global prefix, and executes the installed `--version` and `--help` entrypoints.
438
455
 
439
456
  Releases publish to npm through npm Trusted Publishing with GitHub OIDC. Do not configure `NPM_TOKEN` for the release workflow. In npm, configure Trusted Publishing for organization/user `allendred`, repository `parallel-codex-tui`, workflow filename `release.yml`, and allowed action `npm publish`. The package already exists on npm, so future releases can use Trusted Publishing directly. You can also configure the trust relationship from an authenticated npm CLI session:
440
457
 
441
458
  ```bash
442
- npm install -g npm@^11.15.0
459
+ npm install -g npm@^11.5.1
443
460
  npm trust github parallel-codex-tui --repo allendred/parallel-codex-tui --file release.yml --allow-publish --dry-run
444
461
  npm trust github parallel-codex-tui --repo allendred/parallel-codex-tui --file release.yml --allow-publish --yes
445
462
  ```
@@ -451,11 +468,12 @@ The release job installs npm `^11.5.1`, runs on Node `24.15.x`, publishes the pr
451
468
  To publish a release, update `package.json` and `src/version.ts` to the same version, then push a matching tag:
452
469
 
453
470
  ```bash
454
- git tag v0.1.5
455
- git push origin v0.1.5
471
+ VERSION=0.1.7
472
+ git tag "v$VERSION"
473
+ git push origin "v$VERSION"
456
474
  ```
457
475
 
458
- You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.1.5` requires tag `v0.1.5`.
476
+ You can also run the Release workflow manually and enter the same tag value. The release tag must match `package.json`; for example, package version `0.1.7` requires tag `v0.1.7`. Published tags such as `v0.1.6` are immutable and must not be moved or reused.
459
477
 
460
478
  ## Publishing Hygiene
461
479
 
package/dist/cli.js CHANGED
@@ -158,12 +158,14 @@ async function main() {
158
158
  taskId
159
159
  }) }, state.runtime.workspaceRoot));
160
160
  const removeSigintHandler = installInteractiveSigintExitHandler(() => shutdownController.abort());
161
+ enterInteractiveTerminal();
161
162
  try {
162
163
  instance = render(appElement(current), { exitOnCtrlC: false });
163
164
  await instance.waitUntilExit();
164
165
  }
165
166
  finally {
166
167
  removeSigintHandler();
168
+ restoreInteractiveTerminal();
167
169
  retryDeferredWorkspaceClosures(deferredWorkspaceClosures);
168
170
  }
169
171
  }
@@ -291,7 +293,12 @@ function restoreInteractiveTerminal() {
291
293
  }
292
294
  process.stdin.pause();
293
295
  if (process.stdout.isTTY) {
294
- process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h");
296
+ process.stdout.write("\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007l\x1b[?1049l\x1b[?2004l\x1b[?25h");
297
+ }
298
+ }
299
+ function enterInteractiveTerminal() {
300
+ if (process.stdout.isTTY) {
301
+ process.stdout.write("\x1b[?1049h\x1b[?1007h\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l");
295
302
  }
296
303
  }
297
304
  function formatStartupError(error) {
@@ -0,0 +1,97 @@
1
+ import { spawn } from "node:child_process";
2
+ const MAX_CLIPBOARD_CHARACTERS = 200_000;
3
+ export async function copyTextToClipboard(text, options = {}) {
4
+ const value = normalizeClipboardText(text);
5
+ if (!value) {
6
+ throw new Error("No visible text to copy");
7
+ }
8
+ const platform = options.platform ?? process.platform;
9
+ const env = options.env ?? process.env;
10
+ const runCommand = options.runCommand ?? runClipboardCommand;
11
+ for (const candidate of clipboardCommands(platform, env)) {
12
+ try {
13
+ await runCommand(candidate.command, candidate.args, value);
14
+ return { method: candidate.method, characters: Array.from(value).length };
15
+ }
16
+ catch {
17
+ // Try the next local clipboard integration before falling back to OSC 52.
18
+ }
19
+ }
20
+ const writeTerminal = options.writeTerminal ?? ((sequence) => {
21
+ process.stdout.write(sequence);
22
+ });
23
+ writeTerminal(osc52ClipboardSequence(value));
24
+ return { method: "osc52", characters: Array.from(value).length };
25
+ }
26
+ export function normalizeClipboardText(text) {
27
+ const lines = text
28
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
29
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
30
+ .replace(/\r\n?/g, "\n")
31
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
32
+ .split("\n")
33
+ .map((line) => line.trimEnd());
34
+ while (lines[0] === "") {
35
+ lines.shift();
36
+ }
37
+ while (lines.at(-1) === "") {
38
+ lines.pop();
39
+ }
40
+ const clean = lines.join("\n");
41
+ return Array.from(clean).slice(0, MAX_CLIPBOARD_CHARACTERS).join("");
42
+ }
43
+ export function osc52ClipboardSequence(text) {
44
+ return `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
45
+ }
46
+ function clipboardCommands(platform, env) {
47
+ if (platform === "darwin") {
48
+ return [{ method: "pbcopy", command: "/usr/bin/pbcopy", args: [] }];
49
+ }
50
+ if (platform !== "linux") {
51
+ return [];
52
+ }
53
+ return [
54
+ ...(env.WAYLAND_DISPLAY
55
+ ? [{ method: "wl-copy", command: "wl-copy", args: [] }]
56
+ : []),
57
+ ...(env.DISPLAY
58
+ ? [
59
+ { method: "xclip", command: "xclip", args: ["-selection", "clipboard"] },
60
+ { method: "xsel", command: "xsel", args: ["--clipboard", "--input"] }
61
+ ]
62
+ : [])
63
+ ];
64
+ }
65
+ function runClipboardCommand(command, args, text) {
66
+ return new Promise((resolve, reject) => {
67
+ const child = spawn(command, args, {
68
+ env: process.env,
69
+ stdio: ["pipe", "ignore", "pipe"]
70
+ });
71
+ let stderr = "";
72
+ let settled = false;
73
+ const finish = (error) => {
74
+ if (settled) {
75
+ return;
76
+ }
77
+ settled = true;
78
+ if (error) {
79
+ reject(error);
80
+ }
81
+ else {
82
+ resolve();
83
+ }
84
+ };
85
+ child.stderr?.on("data", (chunk) => {
86
+ stderr += String(chunk);
87
+ });
88
+ child.on("error", (error) => finish(error));
89
+ child.on("close", (code) => {
90
+ finish(code === 0
91
+ ? undefined
92
+ : new Error(`${command} exited ${code ?? "without a code"}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
93
+ });
94
+ child.stdin?.on("error", (error) => finish(error));
95
+ child.stdin?.end(text);
96
+ });
97
+ }
package/dist/tui/App.js CHANGED
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { basename } from "node:path";
4
4
  import { Box, Text, useApp, useInput, useStdin } from "ink";
5
5
  import { Lexer } from "marked";
6
+ import { copyTextToClipboard } from "../core/clipboard.js";
6
7
  import { readJson } from "../core/file-store.js";
7
8
  import { WorkerStatusSchema } from "../domain/schemas.js";
8
9
  import { effectiveWorkerWatchdog, formatRoutePendingStatus, formatRoutePendingSummaryStatus, formatRouteStatus, formatRouteSummaryStatus, formatStatusLine, formatWorkerRuntimeStatus } from "./status-line.js";
@@ -18,7 +19,7 @@ import { TerminalOutput } from "./TerminalOutput.js";
18
19
  import { NativeTerminalScreen } from "./terminal-screen.js";
19
20
  import { WorkerOutputView } from "./WorkerOutputView.js";
20
21
  import { compactEndByDisplayWidth, displayWidth, wrapByDisplayWidth } from "./display-width.js";
21
- import { isAttachShortcut, isExitShortcut, isLogsShortcut, isNewTaskShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawHistoryDelta, rawPageScrollDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
22
+ import { isAttachShortcut, isCopyShortcut, isExitShortcut, isLogsShortcut, isNewTaskShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawHistoryDelta, rawPageScrollDelta, rawPlainArrowDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
22
23
  import { createRawInputDecoder, tokenizeRawInput } from "./raw-input-decoder.js";
23
24
  import { decodeHtmlEntities } from "./markdown-text.js";
24
25
  import { configureTuiTheme, TUI_THEME } from "./theme.js";
@@ -47,7 +48,7 @@ const EMPTY_WORKER_NAVIGATION_TARGETS = {
47
48
  errorOffsets: [],
48
49
  diffOffsets: []
49
50
  };
50
- export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, prepareNativeFork, startNativeAttach, shutdownSignal }) {
51
+ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
51
52
  configureTuiTheme({
52
53
  theme: config.ui.theme,
53
54
  colors: config.ui.colors
@@ -73,6 +74,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
73
74
  const [activeMode, setActiveMode] = useState(initialTaskId ? "complex" : null);
74
75
  const [canRetryTask, setCanRetryTask] = useState(initialCanRetryTask);
75
76
  const [attachError, setAttachError] = useState(null);
77
+ const [clipboardNotice, setClipboardNotice] = useState(null);
76
78
  const [nativeInput, setNativeInput] = useState("");
77
79
  const [workerScrollOffset, setWorkerScrollOffset] = useState(0);
78
80
  const [workerSearch, setWorkerSearch] = useState({
@@ -120,6 +122,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
120
122
  const mountedRef = useRef(true);
121
123
  const nativeAttachRef = useRef(nativeAttach);
122
124
  const nativeAttachRequestSequenceRef = useRef(0);
125
+ const clipboardNoticeTimerRef = useRef(null);
126
+ const copyTextByViewRef = useRef({ chat: "", worker: "" });
123
127
  const messagesRef = useRef([...initialMessages]);
124
128
  const activeRunControllerRef = useRef(null);
125
129
  const activeTaskIdRef = useRef(initialTaskId);
@@ -180,6 +184,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
180
184
  const openSelectedTaskSessionDetailsRef = useRef(openSelectedTaskSessionDetails);
181
185
  const refreshTaskSessionDetailsRef = useRef(refreshTaskSessionDetails);
182
186
  const openTaskSessionDetailWorkerRef = useRef(openTaskSessionDetailWorker);
187
+ const copyVisibleViewRef = useRef(copyVisibleView);
183
188
  const workspaceReturnViewRef = useRef("chat");
184
189
  const routerReturnViewRef = useRef("chat");
185
190
  const workerOverviewReturnViewRef = useRef("chat");
@@ -366,6 +371,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
366
371
  openSelectedTaskSessionDetailsRef.current = openSelectedTaskSessionDetails;
367
372
  refreshTaskSessionDetailsRef.current = refreshTaskSessionDetails;
368
373
  openTaskSessionDetailWorkerRef.current = openTaskSessionDetailWorker;
374
+ copyVisibleViewRef.current = copyVisibleView;
369
375
  });
370
376
  useEffect(() => {
371
377
  if ((view !== "collaboration" && view !== "features") || !activeTaskId || !loadCollaborationTimeline) {
@@ -585,7 +591,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
585
591
  }, [config.ui.autoOpenFailedWorker, status?.taskId, workerActivityPolicies, workerRefreshKey]);
586
592
  useEffect(() => {
587
593
  setRawMode(true);
588
- process.stdout.write("\x1b[?2004h\x1b[?1000h\x1b[?1002h\x1b[?1006h");
594
+ process.stdout.write("\x1b[?2004h\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l");
589
595
  const commitChatInputUpdate = (update, previousValue, previousCursor) => {
590
596
  if (update.exit) {
591
597
  activeRunControllerRef.current?.abort();
@@ -682,6 +688,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
682
688
  return;
683
689
  }
684
690
  const currentView = viewRef.current;
691
+ if (currentView !== "workspace" && tokenizeRawInput(chunk).some((inputChunk) => isCopyShortcut(inputChunk, {}))) {
692
+ void copyVisibleViewRef.current();
693
+ return;
694
+ }
685
695
  if (currentView === "chat" && routeFallbackPromptRef.current) {
686
696
  const fallbackChunks = tokenizeRawInput(chunk);
687
697
  if (fallbackChunks.some((fallbackChunk) => isExitShortcut(fallbackChunk, {}))) {
@@ -1057,6 +1067,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1057
1067
  }
1058
1068
  if (collaborationDetailOpenRef.current) {
1059
1069
  const detailDelta = mouseScrollDelta(timelineChunk, 3)
1070
+ + rawPlainArrowDelta(timelineChunk)
1060
1071
  + rawPageScrollDelta(timelineChunk, Math.max(1, outputHeight - 3));
1061
1072
  if (detailDelta !== 0) {
1062
1073
  setCollaborationScrollOffset((current) => (nextScrollOffset(current, -detailDelta, collaborationMaxScrollOffsetRef.current)));
@@ -1223,6 +1234,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1223
1234
  return;
1224
1235
  }
1225
1236
  const routeDelta = mouseScrollDelta(routerChunk, 3)
1237
+ + rawPlainArrowDelta(routerChunk)
1226
1238
  + rawPageScrollDelta(routerChunk, Math.max(1, outputHeight - 1));
1227
1239
  if (routeDelta !== 0) {
1228
1240
  setRouterScrollOffset((current) => (nextScrollOffset(current, -routeDelta, routerMaxScrollOffsetRef.current)));
@@ -1298,7 +1310,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1298
1310
  setView("chat");
1299
1311
  return;
1300
1312
  }
1301
- const delta = mouseScrollDelta(workerChunk, 3);
1313
+ const delta = mouseScrollDelta(workerChunk, 3) + rawPlainArrowDelta(workerChunk);
1302
1314
  if (delta !== 0) {
1303
1315
  setWorkerScrollOffset((current) => nextScrollOffset(current, delta, null));
1304
1316
  }
@@ -1364,8 +1376,9 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1364
1376
  return;
1365
1377
  }
1366
1378
  const wheelDelta = mouseScrollDelta(chunk, 3);
1379
+ const arrowScrollDelta = rawPlainArrowDelta(chunk);
1367
1380
  const pageDelta = rawPageScrollDelta(chunk, Math.max(1, outputHeight - 1));
1368
- const historyDelta = wheelDelta + pageDelta;
1381
+ const historyDelta = wheelDelta + arrowScrollDelta + pageDelta;
1369
1382
  if (historyDelta !== 0 && chatMaxScrollOffsetRef.current > 0) {
1370
1383
  setChatScrollOffset((current) => {
1371
1384
  const next = nextScrollOffset(current, taskResultExpandedRef.current ? -historyDelta : historyDelta, chatMaxScrollOffsetRef.current);
@@ -1414,7 +1427,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1414
1427
  void retryRef.current();
1415
1428
  return;
1416
1429
  }
1417
- const draftHistoryDelta = rawHistoryDelta(chunk);
1430
+ const draftHistoryDelta = rawHistoryDelta(chunk) - arrowScrollDelta;
1418
1431
  if (draftHistoryDelta !== 0) {
1419
1432
  if (busyRef.current) {
1420
1433
  return;
@@ -1477,7 +1490,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1477
1490
  stdinEvents.removeListener("input", handleRawInput);
1478
1491
  rawInputDecoderRef.current.end();
1479
1492
  chatPasteDecoderRef.current.reset();
1480
- process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?2004l");
1493
+ process.stdout.write("\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l");
1481
1494
  setRawMode(false);
1482
1495
  };
1483
1496
  }, [outputHeight, setRawMode, stdinEvents]);
@@ -1492,6 +1505,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1492
1505
  collaborationLoadSequenceRef.current += 1;
1493
1506
  routerLoadSequenceRef.current += 1;
1494
1507
  taskSessionsLoadSequenceRef.current += 1;
1508
+ if (clipboardNoticeTimerRef.current) {
1509
+ clearTimeout(clipboardNoticeTimerRef.current);
1510
+ clipboardNoticeTimerRef.current = null;
1511
+ }
1495
1512
  };
1496
1513
  }, []);
1497
1514
  useEffect(() => {
@@ -1509,6 +1526,93 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1509
1526
  shutdownSignal.addEventListener("abort", shutdown, { once: true });
1510
1527
  return () => shutdownSignal.removeEventListener("abort", shutdown);
1511
1528
  }, [shutdownSignal]);
1529
+ async function copyVisibleView() {
1530
+ const payload = visibleClipboardPayload(viewRef.current);
1531
+ if (!payload?.text.trim()) {
1532
+ setAttachError("No visible text to copy in this view");
1533
+ return;
1534
+ }
1535
+ if (clipboardNoticeTimerRef.current) {
1536
+ clearTimeout(clipboardNoticeTimerRef.current);
1537
+ clipboardNoticeTimerRef.current = null;
1538
+ }
1539
+ setAttachError(null);
1540
+ setClipboardNotice({ state: "copying", text: `copying visible ${payload.label}` });
1541
+ try {
1542
+ await copyToClipboard(payload.text);
1543
+ setClipboardNotice({ state: "copied", text: `copied visible ${payload.label} · wheel still active` });
1544
+ clipboardNoticeTimerRef.current = setTimeout(() => {
1545
+ clipboardNoticeTimerRef.current = null;
1546
+ setClipboardNotice(null);
1547
+ }, 1800);
1548
+ }
1549
+ catch (error) {
1550
+ setClipboardNotice(null);
1551
+ setAttachError(`Copy failed · ${error instanceof Error ? error.message : String(error)}`);
1552
+ }
1553
+ }
1554
+ function visibleClipboardPayload(currentView) {
1555
+ if (currentView === "chat") {
1556
+ return { label: "chat", text: copyTextByViewRef.current.chat };
1557
+ }
1558
+ if (currentView === "worker") {
1559
+ return { label: "logs", text: copyTextByViewRef.current.worker };
1560
+ }
1561
+ if (currentView === "native") {
1562
+ return { label: "native output", text: nativeAttachRef.current?.screen.snapshot() ?? "" };
1563
+ }
1564
+ if (currentView === "status") {
1565
+ return {
1566
+ label: "status",
1567
+ text: [cwd, activeTaskIdRef.current, visibleTaskStatus, visibleRouteSummary]
1568
+ .filter(Boolean)
1569
+ .join("\n")
1570
+ };
1571
+ }
1572
+ if (currentView === "workers") {
1573
+ return {
1574
+ label: "workers",
1575
+ text: workersRef.current.map((worker) => [
1576
+ worker.label,
1577
+ worker.runtimeStatus?.state,
1578
+ worker.runtimeStatus?.phase,
1579
+ worker.runtimeStatus?.summary
1580
+ ].filter(Boolean).join(" · ")).join("\n")
1581
+ };
1582
+ }
1583
+ if (currentView === "features") {
1584
+ return {
1585
+ label: "features",
1586
+ text: (collaborationTimelineRef.current?.features ?? []).map((feature) => [
1587
+ feature.id,
1588
+ feature.title,
1589
+ feature.state
1590
+ ].filter(Boolean).join(" · ")).join("\n")
1591
+ };
1592
+ }
1593
+ if (currentView === "sessions") {
1594
+ const details = taskSessionDetailsRef.current;
1595
+ return {
1596
+ label: "sessions",
1597
+ text: details
1598
+ ? details.workers.map((worker) => [worker.id, worker.role, worker.engine, worker.state].join(" · ")).join("\n")
1599
+ : taskSessionsRef.current.map((task) => [task.id, task.title, task.status].join(" · ")).join("\n")
1600
+ };
1601
+ }
1602
+ if (currentView === "router") {
1603
+ return { label: "routes", text: routerRecords.map((record) => JSON.stringify(record)).join("\n") };
1604
+ }
1605
+ if (currentView === "collaboration") {
1606
+ const timeline = collaborationTimelineRef.current;
1607
+ return {
1608
+ label: "timeline",
1609
+ text: timeline
1610
+ ? collaborationTimelineEvents(timeline, collaborationFeatureIndexRef.current, collaborationUnresolvedOnlyRef.current).map((event) => JSON.stringify(event)).join("\n")
1611
+ : ""
1612
+ };
1613
+ }
1614
+ return null;
1615
+ }
1512
1616
  useInput((inputKey, key) => {
1513
1617
  if (view === "worker") {
1514
1618
  if (isExitShortcut(inputKey, key)) {
@@ -2536,7 +2640,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2536
2640
  ? { type: "rename", value: taskSessionAction.value, cursor: taskSessionAction.cursor }
2537
2641
  : taskSessionAction?.type === "delete"
2538
2642
  ? { type: "delete", title: taskSessionAction.title }
2539
- : null, taskSessionsIncludeArchived: taskSessionsIncludeArchived, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, value: workerSearch.open && view === "worker"
2643
+ : null, taskSessionsIncludeArchived: taskSessionsIncludeArchived, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, clipboardNotice: clipboardNotice, value: workerSearch.open && view === "worker"
2540
2644
  ? workerSearch.query
2541
2645
  : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" ? "" : input, cursor: workerSearch.open && view === "worker"
2542
2646
  ? workerSearch.cursor
@@ -2563,7 +2667,11 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2563
2667
  chatScrollOffsetRef.current = offset;
2564
2668
  setChatScrollOffset(offset);
2565
2669
  }
2566
- } })) : (_jsx(WorkerOutputView, { title: workerTitle(workers, selectedWorkerIndex), role: workers[selectedWorkerIndex]?.role, featureId: workers[selectedWorkerIndex]?.featureId, logPath: workers[selectedWorkerIndex]?.logPath ?? null, scrollOffset: workerScrollOffset, searchQuery: workerSearch.open ? workerSearch.query : "", searchMatchIndex: workerSearch.matchIndex, height: Math.max(1, outputHeight - 1), terminalWidth: terminalWidth, onNavigationChange: updateWorkerNavigationTargets, onViewportChange: ({ offset }) => {
2670
+ }, onCopyTextChange: (text) => {
2671
+ copyTextByViewRef.current.chat = text;
2672
+ } })) : (_jsx(WorkerOutputView, { title: workerTitle(workers, selectedWorkerIndex), role: workers[selectedWorkerIndex]?.role, featureId: workers[selectedWorkerIndex]?.featureId, logPath: workers[selectedWorkerIndex]?.logPath ?? null, scrollOffset: workerScrollOffset, searchQuery: workerSearch.open ? workerSearch.query : "", searchMatchIndex: workerSearch.matchIndex, height: Math.max(1, outputHeight - 1), terminalWidth: terminalWidth, onNavigationChange: updateWorkerNavigationTargets, onCopyTextChange: (text) => {
2673
+ copyTextByViewRef.current.worker = text;
2674
+ }, onViewportChange: ({ offset }) => {
2567
2675
  if (offset !== workerScrollOffset) {
2568
2676
  setWorkerScrollOffset(offset);
2569
2677
  }
@@ -2575,7 +2683,7 @@ function readTerminalSize() {
2575
2683
  rows: Math.max(1, Math.trunc(process.stdout.rows || 30))
2576
2684
  };
2577
2685
  }
2578
- export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.stdout.columns || 120, viewportHeight, scrollOffset = 0, expandedTaskResult = false, onViewportChange }) {
2686
+ export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.stdout.columns || 120, viewportHeight, scrollOffset = 0, expandedTaskResult = false, onViewportChange, onCopyTextChange }) {
2579
2687
  const height = viewportHeight ? Math.max(1, viewportHeight) : undefined;
2580
2688
  const viewport = useMemo(() => chatMessageViewport(messages, terminalWidth, height ?? 12, scrollOffset, {
2581
2689
  expandedTaskResult,
@@ -2587,6 +2695,10 @@ export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.
2587
2695
  maxOffset: viewport.maxOffset
2588
2696
  });
2589
2697
  }, [onViewportChange, viewport.clampedOffset, viewport.maxOffset]);
2698
+ const visibleCopyText = viewport.lines.map((line) => line.text).join("\n");
2699
+ useEffect(() => {
2700
+ onCopyTextChange?.(visibleCopyText);
2701
+ }, [onCopyTextChange, visibleCopyText]);
2590
2702
  if (messages.length === 0) {
2591
2703
  const spacerLines = chatViewportSpacerLineCount(1, height);
2592
2704
  return (_jsxs(Box, { flexDirection: "column", height: height, children: [_jsx(ChatViewportSpacerLines, { count: spacerLines, terminalWidth: terminalWidth }), _jsx(ChatEmptyState, { cwd: cwd, activeTaskId: activeTaskId, terminalWidth: terminalWidth })] }));
@@ -2,9 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Box, Text } from "ink";
3
3
  import { compactEndByDisplayWidth, compactTailByDisplayWidth, displayWidth } from "./display-width.js";
4
4
  import { TUI_THEME } from "./theme.js";
5
- export function InputBar({ mode, ready = true, busy = false, routeFallback = false, collaborationDetail = false, collaborationUnresolved = false, collaborationBack = "workers", featureCanCancel = false, featureCanPause = false, featureCanReassign = false, featureCancelConfirm = false, featurePauseConfirm = false, featureAssignment = false, taskSessionAction = null, taskSessionsIncludeArchived = false, taskSessionDetail = false, taskSessionDetailHasNative = false, taskSessionDetailCanFork = false, canRetry = false, hasWorkers = false, hasActiveTask = false, hasTaskResult = false, taskResultExpanded = false, chatScrollOffset = 0, chatMaxScrollOffset = 0, nativeClosed = false, searchMatchIndex = 0, searchMatchCount = 0, value, cursor, terminalWidth: providedTerminalWidth, onChange, onSubmit }) {
5
+ export function InputBar({ mode, ready = true, busy = false, routeFallback = false, collaborationDetail = false, collaborationUnresolved = false, collaborationBack = "workers", featureCanCancel = false, featureCanPause = false, featureCanReassign = false, featureCancelConfirm = false, featurePauseConfirm = false, featureAssignment = false, taskSessionAction = null, taskSessionsIncludeArchived = false, taskSessionDetail = false, taskSessionDetailHasNative = false, taskSessionDetailCanFork = false, canRetry = false, hasWorkers = false, hasActiveTask = false, hasTaskResult = false, taskResultExpanded = false, chatScrollOffset = 0, chatMaxScrollOffset = 0, nativeClosed = false, searchMatchIndex = 0, searchMatchCount = 0, clipboardNotice = null, value, cursor, terminalWidth: providedTerminalWidth, onChange, onSubmit }) {
6
6
  const terminalWidth = providedTerminalWidth ?? process.stdout.columns ?? 120;
7
7
  const fillRail = providedTerminalWidth !== undefined || typeof process.stdout.columns === "number";
8
+ if (clipboardNotice) {
9
+ const contentWidth = Math.max(1, terminalWidth - (terminalWidth > 1 ? 2 : 0));
10
+ const text = compactEndByDisplayWidth(clipboardNotice.text, contentWidth);
11
+ return (_jsx(InputRail, { terminalWidth: terminalWidth, textWidth: displayWidth(text), fill: fillRail, children: _jsx(Text, { backgroundColor: TUI_THEME.rail, color: clipboardNotice.state === "copied" ? TUI_THEME.success : TUI_THEME.warning, bold: true, children: text }) }));
12
+ }
8
13
  if (mode === "worker-search") {
9
14
  const suffix = workerSearchInputSuffix(terminalWidth, searchMatchIndex, searchMatchCount);
10
15
  const prefix = terminalWidth < 4 ? "/" : "/ ";
@@ -39,7 +39,7 @@ export function workerOutputNavigationTargets(lines, height, query = "") {
39
39
  }
40
40
  return { searchOffsets, searchLineIndexes, errorOffsets, diffOffsets };
41
41
  }
42
- export function WorkerOutputView({ title, role, featureId, logPath, scrollOffset = 0, height = 24, terminalWidth = Number(process.stdout.columns) || 120, searchQuery = "", searchMatchIndex = 0, onViewportChange, onNavigationChange }) {
42
+ export function WorkerOutputView({ title, role, featureId, logPath, scrollOffset = 0, height = 24, terminalWidth = Number(process.stdout.columns) || 120, searchQuery = "", searchMatchIndex = 0, onViewportChange, onNavigationChange, onCopyTextChange }) {
43
43
  const nanoOutput = isNanoWorkerOutputWidth(terminalWidth);
44
44
  const contentKey = workerOutputContentKey(role, featureId, logPath, nanoOutput);
45
45
  const [contentState, setContentState] = useState({ key: "", lines: [] });
@@ -155,6 +155,7 @@ export function WorkerOutputView({ title, role, featureId, logPath, scrollOffset
155
155
  preferLatestSection: selection.clampedOffset === 0 && selection.maxOffset > 0
156
156
  });
157
157
  const visibleLines = displayLines.slice(start, end);
158
+ const visibleCopyText = visibleLines.map((line) => line.text).join("\n");
158
159
  const topPaddingLines = start > rawStart
159
160
  ? 0
160
161
  : workerOutputTailTopPaddingLines(selection.clampedOffset, selection.maxOffset, visibleLines.length, height);
@@ -176,6 +177,9 @@ export function WorkerOutputView({ title, role, featureId, logPath, scrollOffset
176
177
  navigationTargets.searchOffsets.join(","),
177
178
  onNavigationChange
178
179
  ]);
180
+ useEffect(() => {
181
+ onCopyTextChange?.(visibleCopyText);
182
+ }, [onCopyTextChange, visibleCopyText]);
179
183
  const scrollLabel = workerOutputScrollDisplay(selection.clampedOffset, selection.maxOffset, terminalWidth);
180
184
  const normalizedSearchQuery = searchQuery.trim();
181
185
  const navigationLabel = normalizedSearchQuery
@@ -31,6 +31,9 @@ export function isStatusDetailsShortcut(input, key) {
31
31
  export function isWorkerSearchShortcut(input, key) {
32
32
  return (key.ctrl === true && input.toLowerCase() === "f") || input === "\u0006";
33
33
  }
34
+ export function isCopyShortcut(input, key) {
35
+ return (key.ctrl === true && input.toLowerCase() === "y") || input === "\u0019";
36
+ }
34
37
  export function workerLogJumpKind(input) {
35
38
  if (input === "e" || input === "E") {
36
39
  return "error";
@@ -65,6 +68,14 @@ export function rawHistoryDelta(input) {
65
68
  }
66
69
  return delta;
67
70
  }
71
+ export function rawPlainArrowDelta(input) {
72
+ let delta = 0;
73
+ for (const match of input.matchAll(/\x1b(?:O([AB])|\[([AB]))/g)) {
74
+ const direction = match[1] ?? match[2];
75
+ delta += direction === "A" ? 1 : -1;
76
+ }
77
+ return delta;
78
+ }
68
79
  export function mouseScrollDelta(input, linesPerWheel = 3) {
69
80
  let delta = 0;
70
81
  for (const match of input.matchAll(/\x1b\[<(\d+);\d+;\d+[mM]/g)) {
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.1.5";
1
+ export const version = "0.1.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallel-codex-tui",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
5
5
  "license": "MIT",
6
6
  "type": "module",