@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,207 @@
1
+ import type { ConversationState } from './conversation-state.ts';
2
+ import type { LeftNavSelection } from './left-nav.ts';
3
+
4
+ export function selectedRepositoryGroupIdForLeftNav(
5
+ leftNavSelection: LeftNavSelection,
6
+ conversations: ReadonlyMap<string, ConversationState>,
7
+ repositoryGroupIdForDirectory: (directoryId: string) => string,
8
+ ): string | null {
9
+ if (leftNavSelection.kind === 'repository') {
10
+ return leftNavSelection.repositoryId;
11
+ }
12
+ if (leftNavSelection.kind === 'project') {
13
+ return repositoryGroupIdForDirectory(leftNavSelection.directoryId);
14
+ }
15
+ if (leftNavSelection.kind === 'conversation') {
16
+ const conversation = conversations.get(leftNavSelection.sessionId);
17
+ if (conversation?.directoryId !== null && conversation?.directoryId !== undefined) {
18
+ return repositoryGroupIdForDirectory(conversation.directoryId);
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function repositoryTreeArrowAction(
25
+ input: Buffer,
26
+ leftNavSelection: LeftNavSelection,
27
+ repositoryId: string | null,
28
+ ): 'expand' | 'collapse' | null {
29
+ if (leftNavSelection.kind === 'conversation' || repositoryId === null) {
30
+ return null;
31
+ }
32
+ const text = input.toString('utf8');
33
+ if (text === '\u001b[C') {
34
+ return 'expand';
35
+ }
36
+ if (text === '\u001b[D') {
37
+ return 'collapse';
38
+ }
39
+ return null;
40
+ }
41
+
42
+ interface RepositoryFoldChordResult {
43
+ consumed: boolean;
44
+ nextPrefixAtMs: number | null;
45
+ action: 'expand-all' | 'collapse-all' | null;
46
+ }
47
+
48
+ interface RepositoryFoldChordOptions {
49
+ input: Buffer;
50
+ leftNavSelection: LeftNavSelection;
51
+ nowMs: number;
52
+ prefixAtMs: number | null;
53
+ chordTimeoutMs: number;
54
+ collapseAllChordPrefix: Buffer;
55
+ }
56
+
57
+ export function reduceRepositoryFoldChordInput(
58
+ options: RepositoryFoldChordOptions,
59
+ ): RepositoryFoldChordResult {
60
+ if (options.leftNavSelection.kind === 'conversation') {
61
+ return {
62
+ consumed: false,
63
+ nextPrefixAtMs: null,
64
+ action: null,
65
+ };
66
+ }
67
+
68
+ let prefixAtMs = options.prefixAtMs;
69
+ if (prefixAtMs !== null && options.nowMs - prefixAtMs > options.chordTimeoutMs) {
70
+ prefixAtMs = null;
71
+ }
72
+
73
+ if (prefixAtMs !== null) {
74
+ if (options.input.length === 1 && options.input[0] === 0x0a) {
75
+ return {
76
+ consumed: true,
77
+ nextPrefixAtMs: null,
78
+ action: 'expand-all',
79
+ };
80
+ }
81
+ if (options.input.length === 1 && options.input[0] === 0x30) {
82
+ return {
83
+ consumed: true,
84
+ nextPrefixAtMs: null,
85
+ action: 'collapse-all',
86
+ };
87
+ }
88
+ return {
89
+ consumed: false,
90
+ nextPrefixAtMs: null,
91
+ action: null,
92
+ };
93
+ }
94
+
95
+ if (options.input.equals(options.collapseAllChordPrefix)) {
96
+ return {
97
+ consumed: true,
98
+ nextPrefixAtMs: options.nowMs,
99
+ action: null,
100
+ };
101
+ }
102
+
103
+ return {
104
+ consumed: false,
105
+ nextPrefixAtMs: null,
106
+ action: null,
107
+ };
108
+ }
109
+
110
+ function isRepositoryGroupCollapsed(
111
+ repositoryGroupId: string,
112
+ repositoriesCollapsed: boolean,
113
+ expandedRepositoryGroupIds: ReadonlySet<string>,
114
+ collapsedRepositoryGroupIds: ReadonlySet<string>,
115
+ ): boolean {
116
+ if (repositoriesCollapsed) {
117
+ return !expandedRepositoryGroupIds.has(repositoryGroupId);
118
+ }
119
+ return collapsedRepositoryGroupIds.has(repositoryGroupId);
120
+ }
121
+
122
+ export function collapseRepositoryGroup(
123
+ repositoryGroupId: string,
124
+ repositoriesCollapsed: boolean,
125
+ expandedRepositoryGroupIds: Set<string>,
126
+ collapsedRepositoryGroupIds: Set<string>,
127
+ ): void {
128
+ if (repositoriesCollapsed) {
129
+ expandedRepositoryGroupIds.delete(repositoryGroupId);
130
+ return;
131
+ }
132
+ collapsedRepositoryGroupIds.add(repositoryGroupId);
133
+ }
134
+
135
+ export function expandRepositoryGroup(
136
+ repositoryGroupId: string,
137
+ repositoriesCollapsed: boolean,
138
+ expandedRepositoryGroupIds: Set<string>,
139
+ collapsedRepositoryGroupIds: Set<string>,
140
+ ): void {
141
+ if (repositoriesCollapsed) {
142
+ expandedRepositoryGroupIds.add(repositoryGroupId);
143
+ return;
144
+ }
145
+ collapsedRepositoryGroupIds.delete(repositoryGroupId);
146
+ }
147
+
148
+ export function toggleRepositoryGroup(
149
+ repositoryGroupId: string,
150
+ repositoriesCollapsed: boolean,
151
+ expandedRepositoryGroupIds: Set<string>,
152
+ collapsedRepositoryGroupIds: Set<string>,
153
+ ): void {
154
+ if (
155
+ isRepositoryGroupCollapsed(
156
+ repositoryGroupId,
157
+ repositoriesCollapsed,
158
+ expandedRepositoryGroupIds,
159
+ collapsedRepositoryGroupIds,
160
+ )
161
+ ) {
162
+ expandRepositoryGroup(
163
+ repositoryGroupId,
164
+ repositoriesCollapsed,
165
+ expandedRepositoryGroupIds,
166
+ collapsedRepositoryGroupIds,
167
+ );
168
+ return;
169
+ }
170
+ collapseRepositoryGroup(
171
+ repositoryGroupId,
172
+ repositoriesCollapsed,
173
+ expandedRepositoryGroupIds,
174
+ collapsedRepositoryGroupIds,
175
+ );
176
+ }
177
+
178
+ export function collapseAllRepositoryGroups(
179
+ collapsedRepositoryGroupIds: Set<string>,
180
+ expandedRepositoryGroupIds: Set<string>,
181
+ ): true {
182
+ collapsedRepositoryGroupIds.clear();
183
+ expandedRepositoryGroupIds.clear();
184
+ return true;
185
+ }
186
+
187
+ export function expandAllRepositoryGroups(
188
+ collapsedRepositoryGroupIds: Set<string>,
189
+ expandedRepositoryGroupIds: Set<string>,
190
+ ): false {
191
+ collapsedRepositoryGroupIds.clear();
192
+ expandedRepositoryGroupIds.clear();
193
+ return false;
194
+ }
195
+
196
+ export function firstDirectoryForRepositoryGroup<TDirectory extends { directoryId: string }>(
197
+ directories: ReadonlyMap<string, TDirectory>,
198
+ directoryRepositoryGroupId: (directoryId: string) => string,
199
+ repositoryGroupId: string,
200
+ ): string | null {
201
+ for (const directory of directories.values()) {
202
+ if (directoryRepositoryGroupId(directory.directoryId) === repositoryGroupId) {
203
+ return directory.directoryId;
204
+ }
205
+ }
206
+ return null;
207
+ }
@@ -0,0 +1,51 @@
1
+ interface RequestStopOptions {
2
+ stop: boolean;
3
+ hasConversationTitleEdit: boolean;
4
+ stopConversationTitleEdit: () => void;
5
+ activeTaskEditorTaskId: string | null;
6
+ autosaveTaskIds: readonly string[];
7
+ flushTaskComposerPersist: (taskId: string) => void;
8
+ closeLiveSessionsOnClientStop: boolean;
9
+ orderedConversationIds: readonly string[];
10
+ conversations: ReadonlyMap<string, { live: boolean }>;
11
+ queueControlPlaneOp: (task: () => Promise<void>, label: string) => void;
12
+ sendSignal: (sessionId: string, signal: 'interrupt' | 'terminate') => void;
13
+ closeSession: (sessionId: string) => Promise<void>;
14
+ markDirty: () => void;
15
+ setStop: (next: boolean) => void;
16
+ }
17
+
18
+ export function requestStop(options: RequestStopOptions): boolean {
19
+ if (options.stop) {
20
+ return false;
21
+ }
22
+ if (options.hasConversationTitleEdit) {
23
+ options.stopConversationTitleEdit();
24
+ }
25
+ if (options.activeTaskEditorTaskId !== null) {
26
+ options.flushTaskComposerPersist(options.activeTaskEditorTaskId);
27
+ }
28
+ for (const taskId of options.autosaveTaskIds) {
29
+ options.flushTaskComposerPersist(taskId);
30
+ }
31
+ options.setStop(true);
32
+ if (options.closeLiveSessionsOnClientStop) {
33
+ options.queueControlPlaneOp(async () => {
34
+ for (const sessionId of options.orderedConversationIds) {
35
+ const conversation = options.conversations.get(sessionId);
36
+ if (conversation === undefined || !conversation.live) {
37
+ continue;
38
+ }
39
+ options.sendSignal(sessionId, 'interrupt');
40
+ options.sendSignal(sessionId, 'terminate');
41
+ try {
42
+ await options.closeSession(sessionId);
43
+ } catch {
44
+ // Best-effort shutdown only.
45
+ }
46
+ }
47
+ }, 'shutdown-close-live-sessions');
48
+ }
49
+ options.markDirty();
50
+ return true;
51
+ }
@@ -0,0 +1,411 @@
1
+ import type { TerminalSnapshotFrameCore } from '../../terminal/snapshot-oracle.ts';
2
+
3
+ interface SelectionPoint {
4
+ readonly rowAbs: number;
5
+ readonly col: number;
6
+ }
7
+
8
+ export interface PaneSelection {
9
+ readonly anchor: SelectionPoint;
10
+ readonly focus: SelectionPoint;
11
+ readonly text: string;
12
+ }
13
+
14
+ export interface PaneSelectionDrag {
15
+ readonly anchor: SelectionPoint;
16
+ readonly focus: SelectionPoint;
17
+ readonly hasDragged: boolean;
18
+ }
19
+
20
+ export interface SelectionLayout {
21
+ readonly paneRows: number;
22
+ readonly rightCols: number;
23
+ readonly rightStartCol: number;
24
+ }
25
+
26
+ export function compareSelectionPoints(left: SelectionPoint, right: SelectionPoint): number {
27
+ if (left.rowAbs !== right.rowAbs) {
28
+ return left.rowAbs - right.rowAbs;
29
+ }
30
+ return left.col - right.col;
31
+ }
32
+
33
+ export function selectionPointsEqual(left: SelectionPoint, right: SelectionPoint): boolean {
34
+ return left.rowAbs === right.rowAbs && left.col === right.col;
35
+ }
36
+
37
+ export function normalizeSelection(selection: PaneSelection): {
38
+ start: SelectionPoint;
39
+ end: SelectionPoint;
40
+ } {
41
+ if (compareSelectionPoints(selection.anchor, selection.focus) <= 0) {
42
+ return {
43
+ start: selection.anchor,
44
+ end: selection.focus,
45
+ };
46
+ }
47
+ return {
48
+ start: selection.focus,
49
+ end: selection.anchor,
50
+ };
51
+ }
52
+
53
+ export function clampPanePoint(
54
+ layout: SelectionLayout,
55
+ frame: Pick<TerminalSnapshotFrameCore, 'viewport'>,
56
+ rowAbs: number,
57
+ col: number,
58
+ ): SelectionPoint {
59
+ const maxRowAbs = Math.max(0, frame.viewport.totalRows - 1);
60
+ return {
61
+ rowAbs: Math.max(0, Math.min(maxRowAbs, rowAbs)),
62
+ col: Math.max(0, Math.min(layout.rightCols - 1, col)),
63
+ };
64
+ }
65
+
66
+ export function pointFromMouseEvent(
67
+ layout: SelectionLayout,
68
+ frame: Pick<TerminalSnapshotFrameCore, 'viewport'>,
69
+ event: { col: number; row: number },
70
+ ): SelectionPoint {
71
+ const rowViewport = Math.max(0, Math.min(layout.paneRows - 1, event.row - 1));
72
+ return clampPanePoint(
73
+ layout,
74
+ frame,
75
+ frame.viewport.top + rowViewport,
76
+ event.col - layout.rightStartCol,
77
+ );
78
+ }
79
+
80
+ export function isWheelMouseCode(code: number): boolean {
81
+ return (code & 0b0100_0000) !== 0;
82
+ }
83
+
84
+ export function isMotionMouseCode(code: number): boolean {
85
+ return (code & 0b0010_0000) !== 0;
86
+ }
87
+
88
+ export function hasAltModifier(code: number): boolean {
89
+ return (code & 0b0000_1000) !== 0;
90
+ }
91
+
92
+ export function hasShiftModifier(code: number): boolean {
93
+ return (code & 0b0000_0100) !== 0;
94
+ }
95
+
96
+ export function isLeftButtonPress(code: number, final: 'M' | 'm'): boolean {
97
+ if (final !== 'M') {
98
+ return false;
99
+ }
100
+ if (isWheelMouseCode(code) || isMotionMouseCode(code)) {
101
+ return false;
102
+ }
103
+ return (code & 0b0000_0011) === 0;
104
+ }
105
+
106
+ export function isMouseRelease(final: 'M' | 'm'): boolean {
107
+ return final === 'm';
108
+ }
109
+
110
+ export function isSelectionDrag(code: number, final: 'M' | 'm'): boolean {
111
+ return final === 'M' && isMotionMouseCode(code);
112
+ }
113
+
114
+ interface ReduceConversationMouseSelectionOptions {
115
+ selection: PaneSelection | null;
116
+ selectionDrag: PaneSelectionDrag | null;
117
+ point: SelectionPoint;
118
+ isMainPaneTarget: boolean;
119
+ isLeftButtonPress: boolean;
120
+ isSelectionDrag: boolean;
121
+ isMouseRelease: boolean;
122
+ isWheelMouseCode: boolean;
123
+ selectionTextForPane: (selection: PaneSelection) => string;
124
+ }
125
+
126
+ interface ReduceConversationMouseSelectionResult {
127
+ selection: PaneSelection | null;
128
+ selectionDrag: PaneSelectionDrag | null;
129
+ pinViewport: boolean;
130
+ releaseViewportPin: boolean;
131
+ markDirty: boolean;
132
+ consumed: boolean;
133
+ }
134
+
135
+ export function reduceConversationMouseSelection(
136
+ options: ReduceConversationMouseSelectionOptions,
137
+ ): ReduceConversationMouseSelectionResult {
138
+ const startSelection = options.isMainPaneTarget && options.isLeftButtonPress;
139
+ const updateSelection =
140
+ options.selectionDrag !== null && options.isMainPaneTarget && options.isSelectionDrag;
141
+ const releaseSelection = options.selectionDrag !== null && options.isMouseRelease;
142
+
143
+ if (startSelection) {
144
+ return {
145
+ selection: null,
146
+ selectionDrag: {
147
+ anchor: options.point,
148
+ focus: options.point,
149
+ hasDragged: false,
150
+ },
151
+ pinViewport: true,
152
+ releaseViewportPin: false,
153
+ markDirty: true,
154
+ consumed: true,
155
+ };
156
+ }
157
+
158
+ if (updateSelection && options.selectionDrag !== null) {
159
+ return {
160
+ selection: options.selection,
161
+ selectionDrag: {
162
+ anchor: options.selectionDrag.anchor,
163
+ focus: options.point,
164
+ hasDragged:
165
+ options.selectionDrag.hasDragged ||
166
+ !selectionPointsEqual(options.selectionDrag.anchor, options.point),
167
+ },
168
+ pinViewport: false,
169
+ releaseViewportPin: false,
170
+ markDirty: true,
171
+ consumed: true,
172
+ };
173
+ }
174
+
175
+ if (releaseSelection && options.selectionDrag !== null) {
176
+ const finalized = {
177
+ anchor: options.selectionDrag.anchor,
178
+ focus: options.point,
179
+ hasDragged:
180
+ options.selectionDrag.hasDragged ||
181
+ !selectionPointsEqual(options.selectionDrag.anchor, options.point),
182
+ };
183
+ if (finalized.hasDragged) {
184
+ const completedSelection: PaneSelection = {
185
+ anchor: finalized.anchor,
186
+ focus: finalized.focus,
187
+ text: '',
188
+ };
189
+ return {
190
+ selection: {
191
+ ...completedSelection,
192
+ text: options.selectionTextForPane(completedSelection),
193
+ },
194
+ selectionDrag: null,
195
+ pinViewport: false,
196
+ releaseViewportPin: false,
197
+ markDirty: true,
198
+ consumed: true,
199
+ };
200
+ }
201
+ return {
202
+ selection: null,
203
+ selectionDrag: null,
204
+ pinViewport: false,
205
+ releaseViewportPin: true,
206
+ markDirty: true,
207
+ consumed: true,
208
+ };
209
+ }
210
+
211
+ if (options.selection !== null && !options.isWheelMouseCode) {
212
+ return {
213
+ selection: null,
214
+ selectionDrag: null,
215
+ pinViewport: false,
216
+ releaseViewportPin: true,
217
+ markDirty: true,
218
+ consumed: false,
219
+ };
220
+ }
221
+
222
+ return {
223
+ selection: options.selection,
224
+ selectionDrag: options.selectionDrag,
225
+ pinViewport: false,
226
+ releaseViewportPin: false,
227
+ markDirty: false,
228
+ consumed: false,
229
+ };
230
+ }
231
+
232
+ function cellGlyphForOverlay(frame: TerminalSnapshotFrameCore, row: number, col: number): string {
233
+ const line = frame.richLines[row];
234
+ if (line === undefined) {
235
+ return ' ';
236
+ }
237
+ const cell = line.cells[col];
238
+ if (cell === undefined) {
239
+ return ' ';
240
+ }
241
+ if (cell.continued) {
242
+ return ' ';
243
+ }
244
+ return cell.glyph.length > 0 ? cell.glyph : ' ';
245
+ }
246
+
247
+ export function renderSelectionOverlay(
248
+ layout: SelectionLayout,
249
+ frame: TerminalSnapshotFrameCore,
250
+ selection: PaneSelection | null,
251
+ ): string {
252
+ if (selection === null) {
253
+ return '';
254
+ }
255
+
256
+ const { start, end } = normalizeSelection(selection);
257
+ const visibleStartAbs = frame.viewport.top;
258
+ const visibleEndAbs = frame.viewport.top + frame.rows - 1;
259
+ const paintStartAbs = Math.max(start.rowAbs, visibleStartAbs);
260
+ const paintEndAbs = Math.min(end.rowAbs, visibleEndAbs);
261
+ if (paintEndAbs < paintStartAbs) {
262
+ return '';
263
+ }
264
+
265
+ let output = '';
266
+ for (let rowAbs = paintStartAbs; rowAbs <= paintEndAbs; rowAbs += 1) {
267
+ const row = rowAbs - frame.viewport.top;
268
+ const rowStartCol = rowAbs === start.rowAbs ? start.col : 0;
269
+ const rowEndCol = rowAbs === end.rowAbs ? end.col : frame.cols - 1;
270
+ if (rowEndCol < rowStartCol) {
271
+ continue;
272
+ }
273
+
274
+ output += `\u001b[${String(row + 1)};${String(layout.rightStartCol + rowStartCol)}H\u001b[7m`;
275
+ for (let col = rowStartCol; col <= rowEndCol; col += 1) {
276
+ output += cellGlyphForOverlay(frame, row, col);
277
+ }
278
+ output += '\u001b[0m';
279
+ }
280
+
281
+ return output;
282
+ }
283
+
284
+ export function selectionVisibleRows(
285
+ frame: Pick<TerminalSnapshotFrameCore, 'viewport' | 'rows'>,
286
+ selection: PaneSelection | null,
287
+ ): readonly number[] {
288
+ if (selection === null) {
289
+ return [];
290
+ }
291
+
292
+ const { start, end } = normalizeSelection(selection);
293
+ const visibleStartAbs = frame.viewport.top;
294
+ const visibleEndAbs = frame.viewport.top + frame.rows - 1;
295
+ const paintStartAbs = Math.max(start.rowAbs, visibleStartAbs);
296
+ const paintEndAbs = Math.min(end.rowAbs, visibleEndAbs);
297
+ if (paintEndAbs < paintStartAbs) {
298
+ return [];
299
+ }
300
+
301
+ const rows: number[] = [];
302
+ for (let rowAbs = paintStartAbs; rowAbs <= paintEndAbs; rowAbs += 1) {
303
+ rows.push(rowAbs - frame.viewport.top);
304
+ }
305
+ return rows;
306
+ }
307
+
308
+ export function mergeUniqueRows(
309
+ left: readonly number[],
310
+ right: readonly number[],
311
+ ): readonly number[] {
312
+ if (left.length === 0) {
313
+ return right;
314
+ }
315
+ if (right.length === 0) {
316
+ return left;
317
+ }
318
+ const merged = new Set<number>();
319
+ for (const row of left) {
320
+ merged.add(row);
321
+ }
322
+ for (const row of right) {
323
+ merged.add(row);
324
+ }
325
+ return [...merged].sort((a, b) => a - b);
326
+ }
327
+
328
+ export function selectionText(
329
+ frame: TerminalSnapshotFrameCore,
330
+ selection: PaneSelection | null,
331
+ ): string {
332
+ if (selection === null) {
333
+ return '';
334
+ }
335
+
336
+ if (selection.text.length > 0) {
337
+ return selection.text;
338
+ }
339
+
340
+ const { start, end } = normalizeSelection(selection);
341
+ const rows: string[] = [];
342
+ const visibleStartAbs = frame.viewport.top;
343
+ const visibleEndAbs = frame.viewport.top + frame.rows - 1;
344
+ const readStartAbs = Math.max(start.rowAbs, visibleStartAbs);
345
+ const readEndAbs = Math.min(end.rowAbs, visibleEndAbs);
346
+ for (let rowAbs = readStartAbs; rowAbs <= readEndAbs; rowAbs += 1) {
347
+ const row = rowAbs - frame.viewport.top;
348
+ const rowStartCol = rowAbs === start.rowAbs ? start.col : 0;
349
+ const rowEndCol = rowAbs === end.rowAbs ? end.col : frame.cols - 1;
350
+ if (rowEndCol < rowStartCol) {
351
+ rows.push('');
352
+ continue;
353
+ }
354
+
355
+ let line = '';
356
+ for (let col = rowStartCol; col <= rowEndCol; col += 1) {
357
+ const lineRef = frame.richLines[row];
358
+ const cell = lineRef?.cells[col];
359
+ if (cell === undefined || cell.continued) {
360
+ continue;
361
+ }
362
+ line += cell.glyph;
363
+ }
364
+ rows.push(line);
365
+ }
366
+ return rows.join('\n');
367
+ }
368
+
369
+ export function isCopyShortcutInput(input: Buffer): boolean {
370
+ if (input.length === 1 && input[0] === 0x03) {
371
+ return true;
372
+ }
373
+
374
+ const text = input.toString('utf8');
375
+ const prefixes = ['\u001b[99;', '\u001b[67;'] as const;
376
+ for (const prefix of prefixes) {
377
+ let startIndex = text.indexOf(prefix);
378
+ while (startIndex !== -1) {
379
+ let index = startIndex + prefix.length;
380
+ while (
381
+ index < text.length &&
382
+ text.charCodeAt(index) >= 0x30 &&
383
+ text.charCodeAt(index) <= 0x39
384
+ ) {
385
+ index += 1;
386
+ }
387
+ if (index > startIndex + prefix.length && text[index] === 'u') {
388
+ return true;
389
+ }
390
+ startIndex = text.indexOf(prefix, startIndex + 1);
391
+ }
392
+ }
393
+ return false;
394
+ }
395
+
396
+ export function writeTextToClipboard(
397
+ value: string,
398
+ writer: (payload: string) => unknown = (payload) => process.stdout.write(payload),
399
+ ): boolean {
400
+ if (value.length === 0) {
401
+ return false;
402
+ }
403
+
404
+ try {
405
+ const encoded = Buffer.from(value, 'utf8').toString('base64');
406
+ writer(`\u001b]52;c;${encoded}\u0007`);
407
+ return true;
408
+ } catch {
409
+ return false;
410
+ }
411
+ }