@jmoyers/harness 0.1.11 → 0.1.20

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 (232) hide show
  1. package/README.md +31 -39
  2. package/package.json +31 -11
  3. package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
  4. package/packages/harness-ai/src/stream-text.ts +13 -91
  5. package/packages/harness-ui/src/frame-primitives.ts +158 -0
  6. package/packages/harness-ui/src/index.ts +18 -0
  7. package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
  8. package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
  9. package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
  10. package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
  11. package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
  12. package/packages/harness-ui/src/interaction/input.ts +420 -0
  13. package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
  14. package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
  15. package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
  16. package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
  17. package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
  18. package/packages/harness-ui/src/kit.ts +476 -0
  19. package/packages/harness-ui/src/layout.ts +238 -0
  20. package/packages/harness-ui/src/modal-manager.ts +222 -0
  21. package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
  22. package/packages/harness-ui/src/surface.ts +252 -0
  23. package/packages/harness-ui/src/text-layout.ts +210 -0
  24. package/packages/nim-core/src/contracts.ts +239 -0
  25. package/packages/nim-core/src/event-store.ts +299 -0
  26. package/packages/nim-core/src/events.ts +53 -0
  27. package/packages/nim-core/src/index.ts +9 -0
  28. package/packages/nim-core/src/provider-router.ts +129 -0
  29. package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
  30. package/packages/nim-core/src/runtime-factory.ts +49 -0
  31. package/packages/nim-core/src/runtime.ts +1797 -0
  32. package/packages/nim-core/src/session-store.ts +516 -0
  33. package/packages/nim-core/src/telemetry.ts +48 -0
  34. package/packages/nim-test-tui/src/index.ts +150 -0
  35. package/packages/nim-ui-core/src/index.ts +1 -0
  36. package/packages/nim-ui-core/src/projection.ts +87 -0
  37. package/scripts/codex-live-mux-runtime.ts +2 -3872
  38. package/scripts/control-plane-daemon.ts +11 -0
  39. package/scripts/harness-bin.js +5 -0
  40. package/scripts/harness-commands.ts +300 -0
  41. package/scripts/harness-runtime.ts +82 -0
  42. package/scripts/harness.ts +33 -3019
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/gateway/runtime.ts +1872 -0
  46. package/src/cli/parsing/flags.ts +23 -0
  47. package/src/cli/parsing/session.ts +42 -0
  48. package/src/cli/runtime/context.ts +193 -0
  49. package/src/cli/runtime-app/application.ts +392 -0
  50. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  51. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  52. package/src/cli/workflows/runtime.ts +965 -0
  53. package/src/clients/tui/left-rail-interactions.ts +519 -0
  54. package/src/clients/tui/main-pane-interactions.ts +509 -0
  55. package/src/clients/tui/modal-input-routing.ts +71 -0
  56. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  57. package/src/clients/web/synced-selectors.ts +132 -0
  58. package/src/codex/live-session.ts +82 -29
  59. package/src/config/config-core.ts +348 -8
  60. package/src/config/harness.config.template.jsonc +33 -0
  61. package/src/control-plane/agent-realtime-api.ts +82 -427
  62. package/src/control-plane/session-summary.ts +10 -81
  63. package/src/control-plane/status/reducer-base.ts +12 -12
  64. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  65. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  66. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  67. package/src/control-plane/stream-client.ts +12 -2
  68. package/src/control-plane/stream-command-parser.ts +83 -143
  69. package/src/control-plane/stream-protocol.ts +53 -37
  70. package/src/control-plane/stream-server-command.ts +376 -69
  71. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  72. package/src/control-plane/stream-server.ts +864 -70
  73. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  74. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  75. package/src/core/state/observed-stream-cursor.ts +43 -0
  76. package/src/core/state/synced-observed-state.ts +273 -0
  77. package/src/core/store/harness-synced-store.ts +81 -0
  78. package/src/diff/budget.ts +136 -0
  79. package/src/diff/build.ts +289 -0
  80. package/src/diff/chunker.ts +146 -0
  81. package/src/diff/git-invoke.ts +315 -0
  82. package/src/diff/git-parse.ts +472 -0
  83. package/src/diff/hash.ts +70 -0
  84. package/src/diff/index.ts +24 -0
  85. package/src/diff/normalize.ts +134 -0
  86. package/src/diff/types.ts +178 -0
  87. package/src/diff-ui/args.ts +346 -0
  88. package/src/diff-ui/commands.ts +123 -0
  89. package/src/diff-ui/finder.ts +94 -0
  90. package/src/diff-ui/highlight.ts +127 -0
  91. package/src/diff-ui/index.ts +2 -0
  92. package/src/diff-ui/model.ts +141 -0
  93. package/src/diff-ui/pager.ts +412 -0
  94. package/src/diff-ui/render.ts +337 -0
  95. package/src/diff-ui/runtime.ts +379 -0
  96. package/src/diff-ui/state.ts +224 -0
  97. package/src/diff-ui/types.ts +236 -0
  98. package/src/domain/workspace.ts +68 -5
  99. package/src/mux/control-plane-op-queue.ts +93 -7
  100. package/src/mux/conversation-rail.ts +28 -71
  101. package/src/mux/dual-pane-core.ts +13 -13
  102. package/src/mux/harness-core-ui.ts +313 -42
  103. package/src/mux/input-shortcuts.ts +13 -131
  104. package/src/mux/keybinding-catalog.ts +340 -0
  105. package/src/mux/keybinding-registry.ts +103 -0
  106. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  107. package/src/mux/live-mux/command-menu.ts +167 -4
  108. package/src/mux/live-mux/conversation-state.ts +13 -0
  109. package/src/mux/live-mux/directory-resolution.ts +1 -1
  110. package/src/mux/live-mux/git-snapshot.ts +33 -2
  111. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  112. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  113. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  114. package/src/mux/live-mux/input-forwarding.ts +59 -2
  115. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  116. package/src/mux/live-mux/left-nav.ts +35 -0
  117. package/src/mux/live-mux/link-click.ts +292 -0
  118. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  119. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  120. package/src/mux/live-mux/modal-input-reducers.ts +77 -12
  121. package/src/mux/live-mux/modal-overlays.ts +168 -34
  122. package/src/mux/live-mux/modal-pointer.ts +3 -7
  123. package/src/mux/live-mux/modal-prompt-handlers.ts +23 -2
  124. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  125. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  126. package/src/mux/live-mux/pointer-routing.ts +5 -2
  127. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  128. package/src/mux/live-mux/rail-layout.ts +33 -30
  129. package/src/mux/live-mux/release-notes.ts +383 -0
  130. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  131. package/src/mux/live-mux/repository-folding.ts +3 -0
  132. package/src/mux/live-mux/selection.ts +0 -4
  133. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  134. package/src/mux/project-pane-github-review.ts +271 -0
  135. package/src/mux/render-frame.ts +4 -0
  136. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  137. package/src/mux/task-composer.ts +21 -14
  138. package/src/mux/task-focused-pane.ts +118 -117
  139. package/src/mux/task-screen-keybindings.ts +10 -101
  140. package/src/mux/workspace-rail-model.ts +270 -104
  141. package/src/mux/workspace-rail.ts +45 -22
  142. package/src/pty/session-broker.ts +1 -1
  143. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  144. package/src/services/control-plane.ts +50 -32
  145. package/src/services/conversation-lifecycle.ts +118 -87
  146. package/src/services/conversation-startup-hydration.ts +20 -12
  147. package/src/services/directory-hydration.ts +21 -16
  148. package/src/services/event-persistence.ts +7 -0
  149. package/src/services/left-rail-pointer-handler.ts +329 -0
  150. package/src/services/mux-ui-state-persistence.ts +5 -1
  151. package/src/services/recording.ts +34 -26
  152. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  153. package/src/services/runtime-control-actions.ts +79 -61
  154. package/src/services/runtime-control-plane-ops.ts +122 -83
  155. package/src/services/runtime-conversation-actions.ts +40 -26
  156. package/src/services/runtime-conversation-activation.ts +73 -46
  157. package/src/services/runtime-conversation-starter.ts +53 -45
  158. package/src/services/runtime-conversation-title-edit.ts +91 -80
  159. package/src/services/runtime-envelope-handler.ts +107 -105
  160. package/src/services/runtime-git-state.ts +42 -29
  161. package/src/services/runtime-layout-resize.ts +3 -1
  162. package/src/services/runtime-left-rail-render.ts +99 -63
  163. package/src/services/runtime-nim-cli-session.ts +438 -0
  164. package/src/services/runtime-nim-session.ts +705 -0
  165. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  166. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  167. package/src/services/runtime-process-wiring.ts +29 -36
  168. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  169. package/src/services/runtime-render-flush.ts +63 -70
  170. package/src/services/runtime-render-lifecycle.ts +65 -64
  171. package/src/services/runtime-render-orchestrator.ts +55 -45
  172. package/src/services/runtime-render-pipeline.ts +106 -103
  173. package/src/services/runtime-render-state.ts +62 -49
  174. package/src/services/runtime-repository-actions.ts +97 -72
  175. package/src/services/runtime-right-pane-render.ts +80 -53
  176. package/src/services/runtime-shutdown.ts +38 -35
  177. package/src/services/runtime-stream-subscriptions.ts +35 -27
  178. package/src/services/runtime-task-composer-persistence.ts +71 -59
  179. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  180. package/src/services/runtime-task-editor-actions.ts +46 -29
  181. package/src/services/runtime-task-pane-actions.ts +220 -134
  182. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  183. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  184. package/src/services/runtime-workspace-observed-events.ts +33 -184
  185. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  186. package/src/services/session-diagnostics-store.ts +217 -0
  187. package/src/services/startup-background-resume.ts +26 -21
  188. package/src/services/startup-orchestrator.ts +16 -13
  189. package/src/services/startup-paint-tracker.ts +29 -21
  190. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  191. package/src/services/startup-settled-gate.ts +25 -15
  192. package/src/services/startup-shutdown.ts +18 -22
  193. package/src/services/startup-state-hydration.ts +44 -34
  194. package/src/services/startup-visibility.ts +12 -4
  195. package/src/services/task-pane-selection-actions.ts +89 -72
  196. package/src/services/task-planning-hydration.ts +24 -18
  197. package/src/services/task-planning-observed-events.ts +50 -52
  198. package/src/services/workspace-observed-events.ts +66 -63
  199. package/src/storage/storage-lifecycle-core.ts +438 -0
  200. package/src/store/control-plane-store-normalize.ts +33 -242
  201. package/src/store/control-plane-store-types.ts +1 -35
  202. package/src/store/control-plane-store.ts +360 -56
  203. package/src/store/event-store.ts +366 -8
  204. package/src/terminal/snapshot-oracle.ts +207 -94
  205. package/src/ui/mux-theme.ts +112 -8
  206. package/src/ui/panes/home-gridfire.ts +40 -31
  207. package/src/ui/panes/home.ts +10 -2
  208. package/src/ui/panes/nim.ts +315 -0
  209. package/src/mux/live-mux/actions-task.ts +0 -115
  210. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  211. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -85
  212. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  213. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  214. package/src/services/runtime-directory-actions.ts +0 -164
  215. package/src/services/runtime-input-pipeline.ts +0 -50
  216. package/src/services/runtime-input-router.ts +0 -195
  217. package/src/services/runtime-main-pane-input.ts +0 -230
  218. package/src/services/runtime-modal-input.ts +0 -137
  219. package/src/services/runtime-navigation-input.ts +0 -197
  220. package/src/services/runtime-rail-input.ts +0 -279
  221. package/src/services/runtime-task-pane.ts +0 -62
  222. package/src/services/runtime-workspace-actions.ts +0 -158
  223. package/src/ui/conversation-input-forwarder.ts +0 -114
  224. package/src/ui/conversation-selection-input.ts +0 -103
  225. package/src/ui/global-shortcut-input.ts +0 -89
  226. package/src/ui/input.ts +0 -269
  227. package/src/ui/kit.ts +0 -509
  228. package/src/ui/left-nav-input.ts +0 -80
  229. package/src/ui/left-rail-pointer-input.ts +0 -148
  230. package/src/ui/modals/manager.ts +0 -218
  231. package/src/ui/repository-fold-input.ts +0 -91
  232. package/src/ui/surface.ts +0 -224
@@ -0,0 +1,337 @@
1
+ import { padOrTrimDisplay } from '../mux/dual-pane-core.ts';
2
+ import { renderSyntaxAnsiLine } from './highlight.ts';
3
+ import type {
4
+ DiffUiModel,
5
+ DiffUiRenderOutput,
6
+ DiffUiRenderTheme,
7
+ DiffUiState,
8
+ DiffUiSyntaxMode,
9
+ DiffUiViewMode,
10
+ DiffUiVirtualRow,
11
+ DiffUiWordDiffMode,
12
+ } from './types.ts';
13
+
14
+ const RESET_ANSI = '\u001b[0m';
15
+
16
+ function fg(index: number): string {
17
+ return `\u001b[38;5;${String(index)}m`;
18
+ }
19
+
20
+ function fgBg(fgIndex: number, bgIndex: number, bold = false): string {
21
+ const boldCode = bold ? '1;' : '';
22
+ return `\u001b[${boldCode}38;5;${String(fgIndex)};48;5;${String(bgIndex)}m`;
23
+ }
24
+
25
+ export const DEFAULT_DIFF_UI_THEME: DiffUiRenderTheme = {
26
+ headerAnsi: fgBg(231, 24, true),
27
+ footerAnsi: fgBg(252, 236),
28
+ fileHeaderAnsi: fgBg(230, 238, true),
29
+ hunkHeaderAnsi: fgBg(159, 237, true),
30
+ contextAnsi: fg(252),
31
+ addAnsi: fgBg(120, 22),
32
+ delAnsi: fgBg(224, 52),
33
+ noticeAnsi: fgBg(230, 94, true),
34
+ gutterAnsi: fg(244),
35
+ resetAnsi: RESET_ANSI,
36
+ syntaxKeywordAnsi: fg(81),
37
+ syntaxStringAnsi: fg(114),
38
+ syntaxCommentAnsi: fg(245),
39
+ syntaxNumberAnsi: fg(221),
40
+ };
41
+
42
+ export function resolveDiffUiTheme(theme: string | null): DiffUiRenderTheme {
43
+ if (theme === null || theme === 'default') {
44
+ return DEFAULT_DIFF_UI_THEME;
45
+ }
46
+ if (theme === 'plain') {
47
+ return {
48
+ ...DEFAULT_DIFF_UI_THEME,
49
+ headerAnsi: '',
50
+ footerAnsi: '',
51
+ fileHeaderAnsi: '',
52
+ hunkHeaderAnsi: '',
53
+ contextAnsi: '',
54
+ addAnsi: '',
55
+ delAnsi: '',
56
+ noticeAnsi: '',
57
+ gutterAnsi: '',
58
+ syntaxKeywordAnsi: '',
59
+ syntaxStringAnsi: '',
60
+ syntaxCommentAnsi: '',
61
+ syntaxNumberAnsi: '',
62
+ };
63
+ }
64
+ throw new Error(`unknown theme: ${theme}`);
65
+ }
66
+
67
+ function syntaxEnabled(color: boolean, syntaxMode: DiffUiSyntaxMode): boolean {
68
+ if (!color) {
69
+ return false;
70
+ }
71
+ if (syntaxMode === 'on') {
72
+ return true;
73
+ }
74
+ if (syntaxMode === 'off') {
75
+ return false;
76
+ }
77
+ return true;
78
+ }
79
+
80
+ function wordDiffEnabled(color: boolean, wordDiffMode: DiffUiWordDiffMode): boolean {
81
+ if (!color) {
82
+ return false;
83
+ }
84
+ if (wordDiffMode === 'on') {
85
+ return true;
86
+ }
87
+ if (wordDiffMode === 'off') {
88
+ return false;
89
+ }
90
+ return true;
91
+ }
92
+
93
+ function formatLineNumber(value: number | null): string {
94
+ if (value === null) {
95
+ return ' ';
96
+ }
97
+ const text = String(value);
98
+ return padOrTrimDisplay(text.length > 4 ? text.slice(-4) : text.padStart(4, ' '), 4);
99
+ }
100
+
101
+ function renderUnifiedRow(row: DiffUiVirtualRow, width: number): string {
102
+ if (row.kind === 'code-add' || row.kind === 'code-del' || row.kind === 'code-context') {
103
+ const oldCol = formatLineNumber(row.oldLine);
104
+ const newCol = formatLineNumber(row.newLine);
105
+ return padOrTrimDisplay(`${oldCol} ${newCol} ${row.unified}`, width);
106
+ }
107
+ return padOrTrimDisplay(row.unified, width);
108
+ }
109
+
110
+ function renderSplitRow(row: DiffUiVirtualRow, width: number): string {
111
+ if (row.kind !== 'code-add' && row.kind !== 'code-del' && row.kind !== 'code-context') {
112
+ return padOrTrimDisplay(row.unified, width);
113
+ }
114
+
115
+ const divider = ' │ ';
116
+ const half = Math.max(1, Math.floor((width - divider.length) / 2));
117
+ const left = padOrTrimDisplay(`${formatLineNumber(row.oldLine)} ${row.left}`, half);
118
+ const right = padOrTrimDisplay(`${formatLineNumber(row.newLine)} ${row.right}`, half);
119
+ return `${left}${divider}${right}`;
120
+ }
121
+
122
+ function baseAnsiForRow(row: DiffUiVirtualRow, theme: DiffUiRenderTheme): string {
123
+ if (row.kind === 'file-header') {
124
+ return theme.fileHeaderAnsi;
125
+ }
126
+ if (row.kind === 'hunk-header') {
127
+ return theme.hunkHeaderAnsi;
128
+ }
129
+ if (row.kind === 'code-add') {
130
+ return theme.addAnsi;
131
+ }
132
+ if (row.kind === 'code-del') {
133
+ return theme.delAnsi;
134
+ }
135
+ if (row.kind === 'notice') {
136
+ return theme.noticeAnsi;
137
+ }
138
+ return theme.contextAnsi;
139
+ }
140
+
141
+ function applyWordDiffHint(text: string, enabled: boolean): string {
142
+ if (!enabled) {
143
+ return text;
144
+ }
145
+ return text.replaceAll('\t', ' ');
146
+ }
147
+
148
+ function decorateRow(input: {
149
+ readonly row: DiffUiVirtualRow;
150
+ readonly plainText: string;
151
+ readonly theme: DiffUiRenderTheme;
152
+ readonly color: boolean;
153
+ readonly syntaxMode: DiffUiSyntaxMode;
154
+ readonly wordDiffMode: DiffUiWordDiffMode;
155
+ }): string {
156
+ const baseAnsi = baseAnsiForRow(input.row, input.theme);
157
+ const syntaxOn = syntaxEnabled(input.color, input.syntaxMode);
158
+ const wordDiffOn = wordDiffEnabled(input.color, input.wordDiffMode);
159
+ const withWordHint = applyWordDiffHint(input.plainText, wordDiffOn);
160
+
161
+ let body = withWordHint;
162
+ if (
163
+ syntaxOn &&
164
+ (input.row.kind === 'code-add' ||
165
+ input.row.kind === 'code-del' ||
166
+ input.row.kind === 'code-context')
167
+ ) {
168
+ body = renderSyntaxAnsiLine({
169
+ line: body,
170
+ language: input.row.language,
171
+ theme: input.theme,
172
+ colorEnabled: input.color,
173
+ baseAnsi,
174
+ });
175
+ }
176
+
177
+ if (!input.color || baseAnsi.length === 0) {
178
+ return body;
179
+ }
180
+ return `${baseAnsi}${body}${input.theme.resetAnsi}`;
181
+ }
182
+
183
+ function renderHeader(input: {
184
+ readonly model: DiffUiModel;
185
+ readonly state: DiffUiState;
186
+ readonly width: number;
187
+ readonly color: boolean;
188
+ readonly theme: DiffUiRenderTheme;
189
+ }): string {
190
+ const files = input.model.diff.totals.filesChanged;
191
+ const hunks = input.model.diff.totals.hunks;
192
+ const lines = input.model.diff.totals.lines;
193
+ const text = padOrTrimDisplay(
194
+ `[diff] mode=${input.model.diff.spec.mode} view=${input.state.effectiveViewMode} files=${files} hunks=${hunks} lines=${lines}`,
195
+ input.width,
196
+ );
197
+ if (!input.color || input.theme.headerAnsi.length === 0) {
198
+ return text;
199
+ }
200
+ return `${input.theme.headerAnsi}${text}${input.theme.resetAnsi}`;
201
+ }
202
+
203
+ function renderFooter(input: {
204
+ readonly model: DiffUiModel;
205
+ readonly state: DiffUiState;
206
+ readonly width: number;
207
+ readonly height: number;
208
+ readonly color: boolean;
209
+ readonly theme: DiffUiRenderTheme;
210
+ }): string {
211
+ const maxTop = Math.max(0, input.model.rows.length - Math.max(1, input.height - 2));
212
+ const text = padOrTrimDisplay(
213
+ `row=${input.state.topRow + 1}/${Math.max(1, input.model.rows.length)} maxTop~${maxTop} file=${input.state.activeFileIndex + 1}/${Math.max(1, input.model.diff.files.length)} finder=${input.state.finderOpen ? 'open' : 'closed'}`,
214
+ input.width,
215
+ );
216
+ if (!input.color || input.theme.footerAnsi.length === 0) {
217
+ return text;
218
+ }
219
+ return `${input.theme.footerAnsi}${text}${input.theme.resetAnsi}`;
220
+ }
221
+
222
+ function overlayFinderLines(
223
+ renderedBodyLines: string[],
224
+ state: DiffUiState,
225
+ width: number,
226
+ color: boolean,
227
+ theme: DiffUiRenderTheme,
228
+ ): void {
229
+ if (!state.finderOpen || renderedBodyLines.length === 0) {
230
+ return;
231
+ }
232
+
233
+ const lines: string[] = [];
234
+ lines.push(padOrTrimDisplay(`find: ${state.finderQuery}`, width));
235
+ const maxRows = Math.max(0, renderedBodyLines.length - 1);
236
+ for (let index = 0; index < Math.min(maxRows, state.finderResults.length); index += 1) {
237
+ const row = state.finderResults[index]!;
238
+ const marker = index === state.finderSelectedIndex ? '>' : ' ';
239
+ lines.push(padOrTrimDisplay(`${marker} ${row.path}`, width));
240
+ }
241
+
242
+ for (let index = 0; index < lines.length && index < renderedBodyLines.length; index += 1) {
243
+ const base = lines[index]!;
244
+ if (!color || theme.hunkHeaderAnsi.length === 0) {
245
+ renderedBodyLines[index] = base;
246
+ continue;
247
+ }
248
+ renderedBodyLines[index] = `${theme.hunkHeaderAnsi}${base}${theme.resetAnsi}`;
249
+ }
250
+ }
251
+
252
+ export function renderDiffUiViewport(input: {
253
+ readonly model: DiffUiModel;
254
+ readonly state: DiffUiState;
255
+ readonly width: number;
256
+ readonly height: number;
257
+ readonly viewMode: DiffUiViewMode;
258
+ readonly syntaxMode: DiffUiSyntaxMode;
259
+ readonly wordDiffMode: DiffUiWordDiffMode;
260
+ readonly color: boolean;
261
+ readonly theme: DiffUiRenderTheme;
262
+ }): DiffUiRenderOutput {
263
+ const safeWidth = Math.max(40, Math.floor(input.width));
264
+ const safeHeight = Math.max(6, Math.floor(input.height));
265
+ const header = renderHeader({
266
+ model: input.model,
267
+ state: input.state,
268
+ width: safeWidth,
269
+ color: input.color,
270
+ theme: input.theme,
271
+ });
272
+ const footer = renderFooter({
273
+ model: input.model,
274
+ state: input.state,
275
+ width: safeWidth,
276
+ height: safeHeight,
277
+ color: input.color,
278
+ theme: input.theme,
279
+ });
280
+
281
+ const bodyRows = Math.max(1, safeHeight - 2);
282
+ const start = Math.max(0, input.state.topRow);
283
+ const end = Math.min(input.model.rows.length, start + bodyRows);
284
+ const bodyLines: string[] = [];
285
+
286
+ for (let index = start; index < end; index += 1) {
287
+ const row = input.model.rows[index]!;
288
+ const plainText =
289
+ input.state.effectiveViewMode === 'split'
290
+ ? renderSplitRow(row, safeWidth)
291
+ : renderUnifiedRow(row, safeWidth);
292
+ bodyLines.push(
293
+ decorateRow({
294
+ row,
295
+ plainText,
296
+ theme: input.theme,
297
+ color: input.color,
298
+ syntaxMode: input.syntaxMode,
299
+ wordDiffMode: input.wordDiffMode,
300
+ }),
301
+ );
302
+ }
303
+
304
+ while (bodyLines.length < bodyRows) {
305
+ bodyLines.push(padOrTrimDisplay('', safeWidth));
306
+ }
307
+
308
+ overlayFinderLines(bodyLines, input.state, safeWidth, input.color, input.theme);
309
+
310
+ return {
311
+ state: input.state,
312
+ lines: [header, ...bodyLines, footer],
313
+ };
314
+ }
315
+
316
+ export function renderDiffUiDocument(input: {
317
+ readonly model: DiffUiModel;
318
+ readonly syntaxMode: DiffUiSyntaxMode;
319
+ readonly wordDiffMode: DiffUiWordDiffMode;
320
+ readonly color: boolean;
321
+ readonly theme: DiffUiRenderTheme;
322
+ }): readonly string[] {
323
+ const lines: string[] = [];
324
+ for (const row of input.model.rows) {
325
+ lines.push(
326
+ decorateRow({
327
+ row,
328
+ plainText: row.unified,
329
+ theme: input.theme,
330
+ color: input.color,
331
+ syntaxMode: input.syntaxMode,
332
+ wordDiffMode: input.wordDiffMode,
333
+ }),
334
+ );
335
+ }
336
+ return lines;
337
+ }
@@ -0,0 +1,379 @@
1
+ import { fstatSync, readFileSync } from 'node:fs';
2
+ import { createDiffBuilder } from '../diff/build.ts';
3
+ import type { DiffBuildResult, DiffBuilder } from '../diff/types.ts';
4
+ import { Screen, type ScreenWriter } from '../../packages/harness-ui/src/screen.ts';
5
+ import { parseDiffUiArgs } from './args.ts';
6
+ import { diffUiCommandToStateAction, parseDiffUiCommand } from './commands.ts';
7
+ import { buildDiffUiModel } from './model.ts';
8
+ import {
9
+ runDiffUiPagerProcess,
10
+ type DiffUiPagerInputStream,
11
+ type DiffUiPagerOutputStream,
12
+ } from './pager.ts';
13
+ import { renderDiffUiDocument, renderDiffUiViewport, resolveDiffUiTheme } from './render.ts';
14
+ import { createInitialDiffUiState, reduceDiffUiState } from './state.ts';
15
+ import type { DiffUiCliOptions, DiffUiEvent, DiffUiRunOutput, DiffUiState } from './types.ts';
16
+
17
+ interface RunDiffUiCliDeps {
18
+ readonly cwd?: string;
19
+ readonly env?: NodeJS.ProcessEnv;
20
+ readonly argv?: readonly string[];
21
+ readonly writeStdout?: (text: string) => void;
22
+ readonly writeStderr?: (text: string) => void;
23
+ readonly stdoutCols?: number;
24
+ readonly stdoutRows?: number;
25
+ readonly readStdinText?: () => string;
26
+ readonly createBuilder?: () => DiffBuilder;
27
+ readonly isStdoutTty?: boolean;
28
+ readonly readRpcStdinFromFd0?: () => string;
29
+ readonly resolveStdinFdKind?: () => StdinFdKind;
30
+ readonly pagerStdin?: DiffUiPagerInputStream;
31
+ readonly pagerStdout?: DiffUiPagerOutputStream;
32
+ readonly createScreen?: (writer: ScreenWriter) => Pick<Screen, 'markDirty' | 'flush'>;
33
+ }
34
+
35
+ type StdinFdKind = 'tty' | 'pipe' | 'file' | 'other';
36
+
37
+ interface StdinFdStatLike {
38
+ isCharacterDevice(): boolean;
39
+ isFIFO(): boolean;
40
+ isFile(): boolean;
41
+ }
42
+
43
+ function viewportFromOptions(
44
+ options: DiffUiCliOptions,
45
+ deps: RunDiffUiCliDeps,
46
+ ): {
47
+ readonly width: number;
48
+ readonly height: number;
49
+ } {
50
+ const width = options.width ?? deps.stdoutCols ?? process.stdout.columns ?? 120;
51
+ const height = options.height ?? deps.stdoutRows ?? process.stdout.rows ?? 40;
52
+ return {
53
+ width: Math.max(40, Math.floor(width)),
54
+ height: Math.max(6, Math.floor(height)),
55
+ };
56
+ }
57
+
58
+ function emitEvents(events: readonly DiffUiEvent[], writeStdout: (text: string) => void): void {
59
+ for (const event of events) {
60
+ writeStdout(`${JSON.stringify(event)}\n`);
61
+ }
62
+ }
63
+
64
+ function emitRenderedLines(lines: readonly string[], writeStdout: (text: string) => void): void {
65
+ if (lines.length === 0) {
66
+ return;
67
+ }
68
+ writeStdout(`${lines.join('\n')}\n`);
69
+ }
70
+
71
+ function stdinFdKindFromStat(stats: StdinFdStatLike): StdinFdKind {
72
+ if (stats.isCharacterDevice()) {
73
+ return 'tty';
74
+ }
75
+ if (stats.isFIFO()) {
76
+ return 'pipe';
77
+ }
78
+ if (stats.isFile()) {
79
+ return 'file';
80
+ }
81
+ return 'other';
82
+ }
83
+
84
+ function readProcessStdinTextFromFd0(
85
+ readFdText: (fd: number, encoding: BufferEncoding) => string = readFileSync,
86
+ ): string {
87
+ return readFdText(0, 'utf8');
88
+ }
89
+
90
+ function resolveProcessStdinFdKind(
91
+ readStat: (fd: number) => StdinFdStatLike = (fd) => fstatSync(fd),
92
+ ): StdinFdKind {
93
+ try {
94
+ return stdinFdKindFromStat(readStat(0));
95
+ } catch {
96
+ return 'other';
97
+ }
98
+ }
99
+
100
+ function shouldReadRpcStdinFromFd0(isStdinTty: boolean, stdinFdKind: StdinFdKind): boolean {
101
+ if (isStdinTty) {
102
+ return false;
103
+ }
104
+ return stdinFdKind === 'pipe' || stdinFdKind === 'file';
105
+ }
106
+
107
+ function readRpcStdinText(
108
+ readStdinText: (() => string) | undefined,
109
+ readRpcStdinFromFd0: (() => string) | undefined,
110
+ resolveStdinFdKind: (() => StdinFdKind) | undefined,
111
+ ): string {
112
+ if (readStdinText !== undefined) {
113
+ return readStdinText();
114
+ }
115
+ const stdinFdKind = (resolveStdinFdKind ?? resolveProcessStdinFdKind)();
116
+ if (!shouldReadRpcStdinFromFd0(process.stdin.isTTY === true, stdinFdKind)) {
117
+ return '';
118
+ }
119
+ const readFromFd0 = readRpcStdinFromFd0 ?? readProcessStdinTextFromFd0;
120
+ return readFromFd0();
121
+ }
122
+
123
+ function renderCurrentViewport(input: {
124
+ readonly model: ReturnType<typeof buildDiffUiModel>;
125
+ readonly state: DiffUiState;
126
+ readonly options: DiffUiCliOptions;
127
+ readonly width: number;
128
+ readonly height: number;
129
+ }): readonly string[] {
130
+ return renderDiffUiViewport({
131
+ model: input.model,
132
+ state: input.state,
133
+ width: input.width,
134
+ height: input.height,
135
+ viewMode: input.options.viewMode,
136
+ syntaxMode: input.options.syntaxMode,
137
+ wordDiffMode: input.options.wordDiffMode,
138
+ color: input.options.color,
139
+ theme: resolveDiffUiTheme(input.options.theme),
140
+ }).lines;
141
+ }
142
+
143
+ function renderDocument(input: {
144
+ readonly model: ReturnType<typeof buildDiffUiModel>;
145
+ readonly options: DiffUiCliOptions;
146
+ }): readonly string[] {
147
+ return renderDiffUiDocument({
148
+ model: input.model,
149
+ syntaxMode: input.options.syntaxMode,
150
+ wordDiffMode: input.options.wordDiffMode,
151
+ color: input.options.color,
152
+ theme: resolveDiffUiTheme(input.options.theme),
153
+ });
154
+ }
155
+
156
+ async function buildDiffResult(
157
+ options: DiffUiCliOptions,
158
+ builder: DiffBuilder,
159
+ ): Promise<DiffBuildResult> {
160
+ const gitOptions =
161
+ options.renameLimit === null
162
+ ? {
163
+ noRenames: options.noRenames,
164
+ }
165
+ : {
166
+ noRenames: options.noRenames,
167
+ renameLimit: options.renameLimit,
168
+ };
169
+
170
+ const buildOptions = {
171
+ cwd: options.cwd,
172
+ mode: options.mode,
173
+ ...(options.baseRef !== null ? { baseRef: options.baseRef } : {}),
174
+ ...(options.headRef !== null ? { headRef: options.headRef } : {}),
175
+ includeGenerated: options.includeGenerated,
176
+ includeBinary: options.includeBinary,
177
+ git: gitOptions,
178
+ budget: options.budget,
179
+ };
180
+
181
+ return await builder.build(buildOptions);
182
+ }
183
+
184
+ export async function runDiffUiCli(deps: RunDiffUiCliDeps = {}): Promise<DiffUiRunOutput> {
185
+ const writeStdout = deps.writeStdout ?? ((text) => process.stdout.write(text));
186
+ const writeStderr = deps.writeStderr ?? ((text) => process.stderr.write(text));
187
+ const argv = deps.argv ?? process.argv.slice(2);
188
+ const events: DiffUiEvent[] = [];
189
+
190
+ try {
191
+ const options = parseDiffUiArgs(argv, {
192
+ ...(deps.cwd !== undefined ? { cwd: deps.cwd } : {}),
193
+ ...(deps.env !== undefined ? { env: deps.env } : {}),
194
+ ...(deps.isStdoutTty !== undefined ? { isStdoutTty: deps.isStdoutTty } : {}),
195
+ });
196
+
197
+ if (options.watch) {
198
+ events.push({
199
+ type: 'warning',
200
+ message: '--watch is parsed but not yet implemented; running one-shot render',
201
+ });
202
+ }
203
+
204
+ const builder = deps.createBuilder?.() ?? createDiffBuilder();
205
+ const diffResult = await buildDiffResult(options, builder);
206
+ const model = buildDiffUiModel(diffResult.diff);
207
+ const viewport = viewportFromOptions(options, deps);
208
+ const pageSize = Math.max(1, viewport.height - 2);
209
+
210
+ events.push({
211
+ type: 'diff.loaded',
212
+ files: model.diff.totals.filesChanged,
213
+ hunks: model.diff.totals.hunks,
214
+ lines: model.diff.totals.lines,
215
+ coverageReason: model.diff.coverage.reason,
216
+ });
217
+
218
+ let state = createInitialDiffUiState(model, options.viewMode, viewport.width);
219
+ let renderedLines: readonly string[] = [];
220
+
221
+ if (options.pager) {
222
+ const pagerResult = await runDiffUiPagerProcess({
223
+ model,
224
+ options,
225
+ initialState: state,
226
+ writeStdout,
227
+ writeStderr,
228
+ stdin: deps.pagerStdin ?? (process.stdin as unknown as DiffUiPagerInputStream),
229
+ stdout: deps.pagerStdout ?? (process.stdout as unknown as DiffUiPagerOutputStream),
230
+ createScreen:
231
+ deps.createScreen ??
232
+ ((writer) => {
233
+ return new Screen(writer);
234
+ }),
235
+ });
236
+ state = pagerResult.state;
237
+ renderedLines = pagerResult.renderedLines;
238
+ events.push(...pagerResult.events);
239
+ return {
240
+ exitCode: 0,
241
+ events,
242
+ renderedLines,
243
+ };
244
+ }
245
+
246
+ if (options.rpcStdio) {
247
+ renderedLines = renderCurrentViewport({
248
+ model,
249
+ state,
250
+ options,
251
+ width: viewport.width,
252
+ height: viewport.height,
253
+ });
254
+ events.push({
255
+ type: 'state.changed',
256
+ state,
257
+ });
258
+ events.push({
259
+ type: 'render.completed',
260
+ rows: renderedLines.length,
261
+ width: viewport.width,
262
+ height: viewport.height,
263
+ view: state.effectiveViewMode,
264
+ });
265
+
266
+ const stdinText = readRpcStdinText(
267
+ deps.readStdinText,
268
+ deps.readRpcStdinFromFd0,
269
+ deps.resolveStdinFdKind,
270
+ );
271
+ for (const rawLine of stdinText.split('\n')) {
272
+ const line = rawLine.trim();
273
+ if (line.length === 0) {
274
+ continue;
275
+ }
276
+ let parsed: unknown;
277
+ try {
278
+ parsed = JSON.parse(line);
279
+ } catch {
280
+ events.push({
281
+ type: 'warning',
282
+ message: `invalid json command: ${line}`,
283
+ });
284
+ continue;
285
+ }
286
+
287
+ const command = parseDiffUiCommand(parsed);
288
+ if (command === null) {
289
+ events.push({
290
+ type: 'warning',
291
+ message: `invalid command payload: ${line}`,
292
+ });
293
+ continue;
294
+ }
295
+ if (command.type === 'session.quit') {
296
+ events.push({
297
+ type: 'session.quit',
298
+ });
299
+ break;
300
+ }
301
+
302
+ const action = diffUiCommandToStateAction(command, pageSize);
303
+ state = reduceDiffUiState({
304
+ model,
305
+ state,
306
+ action,
307
+ viewportWidth: viewport.width,
308
+ viewportHeight: viewport.height,
309
+ });
310
+ renderedLines = renderCurrentViewport({
311
+ model,
312
+ state,
313
+ options,
314
+ width: viewport.width,
315
+ height: viewport.height,
316
+ });
317
+
318
+ events.push({
319
+ type: 'state.changed',
320
+ state,
321
+ });
322
+ events.push({
323
+ type: 'render.completed',
324
+ rows: renderedLines.length,
325
+ width: viewport.width,
326
+ height: viewport.height,
327
+ view: state.effectiveViewMode,
328
+ });
329
+ }
330
+ } else {
331
+ renderedLines = renderDocument({
332
+ model,
333
+ options,
334
+ });
335
+ events.push({
336
+ type: 'state.changed',
337
+ state,
338
+ });
339
+ events.push({
340
+ type: 'render.completed',
341
+ rows: renderedLines.length,
342
+ width: viewport.width,
343
+ height: viewport.height,
344
+ view: state.effectiveViewMode,
345
+ });
346
+ }
347
+
348
+ if (options.jsonEvents || options.rpcStdio) {
349
+ emitEvents(events, writeStdout);
350
+ } else {
351
+ emitRenderedLines(renderedLines, writeStdout);
352
+ }
353
+
354
+ return {
355
+ exitCode: 0,
356
+ events,
357
+ renderedLines,
358
+ };
359
+ } catch (error: unknown) {
360
+ const message = error instanceof Error ? error.message : String(error);
361
+ writeStderr(`[diff-ui] ${message}\n`);
362
+ const warning: DiffUiEvent = {
363
+ type: 'warning',
364
+ message,
365
+ };
366
+ return {
367
+ exitCode: 1,
368
+ events: [warning],
369
+ renderedLines: [],
370
+ };
371
+ }
372
+ }
373
+
374
+ export const __diffUiRuntimeInternals = {
375
+ stdinFdKindFromStat,
376
+ readProcessStdinTextFromFd0,
377
+ shouldReadRpcStdinFromFd0,
378
+ resolveProcessStdinFdKind,
379
+ };