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 +24 -6
- package/dist/cli.js +1 -1
- package/dist/core/clipboard.js +97 -0
- package/dist/tui/App.js +116 -7
- package/dist/tui/InputBar.js +6 -1
- package/dist/tui/WorkerOutputView.js +5 -1
- package/dist/tui/keyboard.js +3 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
-
|
|
455
|
-
git
|
|
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.
|
|
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[?
|
|
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[?
|
|
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[?
|
|
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
|
-
}
|
|
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 })] }));
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -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
|
package/dist/tui/keyboard.js
CHANGED
|
@@ -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.
|
|
1
|
+
export const version = "0.1.6";
|