parallel-codex-tui 0.1.0 → 0.1.4
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/.parallel-codex/config.example.toml +90 -3
- package/README.md +269 -12
- package/dist/bootstrap.js +50 -18
- package/dist/cli-args.js +96 -14
- package/dist/cli-help.js +20 -0
- package/dist/cli-startup-recovery.js +70 -0
- package/dist/cli-workspace-picker.js +330 -0
- package/dist/cli-workspace-transition.js +33 -0
- package/dist/cli-workspace.js +40 -0
- package/dist/cli.js +291 -35
- package/dist/core/app-root.js +8 -0
- package/dist/core/collaboration-timeline.js +261 -0
- package/dist/core/config-errors.js +14 -0
- package/dist/core/config.js +191 -23
- package/dist/core/file-store.js +130 -6
- package/dist/core/lease-finalization.js +22 -0
- package/dist/core/paths.js +10 -0
- package/dist/core/process-identity.js +48 -0
- package/dist/core/process-mutation-turn.js +128 -0
- package/dist/core/process-ownership.js +276 -0
- package/dist/core/process-tree.js +90 -0
- package/dist/core/router-audit.js +155 -0
- package/dist/core/router-redaction.js +31 -0
- package/dist/core/router.js +473 -42
- package/dist/core/session-index.js +225 -30
- package/dist/core/session-manager.js +1182 -44
- package/dist/core/task-state-machine.js +17 -0
- package/dist/core/workspace-commit-recovery.js +118 -0
- package/dist/core/workspace.js +126 -0
- package/dist/doctor.js +384 -30
- package/dist/domain/schemas.js +127 -6
- package/dist/orchestrator/collaboration-channel.js +255 -4
- package/dist/orchestrator/feature-plan.js +70 -0
- package/dist/orchestrator/judge-artifacts.js +236 -0
- package/dist/orchestrator/orchestrator.js +1777 -212
- package/dist/orchestrator/prompts.js +126 -2
- package/dist/orchestrator/supervisor-summary.js +56 -2
- package/dist/orchestrator/workspace-sandbox.js +911 -0
- package/dist/tui/App.js +2838 -159
- package/dist/tui/AppShell.js +188 -23
- package/dist/tui/CollaborationTimelineView.js +327 -0
- package/dist/tui/FeatureBoardView.js +227 -0
- package/dist/tui/InputBar.js +514 -57
- package/dist/tui/RouterDiagnosticsView.js +469 -0
- package/dist/tui/StatusBar.js +610 -57
- package/dist/tui/TaskSessionsView.js +207 -0
- package/dist/tui/TerminalOutput.js +53 -9
- package/dist/tui/WorkerOutputView.js +1403 -161
- package/dist/tui/WorkerOverviewView.js +250 -0
- package/dist/tui/chat-history.js +25 -0
- package/dist/tui/chat-input.js +67 -19
- package/dist/tui/chat-paste.js +76 -0
- package/dist/tui/display-width.js +41 -3
- package/dist/tui/incremental-text-file.js +101 -0
- package/dist/tui/keyboard.js +46 -0
- package/dist/tui/markdown-text.js +14 -0
- package/dist/tui/raw-input-decoder.js +3 -0
- package/dist/tui/scrolling.js +2 -1
- package/dist/tui/status-line.js +318 -11
- package/dist/tui/task-memory.js +15 -0
- package/dist/tui/task-result.js +105 -0
- package/dist/tui/terminal-screen.js +13 -1
- package/dist/tui/theme-contrast.js +144 -0
- package/dist/tui/theme-preview.js +109 -0
- package/dist/tui/theme.js +158 -0
- package/dist/version.js +1 -1
- package/dist/workers/capabilities.js +212 -0
- package/dist/workers/live-probe.js +176 -0
- package/dist/workers/mock-adapter.js +39 -6
- package/dist/workers/native-attach.js +147 -8
- package/dist/workers/native-session-detection.js +17 -0
- package/dist/workers/process-adapter.js +580 -81
- package/dist/workers/registry.js +4 -2
- package/package.json +17 -2
package/dist/cli-args.js
CHANGED
|
@@ -1,26 +1,108 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { normalizeTuiThemeName, TUI_THEME_NAMES } from "./tui/theme.js";
|
|
4
|
+
import { TaskIdSchema } from "./domain/schemas.js";
|
|
5
|
+
const allowedValueOptions = new Set(["--app-root", "--workspace", "-w", "--task", "-t", "--theme"]);
|
|
6
|
+
const allowedBooleanOptions = new Set(["--doctor", "--help", "-h", "--init", "--probe-agents", "--probe-router", "--themes", "--version", "-v"]);
|
|
2
7
|
export function parseCliArgs(args, cwd) {
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
8
|
+
const optionArgs = argsBeforeTerminator(args);
|
|
9
|
+
const appRootFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--app-root" || arg.startsWith("--app-root="));
|
|
10
|
+
const workspaceFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--workspace" || arg.startsWith("--workspace=") || arg === "-w" || arg.startsWith("-w="));
|
|
11
|
+
const taskFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--task" || arg.startsWith("--task=") || arg === "-t" || arg.startsWith("-t="));
|
|
12
|
+
const themeFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--theme" || arg.startsWith("--theme="));
|
|
13
|
+
const doctor = optionArgs.includes("--doctor");
|
|
14
|
+
const help = optionArgs.includes("--help") || optionArgs.includes("-h");
|
|
15
|
+
const init = optionArgs.includes("--init");
|
|
16
|
+
const probeAgents = optionArgs.includes("--probe-agents");
|
|
17
|
+
const probeRouter = optionArgs.includes("--probe-router");
|
|
18
|
+
const themes = optionArgs.includes("--themes");
|
|
19
|
+
const version = optionArgs.includes("--version") || optionArgs.includes("-v");
|
|
20
|
+
const appRootValue = flagValue(optionArgs, appRootFlagIndex);
|
|
21
|
+
const appRoot = appRootValue ? resolvePathArg(cwd, appRootValue) : cwd;
|
|
22
|
+
const explicitWorkspace = flagValue(optionArgs, workspaceFlagIndex);
|
|
23
|
+
const workspaceRoot = explicitWorkspace ? resolvePathArg(cwd, explicitWorkspace) : cwd;
|
|
24
|
+
const taskId = flagValue(optionArgs, taskFlagIndex);
|
|
25
|
+
const theme = cliThemeValue(flagValue(optionArgs, themeFlagIndex));
|
|
17
26
|
return {
|
|
18
27
|
appRoot,
|
|
19
28
|
doctor,
|
|
29
|
+
explicitWorkspace,
|
|
20
30
|
help,
|
|
21
31
|
init,
|
|
32
|
+
probeAgents,
|
|
33
|
+
probeRouter,
|
|
22
34
|
workspaceRoot,
|
|
23
35
|
taskId,
|
|
36
|
+
theme,
|
|
37
|
+
themes,
|
|
24
38
|
version
|
|
25
39
|
};
|
|
26
40
|
}
|
|
41
|
+
function resolvePathArg(cwd, value) {
|
|
42
|
+
if (value === "~") {
|
|
43
|
+
return homedir();
|
|
44
|
+
}
|
|
45
|
+
if (value.startsWith("~/")) {
|
|
46
|
+
return resolve(homedir(), value.slice(2));
|
|
47
|
+
}
|
|
48
|
+
return resolve(cwd, value);
|
|
49
|
+
}
|
|
50
|
+
export function validateCliArgs(args) {
|
|
51
|
+
const errors = [];
|
|
52
|
+
const optionArgs = argsBeforeTerminator(args);
|
|
53
|
+
for (const arg of optionArgs) {
|
|
54
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (allowedBooleanOptions.has(arg) || allowedValueOptions.has(arg)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const equalsIndex = arg.indexOf("=");
|
|
61
|
+
const optionName = equalsIndex >= 0 ? arg.slice(0, equalsIndex) : arg;
|
|
62
|
+
if (allowedValueOptions.has(optionName)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
errors.push(`Unknown option: ${arg}`);
|
|
66
|
+
}
|
|
67
|
+
const themeFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--theme" || arg.startsWith("--theme="));
|
|
68
|
+
const rawThemeValue = flagValue(optionArgs, themeFlagIndex);
|
|
69
|
+
if (rawThemeValue?.trim() && !normalizeTuiThemeName(rawThemeValue)) {
|
|
70
|
+
errors.push(`Invalid --theme: ${rawThemeValue.trim()} (expected ${TUI_THEME_NAMES.join(", ")})`);
|
|
71
|
+
}
|
|
72
|
+
const taskFlagIndex = lastFlagIndex(optionArgs, (arg) => arg === "--task" || arg.startsWith("--task=") || arg === "-t" || arg.startsWith("-t="));
|
|
73
|
+
const rawTaskId = flagValue(optionArgs, taskFlagIndex);
|
|
74
|
+
if (rawTaskId && !TaskIdSchema.safeParse(rawTaskId).success) {
|
|
75
|
+
errors.push("Invalid --task: expected task- followed by letters, numbers, dot, underscore, or hyphen");
|
|
76
|
+
}
|
|
77
|
+
if (optionArgs.includes("--probe-router") && !optionArgs.includes("--doctor")) {
|
|
78
|
+
errors.push("--probe-router requires --doctor");
|
|
79
|
+
}
|
|
80
|
+
if (optionArgs.includes("--probe-agents") && !optionArgs.includes("--doctor")) {
|
|
81
|
+
errors.push("--probe-agents requires --doctor");
|
|
82
|
+
}
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
function argsBeforeTerminator(args) {
|
|
86
|
+
const terminatorIndex = args.indexOf("--");
|
|
87
|
+
return terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args;
|
|
88
|
+
}
|
|
89
|
+
function lastFlagIndex(args, predicate) {
|
|
90
|
+
for (let index = args.length - 1; index >= 0; index -= 1) {
|
|
91
|
+
if (predicate(args[index] ?? "")) {
|
|
92
|
+
return index;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return -1;
|
|
96
|
+
}
|
|
97
|
+
function flagValue(args, flagIndex) {
|
|
98
|
+
const flag = flagIndex >= 0 ? args[flagIndex] : null;
|
|
99
|
+
const inlineMatch = flag?.match(/^-{1,2}[^=]+=(.*)$/);
|
|
100
|
+
if (inlineMatch) {
|
|
101
|
+
return inlineMatch[1] || null;
|
|
102
|
+
}
|
|
103
|
+
const value = flagIndex >= 0 ? args[flagIndex + 1] : null;
|
|
104
|
+
return value && !value.startsWith("-") ? value : null;
|
|
105
|
+
}
|
|
106
|
+
function cliThemeValue(value) {
|
|
107
|
+
return normalizeTuiThemeName(value);
|
|
108
|
+
}
|
package/dist/cli-help.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { TUI_THEME_NAMES } from "./tui/theme.js";
|
|
2
|
+
export function buildCliHelpText(themeNames = TUI_THEME_NAMES) {
|
|
3
|
+
return `Usage: parallel-codex-tui [options]
|
|
4
|
+
|
|
5
|
+
Options:
|
|
6
|
+
-w, --workspace <path> Project workspace for worker sessions and edits
|
|
7
|
+
--app-root <path> App root for configuration lookup
|
|
8
|
+
-t, --task <id> Open an existing task session
|
|
9
|
+
--theme <name> Temporarily use a TUI theme: ${themeNames.join(", ")}
|
|
10
|
+
--themes List built-in TUI theme palettes; combine with --theme to filter
|
|
11
|
+
--init Write .parallel-codex/config.toml if missing
|
|
12
|
+
--doctor Check config, agent commands, and theme palette preview
|
|
13
|
+
--probe-agents With --doctor, run fresh + resume probes (uses model quota)
|
|
14
|
+
--probe-router With --doctor, run one live Codex Router request
|
|
15
|
+
-v, --version Print the current version
|
|
16
|
+
-h, --help Print this help message
|
|
17
|
+
|
|
18
|
+
Options with values also accept --name=value and -x=value forms.`;
|
|
19
|
+
}
|
|
20
|
+
export const helpText = buildCliHelpText();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function startupRecoveryMessages(recoveredTasks, activeTaskId, pendingTaskCreations) {
|
|
2
|
+
return [
|
|
3
|
+
...interruptedTaskRecoveryMessages(recoveredTasks, activeTaskId),
|
|
4
|
+
...pendingTaskCreationMessages(pendingTaskCreations)
|
|
5
|
+
];
|
|
6
|
+
}
|
|
7
|
+
function interruptedTaskRecoveryMessages(recoveredTasks, activeTaskId) {
|
|
8
|
+
if (recoveredTasks.length === 0) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const active = activeTaskId
|
|
12
|
+
? recoveredTasks.find((recovery) => recovery.taskId === activeTaskId)
|
|
13
|
+
: null;
|
|
14
|
+
if (active) {
|
|
15
|
+
const restoredTurns = restoredTurnCount(active);
|
|
16
|
+
const archived = archivedTurnDetail(active);
|
|
17
|
+
if (restoredTurns > 0) {
|
|
18
|
+
return [{
|
|
19
|
+
from: "system",
|
|
20
|
+
text: `Recovered ${restoredTurns === 1 ? "follow-up turn" : `${restoredTurns} follow-up turns`} #${compactStartupTaskId(active.taskId)} · request and route kept${archived} · checkpoints kept · Ctrl+R resume`
|
|
21
|
+
}];
|
|
22
|
+
}
|
|
23
|
+
if (active.previousState === "done") {
|
|
24
|
+
return [{
|
|
25
|
+
from: "system",
|
|
26
|
+
text: `Recovered incomplete task #${compactStartupTaskId(active.taskId)} · completion evidence missing${archived} · checkpoints kept · Ctrl+R rebuild`
|
|
27
|
+
}];
|
|
28
|
+
}
|
|
29
|
+
const workerLabel = `${active.workersRecovered} ${active.workersRecovered === 1 ? "worker" : "workers"}`;
|
|
30
|
+
return [{
|
|
31
|
+
from: "system",
|
|
32
|
+
text: `Recovered interrupted task #${compactStartupTaskId(active.taskId)} · ${workerLabel} stopped${archived} · checkpoints kept · Ctrl+R resume`
|
|
33
|
+
}];
|
|
34
|
+
}
|
|
35
|
+
const restoredTurns = recoveredTasks.reduce((total, recovery) => total + restoredTurnCount(recovery), 0);
|
|
36
|
+
const turnDetail = restoredTurns > 0
|
|
37
|
+
? ` · ${restoredTurns} ${restoredTurns === 1 ? "turn" : "turns"} restored`
|
|
38
|
+
: "";
|
|
39
|
+
return [{
|
|
40
|
+
from: "system",
|
|
41
|
+
text: `Recovered ${recoveredTasks.length} interrupted ${recoveredTasks.length === 1 ? "task" : "tasks"}${turnDetail} · checkpoints kept · Ctrl+T inspect`
|
|
42
|
+
}];
|
|
43
|
+
}
|
|
44
|
+
function pendingTaskCreationMessages(recovery) {
|
|
45
|
+
if (!recovery || (recovery.abandoned === 0 && recovery.active === 0)) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const details = [];
|
|
49
|
+
if (recovery.abandoned > 0) {
|
|
50
|
+
details.push(`${recovery.abandoned} incomplete task ${recovery.abandoned === 1 ? "creation" : "creations"} archived`);
|
|
51
|
+
}
|
|
52
|
+
if (recovery.active > 0) {
|
|
53
|
+
details.push(`${recovery.active} task ${recovery.active === 1 ? "creation" : "creations"} active in another TUI`);
|
|
54
|
+
}
|
|
55
|
+
return [{
|
|
56
|
+
from: "system",
|
|
57
|
+
text: `Startup cleanup · ${details.join(" · ")}`
|
|
58
|
+
}];
|
|
59
|
+
}
|
|
60
|
+
function restoredTurnCount(recovery) {
|
|
61
|
+
return (recovery.turnsPublished ?? 0) + (recovery.turnsRepaired ?? 0);
|
|
62
|
+
}
|
|
63
|
+
function archivedTurnDetail(recovery) {
|
|
64
|
+
const count = recovery.turnsAbandoned ?? 0;
|
|
65
|
+
return count > 0 ? ` · ${count} ${count === 1 ? "fragment" : "fragments"} archived` : "";
|
|
66
|
+
}
|
|
67
|
+
function compactStartupTaskId(taskId) {
|
|
68
|
+
const parts = taskId.split("-");
|
|
69
|
+
return parts.length > 2 ? parts.slice(-2).join("-") : taskId;
|
|
70
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { Box, Text, render, useInput } from "ink";
|
|
5
|
+
import { resolveWorkspacePath } from "./core/workspace.js";
|
|
6
|
+
import { compactEndByDisplayWidth, compactTailByDisplayWidth, displayWidth } from "./tui/display-width.js";
|
|
7
|
+
import { TUI_THEME } from "./tui/theme.js";
|
|
8
|
+
const WORKSPACE_SHORTCUT_DELAY_MS = 500;
|
|
9
|
+
export class WorkspaceSelectionCancelledError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("Workspace selection cancelled.");
|
|
12
|
+
this.name = "WorkspaceSelectionCancelledError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function promptForWorkspaceTui(input) {
|
|
16
|
+
let resolveSelection = () => undefined;
|
|
17
|
+
let rejectSelection = () => undefined;
|
|
18
|
+
const selection = new Promise((resolve, reject) => {
|
|
19
|
+
resolveSelection = resolve;
|
|
20
|
+
rejectSelection = reject;
|
|
21
|
+
});
|
|
22
|
+
const cancelSelection = () => rejectSelection(new WorkspaceSelectionCancelledError());
|
|
23
|
+
let instance = null;
|
|
24
|
+
process.on("SIGINT", cancelSelection);
|
|
25
|
+
try {
|
|
26
|
+
instance = render(_jsx(WorkspacePicker, { cwd: input.cwd, choices: input.choices, invalidExplicitWorkspace: input.invalidExplicitWorkspace, terminalHeight: input.stdout.rows ?? 24, terminalWidth: input.stdout.columns ?? 80, onCancel: cancelSelection, onSelect: resolveSelection }), {
|
|
27
|
+
stdin: input.stdin,
|
|
28
|
+
stdout: input.stdout,
|
|
29
|
+
stderr: input.stdout,
|
|
30
|
+
exitOnCtrlC: false,
|
|
31
|
+
patchConsole: false
|
|
32
|
+
});
|
|
33
|
+
void instance.waitUntilExit().catch((error) => {
|
|
34
|
+
rejectSelection(error instanceof Error ? error : new Error(String(error)));
|
|
35
|
+
});
|
|
36
|
+
return await selection;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
process.off("SIGINT", cancelSelection);
|
|
40
|
+
instance?.clear();
|
|
41
|
+
instance?.unmount();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function WorkspacePicker({ cwd, choices, invalidExplicitWorkspace, terminalHeight, terminalWidth, onCancel, onSelect }) {
|
|
45
|
+
const options = useMemo(() => workspacePickerOptions(choices, invalidExplicitWorkspace), [choices, invalidExplicitWorkspace]);
|
|
46
|
+
const defaultWorkspace = invalidExplicitWorkspace?.reason === "missing"
|
|
47
|
+
? invalidExplicitWorkspace.path
|
|
48
|
+
: null;
|
|
49
|
+
const [mode, setMode] = useState(options.some((option) => option.kind === "workspace") ? "list" : "path");
|
|
50
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
51
|
+
const [pathValue, setPathValue] = useState("");
|
|
52
|
+
const [openingPath, setOpeningPath] = useState(null);
|
|
53
|
+
const pathValueRef = useRef("");
|
|
54
|
+
const settledRef = useRef(false);
|
|
55
|
+
const shortcutBufferRef = useRef("");
|
|
56
|
+
const shortcutTimerRef = useRef(null);
|
|
57
|
+
const width = Math.max(1, terminalWidth - 1);
|
|
58
|
+
const visibleRows = Math.max(1, Math.min(9, terminalHeight - (invalidExplicitWorkspace ? 7 : 6)));
|
|
59
|
+
const visible = workspacePickerWindow(options, selectedIndex, visibleRows);
|
|
60
|
+
const contentRows = 1
|
|
61
|
+
+ (invalidExplicitWorkspace ? 1 : 0)
|
|
62
|
+
+ 1
|
|
63
|
+
+ (mode === "list" ? visible.items.length + 1 : 2);
|
|
64
|
+
const trailingRows = Math.max(0, terminalHeight - contentRows);
|
|
65
|
+
useEffect(() => () => {
|
|
66
|
+
if (shortcutTimerRef.current) {
|
|
67
|
+
clearTimeout(shortcutTimerRef.current);
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
function clearShortcutBuffer() {
|
|
71
|
+
shortcutBufferRef.current = "";
|
|
72
|
+
if (shortcutTimerRef.current) {
|
|
73
|
+
clearTimeout(shortcutTimerRef.current);
|
|
74
|
+
shortcutTimerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function finish(value) {
|
|
78
|
+
if (settledRef.current) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
settledRef.current = true;
|
|
82
|
+
clearShortcutBuffer();
|
|
83
|
+
setOpeningPath(value);
|
|
84
|
+
onSelect(value);
|
|
85
|
+
}
|
|
86
|
+
function replacePathValue(value) {
|
|
87
|
+
pathValueRef.current = value;
|
|
88
|
+
setPathValue(value);
|
|
89
|
+
}
|
|
90
|
+
function submitPath(value) {
|
|
91
|
+
const requested = value.trim() || defaultWorkspace || cwd;
|
|
92
|
+
finish(resolveWorkspacePath(cwd, requested));
|
|
93
|
+
}
|
|
94
|
+
function openOption(option) {
|
|
95
|
+
clearShortcutBuffer();
|
|
96
|
+
if (!option || option.kind === "new" || !option.path) {
|
|
97
|
+
replacePathValue("");
|
|
98
|
+
setMode("path");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
finish(option.path);
|
|
102
|
+
}
|
|
103
|
+
function selectNumericShortcut(digits) {
|
|
104
|
+
const candidate = `${shortcutBufferRef.current}${digits}`;
|
|
105
|
+
const exactIndex = options.findIndex((option) => option.shortcut === candidate);
|
|
106
|
+
const hasLonger = options.some((option) => (option.shortcut.length > candidate.length && option.shortcut.startsWith(candidate)));
|
|
107
|
+
if (exactIndex < 0 && !hasLonger) {
|
|
108
|
+
clearShortcutBuffer();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
shortcutBufferRef.current = candidate;
|
|
112
|
+
if (exactIndex >= 0) {
|
|
113
|
+
setSelectedIndex(exactIndex);
|
|
114
|
+
}
|
|
115
|
+
if (exactIndex >= 0 && !hasLonger) {
|
|
116
|
+
openOption(options[exactIndex]);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (shortcutTimerRef.current) {
|
|
120
|
+
clearTimeout(shortcutTimerRef.current);
|
|
121
|
+
}
|
|
122
|
+
shortcutTimerRef.current = setTimeout(() => {
|
|
123
|
+
const pending = shortcutBufferRef.current;
|
|
124
|
+
clearShortcutBuffer();
|
|
125
|
+
openOption(options.find((option) => option.shortcut === pending));
|
|
126
|
+
}, WORKSPACE_SHORTCUT_DELAY_MS);
|
|
127
|
+
}
|
|
128
|
+
useInput((input, key) => {
|
|
129
|
+
if (settledRef.current) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const inputHasReturn = /[\r\n]/.test(input);
|
|
133
|
+
const printableInput = input.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
134
|
+
if (key.ctrl && input.toLowerCase() === "c") {
|
|
135
|
+
if (!settledRef.current) {
|
|
136
|
+
settledRef.current = true;
|
|
137
|
+
onCancel();
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (mode === "path") {
|
|
142
|
+
clearShortcutBuffer();
|
|
143
|
+
if (key.escape) {
|
|
144
|
+
if (options.some((option) => option.kind === "workspace")) {
|
|
145
|
+
setMode("list");
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
onCancel();
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (key.return) {
|
|
153
|
+
submitPath(pathValueRef.current);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (key.backspace || key.delete) {
|
|
157
|
+
replacePathValue(Array.from(pathValueRef.current).slice(0, -1).join(""));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (key.ctrl && input.toLowerCase() === "u") {
|
|
161
|
+
replacePathValue("");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!key.ctrl && !key.meta) {
|
|
165
|
+
const nextValue = `${pathValueRef.current}${printableInput}`;
|
|
166
|
+
if (inputHasReturn) {
|
|
167
|
+
submitPath(nextValue);
|
|
168
|
+
}
|
|
169
|
+
else if (printableInput) {
|
|
170
|
+
replacePathValue(nextValue);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (key.escape) {
|
|
176
|
+
clearShortcutBuffer();
|
|
177
|
+
onCancel();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (key.upArrow || (key.tab && key.shift)) {
|
|
181
|
+
clearShortcutBuffer();
|
|
182
|
+
setSelectedIndex((current) => (current - 1 + options.length) % options.length);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (key.downArrow || key.tab) {
|
|
186
|
+
clearShortcutBuffer();
|
|
187
|
+
setSelectedIndex((current) => (current + 1) % options.length);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (key.return) {
|
|
191
|
+
const pending = shortcutBufferRef.current;
|
|
192
|
+
if (pending) {
|
|
193
|
+
clearShortcutBuffer();
|
|
194
|
+
openOption(options.find((option) => option.shortcut === pending));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
openOption(options[selectedIndex]);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const newPath = printableInput.match(/^n(?:ew)?\s+(.+)$/i)?.[1]?.trim();
|
|
201
|
+
if (newPath) {
|
|
202
|
+
submitPath(newPath);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (/^n(?:ew)?$/i.test(printableInput)) {
|
|
206
|
+
clearShortcutBuffer();
|
|
207
|
+
replacePathValue("");
|
|
208
|
+
setMode("path");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (/^\d+$/.test(printableInput)) {
|
|
212
|
+
selectNumericShortcut(printableInput);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (printableInput) {
|
|
216
|
+
clearShortcutBuffer();
|
|
217
|
+
if (inputHasReturn) {
|
|
218
|
+
submitPath(printableInput);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
replacePathValue(printableInput);
|
|
222
|
+
setMode("path");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(WorkspacePickerHeader, { width: width }), invalidExplicitWorkspace ? (_jsx(WorkspacePickerNotice, { invalid: invalidExplicitWorkspace, width: width })) : null, _jsx(WorkspacePickerTitle, { count: choices.length, mode: mode, openingPath: openingPath, width: width }), mode === "list" ? (_jsxs(_Fragment, { children: [visible.items.map(({ option, index }) => (_jsx(WorkspacePickerOptionRow, { option: option, selected: index === selectedIndex, width: width }, `${option.kind}-${option.path ?? "new"}`))), _jsx(WorkspacePickerFooter, { text: openingPath
|
|
227
|
+
? workspacePickerOpeningStatus(openingPath)
|
|
228
|
+
: workspacePickerListStatus(selectedIndex, options.length), width: width })] })) : (_jsxs(_Fragment, { children: [_jsx(WorkspacePickerPathRow, { defaultWorkspace: defaultWorkspace ?? cwd, locked: Boolean(openingPath), value: pathValue, width: width }), _jsx(WorkspacePickerFooter, { text: openingPath
|
|
229
|
+
? workspacePickerOpeningStatus(openingPath)
|
|
230
|
+
: choices.length > 0 ? `${choices.length} recent` : "new workspace", width: width })] })), Array.from({ length: trailingRows }, (_, index) => (_jsx(FilledText, { text: "", width: width, backgroundColor: TUI_THEME.surface, color: TUI_THEME.text }, `workspace-fill-${index}`)))] }));
|
|
231
|
+
}
|
|
232
|
+
function WorkspacePickerHeader({ width }) {
|
|
233
|
+
const fullBrand = " parallel-codex-tui";
|
|
234
|
+
const fullSection = " · workspace";
|
|
235
|
+
const brand = width >= displayWidth(`${fullBrand}${fullSection}`)
|
|
236
|
+
? fullBrand
|
|
237
|
+
: width >= 9 ? " pct" : compactEndByDisplayWidth("pct", width);
|
|
238
|
+
const section = width >= displayWidth(`${brand}${fullSection}`)
|
|
239
|
+
? fullSection
|
|
240
|
+
: width >= displayWidth(`${brand} · ws`) ? " · ws" : "";
|
|
241
|
+
const fill = Math.max(0, width - displayWidth(brand) - displayWidth(section));
|
|
242
|
+
return (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: TUI_THEME.chrome, color: TUI_THEME.accent, bold: true, children: brand }), _jsx(Text, { backgroundColor: TUI_THEME.chrome, color: TUI_THEME.muted, children: section }), _jsx(Text, { backgroundColor: TUI_THEME.chrome, children: " ".repeat(fill) })] }));
|
|
243
|
+
}
|
|
244
|
+
function WorkspacePickerNotice({ invalid, width }) {
|
|
245
|
+
const label = invalid.reason === "missing" ? "Workspace does not exist" : "Workspace is not a directory";
|
|
246
|
+
const text = compactEndByDisplayWidth(` ${label}: ${invalid.path}`, width);
|
|
247
|
+
return _jsx(FilledText, { text: text, width: width, backgroundColor: TUI_THEME.dangerSurface, color: TUI_THEME.danger });
|
|
248
|
+
}
|
|
249
|
+
function WorkspacePickerTitle({ count, mode, openingPath, width }) {
|
|
250
|
+
const rawLabel = openingPath ? " Opening project" : mode === "list" ? " Open project" : " Workspace path";
|
|
251
|
+
const rawMeta = !openingPath && mode === "list" && count > 0 ? ` ${count} recent` : "";
|
|
252
|
+
const label = compactEndByDisplayWidth(rawLabel, width);
|
|
253
|
+
const meta = displayWidth(label) + displayWidth(rawMeta) <= width ? rawMeta : "";
|
|
254
|
+
const fill = Math.max(0, width - displayWidth(label) - displayWidth(meta));
|
|
255
|
+
return (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: TUI_THEME.surface, color: TUI_THEME.text, bold: true, children: label }), _jsx(Text, { backgroundColor: TUI_THEME.surface, color: TUI_THEME.muted, children: meta }), _jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(fill) })] }));
|
|
256
|
+
}
|
|
257
|
+
function WorkspacePickerOptionRow({ option, selected, width }) {
|
|
258
|
+
const backgroundColor = selected ? TUI_THEME.rail : TUI_THEME.surface;
|
|
259
|
+
const rawName = option.kind === "new" ? "New project" : basename(option.path ?? "") || option.path || "workspace";
|
|
260
|
+
if (width < 12) {
|
|
261
|
+
return (_jsx(FilledText, { text: `${selected ? ">" : " "} ${rawName}`, width: width, backgroundColor: backgroundColor, color: selected ? TUI_THEME.accent : TUI_THEME.text }));
|
|
262
|
+
}
|
|
263
|
+
const indicator = selected ? " > " : " ";
|
|
264
|
+
const shortcut = `${option.shortcut.padStart(2)} `;
|
|
265
|
+
const status = option.kind === "workspace" && !option.exists && width >= 20 ? " new" : "";
|
|
266
|
+
const fixedWidth = displayWidth(indicator) + displayWidth(shortcut) + displayWidth(status);
|
|
267
|
+
const showPath = option.kind === "workspace" && width >= 46;
|
|
268
|
+
const nameBudget = Math.max(4, Math.min(24, width - fixedWidth - (showPath ? 14 : 0)));
|
|
269
|
+
const name = compactEndByDisplayWidth(rawName, nameBudget);
|
|
270
|
+
const pathBudget = Math.max(0, width - fixedWidth - displayWidth(name) - (showPath ? 2 : 0));
|
|
271
|
+
const path = showPath ? compactTailByDisplayWidth(option.path ?? "", pathBudget) : "";
|
|
272
|
+
const gap = path ? " " : "";
|
|
273
|
+
const used = displayWidth(indicator) + displayWidth(shortcut) + displayWidth(name) + displayWidth(gap) + displayWidth(path) + displayWidth(status);
|
|
274
|
+
const fill = Math.max(0, width - used);
|
|
275
|
+
return (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: backgroundColor, color: selected ? TUI_THEME.accent : TUI_THEME.muted, bold: selected, children: indicator }), _jsx(Text, { backgroundColor: backgroundColor, color: TUI_THEME.muted, children: shortcut }), _jsx(Text, { backgroundColor: backgroundColor, color: TUI_THEME.text, bold: selected, children: name }), gap ? _jsx(Text, { backgroundColor: backgroundColor, children: gap }) : null, path ? _jsx(Text, { backgroundColor: backgroundColor, color: TUI_THEME.muted, children: path }) : null, status ? _jsx(Text, { backgroundColor: backgroundColor, color: TUI_THEME.warning, children: status }) : null, fill > 0 ? _jsx(Text, { backgroundColor: backgroundColor, children: " ".repeat(fill) }) : null] }));
|
|
276
|
+
}
|
|
277
|
+
function WorkspacePickerPathRow({ defaultWorkspace, locked, value, width }) {
|
|
278
|
+
if (width < 4) {
|
|
279
|
+
return _jsx(FilledText, { text: locked ? "·" : "|", width: width, backgroundColor: TUI_THEME.rail, color: locked ? TUI_THEME.muted : TUI_THEME.accent });
|
|
280
|
+
}
|
|
281
|
+
const prefix = locked ? " " : " > ";
|
|
282
|
+
const cursor = locked ? "" : "|";
|
|
283
|
+
const valueWidth = Math.max(1, width - displayWidth(prefix) - displayWidth(cursor));
|
|
284
|
+
const visibleValue = value
|
|
285
|
+
? compactTailByDisplayWidth(value, valueWidth)
|
|
286
|
+
: compactTailByDisplayWidth(defaultWorkspace, valueWidth);
|
|
287
|
+
const valueColor = value ? TUI_THEME.text : TUI_THEME.muted;
|
|
288
|
+
const used = displayWidth(prefix) + displayWidth(visibleValue) + displayWidth(cursor);
|
|
289
|
+
const fill = Math.max(0, width - used);
|
|
290
|
+
return (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: TUI_THEME.rail, color: locked ? TUI_THEME.muted : TUI_THEME.accent, bold: !locked, children: prefix }), _jsx(Text, { backgroundColor: TUI_THEME.rail, color: valueColor, children: visibleValue }), cursor ? _jsx(Text, { backgroundColor: TUI_THEME.rail, color: TUI_THEME.accent, bold: true, children: cursor }) : null, fill > 0 ? _jsx(Text, { backgroundColor: TUI_THEME.rail, children: " ".repeat(fill) }) : null] }));
|
|
291
|
+
}
|
|
292
|
+
function WorkspacePickerFooter({ text, width }) {
|
|
293
|
+
return _jsx(FilledText, { text: ` ${text}`, width: width, backgroundColor: TUI_THEME.chrome, color: TUI_THEME.muted });
|
|
294
|
+
}
|
|
295
|
+
function FilledText({ backgroundColor, color, text, width }) {
|
|
296
|
+
const visible = compactEndByDisplayWidth(text, width);
|
|
297
|
+
const fill = Math.max(0, width - displayWidth(visible));
|
|
298
|
+
return (_jsxs(Text, { children: [_jsx(Text, { backgroundColor: backgroundColor, color: color, children: visible }), fill > 0 ? _jsx(Text, { backgroundColor: backgroundColor, children: " ".repeat(fill) }) : null] }));
|
|
299
|
+
}
|
|
300
|
+
function workspacePickerOptions(choices, invalidExplicitWorkspace) {
|
|
301
|
+
const explicit = invalidExplicitWorkspace?.reason === "missing" ? invalidExplicitWorkspace.path : null;
|
|
302
|
+
const options = [];
|
|
303
|
+
if (explicit && !choices.some((choice) => choice.path === explicit)) {
|
|
304
|
+
options.push({ kind: "workspace", shortcut: "+", path: explicit, exists: false });
|
|
305
|
+
}
|
|
306
|
+
options.push(...choices.map((choice, index) => ({
|
|
307
|
+
kind: "workspace",
|
|
308
|
+
shortcut: String(index + 1),
|
|
309
|
+
path: choice.path,
|
|
310
|
+
exists: choice.exists
|
|
311
|
+
})));
|
|
312
|
+
options.push({ kind: "new", shortcut: "n", path: null, exists: false });
|
|
313
|
+
return options;
|
|
314
|
+
}
|
|
315
|
+
function workspacePickerWindow(options, selectedIndex, limit) {
|
|
316
|
+
const maxStart = Math.max(0, options.length - limit);
|
|
317
|
+
const start = Math.min(maxStart, Math.max(0, selectedIndex - Math.floor(limit / 2)));
|
|
318
|
+
const end = Math.min(options.length, start + limit);
|
|
319
|
+
return {
|
|
320
|
+
start,
|
|
321
|
+
end,
|
|
322
|
+
items: options.slice(start, end).map((option, offset) => ({ option, index: start + offset }))
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function workspacePickerListStatus(selectedIndex, total) {
|
|
326
|
+
return `${Math.min(total, selectedIndex + 1)} / ${total}`;
|
|
327
|
+
}
|
|
328
|
+
function workspacePickerOpeningStatus(path) {
|
|
329
|
+
return `opening ${basename(path) || path}`;
|
|
330
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function commitWorkspaceTransition(input) {
|
|
2
|
+
try {
|
|
3
|
+
input.render(input.next);
|
|
4
|
+
}
|
|
5
|
+
catch (renderError) {
|
|
6
|
+
try {
|
|
7
|
+
input.close(input.next);
|
|
8
|
+
}
|
|
9
|
+
catch (cleanupError) {
|
|
10
|
+
deferCloseSafely(input, input.next, cleanupError);
|
|
11
|
+
throw new Error(`${errorMessage(renderError)}; prepared workspace cleanup failed: ${errorMessage(cleanupError)}`, { cause: new AggregateError([renderError, cleanupError]) });
|
|
12
|
+
}
|
|
13
|
+
throw renderError;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
input.close(input.previous);
|
|
17
|
+
}
|
|
18
|
+
catch (cleanupError) {
|
|
19
|
+
deferCloseSafely(input, input.previous, cleanupError);
|
|
20
|
+
}
|
|
21
|
+
return input.next;
|
|
22
|
+
}
|
|
23
|
+
function deferCloseSafely(input, state, error) {
|
|
24
|
+
try {
|
|
25
|
+
input.deferClose(state, error);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// The rendered workspace is already committed; do not turn cleanup bookkeeping into a failed switch.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function errorMessage(error) {
|
|
32
|
+
return error instanceof Error ? error.message : String(error);
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { pathExists, pathIsDirectory } from "./core/file-store.js";
|
|
2
|
+
import { listWorkspaceChoices, resolveWorkspacePath, resolveWorkspaceSelection } from "./core/workspace.js";
|
|
3
|
+
import { promptForWorkspaceTui } from "./cli-workspace-picker.js";
|
|
4
|
+
export async function selectWorkspaceForCli(input) {
|
|
5
|
+
if (input.explicitWorkspace?.trim()) {
|
|
6
|
+
const explicit = resolveWorkspacePath(input.cwd, input.explicitWorkspace);
|
|
7
|
+
const stdin = input.stdin ?? process.stdin;
|
|
8
|
+
const stdout = input.stdout ?? process.stdout;
|
|
9
|
+
const explicitExists = await pathExists(explicit);
|
|
10
|
+
const explicitIsDirectory = explicitExists && (await pathIsDirectory(explicit));
|
|
11
|
+
if (!explicitIsDirectory && input.interactive !== false && shouldPromptForWorkspace(stdin, stdout)) {
|
|
12
|
+
return promptForWorkspaceTui({
|
|
13
|
+
cwd: input.cwd,
|
|
14
|
+
choices: await listWorkspaceChoices(input.appRoot),
|
|
15
|
+
invalidExplicitWorkspace: {
|
|
16
|
+
path: explicit,
|
|
17
|
+
reason: explicitExists ? "file" : "missing"
|
|
18
|
+
},
|
|
19
|
+
stdin,
|
|
20
|
+
stdout
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return resolveWorkspaceSelection(input);
|
|
24
|
+
}
|
|
25
|
+
const stdin = input.stdin ?? process.stdin;
|
|
26
|
+
const stdout = input.stdout ?? process.stdout;
|
|
27
|
+
const choices = await listWorkspaceChoices(input.appRoot);
|
|
28
|
+
if (input.interactive === false || !shouldPromptForWorkspace(stdin, stdout)) {
|
|
29
|
+
return resolveWorkspaceSelection(input);
|
|
30
|
+
}
|
|
31
|
+
return promptForWorkspaceTui({
|
|
32
|
+
cwd: input.cwd,
|
|
33
|
+
choices,
|
|
34
|
+
stdin,
|
|
35
|
+
stdout
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function shouldPromptForWorkspace(stdin, stdout) {
|
|
39
|
+
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
40
|
+
}
|