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.
Files changed (74) hide show
  1. package/.parallel-codex/config.example.toml +90 -3
  2. package/README.md +269 -12
  3. package/dist/bootstrap.js +50 -18
  4. package/dist/cli-args.js +96 -14
  5. package/dist/cli-help.js +20 -0
  6. package/dist/cli-startup-recovery.js +70 -0
  7. package/dist/cli-workspace-picker.js +330 -0
  8. package/dist/cli-workspace-transition.js +33 -0
  9. package/dist/cli-workspace.js +40 -0
  10. package/dist/cli.js +291 -35
  11. package/dist/core/app-root.js +8 -0
  12. package/dist/core/collaboration-timeline.js +261 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +191 -23
  15. package/dist/core/file-store.js +130 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +10 -0
  18. package/dist/core/process-identity.js +48 -0
  19. package/dist/core/process-mutation-turn.js +128 -0
  20. package/dist/core/process-ownership.js +276 -0
  21. package/dist/core/process-tree.js +90 -0
  22. package/dist/core/router-audit.js +155 -0
  23. package/dist/core/router-redaction.js +31 -0
  24. package/dist/core/router.js +473 -42
  25. package/dist/core/session-index.js +225 -30
  26. package/dist/core/session-manager.js +1182 -44
  27. package/dist/core/task-state-machine.js +17 -0
  28. package/dist/core/workspace-commit-recovery.js +118 -0
  29. package/dist/core/workspace.js +126 -0
  30. package/dist/doctor.js +384 -30
  31. package/dist/domain/schemas.js +127 -6
  32. package/dist/orchestrator/collaboration-channel.js +255 -4
  33. package/dist/orchestrator/feature-plan.js +70 -0
  34. package/dist/orchestrator/judge-artifacts.js +236 -0
  35. package/dist/orchestrator/orchestrator.js +1777 -212
  36. package/dist/orchestrator/prompts.js +126 -2
  37. package/dist/orchestrator/supervisor-summary.js +56 -2
  38. package/dist/orchestrator/workspace-sandbox.js +911 -0
  39. package/dist/tui/App.js +2838 -159
  40. package/dist/tui/AppShell.js +188 -23
  41. package/dist/tui/CollaborationTimelineView.js +327 -0
  42. package/dist/tui/FeatureBoardView.js +227 -0
  43. package/dist/tui/InputBar.js +514 -57
  44. package/dist/tui/RouterDiagnosticsView.js +469 -0
  45. package/dist/tui/StatusBar.js +610 -57
  46. package/dist/tui/TaskSessionsView.js +207 -0
  47. package/dist/tui/TerminalOutput.js +53 -9
  48. package/dist/tui/WorkerOutputView.js +1403 -161
  49. package/dist/tui/WorkerOverviewView.js +250 -0
  50. package/dist/tui/chat-history.js +25 -0
  51. package/dist/tui/chat-input.js +67 -19
  52. package/dist/tui/chat-paste.js +76 -0
  53. package/dist/tui/display-width.js +41 -3
  54. package/dist/tui/incremental-text-file.js +101 -0
  55. package/dist/tui/keyboard.js +46 -0
  56. package/dist/tui/markdown-text.js +14 -0
  57. package/dist/tui/raw-input-decoder.js +3 -0
  58. package/dist/tui/scrolling.js +2 -1
  59. package/dist/tui/status-line.js +318 -11
  60. package/dist/tui/task-memory.js +15 -0
  61. package/dist/tui/task-result.js +105 -0
  62. package/dist/tui/terminal-screen.js +13 -1
  63. package/dist/tui/theme-contrast.js +144 -0
  64. package/dist/tui/theme-preview.js +109 -0
  65. package/dist/tui/theme.js +158 -0
  66. package/dist/version.js +1 -1
  67. package/dist/workers/capabilities.js +212 -0
  68. package/dist/workers/live-probe.js +176 -0
  69. package/dist/workers/mock-adapter.js +39 -6
  70. package/dist/workers/native-attach.js +147 -8
  71. package/dist/workers/native-session-detection.js +17 -0
  72. package/dist/workers/process-adapter.js +580 -81
  73. package/dist/workers/registry.js +4 -2
  74. 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 appRootFlagIndex = args.findIndex((arg) => arg === "--app-root");
4
- const workspaceFlagIndex = args.findIndex((arg) => arg === "--workspace" || arg === "-w");
5
- const taskFlagIndex = args.findIndex((arg) => arg === "--task" || arg === "-t");
6
- const doctor = args.includes("--doctor");
7
- const help = args.includes("--help") || args.includes("-h");
8
- const init = args.includes("--init");
9
- const version = args.includes("--version") || args.includes("-v");
10
- const appRoot = appRootFlagIndex >= 0 && args[appRootFlagIndex + 1]
11
- ? resolve(cwd, args[appRootFlagIndex + 1])
12
- : cwd;
13
- const workspaceRoot = workspaceFlagIndex >= 0 && args[workspaceFlagIndex + 1]
14
- ? resolve(cwd, args[workspaceFlagIndex + 1])
15
- : cwd;
16
- const taskId = taskFlagIndex >= 0 && args[taskFlagIndex + 1] ? args[taskFlagIndex + 1] : null;
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
+ }
@@ -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
+ }