@jmoyers/harness 0.1.0

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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/native/ptyd/Cargo.lock +16 -0
  4. package/native/ptyd/Cargo.toml +7 -0
  5. package/native/ptyd/src/main.rs +257 -0
  6. package/package.json +90 -0
  7. package/scripts/build-ptyd.sh +73 -0
  8. package/scripts/control-plane-daemon.ts +277 -0
  9. package/scripts/cursor-hook-relay.ts +82 -0
  10. package/scripts/harness-animate.ts +469 -0
  11. package/scripts/harness-bin.js +77 -0
  12. package/scripts/harness-core.ts +1 -0
  13. package/scripts/harness-inspector.ts +439 -0
  14. package/scripts/harness.ts +2493 -0
  15. package/src/adapters/agent-session-state.ts +390 -0
  16. package/src/cli/gateway-record.ts +173 -0
  17. package/src/codex/live-session.ts +872 -0
  18. package/src/config/config-core.ts +1359 -0
  19. package/src/config/secrets-core.ts +170 -0
  20. package/src/control-plane/agent-realtime-api.ts +2441 -0
  21. package/src/control-plane/codex-session-stream.ts +392 -0
  22. package/src/control-plane/codex-telemetry.ts +1325 -0
  23. package/src/control-plane/lifecycle-hooks.ts +706 -0
  24. package/src/control-plane/session-summary.ts +380 -0
  25. package/src/control-plane/status/agent-status-reducer.ts +21 -0
  26. package/src/control-plane/status/reducer-base.ts +170 -0
  27. package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
  28. package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
  29. package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
  30. package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
  31. package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
  32. package/src/control-plane/status/session-status-engine.ts +76 -0
  33. package/src/control-plane/stream-client.ts +396 -0
  34. package/src/control-plane/stream-command-parser.ts +1673 -0
  35. package/src/control-plane/stream-protocol.ts +1808 -0
  36. package/src/control-plane/stream-server-background.ts +486 -0
  37. package/src/control-plane/stream-server-command.ts +2557 -0
  38. package/src/control-plane/stream-server-connection.ts +234 -0
  39. package/src/control-plane/stream-server-observed-filter.ts +112 -0
  40. package/src/control-plane/stream-server-session-runtime.ts +566 -0
  41. package/src/control-plane/stream-server-state-store.ts +15 -0
  42. package/src/control-plane/stream-server.ts +3192 -0
  43. package/src/cursor/managed-hooks.ts +282 -0
  44. package/src/domain/conversations.ts +414 -0
  45. package/src/domain/directories.ts +78 -0
  46. package/src/domain/repositories.ts +123 -0
  47. package/src/domain/tasks.ts +148 -0
  48. package/src/domain/workspace.ts +156 -0
  49. package/src/events/normalized-events.ts +124 -0
  50. package/src/mux/ansi-integrity.ts +103 -0
  51. package/src/mux/control-plane-op-queue.ts +212 -0
  52. package/src/mux/conversation-rail.ts +339 -0
  53. package/src/mux/double-click.ts +78 -0
  54. package/src/mux/dual-pane-core.ts +435 -0
  55. package/src/mux/harness-core-ui.ts +817 -0
  56. package/src/mux/input-shortcuts.ts +667 -0
  57. package/src/mux/live-mux/actions-conversation.ts +344 -0
  58. package/src/mux/live-mux/actions-repository.ts +246 -0
  59. package/src/mux/live-mux/actions-task.ts +115 -0
  60. package/src/mux/live-mux/args.ts +142 -0
  61. package/src/mux/live-mux/command-menu.ts +298 -0
  62. package/src/mux/live-mux/control-plane-records.ts +546 -0
  63. package/src/mux/live-mux/conversation-state.ts +188 -0
  64. package/src/mux/live-mux/directory-resolution.ts +34 -0
  65. package/src/mux/live-mux/event-mapping.ts +96 -0
  66. package/src/mux/live-mux/gateway-profiler.ts +152 -0
  67. package/src/mux/live-mux/gateway-render-trace.ts +177 -0
  68. package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
  69. package/src/mux/live-mux/git-parsing.ts +131 -0
  70. package/src/mux/live-mux/git-snapshot.ts +263 -0
  71. package/src/mux/live-mux/git-state.ts +136 -0
  72. package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
  73. package/src/mux/live-mux/home-pane-actions.ts +58 -0
  74. package/src/mux/live-mux/home-pane-drop.ts +44 -0
  75. package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
  76. package/src/mux/live-mux/home-pane-pointer.ts +96 -0
  77. package/src/mux/live-mux/input-forwarding.ts +112 -0
  78. package/src/mux/live-mux/layout.ts +30 -0
  79. package/src/mux/live-mux/left-nav-activation.ts +103 -0
  80. package/src/mux/live-mux/left-nav.ts +85 -0
  81. package/src/mux/live-mux/left-rail-actions.ts +118 -0
  82. package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
  83. package/src/mux/live-mux/left-rail-pointer.ts +74 -0
  84. package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
  85. package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
  86. package/src/mux/live-mux/modal-input-reducers.ts +94 -0
  87. package/src/mux/live-mux/modal-overlays.ts +287 -0
  88. package/src/mux/live-mux/modal-pointer.ts +70 -0
  89. package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
  90. package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
  91. package/src/mux/live-mux/observed-stream.ts +87 -0
  92. package/src/mux/live-mux/palette-parsing.ts +128 -0
  93. package/src/mux/live-mux/pointer-routing.ts +108 -0
  94. package/src/mux/live-mux/process-usage.ts +53 -0
  95. package/src/mux/live-mux/project-pane-pointer.ts +44 -0
  96. package/src/mux/live-mux/rail-layout.ts +244 -0
  97. package/src/mux/live-mux/render-trace-analysis.ts +213 -0
  98. package/src/mux/live-mux/render-trace-state.ts +84 -0
  99. package/src/mux/live-mux/repository-folding.ts +207 -0
  100. package/src/mux/live-mux/runtime-shutdown.ts +51 -0
  101. package/src/mux/live-mux/selection.ts +411 -0
  102. package/src/mux/live-mux/startup-utils.ts +187 -0
  103. package/src/mux/live-mux/status-timeline-state.ts +82 -0
  104. package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
  105. package/src/mux/live-mux/terminal-palette.ts +79 -0
  106. package/src/mux/new-thread-prompt.ts +165 -0
  107. package/src/mux/project-tree.ts +295 -0
  108. package/src/mux/render-frame.ts +113 -0
  109. package/src/mux/runtime-wiring.ts +185 -0
  110. package/src/mux/selector-index.ts +160 -0
  111. package/src/mux/startup-sequencer.ts +238 -0
  112. package/src/mux/task-composer.ts +289 -0
  113. package/src/mux/task-focused-pane.ts +417 -0
  114. package/src/mux/task-screen-keybindings.ts +539 -0
  115. package/src/mux/terminal-input-modes.ts +35 -0
  116. package/src/mux/workspace-path.ts +55 -0
  117. package/src/mux/workspace-rail-model.ts +701 -0
  118. package/src/mux/workspace-rail.ts +247 -0
  119. package/src/perf/perf-core.ts +307 -0
  120. package/src/pty/pty_host.ts +217 -0
  121. package/src/pty/session-broker.ts +158 -0
  122. package/src/recording/terminal-recording.ts +383 -0
  123. package/src/services/control-plane.ts +567 -0
  124. package/src/services/conversation-lifecycle.ts +176 -0
  125. package/src/services/conversation-startup-hydration.ts +47 -0
  126. package/src/services/directory-hydration.ts +49 -0
  127. package/src/services/event-persistence.ts +104 -0
  128. package/src/services/mux-ui-state-persistence.ts +82 -0
  129. package/src/services/output-load-sampler.ts +231 -0
  130. package/src/services/process-usage-refresh.ts +88 -0
  131. package/src/services/recording.ts +75 -0
  132. package/src/services/render-trace-recorder.ts +177 -0
  133. package/src/services/runtime-control-actions.ts +123 -0
  134. package/src/services/runtime-control-plane-ops.ts +131 -0
  135. package/src/services/runtime-conversation-actions.ts +113 -0
  136. package/src/services/runtime-conversation-activation.ts +78 -0
  137. package/src/services/runtime-conversation-starter.ts +171 -0
  138. package/src/services/runtime-conversation-title-edit.ts +149 -0
  139. package/src/services/runtime-directory-actions.ts +164 -0
  140. package/src/services/runtime-envelope-handler.ts +198 -0
  141. package/src/services/runtime-git-state.ts +92 -0
  142. package/src/services/runtime-input-pipeline.ts +50 -0
  143. package/src/services/runtime-input-router.ts +202 -0
  144. package/src/services/runtime-layout-resize.ts +236 -0
  145. package/src/services/runtime-left-rail-render.ts +159 -0
  146. package/src/services/runtime-main-pane-input.ts +230 -0
  147. package/src/services/runtime-modal-input.ts +119 -0
  148. package/src/services/runtime-navigation-input.ts +207 -0
  149. package/src/services/runtime-process-wiring.ts +68 -0
  150. package/src/services/runtime-rail-input.ts +287 -0
  151. package/src/services/runtime-render-flush.ts +146 -0
  152. package/src/services/runtime-render-lifecycle.ts +104 -0
  153. package/src/services/runtime-render-orchestrator.ts +108 -0
  154. package/src/services/runtime-render-pipeline.ts +167 -0
  155. package/src/services/runtime-render-state.ts +72 -0
  156. package/src/services/runtime-repository-actions.ts +197 -0
  157. package/src/services/runtime-right-pane-render.ts +132 -0
  158. package/src/services/runtime-shutdown.ts +79 -0
  159. package/src/services/runtime-stream-subscriptions.ts +56 -0
  160. package/src/services/runtime-task-composer-persistence.ts +139 -0
  161. package/src/services/runtime-task-editor-actions.ts +83 -0
  162. package/src/services/runtime-task-pane-actions.ts +198 -0
  163. package/src/services/runtime-task-pane-shortcuts.ts +189 -0
  164. package/src/services/runtime-task-pane.ts +62 -0
  165. package/src/services/runtime-workspace-actions.ts +153 -0
  166. package/src/services/runtime-workspace-observed-events.ts +190 -0
  167. package/src/services/session-projection-instrumentation.ts +190 -0
  168. package/src/services/startup-background-probe.ts +91 -0
  169. package/src/services/startup-background-resume.ts +65 -0
  170. package/src/services/startup-orchestrator.ts +166 -0
  171. package/src/services/startup-output-tracker.ts +54 -0
  172. package/src/services/startup-paint-tracker.ts +115 -0
  173. package/src/services/startup-persisted-conversation-queue.ts +45 -0
  174. package/src/services/startup-settled-gate.ts +67 -0
  175. package/src/services/startup-shutdown.ts +53 -0
  176. package/src/services/startup-span-tracker.ts +77 -0
  177. package/src/services/startup-state-hydration.ts +94 -0
  178. package/src/services/startup-visibility.ts +35 -0
  179. package/src/services/status-timeline-recorder.ts +144 -0
  180. package/src/services/task-pane-selection-actions.ts +153 -0
  181. package/src/services/task-planning-hydration.ts +58 -0
  182. package/src/services/task-planning-observed-events.ts +89 -0
  183. package/src/services/workspace-observed-events.ts +113 -0
  184. package/src/store/control-plane-store-normalize.ts +760 -0
  185. package/src/store/control-plane-store-types.ts +224 -0
  186. package/src/store/control-plane-store.ts +2951 -0
  187. package/src/store/event-store.ts +253 -0
  188. package/src/store/sqlite.ts +81 -0
  189. package/src/terminal/compat-matrix.ts +345 -0
  190. package/src/terminal/differential-checkpoints.ts +132 -0
  191. package/src/terminal/parity-suite.ts +441 -0
  192. package/src/terminal/snapshot-oracle.ts +1840 -0
  193. package/src/ui/conversation-input-forwarder.ts +114 -0
  194. package/src/ui/conversation-selection-input.ts +103 -0
  195. package/src/ui/debug-footer-notice.ts +39 -0
  196. package/src/ui/global-shortcut-input.ts +126 -0
  197. package/src/ui/input-preflight.ts +68 -0
  198. package/src/ui/input-token-router.ts +312 -0
  199. package/src/ui/input.ts +238 -0
  200. package/src/ui/kit.ts +509 -0
  201. package/src/ui/left-nav-input.ts +80 -0
  202. package/src/ui/left-rail-pointer-input.ts +148 -0
  203. package/src/ui/main-pane-pointer-input.ts +150 -0
  204. package/src/ui/modals/manager.ts +192 -0
  205. package/src/ui/mux-theme.ts +529 -0
  206. package/src/ui/panes/conversation.ts +19 -0
  207. package/src/ui/panes/home-gridfire.ts +302 -0
  208. package/src/ui/panes/home.ts +109 -0
  209. package/src/ui/panes/left-rail.ts +12 -0
  210. package/src/ui/panes/project.ts +44 -0
  211. package/src/ui/pointer-routing-input.ts +158 -0
  212. package/src/ui/repository-fold-input.ts +91 -0
  213. package/src/ui/screen.ts +210 -0
  214. package/src/ui/surface.ts +224 -0
@@ -0,0 +1,701 @@
1
+ import type { ConversationRailSessionSummary } from './conversation-rail.ts';
2
+ import { formatUiButton } from '../ui/kit.ts';
3
+ import type {
4
+ StreamSessionController,
5
+ StreamSessionDisplayPhase,
6
+ } from '../control-plane/stream-protocol.ts';
7
+
8
+ interface WorkspaceRailGitSummary {
9
+ readonly branch: string;
10
+ readonly changedFiles: number;
11
+ readonly additions: number;
12
+ readonly deletions: number;
13
+ }
14
+
15
+ interface WorkspaceRailDirectorySummary {
16
+ readonly key: string;
17
+ readonly workspaceId: string;
18
+ readonly worktreeId: string;
19
+ readonly repositoryId?: string | null;
20
+ readonly git: WorkspaceRailGitSummary;
21
+ }
22
+
23
+ interface WorkspaceRailConversationSummary {
24
+ readonly sessionId: string;
25
+ readonly directoryKey: string;
26
+ readonly title: string;
27
+ readonly agentLabel: string;
28
+ readonly cpuPercent: number | null;
29
+ readonly memoryMb: number | null;
30
+ readonly status?: ConversationRailSessionSummary['status'];
31
+ readonly lastKnownWork?: string | null;
32
+ readonly lastKnownWorkAt?: string | null;
33
+ readonly statusModel: ConversationRailSessionSummary['statusModel'] | null;
34
+ readonly attentionReason: string | null;
35
+ readonly startedAt: string;
36
+ readonly lastEventAt: string | null;
37
+ readonly controller?: StreamSessionController | null;
38
+ }
39
+
40
+ interface WorkspaceRailRepositorySummary {
41
+ readonly repositoryId: string;
42
+ readonly name: string;
43
+ readonly remoteUrl: string;
44
+ readonly associatedProjectCount: number;
45
+ readonly commitCount: number | null;
46
+ readonly lastCommitAt: string | null;
47
+ readonly shortCommitHash: string | null;
48
+ }
49
+
50
+ interface WorkspaceRailProcessSummary {
51
+ readonly key: string;
52
+ readonly directoryKey: string;
53
+ readonly label: string;
54
+ readonly cpuPercent: number | null;
55
+ readonly memoryMb: number | null;
56
+ readonly status: 'running' | 'exited';
57
+ }
58
+
59
+ interface WorkspaceRailModel {
60
+ readonly repositories?: readonly WorkspaceRailRepositorySummary[];
61
+ readonly directories: readonly WorkspaceRailDirectorySummary[];
62
+ readonly conversations: readonly WorkspaceRailConversationSummary[];
63
+ readonly processes: readonly WorkspaceRailProcessSummary[];
64
+ readonly showTaskPlanningUi?: boolean;
65
+ readonly activeProjectId: string | null;
66
+ readonly activeRepositoryId?: string | null;
67
+ readonly activeConversationId: string | null;
68
+ readonly projectSelectionEnabled?: boolean;
69
+ readonly repositorySelectionEnabled?: boolean;
70
+ readonly homeSelectionEnabled?: boolean;
71
+ readonly repositoriesCollapsed?: boolean;
72
+ readonly collapsedRepositoryGroupIds?: readonly string[];
73
+ readonly shortcutHint?: string;
74
+ readonly shortcutsCollapsed?: boolean;
75
+ readonly nowMs?: number;
76
+ }
77
+
78
+ interface WorkspaceRailViewRow {
79
+ readonly kind:
80
+ | 'dir-header'
81
+ | 'dir-meta'
82
+ | 'conversation-title'
83
+ | 'conversation-body'
84
+ | 'process-title'
85
+ | 'process-meta'
86
+ | 'repository-header'
87
+ | 'repository-row'
88
+ | 'shortcut-header'
89
+ | 'shortcut-body'
90
+ | 'action'
91
+ | 'muted';
92
+ readonly text: string;
93
+ readonly active: boolean;
94
+ readonly conversationSessionId: string | null;
95
+ readonly directoryKey: string | null;
96
+ readonly repositoryId: string | null;
97
+ readonly railAction: WorkspaceRailAction | null;
98
+ readonly conversationStatus: NormalizedConversationStatus | null;
99
+ }
100
+
101
+ const NEW_THREAD_INLINE_LABEL = '[+ thread]';
102
+ const UNTRACKED_REPOSITORY_GROUP_ID = 'untracked';
103
+ const ADD_PROJECT_BUTTON_LABEL = formatUiButton({
104
+ label: 'add project',
105
+ prefixIcon: '>',
106
+ });
107
+
108
+ type WorkspaceRailAction =
109
+ | 'conversation.new'
110
+ | 'conversation.delete'
111
+ | 'project.add'
112
+ | 'home.open'
113
+ | 'project.close'
114
+ | 'shortcuts.toggle'
115
+ | 'repository.toggle'
116
+ | 'repository.add'
117
+ | 'repository.edit'
118
+ | 'repository.archive'
119
+ | 'repositories.toggle';
120
+
121
+ type NormalizedConversationStatus = StreamSessionDisplayPhase;
122
+
123
+ interface WorkspaceRailConversationProjection {
124
+ readonly status: NormalizedConversationStatus;
125
+ readonly glyph: string;
126
+ readonly detailText: string;
127
+ readonly statusVisible: boolean;
128
+ }
129
+
130
+ function fixedThreadGlyphForAgent(agentLabel: string): string {
131
+ const normalized = agentLabel.trim().toLowerCase();
132
+ if (normalized === 'terminal') {
133
+ return '⌨';
134
+ }
135
+ if (normalized === 'critique') {
136
+ return '✎';
137
+ }
138
+ return '';
139
+ }
140
+
141
+ function processStatusText(status: WorkspaceRailProcessSummary['status']): string {
142
+ return status === 'running' ? 'running' : 'exited';
143
+ }
144
+
145
+ function formatCpu(value: number | null): string {
146
+ if (value === null || !Number.isFinite(value)) {
147
+ return '·';
148
+ }
149
+ return `${value.toFixed(1)}%`;
150
+ }
151
+
152
+ function formatMem(value: number | null): string {
153
+ if (value === null || !Number.isFinite(value)) {
154
+ return '·';
155
+ }
156
+ return `${String(Math.max(0, Math.round(value)))}MB`;
157
+ }
158
+
159
+ function summaryText(value: string | null): string | null {
160
+ if (value === null) {
161
+ return null;
162
+ }
163
+ const normalized = value.replace(/\s+/gu, ' ').trim();
164
+ return normalized.length === 0 ? null : normalized;
165
+ }
166
+
167
+ function statusLineLabel(status: NormalizedConversationStatus): string {
168
+ const labels: Record<NormalizedConversationStatus, string> = {
169
+ 'needs-action': 'needs input',
170
+ starting: 'starting',
171
+ working: 'active',
172
+ idle: 'inactive',
173
+ exited: 'exited',
174
+ };
175
+ return labels[status];
176
+ }
177
+
178
+ function conversationDetailText(
179
+ conversation: WorkspaceRailConversationSummary,
180
+ normalizedStatus: NormalizedConversationStatus,
181
+ ): string {
182
+ const detailText = summaryText(conversation.statusModel?.detailText ?? null);
183
+ if (detailText !== null) {
184
+ return detailText;
185
+ }
186
+ const attentionReason = summaryText(conversation.attentionReason);
187
+ if (attentionReason !== null) {
188
+ return attentionReason;
189
+ }
190
+ return statusLineLabel(normalizedStatus);
191
+ }
192
+
193
+ function statusFromRuntimeStatus(
194
+ status: WorkspaceRailConversationSummary['status'] | undefined,
195
+ ): NormalizedConversationStatus {
196
+ if (status === 'needs-input') {
197
+ return 'needs-action';
198
+ }
199
+ if (status === 'running') {
200
+ return 'starting';
201
+ }
202
+ if (status === 'exited') {
203
+ return 'exited';
204
+ }
205
+ return 'idle';
206
+ }
207
+
208
+ function statusVisibleForAgent(agentLabel: string): boolean {
209
+ const normalized = agentLabel.trim().toLowerCase();
210
+ return normalized !== 'terminal' && normalized !== 'critique';
211
+ }
212
+
213
+ export function projectWorkspaceRailConversation(
214
+ conversation: WorkspaceRailConversationSummary,
215
+ _options: {
216
+ readonly nowMs?: number;
217
+ } = {},
218
+ ): WorkspaceRailConversationProjection {
219
+ const statusModel = conversation.statusModel;
220
+ const normalizedStatus = statusModel?.phase ?? statusFromRuntimeStatus(conversation.status);
221
+ const statusVisible = statusModel !== null && statusVisibleForAgent(conversation.agentLabel);
222
+ const fixedGlyph = fixedThreadGlyphForAgent(conversation.agentLabel);
223
+ return {
224
+ status: normalizedStatus,
225
+ glyph: statusVisible ? (statusModel?.glyph ?? fixedGlyph) : fixedGlyph,
226
+ detailText: statusVisible ? conversationDetailText(conversation, normalizedStatus) : '',
227
+ statusVisible,
228
+ };
229
+ }
230
+
231
+ function directoryDisplayName(directory: WorkspaceRailDirectorySummary): string {
232
+ const name = directory.workspaceId.trim();
233
+ if (name.length === 0) {
234
+ return '(unnamed)';
235
+ }
236
+ return name;
237
+ }
238
+
239
+ function trackedProjectGitSuffix(git: WorkspaceRailGitSummary): string {
240
+ if (git.additions === 0 && git.deletions === 0) {
241
+ return ` (${git.branch})`;
242
+ }
243
+ return ` (${git.branch}:+${String(git.additions)},-${String(git.deletions)})`;
244
+ }
245
+
246
+ function conversationDisplayTitle(conversation: WorkspaceRailConversationSummary): string {
247
+ const title = conversation.title.trim();
248
+ if (title.length === 0) {
249
+ return conversation.agentLabel;
250
+ }
251
+ return `${conversation.agentLabel} - ${conversation.title}`;
252
+ }
253
+
254
+ function pushRow(
255
+ rows: WorkspaceRailViewRow[],
256
+ kind: WorkspaceRailViewRow['kind'],
257
+ text: string,
258
+ active = false,
259
+ conversationSessionId: string | null = null,
260
+ directoryKey: string | null = null,
261
+ repositoryId: string | null = null,
262
+ railAction: WorkspaceRailAction | null = null,
263
+ conversationStatus: NormalizedConversationStatus | null = null,
264
+ ): void {
265
+ rows.push({
266
+ kind,
267
+ text,
268
+ active,
269
+ conversationSessionId,
270
+ directoryKey,
271
+ repositoryId,
272
+ railAction,
273
+ conversationStatus,
274
+ });
275
+ }
276
+
277
+ function buildContentRows(
278
+ model: WorkspaceRailModel,
279
+ nowMs: number,
280
+ ): readonly WorkspaceRailViewRow[] {
281
+ const rows: WorkspaceRailViewRow[] = [];
282
+ const showTaskPlanningUi = model.showTaskPlanningUi ?? true;
283
+ const homeSelectionEnabled = model.homeSelectionEnabled ?? false;
284
+ const projectSelectionEnabled = model.projectSelectionEnabled ?? false;
285
+ const repositorySelectionEnabled = model.repositorySelectionEnabled ?? false;
286
+ const collapsedRepositoryGroupIds = new Set(model.collapsedRepositoryGroupIds ?? []);
287
+ const repositoryById = new Map(
288
+ (model.repositories ?? []).map((repository) => [repository.repositoryId, repository] as const),
289
+ );
290
+ const repositoryGroups = new Map<
291
+ string,
292
+ {
293
+ readonly name: string;
294
+ readonly tracked: boolean;
295
+ readonly directories: WorkspaceRailDirectorySummary[];
296
+ }
297
+ >();
298
+
299
+ const ensureRepositoryGroup = (
300
+ repositoryId: string,
301
+ name: string,
302
+ tracked: boolean,
303
+ ): {
304
+ readonly name: string;
305
+ readonly tracked: boolean;
306
+ readonly directories: WorkspaceRailDirectorySummary[];
307
+ } => {
308
+ const existing = repositoryGroups.get(repositoryId);
309
+ if (existing !== undefined) {
310
+ return existing;
311
+ }
312
+ const created = {
313
+ name,
314
+ tracked,
315
+ directories: [],
316
+ };
317
+ repositoryGroups.set(repositoryId, created);
318
+ return created;
319
+ };
320
+
321
+ for (const directory of model.directories) {
322
+ const repositoryId = directory.repositoryId;
323
+ if (repositoryId === undefined || repositoryId === null || !repositoryById.has(repositoryId)) {
324
+ ensureRepositoryGroup(
325
+ UNTRACKED_REPOSITORY_GROUP_ID,
326
+ UNTRACKED_REPOSITORY_GROUP_ID,
327
+ false,
328
+ ).directories.push(directory);
329
+ continue;
330
+ }
331
+ const repository = repositoryById.get(repositoryId)!;
332
+ ensureRepositoryGroup(repository.repositoryId, repository.name, true).directories.push(
333
+ directory,
334
+ );
335
+ }
336
+
337
+ const orderedRepositoryGroupIds: string[] = [];
338
+ for (const repository of model.repositories ?? []) {
339
+ const group = repositoryGroups.get(repository.repositoryId);
340
+ if (group === undefined || group.directories.length === 0) {
341
+ continue;
342
+ }
343
+ orderedRepositoryGroupIds.push(repository.repositoryId);
344
+ }
345
+ if (repositoryGroups.has(UNTRACKED_REPOSITORY_GROUP_ID)) {
346
+ orderedRepositoryGroupIds.push(UNTRACKED_REPOSITORY_GROUP_ID);
347
+ }
348
+
349
+ const activeConversationCountByDirectoryId = new Map<string, number>();
350
+ for (const conversation of model.conversations) {
351
+ const projection = projectWorkspaceRailConversation(conversation, {
352
+ nowMs,
353
+ });
354
+ if (!projection.statusVisible) {
355
+ continue;
356
+ }
357
+ if (
358
+ projection.status !== 'working' &&
359
+ projection.status !== 'starting' &&
360
+ projection.status !== 'needs-action'
361
+ ) {
362
+ continue;
363
+ }
364
+ const count = activeConversationCountByDirectoryId.get(conversation.directoryKey) ?? 0;
365
+ activeConversationCountByDirectoryId.set(conversation.directoryKey, count + 1);
366
+ }
367
+
368
+ if (showTaskPlanningUi) {
369
+ pushRow(rows, 'dir-header', '├─ 🏠 home', homeSelectionEnabled, null, null, null, 'home.open');
370
+ }
371
+
372
+ if (orderedRepositoryGroupIds.length === 0) {
373
+ pushRow(rows, 'dir-header', '├─ 📁 no projects');
374
+ pushRow(rows, 'muted', '│ create one with ctrl+o');
375
+ return rows;
376
+ }
377
+
378
+ for (const repositoryId of orderedRepositoryGroupIds) {
379
+ const group = repositoryGroups.get(repositoryId);
380
+ if (group === undefined || group.directories.length === 0) {
381
+ continue;
382
+ }
383
+ const activeProjectCount = group.directories.filter(
384
+ (directory) => (activeConversationCountByDirectoryId.get(directory.key) ?? 0) > 0,
385
+ ).length;
386
+ const repositorySelected =
387
+ repositorySelectionEnabled && model.activeRepositoryId === repositoryId;
388
+ const repositoryCollapsed =
389
+ model.repositoriesCollapsed === true || collapsedRepositoryGroupIds.has(repositoryId);
390
+ pushRow(
391
+ rows,
392
+ 'repository-header',
393
+ `├─ 📁 ${group.name} (${String(group.directories.length)} projects, ${String(activeProjectCount)} active) ${
394
+ repositoryCollapsed ? '[+]' : '[-]'
395
+ }`,
396
+ repositorySelected,
397
+ null,
398
+ null,
399
+ repositoryId,
400
+ 'repository.toggle',
401
+ );
402
+ if (repositoryCollapsed) {
403
+ continue;
404
+ }
405
+
406
+ for (let directoryIndex = 0; directoryIndex < group.directories.length; directoryIndex += 1) {
407
+ const directory = group.directories[directoryIndex]!;
408
+ const projectSelected = projectSelectionEnabled && directory.key === model.activeProjectId;
409
+ const projectIsLast = directoryIndex + 1 >= group.directories.length;
410
+ const projectTreePrefix = `│ ${projectIsLast ? '└' : '├'}─ `;
411
+ const projectChildPrefix = `│ ${projectIsLast ? ' ' : '│ '}`;
412
+ const projectGitSuffix = group.tracked ? trackedProjectGitSuffix(directory.git) : '';
413
+ pushRow(
414
+ rows,
415
+ 'dir-header',
416
+ `${projectTreePrefix}📁 ${directoryDisplayName(directory)}${projectGitSuffix} ${NEW_THREAD_INLINE_LABEL}`,
417
+ projectSelected,
418
+ null,
419
+ directory.key,
420
+ repositoryId,
421
+ null,
422
+ );
423
+
424
+ const conversations = model.conversations.filter(
425
+ (conversation) => conversation.directoryKey === directory.key,
426
+ );
427
+ for (
428
+ let conversationIndex = 0;
429
+ conversationIndex < conversations.length;
430
+ conversationIndex += 1
431
+ ) {
432
+ const conversation = conversations[conversationIndex]!;
433
+ const conversationIsLast = conversationIndex + 1 >= conversations.length;
434
+ const active =
435
+ !projectSelectionEnabled &&
436
+ !homeSelectionEnabled &&
437
+ !repositorySelectionEnabled &&
438
+ conversation.sessionId === model.activeConversationId;
439
+ const projection = projectWorkspaceRailConversation(conversation, {
440
+ nowMs,
441
+ });
442
+ const hasTitleGlyph = projection.glyph.trim().length > 0;
443
+ const titleText = hasTitleGlyph
444
+ ? `${projectChildPrefix}${conversationIsLast ? '└' : '├'}─ ${projection.glyph} ${conversationDisplayTitle(
445
+ conversation,
446
+ )}`
447
+ : `${projectChildPrefix}${conversationIsLast ? '└' : '├'}─ ${conversationDisplayTitle(
448
+ conversation,
449
+ )}`;
450
+ pushRow(
451
+ rows,
452
+ 'conversation-title',
453
+ titleText,
454
+ active,
455
+ conversation.sessionId,
456
+ directory.key,
457
+ repositoryId,
458
+ null,
459
+ projection.statusVisible ? projection.status : null,
460
+ );
461
+ if (projection.statusVisible) {
462
+ pushRow(
463
+ rows,
464
+ 'conversation-body',
465
+ `${projectChildPrefix}${conversationIsLast ? ' ' : '│ '}${projection.detailText}`,
466
+ active,
467
+ conversation.sessionId,
468
+ directory.key,
469
+ repositoryId,
470
+ null,
471
+ projection.status,
472
+ );
473
+ }
474
+ }
475
+
476
+ const processes = model.processes.filter((process) => process.directoryKey === directory.key);
477
+ for (const process of processes) {
478
+ pushRow(
479
+ rows,
480
+ 'process-title',
481
+ `${projectChildPrefix}⚙ ${process.label}`,
482
+ false,
483
+ null,
484
+ directory.key,
485
+ repositoryId,
486
+ null,
487
+ );
488
+ pushRow(
489
+ rows,
490
+ 'process-meta',
491
+ `${projectChildPrefix}${processStatusText(process.status)} · ${formatCpu(process.cpuPercent)} · ${formatMem(process.memoryMb)}`,
492
+ false,
493
+ null,
494
+ directory.key,
495
+ repositoryId,
496
+ null,
497
+ );
498
+ }
499
+ }
500
+ }
501
+
502
+ return rows;
503
+ }
504
+
505
+ function shortcutDescriptionRows(shortcutHint: string | undefined): readonly string[] {
506
+ const normalized = shortcutHint?.trim();
507
+ if (normalized === undefined || normalized.length === 0) {
508
+ return [
509
+ 'ctrl+t new thread',
510
+ 'ctrl+g critique thread',
511
+ 'ctrl+x archive thread',
512
+ 'ctrl+l take over thread',
513
+ 'ctrl+o add project',
514
+ 'ctrl+w close project',
515
+ 'ctrl+j/k switch nav',
516
+ '→ expand repo',
517
+ '← collapse repo',
518
+ 'ctrl+k ctrl+j expand all repos',
519
+ 'ctrl+k ctrl+0 collapse all repos',
520
+ 'ctrl+c quit mux',
521
+ ];
522
+ }
523
+ if (normalized.includes('\n')) {
524
+ return normalized
525
+ .split('\n')
526
+ .map((line) => line.trim())
527
+ .filter((line) => line.length > 0);
528
+ }
529
+ return normalized
530
+ .split(/\s{2,}/u)
531
+ .map((segment) => segment.trim())
532
+ .filter((segment) => segment.length > 0);
533
+ }
534
+
535
+ function shortcutRows(
536
+ shortcutHint: string | undefined,
537
+ shortcutsCollapsed: boolean,
538
+ ): readonly WorkspaceRailViewRow[] {
539
+ const rows: WorkspaceRailViewRow[] = [
540
+ {
541
+ kind: 'shortcut-header',
542
+ text: `├─ shortcuts ${shortcutsCollapsed ? '[+]' : '[-]'}`,
543
+ active: false,
544
+ conversationSessionId: null,
545
+ directoryKey: null,
546
+ repositoryId: null,
547
+ railAction: 'shortcuts.toggle',
548
+ conversationStatus: null,
549
+ },
550
+ ];
551
+ if (!shortcutsCollapsed) {
552
+ const descriptions = shortcutDescriptionRows(shortcutHint);
553
+ for (const description of descriptions) {
554
+ rows.push({
555
+ kind: 'shortcut-body',
556
+ text: `│ ${description}`,
557
+ active: false,
558
+ conversationSessionId: null,
559
+ directoryKey: null,
560
+ repositoryId: null,
561
+ railAction: null,
562
+ conversationStatus: null,
563
+ });
564
+ }
565
+ }
566
+ return rows;
567
+ }
568
+
569
+ export function buildWorkspaceRailViewRows(
570
+ model: WorkspaceRailModel,
571
+ maxRows: number,
572
+ ): readonly WorkspaceRailViewRow[] {
573
+ const safeRows = Math.max(1, maxRows);
574
+ const nowMs = model.nowMs ?? Date.now();
575
+ const contentRows = buildContentRows(model, nowMs);
576
+ const renderedShortcuts = shortcutRows(model.shortcutHint, model.shortcutsCollapsed ?? false);
577
+
578
+ if (safeRows <= renderedShortcuts.length) {
579
+ return renderedShortcuts.slice(renderedShortcuts.length - safeRows);
580
+ }
581
+
582
+ const contentCapacity = safeRows - renderedShortcuts.length;
583
+ const rows: WorkspaceRailViewRow[] = [...contentRows.slice(0, Math.max(0, contentCapacity - 1))];
584
+ while (rows.length < contentCapacity) {
585
+ rows.push({
586
+ kind: 'muted',
587
+ text: '│',
588
+ active: false,
589
+ conversationSessionId: null,
590
+ directoryKey: null,
591
+ repositoryId: null,
592
+ railAction: null,
593
+ conversationStatus: null,
594
+ });
595
+ }
596
+ const projectActionRow: WorkspaceRailViewRow = {
597
+ kind: 'action',
598
+ text: ADD_PROJECT_BUTTON_LABEL,
599
+ active: false,
600
+ conversationSessionId: null,
601
+ directoryKey: null,
602
+ repositoryId: null,
603
+ railAction: 'project.add',
604
+ conversationStatus: null,
605
+ };
606
+ const projectActionRowIndex = Math.max(0, contentCapacity - 3);
607
+ rows.splice(projectActionRowIndex, 0, projectActionRow);
608
+ if (rows.length > contentCapacity) {
609
+ rows.length = contentCapacity;
610
+ }
611
+ rows.push(...renderedShortcuts);
612
+ return rows;
613
+ }
614
+
615
+ export function conversationIdAtWorkspaceRailRow(
616
+ rows: readonly WorkspaceRailViewRow[],
617
+ rowIndex: number,
618
+ ): string | null {
619
+ const row = rows[rowIndex];
620
+ if (row === undefined) {
621
+ return null;
622
+ }
623
+ return row.conversationSessionId;
624
+ }
625
+
626
+ export function actionAtWorkspaceRailRow(
627
+ rows: readonly WorkspaceRailViewRow[],
628
+ rowIndex: number,
629
+ ): WorkspaceRailAction | null {
630
+ const row = rows[rowIndex];
631
+ if (row === undefined) {
632
+ return null;
633
+ }
634
+ return row.railAction;
635
+ }
636
+
637
+ export function actionAtWorkspaceRailCell(
638
+ rows: readonly WorkspaceRailViewRow[],
639
+ rowIndex: number,
640
+ colIndex: number,
641
+ paneCols: number | null = null,
642
+ ): WorkspaceRailAction | null {
643
+ const row = rows[rowIndex];
644
+ if (row === undefined) {
645
+ return null;
646
+ }
647
+ if (row.railAction !== null) {
648
+ return row.railAction;
649
+ }
650
+ if (row.kind !== 'dir-header') {
651
+ return null;
652
+ }
653
+ if (!row.text.includes(NEW_THREAD_INLINE_LABEL)) {
654
+ return null;
655
+ }
656
+ const buttonStart =
657
+ paneCols === null
658
+ ? row.text.lastIndexOf(NEW_THREAD_INLINE_LABEL)
659
+ : Math.max(0, Math.floor(paneCols) - NEW_THREAD_INLINE_LABEL.length);
660
+ const normalizedCol = Math.max(0, Math.floor(colIndex));
661
+ if (
662
+ normalizedCol < buttonStart ||
663
+ normalizedCol >= buttonStart + NEW_THREAD_INLINE_LABEL.length
664
+ ) {
665
+ return null;
666
+ }
667
+ return 'conversation.new';
668
+ }
669
+
670
+ export function projectIdAtWorkspaceRailRow(
671
+ rows: readonly WorkspaceRailViewRow[],
672
+ rowIndex: number,
673
+ ): string | null {
674
+ const row = rows[rowIndex];
675
+ if (row === undefined) {
676
+ return null;
677
+ }
678
+ return row.directoryKey;
679
+ }
680
+
681
+ export function repositoryIdAtWorkspaceRailRow(
682
+ rows: readonly WorkspaceRailViewRow[],
683
+ rowIndex: number,
684
+ ): string | null {
685
+ const row = rows[rowIndex];
686
+ if (row === undefined) {
687
+ return null;
688
+ }
689
+ return row.repositoryId;
690
+ }
691
+
692
+ export function kindAtWorkspaceRailRow(
693
+ rows: readonly WorkspaceRailViewRow[],
694
+ rowIndex: number,
695
+ ): WorkspaceRailViewRow['kind'] | null {
696
+ const row = rows[rowIndex];
697
+ if (row === undefined) {
698
+ return null;
699
+ }
700
+ return row.kind;
701
+ }