@jmoyers/harness 0.1.9 → 0.1.11

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 (54) hide show
  1. package/README.md +36 -155
  2. package/package.json +3 -1
  3. package/packages/harness-ai/src/anthropic-client.ts +99 -0
  4. package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
  5. package/packages/harness-ai/src/anthropic-provider.ts +82 -0
  6. package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
  7. package/packages/harness-ai/src/index.ts +36 -0
  8. package/packages/harness-ai/src/json-parse.ts +66 -0
  9. package/packages/harness-ai/src/sse.ts +80 -0
  10. package/packages/harness-ai/src/stream-object.ts +96 -0
  11. package/packages/harness-ai/src/stream-text.ts +1340 -0
  12. package/packages/harness-ai/src/types.ts +330 -0
  13. package/packages/harness-ai/src/ui-stream.ts +217 -0
  14. package/scripts/codex-live-mux-runtime.ts +265 -14
  15. package/scripts/control-plane-daemon.ts +33 -5
  16. package/scripts/harness.ts +579 -134
  17. package/src/cli/default-gateway-pointer.ts +193 -0
  18. package/src/cli/gateway-record.ts +16 -1
  19. package/src/config/config-core.ts +13 -2
  20. package/src/config/harness-paths.ts +4 -7
  21. package/src/config/harness-runtime-migration.ts +142 -19
  22. package/src/config/secrets-core.ts +92 -4
  23. package/src/control-plane/prompt/thread-title-namer.ts +316 -0
  24. package/src/control-plane/stream-command-parser.ts +12 -0
  25. package/src/control-plane/stream-protocol.ts +6 -0
  26. package/src/control-plane/stream-server-background.ts +18 -2
  27. package/src/control-plane/stream-server-command.ts +14 -0
  28. package/src/control-plane/stream-server.ts +460 -28
  29. package/src/domain/conversations.ts +11 -7
  30. package/src/domain/workspace.ts +9 -0
  31. package/src/mux/input-shortcuts.ts +38 -1
  32. package/src/mux/live-mux/git-parsing.ts +40 -0
  33. package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
  34. package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
  35. package/src/mux/live-mux/modal-input-reducers.ts +34 -1
  36. package/src/mux/live-mux/modal-overlays.ts +45 -0
  37. package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
  38. package/src/mux/render-frame.ts +1 -1
  39. package/src/mux/task-screen-keybindings.ts +29 -1
  40. package/src/services/control-plane.ts +22 -0
  41. package/src/services/runtime-control-actions.ts +69 -0
  42. package/src/services/runtime-conversation-activation.ts +25 -0
  43. package/src/services/runtime-conversation-starter.ts +31 -7
  44. package/src/services/runtime-input-router.ts +6 -0
  45. package/src/services/runtime-modal-input.ts +18 -0
  46. package/src/services/runtime-navigation-input.ts +4 -0
  47. package/src/services/runtime-rail-input.ts +5 -0
  48. package/src/services/runtime-repository-actions.ts +2 -0
  49. package/src/services/runtime-workspace-actions.ts +5 -0
  50. package/src/store/control-plane-store.ts +36 -0
  51. package/src/store/event-store.ts +36 -0
  52. package/src/ui/global-shortcut-input.ts +2 -0
  53. package/src/ui/input.ts +31 -0
  54. package/src/ui/modals/manager.ts +26 -0
@@ -33,6 +33,13 @@ interface RuntimeConversationStarterLaunchArgsInput {
33
33
 
34
34
  type RuntimeConversationStarterSpanAttributes = Record<string, string | number | boolean>;
35
35
 
36
+ function isSessionAlreadyExistsError(error: unknown): boolean {
37
+ if (!(error instanceof Error)) {
38
+ return false;
39
+ }
40
+ return error.message.includes('session already exists');
41
+ }
42
+
36
43
  export interface RuntimeConversationStarterOptions<
37
44
  TConversation extends RuntimeConversationStarterConversationRecord,
38
45
  TSessionSummary,
@@ -134,19 +141,36 @@ export class RuntimeConversationStarter<
134
141
  if (this.options.terminalBackgroundHex !== undefined) {
135
142
  ptyStartInput.terminalBackgroundHex = this.options.terminalBackgroundHex;
136
143
  }
137
- await this.options.startPtySession(ptyStartInput);
144
+ let startedSession = false;
145
+ try {
146
+ await this.options.startPtySession(ptyStartInput);
147
+ startedSession = true;
148
+ } catch (error: unknown) {
149
+ if (!isSessionAlreadyExistsError(error)) {
150
+ throw error;
151
+ }
152
+ }
138
153
  this.options.setPtySize(sessionId, {
139
154
  cols: layout.rightCols,
140
155
  rows: layout.paneRows,
141
156
  });
142
157
  this.options.sendResize(sessionId, layout.rightCols, layout.paneRows);
143
- this.endStartCommandSpanIfTarget(sessionId, {
144
- alreadyLive: false,
145
- argCount: launchArgs.length,
146
- resumed: launchArgs[0] === 'resume',
147
- });
158
+ if (startedSession) {
159
+ this.endStartCommandSpanIfTarget(sessionId, {
160
+ alreadyLive: false,
161
+ argCount: launchArgs.length,
162
+ resumed: launchArgs[0] === 'resume',
163
+ });
164
+ } else {
165
+ this.endStartCommandSpanIfTarget(sessionId, {
166
+ alreadyLive: true,
167
+ recoveredDuplicateStart: true,
168
+ });
169
+ }
148
170
  const state = this.options.ensureConversation(sessionId);
149
- this.options.recordStartCommand(sessionId, launchArgs);
171
+ if (startedSession) {
172
+ this.options.recordStartCommand(sessionId, launchArgs);
173
+ }
150
174
  const statusSummary = await this.options.getSessionStatus(sessionId);
151
175
  if (statusSummary !== null) {
152
176
  this.options.upsertFromSessionSummary(statusSummary);
@@ -41,6 +41,7 @@ interface RuntimeInputRouterOptions {
41
41
  readonly scheduleConversationTitlePersist: () => void;
42
42
  readonly resolveCommandMenuActions: () => readonly CommandMenuActionDescriptor[];
43
43
  readonly executeCommandMenuAction: (actionId: string) => void;
44
+ readonly persistApiKey?: RuntimeModalInputOptions['persistApiKey'];
44
45
  readonly requestStop: RuntimeRailInputOptions['requestStop'];
45
46
  readonly resolveDirectoryForAction: RuntimeRailInputOptions['resolveDirectoryForAction'];
46
47
  readonly toggleCommandMenu: RuntimeRailInputOptions['toggleCommandMenu'];
@@ -112,6 +113,11 @@ export class RuntimeInputRouter {
112
113
  scheduleConversationTitlePersist: options.scheduleConversationTitlePersist,
113
114
  resolveCommandMenuActions: options.resolveCommandMenuActions,
114
115
  executeCommandMenuAction: options.executeCommandMenuAction,
116
+ ...(options.persistApiKey === undefined
117
+ ? {}
118
+ : {
119
+ persistApiKey: options.persistApiKey,
120
+ }),
115
121
  });
116
122
  const runtimeRailOptions: RuntimeRailInputOptions = {
117
123
  workspace: options.workspace,
@@ -37,6 +37,7 @@ interface RuntimeModalInputOptions {
37
37
  readonly scheduleConversationTitlePersist: () => void;
38
38
  readonly resolveCommandMenuActions: () => readonly CommandMenuActionDescriptor[];
39
39
  readonly executeCommandMenuAction: (actionId: string) => void;
40
+ readonly persistApiKey?: (keyName: string, value: string) => void;
40
41
  readonly markDirty: () => void;
41
42
  }
42
43
 
@@ -97,6 +98,23 @@ export class RuntimeModalInput {
97
98
  submitTaskEditorPayload: (payload) => {
98
99
  options.taskEditorActions.submitTaskEditorPayload(payload);
99
100
  },
101
+ ...(options.persistApiKey === undefined
102
+ ? {}
103
+ : {
104
+ getApiKeyPrompt: () => options.workspace.apiKeyPrompt,
105
+ setApiKeyPrompt: (
106
+ next: {
107
+ keyName: string;
108
+ displayName: string;
109
+ value: string;
110
+ error: string | null;
111
+ hasExistingValue: boolean;
112
+ } | null,
113
+ ) => {
114
+ options.workspace.apiKeyPrompt = next;
115
+ },
116
+ persistApiKey: options.persistApiKey,
117
+ }),
100
118
  getConversationTitleEdit: () => options.workspace.conversationTitleEdit,
101
119
  getNewThreadPrompt: () => options.workspace.newThreadPrompt,
102
120
  setNewThreadPrompt: (prompt) => {
@@ -17,6 +17,7 @@ interface RuntimeNavigationWorkspaceActions {
17
17
  toggleGatewayStatusTimeline(): Promise<void>;
18
18
  toggleGatewayRenderTrace(conversationId: string | null): Promise<void>;
19
19
  archiveConversation(sessionId: string): Promise<void>;
20
+ refreshAllConversationTitles(): Promise<void>;
20
21
  interruptConversation(sessionId: string): Promise<void>;
21
22
  takeoverConversation(sessionId: string): Promise<void>;
22
23
  closeDirectory(directoryId: string): Promise<void>;
@@ -157,6 +158,9 @@ export class RuntimeNavigationInput {
157
158
  archiveConversation: async (sessionId) => {
158
159
  await options.workspaceActions.archiveConversation(sessionId);
159
160
  },
161
+ refreshAllConversationTitles: async () => {
162
+ await options.workspaceActions.refreshAllConversationTitles();
163
+ },
160
164
  interruptConversation: async (sessionId) => {
161
165
  await options.workspaceActions.interruptConversation(sessionId);
162
166
  },
@@ -15,6 +15,7 @@ interface RuntimeRailWorkspaceActions {
15
15
  toggleGatewayStatusTimeline(): Promise<void>;
16
16
  toggleGatewayRenderTrace(conversationId: string | null): Promise<void>;
17
17
  archiveConversation(sessionId: string): Promise<void>;
18
+ refreshAllConversationTitles(): Promise<void>;
18
19
  interruptConversation(sessionId: string): Promise<void>;
19
20
  takeoverConversation(sessionId: string): Promise<void>;
20
21
  closeDirectory(directoryId: string): Promise<void>;
@@ -142,6 +143,9 @@ export class RuntimeRailInput {
142
143
  archiveConversation: async (sessionId) => {
143
144
  await options.runtimeWorkspaceActions.archiveConversation(sessionId);
144
145
  },
146
+ refreshAllConversationTitles: async () => {
147
+ await options.runtimeWorkspaceActions.refreshAllConversationTitles();
148
+ },
145
149
  interruptConversation: async (sessionId) => {
146
150
  await options.runtimeWorkspaceActions.interruptConversation(sessionId);
147
151
  },
@@ -266,6 +270,7 @@ export class RuntimeRailInput {
266
270
 
267
271
  private openAddDirectoryPrompt(): void {
268
272
  this.options.workspace.repositoryPrompt = null;
273
+ this.options.workspace.apiKeyPrompt = null;
269
274
  this.options.workspace.addDirectoryPrompt = {
270
275
  value: '',
271
276
  error: null,
@@ -55,6 +55,7 @@ export class RuntimeRepositoryActions<TRepository extends RepositoryRecordShape>
55
55
  openRepositoryPromptForCreate(): void {
56
56
  this.options.workspace.newThreadPrompt = null;
57
57
  this.options.workspace.addDirectoryPrompt = null;
58
+ this.options.workspace.apiKeyPrompt = null;
58
59
  if (this.options.workspace.conversationTitleEdit !== null) {
59
60
  this.options.stopConversationTitleEdit();
60
61
  }
@@ -75,6 +76,7 @@ export class RuntimeRepositoryActions<TRepository extends RepositoryRecordShape>
75
76
  }
76
77
  this.options.workspace.newThreadPrompt = null;
77
78
  this.options.workspace.addDirectoryPrompt = null;
79
+ this.options.workspace.apiKeyPrompt = null;
78
80
  if (this.options.workspace.conversationTitleEdit !== null) {
79
81
  this.options.stopConversationTitleEdit();
80
82
  }
@@ -30,6 +30,7 @@ interface RuntimeWorkspaceControlActions {
30
30
  toggleGatewayProfiler(): Promise<void>;
31
31
  toggleGatewayStatusTimeline(): Promise<void>;
32
32
  toggleGatewayRenderTrace(conversationId: string | null): Promise<void>;
33
+ refreshAllConversationTitles(): Promise<void>;
33
34
  }
34
35
 
35
36
  interface RuntimeWorkspaceTaskPaneActions {
@@ -135,6 +136,10 @@ export class RuntimeWorkspaceActions {
135
136
  await this.options.controlActions.toggleGatewayRenderTrace(conversationId);
136
137
  }
137
138
 
139
+ async refreshAllConversationTitles(): Promise<void> {
140
+ await this.options.controlActions.refreshAllConversationTitles();
141
+ }
142
+
138
143
  runTaskPaneAction(action: TaskPaneAction): void {
139
144
  this.options.taskPaneActions.runTaskPaneAction(action);
140
145
  }
@@ -355,6 +355,8 @@ interface ListGitHubSyncStateQuery {
355
355
  limit?: number;
356
356
  }
357
357
 
358
+ const CONTROL_PLANE_SCHEMA_VERSION = 1;
359
+
358
360
  export class SqliteControlPlaneStore {
359
361
  private readonly db: DatabaseSync;
360
362
 
@@ -2628,6 +2630,24 @@ export class SqliteControlPlaneStore {
2628
2630
  }
2629
2631
 
2630
2632
  private initializeSchema(): void {
2633
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
2634
+ try {
2635
+ const currentVersion = this.readSchemaVersion();
2636
+ if (currentVersion > CONTROL_PLANE_SCHEMA_VERSION) {
2637
+ throw new Error(
2638
+ `control-plane schema version ${String(currentVersion)} is newer than supported version ${String(CONTROL_PLANE_SCHEMA_VERSION)}`,
2639
+ );
2640
+ }
2641
+ this.applySchemaV1();
2642
+ this.writeSchemaVersion(CONTROL_PLANE_SCHEMA_VERSION);
2643
+ this.db.exec('COMMIT');
2644
+ } catch (error) {
2645
+ this.db.exec('ROLLBACK');
2646
+ throw error;
2647
+ }
2648
+ }
2649
+
2650
+ private applySchemaV1(): void {
2631
2651
  this.db.exec(`
2632
2652
  CREATE TABLE IF NOT EXISTS directories (
2633
2653
  directory_id TEXT PRIMARY KEY,
@@ -2923,6 +2943,22 @@ export class SqliteControlPlaneStore {
2923
2943
  `);
2924
2944
  }
2925
2945
 
2946
+ private readSchemaVersion(): number {
2947
+ const row = this.db.prepare('PRAGMA user_version;').get();
2948
+ if (row === undefined) {
2949
+ throw new Error('failed to read control-plane schema version');
2950
+ }
2951
+ const version = (row as Record<string, unknown>)['user_version'];
2952
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 0) {
2953
+ throw new Error(`invalid control-plane schema version value: ${String(version)}`);
2954
+ }
2955
+ return version;
2956
+ }
2957
+
2958
+ private writeSchemaVersion(version: number): void {
2959
+ this.db.exec(`PRAGMA user_version = ${String(version)};`);
2960
+ }
2961
+
2926
2962
  private configureConnection(): void {
2927
2963
  this.db.exec('PRAGMA journal_mode = WAL;');
2928
2964
  this.db.exec('PRAGMA synchronous = NORMAL;');
@@ -31,6 +31,8 @@ interface PersistedEvent {
31
31
  event: NormalizedEventEnvelope;
32
32
  }
33
33
 
34
+ const EVENT_STORE_SCHEMA_VERSION = 1;
35
+
34
36
  function asObject(value: unknown): Record<string, unknown> {
35
37
  if (typeof value !== 'object' || value === null) {
36
38
  throw new Error('expected object row');
@@ -214,6 +216,24 @@ export class SqliteEventStore {
214
216
  }
215
217
 
216
218
  private initializeSchema(): void {
219
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
220
+ try {
221
+ const currentVersion = this.readSchemaVersion();
222
+ if (currentVersion > EVENT_STORE_SCHEMA_VERSION) {
223
+ throw new Error(
224
+ `event store schema version ${String(currentVersion)} is newer than supported version ${String(EVENT_STORE_SCHEMA_VERSION)}`,
225
+ );
226
+ }
227
+ this.applySchemaV1();
228
+ this.writeSchemaVersion(EVENT_STORE_SCHEMA_VERSION);
229
+ this.db.exec('COMMIT');
230
+ } catch (error) {
231
+ this.db.exec('ROLLBACK');
232
+ throw error;
233
+ }
234
+ }
235
+
236
+ private applySchemaV1(): void {
217
237
  this.db.exec(`
218
238
  CREATE TABLE IF NOT EXISTS events (
219
239
  row_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -236,6 +256,22 @@ export class SqliteEventStore {
236
256
  `);
237
257
  }
238
258
 
259
+ private readSchemaVersion(): number {
260
+ const row = this.db.prepare('PRAGMA user_version;').get();
261
+ if (row === undefined) {
262
+ throw new Error('failed to read event store schema version');
263
+ }
264
+ const version = (row as Record<string, unknown>)['user_version'];
265
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 0) {
266
+ throw new Error(`invalid event store schema version value: ${String(version)}`);
267
+ }
268
+ return version;
269
+ }
270
+
271
+ private writeSchemaVersion(version: number): void {
272
+ this.db.exec(`PRAGMA user_version = ${String(version)};`);
273
+ }
274
+
239
275
  private configureConnection(): void {
240
276
  this.db.exec('PRAGMA journal_mode = WAL;');
241
277
  this.db.exec('PRAGMA synchronous = NORMAL;');
@@ -23,6 +23,7 @@ interface GlobalShortcutInputOptions {
23
23
  readonly conversationsHas: (sessionId: string) => boolean;
24
24
  readonly queueControlPlaneOp: (task: () => Promise<void>, label: string) => void;
25
25
  readonly archiveConversation: (sessionId: string) => Promise<void>;
26
+ readonly refreshAllConversationTitles: () => Promise<void>;
26
27
  readonly interruptConversation: (sessionId: string) => Promise<void>;
27
28
  readonly takeoverConversation: (sessionId: string) => Promise<void>;
28
29
  readonly openAddDirectoryPrompt: () => void;
@@ -70,6 +71,7 @@ export class GlobalShortcutInput {
70
71
  conversationsHas: this.options.conversationsHas,
71
72
  queueControlPlaneOp: this.options.queueControlPlaneOp,
72
73
  archiveConversation: this.options.archiveConversation,
74
+ refreshAllConversationTitles: this.options.refreshAllConversationTitles,
73
75
  interruptConversation: this.options.interruptConversation,
74
76
  takeoverConversation: this.options.takeoverConversation,
75
77
  openAddDirectoryPrompt: this.options.openAddDirectoryPrompt,
package/src/ui/input.ts CHANGED
@@ -4,11 +4,13 @@ import {
4
4
  handleNewThreadPromptInput as handleNewThreadPromptInputFrame,
5
5
  } from '../mux/live-mux/modal-conversation-handlers.ts';
6
6
  import {
7
+ handleApiKeyPromptInput as handleApiKeyPromptInputFrame,
7
8
  handleAddDirectoryPromptInput as handleAddDirectoryPromptInputFrame,
8
9
  handleRepositoryPromptInput as handleRepositoryPromptInputFrame,
9
10
  } from '../mux/live-mux/modal-prompt-handlers.ts';
10
11
  import { handleTaskEditorPromptInput as handleTaskEditorPromptInputFrame } from '../mux/live-mux/modal-task-editor-handler.ts';
11
12
  import type {
13
+ ApiKeyPromptState,
12
14
  ConversationTitleEditState,
13
15
  RepositoryPromptState,
14
16
  TaskEditorPromptState,
@@ -63,6 +65,9 @@ interface InputRouterOptions {
63
65
  readonly getTaskEditorPrompt: () => TaskEditorPromptState | null;
64
66
  readonly setTaskEditorPrompt: (next: TaskEditorPromptState | null) => void;
65
67
  readonly submitTaskEditorPayload: (payload: TaskEditorSubmitPayload) => void;
68
+ readonly getApiKeyPrompt?: () => ApiKeyPromptState | null;
69
+ readonly setApiKeyPrompt?: (next: ApiKeyPromptState | null) => void;
70
+ readonly persistApiKey?: (keyName: string, value: string) => void;
66
71
  readonly getConversationTitleEdit: () => ConversationTitleEditState | null;
67
72
  readonly getCommandMenu: () => CommandMenuState | null;
68
73
  readonly setCommandMenu: (menu: CommandMenuState | null) => void;
@@ -84,6 +89,7 @@ interface InputRouterOptions {
84
89
  interface InputRouterDependencies {
85
90
  readonly handleCommandMenuInput?: typeof handleCommandMenuInputFrame;
86
91
  readonly handleTaskEditorPromptInput?: typeof handleTaskEditorPromptInputFrame;
92
+ readonly handleApiKeyPromptInput?: typeof handleApiKeyPromptInputFrame;
87
93
  readonly handleConversationTitleEditInput?: typeof handleConversationTitleEditInputFrame;
88
94
  readonly handleNewThreadPromptInput?: typeof handleNewThreadPromptInputFrame;
89
95
  readonly handleAddDirectoryPromptInput?: typeof handleAddDirectoryPromptInputFrame;
@@ -93,6 +99,7 @@ interface InputRouterDependencies {
93
99
  export class InputRouter {
94
100
  private readonly handleCommandMenuInputFrame: typeof handleCommandMenuInputFrame;
95
101
  private readonly handleTaskEditorPromptInputFrame: typeof handleTaskEditorPromptInputFrame;
102
+ private readonly handleApiKeyPromptInputFrame: typeof handleApiKeyPromptInputFrame;
96
103
  private readonly handleConversationTitleEditInputFrame: typeof handleConversationTitleEditInputFrame;
97
104
  private readonly handleNewThreadPromptInputFrame: typeof handleNewThreadPromptInputFrame;
98
105
  private readonly handleAddDirectoryPromptInputFrame: typeof handleAddDirectoryPromptInputFrame;
@@ -106,6 +113,8 @@ export class InputRouter {
106
113
  dependencies.handleCommandMenuInput ?? handleCommandMenuInputFrame;
107
114
  this.handleTaskEditorPromptInputFrame =
108
115
  dependencies.handleTaskEditorPromptInput ?? handleTaskEditorPromptInputFrame;
116
+ this.handleApiKeyPromptInputFrame =
117
+ dependencies.handleApiKeyPromptInput ?? handleApiKeyPromptInputFrame;
109
118
  this.handleConversationTitleEditInputFrame =
110
119
  dependencies.handleConversationTitleEditInput ?? handleConversationTitleEditInputFrame;
111
120
  this.handleNewThreadPromptInputFrame =
@@ -168,6 +177,25 @@ export class InputRouter {
168
177
  });
169
178
  }
170
179
 
180
+ handleApiKeyPromptInput(input: Buffer): boolean {
181
+ if (
182
+ this.options.getApiKeyPrompt === undefined ||
183
+ this.options.setApiKeyPrompt === undefined ||
184
+ this.options.persistApiKey === undefined
185
+ ) {
186
+ return false;
187
+ }
188
+ return this.handleApiKeyPromptInputFrame({
189
+ input,
190
+ prompt: this.options.getApiKeyPrompt(),
191
+ isQuitShortcut: this.options.isModalDismissShortcut,
192
+ dismissOnOutsideClick: this.options.dismissOnOutsideClick,
193
+ setPrompt: this.options.setApiKeyPrompt,
194
+ markDirty: this.options.markDirty,
195
+ persistApiKey: this.options.persistApiKey,
196
+ });
197
+ }
198
+
171
199
  handleNewThreadPromptInput(input: Buffer): boolean {
172
200
  return this.handleNewThreadPromptInputFrame({
173
201
  input,
@@ -224,6 +252,9 @@ export class InputRouter {
224
252
  if (this.handleRepositoryPromptInput(input)) {
225
253
  return true;
226
254
  }
255
+ if (this.handleApiKeyPromptInput(input)) {
256
+ return true;
257
+ }
227
258
  if (this.handleNewThreadPromptInput(input)) {
228
259
  return true;
229
260
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  buildCommandMenuModalOverlay as buildCommandMenuModalOverlayFrame,
3
3
  buildAddDirectoryModalOverlay as buildAddDirectoryModalOverlayFrame,
4
+ buildApiKeyModalOverlay as buildApiKeyModalOverlayFrame,
4
5
  buildConversationTitleModalOverlay as buildConversationTitleModalOverlayFrame,
5
6
  buildNewThreadModalOverlay as buildNewThreadModalOverlayFrame,
6
7
  buildRepositoryModalOverlay as buildRepositoryModalOverlayFrame,
@@ -21,6 +22,13 @@ import { isUiModalOverlayHit } from '../kit.ts';
21
22
 
22
23
  type NewThreadPromptState = ReturnType<typeof createNewThreadPromptState>;
23
24
  type AddDirectoryPromptState = { value: string; error: string | null };
25
+ type ApiKeyPromptState = {
26
+ keyName: string;
27
+ displayName: string;
28
+ value: string;
29
+ error: string | null;
30
+ hasExistingValue: boolean;
31
+ };
24
32
  type ModalOverlay = Exclude<ReturnType<typeof buildNewThreadModalOverlayFrame>, null>;
25
33
  type ModalTheme = Parameters<typeof buildNewThreadModalOverlayFrame>[3];
26
34
  type DismissModalOnOutsideClickInput = Parameters<typeof dismissModalOnOutsideClickFrame>[0];
@@ -32,6 +40,7 @@ interface ModalManagerOptions {
32
40
  readonly resolveCommandMenuActions: () => readonly CommandMenuActionDescriptor[];
33
41
  readonly getNewThreadPrompt: () => NewThreadPromptState | null;
34
42
  readonly getAddDirectoryPrompt: () => AddDirectoryPromptState | null;
43
+ readonly getApiKeyPrompt?: () => ApiKeyPromptState | null;
35
44
  readonly getTaskEditorPrompt: () => TaskEditorPromptState | null;
36
45
  readonly getRepositoryPrompt: () => RepositoryPromptState | null;
37
46
  readonly getConversationTitleEdit: () => ConversationTitleEditState | null;
@@ -42,6 +51,7 @@ interface ModalManagerDependencies {
42
51
  readonly buildNewThreadModalOverlay?: typeof buildNewThreadModalOverlayFrame;
43
52
  readonly buildAddDirectoryModalOverlay?: typeof buildAddDirectoryModalOverlayFrame;
44
53
  readonly buildTaskEditorModalOverlay?: typeof buildTaskEditorModalOverlayFrame;
54
+ readonly buildApiKeyModalOverlay?: typeof buildApiKeyModalOverlayFrame;
45
55
  readonly buildRepositoryModalOverlay?: typeof buildRepositoryModalOverlayFrame;
46
56
  readonly buildConversationTitleModalOverlay?: typeof buildConversationTitleModalOverlayFrame;
47
57
  readonly dismissModalOnOutsideClick?: typeof dismissModalOnOutsideClickFrame;
@@ -67,6 +77,7 @@ export class ModalManager {
67
77
  private readonly buildNewThreadModalOverlay: typeof buildNewThreadModalOverlayFrame;
68
78
  private readonly buildAddDirectoryModalOverlay: typeof buildAddDirectoryModalOverlayFrame;
69
79
  private readonly buildTaskEditorModalOverlay: typeof buildTaskEditorModalOverlayFrame;
80
+ private readonly buildApiKeyModalOverlay: typeof buildApiKeyModalOverlayFrame;
70
81
  private readonly buildRepositoryModalOverlay: typeof buildRepositoryModalOverlayFrame;
71
82
  private readonly buildConversationTitleModalOverlay: typeof buildConversationTitleModalOverlayFrame;
72
83
  private readonly dismissModalOnOutsideClick: typeof dismissModalOnOutsideClickFrame;
@@ -84,6 +95,8 @@ export class ModalManager {
84
95
  dependencies.buildAddDirectoryModalOverlay ?? buildAddDirectoryModalOverlayFrame;
85
96
  this.buildTaskEditorModalOverlay =
86
97
  dependencies.buildTaskEditorModalOverlay ?? buildTaskEditorModalOverlayFrame;
98
+ this.buildApiKeyModalOverlay =
99
+ dependencies.buildApiKeyModalOverlay ?? buildApiKeyModalOverlayFrame;
87
100
  this.buildRepositoryModalOverlay =
88
101
  dependencies.buildRepositoryModalOverlay ?? buildRepositoryModalOverlayFrame;
89
102
  this.buildConversationTitleModalOverlay =
@@ -131,6 +144,15 @@ export class ModalManager {
131
144
  );
132
145
  }
133
146
 
147
+ buildApiKeyOverlay(layoutCols: number, viewportRows: number): ModalOverlay | null {
148
+ return this.buildApiKeyModalOverlay(
149
+ layoutCols,
150
+ viewportRows,
151
+ this.options.getApiKeyPrompt?.() ?? null,
152
+ this.options.theme,
153
+ );
154
+ }
155
+
134
156
  buildRepositoryOverlay(layoutCols: number, viewportRows: number): ModalOverlay | null {
135
157
  return this.buildRepositoryModalOverlay(
136
158
  layoutCols,
@@ -166,6 +188,10 @@ export class ModalManager {
166
188
  if (taskEditorOverlay !== null) {
167
189
  return taskEditorOverlay;
168
190
  }
191
+ const apiKeyOverlay = this.buildApiKeyOverlay(layoutCols, viewportRows);
192
+ if (apiKeyOverlay !== null) {
193
+ return apiKeyOverlay;
194
+ }
169
195
  const repositoryOverlay = this.buildRepositoryOverlay(layoutCols, viewportRows);
170
196
  if (repositoryOverlay !== null) {
171
197
  return repositoryOverlay;