parallel-codex-tui 0.1.3 → 0.1.5

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 (78) hide show
  1. package/.parallel-codex/config.example.toml +136 -3
  2. package/README.md +299 -21
  3. package/dist/bootstrap.js +37 -17
  4. package/dist/cli-args.js +34 -3
  5. package/dist/cli-help.js +20 -0
  6. package/dist/cli-startup-preflight.js +18 -0
  7. package/dist/cli-startup-recovery.js +82 -0
  8. package/dist/cli-workspace-picker.js +330 -0
  9. package/dist/cli-workspace-transition.js +33 -0
  10. package/dist/cli-workspace.js +7 -71
  11. package/dist/cli.js +234 -24
  12. package/dist/core/collaboration-timeline.js +268 -0
  13. package/dist/core/config-errors.js +14 -0
  14. package/dist/core/config.js +297 -109
  15. package/dist/core/file-store.js +119 -6
  16. package/dist/core/lease-finalization.js +22 -0
  17. package/dist/core/paths.js +7 -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 +462 -35
  25. package/dist/core/session-index.js +412 -88
  26. package/dist/core/session-manager.js +1110 -40
  27. package/dist/core/task-session-details.js +175 -0
  28. package/dist/core/task-state-machine.js +18 -0
  29. package/dist/core/workspace-commit-recovery.js +118 -0
  30. package/dist/core/workspace.js +19 -11
  31. package/dist/doctor.js +373 -34
  32. package/dist/domain/schemas.js +142 -7
  33. package/dist/orchestrator/collaboration-channel.js +289 -6
  34. package/dist/orchestrator/feature-plan.js +70 -0
  35. package/dist/orchestrator/final-acceptance.js +86 -0
  36. package/dist/orchestrator/judge-artifacts.js +236 -0
  37. package/dist/orchestrator/orchestrator.js +2086 -203
  38. package/dist/orchestrator/prompts.js +168 -5
  39. package/dist/orchestrator/supervisor-summary.js +56 -2
  40. package/dist/orchestrator/workspace-sandbox.js +927 -0
  41. package/dist/tui/App.js +3187 -161
  42. package/dist/tui/AppShell.js +196 -25
  43. package/dist/tui/CollaborationTimelineView.js +327 -0
  44. package/dist/tui/FeatureBoardView.js +232 -0
  45. package/dist/tui/InputBar.js +581 -57
  46. package/dist/tui/RouterDiagnosticsView.js +469 -0
  47. package/dist/tui/StatusBar.js +610 -57
  48. package/dist/tui/StatusDetailView.js +164 -0
  49. package/dist/tui/TaskSessionDetailView.js +222 -0
  50. package/dist/tui/TaskSessionsView.js +210 -0
  51. package/dist/tui/TerminalOutput.js +53 -9
  52. package/dist/tui/WorkerOutputView.js +1404 -161
  53. package/dist/tui/WorkerOverviewView.js +269 -0
  54. package/dist/tui/chat-history.js +25 -0
  55. package/dist/tui/chat-input.js +67 -19
  56. package/dist/tui/chat-paste.js +76 -0
  57. package/dist/tui/display-width.js +41 -3
  58. package/dist/tui/incremental-text-file.js +101 -0
  59. package/dist/tui/keyboard.js +49 -0
  60. package/dist/tui/markdown-text.js +14 -0
  61. package/dist/tui/raw-input-decoder.js +3 -0
  62. package/dist/tui/scrolling.js +2 -1
  63. package/dist/tui/status-line.js +360 -11
  64. package/dist/tui/task-memory.js +13 -1
  65. package/dist/tui/task-result.js +105 -0
  66. package/dist/tui/terminal-screen.js +13 -1
  67. package/dist/tui/theme-contrast.js +144 -0
  68. package/dist/tui/theme-preview.js +109 -0
  69. package/dist/tui/theme.js +158 -0
  70. package/dist/version.js +1 -1
  71. package/dist/workers/capabilities.js +213 -0
  72. package/dist/workers/live-probe.js +177 -0
  73. package/dist/workers/mock-adapter.js +73 -8
  74. package/dist/workers/native-attach.js +106 -16
  75. package/dist/workers/process-adapter.js +572 -77
  76. package/dist/workers/provider.js +26 -0
  77. package/dist/workers/registry.js +12 -20
  78. package/package.json +11 -2
@@ -0,0 +1,164 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { basename } from "node:path";
3
+ import { Box, Text } from "ink";
4
+ import { compactEndByDisplayWidth, displayWidth, wrapByDisplayWidth } from "./display-width.js";
5
+ import { TUI_THEME } from "./theme.js";
6
+ export function StatusDetailView({ height = 20, terminalWidth = process.stdout.columns || 120, ...input }) {
7
+ const viewportHeight = Math.max(1, Math.trunc(height));
8
+ const width = Math.max(1, terminalWidth - 2);
9
+ const lines = statusDetailDisplayLines(input, width, viewportHeight);
10
+ const blankRows = Math.max(0, viewportHeight - lines.length);
11
+ return (_jsxs(Box, { flexDirection: "column", height: viewportHeight, children: [lines.map((line, index) => (_jsx(StatusDetailRow, { line: line, width: width }, `${line.tone}-${index}`))), Array.from({ length: blankRows }, (_, index) => (_jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(width) }, `status-detail-fill-${index}`)))] }));
12
+ }
13
+ export function statusDetailDisplayLines(input, width, height) {
14
+ const safeWidth = Math.max(1, Math.trunc(width));
15
+ const maxLines = Math.max(1, Math.trunc(height));
16
+ const selected = input.workers[clampWorkerIndex(input.selectedWorkerIndex, input.workers.length)];
17
+ const lines = [
18
+ { text: "Status", tone: "heading" },
19
+ {
20
+ text: fitStatusDetailText(taskDetail(input), safeWidth),
21
+ tone: input.busy ? "active" : input.canRetry ? "warning" : "text"
22
+ },
23
+ {
24
+ text: fitStatusDetailText(`workspace · ${basename(input.cwd) || input.cwd} · ${input.cwd}`, safeWidth),
25
+ tone: "muted"
26
+ }
27
+ ];
28
+ if (input.routeStatus.trim()) {
29
+ lines.push({
30
+ text: fitStatusDetailText(input.routeStatus.trim(), safeWidth),
31
+ tone: /fallback|failed|error|timeout/i.test(input.routeStatus) ? "danger" : "text"
32
+ });
33
+ }
34
+ lines.push({
35
+ text: fitStatusDetailText(workerSummary(input.workers, input.taskStatus), safeWidth),
36
+ tone: input.workers.some((worker) => worker.runtimeStatus?.state === "failed") ? "danger" : "text"
37
+ });
38
+ if (selected) {
39
+ lines.push(...selectedWorkerLines(selected, safeWidth));
40
+ }
41
+ const reason = sanitizeStatusDetailText(input.routeReason ?? "");
42
+ if (reason) {
43
+ const prefix = "reason · ";
44
+ const wrapped = wrapByDisplayWidth(reason, Math.max(1, safeWidth - displayWidth(prefix))).slice(0, 2);
45
+ wrapped.forEach((part, index) => lines.push({
46
+ text: fitStatusDetailText(`${index === 0 ? prefix : " "}${part}`, safeWidth),
47
+ tone: "muted"
48
+ }));
49
+ }
50
+ return lines.slice(0, maxLines);
51
+ }
52
+ function taskDetail(input) {
53
+ if (!input.taskId) {
54
+ return "task · none";
55
+ }
56
+ const state = input.busy ? "running" : input.canRetry ? "retryable" : "ready";
57
+ return ["task", compactTaskId(input.taskId), input.mode, state].filter(Boolean).join(" · ");
58
+ }
59
+ function workerSummary(workers, taskStatus) {
60
+ if (workers.length === 0) {
61
+ return "workers · none";
62
+ }
63
+ const summary = taskStatus
64
+ .split("|")
65
+ .map((part) => part.trim())
66
+ .filter((part) => /^workers\s+\d+$|^(?:fail|stop|run|wait|done|idle)\s+\d+/i.test(part))
67
+ .join(" · ");
68
+ return summary || `workers · ${workers.length}`;
69
+ }
70
+ function selectedWorkerLines(worker, width) {
71
+ const status = worker.runtimeStatus;
72
+ const state = status?.state ?? "waiting";
73
+ const identity = [
74
+ "selected",
75
+ `${worker.role}/${worker.engine}`,
76
+ state,
77
+ status?.feature_title ?? worker.featureId ?? ""
78
+ ].filter(Boolean).join(" · ");
79
+ const model = [status?.model_provider, status?.model_name].filter(Boolean).join("/");
80
+ const phase = status
81
+ ? ["phase", humanizeStatusDetail(status.phase), `updated ${formatStatusDetailTime(status.last_event_at)}`].join(" · ")
82
+ : "phase · status pending";
83
+ const lines = [
84
+ { text: fitStatusDetailText(identity, width), tone: workerStateTone(state) },
85
+ ...(model ? [{ text: fitStatusDetailText(`model · ${model}`, width), tone: "muted" }] : []),
86
+ { text: fitStatusDetailText(phase, width), tone: "muted" }
87
+ ];
88
+ if (status?.native_session_id) {
89
+ lines.push({
90
+ text: fitStatusDetailText(`session · ${status.native_session_id}`, width),
91
+ tone: "muted"
92
+ });
93
+ }
94
+ const summary = sanitizeStatusDetailText(status?.summary ?? "");
95
+ if (summary) {
96
+ lines.push({ text: fitStatusDetailText(`summary · ${summary}`, width), tone: "text" });
97
+ }
98
+ return lines;
99
+ }
100
+ function StatusDetailRow({ line, width }) {
101
+ const text = fitStatusDetailText(line.text, width);
102
+ const trailingWidth = Math.max(0, width - displayWidth(text));
103
+ const theme = statusDetailLineTheme(line.tone);
104
+ return (_jsxs(Text, { children: [_jsx(Text, { ...theme, children: text }), trailingWidth > 0 ? _jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(trailingWidth) }) : null] }));
105
+ }
106
+ export function statusDetailLineTheme(tone) {
107
+ return {
108
+ backgroundColor: TUI_THEME.surface,
109
+ color: tone === "heading" || tone === "active"
110
+ ? TUI_THEME.accent
111
+ : tone === "success"
112
+ ? TUI_THEME.success
113
+ : tone === "warning"
114
+ ? TUI_THEME.warning
115
+ : tone === "danger"
116
+ ? TUI_THEME.danger
117
+ : tone === "muted"
118
+ ? TUI_THEME.muted
119
+ : TUI_THEME.text,
120
+ ...(tone === "heading" || tone === "danger" ? { bold: true } : {})
121
+ };
122
+ }
123
+ function workerStateTone(state) {
124
+ if (state === "done") {
125
+ return "success";
126
+ }
127
+ if (state === "failed") {
128
+ return "danger";
129
+ }
130
+ if (state === "running" || state === "starting") {
131
+ return "active";
132
+ }
133
+ if (state === "waiting" || state === "cancelled") {
134
+ return "warning";
135
+ }
136
+ return "muted";
137
+ }
138
+ function fitStatusDetailText(text, width) {
139
+ return compactEndByDisplayWidth(sanitizeStatusDetailText(text), Math.max(1, width));
140
+ }
141
+ function sanitizeStatusDetailText(text) {
142
+ return text
143
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
144
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
145
+ .replace(/\s+/g, " ")
146
+ .trim();
147
+ }
148
+ function humanizeStatusDetail(value) {
149
+ return sanitizeStatusDetailText(value).replace(/[-_]+/g, " ");
150
+ }
151
+ function formatStatusDetailTime(value) {
152
+ const parsed = new Date(value);
153
+ if (!Number.isFinite(parsed.getTime())) {
154
+ return "unknown";
155
+ }
156
+ return parsed.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z");
157
+ }
158
+ function compactTaskId(taskId) {
159
+ const withoutPrefix = taskId.startsWith("task-") ? taskId.slice("task-".length) : taskId;
160
+ return withoutPrefix.replace(/^\d{8}-/, "");
161
+ }
162
+ function clampWorkerIndex(index, count) {
163
+ return Math.min(Math.max(0, count - 1), Math.max(0, Math.trunc(index)));
164
+ }
@@ -0,0 +1,222 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { compactEndByDisplayWidth, displayWidth } from "./display-width.js";
4
+ import { TUI_THEME } from "./theme.js";
5
+ export function TaskSessionDetailView({ details, selectedWorkerIndex, loading = false, error = null, notice = null, height = 20, terminalWidth = process.stdout.columns || 120 }) {
6
+ const viewportHeight = Math.max(1, Math.trunc(height));
7
+ const width = taskSessionDetailContentWidth(terminalWidth);
8
+ const lines = taskSessionDetailDisplayLines(details, selectedWorkerIndex, viewportHeight, terminalWidth, {
9
+ loading,
10
+ error,
11
+ notice
12
+ });
13
+ const blankRows = Math.max(0, viewportHeight - lines.length);
14
+ return (_jsxs(Box, { flexDirection: "column", height: viewportHeight, children: [lines.map((line, index) => (_jsx(TaskSessionDetailRow, { line: line, width: width }, `${line.kind}-${line.workerIndex ?? index}-${index}`))), Array.from({ length: blankRows }, (_, index) => (_jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(width) }, `task-session-detail-fill-${index}`)))] }));
15
+ }
16
+ export function taskSessionDetailDisplayLines(details, selectedWorkerIndex, height, terminalWidth, state = {}) {
17
+ const viewportHeight = Math.max(1, Math.trunc(height));
18
+ const width = taskSessionDetailContentWidth(terminalWidth);
19
+ const header = [
20
+ { text: fitDetailCandidates(["Session hierarchy", "Session detail", "Session", "S"], width), tone: "heading", kind: "heading" }
21
+ ];
22
+ if (details && viewportHeight >= 3) {
23
+ header.push({
24
+ text: fitDetailCandidates([
25
+ `project · ${safeDetailText(details.projectName)} · ${safeDetailText(details.projectPath)}`,
26
+ `project · ${safeDetailText(details.projectName)}`,
27
+ safeDetailText(details.projectName)
28
+ ], width),
29
+ tone: "muted",
30
+ kind: "project"
31
+ });
32
+ }
33
+ if (details && viewportHeight >= 4) {
34
+ header.push({
35
+ text: fitDetailCandidates([
36
+ `task · ${safeDetailText(details.task.title)} · ${humanizeDetailState(details.task.status)} · ${details.turns.length} turns · ${details.workers.length} workers`,
37
+ `task · ${safeDetailText(details.task.title)} · ${humanizeDetailState(details.task.status)}`,
38
+ safeDetailText(details.task.title)
39
+ ], width),
40
+ tone: detailTaskTone(details.task.status),
41
+ kind: "task"
42
+ });
43
+ }
44
+ if (state.notice && header.length < viewportHeight) {
45
+ header.push({ text: fitDetailText(safeDetailText(state.notice), width), tone: "active", kind: "heading" });
46
+ }
47
+ const bodySlots = Math.max(0, viewportHeight - header.length);
48
+ if (bodySlots === 0) {
49
+ return header;
50
+ }
51
+ if (state.loading) {
52
+ return [...header, { text: fitDetailText("loading session hierarchy", width), tone: "muted", kind: "empty" }];
53
+ }
54
+ if (state.error) {
55
+ return [...header, {
56
+ text: fitDetailText(`error · ${safeDetailText(state.error)}`, width),
57
+ tone: "danger",
58
+ kind: "error"
59
+ }];
60
+ }
61
+ if (!details) {
62
+ return [...header, { text: fitDetailText("No task selected", width), tone: "muted", kind: "empty" }];
63
+ }
64
+ if (details.turns.length === 0) {
65
+ return [...header, { text: fitDetailText("No persisted turns", width), tone: "muted", kind: "empty" }];
66
+ }
67
+ const selected = clampDetailWorkerIndex(selectedWorkerIndex, details.workers.length);
68
+ const body = taskSessionDetailBodyLines(details, selected, width);
69
+ const selectedLine = Math.max(0, body.findIndex((line) => line.workerIndex === selected && line.kind === "worker"));
70
+ let start = Math.min(Math.max(0, body.length - bodySlots), Math.max(0, selectedLine - Math.floor(bodySlots / 2)));
71
+ if (start > 0) {
72
+ for (let index = selectedLine; index >= start; index -= 1) {
73
+ if (body[index]?.kind === "turn" && selectedLine - index < bodySlots) {
74
+ start = index;
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ return [...header, ...body.slice(start, start + bodySlots)];
80
+ }
81
+ export function moveTaskSessionDetailSelection(current, delta, workerCount, wrap = false) {
82
+ if (workerCount <= 0) {
83
+ return 0;
84
+ }
85
+ const normalized = clampDetailWorkerIndex(current, workerCount);
86
+ const next = normalized + Math.trunc(delta);
87
+ if (wrap) {
88
+ return ((next % workerCount) + workerCount) % workerCount;
89
+ }
90
+ return Math.min(workerCount - 1, Math.max(0, next));
91
+ }
92
+ function taskSessionDetailBodyLines(details, selectedWorkerIndex, width) {
93
+ const workerIndexById = new Map(details.workers.map((worker, index) => [worker.id, index]));
94
+ return details.turns.flatMap((turn) => {
95
+ const turnNumber = Number(turn.turnId);
96
+ const turnLabel = Number.isInteger(turnNumber) ? `Turn ${turnNumber}` : `Turn ${turn.turnId}`;
97
+ const lines = [{
98
+ text: fitDetailCandidates([
99
+ `${turnLabel} · ${formatDetailTime(turn.createdAt)} · ${safeDetailText(turn.request) || "request unavailable"}`,
100
+ `${turnLabel} · ${safeDetailText(turn.request) || "request unavailable"}`,
101
+ turnLabel
102
+ ], width),
103
+ tone: "active",
104
+ kind: "turn"
105
+ }];
106
+ if (turn.workers.length === 0) {
107
+ lines.push({ text: fitDetailText(" no workers", width), tone: "muted", kind: "empty" });
108
+ return lines;
109
+ }
110
+ for (const worker of turn.workers) {
111
+ const workerIndex = workerIndexById.get(worker.id) ?? 0;
112
+ lines.push({
113
+ text: detailWorkerText(worker, workerIndex === selectedWorkerIndex, width),
114
+ tone: detailWorkerTone(worker),
115
+ workerIndex,
116
+ kind: "worker"
117
+ });
118
+ lines.push({
119
+ text: detailNativeText(worker, width),
120
+ tone: "muted",
121
+ workerIndex,
122
+ kind: "native"
123
+ });
124
+ }
125
+ return lines;
126
+ });
127
+ }
128
+ function detailWorkerText(worker, selected, width) {
129
+ const marker = selected ? "> " : " ";
130
+ const role = `${worker.role.slice(0, 1).toUpperCase()}${worker.role.slice(1)}`;
131
+ const engine = [worker.engine, worker.model, worker.modelProvider].filter(Boolean).join("/");
132
+ const feature = worker.featureTitle ?? worker.featureId ?? "";
133
+ return fitDetailCandidates([
134
+ `${marker}${role} · ${engine} · ${feature ? `${safeDetailText(feature)} · ` : ""}${worker.state} · ${formatDetailTime(worker.lastActivityAt)}`,
135
+ `${marker}${role} · ${engine} · ${worker.state}`,
136
+ `${marker}${role} · ${worker.engine}`,
137
+ `${marker}${safeDetailText(worker.id)}`,
138
+ marker.trimEnd()
139
+ ], width);
140
+ }
141
+ function detailNativeText(worker, width) {
142
+ const session = worker.nativeSession;
143
+ const cwd = safeDetailText(session?.cwd ?? worker.dir);
144
+ if (!session) {
145
+ return fitDetailCandidates([
146
+ ` native · none · cwd ${cwd}`,
147
+ ` native · none`,
148
+ " no native session"
149
+ ], width);
150
+ }
151
+ return fitDetailCandidates([
152
+ ` native · ${safeDetailText(session.sessionId)} · cwd ${cwd} · used ${formatDetailTime(session.lastUsedAt)}`,
153
+ ` native · ${safeDetailText(session.sessionId)} · used ${formatDetailTime(session.lastUsedAt)}`,
154
+ ` native · ${safeDetailText(session.sessionId)}`,
155
+ ` session ${safeDetailText(session.sessionId)}`
156
+ ], width);
157
+ }
158
+ function TaskSessionDetailRow({ line, width }) {
159
+ const fill = Math.max(0, width - displayWidth(line.text));
160
+ return (_jsxs(Text, { children: [_jsx(Text, { ...detailLineTheme(line.tone), children: line.text }), fill > 0 ? _jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(fill) }) : null] }));
161
+ }
162
+ function detailTaskTone(status) {
163
+ if (status === "done")
164
+ return "success";
165
+ if (status === "failed")
166
+ return "danger";
167
+ if (status === "paused" || status === "cancelled")
168
+ return "warning";
169
+ return "active";
170
+ }
171
+ function detailWorkerTone(worker) {
172
+ if (worker.state === "done")
173
+ return "success";
174
+ if (worker.state === "failed")
175
+ return "danger";
176
+ if (worker.state === "cancelled")
177
+ return "warning";
178
+ if (worker.state === "running" || worker.state === "starting")
179
+ return "active";
180
+ return "muted";
181
+ }
182
+ function detailLineTheme(tone) {
183
+ return {
184
+ backgroundColor: TUI_THEME.surface,
185
+ color: tone === "heading" || tone === "active"
186
+ ? TUI_THEME.accent
187
+ : tone === "success"
188
+ ? TUI_THEME.success
189
+ : tone === "warning"
190
+ ? TUI_THEME.warning
191
+ : tone === "danger"
192
+ ? TUI_THEME.danger
193
+ : TUI_THEME.muted,
194
+ ...(tone === "heading" || tone === "danger" ? { bold: true } : {})
195
+ };
196
+ }
197
+ function clampDetailWorkerIndex(index, count) {
198
+ return Math.min(Math.max(0, count - 1), Math.max(0, Math.trunc(index)));
199
+ }
200
+ function humanizeDetailState(value) {
201
+ return value.replaceAll("_", " ");
202
+ }
203
+ function formatDetailTime(value) {
204
+ return value.slice(5, 16).replace("T", " ");
205
+ }
206
+ function fitDetailCandidates(candidates, width) {
207
+ return candidates.find((candidate) => displayWidth(candidate) <= width)
208
+ ?? fitDetailText(candidates.at(-1) ?? "", width);
209
+ }
210
+ function fitDetailText(text, width) {
211
+ return compactEndByDisplayWidth(text, Math.max(1, width));
212
+ }
213
+ function safeDetailText(text) {
214
+ return text
215
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
216
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
217
+ .replace(/\s+/g, " ")
218
+ .trim();
219
+ }
220
+ function taskSessionDetailContentWidth(terminalWidth) {
221
+ return Math.max(1, terminalWidth - 2);
222
+ }
@@ -0,0 +1,210 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { compactEndByDisplayWidth, displayWidth } from "./display-width.js";
4
+ import { TUI_THEME } from "./theme.js";
5
+ export function TaskSessionsView({ tasks, activeTaskId, selectedIndex, includeArchived = false, notice = null, action = null, loading = false, error = null, height = 20, terminalWidth = process.stdout.columns || 120 }) {
6
+ const viewportHeight = Math.max(1, height);
7
+ const width = taskSessionsContentWidth(terminalWidth);
8
+ const lines = taskSessionsDisplayLines(tasks, activeTaskId, selectedIndex, viewportHeight, terminalWidth, {
9
+ loading,
10
+ error,
11
+ includeArchived,
12
+ notice,
13
+ action
14
+ });
15
+ const blankRows = Math.max(0, viewportHeight - lines.length);
16
+ return (_jsxs(Box, { flexDirection: "column", height: viewportHeight, children: [lines.map((line, index) => (_jsx(TaskSessionRow, { line: line, width: width }, `${line.taskIndex ?? line.tone}-${index}`))), Array.from({ length: blankRows }, (_, index) => (_jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(width) }, `task-session-fill-${index}`)))] }));
17
+ }
18
+ export function taskSessionsDisplayLines(tasks, activeTaskId, selectedIndex, height, terminalWidth, state = {}) {
19
+ const viewportHeight = Math.max(1, Math.trunc(height));
20
+ const width = taskSessionsContentWidth(terminalWidth);
21
+ const lines = [
22
+ {
23
+ text: fitTaskSessionCandidates([
24
+ state.includeArchived ? "Task sessions · archived shown" : "Task sessions",
25
+ state.includeArchived ? "Sessions · all" : "Sessions",
26
+ "Tasks",
27
+ "T"
28
+ ], width),
29
+ tone: "heading"
30
+ }
31
+ ];
32
+ if (viewportHeight >= 3) {
33
+ lines.push({ text: taskSessionSummary(tasks, width), tone: "muted" });
34
+ }
35
+ if (viewportHeight >= 4 && state.action) {
36
+ lines.push({
37
+ text: fitTaskSessionText(state.action.type === "rename"
38
+ ? `rename · ${safeTaskSessionText(state.action.title)} · Enter save · Esc cancel`
39
+ : `delete · ${safeTaskSessionText(state.action.title)} · press D again · Esc cancel`, width),
40
+ tone: state.action.type === "delete" ? "danger" : "active"
41
+ });
42
+ }
43
+ else if (viewportHeight >= 4 && state.notice) {
44
+ lines.push({ text: fitTaskSessionText(state.notice, width), tone: "success" });
45
+ }
46
+ const slots = Math.max(0, viewportHeight - lines.length);
47
+ if (state.loading) {
48
+ if (slots > 0) {
49
+ lines.push({ text: fitTaskSessionText("loading task sessions", width), tone: "muted" });
50
+ }
51
+ return lines;
52
+ }
53
+ if (state.error) {
54
+ if (slots > 0) {
55
+ lines.push({ text: fitTaskSessionText(`error · ${safeTaskSessionText(state.error)}`, width), tone: "danger" });
56
+ }
57
+ return lines;
58
+ }
59
+ if (tasks.length === 0) {
60
+ if (slots > 0) {
61
+ lines.push({ text: fitTaskSessionText("No saved task sessions", width), tone: "muted" });
62
+ }
63
+ return lines;
64
+ }
65
+ const selected = clampTaskIndex(selectedIndex, tasks.length);
66
+ const visibleCount = Math.min(slots, tasks.length);
67
+ const start = taskSessionWindowStart(selected, tasks.length, visibleCount);
68
+ for (let index = start; index < start + visibleCount; index += 1) {
69
+ const task = tasks[index];
70
+ if (!task) {
71
+ continue;
72
+ }
73
+ lines.push({
74
+ text: taskSessionRowText(task, index === selected, task.id === activeTaskId, width),
75
+ tone: taskSessionStatusTone(task),
76
+ taskIndex: index
77
+ });
78
+ }
79
+ return lines;
80
+ }
81
+ export function moveTaskSessionSelection(current, delta, taskCount, wrap = false) {
82
+ if (taskCount <= 0) {
83
+ return 0;
84
+ }
85
+ const normalizedCurrent = clampTaskIndex(current, taskCount);
86
+ const next = normalizedCurrent + Math.trunc(delta);
87
+ if (wrap) {
88
+ return ((next % taskCount) + taskCount) % taskCount;
89
+ }
90
+ return Math.min(taskCount - 1, Math.max(0, next));
91
+ }
92
+ function TaskSessionRow({ line, width }) {
93
+ const trailingWidth = Math.max(0, width - displayWidth(line.text));
94
+ const theme = taskSessionLineTheme(line.tone);
95
+ return (_jsxs(Text, { children: [_jsx(Text, { ...theme, children: line.text }), trailingWidth > 0 ? _jsx(Text, { backgroundColor: TUI_THEME.surface, children: " ".repeat(trailingWidth) }) : null] }));
96
+ }
97
+ function taskSessionSummary(tasks, width) {
98
+ const counts = new Map();
99
+ let archived = 0;
100
+ for (const task of tasks) {
101
+ if (task.archived_at) {
102
+ archived += 1;
103
+ continue;
104
+ }
105
+ const group = taskSessionStatusGroup(task.status);
106
+ counts.set(group, (counts.get(group) ?? 0) + 1);
107
+ }
108
+ const parts = ["running", "paused", "done", "failed", "cancelled"].flatMap((group) => {
109
+ const count = counts.get(group) ?? 0;
110
+ return count > 0 ? [`${count} ${group}`] : [];
111
+ });
112
+ if (archived > 0) {
113
+ parts.push(`${archived} archived`);
114
+ }
115
+ return fitTaskSessionCandidates([
116
+ [`${tasks.length} ${tasks.length === 1 ? "task" : "tasks"}`, ...parts].join(" · "),
117
+ `${tasks.length} tasks · ${counts.get("running") ?? 0} active · ${counts.get("failed") ?? 0} failed`,
118
+ `${tasks.length} tasks`,
119
+ `${tasks.length}t`
120
+ ], width);
121
+ }
122
+ function taskSessionRowText(task, selected, active, width) {
123
+ const marker = `${selected ? ">" : " "} ${active ? "*" : " "} `;
124
+ const title = safeTaskSessionText(task.title);
125
+ const status = task.archived_at
126
+ ? `archived · ${humanizeTaskSessionStatus(task.status)}`
127
+ : humanizeTaskSessionStatus(task.status);
128
+ const date = task.created_at.slice(5, 16).replace("T", " ");
129
+ const turns = `${task.turnCount} ${task.turnCount === 1 ? "turn" : "turns"}`;
130
+ const workers = `${task.workerCount} ${task.workerCount === 1 ? "worker" : "workers"}`;
131
+ const native = `${task.nativeSessionCount} native`;
132
+ const compactId = task.id.replace(/^task-/, "#");
133
+ return fitTaskSessionCandidates([
134
+ [marker + title, status, turns, workers, native, date].join(" · "),
135
+ [marker + title, status, turns, workers, native].join(" · "),
136
+ [marker + title, status, turns, workers].join(" · "),
137
+ [marker + title, status].join(" · "),
138
+ [marker + compactId, status].join(" · "),
139
+ marker.trimEnd()
140
+ ], width);
141
+ }
142
+ function taskSessionWindowStart(selected, count, visibleCount) {
143
+ if (visibleCount <= 0 || count <= visibleCount) {
144
+ return 0;
145
+ }
146
+ return Math.min(count - visibleCount, Math.max(0, selected - Math.floor(visibleCount / 2)));
147
+ }
148
+ function clampTaskIndex(index, count) {
149
+ return Math.min(Math.max(0, count - 1), Math.max(0, Math.trunc(index)));
150
+ }
151
+ function taskSessionStatusGroup(status) {
152
+ if (status === "paused" || status === "done" || status === "failed" || status === "cancelled") {
153
+ return status;
154
+ }
155
+ return "running";
156
+ }
157
+ function humanizeTaskSessionStatus(status) {
158
+ return status.replace(/_/g, " ");
159
+ }
160
+ function taskSessionStatusTone(task) {
161
+ if (task.archived_at) {
162
+ return "muted";
163
+ }
164
+ const status = task.status;
165
+ if (status === "done") {
166
+ return "success";
167
+ }
168
+ if (status === "paused") {
169
+ return "warning";
170
+ }
171
+ if (status === "failed") {
172
+ return "danger";
173
+ }
174
+ if (status === "cancelled") {
175
+ return "warning";
176
+ }
177
+ return "active";
178
+ }
179
+ function fitTaskSessionCandidates(candidates, width) {
180
+ return candidates.find((candidate) => displayWidth(candidate) <= width)
181
+ ?? fitTaskSessionText(candidates.at(-1) ?? "", width);
182
+ }
183
+ function fitTaskSessionText(text, width) {
184
+ return compactEndByDisplayWidth(text, Math.max(1, width));
185
+ }
186
+ function safeTaskSessionText(text) {
187
+ return text
188
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
189
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
190
+ .replace(/\s+/g, " ")
191
+ .trim();
192
+ }
193
+ function taskSessionLineTheme(tone) {
194
+ return {
195
+ backgroundColor: TUI_THEME.surface,
196
+ color: tone === "heading" || tone === "active"
197
+ ? TUI_THEME.accent
198
+ : tone === "success"
199
+ ? TUI_THEME.success
200
+ : tone === "warning"
201
+ ? TUI_THEME.warning
202
+ : tone === "danger"
203
+ ? TUI_THEME.danger
204
+ : TUI_THEME.muted,
205
+ ...(tone === "heading" || tone === "danger" ? { bold: true } : {})
206
+ };
207
+ }
208
+ function taskSessionsContentWidth(terminalWidth) {
209
+ return Math.max(1, terminalWidth - 2);
210
+ }
@@ -1,18 +1,36 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function TerminalOutput({ lines }) {
3
+ import { displayWidth } from "./display-width.js";
4
+ import { TUI_THEME } from "./theme.js";
5
+ const TERMINAL_OUTPUT_EMPTY_TEXT = "waiting for output";
6
+ export function TerminalOutput({ lines, minLines, width }) {
7
+ const blankTailLineCount = terminalOutputBlankTailLineCount(lines.length === 0 ? 1 : lines.length, minLines);
4
8
  if (lines.length === 0) {
5
- return _jsx(Text, { children: "(no output yet)" });
9
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { ...terminalOutputEmptyTheme(), children: TERMINAL_OUTPUT_EMPTY_TEXT }), _jsx(TerminalOutputEmptyTrailingFill, { width: width })] }), _jsx(TerminalOutputBlankTailLines, { count: blankTailLineCount, width: width })] }));
6
10
  }
7
- return (_jsx(Box, { flexDirection: "column", children: lines.map((line, index) => (_jsx(Text, { children: line.chunks.length === 0
8
- ? " "
9
- : line.chunks.map((chunk, chunkIndex) => (_jsx(Text, { ...textProps(chunk.style), children: chunk.text }, chunkIndex))) }, index))) }));
11
+ return (_jsxs(Box, { flexDirection: "column", children: [lines.map((line, index) => (_jsx(Text, { children: line.chunks.length === 0
12
+ ? _jsx(Text, { ...terminalOutputBlankLineTheme(), children: " ".repeat(terminalOutputBlankLineWidth(width)) })
13
+ : _jsxs(_Fragment, { children: [line.chunks.map((chunk, chunkIndex) => (_jsx(Text, { ...terminalOutputTextProps(chunk.style), children: chunk.text }, chunkIndex))), _jsx(TerminalOutputTrailingFill, { line: line, width: width })] }) }, index))), _jsx(TerminalOutputBlankTailLines, { count: blankTailLineCount, width: width })] }));
10
14
  }
11
- function textProps(style) {
15
+ export function terminalOutputEmptyTheme() {
12
16
  return {
13
- backgroundColor: style.backgroundColor,
17
+ backgroundColor: TUI_THEME.surface,
18
+ color: TUI_THEME.muted
19
+ };
20
+ }
21
+ export function terminalOutputEmptyText() {
22
+ return TERMINAL_OUTPUT_EMPTY_TEXT;
23
+ }
24
+ export function terminalOutputBlankLineTheme() {
25
+ return {
26
+ backgroundColor: TUI_THEME.surface
27
+ };
28
+ }
29
+ export function terminalOutputTextProps(style) {
30
+ return {
31
+ backgroundColor: style.backgroundColor ?? TUI_THEME.surface,
14
32
  bold: style.bold,
15
- color: style.color,
33
+ color: style.color ?? TUI_THEME.text,
16
34
  dimColor: style.dimColor,
17
35
  inverse: style.inverse || style.cursor,
18
36
  italic: style.italic,
@@ -20,3 +38,29 @@ function textProps(style) {
20
38
  underline: style.underline
21
39
  };
22
40
  }
41
+ export function terminalOutputTrailingFillWidth(line, width) {
42
+ if (width === undefined) {
43
+ return 0;
44
+ }
45
+ return Math.max(0, width - terminalOutputLineDisplayWidth(line));
46
+ }
47
+ function TerminalOutputTrailingFill({ line, width }) {
48
+ const fillWidth = terminalOutputTrailingFillWidth(line, width);
49
+ return fillWidth > 0 ? _jsx(Text, { ...terminalOutputBlankLineTheme(), children: " ".repeat(fillWidth) }) : null;
50
+ }
51
+ function TerminalOutputEmptyTrailingFill({ width }) {
52
+ const fillWidth = width === undefined ? 0 : Math.max(0, width - displayWidth(TERMINAL_OUTPUT_EMPTY_TEXT));
53
+ return fillWidth > 0 ? _jsx(Text, { ...terminalOutputBlankLineTheme(), children: " ".repeat(fillWidth) }) : null;
54
+ }
55
+ function TerminalOutputBlankTailLines({ count, width }) {
56
+ return (_jsx(_Fragment, { children: Array.from({ length: count }, (_, index) => (_jsx(Text, { ...terminalOutputBlankLineTheme(), children: " ".repeat(terminalOutputBlankLineWidth(width)) }, `blank-tail-${index}`))) }));
57
+ }
58
+ function terminalOutputBlankTailLineCount(lineCount, minLines) {
59
+ return minLines === undefined ? 0 : Math.max(0, minLines - lineCount);
60
+ }
61
+ function terminalOutputLineDisplayWidth(line) {
62
+ return line.chunks.reduce((sum, chunk) => sum + displayWidth(chunk.text), 0);
63
+ }
64
+ function terminalOutputBlankLineWidth(width) {
65
+ return Math.max(1, width ?? 1);
66
+ }