@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.
- package/README.md +36 -155
- package/package.json +3 -1
- package/packages/harness-ai/src/anthropic-client.ts +99 -0
- package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
- package/packages/harness-ai/src/anthropic-provider.ts +82 -0
- package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
- package/packages/harness-ai/src/index.ts +36 -0
- package/packages/harness-ai/src/json-parse.ts +66 -0
- package/packages/harness-ai/src/sse.ts +80 -0
- package/packages/harness-ai/src/stream-object.ts +96 -0
- package/packages/harness-ai/src/stream-text.ts +1340 -0
- package/packages/harness-ai/src/types.ts +330 -0
- package/packages/harness-ai/src/ui-stream.ts +217 -0
- package/scripts/codex-live-mux-runtime.ts +265 -14
- package/scripts/control-plane-daemon.ts +33 -5
- package/scripts/harness.ts +579 -134
- package/src/cli/default-gateway-pointer.ts +193 -0
- package/src/cli/gateway-record.ts +16 -1
- package/src/config/config-core.ts +13 -2
- package/src/config/harness-paths.ts +4 -7
- package/src/config/harness-runtime-migration.ts +142 -19
- package/src/config/secrets-core.ts +92 -4
- package/src/control-plane/prompt/thread-title-namer.ts +316 -0
- package/src/control-plane/stream-command-parser.ts +12 -0
- package/src/control-plane/stream-protocol.ts +6 -0
- package/src/control-plane/stream-server-background.ts +18 -2
- package/src/control-plane/stream-server-command.ts +14 -0
- package/src/control-plane/stream-server.ts +460 -28
- package/src/domain/conversations.ts +11 -7
- package/src/domain/workspace.ts +9 -0
- package/src/mux/input-shortcuts.ts +38 -1
- package/src/mux/live-mux/git-parsing.ts +40 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
- package/src/mux/live-mux/left-rail-conversation-click.ts +6 -3
- package/src/mux/live-mux/modal-input-reducers.ts +34 -1
- package/src/mux/live-mux/modal-overlays.ts +45 -0
- package/src/mux/live-mux/modal-prompt-handlers.ts +85 -0
- package/src/mux/render-frame.ts +1 -1
- package/src/mux/task-screen-keybindings.ts +29 -1
- package/src/services/control-plane.ts +22 -0
- package/src/services/runtime-control-actions.ts +69 -0
- package/src/services/runtime-conversation-activation.ts +25 -0
- package/src/services/runtime-conversation-starter.ts +31 -7
- package/src/services/runtime-input-router.ts +6 -0
- package/src/services/runtime-modal-input.ts +18 -0
- package/src/services/runtime-navigation-input.ts +4 -0
- package/src/services/runtime-rail-input.ts +5 -0
- package/src/services/runtime-repository-actions.ts +2 -0
- package/src/services/runtime-workspace-actions.ts +5 -0
- package/src/store/control-plane-store.ts +36 -0
- package/src/store/event-store.ts +36 -0
- package/src/ui/global-shortcut-input.ts +2 -0
- package/src/ui/input.ts +31 -0
- package/src/ui/modals/manager.ts +26 -0
|
@@ -290,6 +290,8 @@ export class ConversationManager {
|
|
|
290
290
|
|
|
291
291
|
upsertFromPersistedRecord(input: UpsertPersistedConversationInput): ConversationState {
|
|
292
292
|
const { record } = input;
|
|
293
|
+
const existing = this.conversationsBySessionId.get(record.conversationId);
|
|
294
|
+
const preserveLiveRuntime = existing?.live === true;
|
|
293
295
|
const conversation = input.ensureConversation(record.conversationId, {
|
|
294
296
|
directoryId: record.directoryId,
|
|
295
297
|
title: record.title,
|
|
@@ -299,14 +301,16 @@ export class ConversationManager {
|
|
|
299
301
|
conversation.scope.tenantId = record.tenantId;
|
|
300
302
|
conversation.scope.userId = record.userId;
|
|
301
303
|
conversation.scope.workspaceId = record.workspaceId;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
304
|
+
if (!preserveLiveRuntime) {
|
|
305
|
+
const runtimeStatusModel = record.runtimeStatusModel;
|
|
306
|
+
conversation.status = record.runtimeStatus;
|
|
307
|
+
conversation.statusModel = runtimeStatusModel;
|
|
308
|
+
conversation.attentionReason = runtimeStatusModel?.attentionReason ?? null;
|
|
309
|
+
conversation.lastKnownWork = runtimeStatusModel?.lastKnownWork ?? null;
|
|
310
|
+
conversation.lastKnownWorkAt = runtimeStatusModel?.lastKnownWorkAt ?? null;
|
|
311
|
+
}
|
|
308
312
|
// Persisted runtime flags are advisory; session.list is authoritative for live sessions.
|
|
309
|
-
conversation.live = false;
|
|
313
|
+
conversation.live = preserveLiveRuntime ? true : false;
|
|
310
314
|
return conversation;
|
|
311
315
|
}
|
|
312
316
|
|
package/src/domain/workspace.ts
CHANGED
|
@@ -25,6 +25,14 @@ export interface RepositoryPromptState {
|
|
|
25
25
|
readonly error: string | null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface ApiKeyPromptState {
|
|
29
|
+
readonly keyName: string;
|
|
30
|
+
readonly displayName: string;
|
|
31
|
+
readonly value: string;
|
|
32
|
+
readonly error: string | null;
|
|
33
|
+
readonly hasExistingValue: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export interface TaskEditorPromptState {
|
|
29
37
|
mode: 'create' | 'edit';
|
|
30
38
|
taskId: string | null;
|
|
@@ -80,6 +88,7 @@ export class WorkspaceModel {
|
|
|
80
88
|
selectionDrag: PaneSelectionDrag | null = null;
|
|
81
89
|
selectionPinnedFollowOutput: boolean | null = null;
|
|
82
90
|
repositoryPrompt: RepositoryPromptState | null = null;
|
|
91
|
+
apiKeyPrompt: ApiKeyPromptState | null = null;
|
|
83
92
|
commandMenu: CommandMenuState | null = null;
|
|
84
93
|
newThreadPrompt: ReturnType<typeof createNewThreadPromptState> | null = null;
|
|
85
94
|
addDirectoryPrompt: { value: string; error: string | null } | null = null;
|
|
@@ -9,6 +9,7 @@ type MuxGlobalShortcutAction =
|
|
|
9
9
|
| 'mux.conversation.critique.open-or-create'
|
|
10
10
|
| 'mux.conversation.next'
|
|
11
11
|
| 'mux.conversation.previous'
|
|
12
|
+
| 'mux.conversation.titles.refresh-all'
|
|
12
13
|
| 'mux.conversation.interrupt'
|
|
13
14
|
| 'mux.conversation.archive'
|
|
14
15
|
| 'mux.conversation.takeover'
|
|
@@ -47,6 +48,7 @@ const ACTION_ORDER: readonly MuxGlobalShortcutAction[] = [
|
|
|
47
48
|
'mux.conversation.critique.open-or-create',
|
|
48
49
|
'mux.conversation.next',
|
|
49
50
|
'mux.conversation.previous',
|
|
51
|
+
'mux.conversation.titles.refresh-all',
|
|
50
52
|
'mux.conversation.interrupt',
|
|
51
53
|
'mux.conversation.archive',
|
|
52
54
|
'mux.conversation.takeover',
|
|
@@ -68,6 +70,7 @@ const DEFAULT_MUX_SHORTCUT_BINDINGS_RAW: Readonly<
|
|
|
68
70
|
'mux.conversation.critique.open-or-create': ['ctrl+g'],
|
|
69
71
|
'mux.conversation.next': ['ctrl+j'],
|
|
70
72
|
'mux.conversation.previous': ['ctrl+k'],
|
|
73
|
+
'mux.conversation.titles.refresh-all': ['ctrl+r'],
|
|
71
74
|
'mux.conversation.interrupt': [],
|
|
72
75
|
'mux.conversation.archive': [],
|
|
73
76
|
'mux.conversation.takeover': ['ctrl+l'],
|
|
@@ -538,10 +541,38 @@ function strokesEqual(left: KeyStroke, right: KeyStroke): boolean {
|
|
|
538
541
|
|
|
539
542
|
function parseBindingsForAction(rawBindings: readonly string[]): readonly ParsedShortcutBinding[] {
|
|
540
543
|
const parsed: ParsedShortcutBinding[] = [];
|
|
544
|
+
|
|
545
|
+
const pushIfUnique = (candidate: ParsedShortcutBinding): void => {
|
|
546
|
+
if (parsed.some((existing) => strokesEqual(existing.stroke, candidate.stroke))) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
parsed.push(candidate);
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const ctrlMetaAliasStroke = (stroke: KeyStroke): KeyStroke | null => {
|
|
553
|
+
if (stroke.ctrl === stroke.meta) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
key: stroke.key,
|
|
558
|
+
ctrl: !stroke.ctrl,
|
|
559
|
+
alt: stroke.alt,
|
|
560
|
+
shift: stroke.shift,
|
|
561
|
+
meta: !stroke.meta,
|
|
562
|
+
};
|
|
563
|
+
};
|
|
564
|
+
|
|
541
565
|
for (const raw of rawBindings) {
|
|
542
566
|
const normalized = parseShortcutBinding(raw);
|
|
543
567
|
if (normalized !== null) {
|
|
544
|
-
|
|
568
|
+
pushIfUnique(normalized);
|
|
569
|
+
const aliasStroke = ctrlMetaAliasStroke(normalized.stroke);
|
|
570
|
+
if (aliasStroke !== null) {
|
|
571
|
+
pushIfUnique({
|
|
572
|
+
stroke: aliasStroke,
|
|
573
|
+
originalText: normalized.originalText,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
545
576
|
}
|
|
546
577
|
}
|
|
547
578
|
return parsed;
|
|
@@ -580,6 +611,9 @@ function withDefaultBindings(
|
|
|
580
611
|
'mux.conversation.previous':
|
|
581
612
|
overrides?.['mux.conversation.previous'] ??
|
|
582
613
|
DEFAULT_MUX_SHORTCUT_BINDINGS_RAW['mux.conversation.previous'],
|
|
614
|
+
'mux.conversation.titles.refresh-all':
|
|
615
|
+
overrides?.['mux.conversation.titles.refresh-all'] ??
|
|
616
|
+
DEFAULT_MUX_SHORTCUT_BINDINGS_RAW['mux.conversation.titles.refresh-all'],
|
|
583
617
|
'mux.conversation.interrupt':
|
|
584
618
|
overrides?.['mux.conversation.interrupt'] ??
|
|
585
619
|
DEFAULT_MUX_SHORTCUT_BINDINGS_RAW['mux.conversation.interrupt'],
|
|
@@ -625,6 +659,9 @@ export function resolveMuxShortcutBindings(
|
|
|
625
659
|
),
|
|
626
660
|
'mux.conversation.next': parseBindingsForAction(rawByAction['mux.conversation.next']),
|
|
627
661
|
'mux.conversation.previous': parseBindingsForAction(rawByAction['mux.conversation.previous']),
|
|
662
|
+
'mux.conversation.titles.refresh-all': parseBindingsForAction(
|
|
663
|
+
rawByAction['mux.conversation.titles.refresh-all'],
|
|
664
|
+
),
|
|
628
665
|
'mux.conversation.interrupt': parseBindingsForAction(
|
|
629
666
|
rawByAction['mux.conversation.interrupt'],
|
|
630
667
|
),
|
|
@@ -82,6 +82,22 @@ export function repositoryNameFromGitHubRemoteUrl(remoteUrl: string): string {
|
|
|
82
82
|
return name;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
function normalizeDefaultBranchForActions(value: string | null): string | null {
|
|
86
|
+
const trimmed = value?.trim() ?? '';
|
|
87
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveGitHubDefaultBranchForActions(input: {
|
|
91
|
+
repositoryDefaultBranch: string | null;
|
|
92
|
+
snapshotDefaultBranch: string | null;
|
|
93
|
+
}): string | null {
|
|
94
|
+
const repositoryDefaultBranch = normalizeDefaultBranchForActions(input.repositoryDefaultBranch);
|
|
95
|
+
if (repositoryDefaultBranch !== null) {
|
|
96
|
+
return repositoryDefaultBranch;
|
|
97
|
+
}
|
|
98
|
+
return normalizeDefaultBranchForActions(input.snapshotDefaultBranch);
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
export function shouldShowGitHubPrActions(input: {
|
|
86
102
|
trackedBranch: string | null;
|
|
87
103
|
defaultBranch: string | null;
|
|
@@ -98,6 +114,30 @@ export function shouldShowGitHubPrActions(input: {
|
|
|
98
114
|
return normalizedTrackedBranch !== 'main';
|
|
99
115
|
}
|
|
100
116
|
|
|
117
|
+
function normalizeTrackedBranchForActions(value: string | null): string | null {
|
|
118
|
+
const trimmed = value?.trim() ?? '';
|
|
119
|
+
if (
|
|
120
|
+
trimmed.length === 0 ||
|
|
121
|
+
trimmed === '(detached)' ||
|
|
122
|
+
trimmed === '(loading)' ||
|
|
123
|
+
trimmed === 'HEAD'
|
|
124
|
+
) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return trimmed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resolveGitHubTrackedBranchForActions(input: {
|
|
131
|
+
projectTrackedBranch: string | null;
|
|
132
|
+
currentBranch: string | null;
|
|
133
|
+
}): string | null {
|
|
134
|
+
const trackedBranch = normalizeTrackedBranchForActions(input.projectTrackedBranch);
|
|
135
|
+
if (trackedBranch !== null) {
|
|
136
|
+
return trackedBranch;
|
|
137
|
+
}
|
|
138
|
+
return normalizeTrackedBranchForActions(input.currentBranch);
|
|
139
|
+
}
|
|
140
|
+
|
|
101
141
|
export function parseCommitCount(output: string): number | null {
|
|
102
142
|
const trimmed = output.trim();
|
|
103
143
|
if (trimmed.length === 0 || !/^\d+$/u.test(trimmed)) {
|
|
@@ -14,6 +14,7 @@ interface HandleGlobalShortcutOptions {
|
|
|
14
14
|
conversationsHas: (sessionId: string) => boolean;
|
|
15
15
|
queueControlPlaneOp: (task: () => Promise<void>, label: string) => void;
|
|
16
16
|
archiveConversation: (sessionId: string) => Promise<void>;
|
|
17
|
+
refreshAllConversationTitles: () => Promise<void>;
|
|
17
18
|
interruptConversation: (sessionId: string) => Promise<void>;
|
|
18
19
|
takeoverConversation: (sessionId: string) => Promise<void>;
|
|
19
20
|
openAddDirectoryPrompt: () => void;
|
|
@@ -37,6 +38,7 @@ export function handleGlobalShortcut(options: HandleGlobalShortcutOptions): bool
|
|
|
37
38
|
conversationsHas,
|
|
38
39
|
queueControlPlaneOp,
|
|
39
40
|
archiveConversation,
|
|
41
|
+
refreshAllConversationTitles,
|
|
40
42
|
interruptConversation,
|
|
41
43
|
takeoverConversation,
|
|
42
44
|
openAddDirectoryPrompt,
|
|
@@ -104,6 +106,12 @@ export function handleGlobalShortcut(options: HandleGlobalShortcutOptions): bool
|
|
|
104
106
|
}
|
|
105
107
|
return true;
|
|
106
108
|
}
|
|
109
|
+
if (shortcut === 'mux.conversation.titles.refresh-all') {
|
|
110
|
+
queueControlPlaneOp(async () => {
|
|
111
|
+
await refreshAllConversationTitles();
|
|
112
|
+
}, 'shortcut-refresh-conversation-titles');
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
107
115
|
if (shortcut === 'mux.conversation.interrupt') {
|
|
108
116
|
const targetConversationId = resolveConversationForAction();
|
|
109
117
|
if (targetConversationId !== null && conversationsHas(targetConversationId)) {
|
|
@@ -46,9 +46,12 @@ export function handleLeftRailConversationClick(
|
|
|
46
46
|
options.selectedConversationId === options.activeConversationId
|
|
47
47
|
) {
|
|
48
48
|
if (!options.isConversationPaneActive) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
if (conversationClick.doubleClick) {
|
|
50
|
+
options.queueActivateConversationAndEdit(options.selectedConversationId);
|
|
51
|
+
} else {
|
|
52
|
+
options.queueActivateConversation(options.selectedConversationId);
|
|
53
|
+
}
|
|
54
|
+
} else if (conversationClick.doubleClick) {
|
|
52
55
|
options.beginConversationTitleEdit(options.selectedConversationId);
|
|
53
56
|
}
|
|
54
57
|
options.markDirty();
|
|
@@ -19,10 +19,43 @@ interface LinePromptReduction {
|
|
|
19
19
|
submit: boolean;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const BRACKETED_PASTE_START = Buffer.from('\u001b[200~', 'utf8');
|
|
23
|
+
const BRACKETED_PASTE_END = Buffer.from('\u001b[201~', 'utf8');
|
|
24
|
+
|
|
25
|
+
function matchesSequence(input: Buffer, startIndex: number, sequence: Buffer): boolean {
|
|
26
|
+
if (startIndex < 0 || startIndex + sequence.length > input.length) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
for (let index = 0; index < sequence.length; index += 1) {
|
|
30
|
+
if (input[startIndex + index] !== sequence[index]) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
export function reduceLinePromptInput(value: string, input: Buffer): LinePromptReduction {
|
|
23
38
|
let nextValue = value;
|
|
24
39
|
let submit = false;
|
|
25
|
-
|
|
40
|
+
let inBracketedPaste = false;
|
|
41
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
42
|
+
if (!inBracketedPaste && matchesSequence(input, index, BRACKETED_PASTE_START)) {
|
|
43
|
+
inBracketedPaste = true;
|
|
44
|
+
index += BRACKETED_PASTE_START.length - 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (inBracketedPaste && matchesSequence(input, index, BRACKETED_PASTE_END)) {
|
|
48
|
+
inBracketedPaste = false;
|
|
49
|
+
index += BRACKETED_PASTE_END.length - 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const byte = input[index]!;
|
|
53
|
+
if (inBracketedPaste) {
|
|
54
|
+
if (byte >= 32 && byte <= 126) {
|
|
55
|
+
nextValue += String.fromCharCode(byte);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
26
59
|
if (byte === 0x0d || byte === 0x0a) {
|
|
27
60
|
submit = true;
|
|
28
61
|
break;
|
|
@@ -36,6 +36,14 @@ interface RepositoryPromptOverlayState {
|
|
|
36
36
|
readonly error: string | null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
interface ApiKeyPromptOverlayState {
|
|
40
|
+
readonly keyName: string;
|
|
41
|
+
readonly displayName: string;
|
|
42
|
+
readonly value: string;
|
|
43
|
+
readonly error: string | null;
|
|
44
|
+
readonly hasExistingValue: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
interface ConversationTitleOverlayState {
|
|
40
48
|
value: string;
|
|
41
49
|
lastSavedValue: string;
|
|
@@ -252,6 +260,43 @@ export function buildRepositoryModalOverlay(
|
|
|
252
260
|
});
|
|
253
261
|
}
|
|
254
262
|
|
|
263
|
+
export function buildApiKeyModalOverlay(
|
|
264
|
+
layoutCols: number,
|
|
265
|
+
viewportRows: number,
|
|
266
|
+
prompt: ApiKeyPromptOverlayState | null,
|
|
267
|
+
theme: UiModalThemeInput,
|
|
268
|
+
): ReturnType<typeof buildUiModalOverlay> | null {
|
|
269
|
+
if (prompt === null) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const modalSize = resolveGoldenModalSize(layoutCols, viewportRows, {
|
|
273
|
+
preferredHeight: 16,
|
|
274
|
+
minWidth: 34,
|
|
275
|
+
maxWidth: 64,
|
|
276
|
+
});
|
|
277
|
+
const promptValue = prompt.value.length > 0 ? prompt.value : '(enter value)';
|
|
278
|
+
const bodyLines = [`${prompt.keyName}: ${promptValue}_`];
|
|
279
|
+
if (prompt.error !== null && prompt.error.length > 0) {
|
|
280
|
+
bodyLines.push(`error: ${prompt.error}`);
|
|
281
|
+
} else if (prompt.hasExistingValue) {
|
|
282
|
+
bodyLines.push('warning: existing value detected (submit will overwrite)');
|
|
283
|
+
} else {
|
|
284
|
+
bodyLines.push('value is saved to user-global secrets.env');
|
|
285
|
+
}
|
|
286
|
+
return buildUiModalOverlay({
|
|
287
|
+
viewportCols: layoutCols,
|
|
288
|
+
viewportRows,
|
|
289
|
+
width: modalSize.width,
|
|
290
|
+
height: modalSize.height,
|
|
291
|
+
anchor: 'center',
|
|
292
|
+
marginRows: 1,
|
|
293
|
+
title: `Set ${prompt.displayName}`,
|
|
294
|
+
bodyLines,
|
|
295
|
+
footer: 'enter save esc',
|
|
296
|
+
theme,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
255
300
|
export function buildConversationTitleModalOverlay(
|
|
256
301
|
layoutCols: number,
|
|
257
302
|
viewportRows: number,
|
|
@@ -12,6 +12,14 @@ interface RepositoryPromptState {
|
|
|
12
12
|
readonly error: string | null;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
interface ApiKeyPromptState {
|
|
16
|
+
readonly keyName: string;
|
|
17
|
+
readonly displayName: string;
|
|
18
|
+
readonly value: string;
|
|
19
|
+
readonly error: string | null;
|
|
20
|
+
readonly hasExistingValue: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
interface HandleAddDirectoryPromptInputOptions {
|
|
16
24
|
input: Buffer;
|
|
17
25
|
prompt: AddDirectoryPromptState | null;
|
|
@@ -36,6 +44,16 @@ interface HandleRepositoryPromptInputOptions {
|
|
|
36
44
|
upsertRepositoryByRemoteUrl: (remoteUrl: string, existingRepositoryId?: string) => Promise<void>;
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
interface HandleApiKeyPromptInputOptions {
|
|
48
|
+
input: Buffer;
|
|
49
|
+
prompt: ApiKeyPromptState | null;
|
|
50
|
+
isQuitShortcut: (input: Buffer) => boolean;
|
|
51
|
+
dismissOnOutsideClick: (input: Buffer, dismiss: () => void) => boolean;
|
|
52
|
+
setPrompt: (next: ApiKeyPromptState | null) => void;
|
|
53
|
+
markDirty: () => void;
|
|
54
|
+
persistApiKey: (keyName: string, value: string) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
export function handleAddDirectoryPromptInput(
|
|
40
58
|
options: HandleAddDirectoryPromptInputOptions,
|
|
41
59
|
): boolean {
|
|
@@ -185,3 +203,70 @@ export function handleRepositoryPromptInput(options: HandleRepositoryPromptInput
|
|
|
185
203
|
markDirty();
|
|
186
204
|
return true;
|
|
187
205
|
}
|
|
206
|
+
|
|
207
|
+
export function handleApiKeyPromptInput(options: HandleApiKeyPromptInputOptions): boolean {
|
|
208
|
+
const {
|
|
209
|
+
input,
|
|
210
|
+
prompt,
|
|
211
|
+
isQuitShortcut,
|
|
212
|
+
dismissOnOutsideClick,
|
|
213
|
+
setPrompt,
|
|
214
|
+
markDirty,
|
|
215
|
+
persistApiKey,
|
|
216
|
+
} = options;
|
|
217
|
+
if (prompt === null) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
if (input.length === 1 && input[0] === 0x03) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
if (isQuitShortcut(input)) {
|
|
224
|
+
setPrompt(null);
|
|
225
|
+
markDirty();
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
dismissOnOutsideClick(input, () => {
|
|
230
|
+
setPrompt(null);
|
|
231
|
+
markDirty();
|
|
232
|
+
})
|
|
233
|
+
) {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const reduced = reduceLinePromptInput(prompt.value, input);
|
|
238
|
+
const value = reduced.value;
|
|
239
|
+
if (!reduced.submit) {
|
|
240
|
+
setPrompt({
|
|
241
|
+
...prompt,
|
|
242
|
+
value,
|
|
243
|
+
error: null,
|
|
244
|
+
});
|
|
245
|
+
markDirty();
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const trimmed = value.trim();
|
|
250
|
+
if (trimmed.length === 0) {
|
|
251
|
+
setPrompt({
|
|
252
|
+
...prompt,
|
|
253
|
+
value,
|
|
254
|
+
error: `${prompt.displayName.toLowerCase()} required`,
|
|
255
|
+
});
|
|
256
|
+
markDirty();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
persistApiKey(prompt.keyName, trimmed);
|
|
261
|
+
setPrompt(null);
|
|
262
|
+
} catch (error: unknown) {
|
|
263
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
264
|
+
setPrompt({
|
|
265
|
+
...prompt,
|
|
266
|
+
value,
|
|
267
|
+
error: message,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
markDirty();
|
|
271
|
+
return true;
|
|
272
|
+
}
|
package/src/mux/render-frame.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function buildRenderRows(
|
|
|
51
51
|
const statusText =
|
|
52
52
|
statusRowDetailText === undefined || statusRowDetailText.length === 0
|
|
53
53
|
? defaultStatus
|
|
54
|
-
: `${
|
|
54
|
+
: `${statusRowDetailText} ${defaultStatus}`;
|
|
55
55
|
const status = padOrTrimDisplay(statusText, layout.cols);
|
|
56
56
|
rows.push(status);
|
|
57
57
|
return rows;
|
|
@@ -437,10 +437,38 @@ function parseBinding(input: string): ParsedBinding | null {
|
|
|
437
437
|
|
|
438
438
|
function bindingsForAction(raw: readonly string[]): readonly ParsedBinding[] {
|
|
439
439
|
const parsed: ParsedBinding[] = [];
|
|
440
|
+
|
|
441
|
+
const pushIfUnique = (candidate: ParsedBinding): void => {
|
|
442
|
+
if (parsed.some((existing) => strokesEqual(existing.stroke, candidate.stroke))) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
parsed.push(candidate);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const ctrlMetaAliasStroke = (stroke: KeyStroke): KeyStroke | null => {
|
|
449
|
+
if (stroke.ctrl === stroke.meta) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
key: stroke.key,
|
|
454
|
+
ctrl: !stroke.ctrl,
|
|
455
|
+
alt: stroke.alt,
|
|
456
|
+
shift: stroke.shift,
|
|
457
|
+
meta: !stroke.meta,
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
|
|
440
461
|
for (const value of raw) {
|
|
441
462
|
const next = parseBinding(value);
|
|
442
463
|
if (next !== null) {
|
|
443
|
-
|
|
464
|
+
pushIfUnique(next);
|
|
465
|
+
const aliasStroke = ctrlMetaAliasStroke(next.stroke);
|
|
466
|
+
if (aliasStroke !== null) {
|
|
467
|
+
pushIfUnique({
|
|
468
|
+
stroke: aliasStroke,
|
|
469
|
+
originalText: next.originalText,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
444
472
|
}
|
|
445
473
|
}
|
|
446
474
|
return parsed;
|
|
@@ -245,6 +245,28 @@ export class ControlPlaneService {
|
|
|
245
245
|
return parseConversationRecord(result['conversation']);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
async refreshConversationTitle(conversationId: string): Promise<{
|
|
249
|
+
status: 'updated' | 'unchanged' | 'skipped';
|
|
250
|
+
reason: string | null;
|
|
251
|
+
}> {
|
|
252
|
+
const result = await this.client.sendCommand({
|
|
253
|
+
type: 'conversation.title.refresh',
|
|
254
|
+
conversationId,
|
|
255
|
+
});
|
|
256
|
+
const status = result['status'];
|
|
257
|
+
if (status !== 'updated' && status !== 'unchanged' && status !== 'skipped') {
|
|
258
|
+
throw new Error('control-plane conversation.title.refresh returned malformed status');
|
|
259
|
+
}
|
|
260
|
+
const reason = result['reason'];
|
|
261
|
+
if (reason !== null && reason !== undefined && typeof reason !== 'string') {
|
|
262
|
+
throw new Error('control-plane conversation.title.refresh returned malformed reason');
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
status,
|
|
266
|
+
reason: reason ?? null,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
248
270
|
async archiveConversation(conversationId: string): Promise<void> {
|
|
249
271
|
await this.client.sendCommand({
|
|
250
272
|
type: 'conversation.archive',
|
|
@@ -21,6 +21,13 @@ interface RuntimeGatewayRenderTraceResult {
|
|
|
21
21
|
readonly message: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
interface RuntimeConversationTitleRefreshResult {
|
|
25
|
+
readonly status: 'updated' | 'unchanged' | 'skipped';
|
|
26
|
+
readonly reason: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const THREAD_TITLE_AGENT_TYPES = new Set(['codex', 'claude', 'cursor']);
|
|
30
|
+
|
|
24
31
|
interface RuntimeControlActionsOptions<TConversation extends RuntimeConversationControlState> {
|
|
25
32
|
readonly conversationById: (sessionId: string) => TConversation | undefined;
|
|
26
33
|
readonly interruptSession: (sessionId: string) => Promise<RuntimeInterruptResult>;
|
|
@@ -43,6 +50,11 @@ interface RuntimeControlActionsOptions<TConversation extends RuntimeConversation
|
|
|
43
50
|
readonly sessionName: string | null;
|
|
44
51
|
readonly setTaskPaneNotice: (message: string) => void;
|
|
45
52
|
readonly setDebugFooterNotice: (message: string) => void;
|
|
53
|
+
readonly listConversationIdsForTitleRefresh?: () => readonly string[];
|
|
54
|
+
readonly conversationAgentTypeForTitleRefresh?: (sessionId: string) => string | null;
|
|
55
|
+
readonly refreshConversationTitle?: (
|
|
56
|
+
sessionId: string,
|
|
57
|
+
) => Promise<RuntimeConversationTitleRefreshResult>;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
export class RuntimeControlActions<TConversation extends RuntimeConversationControlState> {
|
|
@@ -109,6 +121,63 @@ export class RuntimeControlActions<TConversation extends RuntimeConversationCont
|
|
|
109
121
|
}
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
async refreshAllConversationTitles(): Promise<void> {
|
|
125
|
+
const listConversationIds = this.options.listConversationIdsForTitleRefresh;
|
|
126
|
+
const resolveAgentType = this.options.conversationAgentTypeForTitleRefresh;
|
|
127
|
+
const refreshConversationTitle = this.options.refreshConversationTitle;
|
|
128
|
+
if (
|
|
129
|
+
listConversationIds === undefined ||
|
|
130
|
+
resolveAgentType === undefined ||
|
|
131
|
+
refreshConversationTitle === undefined
|
|
132
|
+
) {
|
|
133
|
+
this.setNotices(this.scopeMessage('thread-title', 'refresh unavailable'));
|
|
134
|
+
this.options.markDirty();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const allConversationIds = listConversationIds();
|
|
138
|
+
const eligibleConversationIds = allConversationIds.filter((sessionId) => {
|
|
139
|
+
const agentType = resolveAgentType(sessionId)?.trim().toLowerCase();
|
|
140
|
+
return agentType !== undefined && THREAD_TITLE_AGENT_TYPES.has(agentType);
|
|
141
|
+
});
|
|
142
|
+
if (eligibleConversationIds.length === 0) {
|
|
143
|
+
this.setNotices(this.scopeMessage('thread-title', 'no agent threads to refresh'));
|
|
144
|
+
this.options.markDirty();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const total = eligibleConversationIds.length;
|
|
148
|
+
let updated = 0;
|
|
149
|
+
let unchanged = 0;
|
|
150
|
+
let skipped = 0;
|
|
151
|
+
this.setNotices(this.scopeMessage('thread-title', `refreshing names 0/${String(total)}`));
|
|
152
|
+
this.options.markDirty();
|
|
153
|
+
for (let index = 0; index < eligibleConversationIds.length; index += 1) {
|
|
154
|
+
const sessionId = eligibleConversationIds[index]!;
|
|
155
|
+
try {
|
|
156
|
+
const result = await refreshConversationTitle(sessionId);
|
|
157
|
+
if (result.status === 'updated') {
|
|
158
|
+
updated += 1;
|
|
159
|
+
} else if (result.status === 'unchanged') {
|
|
160
|
+
unchanged += 1;
|
|
161
|
+
} else {
|
|
162
|
+
skipped += 1;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
skipped += 1;
|
|
166
|
+
}
|
|
167
|
+
this.setNotices(
|
|
168
|
+
this.scopeMessage('thread-title', `refreshing names ${String(index + 1)}/${String(total)}`),
|
|
169
|
+
);
|
|
170
|
+
this.options.markDirty();
|
|
171
|
+
}
|
|
172
|
+
this.setNotices(
|
|
173
|
+
this.scopeMessage(
|
|
174
|
+
'thread-title',
|
|
175
|
+
`refreshed ${String(updated)} updated ${String(unchanged)} unchanged ${String(skipped)} skipped`,
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
this.options.markDirty();
|
|
179
|
+
}
|
|
180
|
+
|
|
112
181
|
private scopeMessage(prefix: string, message: string): string {
|
|
113
182
|
if (this.options.sessionName === null) {
|
|
114
183
|
return `[${prefix}] ${message}`;
|
|
@@ -30,7 +30,32 @@ export class RuntimeConversationActivation {
|
|
|
30
30
|
async activateConversation(sessionId: string): Promise<void> {
|
|
31
31
|
if (this.options.getActiveConversationId() === sessionId) {
|
|
32
32
|
if (!this.options.isConversationPaneMode()) {
|
|
33
|
+
const targetConversation = this.options.conversationById(sessionId);
|
|
33
34
|
this.options.enterConversationPaneForActiveSession(sessionId);
|
|
35
|
+
this.options.noteGitActivity(targetConversation?.directoryId ?? null);
|
|
36
|
+
if (
|
|
37
|
+
targetConversation !== undefined &&
|
|
38
|
+
!targetConversation.live &&
|
|
39
|
+
targetConversation.status !== 'exited'
|
|
40
|
+
) {
|
|
41
|
+
await this.options.startConversation(sessionId);
|
|
42
|
+
}
|
|
43
|
+
if (targetConversation?.status !== 'exited') {
|
|
44
|
+
try {
|
|
45
|
+
await this.options.attachConversation(sessionId);
|
|
46
|
+
} catch (error: unknown) {
|
|
47
|
+
if (
|
|
48
|
+
!this.options.isSessionNotFoundError(error) &&
|
|
49
|
+
!this.options.isSessionNotLiveError(error)
|
|
50
|
+
) {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
this.options.markSessionUnavailable(sessionId);
|
|
54
|
+
await this.options.startConversation(sessionId);
|
|
55
|
+
await this.options.attachConversation(sessionId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
this.options.schedulePtyResizeImmediate();
|
|
34
59
|
this.options.markDirty();
|
|
35
60
|
}
|
|
36
61
|
return;
|