@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,289 @@
1
+ export interface TaskComposerBuffer {
2
+ readonly text: string;
3
+ readonly cursor: number;
4
+ }
5
+
6
+ interface VerticalMoveResult {
7
+ readonly next: TaskComposerBuffer;
8
+ readonly hitBoundary: boolean;
9
+ }
10
+
11
+ interface LineRange {
12
+ readonly start: number;
13
+ readonly end: number;
14
+ }
15
+
16
+ function clampCursor(text: string, cursor: number): number {
17
+ if (!Number.isFinite(cursor)) {
18
+ return text.length;
19
+ }
20
+ return Math.max(0, Math.min(text.length, Math.floor(cursor)));
21
+ }
22
+
23
+ function lineRanges(text: string): readonly LineRange[] {
24
+ if (text.length === 0) {
25
+ return [{ start: 0, end: 0 }];
26
+ }
27
+ const ranges: LineRange[] = [];
28
+ let start = 0;
29
+ for (let idx = 0; idx < text.length; idx += 1) {
30
+ if (text[idx] !== '\n') {
31
+ continue;
32
+ }
33
+ ranges.push({ start, end: idx });
34
+ start = idx + 1;
35
+ }
36
+ ranges.push({ start, end: text.length });
37
+ return ranges;
38
+ }
39
+
40
+ function locateCursorLine(
41
+ text: string,
42
+ cursor: number,
43
+ ): {
44
+ readonly ranges: readonly LineRange[];
45
+ readonly lineIndex: number;
46
+ readonly column: number;
47
+ } {
48
+ const ranges = lineRanges(text);
49
+ let lineIndex = 0;
50
+ let column = 0;
51
+ for (let idx = 0; idx < ranges.length; idx += 1) {
52
+ const range = ranges[idx]!;
53
+ if (cursor >= range.start && cursor <= range.end) {
54
+ lineIndex = idx;
55
+ column = cursor - range.start;
56
+ break;
57
+ }
58
+ }
59
+ return {
60
+ ranges,
61
+ lineIndex,
62
+ column,
63
+ };
64
+ }
65
+
66
+ function isWordChar(char: string): boolean {
67
+ return /[a-zA-Z0-9_]/u.test(char);
68
+ }
69
+
70
+ export function createTaskComposerBuffer(text = ''): TaskComposerBuffer {
71
+ return {
72
+ text,
73
+ cursor: text.length,
74
+ };
75
+ }
76
+
77
+ export function normalizeTaskComposerBuffer(buffer: TaskComposerBuffer): TaskComposerBuffer {
78
+ return {
79
+ text: buffer.text,
80
+ cursor: clampCursor(buffer.text, buffer.cursor),
81
+ };
82
+ }
83
+
84
+ export function replaceTaskComposerText(
85
+ buffer: TaskComposerBuffer,
86
+ text: string,
87
+ cursor = text.length,
88
+ ): TaskComposerBuffer {
89
+ return {
90
+ text,
91
+ cursor: clampCursor(text, cursor),
92
+ };
93
+ }
94
+
95
+ export function insertTaskComposerText(
96
+ buffer: TaskComposerBuffer,
97
+ value: string,
98
+ ): TaskComposerBuffer {
99
+ const normalized = normalizeTaskComposerBuffer(buffer);
100
+ const head = normalized.text.slice(0, normalized.cursor);
101
+ const tail = normalized.text.slice(normalized.cursor);
102
+ const nextText = `${head}${value}${tail}`;
103
+ return {
104
+ text: nextText,
105
+ cursor: normalized.cursor + value.length,
106
+ };
107
+ }
108
+
109
+ export function taskComposerBackspace(buffer: TaskComposerBuffer): TaskComposerBuffer {
110
+ const normalized = normalizeTaskComposerBuffer(buffer);
111
+ if (normalized.cursor === 0) {
112
+ return normalized;
113
+ }
114
+ const nextText =
115
+ normalized.text.slice(0, normalized.cursor - 1) + normalized.text.slice(normalized.cursor);
116
+ return {
117
+ text: nextText,
118
+ cursor: normalized.cursor - 1,
119
+ };
120
+ }
121
+
122
+ export function taskComposerDeleteForward(buffer: TaskComposerBuffer): TaskComposerBuffer {
123
+ const normalized = normalizeTaskComposerBuffer(buffer);
124
+ if (normalized.cursor >= normalized.text.length) {
125
+ return normalized;
126
+ }
127
+ const nextText =
128
+ normalized.text.slice(0, normalized.cursor) + normalized.text.slice(normalized.cursor + 1);
129
+ return {
130
+ text: nextText,
131
+ cursor: normalized.cursor,
132
+ };
133
+ }
134
+
135
+ export function taskComposerMoveLeft(buffer: TaskComposerBuffer): TaskComposerBuffer {
136
+ const normalized = normalizeTaskComposerBuffer(buffer);
137
+ return {
138
+ text: normalized.text,
139
+ cursor: Math.max(0, normalized.cursor - 1),
140
+ };
141
+ }
142
+
143
+ export function taskComposerMoveRight(buffer: TaskComposerBuffer): TaskComposerBuffer {
144
+ const normalized = normalizeTaskComposerBuffer(buffer);
145
+ return {
146
+ text: normalized.text,
147
+ cursor: Math.min(normalized.text.length, normalized.cursor + 1),
148
+ };
149
+ }
150
+
151
+ export function taskComposerMoveLineStart(buffer: TaskComposerBuffer): TaskComposerBuffer {
152
+ const normalized = normalizeTaskComposerBuffer(buffer);
153
+ const located = locateCursorLine(normalized.text, normalized.cursor);
154
+ return {
155
+ text: normalized.text,
156
+ cursor: located.ranges[located.lineIndex]!.start,
157
+ };
158
+ }
159
+
160
+ export function taskComposerMoveLineEnd(buffer: TaskComposerBuffer): TaskComposerBuffer {
161
+ const normalized = normalizeTaskComposerBuffer(buffer);
162
+ const located = locateCursorLine(normalized.text, normalized.cursor);
163
+ return {
164
+ text: normalized.text,
165
+ cursor: located.ranges[located.lineIndex]!.end,
166
+ };
167
+ }
168
+
169
+ export function taskComposerMoveWordLeft(buffer: TaskComposerBuffer): TaskComposerBuffer {
170
+ const normalized = normalizeTaskComposerBuffer(buffer);
171
+ let cursor = normalized.cursor;
172
+ while (cursor > 0 && /\s/u.test(normalized.text[cursor - 1]!)) {
173
+ cursor -= 1;
174
+ }
175
+ while (cursor > 0 && isWordChar(normalized.text[cursor - 1]!)) {
176
+ cursor -= 1;
177
+ }
178
+ return {
179
+ text: normalized.text,
180
+ cursor,
181
+ };
182
+ }
183
+
184
+ export function taskComposerMoveWordRight(buffer: TaskComposerBuffer): TaskComposerBuffer {
185
+ const normalized = normalizeTaskComposerBuffer(buffer);
186
+ let cursor = normalized.cursor;
187
+ while (cursor < normalized.text.length && /\s/u.test(normalized.text[cursor]!)) {
188
+ cursor += 1;
189
+ }
190
+ while (cursor < normalized.text.length && isWordChar(normalized.text[cursor]!)) {
191
+ cursor += 1;
192
+ }
193
+ return {
194
+ text: normalized.text,
195
+ cursor,
196
+ };
197
+ }
198
+
199
+ export function taskComposerDeleteWordLeft(buffer: TaskComposerBuffer): TaskComposerBuffer {
200
+ const normalized = normalizeTaskComposerBuffer(buffer);
201
+ const moved = taskComposerMoveWordLeft(normalized);
202
+ if (moved.cursor === normalized.cursor) {
203
+ return normalized;
204
+ }
205
+ return {
206
+ text: normalized.text.slice(0, moved.cursor) + normalized.text.slice(normalized.cursor),
207
+ cursor: moved.cursor,
208
+ };
209
+ }
210
+
211
+ export function taskComposerDeleteToLineStart(buffer: TaskComposerBuffer): TaskComposerBuffer {
212
+ const normalized = normalizeTaskComposerBuffer(buffer);
213
+ const lineStart = taskComposerMoveLineStart(normalized).cursor;
214
+ if (lineStart === normalized.cursor) {
215
+ return normalized;
216
+ }
217
+ return {
218
+ text: normalized.text.slice(0, lineStart) + normalized.text.slice(normalized.cursor),
219
+ cursor: lineStart,
220
+ };
221
+ }
222
+
223
+ export function taskComposerDeleteToLineEnd(buffer: TaskComposerBuffer): TaskComposerBuffer {
224
+ const normalized = normalizeTaskComposerBuffer(buffer);
225
+ const lineEnd = taskComposerMoveLineEnd(normalized).cursor;
226
+ if (lineEnd === normalized.cursor) {
227
+ return normalized;
228
+ }
229
+ return {
230
+ text: normalized.text.slice(0, normalized.cursor) + normalized.text.slice(lineEnd),
231
+ cursor: normalized.cursor,
232
+ };
233
+ }
234
+
235
+ export function taskComposerMoveVertical(
236
+ buffer: TaskComposerBuffer,
237
+ direction: -1 | 1,
238
+ ): VerticalMoveResult {
239
+ const normalized = normalizeTaskComposerBuffer(buffer);
240
+ const located = locateCursorLine(normalized.text, normalized.cursor);
241
+ const targetLineIndex = located.lineIndex + direction;
242
+ if (targetLineIndex < 0 || targetLineIndex >= located.ranges.length) {
243
+ return {
244
+ next: normalized,
245
+ hitBoundary: true,
246
+ };
247
+ }
248
+ const target = located.ranges[targetLineIndex]!;
249
+ const targetLength = target.end - target.start;
250
+ return {
251
+ next: {
252
+ text: normalized.text,
253
+ cursor: target.start + Math.min(located.column, targetLength),
254
+ },
255
+ hitBoundary: false,
256
+ };
257
+ }
258
+
259
+ export function taskComposerVisibleLines(
260
+ buffer: TaskComposerBuffer,
261
+ cursorToken = '_',
262
+ ): readonly string[] {
263
+ const normalized = normalizeTaskComposerBuffer(buffer);
264
+ const textWithCursor =
265
+ normalized.text.slice(0, normalized.cursor) +
266
+ cursorToken +
267
+ normalized.text.slice(normalized.cursor);
268
+ return textWithCursor.split('\n');
269
+ }
270
+
271
+ export function taskComposerTextFromTaskFields(title: string, description: string): string {
272
+ if (description.length === 0) {
273
+ return title;
274
+ }
275
+ return `${title}\n${description}`;
276
+ }
277
+
278
+ export function taskFieldsFromComposerText(text: string): {
279
+ readonly title: string;
280
+ readonly description: string;
281
+ } {
282
+ const lines = text.split('\n');
283
+ const firstLine = lines[0] ?? '';
284
+ const rest = lines.slice(1).join('\n');
285
+ return {
286
+ title: firstLine.trim(),
287
+ description: rest,
288
+ };
289
+ }
@@ -0,0 +1,417 @@
1
+ import { padOrTrimDisplay } from './dual-pane-core.ts';
2
+ import { type TaskStatus } from './harness-core-ui.ts';
3
+ import { formatUiButton } from '../ui/kit.ts';
4
+ import {
5
+ taskComposerTextFromTaskFields,
6
+ taskComposerVisibleLines,
7
+ type TaskComposerBuffer,
8
+ } from './task-composer.ts';
9
+
10
+ export type TaskFocusedPaneAction =
11
+ | 'repository.dropdown.toggle'
12
+ | 'repository.select'
13
+ | 'task.focus'
14
+ | 'task.status.ready'
15
+ | 'task.status.draft'
16
+ | 'task.status.complete';
17
+
18
+ interface ActionCell {
19
+ readonly startCol: number;
20
+ readonly endCol: number;
21
+ readonly action: TaskFocusedPaneAction;
22
+ }
23
+
24
+ export interface TaskFocusedPaneRepositoryRecord {
25
+ readonly repositoryId: string;
26
+ readonly name: string;
27
+ readonly archivedAt: string | null;
28
+ }
29
+
30
+ export interface TaskFocusedPaneTaskRecord {
31
+ readonly taskId: string;
32
+ readonly repositoryId: string | null;
33
+ readonly title: string;
34
+ readonly description: string;
35
+ readonly status: TaskStatus;
36
+ readonly orderIndex: number;
37
+ readonly createdAt: string;
38
+ }
39
+
40
+ export type TaskFocusedPaneEditorTarget =
41
+ | {
42
+ readonly kind: 'draft';
43
+ }
44
+ | {
45
+ readonly kind: 'task';
46
+ readonly taskId: string;
47
+ };
48
+
49
+ interface BuildTaskFocusedPaneOptions {
50
+ readonly repositories: ReadonlyMap<string, TaskFocusedPaneRepositoryRecord>;
51
+ readonly tasks: ReadonlyMap<string, TaskFocusedPaneTaskRecord>;
52
+ readonly selectedRepositoryId: string | null;
53
+ readonly repositoryDropdownOpen: boolean;
54
+ readonly editorTarget: TaskFocusedPaneEditorTarget;
55
+ readonly draftBuffer: TaskComposerBuffer;
56
+ readonly taskBufferById: ReadonlyMap<string, TaskComposerBuffer>;
57
+ readonly notice: string | null;
58
+ readonly cols: number;
59
+ readonly rows: number;
60
+ readonly scrollTop: number;
61
+ }
62
+
63
+ interface PaneLine {
64
+ readonly text: string;
65
+ readonly taskId: string | null;
66
+ readonly repositoryId: string | null;
67
+ readonly action: TaskFocusedPaneAction | null;
68
+ readonly actionCells: readonly ActionCell[] | null;
69
+ }
70
+
71
+ export interface TaskFocusedPaneView {
72
+ readonly rows: readonly string[];
73
+ readonly taskIds: readonly (string | null)[];
74
+ readonly repositoryIds: readonly (string | null)[];
75
+ readonly actions: readonly (TaskFocusedPaneAction | null)[];
76
+ readonly actionCells: readonly (readonly ActionCell[] | null)[];
77
+ readonly top: number;
78
+ readonly selectedRepositoryId: string | null;
79
+ }
80
+
81
+ const READY_CHIP_LABEL = formatUiButton({
82
+ label: 'ready',
83
+ prefixIcon: 'r',
84
+ });
85
+ const DRAFT_CHIP_LABEL = formatUiButton({
86
+ label: 'queued',
87
+ prefixIcon: 'd',
88
+ });
89
+ const COMPLETE_CHIP_LABEL = formatUiButton({
90
+ label: 'complete',
91
+ prefixIcon: 'c',
92
+ });
93
+
94
+ function sortedRepositories(
95
+ repositories: ReadonlyMap<string, TaskFocusedPaneRepositoryRecord>,
96
+ ): readonly TaskFocusedPaneRepositoryRecord[] {
97
+ return [...repositories.values()]
98
+ .filter((entry) => entry.archivedAt === null)
99
+ .sort(
100
+ (left, right) =>
101
+ left.name.localeCompare(right.name) || left.repositoryId.localeCompare(right.repositoryId),
102
+ );
103
+ }
104
+
105
+ function parseIsoMs(value: string): number {
106
+ return Date.parse(value);
107
+ }
108
+
109
+ function sortTasksByOrderLocal(
110
+ tasks: readonly TaskFocusedPaneTaskRecord[],
111
+ ): readonly TaskFocusedPaneTaskRecord[] {
112
+ return [...tasks].sort((left, right) => {
113
+ if (left.orderIndex !== right.orderIndex) {
114
+ return left.orderIndex - right.orderIndex;
115
+ }
116
+ const leftTs = parseIsoMs(left.createdAt);
117
+ const rightTs = parseIsoMs(right.createdAt);
118
+ const leftFinite = Number.isFinite(leftTs);
119
+ const rightFinite = Number.isFinite(rightTs);
120
+ if (leftFinite && rightFinite && leftTs !== rightTs) {
121
+ return leftTs - rightTs;
122
+ }
123
+ if (leftFinite !== rightFinite) {
124
+ return leftFinite ? -1 : 1;
125
+ }
126
+ return left.taskId.localeCompare(right.taskId);
127
+ });
128
+ }
129
+
130
+ function statusGlyph(status: TaskStatus): string {
131
+ if (status === 'ready') {
132
+ return '○';
133
+ }
134
+ if (status === 'in-progress') {
135
+ return '◔';
136
+ }
137
+ if (status === 'completed') {
138
+ return '✓';
139
+ }
140
+ return '◇';
141
+ }
142
+
143
+ function truncate(text: string, max: number): string {
144
+ const safeMax = Math.max(1, max);
145
+ if (text.length <= safeMax) {
146
+ return text;
147
+ }
148
+ if (safeMax === 1) {
149
+ return '…';
150
+ }
151
+ return `${text.slice(0, safeMax - 1)}…`;
152
+ }
153
+
154
+ function composeRowWithRightChips(
155
+ left: string,
156
+ width: number,
157
+ chips: readonly { label: string; action: TaskFocusedPaneAction }[],
158
+ ): { readonly text: string; readonly cells: readonly ActionCell[] } {
159
+ const joined = chips.map((chip) => chip.label).join(' ');
160
+ if (joined.length === 0 || joined.length >= width) {
161
+ return {
162
+ text: padOrTrimDisplay(left, width),
163
+ cells: [],
164
+ };
165
+ }
166
+ const startCol = Math.max(0, width - joined.length);
167
+ const leftMax = Math.max(0, startCol - 1);
168
+ const leftText = padOrTrimDisplay(truncate(left, leftMax), leftMax);
169
+ const gap = width - leftText.length - joined.length;
170
+ let cursor = leftText.length + Math.max(0, gap);
171
+ const cells: ActionCell[] = [];
172
+ const parts: string[] = [leftText, ' '.repeat(Math.max(0, gap))];
173
+ for (let idx = 0; idx < chips.length; idx += 1) {
174
+ const chip = chips[idx]!;
175
+ parts.push(chip.label);
176
+ cells.push({
177
+ startCol: cursor,
178
+ endCol: cursor + chip.label.length - 1,
179
+ action: chip.action,
180
+ });
181
+ cursor += chip.label.length;
182
+ if (idx < chips.length - 1) {
183
+ parts.push(' ');
184
+ cursor += 1;
185
+ }
186
+ }
187
+ return {
188
+ text: padOrTrimDisplay(parts.join(''), width),
189
+ cells,
190
+ };
191
+ }
192
+
193
+ function taskBufferFromRecord(
194
+ task: TaskFocusedPaneTaskRecord,
195
+ overrides: ReadonlyMap<string, TaskComposerBuffer>,
196
+ ): TaskComposerBuffer {
197
+ return (
198
+ overrides.get(task.taskId) ?? {
199
+ text: taskComposerTextFromTaskFields(task.title, task.description),
200
+ cursor: task.title.length,
201
+ }
202
+ );
203
+ }
204
+
205
+ function taskPreviewText(task: TaskFocusedPaneTaskRecord): string {
206
+ const summary = task.description.split('\n')[0] ?? '';
207
+ if (summary.length === 0) {
208
+ return task.title;
209
+ }
210
+ return `${task.title} · ${summary}`;
211
+ }
212
+
213
+ export function buildTaskFocusedPaneView(
214
+ options: BuildTaskFocusedPaneOptions,
215
+ ): TaskFocusedPaneView {
216
+ const safeCols = Math.max(1, options.cols);
217
+ const safeRows = Math.max(1, options.rows);
218
+ const repositories = sortedRepositories(options.repositories);
219
+ const selectedRepositoryId =
220
+ (options.selectedRepositoryId !== null &&
221
+ repositories.some((entry) => entry.repositoryId === options.selectedRepositoryId)
222
+ ? options.selectedRepositoryId
223
+ : null) ??
224
+ repositories[0]?.repositoryId ??
225
+ null;
226
+
227
+ const scopedTasks = sortTasksByOrderLocal(
228
+ [...options.tasks.values()].filter((task) => task.repositoryId === selectedRepositoryId),
229
+ );
230
+
231
+ const lines: PaneLine[] = [];
232
+ const push = (
233
+ text: string,
234
+ taskId: string | null = null,
235
+ repositoryId: string | null = null,
236
+ action: TaskFocusedPaneAction | null = null,
237
+ actionCells: readonly ActionCell[] | null = null,
238
+ ): void => {
239
+ lines.push({
240
+ text: padOrTrimDisplay(text, safeCols),
241
+ taskId,
242
+ repositoryId,
243
+ action,
244
+ actionCells,
245
+ });
246
+ };
247
+
248
+ push(' task composer');
249
+ const selectedRepositoryName =
250
+ selectedRepositoryId === null
251
+ ? 'select repository'
252
+ : (repositories.find((entry) => entry.repositoryId === selectedRepositoryId)?.name ??
253
+ '(missing)');
254
+ const repositoryButton = formatUiButton({
255
+ label: truncate(selectedRepositoryName, Math.max(8, safeCols - 16)),
256
+ suffixIcon: 'v',
257
+ });
258
+ const repositoryRowText = ` repo: ${repositoryButton}`;
259
+ const toggleStart = repositoryRowText.indexOf(repositoryButton);
260
+ const repoCells =
261
+ toggleStart < 0
262
+ ? []
263
+ : [
264
+ {
265
+ startCol: toggleStart,
266
+ endCol: toggleStart + repositoryButton.length - 1,
267
+ action: 'repository.dropdown.toggle' as const,
268
+ },
269
+ ];
270
+ push(repositoryRowText, null, selectedRepositoryId, 'repository.dropdown.toggle', repoCells);
271
+
272
+ if (options.repositoryDropdownOpen) {
273
+ for (const repository of repositories) {
274
+ const activeMark = repository.repositoryId === selectedRepositoryId ? '●' : '○';
275
+ push(
276
+ ` ${activeMark} ${repository.name}`,
277
+ null,
278
+ repository.repositoryId,
279
+ 'repository.select',
280
+ );
281
+ }
282
+ }
283
+
284
+ if (options.notice !== null && options.notice.length > 0) {
285
+ push(` notice: ${truncate(options.notice, Math.max(1, safeCols - 9))}`);
286
+ }
287
+
288
+ push('');
289
+ if (selectedRepositoryId === null) {
290
+ push(' no repository selected');
291
+ } else if (scopedTasks.length === 0) {
292
+ push(' no tasks yet for this repository');
293
+ } else {
294
+ push(` tasks (${String(scopedTasks.length)})`);
295
+ for (let index = 0; index < scopedTasks.length; index += 1) {
296
+ const task = scopedTasks[index]!;
297
+ const focused =
298
+ options.editorTarget.kind === 'task' && options.editorTarget.taskId === task.taskId;
299
+ const leftLabel = ` ${focused ? '▸' : ' '} ${statusGlyph(task.status)} ${truncate(taskPreviewText(task), Math.max(8, safeCols - 24))}`;
300
+ const chips =
301
+ task.status === 'completed'
302
+ ? []
303
+ : [
304
+ { label: READY_CHIP_LABEL, action: 'task.status.ready' as const },
305
+ { label: DRAFT_CHIP_LABEL, action: 'task.status.draft' as const },
306
+ { label: COMPLETE_CHIP_LABEL, action: 'task.status.complete' as const },
307
+ ];
308
+ const composed = composeRowWithRightChips(leftLabel, safeCols, chips);
309
+ push(composed.text, task.taskId, selectedRepositoryId, 'task.focus', composed.cells);
310
+
311
+ if (focused) {
312
+ const editBuffer = taskBufferFromRecord(task, options.taskBufferById);
313
+ const linesWithCursor = taskComposerVisibleLines(editBuffer);
314
+ push(
315
+ ` ${padOrTrimDisplay('─'.repeat(Math.max(4, Math.min(20, safeCols - 4))), Math.max(0, safeCols - 4))}`,
316
+ );
317
+ for (const line of linesWithCursor) {
318
+ push(
319
+ ` ${truncate(line, Math.max(1, safeCols - 4))}`,
320
+ task.taskId,
321
+ selectedRepositoryId,
322
+ 'task.focus',
323
+ );
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ push('');
330
+ const draftFocused = options.editorTarget.kind === 'draft';
331
+ push(` draft ${draftFocused ? '(editing)' : '(saved)'}`);
332
+ const draftLines = draftFocused
333
+ ? taskComposerVisibleLines(options.draftBuffer)
334
+ : options.draftBuffer.text.length === 0
335
+ ? ['']
336
+ : options.draftBuffer.text.split('\n');
337
+ for (const line of draftLines) {
338
+ push(` > ${truncate(line, Math.max(1, safeCols - 3))}`);
339
+ }
340
+ push(' enter ready tab queue shift+enter newline ctrl+g repos');
341
+
342
+ const maxTop = Math.max(0, lines.length - safeRows);
343
+ const top = Math.max(0, Math.min(maxTop, options.scrollTop));
344
+ const viewport = lines.slice(top, top + safeRows);
345
+ while (viewport.length < safeRows) {
346
+ viewport.push({
347
+ text: ' '.repeat(safeCols),
348
+ taskId: null,
349
+ repositoryId: null,
350
+ action: null,
351
+ actionCells: null,
352
+ });
353
+ }
354
+ return {
355
+ rows: viewport.map((line) => line.text),
356
+ taskIds: viewport.map((line) => line.taskId),
357
+ repositoryIds: viewport.map((line) => line.repositoryId),
358
+ actions: viewport.map((line) => line.action),
359
+ actionCells: viewport.map((line) => line.actionCells),
360
+ top,
361
+ selectedRepositoryId,
362
+ };
363
+ }
364
+
365
+ export function taskFocusedPaneActionAtRow(
366
+ view: TaskFocusedPaneView,
367
+ rowIndex: number,
368
+ ): TaskFocusedPaneAction | null {
369
+ if (view.actions.length === 0) {
370
+ return null;
371
+ }
372
+ const normalized = Math.max(0, Math.min(view.actions.length - 1, rowIndex));
373
+ return view.actions[normalized] ?? null;
374
+ }
375
+
376
+ export function taskFocusedPaneActionAtCell(
377
+ view: TaskFocusedPaneView,
378
+ rowIndex: number,
379
+ colIndex: number,
380
+ ): TaskFocusedPaneAction | null {
381
+ if (view.rows.length === 0) {
382
+ return null;
383
+ }
384
+ const normalizedRow = Math.max(0, Math.min(view.rows.length - 1, rowIndex));
385
+ const normalizedCol = Math.max(0, Math.floor(colIndex));
386
+ const cells = view.actionCells[normalizedRow] ?? null;
387
+ if (cells !== null) {
388
+ for (const cell of cells) {
389
+ if (normalizedCol >= cell.startCol && normalizedCol <= cell.endCol) {
390
+ return cell.action;
391
+ }
392
+ }
393
+ }
394
+ return taskFocusedPaneActionAtRow(view, normalizedRow);
395
+ }
396
+
397
+ export function taskFocusedPaneTaskIdAtRow(
398
+ view: TaskFocusedPaneView,
399
+ rowIndex: number,
400
+ ): string | null {
401
+ if (view.taskIds.length === 0) {
402
+ return null;
403
+ }
404
+ const normalized = Math.max(0, Math.min(view.taskIds.length - 1, rowIndex));
405
+ return view.taskIds[normalized] ?? null;
406
+ }
407
+
408
+ export function taskFocusedPaneRepositoryIdAtRow(
409
+ view: TaskFocusedPaneView,
410
+ rowIndex: number,
411
+ ): string | null {
412
+ if (view.repositoryIds.length === 0) {
413
+ return null;
414
+ }
415
+ const normalized = Math.max(0, Math.min(view.repositoryIds.length - 1, rowIndex));
416
+ return view.repositoryIds[normalized] ?? null;
417
+ }