parallel-codex-tui 0.1.5 → 0.1.6

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.6` is available from [npm](https://www.npmjs.com/package/parallel-codex-tui/v/0.1.6) and as a [GitHub Release](https://github.com/allendred/parallel-codex-tui/releases/tag/v0.1.6). It keeps terminal scrolling and copying available at the same time: mouse reporting now matches Claude Code's click/wheel protocol (`1000` + `1006`) without enabling drag-event tracking (`1002`), so normal terminal selection and system copy remain available while the app receives wheel input. `Ctrl+Y` is an additional fallback that copies the visible chat, rendered Worker log, native Agent screen, or structured overview without changing mouse mode. 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,240 tests across 119 files, including PTY coverage proving wheel delivery without drag-event tracking. The npm package was independently installed and returned `parallel-codex-tui 0.1.6`.
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+.
@@ -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 or PageUp/PageDown; sending a new message returns to the latest reply. Mouse reporting matches Claude Code's click/wheel protocol and does not enable drag-event tracking, so ordinary terminal drag selection and system copy remain available while wheel scrolling stays active. `Ctrl+Y` also copies the current visible chat, rendered Worker log, native Agent screen, or structured overview without changing mouse mode. 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 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.6
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.6` requires tag `v0.1.6`. Published tags such as `v0.1.5` are immutable and must not be moved or reused.
459
477
 
460
478
  ## Publishing Hygiene
461
479
 
package/dist/cli.js CHANGED
@@ -291,7 +291,7 @@ function restoreInteractiveTerminal() {
291
291
  }
292
292
  process.stdin.pause();
293
293
  if (process.stdout.isTTY) {
294
- process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h");
294
+ process.stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?2004l\x1b[?25h");
295
295
  }
296
296
  }
297
297
  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, 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[?1000h\x1b[?1006h");
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, {}))) {
@@ -1477,7 +1487,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1477
1487
  stdinEvents.removeListener("input", handleRawInput);
1478
1488
  rawInputDecoderRef.current.end();
1479
1489
  chatPasteDecoderRef.current.reset();
1480
- process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?2004l");
1490
+ process.stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?2004l");
1481
1491
  setRawMode(false);
1482
1492
  };
1483
1493
  }, [outputHeight, setRawMode, stdinEvents]);
@@ -1492,6 +1502,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1492
1502
  collaborationLoadSequenceRef.current += 1;
1493
1503
  routerLoadSequenceRef.current += 1;
1494
1504
  taskSessionsLoadSequenceRef.current += 1;
1505
+ if (clipboardNoticeTimerRef.current) {
1506
+ clearTimeout(clipboardNoticeTimerRef.current);
1507
+ clipboardNoticeTimerRef.current = null;
1508
+ }
1495
1509
  };
1496
1510
  }, []);
1497
1511
  useEffect(() => {
@@ -1509,6 +1523,93 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1509
1523
  shutdownSignal.addEventListener("abort", shutdown, { once: true });
1510
1524
  return () => shutdownSignal.removeEventListener("abort", shutdown);
1511
1525
  }, [shutdownSignal]);
1526
+ async function copyVisibleView() {
1527
+ const payload = visibleClipboardPayload(viewRef.current);
1528
+ if (!payload?.text.trim()) {
1529
+ setAttachError("No visible text to copy in this view");
1530
+ return;
1531
+ }
1532
+ if (clipboardNoticeTimerRef.current) {
1533
+ clearTimeout(clipboardNoticeTimerRef.current);
1534
+ clipboardNoticeTimerRef.current = null;
1535
+ }
1536
+ setAttachError(null);
1537
+ setClipboardNotice({ state: "copying", text: `copying visible ${payload.label}` });
1538
+ try {
1539
+ await copyToClipboard(payload.text);
1540
+ setClipboardNotice({ state: "copied", text: `copied visible ${payload.label} · wheel still active` });
1541
+ clipboardNoticeTimerRef.current = setTimeout(() => {
1542
+ clipboardNoticeTimerRef.current = null;
1543
+ setClipboardNotice(null);
1544
+ }, 1800);
1545
+ }
1546
+ catch (error) {
1547
+ setClipboardNotice(null);
1548
+ setAttachError(`Copy failed · ${error instanceof Error ? error.message : String(error)}`);
1549
+ }
1550
+ }
1551
+ function visibleClipboardPayload(currentView) {
1552
+ if (currentView === "chat") {
1553
+ return { label: "chat", text: copyTextByViewRef.current.chat };
1554
+ }
1555
+ if (currentView === "worker") {
1556
+ return { label: "logs", text: copyTextByViewRef.current.worker };
1557
+ }
1558
+ if (currentView === "native") {
1559
+ return { label: "native output", text: nativeAttachRef.current?.screen.snapshot() ?? "" };
1560
+ }
1561
+ if (currentView === "status") {
1562
+ return {
1563
+ label: "status",
1564
+ text: [cwd, activeTaskIdRef.current, visibleTaskStatus, visibleRouteSummary]
1565
+ .filter(Boolean)
1566
+ .join("\n")
1567
+ };
1568
+ }
1569
+ if (currentView === "workers") {
1570
+ return {
1571
+ label: "workers",
1572
+ text: workersRef.current.map((worker) => [
1573
+ worker.label,
1574
+ worker.runtimeStatus?.state,
1575
+ worker.runtimeStatus?.phase,
1576
+ worker.runtimeStatus?.summary
1577
+ ].filter(Boolean).join(" · ")).join("\n")
1578
+ };
1579
+ }
1580
+ if (currentView === "features") {
1581
+ return {
1582
+ label: "features",
1583
+ text: (collaborationTimelineRef.current?.features ?? []).map((feature) => [
1584
+ feature.id,
1585
+ feature.title,
1586
+ feature.state
1587
+ ].filter(Boolean).join(" · ")).join("\n")
1588
+ };
1589
+ }
1590
+ if (currentView === "sessions") {
1591
+ const details = taskSessionDetailsRef.current;
1592
+ return {
1593
+ label: "sessions",
1594
+ text: details
1595
+ ? details.workers.map((worker) => [worker.id, worker.role, worker.engine, worker.state].join(" · ")).join("\n")
1596
+ : taskSessionsRef.current.map((task) => [task.id, task.title, task.status].join(" · ")).join("\n")
1597
+ };
1598
+ }
1599
+ if (currentView === "router") {
1600
+ return { label: "routes", text: routerRecords.map((record) => JSON.stringify(record)).join("\n") };
1601
+ }
1602
+ if (currentView === "collaboration") {
1603
+ const timeline = collaborationTimelineRef.current;
1604
+ return {
1605
+ label: "timeline",
1606
+ text: timeline
1607
+ ? collaborationTimelineEvents(timeline, collaborationFeatureIndexRef.current, collaborationUnresolvedOnlyRef.current).map((event) => JSON.stringify(event)).join("\n")
1608
+ : ""
1609
+ };
1610
+ }
1611
+ return null;
1612
+ }
1512
1613
  useInput((inputKey, key) => {
1513
1614
  if (view === "worker") {
1514
1615
  if (isExitShortcut(inputKey, key)) {
@@ -2536,7 +2637,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2536
2637
  ? { type: "rename", value: taskSessionAction.value, cursor: taskSessionAction.cursor }
2537
2638
  : taskSessionAction?.type === "delete"
2538
2639
  ? { 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"
2640
+ : 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
2641
  ? workerSearch.query
2541
2642
  : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" ? "" : input, cursor: workerSearch.open && view === "worker"
2542
2643
  ? workerSearch.cursor
@@ -2563,7 +2664,11 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2563
2664
  chatScrollOffsetRef.current = offset;
2564
2665
  setChatScrollOffset(offset);
2565
2666
  }
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 }) => {
2667
+ }, onCopyTextChange: (text) => {
2668
+ copyTextByViewRef.current.chat = text;
2669
+ } })) : (_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) => {
2670
+ copyTextByViewRef.current.worker = text;
2671
+ }, onViewportChange: ({ offset }) => {
2567
2672
  if (offset !== workerScrollOffset) {
2568
2673
  setWorkerScrollOffset(offset);
2569
2674
  }
@@ -2575,7 +2680,7 @@ function readTerminalSize() {
2575
2680
  rows: Math.max(1, Math.trunc(process.stdout.rows || 30))
2576
2681
  };
2577
2682
  }
2578
- export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.stdout.columns || 120, viewportHeight, scrollOffset = 0, expandedTaskResult = false, onViewportChange }) {
2683
+ export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.stdout.columns || 120, viewportHeight, scrollOffset = 0, expandedTaskResult = false, onViewportChange, onCopyTextChange }) {
2579
2684
  const height = viewportHeight ? Math.max(1, viewportHeight) : undefined;
2580
2685
  const viewport = useMemo(() => chatMessageViewport(messages, terminalWidth, height ?? 12, scrollOffset, {
2581
2686
  expandedTaskResult,
@@ -2587,6 +2692,10 @@ export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.
2587
2692
  maxOffset: viewport.maxOffset
2588
2693
  });
2589
2694
  }, [onViewportChange, viewport.clampedOffset, viewport.maxOffset]);
2695
+ const visibleCopyText = viewport.lines.map((line) => line.text).join("\n");
2696
+ useEffect(() => {
2697
+ onCopyTextChange?.(visibleCopyText);
2698
+ }, [onCopyTextChange, visibleCopyText]);
2590
2699
  if (messages.length === 0) {
2591
2700
  const spacerLines = chatViewportSpacerLineCount(1, height);
2592
2701
  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";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "0.1.5";
1
+ export const version = "0.1.6";
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.6",
4
4
  "description": "A TypeScript TUI wrapper for routed parallel coding with Codex and Claude workers.",
5
5
  "license": "MIT",
6
6
  "type": "module",