@jmoyers/harness 0.1.10 → 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 (239) hide show
  1. package/README.md +31 -35
  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/{src/ui/modals/manager.ts → packages/harness-ui/src/modal-manager.ts} +94 -64
  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 -3721
  38. package/scripts/control-plane-daemon.ts +24 -2
  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 -3007
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/default-gateway-pointer.ts +193 -0
  46. package/src/cli/gateway/runtime.ts +1872 -0
  47. package/src/cli/parsing/flags.ts +23 -0
  48. package/src/cli/parsing/session.ts +42 -0
  49. package/src/cli/runtime/context.ts +193 -0
  50. package/src/cli/runtime-app/application.ts +392 -0
  51. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  52. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  53. package/src/cli/workflows/runtime.ts +965 -0
  54. package/src/clients/tui/left-rail-interactions.ts +519 -0
  55. package/src/clients/tui/main-pane-interactions.ts +509 -0
  56. package/src/clients/tui/modal-input-routing.ts +71 -0
  57. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  58. package/src/clients/web/synced-selectors.ts +132 -0
  59. package/src/codex/live-session.ts +82 -29
  60. package/src/config/config-core.ts +361 -10
  61. package/src/config/harness-paths.ts +4 -7
  62. package/src/config/harness-runtime-migration.ts +142 -19
  63. package/src/config/harness.config.template.jsonc +33 -0
  64. package/src/config/secrets-core.ts +92 -4
  65. package/src/control-plane/agent-realtime-api.ts +82 -427
  66. package/src/control-plane/prompt/thread-title-namer.ts +49 -23
  67. package/src/control-plane/session-summary.ts +10 -81
  68. package/src/control-plane/status/reducer-base.ts +12 -12
  69. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  70. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  71. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  72. package/src/control-plane/stream-client.ts +12 -2
  73. package/src/control-plane/stream-command-parser.ts +83 -143
  74. package/src/control-plane/stream-protocol.ts +53 -37
  75. package/src/control-plane/stream-server-background.ts +18 -2
  76. package/src/control-plane/stream-server-command.ts +376 -69
  77. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  78. package/src/control-plane/stream-server.ts +943 -80
  79. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  80. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  81. package/src/core/state/observed-stream-cursor.ts +43 -0
  82. package/src/core/state/synced-observed-state.ts +273 -0
  83. package/src/core/store/harness-synced-store.ts +81 -0
  84. package/src/diff/budget.ts +136 -0
  85. package/src/diff/build.ts +289 -0
  86. package/src/diff/chunker.ts +146 -0
  87. package/src/diff/git-invoke.ts +315 -0
  88. package/src/diff/git-parse.ts +472 -0
  89. package/src/diff/hash.ts +70 -0
  90. package/src/diff/index.ts +24 -0
  91. package/src/diff/normalize.ts +134 -0
  92. package/src/diff/types.ts +178 -0
  93. package/src/diff-ui/args.ts +346 -0
  94. package/src/diff-ui/commands.ts +123 -0
  95. package/src/diff-ui/finder.ts +94 -0
  96. package/src/diff-ui/highlight.ts +127 -0
  97. package/src/diff-ui/index.ts +2 -0
  98. package/src/diff-ui/model.ts +141 -0
  99. package/src/diff-ui/pager.ts +412 -0
  100. package/src/diff-ui/render.ts +337 -0
  101. package/src/diff-ui/runtime.ts +379 -0
  102. package/src/diff-ui/state.ts +224 -0
  103. package/src/diff-ui/types.ts +236 -0
  104. package/src/domain/conversations.ts +11 -7
  105. package/src/domain/workspace.ts +76 -4
  106. package/src/mux/control-plane-op-queue.ts +93 -7
  107. package/src/mux/conversation-rail.ts +28 -71
  108. package/src/mux/dual-pane-core.ts +13 -13
  109. package/src/mux/harness-core-ui.ts +313 -42
  110. package/src/mux/input-shortcuts.ts +22 -112
  111. package/src/mux/keybinding-catalog.ts +340 -0
  112. package/src/mux/keybinding-registry.ts +103 -0
  113. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  114. package/src/mux/live-mux/command-menu.ts +167 -4
  115. package/src/mux/live-mux/conversation-state.ts +13 -0
  116. package/src/mux/live-mux/directory-resolution.ts +1 -1
  117. package/src/mux/live-mux/git-parsing.ts +16 -0
  118. package/src/mux/live-mux/git-snapshot.ts +33 -2
  119. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  120. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  121. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  122. package/src/mux/live-mux/input-forwarding.ts +59 -2
  123. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  124. package/src/mux/live-mux/left-nav.ts +35 -0
  125. package/src/mux/live-mux/link-click.ts +292 -0
  126. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  127. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  128. package/src/mux/live-mux/modal-input-reducers.ts +106 -8
  129. package/src/mux/live-mux/modal-overlays.ts +210 -31
  130. package/src/mux/live-mux/modal-pointer.ts +3 -7
  131. package/src/mux/live-mux/modal-prompt-handlers.ts +107 -1
  132. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  133. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  134. package/src/mux/live-mux/pointer-routing.ts +5 -2
  135. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  136. package/src/mux/live-mux/rail-layout.ts +33 -30
  137. package/src/mux/live-mux/release-notes.ts +383 -0
  138. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  139. package/src/mux/live-mux/repository-folding.ts +3 -0
  140. package/src/mux/live-mux/selection.ts +0 -4
  141. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  142. package/src/mux/project-pane-github-review.ts +271 -0
  143. package/src/mux/render-frame.ts +4 -0
  144. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  145. package/src/mux/task-composer.ts +21 -14
  146. package/src/mux/task-focused-pane.ts +118 -117
  147. package/src/mux/task-screen-keybindings.ts +19 -82
  148. package/src/mux/workspace-rail-model.ts +270 -104
  149. package/src/mux/workspace-rail.ts +45 -22
  150. package/src/pty/session-broker.ts +1 -1
  151. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  152. package/src/services/control-plane.ts +50 -32
  153. package/src/services/conversation-lifecycle.ts +118 -87
  154. package/src/services/conversation-startup-hydration.ts +20 -12
  155. package/src/services/directory-hydration.ts +21 -16
  156. package/src/services/event-persistence.ts +7 -0
  157. package/src/services/left-rail-pointer-handler.ts +329 -0
  158. package/src/services/mux-ui-state-persistence.ts +5 -1
  159. package/src/services/recording.ts +34 -26
  160. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  161. package/src/services/runtime-control-actions.ts +79 -61
  162. package/src/services/runtime-control-plane-ops.ts +122 -83
  163. package/src/services/runtime-conversation-actions.ts +40 -26
  164. package/src/services/runtime-conversation-activation.ts +82 -30
  165. package/src/services/runtime-conversation-starter.ts +80 -48
  166. package/src/services/runtime-conversation-title-edit.ts +91 -80
  167. package/src/services/runtime-envelope-handler.ts +107 -105
  168. package/src/services/runtime-git-state.ts +42 -29
  169. package/src/services/runtime-layout-resize.ts +3 -1
  170. package/src/services/runtime-left-rail-render.ts +99 -63
  171. package/src/services/runtime-nim-cli-session.ts +438 -0
  172. package/src/services/runtime-nim-session.ts +705 -0
  173. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  174. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  175. package/src/services/runtime-process-wiring.ts +29 -36
  176. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  177. package/src/services/runtime-render-flush.ts +63 -70
  178. package/src/services/runtime-render-lifecycle.ts +65 -64
  179. package/src/services/runtime-render-orchestrator.ts +55 -45
  180. package/src/services/runtime-render-pipeline.ts +106 -103
  181. package/src/services/runtime-render-state.ts +62 -49
  182. package/src/services/runtime-repository-actions.ts +97 -70
  183. package/src/services/runtime-right-pane-render.ts +80 -53
  184. package/src/services/runtime-shutdown.ts +38 -35
  185. package/src/services/runtime-stream-subscriptions.ts +35 -27
  186. package/src/services/runtime-task-composer-persistence.ts +71 -59
  187. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  188. package/src/services/runtime-task-editor-actions.ts +46 -29
  189. package/src/services/runtime-task-pane-actions.ts +220 -134
  190. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  191. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  192. package/src/services/runtime-workspace-observed-events.ts +33 -184
  193. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  194. package/src/services/session-diagnostics-store.ts +217 -0
  195. package/src/services/startup-background-resume.ts +26 -21
  196. package/src/services/startup-orchestrator.ts +16 -13
  197. package/src/services/startup-paint-tracker.ts +29 -21
  198. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  199. package/src/services/startup-settled-gate.ts +25 -15
  200. package/src/services/startup-shutdown.ts +18 -22
  201. package/src/services/startup-state-hydration.ts +44 -34
  202. package/src/services/startup-visibility.ts +12 -4
  203. package/src/services/task-pane-selection-actions.ts +89 -72
  204. package/src/services/task-planning-hydration.ts +24 -18
  205. package/src/services/task-planning-observed-events.ts +50 -52
  206. package/src/services/workspace-observed-events.ts +66 -63
  207. package/src/storage/storage-lifecycle-core.ts +438 -0
  208. package/src/store/control-plane-store-normalize.ts +33 -242
  209. package/src/store/control-plane-store-types.ts +1 -35
  210. package/src/store/control-plane-store.ts +396 -56
  211. package/src/store/event-store.ts +397 -3
  212. package/src/terminal/snapshot-oracle.ts +207 -94
  213. package/src/ui/mux-theme.ts +112 -8
  214. package/src/ui/panes/home-gridfire.ts +40 -31
  215. package/src/ui/panes/home.ts +10 -2
  216. package/src/ui/panes/nim.ts +315 -0
  217. package/src/mux/live-mux/actions-task.ts +0 -115
  218. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  219. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -82
  220. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  221. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  222. package/src/services/runtime-directory-actions.ts +0 -164
  223. package/src/services/runtime-input-pipeline.ts +0 -50
  224. package/src/services/runtime-input-router.ts +0 -189
  225. package/src/services/runtime-main-pane-input.ts +0 -230
  226. package/src/services/runtime-modal-input.ts +0 -119
  227. package/src/services/runtime-navigation-input.ts +0 -197
  228. package/src/services/runtime-rail-input.ts +0 -278
  229. package/src/services/runtime-task-pane.ts +0 -62
  230. package/src/services/runtime-workspace-actions.ts +0 -158
  231. package/src/ui/conversation-input-forwarder.ts +0 -114
  232. package/src/ui/conversation-selection-input.ts +0 -103
  233. package/src/ui/global-shortcut-input.ts +0 -89
  234. package/src/ui/input.ts +0 -238
  235. package/src/ui/kit.ts +0 -509
  236. package/src/ui/left-nav-input.ts +0 -80
  237. package/src/ui/left-rail-pointer-input.ts +0 -148
  238. package/src/ui/repository-fold-input.ts +0 -91
  239. 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
+ }