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 +25 -7
- package/dist/cli.js +8 -1
- package/dist/core/clipboard.js +97 -0
- package/dist/tui/App.js +122 -10
- package/dist/tui/InputBar.js +6 -1
- package/dist/tui/WorkerOutputView.js +5 -1
- package/dist/tui/keyboard.js +11 -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.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
|
|
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.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.
|
|
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[?
|
|
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
|
-
}
|
|
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 })] }));
|
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";
|
|
@@ -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.
|
|
1
|
+
export const version = "0.1.7";
|