@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
@@ -17,7 +17,7 @@ type RoutedInputToken =
17
17
 
18
18
  interface RouteInputTokensForConversationOptions {
19
19
  tokens: readonly RoutedInputToken[];
20
- mainPaneMode: 'conversation' | 'project' | 'home';
20
+ mainPaneMode: 'conversation' | 'project' | 'home' | 'nim';
21
21
  normalizeMuxKeyboardInputForPty: (input: Buffer) => Buffer;
22
22
  classifyPaneAt: (col: number, row: number) => string;
23
23
  wheelDeltaRowsFromCode: (code: number) => number | null;
@@ -27,8 +27,31 @@ interface RouteInputTokensForConversationOptions {
27
27
  rightCols: number;
28
28
  rightStartCol: number;
29
29
  };
30
- snapshotForInput: Pick<TerminalSnapshotFrameCore, 'activeScreen' | 'viewport'> | null;
30
+ snapshotForInput:
31
+ | (Pick<TerminalSnapshotFrameCore, 'activeScreen' | 'viewport'> & {
32
+ lines?: readonly string[];
33
+ })
34
+ | null;
31
35
  appMouseTrackingEnabled: boolean;
36
+ hasMetaModifier?: (code: number) => boolean;
37
+ handleMetaClick?: (input: {
38
+ event: {
39
+ col: number;
40
+ row: number;
41
+ code: number;
42
+ final: 'M' | 'm';
43
+ };
44
+ layout: {
45
+ paneRows: number;
46
+ rightCols: number;
47
+ rightStartCol: number;
48
+ };
49
+ snapshotForInput:
50
+ | (Pick<TerminalSnapshotFrameCore, 'activeScreen' | 'viewport'> & {
51
+ lines?: readonly string[];
52
+ })
53
+ | null;
54
+ }) => boolean;
32
55
  }
33
56
 
34
57
  interface RouteInputTokensForConversationResult {
@@ -40,6 +63,28 @@ function encodeSgrMouseEvent(code: number, col: number, row: number, final: 'M'
40
63
  return Buffer.from(`\u001b[<${String(code)};${String(col)};${String(row)}${final}`, 'utf8');
41
64
  }
42
65
 
66
+ function isWheelMouseCode(code: number): boolean {
67
+ return (code & 0b0100_0000) !== 0;
68
+ }
69
+
70
+ function isMotionMouseCode(code: number): boolean {
71
+ return (code & 0b0010_0000) !== 0;
72
+ }
73
+
74
+ function isLeftButtonPress(code: number, final: 'M' | 'm'): boolean {
75
+ if (final !== 'M') {
76
+ return false;
77
+ }
78
+ if (isWheelMouseCode(code) || isMotionMouseCode(code)) {
79
+ return false;
80
+ }
81
+ return (code & 0b0000_0011) === 0;
82
+ }
83
+
84
+ function hasMetaModifier(code: number): boolean {
85
+ return (code & 0b0000_1000) !== 0;
86
+ }
87
+
43
88
  function shouldPassThroughMouseToConversation(
44
89
  options: Pick<
45
90
  RouteInputTokensForConversationOptions,
@@ -85,6 +130,18 @@ export function routeInputTokensForConversation(
85
130
  if (options.mainPaneMode !== 'conversation') {
86
131
  continue;
87
132
  }
133
+ if (
134
+ options.handleMetaClick !== undefined &&
135
+ isLeftButtonPress(token.event.code, token.event.final) &&
136
+ (options.hasMetaModifier ?? hasMetaModifier)(token.event.code)
137
+ ) {
138
+ const handled = options.handleMetaClick({
139
+ event: token.event,
140
+ layout: options.layout,
141
+ snapshotForInput: options.snapshotForInput,
142
+ });
143
+ if (handled) continue;
144
+ }
88
145
  if (shouldPassThroughMouseToConversation(options, token.event.code)) {
89
146
  const sessionCol = Math.max(
90
147
  1,
@@ -5,33 +5,81 @@ interface ActivateLeftNavTargetOptions {
5
5
  target: LeftNavSelection;
6
6
  direction: 'next' | 'previous';
7
7
  enterHomePane: () => void;
8
+ enterNimPane?: () => void;
9
+ enterTasksPane?: () => void;
8
10
  firstDirectoryForRepositoryGroup: (repositoryGroupId: string) => string | null;
9
11
  enterProjectPane: (directoryId: string) => void;
12
+ enterGitHubPane?: (directoryId: string) => void;
10
13
  setMainPaneProjectMode: () => void;
11
14
  selectLeftNavRepository: (repositoryGroupId: string) => void;
15
+ selectLeftNavConversation?: (sessionId: string) => void;
12
16
  markDirty: () => void;
13
17
  directoriesHas: (directoryId: string) => boolean;
14
18
  visibleTargetsForState: () => readonly LeftNavSelection[];
15
19
  conversationDirectoryId: (sessionId: string) => string | null;
16
20
  queueControlPlaneOp: (task: () => Promise<void>, label: string) => void;
17
- activateConversation: (sessionId: string) => Promise<void>;
21
+ queueLatestControlPlaneOp?: (
22
+ key: string,
23
+ task: (options: { readonly signal: AbortSignal }) => Promise<void>,
24
+ label: string,
25
+ ) => void;
26
+ activateConversation: (
27
+ sessionId: string,
28
+ options?: { readonly signal?: AbortSignal },
29
+ ) => Promise<void>;
18
30
  conversationsHas: (sessionId: string) => boolean;
19
31
  }
20
32
 
33
+ const LEFT_NAV_ACTIVATION_KEY = 'left-nav:activate-conversation';
34
+
35
+ function queueLeftNavConversationActivation(
36
+ options: Pick<
37
+ ActivateLeftNavTargetOptions,
38
+ 'queueControlPlaneOp' | 'queueLatestControlPlaneOp' | 'activateConversation'
39
+ > & {
40
+ readonly sessionId: string;
41
+ readonly label: string;
42
+ },
43
+ ): void {
44
+ if (options.queueLatestControlPlaneOp !== undefined) {
45
+ options.queueLatestControlPlaneOp(
46
+ LEFT_NAV_ACTIVATION_KEY,
47
+ async ({ signal }) => {
48
+ if (signal.aborted) {
49
+ return;
50
+ }
51
+ await options.activateConversation(options.sessionId, {
52
+ signal,
53
+ });
54
+ },
55
+ options.label,
56
+ );
57
+ return;
58
+ }
59
+ options.queueControlPlaneOp(async () => {
60
+ await options.activateConversation(options.sessionId);
61
+ }, options.label);
62
+ }
63
+
21
64
  export function activateLeftNavTarget(options: ActivateLeftNavTargetOptions): void {
22
65
  const {
23
66
  target,
24
67
  direction,
25
68
  enterHomePane,
69
+ enterNimPane,
70
+ enterTasksPane,
26
71
  firstDirectoryForRepositoryGroup,
27
72
  enterProjectPane,
73
+ enterGitHubPane,
28
74
  setMainPaneProjectMode,
29
75
  selectLeftNavRepository,
76
+ selectLeftNavConversation,
30
77
  markDirty,
31
78
  directoriesHas,
32
79
  visibleTargetsForState,
33
80
  conversationDirectoryId,
34
81
  queueControlPlaneOp,
82
+ queueLatestControlPlaneOp,
35
83
  activateConversation,
36
84
  conversationsHas,
37
85
  } = options;
@@ -39,6 +87,22 @@ export function activateLeftNavTarget(options: ActivateLeftNavTargetOptions): vo
39
87
  enterHomePane();
40
88
  return;
41
89
  }
90
+ if (target.kind === 'nim') {
91
+ if (enterNimPane !== undefined) {
92
+ enterNimPane();
93
+ return;
94
+ }
95
+ enterHomePane();
96
+ return;
97
+ }
98
+ if (target.kind === 'tasks') {
99
+ if (enterTasksPane !== undefined) {
100
+ enterTasksPane();
101
+ return;
102
+ }
103
+ enterHomePane();
104
+ return;
105
+ }
42
106
  if (target.kind === 'repository') {
43
107
  const firstDirectoryId = firstDirectoryForRepositoryGroup(target.repositoryId);
44
108
  if (firstDirectoryId !== null) {
@@ -63,18 +127,71 @@ export function activateLeftNavTarget(options: ActivateLeftNavTargetOptions): vo
63
127
  conversationDirectoryId(entry.sessionId) === target.directoryId,
64
128
  );
65
129
  if (fallbackConversation !== undefined) {
66
- queueControlPlaneOp(async () => {
67
- await activateConversation(fallbackConversation.sessionId);
68
- }, `shortcut-activate-${direction}-directory-fallback`);
130
+ selectLeftNavConversation?.(fallbackConversation.sessionId);
131
+ markDirty();
132
+ queueLeftNavConversationActivation({
133
+ queueControlPlaneOp,
134
+ ...(queueLatestControlPlaneOp === undefined
135
+ ? {}
136
+ : {
137
+ queueLatestControlPlaneOp,
138
+ }),
139
+ activateConversation,
140
+ sessionId: fallbackConversation.sessionId,
141
+ label: `shortcut-activate-${direction}-directory-fallback`,
142
+ });
143
+ }
144
+ return;
145
+ }
146
+ if (target.kind === 'github') {
147
+ if (directoriesHas(target.directoryId)) {
148
+ if (enterGitHubPane !== undefined) {
149
+ enterGitHubPane(target.directoryId);
150
+ } else {
151
+ enterProjectPane(target.directoryId);
152
+ }
153
+ markDirty();
154
+ return;
155
+ }
156
+ const visibleTargets = visibleTargetsForState();
157
+ const fallbackConversation = visibleTargets.find(
158
+ (entry): entry is Extract<LeftNavSelection, { kind: 'conversation' }> =>
159
+ entry.kind === 'conversation' &&
160
+ conversationDirectoryId(entry.sessionId) === target.directoryId,
161
+ );
162
+ if (fallbackConversation !== undefined) {
163
+ selectLeftNavConversation?.(fallbackConversation.sessionId);
164
+ markDirty();
165
+ queueLeftNavConversationActivation({
166
+ queueControlPlaneOp,
167
+ ...(queueLatestControlPlaneOp === undefined
168
+ ? {}
169
+ : {
170
+ queueLatestControlPlaneOp,
171
+ }),
172
+ activateConversation,
173
+ sessionId: fallbackConversation.sessionId,
174
+ label: `shortcut-activate-${direction}-github-fallback`,
175
+ });
69
176
  }
70
177
  return;
71
178
  }
72
179
  if (!conversationsHas(target.sessionId)) {
73
180
  return;
74
181
  }
75
- queueControlPlaneOp(async () => {
76
- await activateConversation(target.sessionId);
77
- }, `shortcut-activate-${direction}`);
182
+ selectLeftNavConversation?.(target.sessionId);
183
+ markDirty();
184
+ queueLeftNavConversationActivation({
185
+ queueControlPlaneOp,
186
+ ...(queueLatestControlPlaneOp === undefined
187
+ ? {}
188
+ : {
189
+ queueLatestControlPlaneOp,
190
+ }),
191
+ activateConversation,
192
+ sessionId: target.sessionId,
193
+ label: `shortcut-activate-${direction}`,
194
+ });
78
195
  }
79
196
 
80
197
  interface CycleLeftNavSelectionOptions {
@@ -4,6 +4,12 @@ export type LeftNavSelection =
4
4
  | {
5
5
  readonly kind: 'home';
6
6
  }
7
+ | {
8
+ readonly kind: 'nim';
9
+ }
10
+ | {
11
+ readonly kind: 'tasks';
12
+ }
7
13
  | {
8
14
  readonly kind: 'repository';
9
15
  readonly repositoryId: string;
@@ -12,6 +18,10 @@ export type LeftNavSelection =
12
18
  readonly kind: 'project';
13
19
  readonly directoryId: string;
14
20
  }
21
+ | {
22
+ readonly kind: 'github';
23
+ readonly directoryId: string;
24
+ }
15
25
  | {
16
26
  readonly kind: 'conversation';
17
27
  readonly sessionId: string;
@@ -21,12 +31,21 @@ export function leftNavTargetKey(target: LeftNavSelection): string {
21
31
  if (target.kind === 'home') {
22
32
  return 'home';
23
33
  }
34
+ if (target.kind === 'nim') {
35
+ return 'nim';
36
+ }
37
+ if (target.kind === 'tasks') {
38
+ return 'tasks';
39
+ }
24
40
  if (target.kind === 'repository') {
25
41
  return `repository:${target.repositoryId}`;
26
42
  }
27
43
  if (target.kind === 'project') {
28
44
  return `directory:${target.directoryId}`;
29
45
  }
46
+ if (target.kind === 'github') {
47
+ return `github:${target.directoryId}`;
48
+ }
30
49
  return `conversation:${target.sessionId}`;
31
50
  }
32
51
 
@@ -43,6 +62,16 @@ function leftNavTargetFromRow(
43
62
  kind: 'home',
44
63
  };
45
64
  }
65
+ if (row.railAction === 'nim.open') {
66
+ return {
67
+ kind: 'nim',
68
+ };
69
+ }
70
+ if (row.railAction === 'tasks.open') {
71
+ return {
72
+ kind: 'tasks',
73
+ };
74
+ }
46
75
  if (row.kind === 'repository-header' && row.repositoryId !== null) {
47
76
  return {
48
77
  kind: 'repository',
@@ -55,6 +84,12 @@ function leftNavTargetFromRow(
55
84
  directoryId: row.directoryKey,
56
85
  };
57
86
  }
87
+ if (row.kind === 'github-header' && row.directoryKey !== null) {
88
+ return {
89
+ kind: 'github',
90
+ directoryId: row.directoryKey,
91
+ };
92
+ }
58
93
  if (row.kind === 'conversation-title' && row.conversationSessionId !== null) {
59
94
  return {
60
95
  kind: 'conversation',
@@ -0,0 +1,292 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { ResolvedCommandMenuOpenInTarget } from './command-menu-open-in.ts';
4
+
5
+ const TOKEN_REGEX = /\S+/gu;
6
+ const LEADING_WRAP_CHARS = new Set(['"', "'", '`', '(', '[', '{', '<']);
7
+ const TRAILING_WRAP_CHARS = new Set(['"', "'", '`', ')', ']', '}', '>', ',', '.', ';', '!', '?']);
8
+ const EDITOR_LINE_TARGET_IDS = new Set(['zed', 'cursor', 'vscode']);
9
+
10
+ type TerminalLinkTarget = TerminalUrlLinkTarget | TerminalFileLinkTarget;
11
+
12
+ interface TerminalUrlLinkTarget {
13
+ readonly kind: 'url';
14
+ readonly url: string;
15
+ }
16
+
17
+ interface TerminalFileLinkTarget {
18
+ readonly kind: 'file';
19
+ readonly path: string;
20
+ readonly line: number | null;
21
+ readonly column: number | null;
22
+ }
23
+
24
+ function trimTokenWrapper(token: string): string {
25
+ let start = 0;
26
+ let end = token.length;
27
+ while (start < end && LEADING_WRAP_CHARS.has(token[start]!)) {
28
+ start += 1;
29
+ }
30
+ while (end > start && TRAILING_WRAP_CHARS.has(token[end - 1]!)) {
31
+ end -= 1;
32
+ }
33
+ return token.slice(start, end);
34
+ }
35
+
36
+ function parsePositiveInteger(value: string): number | null {
37
+ const parsed = Number.parseInt(value, 10);
38
+ if (!Number.isFinite(parsed) || parsed <= 0) {
39
+ return null;
40
+ }
41
+ return parsed;
42
+ }
43
+
44
+ function splitTrailingLineColumn(value: string): {
45
+ readonly path: string;
46
+ readonly line: number | null;
47
+ readonly column: number | null;
48
+ } {
49
+ let remainder = value;
50
+ let column: number | null = null;
51
+ let line: number | null = null;
52
+
53
+ const trailingColumn = /:(\d+)$/u.exec(remainder);
54
+ if (trailingColumn !== null) {
55
+ const parsed = parsePositiveInteger(trailingColumn[1]!);
56
+ if (parsed !== null) {
57
+ column = parsed;
58
+ remainder = remainder.slice(0, -trailingColumn[0].length);
59
+ }
60
+ }
61
+
62
+ const trailingLine = /:(\d+)$/u.exec(remainder);
63
+ if (trailingLine !== null) {
64
+ const parsed = parsePositiveInteger(trailingLine[1]!);
65
+ if (parsed !== null) {
66
+ line = parsed;
67
+ remainder = remainder.slice(0, -trailingLine[0].length);
68
+ }
69
+ } else if (column !== null) {
70
+ line = column;
71
+ column = null;
72
+ }
73
+
74
+ return {
75
+ path: remainder,
76
+ line,
77
+ column,
78
+ };
79
+ }
80
+
81
+ function parseUrlTarget(candidate: string): TerminalUrlLinkTarget | null {
82
+ if (!/^https?:\/\//iu.test(candidate)) {
83
+ return null;
84
+ }
85
+ let parsed: URL;
86
+ try {
87
+ parsed = new URL(candidate);
88
+ } catch {
89
+ return null;
90
+ }
91
+ return {
92
+ kind: 'url',
93
+ url: parsed.toString(),
94
+ };
95
+ }
96
+
97
+ function looksLikeFilePath(pathLike: string, hasLineOrColumnSuffix: boolean): boolean {
98
+ if (pathLike.length === 0 || /\s/u.test(pathLike) || pathLike.includes('://')) {
99
+ return false;
100
+ }
101
+ if (
102
+ pathLike.startsWith('/') ||
103
+ pathLike.startsWith('./') ||
104
+ pathLike.startsWith('../') ||
105
+ pathLike.startsWith('~/')
106
+ ) {
107
+ return true;
108
+ }
109
+ if (pathLike.includes('/') || pathLike.includes('\\') || /^[A-Za-z]:[\\/]/u.test(pathLike)) {
110
+ return true;
111
+ }
112
+ if (hasLineOrColumnSuffix) {
113
+ return /^[A-Za-z0-9._-]+$/u.test(pathLike);
114
+ }
115
+ return /^[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+$/u.test(pathLike);
116
+ }
117
+
118
+ function parseFileTarget(candidate: string): TerminalFileLinkTarget | null {
119
+ if (candidate.startsWith('file://')) {
120
+ try {
121
+ const fileUrl = new URL(candidate);
122
+ return {
123
+ kind: 'file',
124
+ path: fileURLToPath(fileUrl),
125
+ line: null,
126
+ column: null,
127
+ };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+ const split = splitTrailingLineColumn(candidate);
133
+ const hasLineOrColumnSuffix = split.line !== null || split.column !== null;
134
+ if (!looksLikeFilePath(split.path, hasLineOrColumnSuffix)) {
135
+ return null;
136
+ }
137
+ return {
138
+ kind: 'file',
139
+ path: split.path,
140
+ line: split.line,
141
+ column: split.column,
142
+ };
143
+ }
144
+
145
+ export function resolveTerminalLinkTargetAtCell(options: {
146
+ readonly lines: readonly string[];
147
+ readonly row: number;
148
+ readonly col: number;
149
+ }): TerminalLinkTarget | null {
150
+ if (options.row < 1 || options.col < 1) {
151
+ return null;
152
+ }
153
+ const line = options.lines[options.row - 1];
154
+ if (line === undefined || line.length === 0) {
155
+ return null;
156
+ }
157
+ const targetColIndex = options.col - 1;
158
+ for (const match of line.matchAll(TOKEN_REGEX)) {
159
+ const token = match[0];
160
+ const start = match.index ?? -1;
161
+ const end = start + token.length;
162
+ if (start < 0 || targetColIndex < start || targetColIndex >= end) {
163
+ continue;
164
+ }
165
+ const trimmed = trimTokenWrapper(token);
166
+ if (trimmed.length === 0) {
167
+ return null;
168
+ }
169
+ const urlTarget = parseUrlTarget(trimmed);
170
+ if (urlTarget !== null) {
171
+ return urlTarget;
172
+ }
173
+ return parseFileTarget(trimmed);
174
+ }
175
+ return null;
176
+ }
177
+
178
+ export function resolveFileLinkPath(options: {
179
+ readonly path: string;
180
+ readonly directoryPath: string | null;
181
+ readonly homeDirectory: string;
182
+ }): string {
183
+ const normalized = options.path.trim();
184
+ if (normalized.startsWith('~/')) {
185
+ return resolve(options.homeDirectory, normalized.slice(2));
186
+ }
187
+ if (isAbsolute(normalized) || /^[A-Za-z]:[\\/]/u.test(normalized)) {
188
+ return normalized;
189
+ }
190
+ if (options.directoryPath !== null && options.directoryPath.trim().length > 0) {
191
+ return resolve(options.directoryPath, normalized);
192
+ }
193
+ return normalized;
194
+ }
195
+
196
+ export function buildFileLinkPathArgumentForTarget(options: {
197
+ readonly targetId: string;
198
+ readonly path: string;
199
+ readonly line: number | null;
200
+ readonly column: number | null;
201
+ }): string {
202
+ if (options.line === null || !EDITOR_LINE_TARGET_IDS.has(options.targetId)) {
203
+ return options.path;
204
+ }
205
+ if (options.column === null) {
206
+ return `${options.path}:${String(options.line)}`;
207
+ }
208
+ return `${options.path}:${String(options.line)}:${String(options.column)}`;
209
+ }
210
+
211
+ export function prioritizeOpenInTargetsForFileLinks(
212
+ targets: readonly ResolvedCommandMenuOpenInTarget[],
213
+ ): readonly ResolvedCommandMenuOpenInTarget[] {
214
+ const preferred = targets.filter((target) => target.id === 'zed');
215
+ const remainder = targets.filter((target) => target.id !== 'zed');
216
+ return [...preferred, ...remainder];
217
+ }
218
+
219
+ function valueForPlaceholder(
220
+ placeholder: string,
221
+ values: {
222
+ readonly path?: string | null;
223
+ readonly url?: string | null;
224
+ readonly line?: number | null;
225
+ readonly column?: number | null;
226
+ },
227
+ ): string | null {
228
+ if (placeholder === '{path}') {
229
+ const value = values.path;
230
+ return typeof value === 'string' && value.length > 0 ? value : null;
231
+ }
232
+ if (placeholder === '{url}') {
233
+ const value = values.url;
234
+ return typeof value === 'string' && value.length > 0 ? value : null;
235
+ }
236
+ if (placeholder === '{line}') {
237
+ return typeof values.line === 'number' && values.line > 0 ? String(values.line) : null;
238
+ }
239
+ if (placeholder === '{column}') {
240
+ return typeof values.column === 'number' && values.column > 0 ? String(values.column) : null;
241
+ }
242
+ return null;
243
+ }
244
+
245
+ export function resolveLinkCommandFromTemplate(options: {
246
+ readonly template: readonly string[] | null;
247
+ readonly values: {
248
+ readonly path?: string | null;
249
+ readonly url?: string | null;
250
+ readonly line?: number | null;
251
+ readonly column?: number | null;
252
+ };
253
+ readonly appendPrimaryPlaceholder: '{path}' | '{url}';
254
+ }): { command: string; args: readonly string[] } | null {
255
+ if (options.template === null || options.template.length === 0) {
256
+ return null;
257
+ }
258
+ const command = options.template[0]?.trim() ?? '';
259
+ if (command.length === 0) {
260
+ return null;
261
+ }
262
+ const args: string[] = [];
263
+ let injectedPrimary = false;
264
+ for (const rawPart of options.template.slice(1)) {
265
+ const part = rawPart.trim();
266
+ if (part.length === 0) {
267
+ continue;
268
+ }
269
+ const placeholderValue = valueForPlaceholder(part, options.values);
270
+ if (placeholderValue !== null) {
271
+ args.push(placeholderValue);
272
+ if (part === options.appendPrimaryPlaceholder) {
273
+ injectedPrimary = true;
274
+ }
275
+ continue;
276
+ }
277
+ if (part === '{path}' || part === '{url}' || part === '{line}' || part === '{column}') {
278
+ continue;
279
+ }
280
+ args.push(part);
281
+ }
282
+ if (!injectedPrimary) {
283
+ const fallbackValue = valueForPlaceholder(options.appendPrimaryPlaceholder, options.values);
284
+ if (fallbackValue !== null) {
285
+ args.push(fallbackValue);
286
+ }
287
+ }
288
+ return {
289
+ command,
290
+ args,
291
+ };
292
+ }