mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -41,7 +41,11 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||
import type { APIError } from '@anthropic-ai/sdk'
|
||||
import type { CompactMetadata, Message, SystemCompactBoundaryMessage } from './types/message.js'
|
||||
import type {
|
||||
CompactMetadata,
|
||||
Message,
|
||||
SystemCompactBoundaryMessage,
|
||||
} from './types/message.js'
|
||||
import type { OrphanedPermission } from './types/textInputTypes.js'
|
||||
import { createAbortController } from './utils/abortController.js'
|
||||
import type { AttributionState } from './utils/commitAttribution.js'
|
||||
@@ -86,7 +90,9 @@ import {
|
||||
|
||||
// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const messageSelector = (): typeof import('src/components/MessageSelector.js') | null => {
|
||||
const messageSelector = ():
|
||||
| typeof import('src/components/MessageSelector.js')
|
||||
| null => {
|
||||
try {
|
||||
return require('src/components/MessageSelector.js')
|
||||
} catch {
|
||||
@@ -649,20 +655,19 @@ export class QueryEngine {
|
||||
|
||||
if (fileHistoryEnabled() && persistSession) {
|
||||
const _sel = messageSelector()
|
||||
const _filter = _sel?.selectableUserMessagesFilter ?? ((_msg: unknown) => true)
|
||||
messagesFromUserInput
|
||||
.filter(_filter)
|
||||
.forEach(message => {
|
||||
void fileHistoryMakeSnapshot(
|
||||
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fileHistory: updater(prev.fileHistory),
|
||||
}))
|
||||
},
|
||||
message.uuid,
|
||||
)
|
||||
})
|
||||
const _filter =
|
||||
_sel?.selectableUserMessagesFilter ?? ((_msg: unknown) => true)
|
||||
messagesFromUserInput.filter(_filter).forEach(message => {
|
||||
void fileHistoryMakeSnapshot(
|
||||
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fileHistory: updater(prev.fileHistory),
|
||||
}))
|
||||
},
|
||||
message.uuid,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Track current message usage (reset on each message_start)
|
||||
@@ -715,7 +720,8 @@ export class QueryEngine {
|
||||
message.subtype === 'compact_boundary'
|
||||
) {
|
||||
const compactMsg = message as SystemCompactBoundaryMessage
|
||||
const tailUuid = compactMsg.compactMetadata?.preservedSegment?.tailUuid
|
||||
const tailUuid =
|
||||
compactMsg.compactMetadata?.preservedSegment?.tailUuid
|
||||
if (tailUuid) {
|
||||
const tailIdx = this.mutableMessages.findLastIndex(
|
||||
m => m.uuid === tailUuid,
|
||||
@@ -775,7 +781,10 @@ export class QueryEngine {
|
||||
// streamed responses, this is null at content_block_stop time;
|
||||
// the real value arrives via message_delta (handled below).
|
||||
const msg = message as Message
|
||||
const stopReason = msg.message?.stop_reason as string | null | undefined
|
||||
const stopReason = msg.message?.stop_reason as
|
||||
| string
|
||||
| null
|
||||
| undefined
|
||||
if (stopReason != null) {
|
||||
lastStopReason = stopReason
|
||||
}
|
||||
@@ -805,11 +814,15 @@ export class QueryEngine {
|
||||
break
|
||||
}
|
||||
case 'stream_event': {
|
||||
const event = (message as unknown as { event: Record<string, unknown> }).event
|
||||
const event = (
|
||||
message as unknown as { event: Record<string, unknown> }
|
||||
).event
|
||||
if (event.type === 'message_start') {
|
||||
// Reset current message usage for new message
|
||||
currentMessageUsage = EMPTY_USAGE
|
||||
const eventMessage = event.message as { usage: BetaMessageDeltaUsage }
|
||||
const eventMessage = event.message as {
|
||||
usage: BetaMessageDeltaUsage
|
||||
}
|
||||
currentMessageUsage = updateUsage(
|
||||
currentMessageUsage,
|
||||
eventMessage.usage,
|
||||
@@ -858,7 +871,15 @@ export class QueryEngine {
|
||||
void recordTranscript(messages)
|
||||
}
|
||||
|
||||
const attachment = msg.attachment as { type: string; data?: unknown; turnCount?: number; maxTurns?: number; prompt?: string; source_uuid?: string; [key: string]: unknown }
|
||||
const attachment = msg.attachment as {
|
||||
type: string
|
||||
data?: unknown
|
||||
turnCount?: number
|
||||
maxTurns?: number
|
||||
prompt?: string
|
||||
source_uuid?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Extract structured output from StructuredOutput tool calls
|
||||
if (attachment.type === 'structured_output') {
|
||||
@@ -899,10 +920,7 @@ export class QueryEngine {
|
||||
return
|
||||
}
|
||||
// Yield queued_command attachments as SDK user message replays
|
||||
else if (
|
||||
replayUserMessages &&
|
||||
attachment.type === 'queued_command'
|
||||
) {
|
||||
else if (replayUserMessages && attachment.type === 'queued_command') {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: {
|
||||
@@ -930,10 +948,7 @@ export class QueryEngine {
|
||||
// never shrinks (memory leak in long SDK sessions). The subtype
|
||||
// check lives inside the injected callback so feature-gated strings
|
||||
// stay out of this file (excluded-strings check).
|
||||
const snipResult = this.config.snipReplay?.(
|
||||
msg,
|
||||
this.mutableMessages,
|
||||
)
|
||||
const snipResult = this.config.snipReplay?.(msg, this.mutableMessages)
|
||||
if (snipResult !== undefined) {
|
||||
if (snipResult.executed) {
|
||||
this.mutableMessages.length = 0
|
||||
@@ -943,10 +958,7 @@ export class QueryEngine {
|
||||
}
|
||||
this.mutableMessages.push(msg)
|
||||
// Yield compact boundary messages to SDK
|
||||
if (
|
||||
msg.subtype === 'compact_boundary' &&
|
||||
msg.compactMetadata
|
||||
) {
|
||||
if (msg.subtype === 'compact_boundary' && msg.compactMetadata) {
|
||||
const compactMsg = msg as SystemCompactBoundaryMessage
|
||||
// Release pre-compaction messages for GC. The boundary was just
|
||||
// pushed so it's the last element. query.ts already uses
|
||||
@@ -966,11 +978,18 @@ export class QueryEngine {
|
||||
subtype: 'compact_boundary' as const,
|
||||
session_id: getSessionId(),
|
||||
uuid: msg.uuid,
|
||||
compact_metadata: toSDKCompactMetadata(compactMsg.compactMetadata),
|
||||
compact_metadata: toSDKCompactMetadata(
|
||||
compactMsg.compactMetadata,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (msg.subtype === 'api_error') {
|
||||
const apiErrorMsg = msg as Message & { retryAttempt: number; maxRetries: number; retryInMs: number; error: APIError }
|
||||
const apiErrorMsg = msg as Message & {
|
||||
retryAttempt: number
|
||||
maxRetries: number
|
||||
retryInMs: number
|
||||
error: APIError
|
||||
}
|
||||
yield {
|
||||
type: 'system',
|
||||
subtype: 'api_retry' as const,
|
||||
@@ -987,7 +1006,10 @@ export class QueryEngine {
|
||||
break
|
||||
}
|
||||
case 'tool_use_summary': {
|
||||
const msg = message as Message & { summary: unknown; precedingToolUseIds: unknown }
|
||||
const msg = message as Message & {
|
||||
summary: unknown
|
||||
precedingToolUseIds: unknown
|
||||
}
|
||||
// Yield tool use summary messages to SDK
|
||||
yield {
|
||||
type: 'tool_use_summary' as const,
|
||||
@@ -1096,7 +1118,10 @@ export class QueryEngine {
|
||||
const edeResultType = result?.type ?? 'undefined'
|
||||
const edeLastContentType =
|
||||
result?.type === 'assistant'
|
||||
? (last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])?.type ?? 'none')
|
||||
? (last(
|
||||
result.message!
|
||||
.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[],
|
||||
)?.type ?? 'none')
|
||||
: 'n/a'
|
||||
|
||||
// Flush buffered transcript writes before yielding result.
|
||||
@@ -1154,7 +1179,10 @@ export class QueryEngine {
|
||||
let isApiError = false
|
||||
|
||||
if (result.type === 'assistant') {
|
||||
const lastContent = last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])
|
||||
const lastContent = last(
|
||||
result.message!
|
||||
.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[],
|
||||
)
|
||||
if (
|
||||
lastContent?.type === 'text' &&
|
||||
!SYNTHETIC_MESSAGES.has(lastContent.text)
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
setSystemPromptInjection,
|
||||
} from '../context'
|
||||
import { clearMemoryFileCaches } from '../utils/claudemd'
|
||||
import { cleanupTempDir, createTempDir, writeTempFile } from '../../tests/mocks/file-system'
|
||||
import {
|
||||
cleanupTempDir,
|
||||
createTempDir,
|
||||
writeTempFile,
|
||||
} from '../../tests/mocks/file-system'
|
||||
|
||||
let tempDir = ''
|
||||
let projectClaudeMdContent = ''
|
||||
|
||||
@@ -1740,4 +1740,6 @@ export function getPromptId(): string | null {
|
||||
export function setPromptId(id: string | null): void {
|
||||
STATE.promptId = id
|
||||
}
|
||||
export function isReplBridgeActive(): boolean { return false; }
|
||||
export function isReplBridgeActive(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -225,7 +225,9 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'Poll')
|
||||
rcLog(`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`)
|
||||
rcLog(
|
||||
`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`,
|
||||
)
|
||||
|
||||
// Empty body or null = no work available
|
||||
if (!response.data) {
|
||||
|
||||
@@ -447,9 +447,11 @@ export async function runBridgeLoop(
|
||||
): (status: SessionDoneStatus) => void {
|
||||
return (rawStatus: SessionDoneStatus): void => {
|
||||
const workId = sessionWorkIds.get(sessionId)
|
||||
rcLog(`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
|
||||
` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` +
|
||||
` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`)
|
||||
rcLog(
|
||||
`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
|
||||
` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` +
|
||||
` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`,
|
||||
)
|
||||
activeSessions.delete(sessionId)
|
||||
sessionStartTimes.delete(sessionId)
|
||||
sessionWorkIds.delete(sessionId)
|
||||
@@ -608,7 +610,9 @@ export async function runBridgeLoop(
|
||||
const pollConfig = getPollIntervalConfig()
|
||||
|
||||
try {
|
||||
rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`)
|
||||
rcLog(
|
||||
`poll: envId=${environmentId} activeSessions=${activeSessions.size}`,
|
||||
)
|
||||
const work = await api.pollForWork(
|
||||
environmentId,
|
||||
environmentSecret,
|
||||
@@ -863,7 +867,9 @@ export async function runBridgeLoop(
|
||||
break
|
||||
case 'session': {
|
||||
const sessionId = work.data.id
|
||||
rcLog(`work received: type=session sessionId=${sessionId} workId=${work.id}`)
|
||||
rcLog(
|
||||
`work received: type=session sessionId=${sessionId} workId=${work.id}`,
|
||||
)
|
||||
try {
|
||||
validateBridgeId(sessionId, 'session_id')
|
||||
} catch {
|
||||
@@ -1031,9 +1037,9 @@ export async function runBridgeLoop(
|
||||
|
||||
rcLog(
|
||||
`spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` +
|
||||
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
|
||||
` dir=${sessionDir}` +
|
||||
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
|
||||
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
|
||||
` dir=${sessionDir}` +
|
||||
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
|
||||
)
|
||||
const spawnResult = safeSpawn(
|
||||
spawner,
|
||||
@@ -1280,8 +1286,8 @@ export async function runBridgeLoop(
|
||||
const errMsg = describeAxiosError(err)
|
||||
rcLog(
|
||||
`poll error: ${errMsg}` +
|
||||
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
|
||||
` activeSessions=${activeSessions.size}`,
|
||||
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
|
||||
` activeSessions=${activeSessions.size}`,
|
||||
)
|
||||
|
||||
if (isConnectionError(err) || isServerError(err)) {
|
||||
|
||||
@@ -116,7 +116,8 @@ export function isEligibleBridgeMessage(m: Message): boolean {
|
||||
export function extractTitleText(m: Message): string | undefined {
|
||||
if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
|
||||
return undefined
|
||||
if (m.origin && (m.origin as { kind?: string }).kind !== 'human') return undefined
|
||||
if (m.origin && (m.origin as { kind?: string }).kind !== 'human')
|
||||
return undefined
|
||||
const content = m.message!.content
|
||||
let raw: string | undefined
|
||||
if (typeof content === 'string') {
|
||||
@@ -135,8 +136,7 @@ export function extractTitleText(m: Message): string | undefined {
|
||||
}
|
||||
|
||||
const SYSTEM_REMINDER_TAG = 'system-reminder'
|
||||
const XML_BLOCK_PATTERN =
|
||||
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy
|
||||
const XML_BLOCK_PATTERN = /\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy
|
||||
const RUNNING_STATE_META_TAGS = new Set([
|
||||
BASH_INPUT_TAG,
|
||||
CHANNEL_MESSAGE_TAG,
|
||||
@@ -357,7 +357,13 @@ export function handleServerControlRequest(
|
||||
// Outbound-only: reply error for mutable requests so claude.ai doesn't show
|
||||
// false success. initialize must still succeed (server kills the connection
|
||||
// if it doesn't — see comment above).
|
||||
const req = request.request as { subtype: string; model?: string; max_thinking_tokens?: number | null; mode?: string; [key: string]: unknown }
|
||||
const req = request.request as {
|
||||
subtype: string
|
||||
model?: string
|
||||
max_thinking_tokens?: number | null
|
||||
mode?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
if (outboundOnly && req.subtype !== 'initialize') {
|
||||
response = {
|
||||
type: 'control_response',
|
||||
@@ -480,8 +486,8 @@ export function handleServerControlRequest(
|
||||
void transport.write(event)
|
||||
rcLog(
|
||||
`control_response: subtype=${req.subtype}` +
|
||||
` request_id=${request.request_id}` +
|
||||
` result=${(response.response as { subtype?: string }).subtype}`,
|
||||
` request_id=${request.request_id}` +
|
||||
` result=${(response.response as { subtype?: string }).subtype}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`,
|
||||
|
||||
@@ -24,7 +24,9 @@ export function extractInboundMessageFields(
|
||||
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
|
||||
| undefined {
|
||||
if (msg.type !== 'user') return undefined
|
||||
const content = (msg.message as { content?: string | Array<ContentBlockParam> } | undefined)?.content
|
||||
const content = (
|
||||
msg.message as { content?: string | Array<ContentBlockParam> } | undefined
|
||||
)?.content
|
||||
if (!content) return undefined
|
||||
if (Array.isArray(content) && content.length === 0) return undefined
|
||||
|
||||
|
||||
@@ -290,7 +290,9 @@ export async function initReplBridge(
|
||||
isSyntheticMessage(msg)
|
||||
)
|
||||
continue
|
||||
const rawContent = getContentText(msg.message!.content as string | ContentBlockParam[])
|
||||
const rawContent = getContentText(
|
||||
msg.message!.content as string | ContentBlockParam[],
|
||||
)
|
||||
if (!rawContent) continue
|
||||
const derived = deriveTitle(rawContent)
|
||||
if (!derived) continue
|
||||
|
||||
@@ -20,7 +20,10 @@ export function rcLog(msg: string): void {
|
||||
try {
|
||||
if (!headerWritten) {
|
||||
ensureLogDir()
|
||||
appendFileSync(LOG_PATH, `\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`)
|
||||
appendFileSync(
|
||||
LOG_PATH,
|
||||
`\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`,
|
||||
)
|
||||
headerWritten = true
|
||||
}
|
||||
const ts = new Date().toISOString().slice(11, 23) // HH:mm:ss.SSS
|
||||
|
||||
@@ -850,7 +850,10 @@ export async function initEnvLessBridgeCore(
|
||||
for (const msg of filtered) {
|
||||
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
|
||||
}
|
||||
const events = filtered.map(m => ({ ...m, session_id: sessionId })) as StdoutMessage[]
|
||||
const events = filtered.map(m => ({
|
||||
...m,
|
||||
session_id: sessionId,
|
||||
})) as StdoutMessage[]
|
||||
void transport.writeBatch(events)
|
||||
},
|
||||
sendControlRequest(request: SDKControlRequest) {
|
||||
@@ -860,8 +863,14 @@ export async function initEnvLessBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event: TransportMessage = { ...request, session_id: sessionId } as TransportMessage
|
||||
if ((request as { request?: { subtype?: string } }).request?.subtype === 'can_use_tool') {
|
||||
const event: TransportMessage = {
|
||||
...request,
|
||||
session_id: sessionId,
|
||||
} as TransportMessage
|
||||
if (
|
||||
(request as { request?: { subtype?: string } }).request?.subtype ===
|
||||
'can_use_tool'
|
||||
) {
|
||||
transport.reportState('requires_action')
|
||||
}
|
||||
void transport.write(event as StdoutMessage)
|
||||
@@ -876,7 +885,10 @@ export async function initEnvLessBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event: TransportMessage = { ...response, session_id: sessionId } as TransportMessage
|
||||
const event: TransportMessage = {
|
||||
...response,
|
||||
session_id: sessionId,
|
||||
} as TransportMessage
|
||||
transport.reportState('running')
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging('[remote-bridge] Sent control_response')
|
||||
|
||||
@@ -454,7 +454,6 @@ export async function initBridgeCore(
|
||||
// re-created after a connection loss.
|
||||
let currentSessionId: string
|
||||
|
||||
|
||||
if (reusedPriorSession && prior) {
|
||||
currentSessionId = prior.sessionId
|
||||
logForDebugging(
|
||||
@@ -645,9 +644,9 @@ export async function initBridgeCore(
|
||||
environmentRecreations++
|
||||
rcLog(
|
||||
`doReconnect: attempt=${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS}` +
|
||||
` envId=${environmentId}` +
|
||||
` sessionId=${currentSessionId}` +
|
||||
` workId=${currentWorkId}`,
|
||||
` envId=${environmentId}` +
|
||||
` sessionId=${currentSessionId}` +
|
||||
` workId=${currentWorkId}`,
|
||||
)
|
||||
// Invalidate any in-flight v2 handshake — the environment is being
|
||||
// recreated, so a stale transport arriving post-reconnect would be
|
||||
@@ -859,7 +858,6 @@ export async function initBridgeCore(
|
||||
// UUIDs are scoped per-session on the server, so re-flushing is safe.
|
||||
previouslyFlushedUUIDs?.clear()
|
||||
|
||||
|
||||
// Reset the counter so independent reconnections hours apart don't
|
||||
// exhaust the limit — it guards against rapid consecutive failures,
|
||||
// not lifetime total.
|
||||
@@ -920,8 +918,8 @@ export async function initBridgeCore(
|
||||
function handleTransportPermanentClose(closeCode: number | undefined): void {
|
||||
rcLog(
|
||||
`handleTransportPermanentClose: code=${closeCode}` +
|
||||
` transport=${transport ? 'exists' : 'null'}` +
|
||||
` pollAborted=${pollController.signal.aborted}`,
|
||||
` transport=${transport ? 'exists' : 'null'}` +
|
||||
` pollAborted=${pollController.signal.aborted}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Transport permanently closed: code=${closeCode}`,
|
||||
@@ -1316,7 +1314,9 @@ export async function initBridgeCore(
|
||||
session_id: currentSessionId,
|
||||
})) as TransportMessage[]
|
||||
const dropsBefore = newTransport.droppedBatchCount
|
||||
void newTransport.writeBatch(events as StdoutMessage[]).then(() => {
|
||||
void newTransport
|
||||
.writeBatch(events as StdoutMessage[])
|
||||
.then(() => {
|
||||
// If any batch was dropped during this flush (SI down for
|
||||
// maxConsecutiveFailures attempts), flush() still resolved
|
||||
// normally but the events were NOT delivered. Don't mark
|
||||
@@ -1370,10 +1370,10 @@ export async function initBridgeCore(
|
||||
const parsed = JSON.parse(data)
|
||||
rcLog(
|
||||
`ingress: type=${parsed.type}` +
|
||||
`${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record<string, unknown>)?.subtype} request_id=${parsed.request_id}` : ''}` +
|
||||
`${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record<string, unknown>)?.subtype} request_id=${(parsed.response as Record<string, unknown>)?.request_id}` : ''}` +
|
||||
`${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
|
||||
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
|
||||
`${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record<string, unknown>)?.subtype} request_id=${parsed.request_id}` : ''}` +
|
||||
`${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record<string, unknown>)?.subtype} request_id=${(parsed.response as Record<string, unknown>)?.request_id}` : ''}` +
|
||||
`${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
|
||||
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
|
||||
)
|
||||
} catch {
|
||||
rcLog(`ingress (non-JSON): ${String(data).slice(0, 200)}`)
|
||||
@@ -1400,9 +1400,9 @@ export async function initBridgeCore(
|
||||
if (transport !== newTransport) return
|
||||
rcLog(
|
||||
`transport onClose: code=${closeCode}` +
|
||||
` connected=${newTransport.isConnectedStatus()}` +
|
||||
` state=${newTransport.getStateLabel()}` +
|
||||
` seq=${newTransport.getLastSequenceNum()}`,
|
||||
` connected=${newTransport.isConnectedStatus()}` +
|
||||
` state=${newTransport.getStateLabel()}` +
|
||||
` seq=${newTransport.getLastSequenceNum()}`,
|
||||
)
|
||||
handleTransportPermanentClose(closeCode)
|
||||
})
|
||||
@@ -1831,7 +1831,10 @@ export async function initBridgeCore(
|
||||
for (const msg of filtered) {
|
||||
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
|
||||
}
|
||||
const events: TransportMessage[] = filtered.map(m => ({ ...m, session_id: currentSessionId })) as TransportMessage[]
|
||||
const events: TransportMessage[] = filtered.map(m => ({
|
||||
...m,
|
||||
session_id: currentSessionId,
|
||||
})) as TransportMessage[]
|
||||
void transport.writeBatch(events as StdoutMessage[])
|
||||
},
|
||||
sendControlRequest(request: SDKControlRequest) {
|
||||
@@ -1841,7 +1844,10 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event: TransportMessage = { ...request, session_id: currentSessionId } as TransportMessage
|
||||
const event: TransportMessage = {
|
||||
...request,
|
||||
session_id: currentSessionId,
|
||||
} as TransportMessage
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent control_request request_id=${request.request_id}`,
|
||||
@@ -1854,7 +1860,10 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event: TransportMessage = { ...response, session_id: currentSessionId } as TransportMessage
|
||||
const event: TransportMessage = {
|
||||
...response,
|
||||
session_id: currentSessionId,
|
||||
} as TransportMessage
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging('[bridge:repl] Sent control_response')
|
||||
},
|
||||
|
||||
@@ -11,21 +11,44 @@
|
||||
/** Patterns that match known secret/token formats. */
|
||||
const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
||||
// GitHub tokens (PAT, OAuth, App, Server-to-server)
|
||||
{ pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g, replacement: '[REDACTED_GITHUB_TOKEN]' },
|
||||
{
|
||||
pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g,
|
||||
replacement: '[REDACTED_GITHUB_TOKEN]',
|
||||
},
|
||||
// Anthropic API keys
|
||||
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, replacement: '[REDACTED_ANTHROPIC_KEY]' },
|
||||
{
|
||||
pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g,
|
||||
replacement: '[REDACTED_ANTHROPIC_KEY]',
|
||||
},
|
||||
// Generic Bearer tokens in headers
|
||||
{ pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi, replacement: '$1[REDACTED_TOKEN]' },
|
||||
{
|
||||
pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi,
|
||||
replacement: '$1[REDACTED_TOKEN]',
|
||||
},
|
||||
// AWS access keys
|
||||
{ pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, replacement: '[REDACTED_AWS_KEY]' },
|
||||
{
|
||||
pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
||||
replacement: '[REDACTED_AWS_KEY]',
|
||||
},
|
||||
// AWS secret keys (40-char base64-like strings after common labels)
|
||||
{ pattern: /(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi, replacement: '$1=[REDACTED_AWS_SECRET]' },
|
||||
{
|
||||
pattern:
|
||||
/(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi,
|
||||
replacement: '$1=[REDACTED_AWS_SECRET]',
|
||||
},
|
||||
// Generic API key patterns (key=value or "key": "value")
|
||||
{ pattern: /(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi, replacement: '$1=[REDACTED]' },
|
||||
{
|
||||
pattern:
|
||||
/(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi,
|
||||
replacement: '$1=[REDACTED]',
|
||||
},
|
||||
// npm tokens
|
||||
{ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: '[REDACTED_NPM_TOKEN]' },
|
||||
// Slack tokens
|
||||
{ pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g, replacement: '[REDACTED_SLACK_TOKEN]' },
|
||||
{
|
||||
pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g,
|
||||
replacement: '[REDACTED_SLACK_TOKEN]',
|
||||
},
|
||||
]
|
||||
|
||||
/** Maximum content length before truncation (100KB). */
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import figures from 'figures'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import type { AppState } from '../state/AppStateStore.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js'
|
||||
import type { Theme } from '../utils/theme.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
|
||||
import { RARITY_COLORS } from './types.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import figures from 'figures';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import type { AppState } from '../state/AppStateStore.js';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js';
|
||||
import type { Theme } from '../utils/theme.js';
|
||||
import { getCompanion } from './companion.js';
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
||||
import { RARITY_COLORS } from './types.js';
|
||||
|
||||
const TICK_MS = 500
|
||||
const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
|
||||
const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500 // how long hearts float after /buddy pet
|
||||
const TICK_MS = 500;
|
||||
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
|
||||
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
|
||||
|
||||
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
|
||||
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
|
||||
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
|
||||
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0];
|
||||
|
||||
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
|
||||
const H = figures.heart
|
||||
const H = figures.heart;
|
||||
const PET_HEARTS = [
|
||||
` ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
`${H} ${H} ${H} `,
|
||||
'· · · ',
|
||||
]
|
||||
];
|
||||
|
||||
function wrap(text: string, width: number): string[] {
|
||||
const words = text.split(' ')
|
||||
const lines: string[] = []
|
||||
let cur = ''
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let cur = '';
|
||||
for (const w of words) {
|
||||
if (cur.length + w.length + 1 > width && cur) {
|
||||
lines.push(cur)
|
||||
cur = w
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else {
|
||||
cur = cur ? `${cur} ${w}` : w
|
||||
cur = cur ? `${cur} ${w}` : w;
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur)
|
||||
return lines
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function SpeechBubble({
|
||||
@@ -53,40 +53,29 @@ function SpeechBubble({
|
||||
fading,
|
||||
tail,
|
||||
}: {
|
||||
text: string
|
||||
color: keyof Theme
|
||||
fading: boolean
|
||||
tail: 'down' | 'right'
|
||||
text: string;
|
||||
color: keyof Theme;
|
||||
fading: boolean;
|
||||
tail: 'down' | 'right';
|
||||
}): React.ReactNode {
|
||||
const lines = wrap(text, 30)
|
||||
const borderColor = fading ? 'inactive' : color
|
||||
const lines = wrap(text, 30);
|
||||
const borderColor = fading ? 'inactive' : color;
|
||||
const bubble = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
width={34}
|
||||
>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
|
||||
{lines.map((l, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
italic
|
||||
dimColor={!fading}
|
||||
color={fading ? 'inactive' : undefined}
|
||||
>
|
||||
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
if (tail === 'right') {
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{bubble}
|
||||
<Text color={borderColor}>─</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
||||
@@ -96,18 +85,18 @@ function SpeechBubble({
|
||||
<Text color={borderColor}>╲</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100
|
||||
const SPRITE_BODY_WIDTH = 12
|
||||
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2
|
||||
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100;
|
||||
const SPRITE_BODY_WIDTH = 12;
|
||||
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2;
|
||||
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24;
|
||||
|
||||
function spriteColWidth(nameWidth: number): number {
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
|
||||
}
|
||||
|
||||
// Width the sprite area consumes. PromptInput subtracts this so text wraps
|
||||
@@ -115,89 +104,73 @@ function spriteColWidth(nameWidth: number): number {
|
||||
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
|
||||
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
|
||||
// (above input in fullscreen, below in scrollback), so no reservation.
|
||||
export function companionReservedColumns(
|
||||
terminalColumns: number,
|
||||
speaking: boolean,
|
||||
): number {
|
||||
if (!feature('BUDDY')) return 0
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
|
||||
const nameWidth = stringWidth(companion.name)
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
|
||||
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
||||
if (!feature('BUDDY')) return 0;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0;
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
|
||||
const nameWidth = stringWidth(companion.name);
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
|
||||
}
|
||||
|
||||
export function CompanionSprite(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const petAt = useAppState(s => s.companionPetAt)
|
||||
const focused = useAppState(s => s.footerSelection === 'companion')
|
||||
const setAppState = useSetAppState()
|
||||
const { columns } = useTerminalSize()
|
||||
const [tick, setTick] = useState(0)
|
||||
const lastSpokeTick = useRef(0)
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const petAt = useAppState(s => s.companionPetAt);
|
||||
const focused = useAppState(s => s.footerSelection === 'companion');
|
||||
const setAppState = useSetAppState();
|
||||
const { columns } = useTerminalSize();
|
||||
const [tick, setTick] = useState(0);
|
||||
const lastSpokeTick = useRef(0);
|
||||
// Sync-during-render (not useEffect) so the first post-pet render already
|
||||
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
|
||||
const [{ petStartTick, forPetAt }, setPetStart] = useState({
|
||||
petStartTick: 0,
|
||||
forPetAt: petAt,
|
||||
})
|
||||
});
|
||||
if (petAt !== forPetAt) {
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt })
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(
|
||||
setT => setT((t: number) => t + 1),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
lastSpokeTick.current = tick
|
||||
if (!reaction) return;
|
||||
lastSpokeTick.current = tick;
|
||||
const timer = setTimeout(
|
||||
setA =>
|
||||
setA((prev: AppState) =>
|
||||
prev.companionReaction === undefined
|
||||
? prev
|
||||
: { ...prev, companionReaction: undefined },
|
||||
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
|
||||
),
|
||||
BUBBLE_SHOW * TICK_MS,
|
||||
setAppState,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
||||
}, [reaction, setAppState])
|
||||
}, [reaction, setAppState]);
|
||||
|
||||
if (!feature('BUDDY')) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY')) return null;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
const color = RARITY_COLORS[companion.rarity]
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name))
|
||||
const color = RARITY_COLORS[companion.rarity];
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name));
|
||||
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
|
||||
const fading =
|
||||
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
|
||||
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
|
||||
|
||||
const petAge = petAt ? tick - petStartTick : Infinity
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS
|
||||
const petAge = petAt ? tick - petStartTick : Infinity;
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS;
|
||||
|
||||
// Narrow terminals: collapse to one-line face. When speaking, the quip
|
||||
// replaces the name beside the face (no room for a bubble).
|
||||
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
||||
const quip =
|
||||
reaction && reaction.length > NARROW_QUIP_CAP
|
||||
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
|
||||
: reaction
|
||||
const label = quip
|
||||
? `"${quip}"`
|
||||
: focused
|
||||
? ` ${companion.name} `
|
||||
: companion.name
|
||||
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
|
||||
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name;
|
||||
return (
|
||||
<Box paddingX={1} alignSelf="flex-end">
|
||||
<Text>
|
||||
@@ -210,44 +183,34 @@ export function CompanionSprite(): React.ReactNode {
|
||||
dimColor={!focused && !reaction}
|
||||
bold={focused}
|
||||
inverse={focused && !reaction}
|
||||
color={
|
||||
reaction
|
||||
? fading
|
||||
? 'inactive'
|
||||
: color
|
||||
: focused
|
||||
? color
|
||||
: undefined
|
||||
}
|
||||
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
const frameCount = spriteFrameCount(companion.species)
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
|
||||
const frameCount = spriteFrameCount(companion.species);
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
|
||||
|
||||
let spriteFrame: number
|
||||
let blink = false
|
||||
let spriteFrame: number;
|
||||
let blink = false;
|
||||
if (reaction || petting) {
|
||||
// Excited: cycle all fidget frames fast
|
||||
spriteFrame = tick % frameCount
|
||||
spriteFrame = tick % frameCount;
|
||||
} else {
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!;
|
||||
if (step === -1) {
|
||||
spriteFrame = 0
|
||||
blink = true
|
||||
spriteFrame = 0;
|
||||
blink = true;
|
||||
} else {
|
||||
spriteFrame = step % frameCount
|
||||
spriteFrame = step % frameCount;
|
||||
}
|
||||
}
|
||||
|
||||
const body = renderSprite(companion, spriteFrame).map(line =>
|
||||
blink ? line.replaceAll(companion.eye, '-') : line,
|
||||
)
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body
|
||||
const body = renderSprite(companion, spriteFrame).map(line => (blink ? line.replaceAll(companion.eye, '-') : line));
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body;
|
||||
|
||||
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
|
||||
// focused shows inverse name. The enter-to-open hint lives in
|
||||
@@ -255,31 +218,20 @@ export function CompanionSprite(): React.ReactNode {
|
||||
// sprite doesn't jump up when selected. flexShrink=0 stops the
|
||||
// inline-bubble row wrapper from squeezing the sprite to fit.
|
||||
const spriteColumn = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
alignItems="center"
|
||||
width={colWidth}
|
||||
>
|
||||
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
||||
{sprite.map((line, i) => (
|
||||
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
<Text
|
||||
italic
|
||||
bold={focused}
|
||||
dimColor={!focused}
|
||||
color={focused ? color : undefined}
|
||||
inverse={focused}
|
||||
>
|
||||
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
|
||||
{focused ? ` ${companion.name} ` : companion.name}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
if (!reaction) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
|
||||
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
|
||||
@@ -288,19 +240,14 @@ export function CompanionSprite(): React.ReactNode {
|
||||
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
|
||||
// because floating into Static scrollback can't be cleared.
|
||||
if (isFullscreenActive()) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
||||
<SpeechBubble
|
||||
text={reaction}
|
||||
color={color}
|
||||
fading={fading}
|
||||
tail="right"
|
||||
/>
|
||||
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" />
|
||||
{spriteColumn}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
|
||||
@@ -308,33 +255,29 @@ export function CompanionSprite(): React.ReactNode {
|
||||
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
|
||||
// just reads companionReaction and renders the fade.
|
||||
export function CompanionFloatingBubble(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const [{ tick, forReaction }, setTick] = useState({
|
||||
tick: 0,
|
||||
forReaction: reaction,
|
||||
})
|
||||
});
|
||||
|
||||
// Reset tick synchronously when reaction changes (not in useEffect, which
|
||||
// runs post-render and would show one stale-faded frame). Storing the
|
||||
// reaction the tick is counting FOR alongside the tick itself means the
|
||||
// fade computation never sees a tick from a previous reaction.
|
||||
if (reaction !== forReaction) {
|
||||
setTick({ tick: 0, forReaction: reaction })
|
||||
setTick({ tick: 0, forReaction: reaction });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
const timer = setInterval(
|
||||
set => set(s => ({ ...s, tick: s.tick + 1 })),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [reaction])
|
||||
if (!reaction) return;
|
||||
const timer = setInterval(set => set(s => ({ ...s, tick: s.tick + 1 })), TICK_MS, setTick);
|
||||
return () => clearInterval(timer);
|
||||
}, [reaction]);
|
||||
|
||||
if (!feature('BUDDY') || !reaction) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY') || !reaction) return null;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
return (
|
||||
<SpeechBubble
|
||||
@@ -343,5 +286,5 @@ export function CompanionFloatingBubble(): React.ReactNode {
|
||||
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
|
||||
tail="down"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getRainbowColor } from '../utils/thinking.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNotifications } from '../context/notifications.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { getRainbowColor } from '../utils/thinking.js';
|
||||
|
||||
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
|
||||
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
|
||||
// Teaser window: April 1-7, 2026 only. Command stays live forever after.
|
||||
export function isBuddyTeaserWindow(): boolean {
|
||||
if (process.env.USER_TYPE === 'ant') return true
|
||||
const d = new Date()
|
||||
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
|
||||
if (process.env.USER_TYPE === 'ant') return true;
|
||||
const d = new Date();
|
||||
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7;
|
||||
}
|
||||
|
||||
export function isBuddyLive(): boolean {
|
||||
if (process.env.USER_TYPE === 'ant') return true
|
||||
const d = new Date()
|
||||
return (
|
||||
d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)
|
||||
)
|
||||
if (process.env.USER_TYPE === 'ant') return true;
|
||||
const d = new Date();
|
||||
return d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3);
|
||||
}
|
||||
|
||||
function RainbowText({ text }: { text: string }): React.ReactNode {
|
||||
@@ -31,37 +29,35 @@ function RainbowText({ text }: { text: string }): React.ReactNode {
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Rainbow /buddy teaser shown on startup when no companion hatched yet.
|
||||
// Idle presence and reactions are handled by CompanionSprite directly.
|
||||
export function useBuddyNotification(): void {
|
||||
const { addNotification, removeNotification } = useNotifications()
|
||||
const { addNotification, removeNotification } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature('BUDDY')) return
|
||||
const config = getGlobalConfig()
|
||||
if (config.companion || !isBuddyTeaserWindow()) return
|
||||
if (!feature('BUDDY')) return;
|
||||
const config = getGlobalConfig();
|
||||
if (config.companion || !isBuddyTeaserWindow()) return;
|
||||
addNotification({
|
||||
key: 'buddy-teaser',
|
||||
jsx: <RainbowText text="/buddy" />,
|
||||
priority: 'immediate',
|
||||
timeoutMs: 15_000,
|
||||
})
|
||||
return () => removeNotification('buddy-teaser')
|
||||
}, [addNotification, removeNotification])
|
||||
});
|
||||
return () => removeNotification('buddy-teaser');
|
||||
}, [addNotification, removeNotification]);
|
||||
}
|
||||
|
||||
export function findBuddyTriggerPositions(
|
||||
text: string,
|
||||
): Array<{ start: number; end: number }> {
|
||||
if (!feature('BUDDY')) return []
|
||||
const triggers: Array<{ start: number; end: number }> = []
|
||||
const re = /\/buddy\b/g
|
||||
let m: RegExpExecArray | null
|
||||
export function findBuddyTriggerPositions(text: string): Array<{ start: number; end: number }> {
|
||||
if (!feature('BUDDY')) return [];
|
||||
const triggers: Array<{ start: number; end: number }> = [];
|
||||
const re = /\/buddy\b/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
triggers.push({ start: m.index, end: m.index + m[0].length })
|
||||
triggers.push({ start: m.index, end: m.index + m[0].length });
|
||||
}
|
||||
return triggers
|
||||
return triggers;
|
||||
}
|
||||
|
||||
@@ -200,7 +200,9 @@ export async function attachHandler(target: string | undefined): Promise<void> {
|
||||
const { TmuxEngine } = await import('./bg/engines/tmux.js')
|
||||
const tmux = new TmuxEngine()
|
||||
if (!(await tmux.available())) {
|
||||
console.error('tmux is no longer available. Cannot attach to tmux session.')
|
||||
console.error(
|
||||
'tmux is no longer available. Cannot attach to tmux session.',
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
@@ -324,7 +326,9 @@ export async function handleBgStart(args: string[]): Promise<void> {
|
||||
console.log(` Engine: ${result.engineUsed}`)
|
||||
console.log(` Log: ${result.logPath}`)
|
||||
console.log()
|
||||
console.log(`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`)
|
||||
console.log(
|
||||
`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`,
|
||||
)
|
||||
console.log(`Use \`claude daemon status\` to check status.`)
|
||||
console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`)
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { closeSync, mkdirSync, openSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { buildCliLaunch, spawnCli } from '../../../utils/cliLaunch.js'
|
||||
import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
import type {
|
||||
BgEngine,
|
||||
BgStartOptions,
|
||||
BgStartResult,
|
||||
SessionEntry,
|
||||
} from '../engine.js'
|
||||
import { tailLog } from '../tail.js'
|
||||
|
||||
export class DetachedEngine implements BgEngine {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
export type {
|
||||
BgEngine,
|
||||
BgStartOptions,
|
||||
BgStartResult,
|
||||
SessionEntry,
|
||||
} from '../engine.js'
|
||||
|
||||
export async function selectEngine(): Promise<import('../engine.js').BgEngine> {
|
||||
if (process.platform === 'win32') {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { spawnSync } from 'child_process'
|
||||
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
|
||||
import { buildCliLaunch, quoteCliLaunch } from '../../../utils/cliLaunch.js'
|
||||
import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
|
||||
import type {
|
||||
BgEngine,
|
||||
BgStartOptions,
|
||||
BgStartResult,
|
||||
SessionEntry,
|
||||
} from '../engine.js'
|
||||
|
||||
export class TmuxEngine implements BgEngine {
|
||||
readonly name = 'tmux' as const
|
||||
|
||||
@@ -87,8 +87,12 @@ describe('autonomy CLI handler', () => {
|
||||
})
|
||||
|
||||
test('prints individual deep status sections for panel actions', async () => {
|
||||
const pipes = await getAutonomyDeepSectionText('pipes', { rootDir: tempDir })
|
||||
const remoteControl = await getAutonomyDeepSectionText('remote-control', { rootDir: tempDir })
|
||||
const pipes = await getAutonomyDeepSectionText('pipes', {
|
||||
rootDir: tempDir,
|
||||
})
|
||||
const remoteControl = await getAutonomyDeepSectionText('remote-control', {
|
||||
rootDir: tempDir,
|
||||
})
|
||||
|
||||
expect(pipes).toContain('# Pipes')
|
||||
expect(pipes).toContain('Pipe registry:')
|
||||
@@ -116,17 +120,24 @@ describe('autonomy CLI handler', () => {
|
||||
})
|
||||
const [waitingFlow] = await listAutonomyFlows(tempDir)
|
||||
|
||||
expect(await getAutonomyFlowsText(undefined, { rootDir: tempDir })).toContain(waitingFlow!.flowId)
|
||||
expect(await getAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir })).toContain(
|
||||
'Current step: wait',
|
||||
)
|
||||
expect(
|
||||
await getAutonomyFlowsText(undefined, { rootDir: tempDir }),
|
||||
).toContain(waitingFlow!.flowId)
|
||||
expect(
|
||||
await getAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir }),
|
||||
).toContain('Current step: wait')
|
||||
|
||||
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir, currentDir: tempDir })
|
||||
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId, {
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
expect(resumed).toContain('Prepared the next managed step')
|
||||
expect(resumed).toContain('Prompt:')
|
||||
expect(resumed).toContain('Wait for manual signal')
|
||||
|
||||
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir })
|
||||
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId, {
|
||||
rootDir: tempDir,
|
||||
})
|
||||
expect(cancelled).toContain('Cancelled flow')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,7 +159,9 @@ export async function authLogin({
|
||||
|
||||
const orgResult = await validateForceLoginOrg()
|
||||
if (!orgResult.valid) {
|
||||
process.stderr.write((orgResult as { valid: false; message: string }).message + '\n')
|
||||
process.stderr.write(
|
||||
(orgResult as { valid: false; message: string }).message + '\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -209,7 +211,9 @@ export async function authLogin({
|
||||
|
||||
const orgResult = await validateForceLoginOrg()
|
||||
if (!orgResult.valid) {
|
||||
process.stderr.write((orgResult as { valid: false; message: string }).message + '\n')
|
||||
process.stderr.write(
|
||||
(orgResult as { valid: false; message: string }).message + '\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,9 @@ export async function getAutonomyFlowText(
|
||||
flowId: string,
|
||||
options?: { rootDir?: string },
|
||||
): Promise<string> {
|
||||
return formatAutonomyFlowDetail(await getAutonomyFlowById(flowId, options?.rootDir))
|
||||
return formatAutonomyFlowDetail(
|
||||
await getAutonomyFlowById(flowId, options?.rootDir),
|
||||
)
|
||||
}
|
||||
|
||||
export async function autonomyFlowHandler(flowId: string): Promise<void> {
|
||||
|
||||
@@ -3,201 +3,163 @@
|
||||
* These are dynamically imported only when the corresponding `claude mcp *` command runs.
|
||||
*/
|
||||
|
||||
import { stat } from 'fs/promises'
|
||||
import pMap from 'p-map'
|
||||
import { cwd } from 'process'
|
||||
import React from 'react'
|
||||
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'
|
||||
import { wrappedRender as render } from '@anthropic/ink'
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
|
||||
import { stat } from 'fs/promises';
|
||||
import pMap from 'p-map';
|
||||
import { cwd } from 'process';
|
||||
import React from 'react';
|
||||
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js';
|
||||
import { wrappedRender as render } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import {
|
||||
clearMcpClientConfig,
|
||||
clearServerTokensFromLocalStorage,
|
||||
getMcpClientConfig,
|
||||
readClientSecret,
|
||||
saveMcpClientSecret,
|
||||
} from '../../services/mcp/auth.js'
|
||||
import {
|
||||
connectToServer,
|
||||
getMcpServerConnectionBatchSize,
|
||||
} from '../../services/mcp/client.js'
|
||||
} from '../../services/mcp/auth.js';
|
||||
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
||||
import {
|
||||
addMcpConfig,
|
||||
getAllMcpConfigs,
|
||||
getMcpConfigByName,
|
||||
getMcpConfigsByScope,
|
||||
removeMcpConfig,
|
||||
} from '../../services/mcp/config.js'
|
||||
import type {
|
||||
ConfigScope,
|
||||
ScopedMcpServerConfig,
|
||||
} from '../../services/mcp/types.js'
|
||||
import {
|
||||
describeMcpConfigFilePath,
|
||||
ensureConfigScope,
|
||||
getScopeLabel,
|
||||
} from '../../services/mcp/utils.js'
|
||||
import { AppStateProvider } from '../../state/AppState.js'
|
||||
import {
|
||||
getCurrentProjectConfig,
|
||||
getGlobalConfig,
|
||||
saveCurrentProjectConfig,
|
||||
} from '../../utils/config.js'
|
||||
import { isFsInaccessible } from '../../utils/errors.js'
|
||||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
|
||||
import { safeParseJSON } from '../../utils/json.js'
|
||||
import { getPlatform } from '../../utils/platform.js'
|
||||
import { cliError, cliOk } from '../exit.js'
|
||||
} from '../../services/mcp/config.js';
|
||||
import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js';
|
||||
import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } from '../../services/mcp/utils.js';
|
||||
import { AppStateProvider } from '../../state/AppState.js';
|
||||
import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig } from '../../utils/config.js';
|
||||
import { isFsInaccessible } from '../../utils/errors.js';
|
||||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js';
|
||||
import { safeParseJSON } from '../../utils/json.js';
|
||||
import { getPlatform } from '../../utils/platform.js';
|
||||
import { cliError, cliOk } from '../exit.js';
|
||||
|
||||
async function checkMcpServerHealth(
|
||||
name: string,
|
||||
server: ScopedMcpServerConfig,
|
||||
): Promise<string> {
|
||||
async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise<string> {
|
||||
try {
|
||||
const result = await connectToServer(name, server)
|
||||
const result = await connectToServer(name, server);
|
||||
if (result.type === 'connected') {
|
||||
return '✓ Connected'
|
||||
return '✓ Connected';
|
||||
} else if (result.type === 'needs-auth') {
|
||||
return '! Needs authentication'
|
||||
return '! Needs authentication';
|
||||
} else {
|
||||
return '✗ Failed to connect'
|
||||
return '✗ Failed to connect';
|
||||
}
|
||||
} catch (_error) {
|
||||
return '✗ Connection error'
|
||||
return '✗ Connection error';
|
||||
}
|
||||
}
|
||||
|
||||
// mcp serve (lines 4512–4532)
|
||||
export async function mcpServeHandler({
|
||||
debug,
|
||||
verbose,
|
||||
}: {
|
||||
debug?: boolean
|
||||
verbose?: boolean
|
||||
}): Promise<void> {
|
||||
const providedCwd = cwd()
|
||||
logEvent('tengu_mcp_start', {})
|
||||
export async function mcpServeHandler({ debug, verbose }: { debug?: boolean; verbose?: boolean }): Promise<void> {
|
||||
const providedCwd = cwd();
|
||||
logEvent('tengu_mcp_start', {});
|
||||
|
||||
try {
|
||||
await stat(providedCwd)
|
||||
await stat(providedCwd);
|
||||
} catch (error) {
|
||||
if (isFsInaccessible(error)) {
|
||||
cliError(`Error: Directory ${providedCwd} does not exist`)
|
||||
cliError(`Error: Directory ${providedCwd} does not exist`);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const { setup } = await import('../../setup.js')
|
||||
await setup(providedCwd, 'default', false, false, undefined, false)
|
||||
const { startMCPServer } = await import('../../entrypoints/mcp.js')
|
||||
await startMCPServer(providedCwd, debug ?? false, verbose ?? false)
|
||||
const { setup } = await import('../../setup.js');
|
||||
await setup(providedCwd, 'default', false, false, undefined, false);
|
||||
const { startMCPServer } = await import('../../entrypoints/mcp.js');
|
||||
await startMCPServer(providedCwd, debug ?? false, verbose ?? false);
|
||||
} catch (error) {
|
||||
cliError(`Error: Failed to start MCP server: ${error}`)
|
||||
cliError(`Error: Failed to start MCP server: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// mcp remove (lines 4545–4635)
|
||||
export async function mcpRemoveHandler(
|
||||
name: string,
|
||||
options: { scope?: string },
|
||||
): Promise<void> {
|
||||
export async function mcpRemoveHandler(name: string, options: { scope?: string }): Promise<void> {
|
||||
// Look up config before removing so we can clean up secure storage
|
||||
const serverBeforeRemoval = getMcpConfigByName(name)
|
||||
const serverBeforeRemoval = getMcpConfigByName(name);
|
||||
|
||||
const cleanupSecureStorage = () => {
|
||||
if (
|
||||
serverBeforeRemoval &&
|
||||
(serverBeforeRemoval.type === 'sse' ||
|
||||
serverBeforeRemoval.type === 'http')
|
||||
) {
|
||||
clearServerTokensFromLocalStorage(name, serverBeforeRemoval)
|
||||
clearMcpClientConfig(name, serverBeforeRemoval)
|
||||
if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) {
|
||||
clearServerTokensFromLocalStorage(name, serverBeforeRemoval);
|
||||
clearMcpClientConfig(name, serverBeforeRemoval);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (options.scope) {
|
||||
const scope = ensureConfigScope(options.scope)
|
||||
const scope = ensureConfigScope(options.scope);
|
||||
logEvent('tengu_mcp_delete', {
|
||||
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
scope:
|
||||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
await removeMcpConfig(name, scope)
|
||||
cleanupSecureStorage()
|
||||
process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`)
|
||||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
|
||||
await removeMcpConfig(name, scope);
|
||||
cleanupSecureStorage();
|
||||
process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`);
|
||||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`);
|
||||
}
|
||||
|
||||
// If no scope specified, check where the server exists
|
||||
const projectConfig = getCurrentProjectConfig()
|
||||
const globalConfig = getGlobalConfig()
|
||||
const projectConfig = getCurrentProjectConfig();
|
||||
const globalConfig = getGlobalConfig();
|
||||
|
||||
// Check if server exists in project scope (.mcp.json)
|
||||
const { servers: projectServers } = getMcpConfigsByScope('project')
|
||||
const mcpJsonExists = !!projectServers[name]
|
||||
const { servers: projectServers } = getMcpConfigsByScope('project');
|
||||
const mcpJsonExists = !!projectServers[name];
|
||||
|
||||
// Count how many scopes contain this server
|
||||
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []
|
||||
if (projectConfig.mcpServers?.[name]) scopes.push('local')
|
||||
if (mcpJsonExists) scopes.push('project')
|
||||
if (globalConfig.mcpServers?.[name]) scopes.push('user')
|
||||
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = [];
|
||||
if (projectConfig.mcpServers?.[name]) scopes.push('local');
|
||||
if (mcpJsonExists) scopes.push('project');
|
||||
if (globalConfig.mcpServers?.[name]) scopes.push('user');
|
||||
|
||||
if (scopes.length === 0) {
|
||||
cliError(`No MCP server found with name: "${name}"`)
|
||||
cliError(`No MCP server found with name: "${name}"`);
|
||||
} else if (scopes.length === 1) {
|
||||
// Server exists in only one scope, remove it
|
||||
const scope = scopes[0]!
|
||||
const scope = scopes[0]!;
|
||||
logEvent('tengu_mcp_delete', {
|
||||
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
scope:
|
||||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
await removeMcpConfig(name, scope)
|
||||
cleanupSecureStorage()
|
||||
process.stdout.write(
|
||||
`Removed MCP server "${name}" from ${scope} config\n`,
|
||||
)
|
||||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
|
||||
await removeMcpConfig(name, scope);
|
||||
cleanupSecureStorage();
|
||||
process.stdout.write(`Removed MCP server "${name}" from ${scope} config\n`);
|
||||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`);
|
||||
} else {
|
||||
// Server exists in multiple scopes
|
||||
process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`)
|
||||
process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`);
|
||||
scopes.forEach(scope => {
|
||||
process.stderr.write(
|
||||
` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`,
|
||||
)
|
||||
})
|
||||
process.stderr.write('\nTo remove from a specific scope, use:\n')
|
||||
process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`);
|
||||
});
|
||||
process.stderr.write('\nTo remove from a specific scope, use:\n');
|
||||
scopes.forEach(scope => {
|
||||
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`)
|
||||
})
|
||||
cliError()
|
||||
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`);
|
||||
});
|
||||
cliError();
|
||||
}
|
||||
} catch (error) {
|
||||
cliError((error as Error).message)
|
||||
cliError((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// mcp list (lines 4641–4688)
|
||||
export async function mcpListHandler(): Promise<void> {
|
||||
logEvent('tengu_mcp_list', {})
|
||||
const { servers: configs } = await getAllMcpConfigs()
|
||||
logEvent('tengu_mcp_list', {});
|
||||
const { servers: configs } = await getAllMcpConfigs();
|
||||
if (Object.keys(configs).length === 0) {
|
||||
console.log(
|
||||
'No MCP servers configured. Use `claude mcp add` to add a server.',
|
||||
)
|
||||
console.log('No MCP servers configured. Use `claude mcp add` to add a server.');
|
||||
} else {
|
||||
console.log('Checking MCP server health...\n')
|
||||
console.log('Checking MCP server health...\n');
|
||||
|
||||
// Check servers concurrently
|
||||
const entries = Object.entries(configs)
|
||||
const entries = Object.entries(configs);
|
||||
const results = await pMap(
|
||||
entries,
|
||||
async ([name, server]) => ({
|
||||
@@ -206,104 +168,100 @@ export async function mcpListHandler(): Promise<void> {
|
||||
status: await checkMcpServerHealth(name, server),
|
||||
}),
|
||||
{ concurrency: getMcpServerConnectionBatchSize() },
|
||||
)
|
||||
);
|
||||
|
||||
for (const { name, server, status } of results) {
|
||||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
console.log(`${name}: ${server.url} (SSE) - ${status}`)
|
||||
console.log(`${name}: ${server.url} (SSE) - ${status}`);
|
||||
} else if (server.type === 'http') {
|
||||
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
|
||||
console.log(`${name}: ${server.url} (HTTP) - ${status}`);
|
||||
} else if (server.type === 'claudeai-proxy') {
|
||||
console.log(`${name}: ${server.url} - ${status}`)
|
||||
console.log(`${name}: ${server.url} - ${status}`);
|
||||
} else if (!server.type || server.type === 'stdio') {
|
||||
const stdioServer = server as { command: string; args: string[]; type?: string }
|
||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
|
||||
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
|
||||
const stdioServer = server as { command: string; args: string[]; type?: string };
|
||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : [];
|
||||
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use gracefulShutdown to properly clean up MCP server connections
|
||||
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
|
||||
await gracefulShutdown(0)
|
||||
await gracefulShutdown(0);
|
||||
}
|
||||
|
||||
// mcp get (lines 4694–4786)
|
||||
export async function mcpGetHandler(name: string): Promise<void> {
|
||||
logEvent('tengu_mcp_get', {
|
||||
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
const server = getMcpConfigByName(name)
|
||||
});
|
||||
const server = getMcpConfigByName(name);
|
||||
if (!server) {
|
||||
cliError(`No MCP server found with name: ${name}`)
|
||||
cliError(`No MCP server found with name: ${name}`);
|
||||
}
|
||||
|
||||
console.log(`${name}:`)
|
||||
console.log(` Scope: ${getScopeLabel(server.scope)}`)
|
||||
console.log(`${name}:`);
|
||||
console.log(` Scope: ${getScopeLabel(server.scope)}`);
|
||||
|
||||
// Check server health
|
||||
const status = await checkMcpServerHealth(name, server)
|
||||
console.log(` Status: ${status}`)
|
||||
const status = await checkMcpServerHealth(name, server);
|
||||
console.log(` Status: ${status}`);
|
||||
|
||||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
console.log(` Type: sse`)
|
||||
console.log(` URL: ${server.url}`)
|
||||
console.log(` Type: sse`);
|
||||
console.log(` URL: ${server.url}`);
|
||||
if (server.headers) {
|
||||
console.log(' Headers:')
|
||||
console.log(' Headers:');
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
console.log(` ${key}: ${value}`)
|
||||
console.log(` ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||||
const parts: string[] = []
|
||||
const parts: string[] = [];
|
||||
if (server.oauth.clientId) {
|
||||
parts.push('client_id configured')
|
||||
const clientConfig = getMcpClientConfig(name, server)
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured')
|
||||
parts.push('client_id configured');
|
||||
const clientConfig = getMcpClientConfig(name, server);
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured');
|
||||
}
|
||||
if (server.oauth.callbackPort)
|
||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||
console.log(` OAuth: ${parts.join(', ')}`)
|
||||
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
|
||||
console.log(` OAuth: ${parts.join(', ')}`);
|
||||
}
|
||||
} else if (server.type === 'http') {
|
||||
console.log(` Type: http`)
|
||||
console.log(` URL: ${server.url}`)
|
||||
console.log(` Type: http`);
|
||||
console.log(` URL: ${server.url}`);
|
||||
if (server.headers) {
|
||||
console.log(' Headers:')
|
||||
console.log(' Headers:');
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
console.log(` ${key}: ${value}`)
|
||||
console.log(` ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||||
const parts: string[] = []
|
||||
const parts: string[] = [];
|
||||
if (server.oauth.clientId) {
|
||||
parts.push('client_id configured')
|
||||
const clientConfig = getMcpClientConfig(name, server)
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured')
|
||||
parts.push('client_id configured');
|
||||
const clientConfig = getMcpClientConfig(name, server);
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured');
|
||||
}
|
||||
if (server.oauth.callbackPort)
|
||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||
console.log(` OAuth: ${parts.join(', ')}`)
|
||||
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
|
||||
console.log(` OAuth: ${parts.join(', ')}`);
|
||||
}
|
||||
} else if (server.type === 'stdio') {
|
||||
console.log(` Type: stdio`)
|
||||
console.log(` Command: ${server.command}`)
|
||||
const args = Array.isArray(server.args) ? server.args : []
|
||||
console.log(` Args: ${args.join(' ')}`)
|
||||
console.log(` Type: stdio`);
|
||||
console.log(` Command: ${server.command}`);
|
||||
const args = Array.isArray(server.args) ? server.args : [];
|
||||
console.log(` Args: ${args.join(' ')}`);
|
||||
if (server.env) {
|
||||
console.log(' Environment:')
|
||||
console.log(' Environment:');
|
||||
for (const [key, value] of Object.entries(server.env)) {
|
||||
console.log(` ${key}=${value}`)
|
||||
console.log(` ${key}=${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
|
||||
)
|
||||
console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`);
|
||||
// Use gracefulShutdown to properly clean up MCP server connections
|
||||
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
|
||||
await gracefulShutdown(0)
|
||||
await gracefulShutdown(0);
|
||||
}
|
||||
|
||||
// mcp add-json (lines 4801–4870)
|
||||
@@ -313,8 +271,8 @@ export async function mcpAddJsonHandler(
|
||||
options: { scope?: string; clientSecret?: true },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const scope = ensureConfigScope(options.scope)
|
||||
const parsedJson = safeParseJSON(json)
|
||||
const scope = ensureConfigScope(options.scope);
|
||||
const parsedJson = safeParseJSON(json);
|
||||
|
||||
// Read secret before writing config so cancellation doesn't leave partial state
|
||||
const needsSecret =
|
||||
@@ -328,15 +286,15 @@ export async function mcpAddJsonHandler(
|
||||
'oauth' in parsedJson &&
|
||||
parsedJson.oauth &&
|
||||
typeof parsedJson.oauth === 'object' &&
|
||||
'clientId' in parsedJson.oauth
|
||||
const clientSecret = needsSecret ? await readClientSecret() : undefined
|
||||
'clientId' in parsedJson.oauth;
|
||||
const clientSecret = needsSecret ? await readClientSecret() : undefined;
|
||||
|
||||
await addMcpConfig(name, parsedJson, scope)
|
||||
await addMcpConfig(name, parsedJson, scope);
|
||||
|
||||
const transportType =
|
||||
parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson
|
||||
? String(parsedJson.type || 'stdio')
|
||||
: 'stdio'
|
||||
: 'stdio';
|
||||
|
||||
if (
|
||||
clientSecret &&
|
||||
@@ -347,53 +305,38 @@ export async function mcpAddJsonHandler(
|
||||
'url' in parsedJson &&
|
||||
typeof parsedJson.url === 'string'
|
||||
) {
|
||||
saveMcpClientSecret(
|
||||
name,
|
||||
{ type: parsedJson.type, url: parsedJson.url },
|
||||
clientSecret,
|
||||
)
|
||||
saveMcpClientSecret(name, { type: parsedJson.type, url: parsedJson.url }, clientSecret);
|
||||
}
|
||||
|
||||
logEvent('tengu_mcp_add', {
|
||||
scope:
|
||||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
source:
|
||||
'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
source: 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
|
||||
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`)
|
||||
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`);
|
||||
} catch (error) {
|
||||
cliError((error as Error).message)
|
||||
cliError((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// mcp add-from-claude-desktop (lines 4881–4927)
|
||||
export async function mcpAddFromDesktopHandler(options: {
|
||||
scope?: string
|
||||
}): Promise<void> {
|
||||
export async function mcpAddFromDesktopHandler(options: { scope?: string }): Promise<void> {
|
||||
try {
|
||||
const scope = ensureConfigScope(options.scope)
|
||||
const platform = getPlatform()
|
||||
const scope = ensureConfigScope(options.scope);
|
||||
const platform = getPlatform();
|
||||
|
||||
logEvent('tengu_mcp_add', {
|
||||
scope:
|
||||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
platform:
|
||||
platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
source:
|
||||
'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
source: 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
const { readClaudeDesktopMcpServers } = await import(
|
||||
'../../utils/claudeDesktop.js'
|
||||
)
|
||||
const servers = await readClaudeDesktopMcpServers()
|
||||
const { readClaudeDesktopMcpServers } = await import('../../utils/claudeDesktop.js');
|
||||
const servers = await readClaudeDesktopMcpServers();
|
||||
|
||||
if (Object.keys(servers).length === 0) {
|
||||
cliOk(
|
||||
'No MCP servers found in Claude Desktop configuration or configuration file does not exist.',
|
||||
)
|
||||
cliOk('No MCP servers found in Claude Desktop configuration or configuration file does not exist.');
|
||||
}
|
||||
|
||||
const { unmount } = await render(
|
||||
@@ -403,29 +346,29 @@ export async function mcpAddFromDesktopHandler(options: {
|
||||
servers={servers}
|
||||
scope={scope}
|
||||
onDone={() => {
|
||||
unmount()
|
||||
unmount();
|
||||
}}
|
||||
/>
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
{ exitOnCtrlC: true },
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
cliError((error as Error).message)
|
||||
cliError((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// mcp reset-project-choices (lines 4935–4952)
|
||||
export async function mcpResetChoicesHandler(): Promise<void> {
|
||||
logEvent('tengu_mcp_reset_mcpjson_choices', {})
|
||||
logEvent('tengu_mcp_reset_mcpjson_choices', {});
|
||||
saveCurrentProjectConfig(current => ({
|
||||
...current,
|
||||
enabledMcpjsonServers: [],
|
||||
disabledMcpjsonServers: [],
|
||||
enableAllProjectMcpServers: false,
|
||||
}))
|
||||
}));
|
||||
cliOk(
|
||||
'All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' +
|
||||
'You will be prompted for approval next time you start Claude Code.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,26 +4,24 @@
|
||||
*/
|
||||
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */
|
||||
|
||||
import { cwd } from 'process'
|
||||
import React from 'react'
|
||||
import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'
|
||||
import { useManagePlugins } from '../../hooks/useManagePlugins.js'
|
||||
import type { Root } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'
|
||||
import { AppStateProvider } from '../../state/AppState.js'
|
||||
import { onChangeAppState } from '../../state/onChangeAppState.js'
|
||||
import { isAnthropicAuthEnabled } from '../../utils/auth.js'
|
||||
import { cwd } from 'process';
|
||||
import React from 'react';
|
||||
import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js';
|
||||
import { useManagePlugins } from '../../hooks/useManagePlugins.js';
|
||||
import type { Root } from '@anthropic/ink';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js';
|
||||
import { AppStateProvider } from '../../state/AppState.js';
|
||||
import { onChangeAppState } from '../../state/onChangeAppState.js';
|
||||
import { isAnthropicAuthEnabled } from '../../utils/auth.js';
|
||||
|
||||
export async function setupTokenHandler(root: Root): Promise<void> {
|
||||
logEvent('tengu_setup_token_command', {})
|
||||
logEvent('tengu_setup_token_command', {});
|
||||
|
||||
const showAuthWarning = !isAnthropicAuthEnabled()
|
||||
const { ConsoleOAuthFlow } = await import(
|
||||
'../../components/ConsoleOAuthFlow.js'
|
||||
)
|
||||
const showAuthWarning = !isAnthropicAuthEnabled();
|
||||
const { ConsoleOAuthFlow } = await import('../../components/ConsoleOAuthFlow.js');
|
||||
await new Promise<void>(resolve => {
|
||||
root.render(
|
||||
<AppStateProvider onChangeAppState={onChangeAppState}>
|
||||
@@ -33,18 +31,16 @@ export async function setupTokenHandler(root: Root): Promise<void> {
|
||||
{showAuthWarning && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="warning">
|
||||
Warning: You already have authentication configured via
|
||||
environment variable or API key helper.
|
||||
Warning: You already have authentication configured via environment variable or API key helper.
|
||||
</Text>
|
||||
<Text color="warning">
|
||||
The setup-token command will create a new OAuth token which
|
||||
you can use instead.
|
||||
The setup-token command will create a new OAuth token which you can use instead.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ConsoleOAuthFlow
|
||||
onDone={() => {
|
||||
void resolve()
|
||||
void resolve();
|
||||
}}
|
||||
mode="setup-token"
|
||||
startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required."
|
||||
@@ -52,75 +48,63 @@ export async function setupTokenHandler(root: Root): Promise<void> {
|
||||
</Box>
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
)
|
||||
})
|
||||
root.unmount()
|
||||
process.exit(0)
|
||||
);
|
||||
});
|
||||
root.unmount();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// DoctorWithPlugins wrapper + doctor handler
|
||||
const DoctorLazy = React.lazy(() =>
|
||||
import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })),
|
||||
)
|
||||
const DoctorLazy = React.lazy(() => import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })));
|
||||
|
||||
function DoctorWithPlugins({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: () => void
|
||||
}): React.ReactNode {
|
||||
useManagePlugins()
|
||||
function DoctorWithPlugins({ onDone }: { onDone: () => void }): React.ReactNode {
|
||||
useManagePlugins();
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<DoctorLazy onDone={onDone} />
|
||||
</React.Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function doctorHandler(root: Root): Promise<void> {
|
||||
logEvent('tengu_doctor_command', {})
|
||||
logEvent('tengu_doctor_command', {});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<KeybindingSetup>
|
||||
<MCPConnectionManager
|
||||
dynamicMcpConfig={undefined}
|
||||
isStrictMcpConfig={false}
|
||||
>
|
||||
<MCPConnectionManager dynamicMcpConfig={undefined} isStrictMcpConfig={false}>
|
||||
<DoctorWithPlugins
|
||||
onDone={() => {
|
||||
void resolve()
|
||||
void resolve();
|
||||
}}
|
||||
/>
|
||||
</MCPConnectionManager>
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
)
|
||||
})
|
||||
root.unmount()
|
||||
process.exit(0)
|
||||
);
|
||||
});
|
||||
root.unmount();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// install handler
|
||||
export async function installHandler(
|
||||
target: string | undefined,
|
||||
options: { force?: boolean },
|
||||
): Promise<void> {
|
||||
const { setup } = await import('../../setup.js')
|
||||
await setup(cwd(), 'default', false, false, undefined, false)
|
||||
const { install } = await import('../../commands/install.js')
|
||||
export async function installHandler(target: string | undefined, options: { force?: boolean }): Promise<void> {
|
||||
const { setup } = await import('../../setup.js');
|
||||
await setup(cwd(), 'default', false, false, undefined, false);
|
||||
const { install } = await import('../../commands/install.js');
|
||||
await new Promise<void>(resolve => {
|
||||
const args: string[] = []
|
||||
if (target) args.push(target)
|
||||
if (options.force) args.push('--force')
|
||||
const args: string[] = [];
|
||||
if (target) args.push(target);
|
||||
if (options.force) args.push('--force');
|
||||
|
||||
void install.call(
|
||||
result => {
|
||||
void resolve()
|
||||
process.exit(result.includes('failed') ? 1 : 0)
|
||||
void resolve();
|
||||
process.exit(result.includes('failed') ? 1 : 0);
|
||||
},
|
||||
{},
|
||||
args,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,7 +267,9 @@ export class StructuredIO {
|
||||
getPendingPermissionRequests() {
|
||||
return Array.from(this.pendingRequests.values())
|
||||
.map(entry => entry.request)
|
||||
.filter(pr => (pr.request as { subtype?: string }).subtype === 'can_use_tool')
|
||||
.filter(
|
||||
pr => (pr.request as { subtype?: string }).subtype === 'can_use_tool',
|
||||
)
|
||||
}
|
||||
|
||||
setUnexpectedResponseCallback(
|
||||
@@ -285,7 +287,14 @@ export class StructuredIO {
|
||||
* callback is aborted via the signal — otherwise the callback hangs.
|
||||
*/
|
||||
injectControlResponse(response: SDKControlResponse): void {
|
||||
const responseInner = response.response as { request_id?: string; subtype?: string; error?: string; response?: unknown } | undefined
|
||||
const responseInner = response.response as
|
||||
| {
|
||||
request_id?: string
|
||||
subtype?: string
|
||||
error?: string
|
||||
response?: unknown
|
||||
}
|
||||
| undefined
|
||||
const requestId = responseInner?.request_id
|
||||
if (!requestId) return
|
||||
const request = this.pendingRequests.get(requestId as string)
|
||||
@@ -377,7 +386,12 @@ export class StructuredIO {
|
||||
if (uuid) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
}
|
||||
const resp = message.response as { request_id: string; subtype: string; response?: Record<string, unknown>; error?: string }
|
||||
const resp = message.response as {
|
||||
request_id: string
|
||||
subtype: string
|
||||
response?: Record<string, unknown>
|
||||
error?: string
|
||||
}
|
||||
const request = this.pendingRequests.get(resp.request_id)
|
||||
if (!request) {
|
||||
// Check if this tool_use was already resolved through the normal
|
||||
@@ -386,9 +400,7 @@ export class StructuredIO {
|
||||
// re-processing them would push duplicate assistant messages into
|
||||
// the conversation, causing API 400 errors.
|
||||
const responsePayload =
|
||||
resp.subtype === 'success'
|
||||
? resp.response
|
||||
: undefined
|
||||
resp.subtype === 'success' ? resp.response : undefined
|
||||
const toolUseID = responsePayload?.toolUseID
|
||||
if (
|
||||
typeof toolUseID === 'string' &&
|
||||
@@ -400,7 +412,9 @@ export class StructuredIO {
|
||||
return undefined
|
||||
}
|
||||
if (this.unexpectedResponseCallback) {
|
||||
await this.unexpectedResponseCallback(message as SDKControlResponse & { uuid?: string })
|
||||
await this.unexpectedResponseCallback(
|
||||
message as SDKControlResponse & { uuid?: string },
|
||||
)
|
||||
}
|
||||
return undefined // Ignore responses for requests we don't know about
|
||||
}
|
||||
@@ -409,7 +423,8 @@ export class StructuredIO {
|
||||
// Notify the bridge when the SDK consumer resolves a can_use_tool
|
||||
// request, so it can cancel the stale permission prompt on claude.ai.
|
||||
if (
|
||||
(request.request.request as { subtype?: string }).subtype === 'can_use_tool' &&
|
||||
(request.request.request as { subtype?: string }).subtype ===
|
||||
'can_use_tool' &&
|
||||
this.onControlRequestResolved
|
||||
) {
|
||||
this.onControlRequestResolved(resp.request_id)
|
||||
@@ -455,7 +470,9 @@ export class StructuredIO {
|
||||
if (message.type === 'assistant' || message.type === 'system') {
|
||||
return message
|
||||
}
|
||||
if ((message as { message?: { role?: string } }).message?.role !== 'user') {
|
||||
if (
|
||||
(message as { message?: { role?: string } }).message?.role !== 'user'
|
||||
) {
|
||||
exitWithMessage(
|
||||
`Error: Expected message role 'user', got '${(message as { message?: { role?: string } }).message?.role}'`,
|
||||
)
|
||||
@@ -490,7 +507,10 @@ export class StructuredIO {
|
||||
throw new Error('Request aborted')
|
||||
}
|
||||
this.outbound.enqueue(message)
|
||||
if ((request as { subtype?: string }).subtype === 'can_use_tool' && this.onControlRequestSent) {
|
||||
if (
|
||||
(request as { subtype?: string }).subtype === 'can_use_tool' &&
|
||||
this.onControlRequestSent
|
||||
) {
|
||||
this.onControlRequestSent(message)
|
||||
}
|
||||
const aborted = () => {
|
||||
@@ -820,7 +840,8 @@ async function executePermissionRequestHooksForSDK(
|
||||
const finalInput = decision.updatedInput || input
|
||||
|
||||
// Apply permission updates if provided by hook ("always allow")
|
||||
const permissionUpdates = (decision.updatedPermissions ?? []) as unknown as InternalPermissionUpdate[]
|
||||
const permissionUpdates = (decision.updatedPermissions ??
|
||||
[]) as unknown as InternalPermissionUpdate[]
|
||||
if (permissionUpdates.length > 0) {
|
||||
persistPermissionUpdates(permissionUpdates)
|
||||
const currentAppState = toolUseContext.getAppState()
|
||||
|
||||
@@ -244,7 +244,7 @@ export class HybridTransport extends WebSocketTransport {
|
||||
) {
|
||||
rcLog(
|
||||
`Hybrid POST ${response.status}: url=${this.postUrl.replace(/token=[^&]+/, 'token=***')}` +
|
||||
` events=${events.length} body=${JSON.stringify(response.data).slice(0, 200)}`,
|
||||
` events=${events.length} body=${JSON.stringify(response.data).slice(0, 200)}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`HybridTransport: POST returned ${response.status} (permanent), dropping`,
|
||||
|
||||
@@ -82,9 +82,7 @@ export function parseSSEFrames(buffer: string): {
|
||||
for (const rawLine of rawFrame.split('\n')) {
|
||||
// Normalize CRLF lines in mixed-line-ending streams.
|
||||
const line =
|
||||
rawLine[rawLine.length - 1] === '\r'
|
||||
? rawLine.slice(0, -1)
|
||||
: rawLine
|
||||
rawLine[rawLine.length - 1] === '\r' ? rawLine.slice(0, -1) : rawLine
|
||||
|
||||
if (line.startsWith(':')) {
|
||||
// SSE comment (e.g., `:keepalive`)
|
||||
@@ -482,9 +480,9 @@ export class SSETransport implements Transport {
|
||||
private handleConnectionError(): void {
|
||||
rcLog(
|
||||
`SSE handleConnectionError: state=${this.state}` +
|
||||
` lastSeqNum=${this.getLastSequenceNum()}` +
|
||||
` reconnectAttempts=${this.reconnectAttempts}` +
|
||||
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`,
|
||||
` lastSeqNum=${this.getLastSequenceNum()}` +
|
||||
` reconnectAttempts=${this.reconnectAttempts}` +
|
||||
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`,
|
||||
)
|
||||
this.clearLivenessTimer()
|
||||
|
||||
@@ -561,8 +559,8 @@ export class SSETransport implements Transport {
|
||||
this.livenessTimer = null
|
||||
rcLog(
|
||||
`SSE liveness timeout (${LIVENESS_TIMEOUT_MS}ms)` +
|
||||
` lastSeqNum=${this.getLastSequenceNum()}` +
|
||||
` state=${this.state}`,
|
||||
` lastSeqNum=${this.getLastSequenceNum()}` +
|
||||
` state=${this.state}`,
|
||||
)
|
||||
logForDebugging('SSETransport: Liveness timeout, reconnecting', {
|
||||
level: 'error',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Transport = any;
|
||||
export type Transport = any
|
||||
|
||||
@@ -398,10 +398,10 @@ export class WebSocketTransport implements Transport {
|
||||
private handleConnectionError(closeCode?: number): void {
|
||||
rcLog(
|
||||
`WS handleConnectionError: code=${closeCode}` +
|
||||
` state=${this.state}` +
|
||||
` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` +
|
||||
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` +
|
||||
` reconnectAttempts=${this.reconnectAttempts}`,
|
||||
` state=${this.state}` +
|
||||
` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` +
|
||||
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` +
|
||||
` reconnectAttempts=${this.reconnectAttempts}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`WebSocketTransport: Disconnected from ${this.url.href}` +
|
||||
|
||||
@@ -35,7 +35,8 @@ describe('parseSSEFrames', () => {
|
||||
})
|
||||
|
||||
test('keeps incomplete trailing frame in remaining buffer for CRLF streams', () => {
|
||||
const input = 'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n'
|
||||
const input =
|
||||
'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n'
|
||||
const { frames, remaining } = parseSSEFrames(input)
|
||||
|
||||
expect(frames).toEqual([
|
||||
|
||||
@@ -180,7 +180,10 @@ export function accumulateStreamEvents(
|
||||
chunks.push(delta.text as string)
|
||||
const existing = touched.get(chunks)
|
||||
if (existing) {
|
||||
;(existing.event as Record<string, unknown>).delta = { type: 'text_delta', text: chunks.join('') }
|
||||
;(existing.event as Record<string, unknown>).delta = {
|
||||
type: 'text_delta',
|
||||
text: chunks.join(''),
|
||||
}
|
||||
break
|
||||
}
|
||||
const snapshot: CoalescedStreamEvent = {
|
||||
@@ -430,7 +433,10 @@ export class CCRClient {
|
||||
'delivery batch',
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new RetryableError('delivery POST failed', (result as any).retryAfterMs)
|
||||
throw new RetryableError(
|
||||
'delivery POST failed',
|
||||
(result as any).retryAfterMs,
|
||||
)
|
||||
}
|
||||
},
|
||||
baseDelayMs: 500,
|
||||
@@ -749,7 +755,14 @@ export class CCRClient {
|
||||
}
|
||||
await this.flushStreamEventBuffer()
|
||||
if (message.type === 'assistant') {
|
||||
clearStreamAccumulatorForMessage(this.streamTextAccumulator, message as { session_id: string; parent_tool_use_id: string | null; message: { id: string } })
|
||||
clearStreamAccumulatorForMessage(
|
||||
this.streamTextAccumulator,
|
||||
message as {
|
||||
session_id: string
|
||||
parent_tool_use_id: string | null
|
||||
message: { id: string }
|
||||
},
|
||||
)
|
||||
}
|
||||
await this.eventUploader.enqueue(this.toClientEvent(message))
|
||||
}
|
||||
|
||||
@@ -74,10 +74,9 @@ const assistantCommand = feature('KAIROS')
|
||||
const bridge = feature('BRIDGE_MODE')
|
||||
? require('./commands/bridge/index.js').default
|
||||
: null
|
||||
const remoteControlServerCommand =
|
||||
feature('BRIDGE_MODE')
|
||||
? require('./commands/remoteControlServer/index.js').default
|
||||
: null
|
||||
const remoteControlServerCommand = feature('BRIDGE_MODE')
|
||||
? require('./commands/remoteControlServer/index.js').default
|
||||
: null
|
||||
const voiceCommand = feature('VOICE_MODE')
|
||||
? require('./commands/voice/index.js').default
|
||||
: null
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
import chalk from 'chalk'
|
||||
import figures from 'figures'
|
||||
import React, { useEffect } from 'react'
|
||||
import {
|
||||
getAdditionalDirectoriesForClaudeMd,
|
||||
setAdditionalDirectoriesForClaudeMd,
|
||||
} from '../../bootstrap/state.js'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { MessageResponse } from '../../components/MessageResponse.js'
|
||||
import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import {
|
||||
applyPermissionUpdate,
|
||||
persistPermissionUpdate,
|
||||
} from '../../utils/permissions/PermissionUpdate.js'
|
||||
import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
|
||||
import {
|
||||
addDirHelpMessage,
|
||||
validateDirectoryForWorkspace,
|
||||
} from './validation.js'
|
||||
import chalk from 'chalk';
|
||||
import figures from 'figures';
|
||||
import React, { useEffect } from 'react';
|
||||
import { getAdditionalDirectoriesForClaudeMd, setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { MessageResponse } from '../../components/MessageResponse.js';
|
||||
import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { applyPermissionUpdate, persistPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js';
|
||||
import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js';
|
||||
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js';
|
||||
import { addDirHelpMessage, validateDirectoryForWorkspace } from './validation.js';
|
||||
|
||||
function AddDirError({
|
||||
message,
|
||||
args,
|
||||
onDone,
|
||||
}: {
|
||||
message: string
|
||||
args: string
|
||||
onDone: () => void
|
||||
message: string;
|
||||
args: string;
|
||||
onDone: () => void;
|
||||
}): React.ReactNode {
|
||||
useEffect(() => {
|
||||
// We need to defer calling onDone to avoid the "return null" bug where
|
||||
// the component unmounts before React can render the error message.
|
||||
// Using setTimeout ensures the error displays before the command exits.
|
||||
const timer = setTimeout(onDone, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onDone])
|
||||
const timer = setTimeout(onDone, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDone]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -47,7 +38,7 @@ function AddDirError({
|
||||
<Text>{message}</Text>
|
||||
</MessageResponse>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -55,58 +46,53 @@ export async function call(
|
||||
context: LocalJSXCommandContext,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const directoryPath = (args ?? '').trim()
|
||||
const appState = context.getAppState()
|
||||
const directoryPath = (args ?? '').trim();
|
||||
const appState = context.getAppState();
|
||||
|
||||
// Helper to handle adding a directory (shared by both with-path and no-path cases)
|
||||
const handleAddDirectory = async (path: string, remember = false) => {
|
||||
const destination: PermissionUpdateDestination = remember
|
||||
? 'localSettings'
|
||||
: 'session'
|
||||
const destination: PermissionUpdateDestination = remember ? 'localSettings' : 'session';
|
||||
|
||||
const permissionUpdate = {
|
||||
type: 'addDirectories' as const,
|
||||
directories: [path],
|
||||
destination,
|
||||
}
|
||||
};
|
||||
|
||||
// Apply to session context
|
||||
const latestAppState = context.getAppState()
|
||||
const updatedContext = applyPermissionUpdate(
|
||||
latestAppState.toolPermissionContext,
|
||||
permissionUpdate,
|
||||
)
|
||||
const latestAppState = context.getAppState();
|
||||
const updatedContext = applyPermissionUpdate(latestAppState.toolPermissionContext, permissionUpdate);
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: updatedContext,
|
||||
}))
|
||||
}));
|
||||
|
||||
// Update sandbox config so Bash commands can access the new directory.
|
||||
// Bootstrap state is the source of truth for session-only dirs; persisted
|
||||
// dirs are picked up via the settings subscription, but we refresh
|
||||
// eagerly here to avoid a race when the user acts immediately.
|
||||
const currentDirs = getAdditionalDirectoriesForClaudeMd()
|
||||
const currentDirs = getAdditionalDirectoriesForClaudeMd();
|
||||
if (!currentDirs.includes(path)) {
|
||||
setAdditionalDirectoriesForClaudeMd([...currentDirs, path])
|
||||
setAdditionalDirectoriesForClaudeMd([...currentDirs, path]);
|
||||
}
|
||||
SandboxManager.refreshConfig()
|
||||
SandboxManager.refreshConfig();
|
||||
|
||||
let message: string
|
||||
let message: string;
|
||||
|
||||
if (remember) {
|
||||
try {
|
||||
persistPermissionUpdate(permissionUpdate)
|
||||
message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`
|
||||
persistPermissionUpdate(permissionUpdate);
|
||||
message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`;
|
||||
} catch (error) {
|
||||
message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
} else {
|
||||
message = `Added ${chalk.bold(path)} as a working directory for this session`
|
||||
message = `Added ${chalk.bold(path)} as a working directory for this session`;
|
||||
}
|
||||
|
||||
const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`
|
||||
onDone(messageWithHint)
|
||||
}
|
||||
const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`;
|
||||
onDone(messageWithHint);
|
||||
};
|
||||
|
||||
// When no path is provided, show AddWorkspaceDirectory input form directly
|
||||
// and return to REPL after confirmation
|
||||
@@ -116,27 +102,18 @@ export async function call(
|
||||
permissionContext={appState.toolPermissionContext}
|
||||
onAddDirectory={handleAddDirectory}
|
||||
onCancel={() => {
|
||||
onDone('Did not add a working directory.')
|
||||
onDone('Did not add a working directory.');
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await validateDirectoryForWorkspace(
|
||||
directoryPath,
|
||||
appState.toolPermissionContext,
|
||||
)
|
||||
const result = await validateDirectoryForWorkspace(directoryPath, appState.toolPermissionContext);
|
||||
|
||||
if (result.resultType !== 'success') {
|
||||
const message = addDirHelpMessage(result)
|
||||
const message = addDirHelpMessage(result);
|
||||
|
||||
return (
|
||||
<AddDirError
|
||||
message={message}
|
||||
args={args ?? ''}
|
||||
onDone={() => onDone(message)}
|
||||
/>
|
||||
)
|
||||
return <AddDirError message={message} args={args ?? ''} onDone={() => onDone(message)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -145,10 +122,8 @@ export async function call(
|
||||
permissionContext={appState.toolPermissionContext}
|
||||
onAddDirectory={handleAddDirectory}
|
||||
onCancel={() => {
|
||||
onDone(
|
||||
`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`,
|
||||
)
|
||||
onDone(`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export default { name: 'agents-platform', type: 'local', isEnabled: () => false }
|
||||
export default {
|
||||
name: 'agents-platform',
|
||||
type: 'local',
|
||||
isEnabled: () => false,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import * as React from 'react'
|
||||
import { AgentsMenu } from '../../components/agents/AgentsMenu.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import { getTools } from '../../tools.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { AgentsMenu } from '../../components/agents/AgentsMenu.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: ToolUseContext,
|
||||
): Promise<React.ReactNode> {
|
||||
const appState = context.getAppState()
|
||||
const permissionContext = appState.toolPermissionContext
|
||||
const tools = getTools(permissionContext)
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise<React.ReactNode> {
|
||||
const appState = context.getAppState();
|
||||
const permissionContext = appState.toolPermissionContext;
|
||||
const tools = getTools(permissionContext);
|
||||
|
||||
return <AgentsMenu tools={tools} onExit={onDone} />
|
||||
return <AgentsMenu tools={tools} onExit={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -44,8 +44,10 @@ export function deriveFirstPrompt(
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content.find(
|
||||
(block: { type: string; text?: string }): block is { type: 'text'; text: string } =>
|
||||
block.type === 'text',
|
||||
(block: {
|
||||
type: string
|
||||
text?: string
|
||||
}): block is { type: 'text'; text: string } => block.type === 'text',
|
||||
)?.text
|
||||
if (!raw) return 'Branched conversation'
|
||||
return (
|
||||
@@ -240,7 +242,9 @@ export async function call(
|
||||
// Build LogOption for resume
|
||||
const now = new Date()
|
||||
const firstPrompt = deriveFirstPrompt(
|
||||
serializedMessages.find(m => m.type === 'user') as Extract<SerializedMessage, { type: 'user' }> | undefined,
|
||||
serializedMessages.find(m => m.type === 'user') as
|
||||
| Extract<SerializedMessage, { type: 'user' }>
|
||||
| undefined,
|
||||
)
|
||||
|
||||
// Save custom title - use provided title or firstPrompt as default
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { toString as qrToString } from 'qrcode'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js'
|
||||
import {
|
||||
checkBridgeMinVersion,
|
||||
getBridgeDisabledReason,
|
||||
isEnvLessBridgeEnabled,
|
||||
} from '../../bridge/bridgeEnabled.js'
|
||||
import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js'
|
||||
import {
|
||||
BRIDGE_LOGIN_INSTRUCTION,
|
||||
REMOTE_CONTROL_DISCONNECTED_MSG,
|
||||
} from '../../bridge/types.js'
|
||||
import { Dialog, ListItem } from '@anthropic/ink'
|
||||
import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js'
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import { toString as qrToString } from 'qrcode';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js';
|
||||
import { checkBridgeMinVersion, getBridgeDisabledReason, isEnvLessBridgeEnabled } from '../../bridge/bridgeEnabled.js';
|
||||
import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js';
|
||||
import { BRIDGE_LOGIN_INSTRUCTION, REMOTE_CONTROL_DISCONNECTED_MSG } from '../../bridge/types.js';
|
||||
import { Dialog, ListItem } from '@anthropic/ink';
|
||||
import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js';
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { logForDebugging } from '../../utils/debug.js';
|
||||
|
||||
type Props = {
|
||||
onDone: LocalJSXCommandOnDone
|
||||
name?: string
|
||||
}
|
||||
onDone: LocalJSXCommandOnDone;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* /remote-control command — manages the bidirectional bridge connection.
|
||||
@@ -48,34 +38,33 @@ type Props = {
|
||||
* URL and options to disconnect or continue.
|
||||
*/
|
||||
function BridgeToggle({ onDone, name }: Props): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
const replBridgeConnected = useAppState(s => s.replBridgeConnected)
|
||||
const replBridgeEnabled = useAppState(s => s.replBridgeEnabled)
|
||||
const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly)
|
||||
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false)
|
||||
const setAppState = useSetAppState();
|
||||
const replBridgeConnected = useAppState(s => s.replBridgeConnected);
|
||||
const replBridgeEnabled = useAppState(s => s.replBridgeEnabled);
|
||||
const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly);
|
||||
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If already connected or enabled in full bidirectional mode, show
|
||||
// disconnect confirmation. Outbound-only (CCR mirror) doesn't count —
|
||||
// /remote-control upgrades it to full RC instead.
|
||||
if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) {
|
||||
setShowDisconnectDialog(true)
|
||||
return
|
||||
setShowDisconnectDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
// Pre-flight checks before enabling (awaits GrowthBook init if disk
|
||||
// cache is stale — so Max users don't get a false "not enabled" error)
|
||||
const error = await checkBridgePrerequisites()
|
||||
if (cancelled) return
|
||||
const error = await checkBridgePrerequisites();
|
||||
if (cancelled) return;
|
||||
if (error) {
|
||||
logEvent('tengu_bridge_command', {
|
||||
action:
|
||||
'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
onDone(error, { display: 'system' })
|
||||
return
|
||||
action: 'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(error, { display: 'system' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Show first-time remote dialog if not yet seen.
|
||||
@@ -83,48 +72,47 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode {
|
||||
// enables the bridge (the handler only sets replBridgeEnabled, not the name).
|
||||
if (shouldShowRemoteCallout()) {
|
||||
setAppState(prev => {
|
||||
if (prev.showRemoteCallout) return prev
|
||||
if (prev.showRemoteCallout) return prev;
|
||||
return {
|
||||
...prev,
|
||||
showRemoteCallout: true,
|
||||
replBridgeInitialName: name,
|
||||
}
|
||||
})
|
||||
onDone('', { display: 'system' })
|
||||
return
|
||||
};
|
||||
});
|
||||
onDone('', { display: 'system' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable the bridge — useReplBridge in REPL.tsx handles the rest:
|
||||
// registers environment, creates session with conversation, connects WebSocket
|
||||
logEvent('tengu_bridge_command', {
|
||||
action:
|
||||
'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
action: 'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setAppState(prev => {
|
||||
if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev
|
||||
if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev;
|
||||
return {
|
||||
...prev,
|
||||
replBridgeEnabled: true,
|
||||
replBridgeExplicit: true,
|
||||
replBridgeOutboundOnly: false,
|
||||
replBridgeInitialName: name,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
onDone('Remote Control connecting\u2026', {
|
||||
display: 'system',
|
||||
})
|
||||
})()
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount
|
||||
cancelled = true;
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount
|
||||
|
||||
if (showDisconnectDialog) {
|
||||
return <BridgeDisconnectDialog onDone={onDone} />
|
||||
return <BridgeDisconnectDialog onDone={onDone} />;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,22 +120,22 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode {
|
||||
* Shows the session URL and lets the user disconnect or continue.
|
||||
*/
|
||||
function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
useRegisterOverlay('bridge-disconnect-dialog')
|
||||
const setAppState = useSetAppState()
|
||||
const sessionUrl = useAppState(s => s.replBridgeSessionUrl)
|
||||
const connectUrl = useAppState(s => s.replBridgeConnectUrl)
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive)
|
||||
const [focusIndex, setFocusIndex] = useState(2)
|
||||
const [showQR, setShowQR] = useState(false)
|
||||
const [qrText, setQrText] = useState('')
|
||||
useRegisterOverlay('bridge-disconnect-dialog');
|
||||
const setAppState = useSetAppState();
|
||||
const sessionUrl = useAppState(s => s.replBridgeSessionUrl);
|
||||
const connectUrl = useAppState(s => s.replBridgeConnectUrl);
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive);
|
||||
const [focusIndex, setFocusIndex] = useState(2);
|
||||
const [showQR, setShowQR] = useState(false);
|
||||
const [qrText, setQrText] = useState('');
|
||||
|
||||
const displayUrl = sessionActive ? sessionUrl : connectUrl
|
||||
const displayUrl = sessionActive ? sessionUrl : connectUrl;
|
||||
|
||||
// Generate QR code when URL changes or QR is toggled on
|
||||
useEffect(() => {
|
||||
if (!showQR || !displayUrl) {
|
||||
setQrText('')
|
||||
return
|
||||
setQrText('');
|
||||
return;
|
||||
}
|
||||
qrToString(displayUrl, {
|
||||
type: 'utf8',
|
||||
@@ -155,55 +143,53 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
small: true,
|
||||
} as Parameters<typeof qrToString>[1])
|
||||
.then(setQrText)
|
||||
.catch(() => setQrText(''))
|
||||
}, [showQR, displayUrl])
|
||||
.catch(() => setQrText(''));
|
||||
}, [showQR, displayUrl]);
|
||||
|
||||
function handleDisconnect(): void {
|
||||
setAppState(prev => {
|
||||
if (!prev.replBridgeEnabled) return prev
|
||||
if (!prev.replBridgeEnabled) return prev;
|
||||
return {
|
||||
...prev,
|
||||
replBridgeEnabled: false,
|
||||
replBridgeExplicit: false,
|
||||
replBridgeOutboundOnly: false,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
logEvent('tengu_bridge_command', {
|
||||
action:
|
||||
'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' })
|
||||
action: 'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' });
|
||||
}
|
||||
|
||||
function handleShowQR(): void {
|
||||
setShowQR(prev => !prev)
|
||||
setShowQR(prev => !prev);
|
||||
}
|
||||
|
||||
function handleContinue(): void {
|
||||
onDone(undefined, { display: 'skip' })
|
||||
onDone(undefined, { display: 'skip' });
|
||||
}
|
||||
|
||||
const ITEM_COUNT = 3
|
||||
const ITEM_COUNT = 3;
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'select:next': () => setFocusIndex(i => (i + 1) % ITEM_COUNT),
|
||||
'select:previous': () =>
|
||||
setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT),
|
||||
'select:previous': () => setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT),
|
||||
'select:accept': () => {
|
||||
if (focusIndex === 0) {
|
||||
handleDisconnect()
|
||||
handleDisconnect();
|
||||
} else if (focusIndex === 1) {
|
||||
handleShowQR()
|
||||
handleShowQR();
|
||||
} else {
|
||||
handleContinue()
|
||||
handleContinue();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ context: 'Select' },
|
||||
)
|
||||
);
|
||||
|
||||
const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : []
|
||||
const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [];
|
||||
|
||||
return (
|
||||
<Dialog title="Remote Control" onCancel={handleContinue} hideInputGuide>
|
||||
@@ -233,7 +219,7 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
<Text dimColor>Enter to select · Esc to continue</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,43 +230,39 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
|
||||
*/
|
||||
async function checkBridgePrerequisites(): Promise<string | null> {
|
||||
// Check organization policy — remote control may be disabled
|
||||
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import(
|
||||
'../../services/policyLimits/index.js'
|
||||
)
|
||||
await waitForPolicyLimitsToLoad()
|
||||
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../../services/policyLimits/index.js');
|
||||
await waitForPolicyLimitsToLoad();
|
||||
if (!isPolicyAllowed('allow_remote_control')) {
|
||||
return "Remote Control is disabled by your organization's policy."
|
||||
return "Remote Control is disabled by your organization's policy.";
|
||||
}
|
||||
|
||||
const disabledReason = await getBridgeDisabledReason()
|
||||
const disabledReason = await getBridgeDisabledReason();
|
||||
if (disabledReason) {
|
||||
return disabledReason
|
||||
return disabledReason;
|
||||
}
|
||||
|
||||
// Mirror the v1/v2 branching logic in initReplBridge: env-less (v2) is used
|
||||
// only when the flag is on AND the session is not perpetual. In assistant
|
||||
// mode (KAIROS) useReplBridge sets perpetual=true, which forces
|
||||
// initReplBridge onto the v1 path — so the prerequisite check must match.
|
||||
let useV2 = isEnvLessBridgeEnabled()
|
||||
let useV2 = isEnvLessBridgeEnabled();
|
||||
if (feature('KAIROS') && useV2) {
|
||||
const { isAssistantMode } = await import('../../assistant/index.js')
|
||||
const { isAssistantMode } = await import('../../assistant/index.js');
|
||||
if (isAssistantMode()) {
|
||||
useV2 = false
|
||||
useV2 = false;
|
||||
}
|
||||
}
|
||||
const versionError = useV2
|
||||
? await checkEnvLessBridgeMinVersion()
|
||||
: checkBridgeMinVersion()
|
||||
const versionError = useV2 ? await checkEnvLessBridgeMinVersion() : checkBridgeMinVersion();
|
||||
if (versionError) {
|
||||
return versionError
|
||||
return versionError;
|
||||
}
|
||||
|
||||
if (!getBridgeAccessToken()) {
|
||||
return BRIDGE_LOGIN_INSTRUCTION
|
||||
return BRIDGE_LOGIN_INSTRUCTION;
|
||||
}
|
||||
|
||||
logForDebugging('[bridge] Prerequisites passed, enabling bridge')
|
||||
return null
|
||||
logForDebugging('[bridge] Prerequisites passed, enabling bridge');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -288,6 +270,6 @@ export async function call(
|
||||
_context: ToolUseContext & LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const name = args.trim() || undefined
|
||||
return <BridgeToggle onDone={onDone} name={name} />
|
||||
const name = args.trim() || undefined;
|
||||
return <BridgeToggle onDone={onDone} name={name} />;
|
||||
}
|
||||
|
||||
@@ -1,118 +1,96 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useInterval } from 'usehooks-ts'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import { Markdown } from '../../components/Markdown.js'
|
||||
import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js'
|
||||
import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js'
|
||||
import { getSystemPrompt } from '../../constants/prompts.js'
|
||||
import { useModalOrTerminalSize } from '../../context/modalContext.js'
|
||||
import { getSystemContext, getUserContext } from '../../context.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { type KeyboardEvent, type ScrollBoxHandle, ScrollBox } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { createAbortController } from '../../utils/abortController.js'
|
||||
import { saveGlobalConfig } from '../../utils/config.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import {
|
||||
type CacheSafeParams,
|
||||
getLastCacheSafeParams,
|
||||
} from '../../utils/forkedAgent.js'
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
|
||||
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
|
||||
import { runSideQuestion } from '../../utils/sideQuestion.js'
|
||||
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useInterval } from 'usehooks-ts';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { Markdown } from '../../components/Markdown.js';
|
||||
import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js';
|
||||
import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js';
|
||||
import { getSystemPrompt } from '../../constants/prompts.js';
|
||||
import { useModalOrTerminalSize } from '../../context/modalContext.js';
|
||||
import { getSystemContext, getUserContext } from '../../context.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { type KeyboardEvent, type ScrollBoxHandle, ScrollBox } from '@anthropic/ink';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { createAbortController } from '../../utils/abortController.js';
|
||||
import { saveGlobalConfig } from '../../utils/config.js';
|
||||
import { errorMessage } from '../../utils/errors.js';
|
||||
import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js';
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
|
||||
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js';
|
||||
import { runSideQuestion } from '../../utils/sideQuestion.js';
|
||||
import { asSystemPrompt } from '../../utils/systemPromptType.js';
|
||||
|
||||
type BtwComponentProps = {
|
||||
question: string
|
||||
context: ProcessUserInputContext
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
question: string;
|
||||
context: ProcessUserInputContext;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
const CHROME_ROWS = 5
|
||||
const OUTER_CHROME_ROWS = 6
|
||||
const SCROLL_LINES = 3
|
||||
const CHROME_ROWS = 5;
|
||||
const OUTER_CHROME_ROWS = 6;
|
||||
const SCROLL_LINES = 3;
|
||||
|
||||
function BtwSideQuestion({
|
||||
question,
|
||||
context,
|
||||
onDone,
|
||||
}: BtwComponentProps): React.ReactNode {
|
||||
const [response, setResponse] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [frame, setFrame] = useState(0)
|
||||
const scrollRef = useRef<ScrollBoxHandle>(null)
|
||||
const { rows } = useModalOrTerminalSize(useTerminalSize())
|
||||
function BtwSideQuestion({ question, context, onDone }: BtwComponentProps): React.ReactNode {
|
||||
const [response, setResponse] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [frame, setFrame] = useState(0);
|
||||
const scrollRef = useRef<ScrollBoxHandle>(null);
|
||||
const { rows } = useModalOrTerminalSize(useTerminalSize());
|
||||
|
||||
// Animate spinner while loading
|
||||
useInterval(() => setFrame(f => f + 1), response || error ? null : 80)
|
||||
useInterval(() => setFrame(f => f + 1), response || error ? null : 80);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (
|
||||
e.key === 'escape' ||
|
||||
e.key === 'return' ||
|
||||
e.key === ' ' ||
|
||||
(e.ctrl && (e.key === 'c' || e.key === 'd'))
|
||||
) {
|
||||
e.preventDefault()
|
||||
onDone(undefined, { display: 'skip' })
|
||||
return
|
||||
if (e.key === 'escape' || e.key === 'return' || e.key === ' ' || (e.ctrl && (e.key === 'c' || e.key === 'd'))) {
|
||||
e.preventDefault();
|
||||
onDone(undefined, { display: 'skip' });
|
||||
return;
|
||||
}
|
||||
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
||||
e.preventDefault()
|
||||
scrollRef.current?.scrollBy(-SCROLL_LINES)
|
||||
e.preventDefault();
|
||||
scrollRef.current?.scrollBy(-SCROLL_LINES);
|
||||
}
|
||||
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
||||
e.preventDefault()
|
||||
scrollRef.current?.scrollBy(SCROLL_LINES)
|
||||
e.preventDefault();
|
||||
scrollRef.current?.scrollBy(SCROLL_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = createAbortController()
|
||||
const abortController = createAbortController();
|
||||
|
||||
async function fetchResponse(): Promise<void> {
|
||||
try {
|
||||
const cacheSafeParams = await buildCacheSafeParams(context)
|
||||
const result = await runSideQuestion({ question, cacheSafeParams })
|
||||
const cacheSafeParams = await buildCacheSafeParams(context);
|
||||
const result = await runSideQuestion({ question, cacheSafeParams });
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
if (result.response) {
|
||||
setResponse(result.response)
|
||||
setResponse(result.response);
|
||||
} else {
|
||||
setError('No response received')
|
||||
setError('No response received');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!abortController.signal.aborted) {
|
||||
setError(errorMessage(err) || 'Failed to get response')
|
||||
setError(errorMessage(err) || 'Failed to get response');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void fetchResponse()
|
||||
void fetchResponse();
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [question, context])
|
||||
abortController.abort();
|
||||
};
|
||||
}, [question, context]);
|
||||
|
||||
const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS)
|
||||
const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
<Box>
|
||||
<Text color="warning" bold>
|
||||
/btw{' '}
|
||||
@@ -136,13 +114,12 @@ function BtwSideQuestion({
|
||||
{(response || error) && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to
|
||||
dismiss
|
||||
{UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to dismiss
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,20 +138,16 @@ function BtwSideQuestion({
|
||||
* --append-system-prompt, coordinator mode).
|
||||
*/
|
||||
function stripInProgressAssistantMessage(messages: Message[]): Message[] {
|
||||
const last = messages.at(-1)
|
||||
const last = messages.at(-1);
|
||||
if (last?.type === 'assistant' && last.message!.stop_reason === null) {
|
||||
return messages.slice(0, -1)
|
||||
return messages.slice(0, -1);
|
||||
}
|
||||
return messages
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function buildCacheSafeParams(
|
||||
context: ProcessUserInputContext,
|
||||
): Promise<CacheSafeParams> {
|
||||
const forkContextMessages = getMessagesAfterCompactBoundary(
|
||||
stripInProgressAssistantMessage(context.messages),
|
||||
)
|
||||
const saved = getLastCacheSafeParams()
|
||||
async function buildCacheSafeParams(context: ProcessUserInputContext): Promise<CacheSafeParams> {
|
||||
const forkContextMessages = getMessagesAfterCompactBoundary(stripInProgressAssistantMessage(context.messages));
|
||||
const saved = getLastCacheSafeParams();
|
||||
if (saved) {
|
||||
return {
|
||||
systemPrompt: saved.systemPrompt,
|
||||
@@ -182,25 +155,20 @@ async function buildCacheSafeParams(
|
||||
systemContext: saved.systemContext,
|
||||
toolUseContext: context,
|
||||
forkContextMessages,
|
||||
}
|
||||
};
|
||||
}
|
||||
const [rawSystemPrompt, userContext, systemContext] = await Promise.all([
|
||||
getSystemPrompt(
|
||||
context.options.tools,
|
||||
context.options.mainLoopModel,
|
||||
[],
|
||||
context.options.mcpClients,
|
||||
),
|
||||
getSystemPrompt(context.options.tools, context.options.mainLoopModel, [], context.options.mcpClients),
|
||||
getUserContext(),
|
||||
getSystemContext(),
|
||||
])
|
||||
]);
|
||||
return {
|
||||
systemPrompt: asSystemPrompt(rawSystemPrompt),
|
||||
userContext,
|
||||
systemContext,
|
||||
toolUseContext: context,
|
||||
forkContextMessages,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -208,19 +176,17 @@ export async function call(
|
||||
context: ProcessUserInputContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const question = args?.trim()
|
||||
const question = args?.trim();
|
||||
|
||||
if (!question) {
|
||||
onDone('Usage: /btw <your question>', { display: 'system' })
|
||||
return null
|
||||
onDone('Usage: /btw <your question>', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
btwUseCount: current.btwUseCount + 1,
|
||||
}))
|
||||
}));
|
||||
|
||||
return (
|
||||
<BtwSideQuestion question={question} context={context} onDone={onDone} />
|
||||
)
|
||||
return <BtwSideQuestion question={question} context={context} onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ export async function call(
|
||||
return React.createElement(CompanionCard, {
|
||||
companion,
|
||||
lastReaction,
|
||||
onDone: onDone as unknown as Parameters<typeof CompanionCard>[0]['onDone'],
|
||||
onDone: onDone as unknown as Parameters<
|
||||
typeof CompanionCard
|
||||
>[0]['onDone'],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
type OptionWithDescription,
|
||||
Select,
|
||||
} from '../../components/CustomSelect/select.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import { isClaudeAISubscriber } from '../../utils/auth.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import {
|
||||
CLAUDE_IN_CHROME_MCP_SERVER_NAME,
|
||||
openInChrome,
|
||||
} from '../../utils/claudeInChrome/common.js'
|
||||
import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { env } from '../../utils/env.js'
|
||||
import { isRunningOnHomespace } from '../../utils/envUtils.js'
|
||||
import React, { useState } from 'react';
|
||||
import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import { isClaudeAISubscriber } from '../../utils/auth.js';
|
||||
import { openBrowser } from '../../utils/browser.js';
|
||||
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, openInChrome } from '../../utils/claudeInChrome/common.js';
|
||||
import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { env } from '../../utils/env.js';
|
||||
import { isRunningOnHomespace } from '../../utils/envUtils.js';
|
||||
|
||||
const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'
|
||||
const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'
|
||||
const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect'
|
||||
const CHROME_EXTENSION_URL = 'https://claude.ai/chrome';
|
||||
const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions';
|
||||
const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect';
|
||||
|
||||
type MenuAction =
|
||||
| 'install-extension'
|
||||
| 'reconnect'
|
||||
| 'manage-permissions'
|
||||
| 'toggle-default'
|
||||
type MenuAction = 'install-extension' | 'reconnect' | 'manage-permissions' | 'toggle-default';
|
||||
|
||||
type Props = {
|
||||
onDone: (result?: string) => void
|
||||
isExtensionInstalled: boolean
|
||||
configEnabled: boolean | undefined
|
||||
isClaudeAISubscriber: boolean
|
||||
isWSL: boolean
|
||||
}
|
||||
onDone: (result?: string) => void;
|
||||
isExtensionInstalled: boolean;
|
||||
configEnabled: boolean | undefined;
|
||||
isClaudeAISubscriber: boolean;
|
||||
isWSL: boolean;
|
||||
};
|
||||
|
||||
function ClaudeInChromeMenu({
|
||||
onDone,
|
||||
@@ -42,72 +32,66 @@ function ClaudeInChromeMenu({
|
||||
isClaudeAISubscriber,
|
||||
isWSL,
|
||||
}: Props): React.ReactNode {
|
||||
const mcpClients = useAppState(s => s.mcp.clients)
|
||||
const [selectKey, setSelectKey] = useState(0)
|
||||
const [enabledByDefault, setEnabledByDefault] = useState(
|
||||
configEnabled ?? false,
|
||||
)
|
||||
const [showInstallHint, setShowInstallHint] = useState(false)
|
||||
const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed)
|
||||
const mcpClients = useAppState(s => s.mcp.clients);
|
||||
const [selectKey, setSelectKey] = useState(0);
|
||||
const [enabledByDefault, setEnabledByDefault] = useState(configEnabled ?? false);
|
||||
const [showInstallHint, setShowInstallHint] = useState(false);
|
||||
const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed);
|
||||
|
||||
const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace()
|
||||
const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace();
|
||||
|
||||
const chromeClient = mcpClients.find(
|
||||
c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME,
|
||||
)
|
||||
const isConnected = chromeClient?.type === 'connected'
|
||||
const chromeClient = mcpClients.find(c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME);
|
||||
const isConnected = chromeClient?.type === 'connected';
|
||||
|
||||
function openUrl(url: string): void {
|
||||
if (isHomespace) {
|
||||
void openBrowser(url)
|
||||
void openBrowser(url);
|
||||
} else {
|
||||
void openInChrome(url)
|
||||
void openInChrome(url);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAction(action: MenuAction): void {
|
||||
switch (action) {
|
||||
case 'install-extension':
|
||||
setSelectKey(k => k + 1)
|
||||
setShowInstallHint(true)
|
||||
openUrl(CHROME_EXTENSION_URL)
|
||||
break
|
||||
setSelectKey(k => k + 1);
|
||||
setShowInstallHint(true);
|
||||
openUrl(CHROME_EXTENSION_URL);
|
||||
break;
|
||||
case 'reconnect':
|
||||
setSelectKey(k => k + 1)
|
||||
setSelectKey(k => k + 1);
|
||||
void isChromeExtensionInstalled().then(installed => {
|
||||
setIsExtensionInstalled(installed)
|
||||
setIsExtensionInstalled(installed);
|
||||
if (installed) {
|
||||
setShowInstallHint(false)
|
||||
setShowInstallHint(false);
|
||||
}
|
||||
})
|
||||
openUrl(CHROME_RECONNECT_URL)
|
||||
break
|
||||
});
|
||||
openUrl(CHROME_RECONNECT_URL);
|
||||
break;
|
||||
case 'manage-permissions':
|
||||
setSelectKey(k => k + 1)
|
||||
openUrl(CHROME_PERMISSIONS_URL)
|
||||
break
|
||||
setSelectKey(k => k + 1);
|
||||
openUrl(CHROME_PERMISSIONS_URL);
|
||||
break;
|
||||
case 'toggle-default': {
|
||||
const newValue = !enabledByDefault
|
||||
const newValue = !enabledByDefault;
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
claudeInChromeDefaultEnabled: newValue,
|
||||
}))
|
||||
setEnabledByDefault(newValue)
|
||||
break
|
||||
}));
|
||||
setEnabledByDefault(newValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options: OptionWithDescription<MenuAction>[] = []
|
||||
const requiresExtensionSuffix = isExtensionInstalled
|
||||
? ''
|
||||
: ' (requires extension)'
|
||||
const options: OptionWithDescription<MenuAction>[] = [];
|
||||
const requiresExtensionSuffix = isExtensionInstalled ? '' : ' (requires extension)';
|
||||
|
||||
if (!isExtensionInstalled && !isHomespace) {
|
||||
options.push({
|
||||
label: 'Install Chrome extension',
|
||||
value: 'install-extension',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
options.push(
|
||||
@@ -133,36 +117,23 @@ function ClaudeInChromeMenu({
|
||||
label: `Enabled by default: ${enabledByDefault ? 'Yes' : 'No'}`,
|
||||
value: 'toggle-default',
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
const isDisabled =
|
||||
isWSL || ((process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber)
|
||||
const isDisabled = isWSL || ((process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Claude in Chrome (Beta)"
|
||||
onCancel={() => onDone()}
|
||||
color="chromeYellow"
|
||||
>
|
||||
<Dialog title="Claude in Chrome (Beta)" onCancel={() => onDone()} color="chromeYellow">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
Claude in Chrome works with the Chrome extension to let you control
|
||||
your browser directly from Claude Code. Navigate websites, fill forms,
|
||||
capture screenshots, record GIFs, and debug with console logs and
|
||||
network requests.
|
||||
Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code.
|
||||
Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network
|
||||
requests.
|
||||
</Text>
|
||||
|
||||
{isWSL && (
|
||||
<Text color="error">
|
||||
Claude in Chrome is not supported in WSL at this time.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isWSL && <Text color="error">Claude in Chrome is not supported in WSL at this time.</Text>}
|
||||
|
||||
{(process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber && (
|
||||
<Text color="error">
|
||||
Claude in Chrome requires a claude.ai subscription.
|
||||
</Text>
|
||||
<Text color="error">Claude in Chrome requires a claude.ai subscription.</Text>
|
||||
)}
|
||||
|
||||
{!isDisabled && (
|
||||
@@ -170,12 +141,7 @@ function ClaudeInChromeMenu({
|
||||
{!isHomespace && (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
Status:{' '}
|
||||
{isConnected ? (
|
||||
<Text color="success">Enabled</Text>
|
||||
) : (
|
||||
<Text color="inactive">Disabled</Text>
|
||||
)}
|
||||
Status: {isConnected ? <Text color="success">Enabled</Text> : <Text color="inactive">Disabled</Text>}
|
||||
</Text>
|
||||
<Text>
|
||||
Extension:{' '}
|
||||
@@ -187,17 +153,10 @@ function ClaudeInChromeMenu({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Select
|
||||
key={selectKey}
|
||||
options={options}
|
||||
onChange={handleAction}
|
||||
hideIndexes
|
||||
/>
|
||||
<Select key={selectKey} options={options} onChange={handleAction} hideIndexes />
|
||||
|
||||
{showInstallHint && (
|
||||
<Text color="warning">
|
||||
Once installed, select {'"Reconnect extension"'} to connect.
|
||||
</Text>
|
||||
<Text color="warning">Once installed, select {'"Reconnect extension"'} to connect.</Text>
|
||||
)}
|
||||
|
||||
<Text>
|
||||
@@ -208,25 +167,22 @@ function ClaudeInChromeMenu({
|
||||
</Text>
|
||||
|
||||
<Text dimColor>
|
||||
Site-level permissions are inherited from the Chrome extension.
|
||||
Manage permissions in the Chrome extension settings to control
|
||||
which sites Claude can browse, click, and type on.
|
||||
Site-level permissions are inherited from the Chrome extension. Manage permissions in the Chrome extension
|
||||
settings to control which sites Claude can browse, click, and type on.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text dimColor>Learn more: https://code.claude.com/docs/en/chrome</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const call = async function (
|
||||
onDone: (result?: string) => void,
|
||||
): Promise<React.ReactNode> {
|
||||
const isExtensionInstalled = await isChromeExtensionInstalled()
|
||||
const config = getGlobalConfig()
|
||||
const isSubscriber = isClaudeAISubscriber()
|
||||
const isWSL = env.isWslEnvironment()
|
||||
export const call = async function (onDone: (result?: string) => void): Promise<React.ReactNode> {
|
||||
const isExtensionInstalled = await isChromeExtensionInstalled();
|
||||
const config = getGlobalConfig();
|
||||
const isSubscriber = isClaudeAISubscriber();
|
||||
const isWSL = env.isWslEnvironment();
|
||||
|
||||
return (
|
||||
<ClaudeInChromeMenu
|
||||
@@ -236,5 +192,5 @@ export const call = async function (
|
||||
isClaudeAISubscriber={isSubscriber}
|
||||
isWSL={isWSL}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -93,12 +93,12 @@ export function clearSessionCaches(
|
||||
|
||||
// Clear tungsten session usage tracking
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
void import('@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js').then(
|
||||
({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
|
||||
clearSessionsWithTungstenUsage()
|
||||
resetInitializationState()
|
||||
},
|
||||
)
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js'
|
||||
).then(({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
|
||||
clearSessionsWithTungstenUsage()
|
||||
resetInitializationState()
|
||||
})
|
||||
}
|
||||
// Clear attribution caches (file content cache, pending bash states)
|
||||
// Dynamic import to preserve dead code elimination for COMMIT_ATTRIBUTION feature flag
|
||||
@@ -126,19 +126,21 @@ export function clearSessionCaches(
|
||||
// Clear session environment variables
|
||||
clearSessionEnvVars()
|
||||
// Clear WebFetch URL cache (up to 50MB of cached page content)
|
||||
void import('@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js').then(
|
||||
({ clearWebFetchCache }) => clearWebFetchCache(),
|
||||
)
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js'
|
||||
).then(({ clearWebFetchCache }) => clearWebFetchCache())
|
||||
// Clear ToolSearch description cache (full tool prompts, ~500KB for 50 MCP tools)
|
||||
void import('@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js').then(
|
||||
({ clearToolSearchDescriptionCache }) => clearToolSearchDescriptionCache(),
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js'
|
||||
).then(({ clearToolSearchDescriptionCache }) =>
|
||||
clearToolSearchDescriptionCache(),
|
||||
)
|
||||
// Clear agent definitions cache (accumulates per-cwd via EnterWorktreeTool)
|
||||
void import('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js').then(
|
||||
({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache(),
|
||||
)
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
).then(({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache())
|
||||
// Clear SkillTool prompt cache (accumulates per project root)
|
||||
void import('@claude-code-best/builtin-tools/tools/SkillTool/prompt.js').then(({ clearPromptCache }) =>
|
||||
clearPromptCache(),
|
||||
void import('@claude-code-best/builtin-tools/tools/SkillTool/prompt.js').then(
|
||||
({ clearPromptCache }) => clearPromptCache(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ async function compactViaReactive(
|
||||
context.setStreamMode?.('requesting')
|
||||
context.setResponseLength?.(() => 0)
|
||||
context.onCompactProgress?.({ type: 'compact_end' })
|
||||
context.setSDKStatus?.("" as SDKStatus)
|
||||
context.setSDKStatus?.('' as SDKStatus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { Settings } from '../../components/Settings/Settings.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
return <Settings onClose={onDone} context={context} defaultTab="Config" />
|
||||
}
|
||||
return <Settings onClose={onDone} context={context} defaultTab="Config" />;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { ContextVisualization } from '../../components/ContextVisualization.js'
|
||||
import { microcompactMessages } from '../../services/compact/microCompact.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { analyzeContextUsage } from '../../utils/analyzeContext.js'
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
|
||||
import { renderToAnsiString } from '../../utils/staticRender.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { ContextVisualization } from '../../components/ContextVisualization.js';
|
||||
import { microcompactMessages } from '../../services/compact/microCompact.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { analyzeContextUsage } from '../../utils/analyzeContext.js';
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
|
||||
import { renderToAnsiString } from '../../utils/staticRender.js';
|
||||
|
||||
/**
|
||||
* Apply the same context transforms query.ts does before the API call, so
|
||||
@@ -16,36 +16,33 @@ import { renderToAnsiString } from '../../utils/staticRender.js'
|
||||
* was collapsed — user sees "180k, 3 spans collapsed" when the API sees 120k.
|
||||
*/
|
||||
function toApiView(messages: Message[]): Message[] {
|
||||
let view = getMessagesAfterCompactBoundary(messages)
|
||||
let view = getMessagesAfterCompactBoundary(messages);
|
||||
if (feature('CONTEXT_COLLAPSE')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { projectView } =
|
||||
require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js')
|
||||
require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
view = projectView(view)
|
||||
view = projectView(view);
|
||||
}
|
||||
return view
|
||||
return view;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
|
||||
const {
|
||||
messages,
|
||||
getAppState,
|
||||
options: { mainLoopModel, tools },
|
||||
} = context
|
||||
} = context;
|
||||
|
||||
const apiView = toApiView(messages)
|
||||
const apiView = toApiView(messages);
|
||||
|
||||
// Apply microcompact to get accurate representation of messages sent to API
|
||||
const { messages: compactedMessages } = await microcompactMessages(apiView)
|
||||
const { messages: compactedMessages } = await microcompactMessages(apiView);
|
||||
|
||||
// Get terminal width for responsive sizing
|
||||
const terminalWidth = process.stdout.columns || 80
|
||||
const terminalWidth = process.stdout.columns || 80;
|
||||
|
||||
const appState = getAppState()
|
||||
const appState = getAppState();
|
||||
|
||||
// Analyze context with compacted messages
|
||||
// Pass original messages as last parameter for accurate API usage extraction
|
||||
@@ -59,10 +56,10 @@ export async function call(
|
||||
context, // Pass full context for system prompt calculation
|
||||
undefined, // mainThreadAgentDefinition
|
||||
apiView, // Original messages for API usage extraction
|
||||
)
|
||||
);
|
||||
|
||||
// Render to ANSI string to preserve colors and pass to onDone like local commands do
|
||||
const output = await renderToAnsiString(<ContextVisualization data={data} />)
|
||||
onDone(output)
|
||||
return null
|
||||
const output = await renderToAnsiString(<ContextVisualization data={data} />);
|
||||
onDone(output);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { marked, type Tokens } from 'marked'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import React, { useRef } from 'react'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import type { OptionWithDescription } from '../../components/CustomSelect/select.js'
|
||||
import { Select } from '../../components/CustomSelect/select.js'
|
||||
import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'
|
||||
import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import type { AssistantMessage, Message } from '../../types/message.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js'
|
||||
import { countCharInString } from '../../utils/stringUtils.js'
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { marked, type Tokens } from 'marked';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import React, { useRef } from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import type { OptionWithDescription } from '../../components/CustomSelect/select.js';
|
||||
import { Select } from '../../components/CustomSelect/select.js';
|
||||
import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink';
|
||||
import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import type { AssistantMessage, Message } from '../../types/message.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js';
|
||||
import { countCharInString } from '../../utils/stringUtils.js';
|
||||
|
||||
const COPY_DIR = join(tmpdir(), 'claude')
|
||||
const RESPONSE_FILENAME = 'response.md'
|
||||
const MAX_LOOKBACK = 20
|
||||
const COPY_DIR = join(tmpdir(), 'claude');
|
||||
const RESPONSE_FILENAME = 'response.md';
|
||||
const MAX_LOOKBACK = 20;
|
||||
|
||||
type CodeBlock = {
|
||||
code: string
|
||||
lang: string | undefined
|
||||
}
|
||||
code: string;
|
||||
lang: string | undefined;
|
||||
};
|
||||
|
||||
function extractCodeBlocks(markdown: string): CodeBlock[] {
|
||||
const tokens = marked.lexer(stripPromptXMLTags(markdown))
|
||||
const blocks: CodeBlock[] = []
|
||||
const tokens = marked.lexer(stripPromptXMLTags(markdown));
|
||||
const blocks: CodeBlock[] = [];
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'code') {
|
||||
const codeToken = token as Tokens.Code
|
||||
blocks.push({ code: codeToken.text, lang: codeToken.lang })
|
||||
const codeToken = token as Tokens.Code;
|
||||
blocks.push({ code: codeToken.text, lang: codeToken.lang });
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,95 +42,80 @@ function extractCodeBlocks(markdown: string): CodeBlock[] {
|
||||
* Index 0 = latest, 1 = second-to-latest, etc. Caps at MAX_LOOKBACK.
|
||||
*/
|
||||
export function collectRecentAssistantTexts(messages: Message[]): string[] {
|
||||
const texts: string[] = []
|
||||
for (
|
||||
let i = messages.length - 1;
|
||||
i >= 0 && texts.length < MAX_LOOKBACK;
|
||||
i--
|
||||
) {
|
||||
const msg = messages[i]
|
||||
if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue
|
||||
const content = (msg as AssistantMessage).message.content
|
||||
if (!Array.isArray(content)) continue
|
||||
const text = extractTextContent(content, '\n\n')
|
||||
if (text) texts.push(text)
|
||||
const texts: string[] = [];
|
||||
for (let i = messages.length - 1; i >= 0 && texts.length < MAX_LOOKBACK; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue;
|
||||
const content = (msg as AssistantMessage).message.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const text = extractTextContent(content, '\n\n');
|
||||
if (text) texts.push(text);
|
||||
}
|
||||
return texts
|
||||
return texts;
|
||||
}
|
||||
|
||||
export function fileExtension(lang: string | undefined): string {
|
||||
if (lang) {
|
||||
// Sanitize to prevent path traversal (e.g. ```../../etc/passwd)
|
||||
// Language identifiers are alphanumeric: python, tsx, jsonc, etc.
|
||||
const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '')
|
||||
const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '');
|
||||
if (sanitized && sanitized !== 'plaintext') {
|
||||
return `.${sanitized}`
|
||||
return `.${sanitized}`;
|
||||
}
|
||||
}
|
||||
return '.txt'
|
||||
return '.txt';
|
||||
}
|
||||
|
||||
async function writeToFile(text: string, filename: string): Promise<string> {
|
||||
const filePath = join(COPY_DIR, filename)
|
||||
await mkdir(COPY_DIR, { recursive: true })
|
||||
await writeFile(filePath, text, 'utf-8')
|
||||
return filePath
|
||||
const filePath = join(COPY_DIR, filename);
|
||||
await mkdir(COPY_DIR, { recursive: true });
|
||||
await writeFile(filePath, text, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function copyOrWriteToFile(
|
||||
text: string,
|
||||
filename: string,
|
||||
): Promise<string> {
|
||||
const raw = await setClipboard(text)
|
||||
if (raw) process.stdout.write(raw)
|
||||
const lineCount = countCharInString(text, '\n') + 1
|
||||
const charCount = text.length
|
||||
async function copyOrWriteToFile(text: string, filename: string): Promise<string> {
|
||||
const raw = await setClipboard(text);
|
||||
if (raw) process.stdout.write(raw);
|
||||
const lineCount = countCharInString(text, '\n') + 1;
|
||||
const charCount = text.length;
|
||||
// Also write to a temp file — clipboard paths are best-effort (OSC 52 needs
|
||||
// terminal support), so the file provides a reliable fallback.
|
||||
try {
|
||||
const filePath = await writeToFile(text, filename)
|
||||
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`
|
||||
const filePath = await writeToFile(text, filename);
|
||||
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`;
|
||||
} catch {
|
||||
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`
|
||||
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateLine(text: string, maxLen: number): string {
|
||||
const firstLine = text.split('\n')[0] ?? ''
|
||||
const firstLine = text.split('\n')[0] ?? '';
|
||||
if (stringWidth(firstLine) <= maxLen) {
|
||||
return firstLine
|
||||
return firstLine;
|
||||
}
|
||||
let result = ''
|
||||
let width = 0
|
||||
const targetWidth = maxLen - 1
|
||||
let result = '';
|
||||
let width = 0;
|
||||
const targetWidth = maxLen - 1;
|
||||
for (const char of firstLine) {
|
||||
const charWidth = stringWidth(char)
|
||||
if (width + charWidth > targetWidth) break
|
||||
result += char
|
||||
width += charWidth
|
||||
const charWidth = stringWidth(char);
|
||||
if (width + charWidth > targetWidth) break;
|
||||
result += char;
|
||||
width += charWidth;
|
||||
}
|
||||
return result + '\u2026'
|
||||
return result + '\u2026';
|
||||
}
|
||||
|
||||
type PickerProps = {
|
||||
fullText: string
|
||||
codeBlocks: CodeBlock[]
|
||||
messageAge: number
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
fullText: string;
|
||||
codeBlocks: CodeBlock[];
|
||||
messageAge: number;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
type PickerSelection = number | 'full' | 'always'
|
||||
type PickerSelection = number | 'full' | 'always';
|
||||
|
||||
function CopyPicker({
|
||||
fullText,
|
||||
codeBlocks,
|
||||
messageAge,
|
||||
onDone,
|
||||
}: PickerProps): React.ReactNode {
|
||||
const focusedRef = useRef<PickerSelection>('full')
|
||||
function CopyPicker({ fullText, codeBlocks, messageAge, onDone }: PickerProps): React.ReactNode {
|
||||
const focusedRef = useRef<PickerSelection>('full');
|
||||
|
||||
const options: OptionWithDescription<PickerSelection>[] = [
|
||||
{
|
||||
@@ -139,109 +124,99 @@ function CopyPicker({
|
||||
description: `${fullText.length} chars, ${countCharInString(fullText, '\n') + 1} lines`,
|
||||
},
|
||||
...codeBlocks.map((block, index) => {
|
||||
const blockLines = countCharInString(block.code, '\n') + 1
|
||||
const blockLines = countCharInString(block.code, '\n') + 1;
|
||||
return {
|
||||
label: truncateLine(block.code, 60),
|
||||
value: index,
|
||||
description:
|
||||
[block.lang, blockLines > 1 ? `${blockLines} lines` : undefined]
|
||||
.filter(Boolean)
|
||||
.join(', ') || undefined,
|
||||
}
|
||||
[block.lang, blockLines > 1 ? `${blockLines} lines` : undefined].filter(Boolean).join(', ') || undefined,
|
||||
};
|
||||
}),
|
||||
{
|
||||
label: 'Always copy full response',
|
||||
value: 'always' as const,
|
||||
description: 'Skip this picker in the future (revert via /config)',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
function getSelectionContent(selected: PickerSelection): {
|
||||
text: string
|
||||
filename: string
|
||||
blockIndex?: number
|
||||
text: string;
|
||||
filename: string;
|
||||
blockIndex?: number;
|
||||
} {
|
||||
if (selected === 'full' || selected === 'always') {
|
||||
return { text: fullText, filename: RESPONSE_FILENAME }
|
||||
return { text: fullText, filename: RESPONSE_FILENAME };
|
||||
}
|
||||
const block = codeBlocks[selected]!
|
||||
const block = codeBlocks[selected]!;
|
||||
return {
|
||||
text: block.code,
|
||||
filename: `copy${fileExtension(block.lang)}`,
|
||||
blockIndex: selected,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSelect(selected: PickerSelection): Promise<void> {
|
||||
const content = getSelectionContent(selected)
|
||||
const content = getSelectionContent(selected);
|
||||
if (selected === 'always') {
|
||||
if (!getGlobalConfig().copyFullResponse) {
|
||||
saveGlobalConfig(c => ({ ...c, copyFullResponse: true }))
|
||||
saveGlobalConfig(c => ({ ...c, copyFullResponse: true }));
|
||||
}
|
||||
logEvent('tengu_copy', {
|
||||
block_count: codeBlocks.length,
|
||||
always: true,
|
||||
message_age: messageAge,
|
||||
})
|
||||
const result = await copyOrWriteToFile(content.text, content.filename)
|
||||
onDone(
|
||||
`${result}\nPreference saved. Use /config to change copyFullResponse`,
|
||||
)
|
||||
return
|
||||
});
|
||||
const result = await copyOrWriteToFile(content.text, content.filename);
|
||||
onDone(`${result}\nPreference saved. Use /config to change copyFullResponse`);
|
||||
return;
|
||||
}
|
||||
logEvent('tengu_copy', {
|
||||
selected_block: content.blockIndex,
|
||||
block_count: codeBlocks.length,
|
||||
message_age: messageAge,
|
||||
})
|
||||
const result = await copyOrWriteToFile(content.text, content.filename)
|
||||
onDone(result)
|
||||
});
|
||||
const result = await copyOrWriteToFile(content.text, content.filename);
|
||||
onDone(result);
|
||||
}
|
||||
|
||||
async function handleWrite(selected: PickerSelection): Promise<void> {
|
||||
const content = getSelectionContent(selected)
|
||||
const content = getSelectionContent(selected);
|
||||
logEvent('tengu_copy', {
|
||||
selected_block: content.blockIndex,
|
||||
block_count: codeBlocks.length,
|
||||
message_age: messageAge,
|
||||
write_shortcut: true,
|
||||
})
|
||||
});
|
||||
try {
|
||||
const filePath = await writeToFile(content.text, content.filename)
|
||||
onDone(`Written to ${filePath}`)
|
||||
const filePath = await writeToFile(content.text, content.filename);
|
||||
onDone(`Written to ${filePath}`);
|
||||
} catch (e) {
|
||||
onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`)
|
||||
onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'w') {
|
||||
e.preventDefault()
|
||||
void handleWrite(focusedRef.current)
|
||||
e.preventDefault();
|
||||
void handleWrite(focusedRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Pane>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
<Text dimColor>Select content to copy:</Text>
|
||||
<Select<PickerSelection>
|
||||
options={options}
|
||||
hideIndexes={false}
|
||||
onFocus={value => {
|
||||
focusedRef.current = value
|
||||
focusedRef.current = value;
|
||||
}}
|
||||
onChange={selected => {
|
||||
void handleSelect(selected)
|
||||
void handleSelect(selected);
|
||||
}}
|
||||
onCancel={() => {
|
||||
onDone('Copy cancelled', { display: 'system' })
|
||||
onDone('Copy cancelled', { display: 'system' });
|
||||
}}
|
||||
/>
|
||||
<Text dimColor>
|
||||
@@ -253,56 +228,47 @@ function CopyPicker({
|
||||
</Text>
|
||||
</Box>
|
||||
</Pane>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
|
||||
const texts = collectRecentAssistantTexts(context.messages)
|
||||
const texts = collectRecentAssistantTexts(context.messages);
|
||||
|
||||
if (texts.length === 0) {
|
||||
onDone('No assistant message to copy')
|
||||
return null
|
||||
onDone('No assistant message to copy');
|
||||
return null;
|
||||
}
|
||||
|
||||
// /copy N reaches back N-1 messages (1 = latest, 2 = second-to-latest, ...)
|
||||
let age = 0
|
||||
const arg = args?.trim()
|
||||
let age = 0;
|
||||
const arg = args?.trim();
|
||||
if (arg) {
|
||||
const n = Number(arg)
|
||||
const n = Number(arg);
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`)
|
||||
return null
|
||||
onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`);
|
||||
return null;
|
||||
}
|
||||
if (n > texts.length) {
|
||||
onDone(
|
||||
`Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`,
|
||||
)
|
||||
return null
|
||||
onDone(`Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`);
|
||||
return null;
|
||||
}
|
||||
age = n - 1
|
||||
age = n - 1;
|
||||
}
|
||||
|
||||
const text = texts[age]!
|
||||
const codeBlocks = extractCodeBlocks(text)
|
||||
const config = getGlobalConfig()
|
||||
const text = texts[age]!;
|
||||
const codeBlocks = extractCodeBlocks(text);
|
||||
const config = getGlobalConfig();
|
||||
|
||||
if (codeBlocks.length === 0 || config.copyFullResponse) {
|
||||
logEvent('tengu_copy', {
|
||||
always: config.copyFullResponse,
|
||||
block_count: codeBlocks.length,
|
||||
message_age: age,
|
||||
})
|
||||
const result = await copyOrWriteToFile(text, RESPONSE_FILENAME)
|
||||
onDone(result)
|
||||
return null
|
||||
});
|
||||
const result = await copyOrWriteToFile(text, RESPONSE_FILENAME);
|
||||
onDone(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyPicker
|
||||
fullText={text}
|
||||
codeBlocks={codeBlocks}
|
||||
messageAge={age}
|
||||
onDone={onDone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <CopyPicker fullText={text} codeBlocks={codeBlocks} messageAge={age} onDone={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
LocalJSXCommandOnDone,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../types/command.js'
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
|
||||
|
||||
/**
|
||||
* /daemon slash command — manages daemon and background sessions from the REPL.
|
||||
@@ -14,44 +11,41 @@ export async function call(
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const parts = args ? args.trim().split(/\s+/) : []
|
||||
const sub = parts[0] || 'status'
|
||||
const parts = args ? args.trim().split(/\s+/) : [];
|
||||
const sub = parts[0] || 'status';
|
||||
|
||||
// attach is interactive/blocking — not available inside the REPL
|
||||
if (sub === 'attach') {
|
||||
onDone(
|
||||
'Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
onDone('Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
// For all other subcommands, capture console output and return via onDone
|
||||
const lines = await captureConsole(async () => {
|
||||
if (sub === 'bg') {
|
||||
const bg = await import('../../cli/bg.js')
|
||||
await bg.handleBgStart(parts.slice(1))
|
||||
const bg = await import('../../cli/bg.js');
|
||||
await bg.handleBgStart(parts.slice(1));
|
||||
} else {
|
||||
const { daemonMain } = await import('../../daemon/main.js')
|
||||
await daemonMain([sub, ...parts.slice(1)])
|
||||
const { daemonMain } = await import('../../daemon/main.js');
|
||||
await daemonMain([sub, ...parts.slice(1)]);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' })
|
||||
return null
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
async function captureConsole(fn: () => Promise<void>): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
const origLog = console.log
|
||||
const origError = console.error
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
const lines: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origError = console.error;
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '));
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '));
|
||||
try {
|
||||
await fn()
|
||||
await fn();
|
||||
} finally {
|
||||
console.log = origLog
|
||||
console.error = origError
|
||||
console.log = origLog;
|
||||
console.error = origError;
|
||||
}
|
||||
return lines
|
||||
return lines;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import { DesktopHandoff } from '../../components/DesktopHandoff.js'
|
||||
import React from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { DesktopHandoff } from '../../components/DesktopHandoff.js';
|
||||
|
||||
export async function call(
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void,
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
|
||||
): Promise<React.ReactNode> {
|
||||
return <DesktopHandoff onDone={onDone} />
|
||||
return <DesktopHandoff onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
const { DiffDialog } = await import('../../components/diff/DiffDialog.js')
|
||||
return <DiffDialog messages={context.messages} onDone={onDone} />
|
||||
}
|
||||
const { DiffDialog } = await import('../../components/diff/DiffDialog.js');
|
||||
return <DiffDialog messages={context.messages} onDone={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Doctor } from '../../screens/Doctor.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import React from 'react';
|
||||
import { Doctor } from '../../screens/Doctor.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = (onDone, _context, _args) => {
|
||||
return Promise.resolve(<Doctor onDone={onDone} />)
|
||||
}
|
||||
return Promise.resolve(<Doctor onDone={onDone} />);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
||||
import * as React from 'react';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import {
|
||||
type EffortValue,
|
||||
getDisplayedEffortLevel,
|
||||
@@ -13,171 +13,157 @@ import {
|
||||
getEffortValueDescription,
|
||||
isEffortLevel,
|
||||
toPersistableEffort,
|
||||
} from '../../utils/effort.js'
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||
} from '../../utils/effort.js';
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js';
|
||||
|
||||
const COMMON_HELP_ARGS = ['help', '-h', '--help']
|
||||
const COMMON_HELP_ARGS = ['help', '-h', '--help'];
|
||||
|
||||
type EffortCommandResult = {
|
||||
message: string
|
||||
effortUpdate?: { value: EffortValue | undefined }
|
||||
}
|
||||
message: string;
|
||||
effortUpdate?: { value: EffortValue | undefined };
|
||||
};
|
||||
|
||||
function setEffortValue(effortValue: EffortValue): EffortCommandResult {
|
||||
const persistable = toPersistableEffort(effortValue)
|
||||
const persistable = toPersistableEffort(effortValue);
|
||||
if (persistable !== undefined) {
|
||||
const result = updateSettingsForSource('userSettings', {
|
||||
effortLevel: persistable,
|
||||
})
|
||||
});
|
||||
if (result.error) {
|
||||
return {
|
||||
message: `Failed to set effort level: ${result.error.message}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
logEvent('tengu_effort_command', {
|
||||
effort:
|
||||
effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
effort: effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
// Env var wins at resolveAppliedEffort time. Only flag it when it actually
|
||||
// conflicts — if env matches what the user just asked for, the outcome is
|
||||
// the same, so "Set effort to X" is true and the note is noise.
|
||||
const envOverride = getEffortEnvOverride()
|
||||
const envOverride = getEffortEnvOverride();
|
||||
if (envOverride !== undefined && envOverride !== effortValue) {
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
|
||||
if (persistable === undefined) {
|
||||
return {
|
||||
message: `Not applied: CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides effort this session, and ${effortValue} is session-only (nothing saved)`,
|
||||
effortUpdate: { value: effortValue },
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: `CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session — clear it and ${effortValue} takes over`,
|
||||
effortUpdate: { value: effortValue },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const description = getEffortValueDescription(effortValue)
|
||||
const suffix = persistable !== undefined ? '' : ' (this session only)'
|
||||
const description = getEffortValueDescription(effortValue);
|
||||
const suffix = persistable !== undefined ? '' : ' (this session only)';
|
||||
return {
|
||||
message: `Set effort level to ${effortValue}${suffix}: ${description}`,
|
||||
effortUpdate: { value: effortValue },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function showCurrentEffort(
|
||||
appStateEffort: EffortValue | undefined,
|
||||
model: string,
|
||||
): EffortCommandResult {
|
||||
const envOverride = getEffortEnvOverride()
|
||||
const effectiveValue =
|
||||
envOverride === null ? undefined : (envOverride ?? appStateEffort)
|
||||
export function showCurrentEffort(appStateEffort: EffortValue | undefined, model: string): EffortCommandResult {
|
||||
const envOverride = getEffortEnvOverride();
|
||||
const effectiveValue = envOverride === null ? undefined : (envOverride ?? appStateEffort);
|
||||
if (effectiveValue === undefined) {
|
||||
const level = getDisplayedEffortLevel(model, appStateEffort)
|
||||
return { message: `Effort level: auto (currently ${level})` }
|
||||
const level = getDisplayedEffortLevel(model, appStateEffort);
|
||||
return { message: `Effort level: auto (currently ${level})` };
|
||||
}
|
||||
const description = getEffortValueDescription(effectiveValue)
|
||||
const description = getEffortValueDescription(effectiveValue);
|
||||
return {
|
||||
message: `Current effort level: ${effectiveValue} (${description})`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function unsetEffortLevel(): EffortCommandResult {
|
||||
const result = updateSettingsForSource('userSettings', {
|
||||
effortLevel: undefined,
|
||||
})
|
||||
});
|
||||
if (result.error) {
|
||||
return {
|
||||
message: `Failed to set effort level: ${result.error.message}`,
|
||||
}
|
||||
};
|
||||
}
|
||||
logEvent('tengu_effort_command', {
|
||||
effort:
|
||||
'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
effort: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
// env=auto/unset (null) matches what /effort auto asks for, so only warn
|
||||
// when env is pinning a specific level that will keep overriding.
|
||||
const envOverride = getEffortEnvOverride()
|
||||
const envOverride = getEffortEnvOverride();
|
||||
if (envOverride !== undefined && envOverride !== null) {
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
|
||||
return {
|
||||
message: `Cleared effort from settings, but CLAUDE_CODE_EFFORT_LEVEL=${envRaw} still controls this session`,
|
||||
effortUpdate: { value: undefined },
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: 'Effort level set to auto',
|
||||
effortUpdate: { value: undefined },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function executeEffort(args: string): EffortCommandResult {
|
||||
const normalized = args.toLowerCase()
|
||||
const normalized = args.toLowerCase();
|
||||
if (normalized === 'auto' || normalized === 'unset') {
|
||||
return unsetEffortLevel()
|
||||
return unsetEffortLevel();
|
||||
}
|
||||
|
||||
if (!isEffortLevel(normalized)) {
|
||||
return {
|
||||
message: `Invalid argument: ${args}. Valid options are: low, medium, high, max, auto`,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return setEffortValue(normalized)
|
||||
return setEffortValue(normalized);
|
||||
}
|
||||
|
||||
function ShowCurrentEffort({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (result: string) => void
|
||||
}): React.ReactNode {
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
const model = useMainLoopModel()
|
||||
const { message } = showCurrentEffort(effortValue, model)
|
||||
onDone(message)
|
||||
return null
|
||||
function ShowCurrentEffort({ onDone }: { onDone: (result: string) => void }): React.ReactNode {
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
const model = useMainLoopModel();
|
||||
const { message } = showCurrentEffort(effortValue, model);
|
||||
onDone(message);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ApplyEffortAndClose({
|
||||
result,
|
||||
onDone,
|
||||
}: {
|
||||
result: EffortCommandResult
|
||||
onDone: (result: string) => void
|
||||
result: EffortCommandResult;
|
||||
onDone: (result: string) => void;
|
||||
}): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
const { effortUpdate, message } = result
|
||||
const setAppState = useSetAppState();
|
||||
const { effortUpdate, message } = result;
|
||||
React.useEffect(() => {
|
||||
if (effortUpdate) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
effortValue: effortUpdate.value,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
onDone(message)
|
||||
}, [setAppState, effortUpdate, message, onDone])
|
||||
return null
|
||||
onDone(message);
|
||||
}, [setAppState, effortUpdate, message, onDone]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: unknown,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
args = args?.trim() || ''
|
||||
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
|
||||
args = args?.trim() || '';
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(args)) {
|
||||
onDone(
|
||||
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6/4.7, DeepSeek V4 Pro)\n- auto: Use the default effort level for your model',
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args || args === 'current' || args === 'status') {
|
||||
return <ShowCurrentEffort onDone={onDone} />
|
||||
return <ShowCurrentEffort onDone={onDone} />;
|
||||
}
|
||||
|
||||
const result = executeEffort(args)
|
||||
return <ApplyEffortAndClose result={result} onDone={onDone} />
|
||||
const result = executeEffort(args);
|
||||
return <ApplyEffortAndClose result={result} onDone={onDone} />;
|
||||
}
|
||||
|
||||
2
src/commands/env/index.js
vendored
2
src/commands/env/index.js
vendored
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { spawnSync } from 'child_process'
|
||||
import sample from 'lodash-es/sample.js'
|
||||
import * as React from 'react'
|
||||
import { ExitFlow } from '../../components/ExitFlow.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { isBgSession } from '../../utils/concurrentSessions.js'
|
||||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import { spawnSync } from 'child_process';
|
||||
import sample from 'lodash-es/sample.js';
|
||||
import * as React from 'react';
|
||||
import { ExitFlow } from '../../components/ExitFlow.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { isBgSession } from '../../utils/concurrentSessions.js';
|
||||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js';
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js';
|
||||
|
||||
const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']
|
||||
const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'];
|
||||
|
||||
function getRandomGoodbyeMessage(): string {
|
||||
return sample(GOODBYE_MESSAGES) ?? 'Goodbye!'
|
||||
return sample(GOODBYE_MESSAGES) ?? 'Goodbye!';
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
// Inside a `claude --bg` tmux session: detach instead of kill. The REPL
|
||||
// keeps running; `claude attach` can reconnect. Covers /exit, /quit,
|
||||
// ctrl+c, ctrl+d — all funnel through here via REPL's handleExit.
|
||||
if (feature('BG_SESSIONS') && isBgSession()) {
|
||||
onDone()
|
||||
spawnSync('tmux', ['detach-client'], { stdio: 'ignore' })
|
||||
return null
|
||||
onDone();
|
||||
spawnSync('tmux', ['detach-client'], { stdio: 'ignore' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const showWorktree = getCurrentWorktreeSession() !== null
|
||||
const showWorktree = getCurrentWorktreeSession() !== null;
|
||||
|
||||
if (showWorktree) {
|
||||
return (
|
||||
<ExitFlow
|
||||
showWorktree={showWorktree}
|
||||
onDone={onDone}
|
||||
onCancel={() => onDone()}
|
||||
/>
|
||||
)
|
||||
return <ExitFlow showWorktree={showWorktree} onDone={onDone} onCancel={() => onDone()} />;
|
||||
}
|
||||
|
||||
onDone(getRandomGoodbyeMessage())
|
||||
await gracefulShutdown(0, 'prompt_input_exit')
|
||||
return null
|
||||
onDone(getRandomGoodbyeMessage());
|
||||
await gracefulShutdown(0, 'prompt_input_exit');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
import { join } from 'path'
|
||||
import React from 'react'
|
||||
import { ExportDialog } from '../../components/ExportDialog.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { renderMessagesToPlainText } from '../../utils/exportRenderer.js'
|
||||
import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js'
|
||||
import { join } from 'path';
|
||||
import React from 'react';
|
||||
import { ExportDialog } from '../../components/ExportDialog.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { getCwd } from '../../utils/cwd.js';
|
||||
import { renderMessagesToPlainText } from '../../utils/exportRenderer.js';
|
||||
import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js';
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
export function extractFirstPrompt(messages: Message[]): string {
|
||||
const firstUserMessage = messages.find(msg => msg.type === 'user')
|
||||
const firstUserMessage = messages.find(msg => msg.type === 'user');
|
||||
|
||||
if (!firstUserMessage || firstUserMessage.type !== 'user') {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
const content = firstUserMessage.message?.content
|
||||
let result = ''
|
||||
const content = firstUserMessage.message?.content;
|
||||
let result = '';
|
||||
|
||||
if (typeof content === 'string') {
|
||||
result = content.trim()
|
||||
result = content.trim();
|
||||
} else if (Array.isArray(content)) {
|
||||
const textContent = content.find(item => item.type === 'text')
|
||||
const textContent = content.find(item => item.type === 'text');
|
||||
if (textContent && 'text' in textContent) {
|
||||
result = textContent.text.trim()
|
||||
result = textContent.text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Take first line only and limit length
|
||||
result = result.split('\n')[0] || ''
|
||||
result = result.split('\n')[0] || '';
|
||||
if (result.length > 50) {
|
||||
result = result.substring(0, 49) + '…'
|
||||
result = result.substring(0, 49) + '…';
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(text: string): string {
|
||||
@@ -53,14 +53,12 @@ export function sanitizeFilename(text: string): string {
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
}
|
||||
|
||||
async function exportWithReactRenderer(
|
||||
context: ToolUseContext,
|
||||
): Promise<string> {
|
||||
const tools = context.options.tools || []
|
||||
return renderMessagesToPlainText(context.messages, tools)
|
||||
async function exportWithReactRenderer(context: ToolUseContext): Promise<string> {
|
||||
const tools = context.options.tools || [];
|
||||
return renderMessagesToPlainText(context.messages, tools);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -69,43 +67,37 @@ export async function call(
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
// Render the conversation content
|
||||
const content = await exportWithReactRenderer(context)
|
||||
const content = await exportWithReactRenderer(context);
|
||||
|
||||
// If args are provided, write directly to file and skip dialog
|
||||
const filename = args.trim()
|
||||
const filename = args.trim();
|
||||
if (filename) {
|
||||
const finalFilename = filename.endsWith('.txt')
|
||||
? filename
|
||||
: filename.replace(/\.[^.]+$/, '') + '.txt'
|
||||
const filepath = join(getCwd(), finalFilename)
|
||||
const finalFilename = filename.endsWith('.txt') ? filename : filename.replace(/\.[^.]+$/, '') + '.txt';
|
||||
const filepath = join(getCwd(), finalFilename);
|
||||
|
||||
try {
|
||||
writeFileSync_DEPRECATED(filepath, content, {
|
||||
encoding: 'utf-8',
|
||||
flush: true,
|
||||
})
|
||||
onDone(`Conversation exported to: ${filepath}`)
|
||||
return null
|
||||
});
|
||||
onDone(`Conversation exported to: ${filepath}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
onDone(
|
||||
`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
)
|
||||
return null
|
||||
onDone(`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate default filename from first prompt or timestamp
|
||||
const firstPrompt = extractFirstPrompt(context.messages)
|
||||
const timestamp = formatTimestamp(new Date())
|
||||
const firstPrompt = extractFirstPrompt(context.messages);
|
||||
const timestamp = formatTimestamp(new Date());
|
||||
|
||||
let defaultFilename: string
|
||||
let defaultFilename: string;
|
||||
if (firstPrompt) {
|
||||
const sanitized = sanitizeFilename(firstPrompt)
|
||||
defaultFilename = sanitized
|
||||
? `${timestamp}-${sanitized}.txt`
|
||||
: `conversation-${timestamp}.txt`
|
||||
const sanitized = sanitizeFilename(firstPrompt);
|
||||
defaultFilename = sanitized ? `${timestamp}-${sanitized}.txt` : `conversation-${timestamp}.txt`;
|
||||
} else {
|
||||
defaultFilename = `conversation-${timestamp}.txt`
|
||||
defaultFilename = `conversation-${timestamp}.txt`;
|
||||
}
|
||||
|
||||
// Return the dialog component when no args provided
|
||||
@@ -114,8 +106,8 @@ export async function call(
|
||||
content={content}
|
||||
defaultFilename={defaultFilename}
|
||||
onDone={result => {
|
||||
onDone(result.message)
|
||||
onDone(result.message);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import React from 'react'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { Login } from '../login/login.js'
|
||||
import { runExtraUsage } from './extra-usage-core.js'
|
||||
import React from 'react';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { Login } from '../login/login.js';
|
||||
import { runExtraUsage } from './extra-usage-core.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode | null> {
|
||||
const result = await runExtraUsage()
|
||||
const result = await runExtraUsage();
|
||||
|
||||
if (result.type === 'message') {
|
||||
onDone(result.value)
|
||||
return null
|
||||
onDone(result.value);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Login
|
||||
startingMessage={
|
||||
'Starting new login following /extra-usage. Exit with Ctrl-C to use existing account.'
|
||||
}
|
||||
startingMessage={'Starting new login following /extra-usage. Exit with Ctrl-C to use existing account.'}
|
||||
onDone={success => {
|
||||
context.onChangeAPIKey()
|
||||
onDone(success ? 'Login successful' : 'Login interrupted')
|
||||
context.onChangeAPIKey();
|
||||
onDone(success ? 'Login successful' : 'Login interrupted');
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import type {
|
||||
CommandResultDisplay,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { FastIcon, getFastIconString } from '../../components/FastIcon.js'
|
||||
import { Box, Link, Text } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { FastIcon, getFastIconString } from '../../components/FastIcon.js';
|
||||
import { Box, Link, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import {
|
||||
type AppState,
|
||||
useAppState,
|
||||
useSetAppState,
|
||||
} from '../../state/AppState.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { type AppState, useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import {
|
||||
clearFastModeCooldown,
|
||||
FAST_MODE_MODEL_DISPLAY,
|
||||
@@ -27,33 +20,28 @@ import {
|
||||
isFastModeEnabled,
|
||||
isFastModeSupportedByModel,
|
||||
prefetchFastModeStatus,
|
||||
} from '../../utils/fastMode.js'
|
||||
import { formatDuration } from '../../utils/format.js'
|
||||
import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js'
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js'
|
||||
} from '../../utils/fastMode.js';
|
||||
import { formatDuration } from '../../utils/format.js';
|
||||
import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js';
|
||||
import { updateSettingsForSource } from '../../utils/settings/settings.js';
|
||||
|
||||
function applyFastMode(
|
||||
enable: boolean,
|
||||
setAppState: (f: (prev: AppState) => AppState) => void,
|
||||
): void {
|
||||
clearFastModeCooldown()
|
||||
function applyFastMode(enable: boolean, setAppState: (f: (prev: AppState) => AppState) => void): void {
|
||||
clearFastModeCooldown();
|
||||
updateSettingsForSource('userSettings', {
|
||||
fastMode: enable ? true : undefined,
|
||||
})
|
||||
});
|
||||
if (enable) {
|
||||
setAppState(prev => {
|
||||
// Only switch model if current model doesn't support fast mode
|
||||
const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel)
|
||||
const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel);
|
||||
return {
|
||||
...prev,
|
||||
...(needsModelSwitch
|
||||
? { mainLoopModel: getFastModeModel(), mainLoopModelForSession: null }
|
||||
: {}),
|
||||
...(needsModelSwitch ? { mainLoopModel: getFastModeModel(), mainLoopModelForSession: null } : {}),
|
||||
fastMode: true,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setAppState(prev => ({ ...prev, fastMode: false }))
|
||||
setAppState(prev => ({ ...prev, fastMode: false }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,38 +49,32 @@ export function FastModePicker({
|
||||
onDone,
|
||||
unavailableReason,
|
||||
}: {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
unavailableReason: string | null
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
unavailableReason: string | null;
|
||||
}): React.ReactNode {
|
||||
const model = useAppState(s => s.mainLoopModel)
|
||||
const initialFastMode = useAppState(s => s.fastMode)
|
||||
const setAppState = useSetAppState()
|
||||
const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false)
|
||||
const runtimeState = getFastModeRuntimeState()
|
||||
const isCooldown = runtimeState.status === 'cooldown'
|
||||
const isUnavailable = unavailableReason !== null
|
||||
const pricing = formatModelPricing(getOpus46CostTier(true))
|
||||
const model = useAppState(s => s.mainLoopModel);
|
||||
const initialFastMode = useAppState(s => s.fastMode);
|
||||
const setAppState = useSetAppState();
|
||||
const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false);
|
||||
const runtimeState = getFastModeRuntimeState();
|
||||
const isCooldown = runtimeState.status === 'cooldown';
|
||||
const isUnavailable = unavailableReason !== null;
|
||||
const pricing = formatModelPricing(getOpus46CostTier(true));
|
||||
|
||||
function handleConfirm(): void {
|
||||
if (isUnavailable) return
|
||||
applyFastMode(enableFastMode, setAppState)
|
||||
if (isUnavailable) return;
|
||||
applyFastMode(enableFastMode, setAppState);
|
||||
logEvent('tengu_fast_mode_toggled', {
|
||||
enabled: enableFastMode,
|
||||
source:
|
||||
'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
source: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
if (enableFastMode) {
|
||||
const fastIcon = getFastIconString(enableFastMode)
|
||||
const modelUpdated = !isFastModeSupportedByModel(model)
|
||||
? ` · model set to ${FAST_MODE_MODEL_DISPLAY}`
|
||||
: ''
|
||||
onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`)
|
||||
const fastIcon = getFastIconString(enableFastMode);
|
||||
const modelUpdated = !isFastModeSupportedByModel(model) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : '';
|
||||
onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`);
|
||||
} else {
|
||||
setAppState(prev => ({ ...prev, fastMode: false }))
|
||||
onDone(`Fast mode OFF`)
|
||||
setAppState(prev => ({ ...prev, fastMode: false }));
|
||||
onDone(`Fast mode OFF`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,20 +82,18 @@ export function FastModePicker({
|
||||
if (isUnavailable) {
|
||||
// Ensure fast mode is off if the org has disabled it
|
||||
if (initialFastMode) {
|
||||
applyFastMode(false, setAppState)
|
||||
applyFastMode(false, setAppState);
|
||||
}
|
||||
onDone('Fast mode OFF', { display: 'system' })
|
||||
return
|
||||
onDone('Fast mode OFF', { display: 'system' });
|
||||
return;
|
||||
}
|
||||
const message = initialFastMode
|
||||
? `${getFastIconString()} Kept Fast mode ON`
|
||||
: `Kept Fast mode OFF`
|
||||
onDone(message, { display: 'system' })
|
||||
const message = initialFastMode ? `${getFastIconString()} Kept Fast mode ON` : `Kept Fast mode OFF`;
|
||||
onDone(message, { display: 'system' });
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
if (isUnavailable) return
|
||||
setEnableFastMode(prev => !prev)
|
||||
if (isUnavailable) return;
|
||||
setEnableFastMode(prev => !prev);
|
||||
}
|
||||
|
||||
useKeybindings(
|
||||
@@ -126,13 +106,13 @@ export function FastModePicker({
|
||||
'confirm:toggle': handleToggle,
|
||||
},
|
||||
{ context: 'Confirmation' },
|
||||
)
|
||||
);
|
||||
|
||||
const title = (
|
||||
<Text>
|
||||
<FastIcon cooldown={isCooldown} /> Fast mode (research preview)
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -159,10 +139,7 @@ export function FastModePicker({
|
||||
<Box flexDirection="column" gap={0} marginLeft={2}>
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Text bold>Fast mode</Text>
|
||||
<Text
|
||||
color={enableFastMode ? 'fastMode' : undefined}
|
||||
bold={enableFastMode}
|
||||
>
|
||||
<Text color={enableFastMode ? 'fastMode' : undefined} bold={enableFastMode}>
|
||||
{enableFastMode ? 'ON ' : 'OFF'}
|
||||
</Text>
|
||||
<Text dimColor>{pricing}</Text>
|
||||
@@ -186,12 +163,10 @@ export function FastModePicker({
|
||||
)}
|
||||
<Text dimColor>
|
||||
Learn more:{' '}
|
||||
<Link url="https://code.claude.com/docs/en/fast-mode">
|
||||
https://code.claude.com/docs/en/fast-mode
|
||||
</Link>
|
||||
<Link url="https://code.claude.com/docs/en/fast-mode">https://code.claude.com/docs/en/fast-mode</Link>
|
||||
</Text>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleFastModeShortcut(
|
||||
@@ -199,28 +174,25 @@ async function handleFastModeShortcut(
|
||||
getAppState: () => AppState,
|
||||
setAppState: (f: (prev: AppState) => AppState) => void,
|
||||
): Promise<string> {
|
||||
const unavailableReason = getFastModeUnavailableReason()
|
||||
const unavailableReason = getFastModeUnavailableReason();
|
||||
if (unavailableReason) {
|
||||
return `Fast mode unavailable: ${unavailableReason}`
|
||||
return `Fast mode unavailable: ${unavailableReason}`;
|
||||
}
|
||||
|
||||
const { mainLoopModel } = getAppState()
|
||||
applyFastMode(enable, setAppState)
|
||||
const { mainLoopModel } = getAppState();
|
||||
applyFastMode(enable, setAppState);
|
||||
logEvent('tengu_fast_mode_toggled', {
|
||||
enabled: enable,
|
||||
source:
|
||||
'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
source: 'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
if (enable) {
|
||||
const fastIcon = getFastIconString(true)
|
||||
const modelUpdated = !isFastModeSupportedByModel(mainLoopModel)
|
||||
? ` · model set to ${FAST_MODE_MODEL_DISPLAY}`
|
||||
: ''
|
||||
const pricing = formatModelPricing(getOpus46CostTier(true))
|
||||
return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`
|
||||
const fastIcon = getFastIconString(true);
|
||||
const modelUpdated = !isFastModeSupportedByModel(mainLoopModel) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : '';
|
||||
const pricing = formatModelPricing(getOpus46CostTier(true));
|
||||
return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`;
|
||||
} else {
|
||||
return `Fast mode OFF`
|
||||
return `Fast mode OFF`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,31 +202,24 @@ export async function call(
|
||||
args?: string,
|
||||
): Promise<React.ReactNode | null> {
|
||||
if (!isFastModeEnabled()) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch org fast mode status before showing the picker. We must know
|
||||
// whether the org has disabled fast mode before allowing any toggle.
|
||||
// If a startup prefetch is already in flight, this awaits it.
|
||||
await prefetchFastModeStatus()
|
||||
await prefetchFastModeStatus();
|
||||
|
||||
const arg = args?.trim().toLowerCase()
|
||||
const arg = args?.trim().toLowerCase();
|
||||
if (arg === 'on' || arg === 'off') {
|
||||
const result = await handleFastModeShortcut(
|
||||
arg === 'on',
|
||||
context.getAppState,
|
||||
context.setAppState,
|
||||
)
|
||||
onDone(result)
|
||||
return null
|
||||
const result = await handleFastModeShortcut(arg === 'on', context.getAppState, context.setAppState);
|
||||
onDone(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
const unavailableReason = getFastModeUnavailableReason()
|
||||
const unavailableReason = getFastModeUnavailableReason();
|
||||
logEvent('tengu_fast_mode_picker_shown', {
|
||||
unavailable_reason: (unavailableReason ??
|
||||
'') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return (
|
||||
<FastModePicker onDone={onDone} unavailableReason={unavailableReason} />
|
||||
)
|
||||
unavailable_reason: (unavailableReason ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
return <FastModePicker onDone={onDone} unavailableReason={unavailableReason} />;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import * as React from 'react'
|
||||
import type {
|
||||
CommandResultDisplay,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import { Feedback } from '../../components/Feedback.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import * as React from 'react';
|
||||
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Feedback } from '../../components/Feedback.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
|
||||
// Shared function to render the Feedback component
|
||||
export function renderFeedbackComponent(
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void,
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
|
||||
abortSignal: AbortSignal,
|
||||
messages: Message[],
|
||||
initialDescription: string = '',
|
||||
backgroundTasks: {
|
||||
[taskId: string]: {
|
||||
type: string
|
||||
identity?: { agentId: string }
|
||||
messages?: Message[]
|
||||
}
|
||||
type: string;
|
||||
identity?: { agentId: string };
|
||||
messages?: Message[];
|
||||
};
|
||||
} = {},
|
||||
): React.ReactNode {
|
||||
return (
|
||||
@@ -32,7 +26,7 @@ export function renderFeedbackComponent(
|
||||
onDone={onDone}
|
||||
backgroundTasks={backgroundTasks}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -40,11 +34,6 @@ export async function call(
|
||||
context: LocalJSXCommandContext,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const initialDescription = args || ''
|
||||
return renderFeedbackComponent(
|
||||
onDone,
|
||||
context.abortController.signal,
|
||||
context.messages,
|
||||
initialDescription,
|
||||
)
|
||||
const initialDescription = args || '';
|
||||
return renderFeedbackComponent(onDone, context.abortController.signal, context.messages, initialDescription);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const call: LocalCommandCall = async (_args, context) => {
|
||||
// Collect UUIDs of every message that will be snipped (everything currently
|
||||
// in the conversation). The next call to `snipCompactIfNeeded` will honour
|
||||
// the boundary and strip these from the model-facing view.
|
||||
const removedUuids = messages.map((m) => m.uuid)
|
||||
const removedUuids = messages.map(m => m.uuid)
|
||||
|
||||
const boundaryMessage: Message = {
|
||||
type: 'system',
|
||||
@@ -39,7 +39,7 @@ const call: LocalCommandCall = async (_args, context) => {
|
||||
},
|
||||
} as Message // subtype is feature-gated; cast through Message
|
||||
|
||||
setMessages((prev) => [...prev, boundaryMessage])
|
||||
setMessages(prev => [...prev, boundaryMessage])
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import React from 'react'
|
||||
import { AgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js'
|
||||
import { isInForkChild } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import React from 'react';
|
||||
import { AgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js';
|
||||
import { isInForkChild } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js';
|
||||
import { logForDebugging } from '../../utils/debug.js';
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
@@ -12,30 +12,30 @@ export async function call(
|
||||
): Promise<React.ReactNode> {
|
||||
// Check feature flag
|
||||
if (!feature('FORK_SUBAGENT')) {
|
||||
onDone('Fork subagent feature is not enabled. Set FEATURE_FORK_SUBAGENT=1 to enable.', { display: 'system' })
|
||||
return null
|
||||
onDone('Fork subagent feature is not enabled. Set FEATURE_FORK_SUBAGENT=1 to enable.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recursive fork guard
|
||||
if (isInForkChild(context.messages)) {
|
||||
onDone('Fork is not available inside a forked worker. Complete your task directly using your tools.', { display: 'system' })
|
||||
return null
|
||||
onDone('Fork is not available inside a forked worker. Complete your task directly using your tools.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const directive = args.trim()
|
||||
const directive = args.trim();
|
||||
if (!directive) {
|
||||
onDone('Usage: /fork <directive>\nExample: /fork Fix the null check in validate.ts', { display: 'system' })
|
||||
return null
|
||||
onDone('Usage: /fork <directive>\nExample: /fork Fix the null check in validate.ts', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the last assistant message to fork from
|
||||
const lastAssistantMessage = [...context.messages].reverse().find(
|
||||
m => m.type === 'assistant'
|
||||
) as any // Type assertion to avoid complex type import
|
||||
const lastAssistantMessage = [...context.messages].reverse().find(m => m.type === 'assistant') as any; // Type assertion to avoid complex type import
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
onDone('Cannot fork: no assistant response in conversation history.', { display: 'system' })
|
||||
return null
|
||||
onDone('Cannot fork: no assistant response in conversation history.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -45,29 +45,24 @@ export async function call(
|
||||
prompt: directive,
|
||||
run_in_background: true, // fork always runs async
|
||||
description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`,
|
||||
}
|
||||
};
|
||||
|
||||
// Call AgentTool with proper parameters:
|
||||
// - input: the agent parameters (no subagent_type => fork path)
|
||||
// - toolUseContext: the current context (ToolUseContext)
|
||||
// - canUseTool: permission-check function from context
|
||||
// - assistantMessage: the last assistant message to fork from
|
||||
AgentTool.call(
|
||||
input,
|
||||
context,
|
||||
context.canUseTool!,
|
||||
lastAssistantMessage
|
||||
).catch(error => {
|
||||
logForDebugging(`Fork subagent async error: ${error}`, { level: 'error' })
|
||||
})
|
||||
AgentTool.call(input, context, context.canUseTool!, lastAssistantMessage).catch(error => {
|
||||
logForDebugging(`Fork subagent async error: ${error}`, { level: 'error' });
|
||||
});
|
||||
|
||||
// Notify user that fork has been started
|
||||
onDone(`Forked subagent started with directive: "${directive}"`, { display: 'system' })
|
||||
return null
|
||||
onDone(`Forked subagent started with directive: "${directive}"`, { display: 'system' });
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Catches synchronous setup errors only
|
||||
logForDebugging(`Fork command setup error: ${error}`, { level: 'error' })
|
||||
onDone(`Fork failed: ${error instanceof Error ? error.message : String(error)}`, { display: 'system' })
|
||||
return null
|
||||
logForDebugging(`Fork command setup error: ${error}`, { level: 'error' });
|
||||
onDone(`Fork failed: ${error instanceof Error ? error.message : String(error)}`, { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { HelpV2 } from '../../components/HelpV2/HelpV2.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { HelpV2 } from '../../components/HelpV2/HelpV2.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (
|
||||
onDone,
|
||||
{ options: { commands } },
|
||||
) => {
|
||||
return <HelpV2 commands={commands} onClose={onDone} />
|
||||
}
|
||||
export const call: LocalJSXCommandCall = async (onDone, { options: { commands } }) => {
|
||||
return <HelpV2 commands={commands} onClose={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react'
|
||||
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { getTools } from '../../tools.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import * as React from 'react';
|
||||
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
logEvent('tengu_hooks_command', {})
|
||||
const appState = context.getAppState()
|
||||
const permissionContext = appState.toolPermissionContext
|
||||
const toolNames = getTools(permissionContext).map(tool => tool.name)
|
||||
return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />
|
||||
}
|
||||
logEvent('tengu_hooks_command', {});
|
||||
const appState = context.getAppState();
|
||||
const permissionContext = appState.toolPermissionContext;
|
||||
const toolNames = getTools(permissionContext).map(tool => tool.name);
|
||||
return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import chalk from 'chalk'
|
||||
import * as path from 'path'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import type {
|
||||
CommandResultDisplay,
|
||||
LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import { Select } from '../../components/CustomSelect/index.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import chalk from 'chalk';
|
||||
import * as path from 'path';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Select } from '../../components/CustomSelect/index.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import {
|
||||
IdeAutoConnectDialog,
|
||||
IdeDisableAutoConnectDialog,
|
||||
shouldShowAutoConnectDialog,
|
||||
shouldShowDisableAutoConnectDialog,
|
||||
} from '../../components/IdeAutoConnectDialog.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { clearServerCache } from '../../services/mcp/client.js'
|
||||
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||
} from '../../components/IdeAutoConnectDialog.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { clearServerCache } from '../../services/mcp/client.js';
|
||||
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import { getCwd } from '../../utils/cwd.js';
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
|
||||
import {
|
||||
type DetectedIDEInfo,
|
||||
detectIDEs,
|
||||
@@ -29,16 +26,16 @@ import {
|
||||
isSupportedJetBrainsTerminal,
|
||||
isSupportedTerminal,
|
||||
toIDEDisplayName,
|
||||
} from '../../utils/ide.js'
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
|
||||
} from '../../utils/ide.js';
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js';
|
||||
|
||||
type IDEScreenProps = {
|
||||
availableIDEs: DetectedIDEInfo[]
|
||||
unavailableIDEs: DetectedIDEInfo[]
|
||||
selectedIDE?: DetectedIDEInfo | null
|
||||
onClose: () => void
|
||||
onSelect: (ide?: DetectedIDEInfo) => void
|
||||
}
|
||||
availableIDEs: DetectedIDEInfo[];
|
||||
unavailableIDEs: DetectedIDEInfo[];
|
||||
selectedIDE?: DetectedIDEInfo | null;
|
||||
onClose: () => void;
|
||||
onSelect: (ide?: DetectedIDEInfo) => void;
|
||||
};
|
||||
|
||||
function IDEScreen({
|
||||
availableIDEs,
|
||||
@@ -47,51 +44,43 @@ function IDEScreen({
|
||||
onClose,
|
||||
onSelect,
|
||||
}: IDEScreenProps): React.ReactNode {
|
||||
const [selectedValue, setSelectedValue] = useState(
|
||||
selectedIDE?.port?.toString() ?? 'None',
|
||||
)
|
||||
const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false)
|
||||
const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] =
|
||||
useState(false)
|
||||
const [selectedValue, setSelectedValue] = useState(selectedIDE?.port?.toString() ?? 'None');
|
||||
const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false);
|
||||
const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] = useState(false);
|
||||
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
if (value !== 'None' && shouldShowAutoConnectDialog()) {
|
||||
setShowAutoConnectDialog(true)
|
||||
setShowAutoConnectDialog(true);
|
||||
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
|
||||
setShowDisableAutoConnectDialog(true)
|
||||
setShowDisableAutoConnectDialog(true);
|
||||
} else {
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)))
|
||||
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)));
|
||||
}
|
||||
},
|
||||
[availableIDEs, onSelect],
|
||||
)
|
||||
);
|
||||
|
||||
const ideCounts = availableIDEs.reduce<Record<string, number>>((acc, ide) => {
|
||||
acc[ide.name] = (acc[ide.name] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
acc[ide.name] = (acc[ide.name] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const options = availableIDEs
|
||||
.map(ide => {
|
||||
const hasMultipleInstances = (ideCounts[ide.name] || 0) > 1
|
||||
const showWorkspace =
|
||||
hasMultipleInstances && ide.workspaceFolders.length > 0
|
||||
const hasMultipleInstances = (ideCounts[ide.name] || 0) > 1;
|
||||
const showWorkspace = hasMultipleInstances && ide.workspaceFolders.length > 0;
|
||||
|
||||
return {
|
||||
label: ide.name,
|
||||
value: ide.port.toString(),
|
||||
description: showWorkspace
|
||||
? formatWorkspaceFolders(ide.workspaceFolders)
|
||||
: undefined,
|
||||
}
|
||||
description: showWorkspace ? formatWorkspaceFolders(ide.workspaceFolders) : undefined,
|
||||
};
|
||||
})
|
||||
.concat([{ label: 'None', value: 'None', description: undefined }])
|
||||
.concat([{ label: 'None', value: 'None', description: undefined }]);
|
||||
|
||||
if (showAutoConnectDialog) {
|
||||
return (
|
||||
<IdeAutoConnectDialog onComplete={() => handleSelectIDE(selectedValue)} />
|
||||
)
|
||||
return <IdeAutoConnectDialog onComplete={() => handleSelectIDE(selectedValue)} />;
|
||||
}
|
||||
|
||||
if (showDisableAutoConnectDialog) {
|
||||
@@ -100,10 +89,10 @@ function IDEScreen({
|
||||
onComplete={() => {
|
||||
// Always disconnect when user selects "None", regardless of their
|
||||
// choice about disabling auto-connect
|
||||
onSelect(undefined)
|
||||
onSelect(undefined);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -129,36 +118,28 @@ function IDEScreen({
|
||||
defaultFocusValue={selectedValue}
|
||||
options={options}
|
||||
onChange={value => {
|
||||
setSelectedValue(value)
|
||||
handleSelectIDE(value)
|
||||
setSelectedValue(value);
|
||||
handleSelectIDE(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{availableIDEs.length !== 0 &&
|
||||
availableIDEs.some(
|
||||
ide => ide.name === 'VS Code' || ide.name === 'Visual Studio Code',
|
||||
) && (
|
||||
availableIDEs.some(ide => ide.name === 'VS Code' || ide.name === 'Visual Studio Code') && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="warning">
|
||||
Note: Only one Claude Code instance can be connected to VS Code
|
||||
at a time.
|
||||
</Text>
|
||||
<Text color="warning">Note: Only one Claude Code instance can be connected to VS Code at a time.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{availableIDEs.length !== 0 && !isSupportedTerminal() && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Tip: You can enable auto-connect to IDE in /config or with the
|
||||
--ide flag
|
||||
</Text>
|
||||
<Text dimColor>Tip: You can enable auto-connect to IDE in /config or with the --ide flag</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{unavailableIDEs.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>
|
||||
Found {unavailableIDEs.length} other running IDE(s). However,
|
||||
their workspace/project directories do not match the current cwd.
|
||||
Found {unavailableIDEs.length} other running IDE(s). However, their workspace/project directories do not
|
||||
match the current cwd.
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{unavailableIDEs.map((ide, index) => (
|
||||
@@ -173,82 +154,64 @@ function IDEScreen({
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function findCurrentIDE(
|
||||
availableIDEs: DetectedIDEInfo[],
|
||||
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>,
|
||||
): Promise<DetectedIDEInfo | null> {
|
||||
const currentConfig = dynamicMcpConfig?.ide
|
||||
if (
|
||||
!currentConfig ||
|
||||
(currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide')
|
||||
) {
|
||||
return null
|
||||
const currentConfig = dynamicMcpConfig?.ide;
|
||||
if (!currentConfig || (currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide')) {
|
||||
return null;
|
||||
}
|
||||
for (const ide of availableIDEs) {
|
||||
if (ide.url === currentConfig.url) {
|
||||
return ide
|
||||
return ide;
|
||||
}
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
type IDEOpenSelectionProps = {
|
||||
availableIDEs: DetectedIDEInfo[]
|
||||
onSelectIDE: (ide?: DetectedIDEInfo) => void
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
availableIDEs: DetectedIDEInfo[];
|
||||
onSelectIDE: (ide?: DetectedIDEInfo) => void;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
function IDEOpenSelection({
|
||||
availableIDEs,
|
||||
onSelectIDE,
|
||||
onDone,
|
||||
}: IDEOpenSelectionProps): React.ReactNode {
|
||||
const [selectedValue, setSelectedValue] = useState(
|
||||
availableIDEs[0]?.port?.toString() ?? '',
|
||||
)
|
||||
function IDEOpenSelection({ availableIDEs, onSelectIDE, onDone }: IDEOpenSelectionProps): React.ReactNode {
|
||||
const [selectedValue, setSelectedValue] = useState(availableIDEs[0]?.port?.toString() ?? '');
|
||||
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
const selectedIDE = availableIDEs.find(
|
||||
ide => ide.port === parseInt(value, 10),
|
||||
)
|
||||
onSelectIDE(selectedIDE)
|
||||
const selectedIDE = availableIDEs.find(ide => ide.port === parseInt(value, 10));
|
||||
onSelectIDE(selectedIDE);
|
||||
},
|
||||
[availableIDEs, onSelectIDE],
|
||||
)
|
||||
);
|
||||
|
||||
const options = availableIDEs.map(ide => ({
|
||||
label: ide.name,
|
||||
value: ide.port.toString(),
|
||||
}))
|
||||
}));
|
||||
|
||||
function handleCancel(): void {
|
||||
onDone('IDE selection cancelled', { display: 'system' })
|
||||
onDone('IDE selection cancelled', { display: 'system' });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Select an IDE to open the project"
|
||||
onCancel={handleCancel}
|
||||
color="ide"
|
||||
>
|
||||
<Dialog title="Select an IDE to open the project" onCancel={handleCancel} color="ide">
|
||||
<Select
|
||||
defaultValue={selectedValue}
|
||||
defaultFocusValue={selectedValue}
|
||||
options={options}
|
||||
onChange={value => {
|
||||
setSelectedValue(value)
|
||||
handleSelectIDE(value)
|
||||
setSelectedValue(value);
|
||||
handleSelectIDE(value);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RunningIDESelector({
|
||||
@@ -256,88 +219,72 @@ function RunningIDESelector({
|
||||
onSelectIDE,
|
||||
onDone,
|
||||
}: {
|
||||
runningIDEs: IdeType[]
|
||||
onSelectIDE: (ide: IdeType) => void
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
runningIDEs: IdeType[];
|
||||
onSelectIDE: (ide: IdeType) => void;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
}): React.ReactNode {
|
||||
const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? '')
|
||||
const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? '');
|
||||
|
||||
const handleSelectIDE = useCallback(
|
||||
(value: string) => {
|
||||
onSelectIDE(value as IdeType)
|
||||
onSelectIDE(value as IdeType);
|
||||
},
|
||||
[onSelectIDE],
|
||||
)
|
||||
);
|
||||
|
||||
const options = runningIDEs.map(ide => ({
|
||||
label: toIDEDisplayName(ide),
|
||||
value: ide,
|
||||
}))
|
||||
}));
|
||||
|
||||
function handleCancel(): void {
|
||||
onDone('IDE selection cancelled', { display: 'system' })
|
||||
onDone('IDE selection cancelled', { display: 'system' });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Select IDE to install extension"
|
||||
onCancel={handleCancel}
|
||||
color="ide"
|
||||
>
|
||||
<Dialog title="Select IDE to install extension" onCancel={handleCancel} color="ide">
|
||||
<Select
|
||||
defaultFocusValue={selectedValue}
|
||||
options={options}
|
||||
onChange={value => {
|
||||
setSelectedValue(value)
|
||||
handleSelectIDE(value)
|
||||
setSelectedValue(value);
|
||||
handleSelectIDE(value);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InstallOnMount({
|
||||
ide,
|
||||
onInstall,
|
||||
}: {
|
||||
ide: IdeType
|
||||
onInstall: (ide: IdeType) => void
|
||||
}): React.ReactNode {
|
||||
function InstallOnMount({ ide, onInstall }: { ide: IdeType; onInstall: (ide: IdeType) => void }): React.ReactNode {
|
||||
useEffect(() => {
|
||||
onInstall(ide)
|
||||
}, [ide, onInstall])
|
||||
return null
|
||||
onInstall(ide);
|
||||
}, [ide, onInstall]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void,
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
|
||||
context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode | null> {
|
||||
logEvent('tengu_ext_ide_command', {})
|
||||
logEvent('tengu_ext_ide_command', {});
|
||||
const {
|
||||
options: { dynamicMcpConfig },
|
||||
onChangeDynamicMcpConfig,
|
||||
} = context
|
||||
} = context;
|
||||
|
||||
// Handle 'open' argument
|
||||
if (args?.trim() === 'open') {
|
||||
const worktreeSession = getCurrentWorktreeSession()
|
||||
const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd()
|
||||
const worktreeSession = getCurrentWorktreeSession();
|
||||
const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd();
|
||||
|
||||
// Detect available IDEs
|
||||
const detectedIDEs = await detectIDEs(true)
|
||||
const availableIDEs = detectedIDEs.filter(ide => ide.isValid)
|
||||
const detectedIDEs = await detectIDEs(true);
|
||||
const availableIDEs = detectedIDEs.filter(ide => ide.isValid);
|
||||
|
||||
if (availableIDEs.length === 0) {
|
||||
onDone('No IDEs with Claude Code extension detected.')
|
||||
return null
|
||||
onDone('No IDEs with Claude Code extension detected.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return IDE selection component
|
||||
@@ -346,8 +293,8 @@ export async function call(
|
||||
availableIDEs={availableIDEs}
|
||||
onSelectIDE={async (selectedIDE?: DetectedIDEInfo) => {
|
||||
if (!selectedIDE) {
|
||||
onDone('No IDE selected.')
|
||||
return
|
||||
onDone('No IDE selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to open the project in the selected IDE
|
||||
@@ -357,58 +304,50 @@ export async function call(
|
||||
selectedIDE.name.toLowerCase().includes('windsurf')
|
||||
) {
|
||||
// VS Code-based IDEs
|
||||
const { code } = await execFileNoThrow('code', [targetPath])
|
||||
const { code } = await execFileNoThrow('code', [targetPath]);
|
||||
if (code === 0) {
|
||||
onDone(
|
||||
`Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`,
|
||||
)
|
||||
onDone(`Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`);
|
||||
} else {
|
||||
onDone(
|
||||
`Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`,
|
||||
)
|
||||
onDone(`Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`);
|
||||
}
|
||||
} else if (isSupportedJetBrainsTerminal()) {
|
||||
// JetBrains IDEs - they usually open via their CLI tools
|
||||
onDone(
|
||||
`Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onDone(
|
||||
`Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
onDone={() => {
|
||||
onDone('Exited without opening IDE', { display: 'system' })
|
||||
onDone('Exited without opening IDE', { display: 'system' });
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const detectedIDEs = await detectIDEs(true)
|
||||
const detectedIDEs = await detectIDEs(true);
|
||||
|
||||
// If no IDEs with extensions detected, check for running IDEs and offer to install
|
||||
if (
|
||||
detectedIDEs.length === 0 &&
|
||||
context.onInstallIDEExtension &&
|
||||
!isSupportedTerminal()
|
||||
) {
|
||||
const runningIDEs = await detectRunningIDEs()
|
||||
if (detectedIDEs.length === 0 && context.onInstallIDEExtension && !isSupportedTerminal()) {
|
||||
const runningIDEs = await detectRunningIDEs();
|
||||
|
||||
const onInstall = (ide: IdeType) => {
|
||||
if (context.onInstallIDEExtension) {
|
||||
context.onInstallIDEExtension(ide)
|
||||
context.onInstallIDEExtension(ide);
|
||||
// The completion message will be shown after installation
|
||||
if (isJetBrainsIde(ide)) {
|
||||
onDone(
|
||||
`Installed plugin to ${chalk.bold(toIDEDisplayName(ide))}\n` +
|
||||
`Please ${chalk.bold('restart your IDE')} completely for it to take effect`,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`)
|
||||
onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (runningIDEs.length > 1) {
|
||||
// Show selector when multiple IDEs are running
|
||||
@@ -417,19 +356,19 @@ export async function call(
|
||||
runningIDEs={runningIDEs}
|
||||
onSelectIDE={onInstall}
|
||||
onDone={() => {
|
||||
onDone('No IDE selected.', { display: 'system' })
|
||||
onDone('No IDE selected.', { display: 'system' });
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else if (runningIDEs.length === 1) {
|
||||
return <InstallOnMount ide={runningIDEs[0]!} onInstall={onInstall} />
|
||||
return <InstallOnMount ide={runningIDEs[0]!} onInstall={onInstall} />;
|
||||
}
|
||||
}
|
||||
|
||||
const availableIDEs = detectedIDEs.filter(ide => ide.isValid)
|
||||
const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid)
|
||||
const availableIDEs = detectedIDEs.filter(ide => ide.isValid);
|
||||
const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid);
|
||||
|
||||
const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig)
|
||||
const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig);
|
||||
|
||||
return (
|
||||
<IDECommandFlow
|
||||
@@ -440,25 +379,20 @@ export async function call(
|
||||
onChangeDynamicMcpConfig={onChangeDynamicMcpConfig}
|
||||
onDone={onDone}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Connection timeout slightly longer than the 30s MCP connection timeout
|
||||
const IDE_CONNECTION_TIMEOUT_MS = 35000
|
||||
const IDE_CONNECTION_TIMEOUT_MS = 35000;
|
||||
|
||||
type IDECommandFlowProps = {
|
||||
availableIDEs: DetectedIDEInfo[]
|
||||
unavailableIDEs: DetectedIDEInfo[]
|
||||
currentIDE: DetectedIDEInfo | null
|
||||
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>
|
||||
onChangeDynamicMcpConfig?: (
|
||||
config: Record<string, ScopedMcpServerConfig>,
|
||||
) => void
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
}
|
||||
availableIDEs: DetectedIDEInfo[];
|
||||
unavailableIDEs: DetectedIDEInfo[];
|
||||
currentIDE: DetectedIDEInfo | null;
|
||||
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>;
|
||||
onChangeDynamicMcpConfig?: (config: Record<string, ScopedMcpServerConfig>) => void;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
};
|
||||
|
||||
function IDECommandFlow({
|
||||
availableIDEs,
|
||||
@@ -468,80 +402,66 @@ function IDECommandFlow({
|
||||
onChangeDynamicMcpConfig,
|
||||
onDone,
|
||||
}: IDECommandFlowProps): React.ReactNode {
|
||||
const [connectingIDE, setConnectingIDE] = useState<DetectedIDEInfo | null>(
|
||||
null,
|
||||
)
|
||||
const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide'))
|
||||
const setAppState = useSetAppState()
|
||||
const isFirstCheckRef = useRef(true)
|
||||
const [connectingIDE, setConnectingIDE] = useState<DetectedIDEInfo | null>(null);
|
||||
const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide'));
|
||||
const setAppState = useSetAppState();
|
||||
const isFirstCheckRef = useRef(true);
|
||||
|
||||
// Watch for connection result
|
||||
useEffect(() => {
|
||||
if (!connectingIDE) return
|
||||
if (!connectingIDE) return;
|
||||
// Skip the first check — it reflects stale state from before the
|
||||
// config change was dispatched
|
||||
if (isFirstCheckRef.current) {
|
||||
isFirstCheckRef.current = false
|
||||
return
|
||||
isFirstCheckRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!ideClient || ideClient.type === 'pending') return
|
||||
if (!ideClient || ideClient.type === 'pending') return;
|
||||
if (ideClient.type === 'connected') {
|
||||
onDone(`Connected to ${connectingIDE.name}.`)
|
||||
onDone(`Connected to ${connectingIDE.name}.`);
|
||||
} else if (ideClient.type === 'failed') {
|
||||
onDone(`Failed to connect to ${connectingIDE.name}.`)
|
||||
onDone(`Failed to connect to ${connectingIDE.name}.`);
|
||||
}
|
||||
}, [ideClient, connectingIDE, onDone])
|
||||
}, [ideClient, connectingIDE, onDone]);
|
||||
|
||||
// Timeout fallback
|
||||
useEffect(() => {
|
||||
if (!connectingIDE) return
|
||||
const timer = setTimeout(
|
||||
onDone,
|
||||
IDE_CONNECTION_TIMEOUT_MS,
|
||||
`Connection to ${connectingIDE.name} timed out.`,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [connectingIDE, onDone])
|
||||
if (!connectingIDE) return;
|
||||
const timer = setTimeout(onDone, IDE_CONNECTION_TIMEOUT_MS, `Connection to ${connectingIDE.name} timed out.`);
|
||||
return () => clearTimeout(timer);
|
||||
}, [connectingIDE, onDone]);
|
||||
|
||||
const handleSelectIDE = useCallback(
|
||||
(selectedIDE?: DetectedIDEInfo) => {
|
||||
if (!onChangeDynamicMcpConfig) {
|
||||
onDone('Error connecting to IDE.')
|
||||
return
|
||||
onDone('Error connecting to IDE.');
|
||||
return;
|
||||
}
|
||||
const newConfig = { ...(dynamicMcpConfig || {}) }
|
||||
const newConfig = { ...(dynamicMcpConfig || {}) };
|
||||
if (currentIDE) {
|
||||
delete newConfig.ide
|
||||
delete newConfig.ide;
|
||||
}
|
||||
if (!selectedIDE) {
|
||||
// Close the MCP transport and remove the client from state
|
||||
if (ideClient && ideClient.type === 'connected' && currentIDE) {
|
||||
// Null out onclose to prevent auto-reconnection
|
||||
ideClient.client.onclose = () => {}
|
||||
void clearServerCache('ide', ideClient.config)
|
||||
ideClient.client.onclose = () => {};
|
||||
void clearServerCache('ide', ideClient.config);
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
mcp: {
|
||||
...prev.mcp,
|
||||
clients: prev.mcp.clients.filter(c => c.name !== 'ide'),
|
||||
tools: prev.mcp.tools.filter(
|
||||
t => !t.name?.startsWith('mcp__ide__'),
|
||||
),
|
||||
commands: prev.mcp.commands.filter(
|
||||
c => !c.name?.startsWith('mcp__ide__'),
|
||||
),
|
||||
tools: prev.mcp.tools.filter(t => !t.name?.startsWith('mcp__ide__')),
|
||||
commands: prev.mcp.commands.filter(c => !c.name?.startsWith('mcp__ide__')),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
}
|
||||
onChangeDynamicMcpConfig(newConfig)
|
||||
onDone(
|
||||
currentIDE
|
||||
? `Disconnected from ${currentIDE.name}.`
|
||||
: 'No IDE selected.',
|
||||
)
|
||||
return
|
||||
onChangeDynamicMcpConfig(newConfig);
|
||||
onDone(currentIDE ? `Disconnected from ${currentIDE.name}.` : 'No IDE selected.');
|
||||
return;
|
||||
}
|
||||
const url = selectedIDE.url
|
||||
const url = selectedIDE.url;
|
||||
newConfig.ide = {
|
||||
type: url.startsWith('ws:') ? 'ws-ide' : 'sse-ide',
|
||||
url: url,
|
||||
@@ -549,23 +469,16 @@ function IDECommandFlow({
|
||||
authToken: selectedIDE.authToken,
|
||||
ideRunningInWindows: selectedIDE.ideRunningInWindows,
|
||||
scope: 'dynamic' as const,
|
||||
} as ScopedMcpServerConfig
|
||||
isFirstCheckRef.current = true
|
||||
setConnectingIDE(selectedIDE)
|
||||
onChangeDynamicMcpConfig(newConfig)
|
||||
} as ScopedMcpServerConfig;
|
||||
isFirstCheckRef.current = true;
|
||||
setConnectingIDE(selectedIDE);
|
||||
onChangeDynamicMcpConfig(newConfig);
|
||||
},
|
||||
[
|
||||
dynamicMcpConfig,
|
||||
currentIDE,
|
||||
ideClient,
|
||||
setAppState,
|
||||
onChangeDynamicMcpConfig,
|
||||
onDone,
|
||||
],
|
||||
)
|
||||
[dynamicMcpConfig, currentIDE, ideClient, setAppState, onChangeDynamicMcpConfig, onDone],
|
||||
);
|
||||
|
||||
if (connectingIDE) {
|
||||
return <Text dimColor>Connecting to {connectingIDE.name}…</Text>
|
||||
return <Text dimColor>Connecting to {connectingIDE.name}…</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -576,7 +489,7 @@ function IDECommandFlow({
|
||||
onClose={() => onDone('IDE selection cancelled', { display: 'system' })}
|
||||
onSelect={handleSelectIDE}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -585,46 +498,43 @@ function IDECommandFlow({
|
||||
* @param maxLength Maximum total length of the formatted string
|
||||
* @returns Formatted string with folder paths
|
||||
*/
|
||||
export function formatWorkspaceFolders(
|
||||
folders: string[],
|
||||
maxLength: number = 100,
|
||||
): string {
|
||||
if (folders.length === 0) return ''
|
||||
export function formatWorkspaceFolders(folders: string[], maxLength: number = 100): string {
|
||||
if (folders.length === 0) return '';
|
||||
|
||||
const cwd = getCwd()
|
||||
const cwd = getCwd();
|
||||
|
||||
// Only show first 2 workspaces
|
||||
const foldersToShow = folders.slice(0, 2)
|
||||
const hasMore = folders.length > 2
|
||||
const foldersToShow = folders.slice(0, 2);
|
||||
const hasMore = folders.length > 2;
|
||||
|
||||
// Account for ", …" if there are more folders
|
||||
const ellipsisOverhead = hasMore ? 3 : 0 // ", …"
|
||||
const ellipsisOverhead = hasMore ? 3 : 0; // ", …"
|
||||
|
||||
// Account for commas and spaces between paths (", " = 2 chars per separator)
|
||||
const separatorOverhead = (foldersToShow.length - 1) * 2
|
||||
const availableLength = maxLength - separatorOverhead - ellipsisOverhead
|
||||
const separatorOverhead = (foldersToShow.length - 1) * 2;
|
||||
const availableLength = maxLength - separatorOverhead - ellipsisOverhead;
|
||||
|
||||
const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length)
|
||||
const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length);
|
||||
|
||||
const cwdNFC = cwd.normalize('NFC')
|
||||
const cwdNFC = cwd.normalize('NFC');
|
||||
const formattedFolders = foldersToShow.map(folder => {
|
||||
// Strip cwd from the beginning if present
|
||||
// Normalize both to NFC for consistent comparison (macOS uses NFD paths)
|
||||
const folderNFC = folder.normalize('NFC')
|
||||
const folderNFC = folder.normalize('NFC');
|
||||
if (folderNFC.startsWith(cwdNFC + path.sep)) {
|
||||
folder = folderNFC.slice(cwdNFC.length + 1)
|
||||
folder = folderNFC.slice(cwdNFC.length + 1);
|
||||
}
|
||||
|
||||
if (folder.length <= maxLengthPerPath) {
|
||||
return folder
|
||||
return folder;
|
||||
}
|
||||
return '…' + folder.slice(-(maxLengthPerPath - 1))
|
||||
})
|
||||
return '…' + folder.slice(-(maxLengthPerPath - 1));
|
||||
});
|
||||
|
||||
let result = formattedFolders.join(', ')
|
||||
let result = formattedFolders.join(', ');
|
||||
if (hasMore) {
|
||||
result += ', …'
|
||||
result += ', …';
|
||||
}
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -895,7 +895,9 @@ async function summarizeTranscriptChunk(chunk: string): Promise<string> {
|
||||
},
|
||||
})
|
||||
|
||||
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
|
||||
const text = extractTextContent(
|
||||
result.message.content as readonly { readonly type: string }[],
|
||||
)
|
||||
return text || chunk.slice(0, 2000)
|
||||
} catch {
|
||||
// On error, just return truncated chunk
|
||||
@@ -1038,7 +1040,9 @@ RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
|
||||
},
|
||||
})
|
||||
|
||||
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
|
||||
const text = extractTextContent(
|
||||
result.message.content as readonly { readonly type: string }[],
|
||||
)
|
||||
|
||||
// Parse JSON from response
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||||
@@ -1589,7 +1593,9 @@ async function generateSectionInsight(
|
||||
},
|
||||
})
|
||||
|
||||
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
|
||||
const text = extractTextContent(
|
||||
result.message.content as readonly { readonly type: string }[],
|
||||
)
|
||||
|
||||
if (text) {
|
||||
// Parse JSON from response
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
interface ApiKeyStepProps {
|
||||
existingApiKey: string | null
|
||||
useExistingKey: boolean
|
||||
apiKeyOrOAuthToken: string
|
||||
onApiKeyChange: (value: string) => void
|
||||
onToggleUseExistingKey: (useExisting: boolean) => void
|
||||
onSubmit: () => void
|
||||
onCreateOAuthToken?: () => void
|
||||
selectedOption?: 'existing' | 'new' | 'oauth'
|
||||
onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void
|
||||
existingApiKey: string | null;
|
||||
useExistingKey: boolean;
|
||||
apiKeyOrOAuthToken: string;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
onToggleUseExistingKey: (useExisting: boolean) => void;
|
||||
onSubmit: () => void;
|
||||
onCreateOAuthToken?: () => void;
|
||||
selectedOption?: 'existing' | 'new' | 'oauth';
|
||||
onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void;
|
||||
}
|
||||
|
||||
export function ApiKeyStep({
|
||||
@@ -23,62 +23,47 @@ export function ApiKeyStep({
|
||||
onSubmit,
|
||||
onToggleUseExistingKey,
|
||||
onCreateOAuthToken,
|
||||
selectedOption = existingApiKey
|
||||
? 'existing'
|
||||
: onCreateOAuthToken
|
||||
? 'oauth'
|
||||
: 'new',
|
||||
selectedOption = existingApiKey ? 'existing' : onCreateOAuthToken ? 'oauth' : 'new',
|
||||
onSelectOption,
|
||||
}: ApiKeyStepProps) {
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const terminalSize = useTerminalSize()
|
||||
const [theme] = useTheme()
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const terminalSize = useTerminalSize();
|
||||
const [theme] = useTheme();
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (selectedOption === 'new' && onCreateOAuthToken) {
|
||||
// From 'new' go up to 'oauth'
|
||||
onSelectOption?.('oauth')
|
||||
onSelectOption?.('oauth');
|
||||
} else if (selectedOption === 'oauth' && existingApiKey) {
|
||||
// From 'oauth' go up to 'existing' (only if it exists)
|
||||
onSelectOption?.('existing')
|
||||
onToggleUseExistingKey(true)
|
||||
onSelectOption?.('existing');
|
||||
onToggleUseExistingKey(true);
|
||||
}
|
||||
}, [
|
||||
selectedOption,
|
||||
onCreateOAuthToken,
|
||||
existingApiKey,
|
||||
onSelectOption,
|
||||
onToggleUseExistingKey,
|
||||
])
|
||||
}, [selectedOption, onCreateOAuthToken, existingApiKey, onSelectOption, onToggleUseExistingKey]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (selectedOption === 'existing') {
|
||||
// From 'existing' go down to 'oauth' (if available) or 'new'
|
||||
onSelectOption?.(onCreateOAuthToken ? 'oauth' : 'new')
|
||||
onToggleUseExistingKey(false)
|
||||
onSelectOption?.(onCreateOAuthToken ? 'oauth' : 'new');
|
||||
onToggleUseExistingKey(false);
|
||||
} else if (selectedOption === 'oauth') {
|
||||
// From 'oauth' go down to 'new'
|
||||
onSelectOption?.('new')
|
||||
onSelectOption?.('new');
|
||||
}
|
||||
}, [
|
||||
selectedOption,
|
||||
onCreateOAuthToken,
|
||||
onSelectOption,
|
||||
onToggleUseExistingKey,
|
||||
])
|
||||
}, [selectedOption, onCreateOAuthToken, onSelectOption, onToggleUseExistingKey]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (selectedOption === 'oauth' && onCreateOAuthToken) {
|
||||
onCreateOAuthToken()
|
||||
onCreateOAuthToken();
|
||||
} else {
|
||||
onSubmit()
|
||||
onSubmit();
|
||||
}
|
||||
}, [selectedOption, onCreateOAuthToken, onSubmit])
|
||||
}, [selectedOption, onCreateOAuthToken, onSubmit]);
|
||||
|
||||
// When the text input is visible, omit confirm:yes so bare 'y' passes
|
||||
// through to the input instead of submitting. TextInput's onSubmit handles
|
||||
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
|
||||
const isTextInputVisible = selectedOption === 'new'
|
||||
const isTextInputVisible = selectedOption === 'new';
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
@@ -86,14 +71,14 @@ export function ApiKeyStep({
|
||||
'confirm:yes': handleConfirm,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: !isTextInputVisible },
|
||||
)
|
||||
);
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
'confirm:next': handleNext,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: isTextInputVisible },
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -105,9 +90,7 @@ export function ApiKeyStep({
|
||||
{existingApiKey && (
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
{selectedOption === 'existing'
|
||||
? color('success', theme)('> ')
|
||||
: ' '}
|
||||
{selectedOption === 'existing' ? color('success', theme)('> ') : ' '}
|
||||
Use your existing Claude Code API key
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -115,9 +98,7 @@ export function ApiKeyStep({
|
||||
{onCreateOAuthToken && (
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
{selectedOption === 'oauth'
|
||||
? color('success', theme)('> ')
|
||||
: ' '}
|
||||
{selectedOption === 'oauth' ? color('success', theme)('> ') : ' '}
|
||||
Create a long-lived token with your Claude subscription
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -148,5 +129,5 @@ export function ApiKeyStep({
|
||||
<Text dimColor>↑/↓ to select · Enter to continue</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
interface CheckExistingSecretStepProps {
|
||||
useExistingSecret: boolean
|
||||
secretName: string
|
||||
onToggleUseExistingSecret: (useExisting: boolean) => void
|
||||
onSecretNameChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
useExistingSecret: boolean;
|
||||
secretName: string;
|
||||
onToggleUseExistingSecret: (useExisting: boolean) => void;
|
||||
onSecretNameChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function CheckExistingSecretStep({
|
||||
@@ -19,21 +19,15 @@ export function CheckExistingSecretStep({
|
||||
onSecretNameChange,
|
||||
onSubmit,
|
||||
}: CheckExistingSecretStepProps) {
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const terminalSize = useTerminalSize()
|
||||
const [theme] = useTheme()
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const terminalSize = useTerminalSize();
|
||||
const [theme] = useTheme();
|
||||
|
||||
// When the text input is visible, omit confirm:yes so bare 'y' passes
|
||||
// through to the input instead of submitting. TextInput's onSubmit handles
|
||||
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
|
||||
const handlePrevious = useCallback(
|
||||
() => onToggleUseExistingSecret(true),
|
||||
[onToggleUseExistingSecret],
|
||||
)
|
||||
const handleNext = useCallback(
|
||||
() => onToggleUseExistingSecret(false),
|
||||
[onToggleUseExistingSecret],
|
||||
)
|
||||
const handlePrevious = useCallback(() => onToggleUseExistingSecret(true), [onToggleUseExistingSecret]);
|
||||
const handleNext = useCallback(() => onToggleUseExistingSecret(false), [onToggleUseExistingSecret]);
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
@@ -41,14 +35,14 @@ export function CheckExistingSecretStep({
|
||||
'confirm:yes': onSubmit,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: useExistingSecret },
|
||||
)
|
||||
);
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
'confirm:next': handleNext,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: !useExistingSecret },
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -58,9 +52,7 @@ export function CheckExistingSecretStep({
|
||||
<Text dimColor>Setup API key secret</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text color="warning">
|
||||
ANTHROPIC_API_KEY already exists in repository secrets!
|
||||
</Text>
|
||||
<Text color="warning">ANTHROPIC_API_KEY already exists in repository secrets!</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text>Would you like to:</Text>
|
||||
@@ -80,9 +72,7 @@ export function CheckExistingSecretStep({
|
||||
{!useExistingSecret && (
|
||||
<>
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
Enter new secret name (alphanumeric with underscores):
|
||||
</Text>
|
||||
<Text>Enter new secret name (alphanumeric with underscores):</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={secretName}
|
||||
@@ -102,5 +92,5 @@ export function CheckExistingSecretStep({
|
||||
<Text dimColor>↑/↓ to select · Enter to continue</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
|
||||
export function CheckGitHubStep() {
|
||||
return <Text>Checking GitHub CLI installation…</Text>
|
||||
return <Text>Checking GitHub CLI installation…</Text>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
interface ChooseRepoStepProps {
|
||||
currentRepo: string | null
|
||||
useCurrentRepo: boolean
|
||||
repoUrl: string
|
||||
onRepoUrlChange: (value: string) => void
|
||||
onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void
|
||||
onSubmit: () => void
|
||||
currentRepo: string | null;
|
||||
useCurrentRepo: boolean;
|
||||
repoUrl: string;
|
||||
onRepoUrlChange: (value: string) => void;
|
||||
onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function ChooseRepoStep({
|
||||
@@ -21,32 +21,32 @@ export function ChooseRepoStep({
|
||||
onSubmit,
|
||||
onToggleUseCurrentRepo,
|
||||
}: ChooseRepoStepProps) {
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const [showEmptyError, setShowEmptyError] = useState(false)
|
||||
const terminalSize = useTerminalSize()
|
||||
const textInputColumns = terminalSize.columns
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const [showEmptyError, setShowEmptyError] = useState(false);
|
||||
const terminalSize = useTerminalSize();
|
||||
const textInputColumns = terminalSize.columns;
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const repoName = useCurrentRepo ? currentRepo : repoUrl
|
||||
const repoName = useCurrentRepo ? currentRepo : repoUrl;
|
||||
if (!repoName?.trim()) {
|
||||
setShowEmptyError(true)
|
||||
return
|
||||
setShowEmptyError(true);
|
||||
return;
|
||||
}
|
||||
onSubmit()
|
||||
}, [useCurrentRepo, currentRepo, repoUrl, onSubmit])
|
||||
onSubmit();
|
||||
}, [useCurrentRepo, currentRepo, repoUrl, onSubmit]);
|
||||
|
||||
// When the text input is visible, omit confirm:yes so bare 'y' passes
|
||||
// through to the input instead of submitting. TextInput's onSubmit handles
|
||||
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
|
||||
const isTextInputVisible = !useCurrentRepo || !currentRepo
|
||||
const isTextInputVisible = !useCurrentRepo || !currentRepo;
|
||||
const handlePrevious = useCallback(() => {
|
||||
onToggleUseCurrentRepo(true)
|
||||
setShowEmptyError(false)
|
||||
}, [onToggleUseCurrentRepo])
|
||||
onToggleUseCurrentRepo(true);
|
||||
setShowEmptyError(false);
|
||||
}, [onToggleUseCurrentRepo]);
|
||||
const handleNext = useCallback(() => {
|
||||
onToggleUseCurrentRepo(false)
|
||||
setShowEmptyError(false)
|
||||
}, [onToggleUseCurrentRepo])
|
||||
onToggleUseCurrentRepo(false);
|
||||
setShowEmptyError(false);
|
||||
}, [onToggleUseCurrentRepo]);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
@@ -55,14 +55,14 @@ export function ChooseRepoStep({
|
||||
'confirm:yes': handleSubmit,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: !isTextInputVisible },
|
||||
)
|
||||
);
|
||||
useKeybindings(
|
||||
{
|
||||
'confirm:previous': handlePrevious,
|
||||
'confirm:next': handleNext,
|
||||
},
|
||||
{ context: 'Confirmation', isActive: isTextInputVisible },
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -73,10 +73,7 @@ export function ChooseRepoStep({
|
||||
</Box>
|
||||
{currentRepo && (
|
||||
<Box marginBottom={1}>
|
||||
<Text
|
||||
bold={useCurrentRepo}
|
||||
color={useCurrentRepo ? 'permission' : undefined}
|
||||
>
|
||||
<Text bold={useCurrentRepo} color={useCurrentRepo ? 'permission' : undefined}>
|
||||
{useCurrentRepo ? '> ' : ' '}
|
||||
Use current repository: {currentRepo}
|
||||
</Text>
|
||||
@@ -96,8 +93,8 @@ export function ChooseRepoStep({
|
||||
<TextInput
|
||||
value={repoUrl}
|
||||
onChange={value => {
|
||||
onRepoUrlChange(value)
|
||||
setShowEmptyError(false)
|
||||
onRepoUrlChange(value);
|
||||
setShowEmptyError(false);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
focus={true}
|
||||
@@ -116,10 +113,8 @@ export function ChooseRepoStep({
|
||||
</Box>
|
||||
)}
|
||||
<Box marginLeft={3}>
|
||||
<Text dimColor>
|
||||
{currentRepo ? '↑/↓ to select · ' : ''}Enter to continue
|
||||
</Text>
|
||||
<Text dimColor>{currentRepo ? '↑/↓ to select · ' : ''}Enter to continue</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Workflow } from './types.js'
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Workflow } from './types.js';
|
||||
|
||||
interface CreatingStepProps {
|
||||
currentWorkflowInstallStep: number
|
||||
secretExists: boolean
|
||||
useExistingSecret: boolean
|
||||
secretName: string
|
||||
skipWorkflow?: boolean
|
||||
selectedWorkflows: Workflow[]
|
||||
currentWorkflowInstallStep: number;
|
||||
secretExists: boolean;
|
||||
useExistingSecret: boolean;
|
||||
secretName: string;
|
||||
skipWorkflow?: boolean;
|
||||
selectedWorkflows: Workflow[];
|
||||
}
|
||||
|
||||
export function CreatingStep({
|
||||
@@ -22,21 +22,15 @@ export function CreatingStep({
|
||||
const progressSteps = skipWorkflow
|
||||
? [
|
||||
'Getting repository information',
|
||||
secretExists && useExistingSecret
|
||||
? 'Using existing API key secret'
|
||||
: `Setting up ${secretName} secret`,
|
||||
secretExists && useExistingSecret ? 'Using existing API key secret' : `Setting up ${secretName} secret`,
|
||||
]
|
||||
: [
|
||||
'Getting repository information',
|
||||
'Creating branch',
|
||||
selectedWorkflows.length > 1
|
||||
? 'Creating workflow files'
|
||||
: 'Creating workflow file',
|
||||
secretExists && useExistingSecret
|
||||
? 'Using existing API key secret'
|
||||
: `Setting up ${secretName} secret`,
|
||||
selectedWorkflows.length > 1 ? 'Creating workflow files' : 'Creating workflow file',
|
||||
secretExists && useExistingSecret ? 'Using existing API key secret' : `Setting up ${secretName} secret`,
|
||||
'Opening pull request page',
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -46,33 +40,25 @@ export function CreatingStep({
|
||||
<Text dimColor>Create GitHub Actions workflow</Text>
|
||||
</Box>
|
||||
{progressSteps.map((stepText, index) => {
|
||||
let status: 'completed' | 'in-progress' | 'pending' = 'pending'
|
||||
let status: 'completed' | 'in-progress' | 'pending' = 'pending';
|
||||
|
||||
if (index < currentWorkflowInstallStep) {
|
||||
status = 'completed'
|
||||
status = 'completed';
|
||||
} else if (index === currentWorkflowInstallStep) {
|
||||
status = 'in-progress'
|
||||
status = 'in-progress';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Text
|
||||
color={
|
||||
status === 'completed'
|
||||
? 'success'
|
||||
: status === 'in-progress'
|
||||
? 'warning'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Text color={status === 'completed' ? 'success' : status === 'in-progress' ? 'warning' : undefined}>
|
||||
{status === 'completed' ? '✓ ' : ''}
|
||||
{stepText}
|
||||
{status === 'in-progress' ? '…' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import React from 'react'
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
interface ErrorStepProps {
|
||||
error: string | undefined
|
||||
errorReason?: string
|
||||
errorInstructions?: string[]
|
||||
error: string | undefined;
|
||||
errorReason?: string;
|
||||
errorInstructions?: string[];
|
||||
}
|
||||
|
||||
export function ErrorStep({
|
||||
error,
|
||||
errorReason,
|
||||
errorInstructions,
|
||||
}: ErrorStepProps) {
|
||||
export function ErrorStep({ error, errorReason, errorInstructions }: ErrorStepProps) {
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
@@ -38,8 +34,7 @@ export function ErrorStep({
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
For manual setup instructions, see:{' '}
|
||||
<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
|
||||
For manual setup instructions, see: <Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -47,5 +42,5 @@ export function ErrorStep({
|
||||
<Text dimColor>Press any key to exit</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Select } from 'src/components/CustomSelect/index.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Select } from 'src/components/CustomSelect/index.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
interface ExistingWorkflowStepProps {
|
||||
repoName: string
|
||||
onSelectAction: (action: 'update' | 'skip' | 'exit') => void
|
||||
repoName: string;
|
||||
onSelectAction: (action: 'update' | 'skip' | 'exit') => void;
|
||||
}
|
||||
|
||||
export function ExistingWorkflowStep({
|
||||
repoName,
|
||||
onSelectAction,
|
||||
}: ExistingWorkflowStepProps) {
|
||||
export function ExistingWorkflowStep({ repoName, onSelectAction }: ExistingWorkflowStepProps) {
|
||||
const options = [
|
||||
{
|
||||
label: 'Update workflow file with latest version',
|
||||
@@ -24,15 +21,15 @@ export function ExistingWorkflowStep({
|
||||
label: 'Exit without making changes',
|
||||
value: 'exit',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
onSelectAction(value as 'update' | 'skip' | 'exit')
|
||||
}
|
||||
onSelectAction(value as 'update' | 'skip' | 'exit');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onSelectAction('exit')
|
||||
}
|
||||
onSelectAction('exit');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
|
||||
@@ -43,28 +40,21 @@ export function ExistingWorkflowStep({
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>
|
||||
A Claude workflow file already exists at{' '}
|
||||
<Text color="claude">.github/workflows/claude.yml</Text>
|
||||
A Claude workflow file already exists at <Text color="claude">.github/workflows/claude.yml</Text>
|
||||
</Text>
|
||||
<Text dimColor>What would you like to do?</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleSelect}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<Select options={options} onChange={handleSelect} onCancel={handleCancel} />
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
View the latest workflow template at:{' '}
|
||||
<Text color="claude">
|
||||
https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml
|
||||
</Text>
|
||||
<Text color="claude">https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import figures from 'figures'
|
||||
import React from 'react'
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
interface InstallAppStepProps {
|
||||
repoUrl: string
|
||||
onSubmit: () => void
|
||||
repoUrl: string;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
|
||||
// Enter to submit
|
||||
useKeybinding('confirm:yes', onSubmit, { context: 'Confirmation' })
|
||||
useKeybinding('confirm:yes', onSubmit, { context: 'Confirmation' });
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
|
||||
@@ -33,9 +33,7 @@ export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Important: Make sure to grant access to this specific repository
|
||||
</Text>
|
||||
<Text dimColor>Important: Make sure to grant access to this specific repository</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text bold color="permission">
|
||||
@@ -44,10 +42,9 @@ export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Having trouble? See manual setup instructions at:{' '}
|
||||
<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
|
||||
Having trouble? See manual setup instructions at: <Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { Spinner } from '../../components/Spinner.js'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { type KeyboardEvent, setClipboard, Box, Link, Text } from '@anthropic/ink'
|
||||
import { OAuthService } from '../../services/oauth/index.js'
|
||||
import { saveOAuthTokensIfNeeded } from '../../utils/auth.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { Spinner } from '../../components/Spinner.js';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { type KeyboardEvent, setClipboard, Box, Link, Text } from '@anthropic/ink';
|
||||
import { OAuthService } from '../../services/oauth/index.js';
|
||||
import { saveOAuthTokensIfNeeded } from '../../utils/auth.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
|
||||
interface OAuthFlowStepProps {
|
||||
onSuccess: (token: string) => void
|
||||
onCancel: () => void
|
||||
onSuccess: (token: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type OAuthStatus =
|
||||
@@ -23,139 +23,132 @@ type OAuthStatus =
|
||||
| { state: 'processing' }
|
||||
| { state: 'success'; token: string }
|
||||
| { state: 'error'; message: string; toRetry?: OAuthStatus }
|
||||
| { state: 'about_to_retry'; nextState: OAuthStatus }
|
||||
| { state: 'about_to_retry'; nextState: OAuthStatus };
|
||||
|
||||
const PASTE_HERE_MSG = 'Paste code here if prompted > '
|
||||
const PASTE_HERE_MSG = 'Paste code here if prompted > ';
|
||||
|
||||
export function OAuthFlowStep({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: OAuthFlowStepProps): React.ReactNode {
|
||||
export function OAuthFlowStep({ onSuccess, onCancel }: OAuthFlowStepProps): React.ReactNode {
|
||||
const [oauthStatus, setOAuthStatus] = useState<OAuthStatus>({
|
||||
state: 'starting',
|
||||
})
|
||||
const [oauthService] = useState(() => new OAuthService())
|
||||
const [pastedCode, setPastedCode] = useState('')
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const [showPastePrompt, setShowPastePrompt] = useState(false)
|
||||
const [urlCopied, setUrlCopied] = useState(false)
|
||||
const timersRef = useRef<Set<NodeJS.Timeout>>(new Set())
|
||||
});
|
||||
const [oauthService] = useState(() => new OAuthService());
|
||||
const [pastedCode, setPastedCode] = useState('');
|
||||
const [cursorOffset, setCursorOffset] = useState(0);
|
||||
const [showPastePrompt, setShowPastePrompt] = useState(false);
|
||||
const [urlCopied, setUrlCopied] = useState(false);
|
||||
const timersRef = useRef<Set<NodeJS.Timeout>>(new Set());
|
||||
// Separate ref so startOAuth's timer clear doesn't cancel the urlCopied reset
|
||||
const urlCopiedTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
const urlCopiedTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
const terminalSize = useTerminalSize()
|
||||
const textInputColumns = Math.max(
|
||||
50,
|
||||
terminalSize.columns - PASTE_HERE_MSG.length - 4,
|
||||
)
|
||||
const terminalSize = useTerminalSize();
|
||||
const textInputColumns = Math.max(50, terminalSize.columns - PASTE_HERE_MSG.length - 4);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (oauthStatus.state !== 'error') return
|
||||
e.preventDefault()
|
||||
if (oauthStatus.state !== 'error') return;
|
||||
e.preventDefault();
|
||||
if (e.key === 'return' && oauthStatus.toRetry) {
|
||||
setPastedCode('')
|
||||
setCursorOffset(0)
|
||||
setPastedCode('');
|
||||
setCursorOffset(0);
|
||||
setOAuthStatus({
|
||||
state: 'about_to_retry',
|
||||
nextState: oauthStatus.toRetry,
|
||||
})
|
||||
});
|
||||
} else {
|
||||
onCancel()
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitCode(value: string, url: string) {
|
||||
try {
|
||||
// Expecting format "authorizationCode#state" from the authorization callback URL
|
||||
const [authorizationCode, state] = value.split('#')
|
||||
const [authorizationCode, state] = value.split('#');
|
||||
|
||||
if (!authorizationCode || !state) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: 'Invalid code. Please make sure the full code was copied',
|
||||
toRetry: { state: 'waiting_for_login', url },
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Track which path the user is taking (manual code entry)
|
||||
logEvent('tengu_oauth_manual_entry', {})
|
||||
logEvent('tengu_oauth_manual_entry', {});
|
||||
oauthService.handleManualAuthCodeInput({
|
||||
authorizationCode,
|
||||
state,
|
||||
})
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
logError(err)
|
||||
logError(err);
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: (err as Error).message,
|
||||
toRetry: { state: 'waiting_for_login', url },
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const startOAuth = useCallback(async () => {
|
||||
// Clear any existing timers when starting new OAuth flow
|
||||
timersRef.current.forEach(timer => clearTimeout(timer))
|
||||
timersRef.current.clear()
|
||||
timersRef.current.forEach(timer => clearTimeout(timer));
|
||||
timersRef.current.clear();
|
||||
|
||||
try {
|
||||
const result = await oauthService.startOAuthFlow(
|
||||
async url => {
|
||||
setOAuthStatus({ state: 'waiting_for_login', url })
|
||||
const timer = setTimeout(setShowPastePrompt, 3000, true)
|
||||
timersRef.current.add(timer)
|
||||
setOAuthStatus({ state: 'waiting_for_login', url });
|
||||
const timer = setTimeout(setShowPastePrompt, 3000, true);
|
||||
timersRef.current.add(timer);
|
||||
},
|
||||
{
|
||||
loginWithClaudeAi: true, // Always use Claude AI for subscription tokens
|
||||
inferenceOnly: true,
|
||||
expiresIn: 365 * 24 * 60 * 60, // 1 year
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// Show processing state
|
||||
setOAuthStatus({ state: 'processing' })
|
||||
setOAuthStatus({ state: 'processing' });
|
||||
|
||||
// OAuthFlowStep creates inference-only tokens for GitHub Actions, not a
|
||||
// replacement login. Use saveOAuthTokensIfNeeded directly to avoid
|
||||
// performLogout which would destroy the user's existing auth session.
|
||||
saveOAuthTokensIfNeeded(result)
|
||||
saveOAuthTokensIfNeeded(result);
|
||||
|
||||
// For OAuth flow, the access token can be used as an API key
|
||||
const timer1 = setTimeout(
|
||||
(setOAuthStatus, accessToken, onSuccess, timersRef) => {
|
||||
setOAuthStatus({ state: 'success', token: accessToken })
|
||||
setOAuthStatus({ state: 'success', token: accessToken });
|
||||
// Auto-continue after brief delay to show success
|
||||
const timer2 = setTimeout(onSuccess, 1000, accessToken)
|
||||
timersRef.current.add(timer2 as unknown as NodeJS.Timeout)
|
||||
const timer2 = setTimeout(onSuccess, 1000, accessToken);
|
||||
timersRef.current.add(timer2 as unknown as NodeJS.Timeout);
|
||||
},
|
||||
100,
|
||||
setOAuthStatus,
|
||||
result.accessToken,
|
||||
onSuccess,
|
||||
timersRef,
|
||||
)
|
||||
timersRef.current.add(timer1)
|
||||
);
|
||||
timersRef.current.add(timer1);
|
||||
} catch (err) {
|
||||
const errorMessage = (err as Error).message
|
||||
const errorMessage = (err as Error).message;
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: errorMessage,
|
||||
toRetry: { state: 'starting' }, // Allow retry by starting fresh OAuth flow
|
||||
})
|
||||
logError(err)
|
||||
});
|
||||
logError(err);
|
||||
logEvent('tengu_oauth_error', {
|
||||
error:
|
||||
errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
}
|
||||
}, [oauthService, onSuccess])
|
||||
}, [oauthService, onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthStatus.state === 'starting') {
|
||||
void startOAuth()
|
||||
void startOAuth();
|
||||
}
|
||||
}, [oauthStatus.state, startOAuth])
|
||||
}, [oauthStatus.state, startOAuth]);
|
||||
|
||||
// Retry logic
|
||||
useEffect(() => {
|
||||
@@ -163,46 +156,41 @@ export function OAuthFlowStep({
|
||||
const timer = setTimeout(
|
||||
(nextState, setShowPastePrompt, setOAuthStatus) => {
|
||||
// Only show paste prompt when retrying to waiting_for_login
|
||||
setShowPastePrompt(nextState.state === 'waiting_for_login')
|
||||
setOAuthStatus(nextState)
|
||||
setShowPastePrompt(nextState.state === 'waiting_for_login');
|
||||
setOAuthStatus(nextState);
|
||||
},
|
||||
500,
|
||||
oauthStatus.nextState,
|
||||
setShowPastePrompt,
|
||||
setOAuthStatus,
|
||||
)
|
||||
timersRef.current.add(timer)
|
||||
);
|
||||
timersRef.current.add(timer);
|
||||
}
|
||||
}, [oauthStatus])
|
||||
}, [oauthStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
pastedCode === 'c' &&
|
||||
oauthStatus.state === 'waiting_for_login' &&
|
||||
showPastePrompt &&
|
||||
!urlCopied
|
||||
) {
|
||||
if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) {
|
||||
void setClipboard(oauthStatus.url).then(raw => {
|
||||
if (raw) process.stdout.write(raw)
|
||||
setUrlCopied(true)
|
||||
clearTimeout(urlCopiedTimerRef.current)
|
||||
urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false)
|
||||
})
|
||||
setPastedCode('')
|
||||
if (raw) process.stdout.write(raw);
|
||||
setUrlCopied(true);
|
||||
clearTimeout(urlCopiedTimerRef.current);
|
||||
urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false);
|
||||
});
|
||||
setPastedCode('');
|
||||
}
|
||||
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied])
|
||||
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied]);
|
||||
|
||||
// Cleanup OAuth service and timers when component unmounts
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
oauthService.cleanup()
|
||||
oauthService.cleanup();
|
||||
// Clear all timers
|
||||
timers.forEach(timer => clearTimeout(timer))
|
||||
timers.clear()
|
||||
clearTimeout(urlCopiedTimerRef.current)
|
||||
}
|
||||
}, [oauthService])
|
||||
timers.forEach(timer => clearTimeout(timer));
|
||||
timers.clear();
|
||||
clearTimeout(urlCopiedTimerRef.current);
|
||||
};
|
||||
}, [oauthService]);
|
||||
|
||||
// Helper function to render the appropriate status message
|
||||
function renderStatusMessage(): React.ReactNode {
|
||||
@@ -213,7 +201,7 @@ export function OAuthFlowStep({
|
||||
<Spinner />
|
||||
<Text>Starting authentication…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'waiting_for_login':
|
||||
return (
|
||||
@@ -221,9 +209,7 @@ export function OAuthFlowStep({
|
||||
{!showPastePrompt && (
|
||||
<Box>
|
||||
<Spinner />
|
||||
<Text>
|
||||
Opening browser to sign in with your Claude account…
|
||||
</Text>
|
||||
<Text>Opening browser to sign in with your Claude account…</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -233,9 +219,7 @@ export function OAuthFlowStep({
|
||||
<TextInput
|
||||
value={pastedCode}
|
||||
onChange={setPastedCode}
|
||||
onSubmit={(value: string) =>
|
||||
handleSubmitCode(value, oauthStatus.url)
|
||||
}
|
||||
onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
columns={textInputColumns}
|
||||
@@ -243,7 +227,7 @@ export function OAuthFlowStep({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'processing':
|
||||
return (
|
||||
@@ -251,52 +235,42 @@ export function OAuthFlowStep({
|
||||
<Spinner />
|
||||
<Text>Processing authentication…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="success">
|
||||
✓ Authentication token created successfully!
|
||||
</Text>
|
||||
<Text color="success">✓ Authentication token created successfully!</Text>
|
||||
<Text dimColor>Using token for GitHub Actions setup…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">OAuth error: {oauthStatus.message}</Text>
|
||||
{oauthStatus.toRetry ? (
|
||||
<Text dimColor>
|
||||
Press Enter to try again, or any other key to cancel
|
||||
</Text>
|
||||
<Text dimColor>Press Enter to try again, or any other key to cancel</Text>
|
||||
) : (
|
||||
<Text dimColor>Press any key to return to API key selection</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
case 'about_to_retry':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="permission">Retrying…</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
{/* Show header inline only for initial starting state */}
|
||||
{oauthStatus.state === 'starting' && (
|
||||
<Box flexDirection="column" gap={1} paddingBottom={1}>
|
||||
@@ -305,21 +279,17 @@ export function OAuthFlowStep({
|
||||
</Box>
|
||||
)}
|
||||
{/* Show header for non-starting states (to avoid duplicate with inline header)*/}
|
||||
{oauthStatus.state !== 'success' &&
|
||||
oauthStatus.state !== 'starting' &&
|
||||
oauthStatus.state !== 'processing' && (
|
||||
<Box key="header" flexDirection="column" gap={1} paddingBottom={1}>
|
||||
<Text bold>Create Authentication Token</Text>
|
||||
<Text dimColor>Creating a long-lived token for GitHub Actions</Text>
|
||||
</Box>
|
||||
)}
|
||||
{oauthStatus.state !== 'success' && oauthStatus.state !== 'starting' && oauthStatus.state !== 'processing' && (
|
||||
<Box key="header" flexDirection="column" gap={1} paddingBottom={1}>
|
||||
<Text bold>Create Authentication Token</Text>
|
||||
<Text dimColor>Creating a long-lived token for GitHub Actions</Text>
|
||||
</Box>
|
||||
)}
|
||||
{/* Show URL when paste prompt is visible */}
|
||||
{oauthStatus.state === 'waiting_for_login' && showPastePrompt && (
|
||||
<Box flexDirection="column" key="urlToCopy" gap={1} paddingBottom={1}>
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>
|
||||
Browser didn't open? Use the url below to sign in{' '}
|
||||
</Text>
|
||||
<Text dimColor>Browser didn't open? Use the url below to sign in </Text>
|
||||
{urlCopied ? (
|
||||
<Text color="success">(Copied!)</Text>
|
||||
) : (
|
||||
@@ -337,5 +307,5 @@ export function OAuthFlowStep({
|
||||
{renderStatusMessage()}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
type SuccessStepProps = {
|
||||
secretExists: boolean
|
||||
useExistingSecret: boolean
|
||||
secretName: string
|
||||
skipWorkflow?: boolean
|
||||
}
|
||||
secretExists: boolean;
|
||||
useExistingSecret: boolean;
|
||||
secretName: string;
|
||||
skipWorkflow?: boolean;
|
||||
};
|
||||
|
||||
export function SuccessStep({
|
||||
secretExists,
|
||||
@@ -21,14 +21,10 @@ export function SuccessStep({
|
||||
<Text bold>Install GitHub App</Text>
|
||||
<Text dimColor>Success</Text>
|
||||
</Box>
|
||||
{!skipWorkflow && (
|
||||
<Text color="success">✓ GitHub Actions workflow created!</Text>
|
||||
)}
|
||||
{!skipWorkflow && <Text color="success">✓ GitHub Actions workflow created!</Text>}
|
||||
{secretExists && useExistingSecret && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="success">
|
||||
✓ Using existing ANTHROPIC_API_KEY secret
|
||||
</Text>
|
||||
<Text color="success">✓ Using existing ANTHROPIC_API_KEY secret</Text>
|
||||
</Box>
|
||||
)}
|
||||
{(!secretExists || !useExistingSecret) && (
|
||||
@@ -41,18 +37,14 @@ export function SuccessStep({
|
||||
</Box>
|
||||
{skipWorkflow ? (
|
||||
<>
|
||||
<Text>
|
||||
1. Install the Claude GitHub App if you haven't already
|
||||
</Text>
|
||||
<Text>1. Install the Claude GitHub App if you haven't already</Text>
|
||||
<Text>2. Your workflow file was kept unchanged</Text>
|
||||
<Text>3. API key is configured and ready to use</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>1. A pre-filled PR page has been created</Text>
|
||||
<Text>
|
||||
2. Install the Claude GitHub App if you haven't already
|
||||
</Text>
|
||||
<Text>2. Install the Claude GitHub App if you haven't already</Text>
|
||||
<Text>3. Merge the PR to enable Claude PR assistance</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -61,5 +53,5 @@ export function SuccessStep({
|
||||
<Text dimColor>Press any key to exit</Text>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import figures from 'figures'
|
||||
import React from 'react'
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import type { Warning } from './types.js'
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import type { Warning } from './types.js';
|
||||
|
||||
interface WarningsStepProps {
|
||||
warnings: Warning[]
|
||||
onContinue: () => void
|
||||
warnings: Warning[];
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export function WarningsStep({ warnings, onContinue }: WarningsStepProps) {
|
||||
// Enter to continue
|
||||
useKeybinding('confirm:yes', onContinue, { context: 'Confirmation' })
|
||||
useKeybinding('confirm:yes', onContinue, { context: 'Confirmation' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box flexDirection="column" borderStyle="round" paddingX={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>{figures.warning} Setup Warnings</Text>
|
||||
<Text dimColor>
|
||||
We found some potential issues, but you can continue anyway
|
||||
</Text>
|
||||
<Text dimColor>We found some potential issues, but you can continue anyway</Text>
|
||||
</Box>
|
||||
|
||||
{warnings.map((warning, index) => (
|
||||
@@ -55,5 +53,5 @@ export function WarningsStep({ warnings, onContinue }: WarningsStepProps) {
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { execa } from 'execa'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { execa } from 'execa';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { WorkflowMultiselectDialog } from '../../components/WorkflowMultiselectDialog.js'
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'
|
||||
import { type KeyboardEvent, Box } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||
import { getGithubRepo } from '../../utils/git.js'
|
||||
import { plural } from '../../utils/stringUtils.js'
|
||||
import { ApiKeyStep } from './ApiKeyStep.js'
|
||||
import { CheckExistingSecretStep } from './CheckExistingSecretStep.js'
|
||||
import { CheckGitHubStep } from './CheckGitHubStep.js'
|
||||
import { ChooseRepoStep } from './ChooseRepoStep.js'
|
||||
import { CreatingStep } from './CreatingStep.js'
|
||||
import { ErrorStep } from './ErrorStep.js'
|
||||
import { ExistingWorkflowStep } from './ExistingWorkflowStep.js'
|
||||
import { InstallAppStep } from './InstallAppStep.js'
|
||||
import { OAuthFlowStep } from './OAuthFlowStep.js'
|
||||
import { SuccessStep } from './SuccessStep.js'
|
||||
import { setupGitHubActions } from './setupGitHubActions.js'
|
||||
import type { State, Warning, Workflow } from './types.js'
|
||||
import { WarningsStep } from './WarningsStep.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { WorkflowMultiselectDialog } from '../../components/WorkflowMultiselectDialog.js';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import { type KeyboardEvent, Box } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js';
|
||||
import { openBrowser } from '../../utils/browser.js';
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
|
||||
import { getGithubRepo } from '../../utils/git.js';
|
||||
import { plural } from '../../utils/stringUtils.js';
|
||||
import { ApiKeyStep } from './ApiKeyStep.js';
|
||||
import { CheckExistingSecretStep } from './CheckExistingSecretStep.js';
|
||||
import { CheckGitHubStep } from './CheckGitHubStep.js';
|
||||
import { ChooseRepoStep } from './ChooseRepoStep.js';
|
||||
import { CreatingStep } from './CreatingStep.js';
|
||||
import { ErrorStep } from './ErrorStep.js';
|
||||
import { ExistingWorkflowStep } from './ExistingWorkflowStep.js';
|
||||
import { InstallAppStep } from './InstallAppStep.js';
|
||||
import { OAuthFlowStep } from './OAuthFlowStep.js';
|
||||
import { SuccessStep } from './SuccessStep.js';
|
||||
import { setupGitHubActions } from './setupGitHubActions.js';
|
||||
import type { State, Warning, Workflow } from './types.js';
|
||||
import { WarningsStep } from './WarningsStep.js';
|
||||
|
||||
const INITIAL_STATE: State = {
|
||||
step: 'check-gh',
|
||||
@@ -44,54 +44,50 @@ const INITIAL_STATE: State = {
|
||||
selectedWorkflows: ['claude', 'claude-review'] as Workflow[],
|
||||
selectedApiKeyOption: 'new' as 'existing' | 'new' | 'oauth',
|
||||
authType: 'api_key',
|
||||
}
|
||||
};
|
||||
|
||||
function InstallGitHubApp(props: {
|
||||
onDone: (message: string) => void
|
||||
}): React.ReactNode {
|
||||
const [existingApiKey] = useState(() => getAnthropicApiKey())
|
||||
function InstallGitHubApp(props: { onDone: (message: string) => void }): React.ReactNode {
|
||||
const [existingApiKey] = useState(() => getAnthropicApiKey());
|
||||
const [state, setState] = useState({
|
||||
...INITIAL_STATE,
|
||||
useExistingKey: !!existingApiKey,
|
||||
selectedApiKeyOption: (existingApiKey
|
||||
? 'existing'
|
||||
: isAnthropicAuthEnabled()
|
||||
? 'oauth'
|
||||
: 'new') as 'existing' | 'new' | 'oauth',
|
||||
})
|
||||
useExitOnCtrlCDWithKeybindings()
|
||||
selectedApiKeyOption: (existingApiKey ? 'existing' : isAnthropicAuthEnabled() ? 'oauth' : 'new') as
|
||||
| 'existing'
|
||||
| 'new'
|
||||
| 'oauth',
|
||||
});
|
||||
useExitOnCtrlCDWithKeybindings();
|
||||
|
||||
React.useEffect(() => {
|
||||
logEvent('tengu_install_github_app_started', {})
|
||||
}, [])
|
||||
logEvent('tengu_install_github_app_started', {});
|
||||
}, []);
|
||||
|
||||
const checkGitHubCLI = useCallback(async () => {
|
||||
const warnings: Warning[] = []
|
||||
const warnings: Warning[] = [];
|
||||
|
||||
// Check if gh is installed
|
||||
const ghVersionResult = await execa('gh --version', {
|
||||
shell: true,
|
||||
reject: false,
|
||||
})
|
||||
});
|
||||
if (ghVersionResult.exitCode !== 0) {
|
||||
warnings.push({
|
||||
title: 'GitHub CLI not found',
|
||||
message:
|
||||
'GitHub CLI (gh) does not appear to be installed or accessible.',
|
||||
message: 'GitHub CLI (gh) does not appear to be installed or accessible.',
|
||||
instructions: [
|
||||
'Install GitHub CLI from https://cli.github.com/',
|
||||
'macOS: brew install gh',
|
||||
'Windows: winget install --id GitHub.cli',
|
||||
'Linux: See installation instructions at https://github.com/cli/cli#installation',
|
||||
],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
const authResult = await execa('gh auth status -a', {
|
||||
shell: true,
|
||||
reject: false,
|
||||
})
|
||||
});
|
||||
if (authResult.exitCode !== 0) {
|
||||
warnings.push({
|
||||
title: 'GitHub CLI not authenticated',
|
||||
@@ -101,19 +97,19 @@ function InstallGitHubApp(props: {
|
||||
'Follow the prompts to authenticate with GitHub',
|
||||
'Or set up authentication using environment variables or other methods',
|
||||
],
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Check if required scopes are present in the Token scopes line
|
||||
const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m)
|
||||
const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m);
|
||||
if (tokenScopesMatch) {
|
||||
const scopes = tokenScopesMatch[0]
|
||||
const missingScopes: string[] = []
|
||||
const scopes = tokenScopesMatch[0];
|
||||
const missingScopes: string[] = [];
|
||||
|
||||
if (!scopes.includes('repo')) {
|
||||
missingScopes.push('repo')
|
||||
missingScopes.push('repo');
|
||||
}
|
||||
if (!scopes.includes('workflow')) {
|
||||
missingScopes.push('workflow')
|
||||
missingScopes.push('workflow');
|
||||
}
|
||||
|
||||
if (missingScopes.length > 0) {
|
||||
@@ -131,18 +127,18 @@ function InstallGitHubApp(props: {
|
||||
'',
|
||||
'This will add the necessary permissions to manage workflows and secrets.',
|
||||
],
|
||||
}))
|
||||
return
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if in a git repo and get remote URL
|
||||
const currentRepo = (await getGithubRepo()) ?? ''
|
||||
const currentRepo = (await getGithubRepo()) ?? '';
|
||||
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'check-gh' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
@@ -151,14 +147,14 @@ function InstallGitHubApp(props: {
|
||||
selectedRepoName: currentRepo,
|
||||
useCurrentRepo: !!currentRepo, // Set to false if no repo detected
|
||||
step: warnings.length > 0 ? 'warnings' : 'choose-repo',
|
||||
}))
|
||||
}, [])
|
||||
}));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.step === 'check-gh') {
|
||||
void checkGitHubCLI()
|
||||
void checkGitHubCLI();
|
||||
}
|
||||
}, [state.step, checkGitHubCLI])
|
||||
}, [state.step, checkGitHubCLI]);
|
||||
|
||||
const runSetupGitHubActions = useCallback(
|
||||
async (apiKeyOrOAuthToken: string | null, secretName: string) => {
|
||||
@@ -166,7 +162,7 @@ function InstallGitHubApp(props: {
|
||||
...prev,
|
||||
step: 'creating',
|
||||
currentWorkflowInstallStep: 0,
|
||||
}))
|
||||
}));
|
||||
|
||||
try {
|
||||
await setupGitHubActions(
|
||||
@@ -177,7 +173,7 @@ function InstallGitHubApp(props: {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflowInstallStep: prev.currentWorkflowInstallStep + 1,
|
||||
}))
|
||||
}));
|
||||
},
|
||||
state.workflowAction === 'skip',
|
||||
state.selectedWorkflows,
|
||||
@@ -187,22 +183,18 @@ function InstallGitHubApp(props: {
|
||||
workflowExists: state.workflowExists,
|
||||
secretExists: state.secretExists,
|
||||
},
|
||||
)
|
||||
);
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'creating' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
setState(prev => ({ ...prev, step: 'success' }))
|
||||
});
|
||||
setState(prev => ({ ...prev, step: 'success' }));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to set up GitHub Actions'
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to set up GitHub Actions';
|
||||
|
||||
if (errorMessage.includes('workflow file already exists')) {
|
||||
logEvent('tengu_install_github_app_error', {
|
||||
reason:
|
||||
'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
reason: 'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
step: 'error',
|
||||
@@ -215,12 +207,11 @@ function InstallGitHubApp(props: {
|
||||
' 2. Update the existing file manually using the template from:',
|
||||
` ${GITHUB_ACTION_SETUP_DOCS_URL}`,
|
||||
],
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
logEvent('tengu_install_github_app_error', {
|
||||
reason:
|
||||
'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
reason: 'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
@@ -228,7 +219,7 @@ function InstallGitHubApp(props: {
|
||||
error: errorMessage,
|
||||
errorReason: 'GitHub Actions setup failed',
|
||||
errorInstructions: [],
|
||||
}))
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -241,42 +232,32 @@ function InstallGitHubApp(props: {
|
||||
state.secretExists,
|
||||
state.authType,
|
||||
],
|
||||
)
|
||||
);
|
||||
|
||||
async function openGitHubAppInstallation() {
|
||||
const installUrl = 'https://github.com/apps/claude'
|
||||
await openBrowser(installUrl)
|
||||
const installUrl = 'https://github.com/apps/claude';
|
||||
await openBrowser(installUrl);
|
||||
}
|
||||
|
||||
async function checkRepositoryPermissions(
|
||||
repoName: string,
|
||||
): Promise<{ hasAccess: boolean; error?: string }> {
|
||||
async function checkRepositoryPermissions(repoName: string): Promise<{ hasAccess: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await execFileNoThrow('gh', [
|
||||
'api',
|
||||
`repos/${repoName}`,
|
||||
'--jq',
|
||||
'.permissions.admin',
|
||||
])
|
||||
const result = await execFileNoThrow('gh', ['api', `repos/${repoName}`, '--jq', '.permissions.admin']);
|
||||
|
||||
if (result.code === 0) {
|
||||
const hasAdmin = result.stdout.trim() === 'true'
|
||||
return { hasAccess: hasAdmin }
|
||||
const hasAdmin = result.stdout.trim() === 'true';
|
||||
return { hasAccess: hasAdmin };
|
||||
}
|
||||
|
||||
if (
|
||||
result.stderr.includes('404') ||
|
||||
result.stderr.includes('Not Found')
|
||||
) {
|
||||
if (result.stderr.includes('404') || result.stderr.includes('Not Found')) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
error: 'repository_not_found',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { hasAccess: false }
|
||||
return { hasAccess: false };
|
||||
} catch {
|
||||
return { hasAccess: false }
|
||||
return { hasAccess: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,9 +267,9 @@ function InstallGitHubApp(props: {
|
||||
`repos/${repoName}/contents/.github/workflows/claude.yml`,
|
||||
'--jq',
|
||||
'.sha',
|
||||
])
|
||||
]);
|
||||
|
||||
return checkFileResult.code === 0
|
||||
return checkFileResult.code === 0;
|
||||
}
|
||||
|
||||
async function checkExistingSecret() {
|
||||
@@ -299,20 +280,20 @@ function InstallGitHubApp(props: {
|
||||
'actions',
|
||||
'--repo',
|
||||
state.selectedRepoName,
|
||||
])
|
||||
]);
|
||||
|
||||
if (checkSecretsResult.code === 0) {
|
||||
const lines = checkSecretsResult.stdout.split('\n')
|
||||
const lines = checkSecretsResult.stdout.split('\n');
|
||||
const hasAnthropicKey = lines.some((line: string) => {
|
||||
return /^ANTHROPIC_API_KEY\s+/.test(line)
|
||||
})
|
||||
return /^ANTHROPIC_API_KEY\s+/.test(line);
|
||||
});
|
||||
|
||||
if (hasAnthropicKey) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
secretExists: true,
|
||||
step: 'check-existing-secret',
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
// No existing secret found
|
||||
if (existingApiKey) {
|
||||
@@ -321,11 +302,11 @@ function InstallGitHubApp(props: {
|
||||
...prev,
|
||||
apiKeyOrOAuthToken: existingApiKey,
|
||||
useExistingKey: true,
|
||||
}))
|
||||
await runSetupGitHubActions(existingApiKey, state.secretName)
|
||||
}));
|
||||
await runSetupGitHubActions(existingApiKey, state.secretName);
|
||||
} else {
|
||||
// No local key, go to API key step
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -336,11 +317,11 @@ function InstallGitHubApp(props: {
|
||||
...prev,
|
||||
apiKeyOrOAuthToken: existingApiKey,
|
||||
useExistingKey: true,
|
||||
}))
|
||||
await runSetupGitHubActions(existingApiKey, state.secretName)
|
||||
}));
|
||||
await runSetupGitHubActions(existingApiKey, state.secretName);
|
||||
} else {
|
||||
// No local key, go to API key step
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,33 +330,28 @@ function InstallGitHubApp(props: {
|
||||
if (state.step === 'warnings') {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'warnings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
setState(prev => ({ ...prev, step: 'install-app' }))
|
||||
setTimeout(openGitHubAppInstallation, 0)
|
||||
});
|
||||
setState(prev => ({ ...prev, step: 'install-app' }));
|
||||
setTimeout(openGitHubAppInstallation, 0);
|
||||
} else if (state.step === 'choose-repo') {
|
||||
let repoName = state.useCurrentRepo
|
||||
? state.currentRepo
|
||||
: state.selectedRepoName
|
||||
let repoName = state.useCurrentRepo ? state.currentRepo : state.selectedRepoName;
|
||||
|
||||
if (!repoName.trim()) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const repoWarnings: Warning[] = []
|
||||
const repoWarnings: Warning[] = [];
|
||||
|
||||
if (repoName.includes('github.com')) {
|
||||
const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/)
|
||||
const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/);
|
||||
if (!match) {
|
||||
repoWarnings.push({
|
||||
title: 'Invalid GitHub URL format',
|
||||
message: 'The repository URL format appears to be invalid.',
|
||||
instructions: [
|
||||
'Use format: owner/repo or https://github.com/owner/repo',
|
||||
'Example: anthropics/claude-cli',
|
||||
],
|
||||
})
|
||||
instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli'],
|
||||
});
|
||||
} else {
|
||||
repoName = match[1]?.replace(/\.git$/, '') || ''
|
||||
repoName = match[1]?.replace(/\.git$/, '') || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,14 +359,11 @@ function InstallGitHubApp(props: {
|
||||
repoWarnings.push({
|
||||
title: 'Repository format warning',
|
||||
message: 'Repository should be in format "owner/repo"',
|
||||
instructions: [
|
||||
'Use format: owner/repo',
|
||||
'Example: anthropics/claude-cli',
|
||||
],
|
||||
})
|
||||
instructions: ['Use format: owner/repo', 'Example: anthropics/claude-cli'],
|
||||
});
|
||||
}
|
||||
|
||||
const permissionCheck = await checkRepositoryPermissions(repoName)
|
||||
const permissionCheck = await checkRepositoryPermissions(repoName);
|
||||
|
||||
if (permissionCheck.error === 'repository_not_found') {
|
||||
repoWarnings.push({
|
||||
@@ -402,7 +375,7 @@ function InstallGitHubApp(props: {
|
||||
'For private repositories, make sure your GitHub token has the "repo" scope',
|
||||
'You can add the repo scope with: gh auth refresh -h github.com -s repo,workflow',
|
||||
],
|
||||
})
|
||||
});
|
||||
} else if (!permissionCheck.hasAccess) {
|
||||
repoWarnings.push({
|
||||
title: 'Admin permissions required',
|
||||
@@ -412,81 +385,77 @@ function InstallGitHubApp(props: {
|
||||
'Ask a repository admin to run this command if setup fails',
|
||||
'Alternatively, you can use the manual setup instructions',
|
||||
],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const workflowExists = await checkExistingWorkflowFile(repoName)
|
||||
const workflowExists = await checkExistingWorkflowFile(repoName);
|
||||
|
||||
if (repoWarnings.length > 0) {
|
||||
const allWarnings = [...state.warnings, ...repoWarnings]
|
||||
const allWarnings = [...state.warnings, ...repoWarnings];
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedRepoName: repoName,
|
||||
workflowExists,
|
||||
warnings: allWarnings,
|
||||
step: 'warnings',
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'choose-repo' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedRepoName: repoName,
|
||||
workflowExists,
|
||||
step: 'install-app',
|
||||
}))
|
||||
setTimeout(openGitHubAppInstallation, 0)
|
||||
}));
|
||||
setTimeout(openGitHubAppInstallation, 0);
|
||||
}
|
||||
} else if (state.step === 'install-app') {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'install-app' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
if (state.workflowExists) {
|
||||
setState(prev => ({ ...prev, step: 'check-existing-workflow' }))
|
||||
setState(prev => ({ ...prev, step: 'check-existing-workflow' }));
|
||||
} else {
|
||||
setState(prev => ({ ...prev, step: 'select-workflows' }))
|
||||
setState(prev => ({ ...prev, step: 'select-workflows' }));
|
||||
}
|
||||
} else if (state.step === 'check-existing-workflow') {
|
||||
return
|
||||
return;
|
||||
} else if (state.step === 'select-workflows') {
|
||||
// Handled by the WorkflowMultiselectDialog component
|
||||
return
|
||||
return;
|
||||
} else if (state.step === 'check-existing-secret') {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'check-existing-secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
if (state.useExistingSecret) {
|
||||
await runSetupGitHubActions(null, state.secretName)
|
||||
await runSetupGitHubActions(null, state.secretName);
|
||||
} else {
|
||||
// User wants to use a new secret name with their API key
|
||||
await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName)
|
||||
await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName);
|
||||
}
|
||||
} else if (state.step === 'api-key') {
|
||||
// In the new flow, api-key step only appears when user has no existing key
|
||||
// They either entered a new key or will create OAuth token
|
||||
if (state.selectedApiKeyOption === 'oauth') {
|
||||
// OAuth flow already handled by handleCreateOAuthToken
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// If user selected 'existing' option, use the existing API key
|
||||
const apiKeyToUse =
|
||||
state.selectedApiKeyOption === 'existing'
|
||||
? existingApiKey
|
||||
: state.apiKeyOrOAuthToken
|
||||
const apiKeyToUse = state.selectedApiKeyOption === 'existing' ? existingApiKey : state.apiKeyOrOAuthToken;
|
||||
|
||||
if (!apiKeyToUse) {
|
||||
logEvent('tengu_install_github_app_error', {
|
||||
reason:
|
||||
'api_key_missing' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
reason: 'api_key_missing' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
step: 'error',
|
||||
error: 'API key is required',
|
||||
}))
|
||||
return
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the API key being used (either existing or newly entered)
|
||||
@@ -494,7 +463,7 @@ function InstallGitHubApp(props: {
|
||||
...prev,
|
||||
apiKeyOrOAuthToken: apiKeyToUse,
|
||||
useExistingKey: state.selectedApiKeyOption === 'existing',
|
||||
}))
|
||||
}));
|
||||
|
||||
// Check if ANTHROPIC_API_KEY secret already exists
|
||||
const checkSecretsResult = await execFileNoThrow('gh', [
|
||||
@@ -504,132 +473,132 @@ function InstallGitHubApp(props: {
|
||||
'actions',
|
||||
'--repo',
|
||||
state.selectedRepoName,
|
||||
])
|
||||
]);
|
||||
|
||||
if (checkSecretsResult.code === 0) {
|
||||
const lines = checkSecretsResult.stdout.split('\n')
|
||||
const lines = checkSecretsResult.stdout.split('\n');
|
||||
const hasAnthropicKey = lines.some((line: string) => {
|
||||
return /^ANTHROPIC_API_KEY\s+/.test(line)
|
||||
})
|
||||
return /^ANTHROPIC_API_KEY\s+/.test(line);
|
||||
});
|
||||
|
||||
if (hasAnthropicKey) {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
secretExists: true,
|
||||
step: 'check-existing-secret',
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
// No existing secret, proceed to creating
|
||||
await runSetupGitHubActions(apiKeyToUse, state.secretName)
|
||||
await runSetupGitHubActions(apiKeyToUse, state.secretName);
|
||||
}
|
||||
} else {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
// Error checking secrets, proceed anyway
|
||||
await runSetupGitHubActions(apiKeyToUse, state.secretName)
|
||||
await runSetupGitHubActions(apiKeyToUse, state.secretName);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepoUrlChange = (value: string) => {
|
||||
setState(prev => ({ ...prev, selectedRepoName: value }))
|
||||
}
|
||||
setState(prev => ({ ...prev, selectedRepoName: value }));
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setState(prev => ({ ...prev, apiKeyOrOAuthToken: value }))
|
||||
}
|
||||
setState(prev => ({ ...prev, apiKeyOrOAuthToken: value }));
|
||||
};
|
||||
|
||||
const handleApiKeyOptionChange = (option: 'existing' | 'new' | 'oauth') => {
|
||||
setState(prev => ({ ...prev, selectedApiKeyOption: option }))
|
||||
}
|
||||
setState(prev => ({ ...prev, selectedApiKeyOption: option }));
|
||||
};
|
||||
|
||||
const handleCreateOAuthToken = useCallback(() => {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
setState(prev => ({ ...prev, step: 'oauth-flow' }))
|
||||
}, [])
|
||||
});
|
||||
setState(prev => ({ ...prev, step: 'oauth-flow' }));
|
||||
}, []);
|
||||
|
||||
const handleOAuthSuccess = useCallback(
|
||||
(token: string) => {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'oauth-flow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
apiKeyOrOAuthToken: token,
|
||||
useExistingKey: false,
|
||||
secretName: 'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
authType: 'oauth_token',
|
||||
}))
|
||||
void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN')
|
||||
}));
|
||||
void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN');
|
||||
},
|
||||
[runSetupGitHubActions],
|
||||
)
|
||||
);
|
||||
|
||||
const handleOAuthCancel = useCallback(() => {
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
}, [])
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}, []);
|
||||
|
||||
const handleSecretNameChange = (value: string) => {
|
||||
if (value && !/^[a-zA-Z0-9_]+$/.test(value)) return
|
||||
setState(prev => ({ ...prev, secretName: value }))
|
||||
}
|
||||
if (value && !/^[a-zA-Z0-9_]+$/.test(value)) return;
|
||||
setState(prev => ({ ...prev, secretName: value }));
|
||||
};
|
||||
|
||||
const handleToggleUseCurrentRepo = (useCurrentRepo: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
useCurrentRepo,
|
||||
selectedRepoName: useCurrentRepo ? prev.currentRepo : '',
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleToggleUseExistingKey = (useExistingKey: boolean) => {
|
||||
setState(prev => ({ ...prev, useExistingKey }))
|
||||
}
|
||||
setState(prev => ({ ...prev, useExistingKey }));
|
||||
};
|
||||
|
||||
const handleToggleUseExistingSecret = (useExistingSecret: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
useExistingSecret,
|
||||
secretName: useExistingSecret ? 'ANTHROPIC_API_KEY' : '',
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleWorkflowAction = async (action: 'update' | 'skip' | 'exit') => {
|
||||
if (action === 'exit') {
|
||||
props.onDone('Installation cancelled by user')
|
||||
return
|
||||
props.onDone('Installation cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'check-existing-workflow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
|
||||
setState(prev => ({ ...prev, workflowAction: action }))
|
||||
setState(prev => ({ ...prev, workflowAction: action }));
|
||||
|
||||
if (action === 'skip' || action === 'update') {
|
||||
// Check if user has existing local API key
|
||||
if (existingApiKey) {
|
||||
await checkExistingSecret()
|
||||
await checkExistingSecret();
|
||||
} else {
|
||||
// No local key, go straight to API key step
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function handleDismissKeyDown(e: KeyboardEvent): void {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (state.step === 'success') {
|
||||
logEvent('tengu_install_github_app_completed', {})
|
||||
logEvent('tengu_install_github_app_completed', {});
|
||||
}
|
||||
props.onDone(
|
||||
state.step === 'success'
|
||||
@@ -637,16 +606,14 @@ function InstallGitHubApp(props: {
|
||||
: state.error
|
||||
? `Couldn't install GitHub App: ${state.error}\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}`
|
||||
: `GitHub App installation failed\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
switch (state.step) {
|
||||
case 'check-gh':
|
||||
return <CheckGitHubStep />
|
||||
return <CheckGitHubStep />;
|
||||
case 'warnings':
|
||||
return (
|
||||
<WarningsStep warnings={state.warnings} onContinue={handleSubmit} />
|
||||
)
|
||||
return <WarningsStep warnings={state.warnings} onContinue={handleSubmit} />;
|
||||
case 'choose-repo':
|
||||
return (
|
||||
<ChooseRepoStep
|
||||
@@ -657,21 +624,11 @@ function InstallGitHubApp(props: {
|
||||
onToggleUseCurrentRepo={handleToggleUseCurrentRepo}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'install-app':
|
||||
return (
|
||||
<InstallAppStep
|
||||
repoUrl={state.selectedRepoName}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)
|
||||
return <InstallAppStep repoUrl={state.selectedRepoName} onSubmit={handleSubmit} />;
|
||||
case 'check-existing-workflow':
|
||||
return (
|
||||
<ExistingWorkflowStep
|
||||
repoName={state.selectedRepoName}
|
||||
onSelectAction={handleWorkflowAction}
|
||||
/>
|
||||
)
|
||||
return <ExistingWorkflowStep repoName={state.selectedRepoName} onSelectAction={handleWorkflowAction} />;
|
||||
case 'check-existing-secret':
|
||||
return (
|
||||
<CheckExistingSecretStep
|
||||
@@ -681,7 +638,7 @@ function InstallGitHubApp(props: {
|
||||
onSecretNameChange={handleSecretNameChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'api-key':
|
||||
return (
|
||||
<ApiKeyStep
|
||||
@@ -691,13 +648,11 @@ function InstallGitHubApp(props: {
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
onToggleUseExistingKey={handleToggleUseExistingKey}
|
||||
onSubmit={handleSubmit}
|
||||
onCreateOAuthToken={
|
||||
isAnthropicAuthEnabled() ? handleCreateOAuthToken : undefined
|
||||
}
|
||||
onCreateOAuthToken={isAnthropicAuthEnabled() ? handleCreateOAuthToken : undefined}
|
||||
selectedOption={state.selectedApiKeyOption}
|
||||
onSelectOption={handleApiKeyOptionChange}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'creating':
|
||||
return (
|
||||
<CreatingStep
|
||||
@@ -708,7 +663,7 @@ function InstallGitHubApp(props: {
|
||||
skipWorkflow={state.workflowAction === 'skip'}
|
||||
selectedWorkflows={state.selectedWorkflows}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'success':
|
||||
return (
|
||||
<Box tabIndex={0} autoFocus onKeyDown={handleDismissKeyDown}>
|
||||
@@ -719,17 +674,13 @@ function InstallGitHubApp(props: {
|
||||
skipWorkflow={state.workflowAction === 'skip'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Box tabIndex={0} autoFocus onKeyDown={handleDismissKeyDown}>
|
||||
<ErrorStep
|
||||
error={state.error}
|
||||
errorReason={state.errorReason}
|
||||
errorInstructions={state.errorInstructions}
|
||||
/>
|
||||
<ErrorStep error={state.error} errorReason={state.errorReason} errorInstructions={state.errorInstructions} />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
case 'select-workflows':
|
||||
return (
|
||||
<WorkflowMultiselectDialog
|
||||
@@ -737,33 +688,26 @@ function InstallGitHubApp(props: {
|
||||
onSubmit={selectedWorkflows => {
|
||||
logEvent('tengu_install_github_app_step_completed', {
|
||||
step: 'select-workflows' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
selectedWorkflows,
|
||||
}))
|
||||
}));
|
||||
// Check if user has existing local API key
|
||||
if (existingApiKey) {
|
||||
void checkExistingSecret()
|
||||
void checkExistingSecret();
|
||||
} else {
|
||||
// No local key, go straight to API key step
|
||||
setState(prev => ({ ...prev, step: 'api-key' }))
|
||||
setState(prev => ({ ...prev, step: 'api-key' }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
case 'oauth-flow':
|
||||
return (
|
||||
<OAuthFlowStep
|
||||
onSuccess={handleOAuthSuccess}
|
||||
onCancel={handleOAuthCancel}
|
||||
/>
|
||||
)
|
||||
return <OAuthFlowStep onSuccess={handleOAuthSuccess} onCancel={handleOAuthCancel} />;
|
||||
}
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
return <InstallGitHubApp onDone={onDone} />
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
return <InstallGitHubApp onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { CommandResultDisplay } from 'src/commands.js'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { StatusIcon } from '@anthropic/ink'
|
||||
import { Box, wrappedRender as render, Text } from '@anthropic/ink'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { env } from '../utils/env.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { CommandResultDisplay } from 'src/commands.js';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import { StatusIcon } from '@anthropic/ink';
|
||||
import { Box, wrappedRender as render, Text } from '@anthropic/ink';
|
||||
import { logForDebugging } from '../utils/debug.js';
|
||||
import { env } from '../utils/env.js';
|
||||
import { errorMessage } from '../utils/errors.js';
|
||||
import {
|
||||
checkInstall,
|
||||
cleanupNpmInstallations,
|
||||
cleanupShellAliases,
|
||||
installLatest,
|
||||
} from '../utils/nativeInstaller/index.js'
|
||||
import {
|
||||
getInitialSettings,
|
||||
updateSettingsForSource,
|
||||
} from '../utils/settings/settings.js'
|
||||
} from '../utils/nativeInstaller/index.js';
|
||||
import { getInitialSettings, updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
|
||||
interface InstallProps {
|
||||
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void
|
||||
force?: boolean
|
||||
target?: string // 'latest', 'stable', or version like '1.0.34'
|
||||
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
force?: boolean;
|
||||
target?: string; // 'latest', 'stable', or version like '1.0.34'
|
||||
}
|
||||
|
||||
type InstallState =
|
||||
@@ -32,24 +29,24 @@ type InstallState =
|
||||
| { type: 'setting-up' }
|
||||
| { type: 'set-up'; messages: string[] }
|
||||
| { type: 'success'; version: string; setupMessages?: string[] }
|
||||
| { type: 'error'; message: string; warnings?: string[] }
|
||||
| { type: 'error'; message: string; warnings?: string[] };
|
||||
|
||||
function getInstallationPath(): string {
|
||||
const isWindows = env.platform === 'win32'
|
||||
const homeDir = homedir()
|
||||
const isWindows = env.platform === 'win32';
|
||||
const homeDir = homedir();
|
||||
|
||||
if (isWindows) {
|
||||
// Convert to Windows-style path
|
||||
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe')
|
||||
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe');
|
||||
// Replace forward slashes with backslashes for Windows display
|
||||
return windowsPath.replace(/\//g, '\\')
|
||||
return windowsPath.replace(/\//g, '\\');
|
||||
}
|
||||
|
||||
return '~/.local/bin/claude'
|
||||
return '~/.local/bin/claude';
|
||||
}
|
||||
|
||||
function SetupNotes({ messages }: { messages: string[] }): React.ReactNode {
|
||||
if (messages.length === 0) return null
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={0} marginBottom={1}>
|
||||
@@ -65,183 +62,151 @@ function SetupNotes({ messages }: { messages: string[] }): React.ReactNode {
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Install({ onDone, force, target }: InstallProps): React.ReactNode {
|
||||
const [state, setState] = useState<InstallState>({ type: 'checking' })
|
||||
const [state, setState] = useState<InstallState>({ type: 'checking' });
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
try {
|
||||
logForDebugging(
|
||||
`Install: Starting installation process (force=${force}, target=${target})`,
|
||||
)
|
||||
logForDebugging(`Install: Starting installation process (force=${force}, target=${target})`);
|
||||
|
||||
// Install native build first
|
||||
const channelOrVersion =
|
||||
target || getInitialSettings()?.autoUpdatesChannel || 'latest'
|
||||
setState({ type: 'installing', version: channelOrVersion })
|
||||
const channelOrVersion = target || getInitialSettings()?.autoUpdatesChannel || 'latest';
|
||||
setState({ type: 'installing', version: channelOrVersion });
|
||||
|
||||
// Pass force flag to trigger reinstall even if up to date
|
||||
logForDebugging(
|
||||
`Install: Calling installLatest(channelOrVersion=${channelOrVersion}, forceReinstall=${force})`,
|
||||
)
|
||||
const result = await installLatest(channelOrVersion, force)
|
||||
);
|
||||
const result = await installLatest(channelOrVersion, force);
|
||||
logForDebugging(
|
||||
`Install: installLatest returned version=${result.latestVersion}, wasUpdated=${result.wasUpdated}, lockFailed=${result.lockFailed}`,
|
||||
)
|
||||
);
|
||||
|
||||
// Check specifically for lock failure
|
||||
if (result.lockFailed) {
|
||||
throw new Error(
|
||||
'Could not install - another process is currently installing Claude. Please try again in a moment.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If we couldn't get the version, there might be an issue
|
||||
if (!result.latestVersion) {
|
||||
logForDebugging(
|
||||
'Install: Failed to retrieve version information during install',
|
||||
{ level: 'error' },
|
||||
)
|
||||
logForDebugging('Install: Failed to retrieve version information during install', { level: 'error' });
|
||||
}
|
||||
|
||||
if (!result.wasUpdated) {
|
||||
logForDebugging('Install: Already up to date')
|
||||
logForDebugging('Install: Already up to date');
|
||||
}
|
||||
|
||||
// Set up launcher and shell integration
|
||||
setState({ type: 'setting-up' })
|
||||
const setupMessages = await checkInstall(true)
|
||||
setState({ type: 'setting-up' });
|
||||
const setupMessages = await checkInstall(true);
|
||||
|
||||
logForDebugging(
|
||||
`Install: Setup launcher completed with ${setupMessages.length} messages`,
|
||||
)
|
||||
logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`);
|
||||
if (setupMessages.length > 0) {
|
||||
setupMessages.forEach(msg =>
|
||||
logForDebugging(`Install: Setup message: ${msg.message}`),
|
||||
)
|
||||
setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`));
|
||||
}
|
||||
|
||||
// Now that native installation succeeded, clean up old npm installations
|
||||
logForDebugging(
|
||||
'Install: Cleaning up npm installations after successful install',
|
||||
)
|
||||
const { removed, errors, warnings } = await cleanupNpmInstallations()
|
||||
logForDebugging('Install: Cleaning up npm installations after successful install');
|
||||
const { removed, errors, warnings } = await cleanupNpmInstallations();
|
||||
|
||||
if (removed > 0) {
|
||||
logForDebugging(`Cleaned up ${removed} npm installation(s)`)
|
||||
logForDebugging(`Cleaned up ${removed} npm installation(s)`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logForDebugging(`Cleanup errors: ${errors.join(', ')}`)
|
||||
logForDebugging(`Cleanup errors: ${errors.join(', ')}`);
|
||||
// Continue despite cleanup errors - native install already succeeded
|
||||
}
|
||||
|
||||
// Clean up old shell aliases
|
||||
const aliasMessages = await cleanupShellAliases()
|
||||
const aliasMessages = await cleanupShellAliases();
|
||||
if (aliasMessages.length > 0) {
|
||||
logForDebugging(
|
||||
`Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`,
|
||||
)
|
||||
logForDebugging(`Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`);
|
||||
}
|
||||
|
||||
// Log success event
|
||||
logEvent('tengu_claude_install_command', {
|
||||
has_version: result.latestVersion ? 1 : 0,
|
||||
forced: force ? 1 : 0,
|
||||
})
|
||||
});
|
||||
|
||||
// If user explicitly specified a channel, save it to settings
|
||||
if (target === 'latest' || target === 'stable') {
|
||||
updateSettingsForSource('userSettings', {
|
||||
autoUpdatesChannel: target,
|
||||
})
|
||||
logForDebugging(
|
||||
`Install: Saved autoUpdatesChannel=${target} to user settings`,
|
||||
)
|
||||
});
|
||||
logForDebugging(`Install: Saved autoUpdatesChannel=${target} to user settings`);
|
||||
}
|
||||
|
||||
// Combine all warning/info messages (convert SetupMessage to string)
|
||||
const allWarnings = [...warnings, ...aliasMessages.map(m => m.message)]
|
||||
const allWarnings = [...warnings, ...aliasMessages.map(m => m.message)];
|
||||
|
||||
// Check if there were any setup errors or notes
|
||||
if (setupMessages.length > 0) {
|
||||
setState({
|
||||
type: 'set-up',
|
||||
messages: setupMessages.map(m => m.message),
|
||||
})
|
||||
});
|
||||
// Still mark as success but show both setup messages and cleanup warnings
|
||||
setTimeout(setState, 2000, {
|
||||
type: 'success' as const,
|
||||
version: result.latestVersion || 'current',
|
||||
setupMessages: [
|
||||
...setupMessages.map(m => m.message),
|
||||
...allWarnings,
|
||||
],
|
||||
})
|
||||
setupMessages: [...setupMessages.map(m => m.message), ...allWarnings],
|
||||
});
|
||||
} else {
|
||||
// No setup messages, go straight to success (but still show cleanup warnings if any)
|
||||
logForDebugging('Install: Shell PATH already configured')
|
||||
logForDebugging('Install: Shell PATH already configured');
|
||||
setState({
|
||||
type: 'success',
|
||||
version: result.latestVersion || 'current',
|
||||
setupMessages: allWarnings.length > 0 ? allWarnings : undefined,
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(`Install command failed: ${error}`, {
|
||||
level: 'error',
|
||||
})
|
||||
});
|
||||
setState({
|
||||
type: 'error',
|
||||
message: errorMessage(error),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
}, [force, target])
|
||||
void run();
|
||||
}, [force, target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.type === 'success') {
|
||||
// Give success message time to render before exiting
|
||||
setTimeout(
|
||||
onDone,
|
||||
2000,
|
||||
'Claude Code installation completed successfully',
|
||||
{
|
||||
display: 'system' as const,
|
||||
},
|
||||
)
|
||||
setTimeout(onDone, 2000, 'Claude Code installation completed successfully', {
|
||||
display: 'system' as const,
|
||||
});
|
||||
} else if (state.type === 'error') {
|
||||
// Give error message time to render before exiting
|
||||
setTimeout(onDone, 3000, 'Claude Code installation failed', {
|
||||
display: 'system' as const,
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [state, onDone])
|
||||
}, [state, onDone]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{state.type === 'checking' && (
|
||||
<Text color="claude">Checking installation status...</Text>
|
||||
)}
|
||||
{state.type === 'checking' && <Text color="claude">Checking installation status...</Text>}
|
||||
|
||||
{state.type === 'cleaning-npm' && (
|
||||
<Text color="warning">Cleaning up old npm installations...</Text>
|
||||
)}
|
||||
{state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>}
|
||||
|
||||
{state.type === 'installing' && (
|
||||
<Text color="claude">
|
||||
Installing Claude Code native build {state.version}...
|
||||
</Text>
|
||||
<Text color="claude">Installing Claude Code native build {state.version}...</Text>
|
||||
)}
|
||||
|
||||
{state.type === 'setting-up' && (
|
||||
<Text color="claude">Setting up launcher and shell integration...</Text>
|
||||
)}
|
||||
{state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>}
|
||||
|
||||
{state.type === 'set-up' && <SetupNotes messages={state.messages} />}
|
||||
|
||||
@@ -291,7 +256,7 @@ function Install({ onDone, force, target }: InstallProps): React.ReactNode {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// This is only used from cli.tsx, not as a slash command
|
||||
@@ -301,27 +266,24 @@ export const install = {
|
||||
description: 'Install Claude Code native build',
|
||||
argumentHint: '[options]',
|
||||
async call(
|
||||
onDone: (
|
||||
result: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void,
|
||||
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void,
|
||||
_context: unknown,
|
||||
args: string[],
|
||||
) {
|
||||
// Parse arguments
|
||||
const force = args.includes('--force')
|
||||
const nonFlagArgs = args.filter(arg => !arg.startsWith('--'))
|
||||
const target = nonFlagArgs[0] // 'latest', 'stable', or version like '1.0.34'
|
||||
const force = args.includes('--force');
|
||||
const nonFlagArgs = args.filter(arg => !arg.startsWith('--'));
|
||||
const target = nonFlagArgs[0]; // 'latest', 'stable', or version like '1.0.34'
|
||||
|
||||
const { unmount } = await render(
|
||||
<Install
|
||||
onDone={(result, options) => {
|
||||
unmount()
|
||||
onDone(result, options)
|
||||
unmount();
|
||||
onDone(result, options);
|
||||
}}
|
||||
force={force}
|
||||
target={target}
|
||||
/>,
|
||||
)
|
||||
);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
|
||||
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
|
||||
|
||||
/**
|
||||
* /job slash command — manages template jobs from inside the REPL.
|
||||
@@ -11,24 +11,24 @@ export async function call(
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const parts = args ? args.trim().split(/\s+/) : []
|
||||
const sub = parts[0] || 'list'
|
||||
const parts = args ? args.trim().split(/\s+/) : [];
|
||||
const sub = parts[0] || 'list';
|
||||
|
||||
// Capture console output so we can return it as onDone text
|
||||
const lines: string[] = []
|
||||
const origLog = console.log
|
||||
const origError = console.error
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
|
||||
const lines: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origError = console.error;
|
||||
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '));
|
||||
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '));
|
||||
|
||||
try {
|
||||
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
|
||||
await templatesMain([sub, ...parts.slice(1)])
|
||||
const { templatesMain } = await import('../../cli/handlers/templateJobs.js');
|
||||
await templatesMain([sub, ...parts.slice(1)]);
|
||||
} finally {
|
||||
console.log = origLog
|
||||
console.error = origError
|
||||
console.log = origLog;
|
||||
console.error = origError;
|
||||
}
|
||||
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' })
|
||||
return null
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,81 +1,71 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import { resetCostState } from '../../bootstrap/state.js'
|
||||
import {
|
||||
clearTrustedDeviceToken,
|
||||
enrollTrustedDevice,
|
||||
} from '../../bridge/trustedDevice.js'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
|
||||
import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'
|
||||
import { refreshPolicyLimits } from '../../services/policyLimits/index.js'
|
||||
import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { stripSignatureBlocks } from '../../utils/messages.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { resetCostState } from '../../bootstrap/state.js';
|
||||
import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
|
||||
import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
|
||||
import { refreshPolicyLimits } from '../../services/policyLimits/index.js';
|
||||
import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { stripSignatureBlocks } from '../../utils/messages.js';
|
||||
import {
|
||||
checkAndDisableAutoModeIfNeeded,
|
||||
resetAutoModeGateCheck,
|
||||
} from '../../utils/permissions/bypassPermissionsKillswitch.js'
|
||||
import { resetUserCache } from '../../utils/user.js'
|
||||
} from '../../utils/permissions/bypassPermissionsKillswitch.js';
|
||||
import { resetUserCache } from '../../utils/user.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
|
||||
return (
|
||||
<Login
|
||||
onDone={async success => {
|
||||
context.onChangeAPIKey()
|
||||
context.onChangeAPIKey();
|
||||
// Signature-bearing blocks (thinking, connector_text) are bound to the API key —
|
||||
// strip them so the new key doesn't reject stale signatures.
|
||||
context.setMessages(stripSignatureBlocks)
|
||||
context.setMessages(stripSignatureBlocks);
|
||||
if (success) {
|
||||
// Post-login refresh logic. Keep in sync with onboarding in src/interactiveHelpers.tsx
|
||||
// Reset cost state when switching accounts
|
||||
resetCostState()
|
||||
resetCostState();
|
||||
// Refresh remotely managed settings after login (non-blocking)
|
||||
void refreshRemoteManagedSettings()
|
||||
void refreshRemoteManagedSettings();
|
||||
// Refresh policy limits after login (non-blocking)
|
||||
void refreshPolicyLimits()
|
||||
void refreshPolicyLimits();
|
||||
// Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials
|
||||
resetUserCache()
|
||||
resetUserCache();
|
||||
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
|
||||
refreshGrowthBookAfterAuthChange()
|
||||
refreshGrowthBookAfterAuthChange();
|
||||
// Clear any stale trusted device token from a previous account before
|
||||
// re-enrolling — prevents sending the old token on bridge calls while
|
||||
// the async enrollTrustedDevice() is in-flight.
|
||||
clearTrustedDeviceToken()
|
||||
clearTrustedDeviceToken();
|
||||
// Enroll as a trusted device for Remote Control (10-min fresh-session window)
|
||||
void enrollTrustedDevice()
|
||||
void enrollTrustedDevice();
|
||||
// Reset killswitch gate checks and re-run with new org
|
||||
resetAutoModeGateCheck()
|
||||
const appState = context.getAppState()
|
||||
void checkAndDisableAutoModeIfNeeded(
|
||||
appState.toolPermissionContext,
|
||||
context.setAppState,
|
||||
appState.fastMode,
|
||||
)
|
||||
resetAutoModeGateCheck();
|
||||
const appState = context.getAppState();
|
||||
void checkAndDisableAutoModeIfNeeded(appState.toolPermissionContext, context.setAppState, appState.fastMode);
|
||||
// Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers)
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
authVersion: prev.authVersion + 1,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
onDone(success ? 'Login successful' : 'Login interrupted')
|
||||
onDone(success ? 'Login successful' : 'Login interrupted');
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function Login(props: {
|
||||
onDone: (success: boolean, mainLoopModel: string) => void
|
||||
startingMessage?: string
|
||||
onDone: (success: boolean, mainLoopModel: string) => void;
|
||||
startingMessage?: string;
|
||||
}): React.ReactNode {
|
||||
const mainLoopModel = useMainLoopModel()
|
||||
const mainLoopModel = useMainLoopModel();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -86,19 +76,11 @@ export function Login(props: {
|
||||
exitState.pending ? (
|
||||
<Text>Press {exitState.keyName} again to exit</Text>
|
||||
) : (
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ConsoleOAuthFlow
|
||||
onDone={() => props.onDone(true, mainLoopModel)}
|
||||
startingMessage={props.startingMessage}
|
||||
/>
|
||||
<ConsoleOAuthFlow onDone={() => props.onDone(true, mainLoopModel)} startingMessage={props.startingMessage} />
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,89 +1,80 @@
|
||||
import * as React from 'react'
|
||||
import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'
|
||||
import {
|
||||
getGroveNoticeConfig,
|
||||
getGroveSettings,
|
||||
} from '../../services/api/grove.js'
|
||||
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js'
|
||||
import * as React from 'react';
|
||||
import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
|
||||
import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove.js';
|
||||
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js';
|
||||
// flushTelemetry is loaded lazily to avoid pulling in ~1.1MB of OpenTelemetry at startup
|
||||
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js'
|
||||
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js'
|
||||
import { clearBetasCaches } from '../../utils/betas.js'
|
||||
import { saveGlobalConfig } from '../../utils/config.js'
|
||||
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'
|
||||
import { getSecureStorage } from '../../utils/secureStorage/index.js'
|
||||
import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js'
|
||||
import { resetUserCache } from '../../utils/user.js'
|
||||
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js';
|
||||
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js';
|
||||
import { clearBetasCaches } from '../../utils/betas.js';
|
||||
import { saveGlobalConfig } from '../../utils/config.js';
|
||||
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js';
|
||||
import { getSecureStorage } from '../../utils/secureStorage/index.js';
|
||||
import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js';
|
||||
import { resetUserCache } from '../../utils/user.js';
|
||||
|
||||
export async function performLogout({
|
||||
clearOnboarding = false,
|
||||
}): Promise<void> {
|
||||
export async function performLogout({ clearOnboarding = false }): Promise<void> {
|
||||
// Flush telemetry BEFORE clearing credentials to prevent org data leakage
|
||||
const { flushTelemetry } = await import(
|
||||
'../../utils/telemetry/instrumentation.js'
|
||||
)
|
||||
await flushTelemetry()
|
||||
const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js');
|
||||
await flushTelemetry();
|
||||
|
||||
await removeApiKey()
|
||||
await removeApiKey();
|
||||
|
||||
// Wipe all secure storage data on logout
|
||||
const secureStorage = getSecureStorage()
|
||||
secureStorage.delete()
|
||||
const secureStorage = getSecureStorage();
|
||||
secureStorage.delete();
|
||||
|
||||
await clearAuthRelatedCaches()
|
||||
await clearAuthRelatedCaches();
|
||||
saveGlobalConfig(current => {
|
||||
const updated = { ...current }
|
||||
const updated = { ...current };
|
||||
if (clearOnboarding) {
|
||||
updated.hasCompletedOnboarding = false
|
||||
updated.subscriptionNoticeCount = 0
|
||||
updated.hasAvailableSubscription = false
|
||||
updated.hasCompletedOnboarding = false;
|
||||
updated.subscriptionNoticeCount = 0;
|
||||
updated.hasAvailableSubscription = false;
|
||||
if (updated.customApiKeyResponses?.approved) {
|
||||
updated.customApiKeyResponses = {
|
||||
...updated.customApiKeyResponses,
|
||||
approved: [],
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
updated.oauthAccount = undefined
|
||||
return updated
|
||||
})
|
||||
updated.oauthAccount = undefined;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// clearing anything memoized that must be invalidated when user/session/auth changes
|
||||
export async function clearAuthRelatedCaches(): Promise<void> {
|
||||
// Clear the OAuth token cache
|
||||
getClaudeAIOAuthTokens.cache?.clear?.()
|
||||
clearTrustedDeviceTokenCache()
|
||||
clearBetasCaches()
|
||||
clearToolSchemaCache()
|
||||
getClaudeAIOAuthTokens.cache?.clear?.();
|
||||
clearTrustedDeviceTokenCache();
|
||||
clearBetasCaches();
|
||||
clearToolSchemaCache();
|
||||
|
||||
// Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials
|
||||
resetUserCache()
|
||||
refreshGrowthBookAfterAuthChange()
|
||||
resetUserCache();
|
||||
refreshGrowthBookAfterAuthChange();
|
||||
|
||||
// Clear Grove config cache
|
||||
getGroveNoticeConfig.cache?.clear?.()
|
||||
getGroveSettings.cache?.clear?.()
|
||||
getGroveNoticeConfig.cache?.clear?.();
|
||||
getGroveSettings.cache?.clear?.();
|
||||
|
||||
// Clear remotely managed settings cache
|
||||
await clearRemoteManagedSettingsCache()
|
||||
await clearRemoteManagedSettingsCache();
|
||||
|
||||
// Clear policy limits cache
|
||||
await clearPolicyLimitsCache()
|
||||
await clearPolicyLimitsCache();
|
||||
}
|
||||
|
||||
export async function call(): Promise<React.ReactNode> {
|
||||
await performLogout({ clearOnboarding: true })
|
||||
await performLogout({ clearOnboarding: true });
|
||||
|
||||
const message = (
|
||||
<Text>Successfully logged out from your Anthropic account.</Text>
|
||||
)
|
||||
const message = <Text>Successfully logged out from your Anthropic account.</Text>;
|
||||
|
||||
setTimeout(() => {
|
||||
gracefulShutdownSync(0, 'logout')
|
||||
}, 200)
|
||||
gracefulShutdownSync(0, 'logout');
|
||||
}, 200);
|
||||
|
||||
return message
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { MCPSettings } from '../../components/mcp/index.js'
|
||||
import { MCPReconnect } from '../../components/mcp/MCPReconnect.js'
|
||||
import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { PluginSettings } from '../plugin/PluginSettings.js'
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { MCPSettings } from '../../components/mcp/index.js';
|
||||
import { MCPReconnect } from '../../components/mcp/MCPReconnect.js';
|
||||
import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js';
|
||||
import { useAppState } from '../../state/AppState.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { PluginSettings } from '../plugin/PluginSettings.js';
|
||||
|
||||
// TODO: This is a hack to get the context value from toggleMcpServer (useContext only works in a component)
|
||||
// Ideally, all MCP state and functions would be in global state.
|
||||
@@ -13,93 +13,72 @@ function MCPToggle({
|
||||
target,
|
||||
onComplete,
|
||||
}: {
|
||||
action: 'enable' | 'disable'
|
||||
target: string
|
||||
onComplete: (result: string) => void
|
||||
action: 'enable' | 'disable';
|
||||
target: string;
|
||||
onComplete: (result: string) => void;
|
||||
}): null {
|
||||
const mcpClients = useAppState(s => s.mcp.clients)
|
||||
const toggleMcpServer = useMcpToggleEnabled()
|
||||
const didRun = useRef(false)
|
||||
const mcpClients = useAppState(s => s.mcp.clients);
|
||||
const toggleMcpServer = useMcpToggleEnabled();
|
||||
const didRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (didRun.current) return
|
||||
didRun.current = true
|
||||
if (didRun.current) return;
|
||||
didRun.current = true;
|
||||
|
||||
const isEnabling = action === 'enable'
|
||||
const clients = mcpClients.filter(c => c.name !== 'ide')
|
||||
const isEnabling = action === 'enable';
|
||||
const clients = mcpClients.filter(c => c.name !== 'ide');
|
||||
const toToggle =
|
||||
target === 'all'
|
||||
? clients.filter(c =>
|
||||
isEnabling ? c.type === 'disabled' : c.type !== 'disabled',
|
||||
)
|
||||
: clients.filter(c => c.name === target)
|
||||
? clients.filter(c => (isEnabling ? c.type === 'disabled' : c.type !== 'disabled'))
|
||||
: clients.filter(c => c.name === target);
|
||||
|
||||
if (toToggle.length === 0) {
|
||||
onComplete(
|
||||
target === 'all'
|
||||
? `All MCP servers are already ${isEnabling ? 'enabled' : 'disabled'}`
|
||||
: `MCP server "${target}" not found`,
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of toToggle) {
|
||||
void toggleMcpServer(s.name)
|
||||
void toggleMcpServer(s.name);
|
||||
}
|
||||
|
||||
onComplete(
|
||||
target === 'all'
|
||||
? `${isEnabling ? 'Enabled' : 'Disabled'} ${toToggle.length} MCP server(s)`
|
||||
: `MCP server "${target}" ${isEnabling ? 'enabled' : 'disabled'}`,
|
||||
)
|
||||
}, [action, target, mcpClients, toggleMcpServer, onComplete])
|
||||
);
|
||||
}, [action, target, mcpClients, toggleMcpServer, onComplete]);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: unknown,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
|
||||
if (args) {
|
||||
const parts = args.trim().split(/\s+/)
|
||||
const parts = args.trim().split(/\s+/);
|
||||
|
||||
// Allow /mcp no-redirect to bypass the redirect for testing
|
||||
if (parts[0] === 'no-redirect') {
|
||||
return <MCPSettings onComplete={onDone} />
|
||||
return <MCPSettings onComplete={onDone} />;
|
||||
}
|
||||
|
||||
if (parts[0] === 'reconnect' && parts[1]) {
|
||||
return (
|
||||
<MCPReconnect
|
||||
serverName={parts.slice(1).join(' ')}
|
||||
onComplete={onDone}
|
||||
/>
|
||||
)
|
||||
return <MCPReconnect serverName={parts.slice(1).join(' ')} onComplete={onDone} />;
|
||||
}
|
||||
|
||||
if (parts[0] === 'enable' || parts[0] === 'disable') {
|
||||
return (
|
||||
<MCPToggle
|
||||
action={parts[0]}
|
||||
target={parts.length > 1 ? parts.slice(1).join(' ') : 'all'}
|
||||
onComplete={onDone}
|
||||
/>
|
||||
)
|
||||
<MCPToggle action={parts[0]} target={parts.length > 1 ? parts.slice(1).join(' ') : 'all'} onComplete={onDone} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect base /mcp command to /plugins installed tab for ant users
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
return (
|
||||
<PluginSettings
|
||||
onComplete={onDone}
|
||||
args="manage"
|
||||
showMcpRedirectMessage
|
||||
/>
|
||||
)
|
||||
return <PluginSettings onComplete={onDone} args="manage" showMcpRedirectMessage />;
|
||||
}
|
||||
|
||||
return <MCPSettings onComplete={onDone} />
|
||||
return <MCPSettings onComplete={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1,86 +1,74 @@
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import * as React from 'react'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js'
|
||||
import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js'
|
||||
import { Box, Link, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { getErrnoCode } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js'
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import * as React from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { Dialog } from '@anthropic/ink';
|
||||
import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js';
|
||||
import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js';
|
||||
import { Box, Link, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js';
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js';
|
||||
import { getErrnoCode } from '../../utils/errors.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js';
|
||||
|
||||
function MemoryCommand({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
}): React.ReactNode {
|
||||
const handleSelectMemoryFile = async (memoryPath: string) => {
|
||||
try {
|
||||
// Create claude directory if it doesn't exist (idempotent with recursive)
|
||||
if (memoryPath.includes(getClaudeConfigHomeDir())) {
|
||||
await mkdir(getClaudeConfigHomeDir(), { recursive: true })
|
||||
await mkdir(getClaudeConfigHomeDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Create file if it doesn't exist (wx flag fails if file exists,
|
||||
// which we catch to preserve existing content)
|
||||
try {
|
||||
await writeFile(memoryPath, '', { encoding: 'utf8', flag: 'wx' })
|
||||
await writeFile(memoryPath, '', { encoding: 'utf8', flag: 'wx' });
|
||||
} catch (e: unknown) {
|
||||
if (getErrnoCode(e) !== 'EEXIST') {
|
||||
throw e
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
await editFileInEditor(memoryPath)
|
||||
await editFileInEditor(memoryPath);
|
||||
|
||||
// Determine which environment variable controls the editor
|
||||
let editorSource = 'default'
|
||||
let editorValue = ''
|
||||
let editorSource = 'default';
|
||||
let editorValue = '';
|
||||
if (process.env.VISUAL) {
|
||||
editorSource = '$VISUAL'
|
||||
editorValue = process.env.VISUAL
|
||||
editorSource = '$VISUAL';
|
||||
editorValue = process.env.VISUAL;
|
||||
} else if (process.env.EDITOR) {
|
||||
editorSource = '$EDITOR'
|
||||
editorValue = process.env.EDITOR
|
||||
editorSource = '$EDITOR';
|
||||
editorValue = process.env.EDITOR;
|
||||
}
|
||||
|
||||
const editorInfo =
|
||||
editorSource !== 'default'
|
||||
? `Using ${editorSource}="${editorValue}".`
|
||||
: ''
|
||||
const editorInfo = editorSource !== 'default' ? `Using ${editorSource}="${editorValue}".` : '';
|
||||
|
||||
const editorHint = editorInfo
|
||||
? `> ${editorInfo} To change editor, set $EDITOR or $VISUAL environment variable.`
|
||||
: `> To use a different editor, set the $EDITOR or $VISUAL environment variable.`
|
||||
: `> To use a different editor, set the $EDITOR or $VISUAL environment variable.`;
|
||||
|
||||
onDone(
|
||||
`Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
onDone(`Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`, { display: 'system' });
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
onDone(`Error opening memory file: ${error}`)
|
||||
logError(error);
|
||||
onDone(`Error opening memory file: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onDone('Cancelled memory editing', { display: 'system' })
|
||||
}
|
||||
onDone('Cancelled memory editing', { display: 'system' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog title="Memory" onCancel={handleCancel} color="remember">
|
||||
<Box flexDirection="column">
|
||||
<React.Suspense fallback={null}>
|
||||
<MemoryFileSelector
|
||||
onSelect={handleSelectMemoryFile}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<MemoryFileSelector onSelect={handleSelectMemoryFile} onCancel={handleCancel} />
|
||||
</React.Suspense>
|
||||
|
||||
<Box marginTop={1}>
|
||||
@@ -90,13 +78,13 @@ function MemoryCommand({
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async onDone => {
|
||||
// Clear + prime before rendering — Suspense handles the unprimed case,
|
||||
// but awaiting here avoids a fallback flash on initial open.
|
||||
clearMemoryFileCaches()
|
||||
await getMemoryFiles()
|
||||
return <MemoryCommand onDone={onDone} />
|
||||
}
|
||||
clearMemoryFileCaches();
|
||||
await getMemoryFiles();
|
||||
return <MemoryCommand onDone={onDone} />;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { toString as qrToString } from 'qrcode'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Pane } from '@anthropic/ink'
|
||||
import { type KeyboardEvent, Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { toString as qrToString } from 'qrcode';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Pane } from '@anthropic/ink';
|
||||
import { type KeyboardEvent, Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
type Platform = 'ios' | 'android'
|
||||
type Platform = 'ios' | 'android';
|
||||
|
||||
type Props = {
|
||||
onDone: () => void
|
||||
}
|
||||
onDone: () => void;
|
||||
};
|
||||
|
||||
const PLATFORMS: Record<Platform, { url: string }> = {
|
||||
ios: {
|
||||
@@ -19,17 +19,17 @@ const PLATFORMS: Record<Platform, { url: string }> = {
|
||||
android: {
|
||||
url: 'https://play.google.com/store/apps/details?id=com.anthropic.claude',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
function MobileQRCode({ onDone }: Props): React.ReactNode {
|
||||
const [platform, setPlatform] = useState<Platform>('ios')
|
||||
const [platform, setPlatform] = useState<Platform>('ios');
|
||||
const [qrCodes, setQrCodes] = useState<Record<Platform, string>>({
|
||||
ios: '',
|
||||
android: '',
|
||||
})
|
||||
});
|
||||
|
||||
const { url } = PLATFORMS[platform]
|
||||
const qrCode = qrCodes[platform]
|
||||
const { url } = PLATFORMS[platform];
|
||||
const qrCode = qrCodes[platform];
|
||||
|
||||
// Generate both QR codes upfront to avoid flicker when switching
|
||||
useEffect(() => {
|
||||
@@ -43,42 +43,37 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
|
||||
type: 'utf8',
|
||||
errorCorrectionLevel: 'L',
|
||||
}),
|
||||
])
|
||||
setQrCodes({ ios, android })
|
||||
]);
|
||||
setQrCodes({ ios, android });
|
||||
}
|
||||
generateQRCodes().catch(() => {
|
||||
// QR generation failed, leave empty
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onDone()
|
||||
}, [onDone])
|
||||
onDone();
|
||||
}, [onDone]);
|
||||
|
||||
useKeybinding('confirm:no', handleClose, { context: 'Confirmation' })
|
||||
useKeybinding('confirm:no', handleClose, { context: 'Confirmation' });
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'q' || (e.ctrl && e.key === 'c')) {
|
||||
e.preventDefault()
|
||||
onDone()
|
||||
return
|
||||
e.preventDefault();
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'tab' || e.key === 'left' || e.key === 'right') {
|
||||
e.preventDefault()
|
||||
setPlatform(prev => (prev === 'ios' ? 'android' : 'ios'))
|
||||
e.preventDefault();
|
||||
setPlatform(prev => (prev === 'ios' ? 'android' : 'ios'));
|
||||
}
|
||||
}
|
||||
|
||||
const lines = qrCode.split('\n').filter(line => line.length > 0)
|
||||
const lines = qrCode.split('\n').filter(line => line.length > 0);
|
||||
|
||||
return (
|
||||
<Pane>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Box flexDirection="column" tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
|
||||
<Text> </Text>
|
||||
<Text> </Text>
|
||||
{lines.map((line, i) => (
|
||||
@@ -94,10 +89,7 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
|
||||
iOS
|
||||
</Text>
|
||||
<Text dimColor>{' / '}</Text>
|
||||
<Text
|
||||
bold={platform === 'android'}
|
||||
underline={platform === 'android'}
|
||||
>
|
||||
<Text bold={platform === 'android'} underline={platform === 'android'}>
|
||||
Android
|
||||
</Text>
|
||||
</Text>
|
||||
@@ -106,11 +98,9 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
|
||||
<Text dimColor>{url}</Text>
|
||||
</Box>
|
||||
</Pane>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
return <MobileQRCode onDone={onDone} />
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
return <MobileQRCode onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,119 +1,96 @@
|
||||
import chalk from 'chalk'
|
||||
import * as React from 'react'
|
||||
import type { CommandResultDisplay } from '../../commands.js'
|
||||
import { ModelPicker } from '../../components/ModelPicker.js'
|
||||
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
|
||||
import chalk from 'chalk';
|
||||
import * as React from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { ModelPicker } from '../../components/ModelPicker.js';
|
||||
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import type { EffortLevel } from '../../utils/effort.js'
|
||||
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import type { EffortLevel } from '../../utils/effort.js';
|
||||
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js';
|
||||
import {
|
||||
clearFastModeCooldown,
|
||||
isFastModeAvailable,
|
||||
isFastModeEnabled,
|
||||
isFastModeSupportedByModel,
|
||||
} from '../../utils/fastMode.js'
|
||||
import { MODEL_ALIASES } from '../../utils/model/aliases.js'
|
||||
import {
|
||||
checkOpus1mAccess,
|
||||
checkSonnet1mAccess,
|
||||
} from '../../utils/model/check1mAccess.js'
|
||||
} from '../../utils/fastMode.js';
|
||||
import { MODEL_ALIASES } from '../../utils/model/aliases.js';
|
||||
import { checkOpus1mAccess, checkSonnet1mAccess } from '../../utils/model/check1mAccess.js';
|
||||
import {
|
||||
getDefaultMainLoopModelSetting,
|
||||
isOpus1mMergeEnabled,
|
||||
renderDefaultModelSetting,
|
||||
} from '../../utils/model/model.js'
|
||||
import { isModelAllowed } from '../../utils/model/modelAllowlist.js'
|
||||
import { validateModel } from '../../utils/model/validateModel.js'
|
||||
} from '../../utils/model/model.js';
|
||||
import { isModelAllowed } from '../../utils/model/modelAllowlist.js';
|
||||
import { validateModel } from '../../utils/model/validateModel.js';
|
||||
|
||||
function ModelPickerWrapper({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
}): React.ReactNode {
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel)
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
|
||||
const isFastMode = useAppState(s => s.fastMode)
|
||||
const setAppState = useSetAppState()
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel);
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
|
||||
const isFastMode = useAppState(s => s.fastMode);
|
||||
const setAppState = useSetAppState();
|
||||
|
||||
function handleCancel(): void {
|
||||
logEvent('tengu_model_command_menu', {
|
||||
action:
|
||||
'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
const displayModel = renderModelLabel(mainLoopModel)
|
||||
action: 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
const displayModel = renderModelLabel(mainLoopModel);
|
||||
onDone(`Kept model as ${chalk.bold(displayModel)}`, {
|
||||
display: 'system',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelect(
|
||||
model: string | null,
|
||||
effort: EffortLevel | undefined,
|
||||
): void {
|
||||
function handleSelect(model: string | null, effort: EffortLevel | undefined): void {
|
||||
logEvent('tengu_model_command_menu', {
|
||||
action:
|
||||
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
from_model:
|
||||
mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
to_model:
|
||||
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
action: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
from_model: mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
to_model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
mainLoopModel: model,
|
||||
mainLoopModelForSession: null,
|
||||
}))
|
||||
}));
|
||||
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(model))}`
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(model))}`;
|
||||
if (effort !== undefined) {
|
||||
message += ` with ${chalk.bold(effort)} effort`
|
||||
message += ` with ${chalk.bold(effort)} effort`;
|
||||
}
|
||||
|
||||
// Turn off fast mode if switching to unsupported model
|
||||
let wasFastModeToggledOn
|
||||
let wasFastModeToggledOn;
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
clearFastModeCooldown();
|
||||
if (!isFastModeSupportedByModel(model) && isFastMode) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fastMode: false,
|
||||
}))
|
||||
wasFastModeToggledOn = false
|
||||
}));
|
||||
wasFastModeToggledOn = false;
|
||||
// Do not update fast mode in settings since this is an automatic downgrade
|
||||
} else if (
|
||||
isFastModeSupportedByModel(model) &&
|
||||
isFastModeAvailable() &&
|
||||
isFastMode
|
||||
) {
|
||||
message += ` · Fast mode ON`
|
||||
wasFastModeToggledOn = true
|
||||
} else if (isFastModeSupportedByModel(model) && isFastModeAvailable() && isFastMode) {
|
||||
message += ` · Fast mode ON`;
|
||||
wasFastModeToggledOn = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBilledAsExtraUsage(
|
||||
model,
|
||||
wasFastModeToggledOn === true,
|
||||
isOpus1mMergeEnabled(),
|
||||
)
|
||||
) {
|
||||
message += ` · Billed as extra usage`
|
||||
if (isBilledAsExtraUsage(model, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) {
|
||||
message += ` · Billed as extra usage`;
|
||||
}
|
||||
|
||||
if (wasFastModeToggledOn === false) {
|
||||
// Fast mode was toggled off, show suffix after extra usage billing
|
||||
message += ` · Fast mode OFF`
|
||||
message += ` · Fast mode OFF`;
|
||||
}
|
||||
|
||||
onDone(message)
|
||||
onDone(message);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -124,37 +101,30 @@ function ModelPickerWrapper({
|
||||
onCancel={handleCancel}
|
||||
isStandaloneCommand
|
||||
showFastModeNotice={
|
||||
isFastModeEnabled() &&
|
||||
isFastMode &&
|
||||
isFastModeSupportedByModel(mainLoopModel) &&
|
||||
isFastModeAvailable()
|
||||
isFastModeEnabled() && isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable()
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SetModelAndClose({
|
||||
args,
|
||||
onDone,
|
||||
}: {
|
||||
args: string
|
||||
onDone: (
|
||||
result?: string,
|
||||
options?: { display?: CommandResultDisplay },
|
||||
) => void
|
||||
args: string;
|
||||
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
|
||||
}): React.ReactNode {
|
||||
const isFastMode = useAppState(s => s.fastMode)
|
||||
const setAppState = useSetAppState()
|
||||
const model = args === 'default' ? null : args
|
||||
const isFastMode = useAppState(s => s.fastMode);
|
||||
const setAppState = useSetAppState();
|
||||
const model = args === 'default' ? null : args;
|
||||
|
||||
React.useEffect(() => {
|
||||
async function handleModelChange(): Promise<void> {
|
||||
if (model && !isModelAllowed(model)) {
|
||||
onDone(
|
||||
`Model '${model}' is not available. Your organization restricts model selection.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return
|
||||
onDone(`Model '${model}' is not available. Your organization restricts model selection.`, {
|
||||
display: 'system',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update check for 1M access.
|
||||
@@ -162,47 +132,47 @@ function SetModelAndClose({
|
||||
onDone(
|
||||
`Opus 4.7 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (model && isSonnet1mUnavailable(model)) {
|
||||
onDone(
|
||||
`Sonnet 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation for default model
|
||||
if (!model) {
|
||||
setModel(null)
|
||||
return
|
||||
setModel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation for known aliases - they're predefined and should work
|
||||
if (isKnownAlias(model)) {
|
||||
setModel(model)
|
||||
return
|
||||
setModel(model);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and set custom model
|
||||
try {
|
||||
// Don't use parseUserSpecifiedModel for non-aliases since it lowercases the input
|
||||
// and model names are case-sensitive
|
||||
const { valid, error } = await validateModel(model)
|
||||
const { valid, error } = await validateModel(model);
|
||||
|
||||
if (valid) {
|
||||
setModel(model)
|
||||
setModel(model);
|
||||
} else {
|
||||
onDone(error || `Model '${model}' not found`, {
|
||||
display: 'system',
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
onDone(`Failed to validate model: ${(error as Error).message}`, {
|
||||
display: 'system',
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,127 +181,103 @@ function SetModelAndClose({
|
||||
...prev,
|
||||
mainLoopModel: modelValue,
|
||||
mainLoopModelForSession: null,
|
||||
}))
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
|
||||
}));
|
||||
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`;
|
||||
|
||||
let wasFastModeToggledOn
|
||||
let wasFastModeToggledOn;
|
||||
if (isFastModeEnabled()) {
|
||||
clearFastModeCooldown()
|
||||
clearFastModeCooldown();
|
||||
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fastMode: false,
|
||||
}))
|
||||
wasFastModeToggledOn = false
|
||||
}));
|
||||
wasFastModeToggledOn = false;
|
||||
// Do not update fast mode in settings since this is an automatic downgrade
|
||||
} else if (isFastModeSupportedByModel(modelValue) && isFastMode) {
|
||||
message += ` · Fast mode ON`
|
||||
wasFastModeToggledOn = true
|
||||
message += ` · Fast mode ON`;
|
||||
wasFastModeToggledOn = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBilledAsExtraUsage(
|
||||
modelValue,
|
||||
wasFastModeToggledOn === true,
|
||||
isOpus1mMergeEnabled(),
|
||||
)
|
||||
) {
|
||||
message += ` · Billed as extra usage`
|
||||
if (isBilledAsExtraUsage(modelValue, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) {
|
||||
message += ` · Billed as extra usage`;
|
||||
}
|
||||
|
||||
if (wasFastModeToggledOn === false) {
|
||||
// Fast mode was toggled off, show suffix after extra usage billing
|
||||
message += ` · Fast mode OFF`
|
||||
message += ` · Fast mode OFF`;
|
||||
}
|
||||
|
||||
onDone(message)
|
||||
onDone(message);
|
||||
}
|
||||
|
||||
void handleModelChange()
|
||||
}, [model, onDone, setAppState])
|
||||
void handleModelChange();
|
||||
}, [model, onDone, setAppState]);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
function isKnownAlias(model: string): boolean {
|
||||
return (MODEL_ALIASES as readonly string[]).includes(
|
||||
model.toLowerCase().trim(),
|
||||
)
|
||||
return (MODEL_ALIASES as readonly string[]).includes(model.toLowerCase().trim());
|
||||
}
|
||||
|
||||
function isOpus1mUnavailable(model: string): boolean {
|
||||
const m = model.toLowerCase()
|
||||
return (
|
||||
!checkOpus1mAccess() &&
|
||||
!isOpus1mMergeEnabled() &&
|
||||
m.includes('opus') &&
|
||||
m.includes('[1m]')
|
||||
)
|
||||
const m = model.toLowerCase();
|
||||
return !checkOpus1mAccess() && !isOpus1mMergeEnabled() && m.includes('opus') && m.includes('[1m]');
|
||||
}
|
||||
|
||||
function isSonnet1mUnavailable(model: string): boolean {
|
||||
const m = model.toLowerCase()
|
||||
const m = model.toLowerCase();
|
||||
// Warn about Sonnet and Sonnet 4.6, but not Sonnet 4.5 since that had
|
||||
// a different access criteria.
|
||||
return (
|
||||
!checkSonnet1mAccess() &&
|
||||
(m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]'))
|
||||
)
|
||||
return !checkSonnet1mAccess() && (m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]'));
|
||||
}
|
||||
|
||||
function ShowModelAndClose({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (result?: string) => void
|
||||
}): React.ReactNode {
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel)
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
const displayModel = renderModelLabel(mainLoopModel)
|
||||
const effortInfo =
|
||||
effortValue !== undefined ? ` (effort: ${effortValue})` : ''
|
||||
function ShowModelAndClose({ onDone }: { onDone: (result?: string) => void }): React.ReactNode {
|
||||
const mainLoopModel = useAppState(s => s.mainLoopModel);
|
||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
const displayModel = renderModelLabel(mainLoopModel);
|
||||
const effortInfo = effortValue !== undefined ? ` (effort: ${effortValue})` : '';
|
||||
|
||||
if (mainLoopModelForSession) {
|
||||
onDone(
|
||||
`Current model: ${chalk.bold(renderModelLabel(mainLoopModelForSession))} (session override from plan mode)\nBase model: ${displayModel}${effortInfo}`,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onDone(`Current model: ${displayModel}${effortInfo}`)
|
||||
onDone(`Current model: ${displayModel}${effortInfo}`);
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||
args = args?.trim() || ''
|
||||
args = args?.trim() || '';
|
||||
if (COMMON_INFO_ARGS.includes(args)) {
|
||||
logEvent('tengu_model_command_inline_help', {
|
||||
args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return <ShowModelAndClose onDone={onDone} />
|
||||
});
|
||||
return <ShowModelAndClose onDone={onDone} />;
|
||||
}
|
||||
if (COMMON_HELP_ARGS.includes(args)) {
|
||||
onDone(
|
||||
'Run /model to open the model selection menu, or /model [modelName] to set the model.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return
|
||||
onDone('Run /model to open the model selection menu, or /model [modelName] to set the model.', {
|
||||
display: 'system',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (args) {
|
||||
logEvent('tengu_model_command_inline', {
|
||||
args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return <SetModelAndClose args={args} onDone={onDone} />
|
||||
});
|
||||
return <SetModelAndClose args={args} onDone={onDone} />;
|
||||
}
|
||||
|
||||
return <ModelPickerWrapper onDone={onDone} />
|
||||
}
|
||||
return <ModelPickerWrapper onDone={onDone} />;
|
||||
};
|
||||
|
||||
function renderModelLabel(model: string | null): string {
|
||||
const rendered = renderDefaultModelSetting(
|
||||
model ?? getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
return model === null ? `${rendered} (default)` : rendered
|
||||
const rendered = renderDefaultModelSetting(model ?? getDefaultMainLoopModelSetting());
|
||||
return model === null ? `${rendered} (default)` : rendered;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<undefined> {
|
||||
onDone(
|
||||
'/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import * as React from 'react'
|
||||
import { Passes } from '../../components/Passes/Passes.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { getCachedRemainingPasses } from '../../services/api/referral.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import * as React from 'react';
|
||||
import { Passes } from '../../components/Passes/Passes.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getCachedRemainingPasses } from '../../services/api/referral.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<React.ReactNode> {
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
// Mark that user has visited /passes so we stop showing the upsell
|
||||
const config = getGlobalConfig()
|
||||
const isFirstVisit = !config.hasVisitedPasses
|
||||
const config = getGlobalConfig();
|
||||
const isFirstVisit = !config.hasVisitedPasses;
|
||||
if (isFirstVisit) {
|
||||
const remaining = getCachedRemainingPasses()
|
||||
const remaining = getCachedRemainingPasses();
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
hasVisitedPasses: true,
|
||||
passesLastSeenRemaining: remaining ?? current.passesLastSeenRemaining,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
logEvent('tengu_guest_passes_visited', { is_first_visit: isFirstVisit })
|
||||
return <Passes onDone={onDone} />
|
||||
logEvent('tengu_guest_passes_visited', { is_first_visit: isFirstVisit });
|
||||
return <Passes onDone={onDone} />;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import * as React from 'react'
|
||||
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import { createPermissionRetryMessage } from '../../utils/messages.js'
|
||||
import * as React from 'react';
|
||||
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { createPermissionRetryMessage } from '../../utils/messages.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
return (
|
||||
<PermissionRuleList
|
||||
onExit={onDone}
|
||||
onRetryDenials={commands => {
|
||||
context.setMessages(prev => [
|
||||
...prev,
|
||||
createPermissionRetryMessage(commands),
|
||||
])
|
||||
context.setMessages(prev => [...prev, createPermissionRetryMessage(commands)]);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,9 @@ import plan from './index.js'
|
||||
describe('plan bridge invocation safety', () => {
|
||||
test('allows headless plan mode operations over Remote Control', () => {
|
||||
expect(plan.getBridgeInvocationError?.('')).toBeUndefined()
|
||||
expect(plan.getBridgeInvocationError?.('write a migration plan')).toBeUndefined()
|
||||
expect(
|
||||
plan.getBridgeInvocationError?.('write a migration plan'),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('blocks /plan open over Remote Control', () => {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import * as React from 'react'
|
||||
import { handlePlanModeTransition } from '../../bootstrap/state.js'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import { getExternalEditor } from '../../utils/editor.js'
|
||||
import { toIDEDisplayName } from '../../utils/ide.js'
|
||||
import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'
|
||||
import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js'
|
||||
import { getPlan, getPlanFilePath } from '../../utils/plans.js'
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js'
|
||||
import { renderToString } from '../../utils/staticRender.js'
|
||||
import * as React from 'react';
|
||||
import { handlePlanModeTransition } from '../../bootstrap/state.js';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getExternalEditor } from '../../utils/editor.js';
|
||||
import { toIDEDisplayName } from '../../utils/ide.js';
|
||||
import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js';
|
||||
import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js';
|
||||
import { getPlan, getPlanFilePath } from '../../utils/plans.js';
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js';
|
||||
import { renderToString } from '../../utils/staticRender.js';
|
||||
|
||||
function PlanDisplay({
|
||||
planContent,
|
||||
planPath,
|
||||
editorName,
|
||||
}: {
|
||||
planContent: string
|
||||
planPath: string
|
||||
editorName: string | undefined
|
||||
planContent: string;
|
||||
planPath: string;
|
||||
editorName: string | undefined;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -37,7 +37,7 @@ function PlanDisplay({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -45,63 +45,58 @@ export async function call(
|
||||
context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const { getAppState, setAppState } = context
|
||||
const appState = getAppState()
|
||||
const currentMode = appState.toolPermissionContext.mode
|
||||
const { getAppState, setAppState } = context;
|
||||
const appState = getAppState();
|
||||
const currentMode = appState.toolPermissionContext.mode;
|
||||
|
||||
// If not in plan mode, enable it
|
||||
if (currentMode !== 'plan') {
|
||||
handlePlanModeTransition(currentMode, 'plan')
|
||||
handlePlanModeTransition(currentMode, 'plan');
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prepareContextForPlanMode(prev.toolPermissionContext),
|
||||
{ type: 'setMode', mode: 'plan', destination: 'session' },
|
||||
),
|
||||
}))
|
||||
const description = args.trim()
|
||||
toolPermissionContext: applyPermissionUpdate(prepareContextForPlanMode(prev.toolPermissionContext), {
|
||||
type: 'setMode',
|
||||
mode: 'plan',
|
||||
destination: 'session',
|
||||
}),
|
||||
}));
|
||||
const description = args.trim();
|
||||
if (description && description !== 'open') {
|
||||
onDone('Enabled plan mode', { shouldQuery: true })
|
||||
onDone('Enabled plan mode', { shouldQuery: true });
|
||||
} else {
|
||||
onDone('Enabled plan mode')
|
||||
onDone('Enabled plan mode');
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Already in plan mode - show the current plan
|
||||
const planContent = getPlan()
|
||||
const planPath = getPlanFilePath()
|
||||
const planContent = getPlan();
|
||||
const planPath = getPlanFilePath();
|
||||
|
||||
if (!planContent) {
|
||||
onDone('Already in plan mode. No plan written yet.')
|
||||
return null
|
||||
onDone('Already in plan mode. No plan written yet.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If user typed "/plan open", open in editor
|
||||
const argList = args.trim().split(/\s+/)
|
||||
const argList = args.trim().split(/\s+/);
|
||||
if (argList[0] === 'open') {
|
||||
const result = await editFileInEditor(planPath)
|
||||
const result = await editFileInEditor(planPath);
|
||||
if (result.error) {
|
||||
onDone(`Failed to open plan in editor: ${result.error}`)
|
||||
onDone(`Failed to open plan in editor: ${result.error}`);
|
||||
} else {
|
||||
onDone(`Opened plan in editor: ${planPath}`)
|
||||
onDone(`Opened plan in editor: ${planPath}`);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const editor = getExternalEditor()
|
||||
const editorName = editor ? toIDEDisplayName(editor) : undefined
|
||||
const editor = getExternalEditor();
|
||||
const editorName = editor ? toIDEDisplayName(editor) : undefined;
|
||||
|
||||
const display = (
|
||||
<PlanDisplay
|
||||
planContent={planContent}
|
||||
planPath={planPath}
|
||||
editorName={editorName}
|
||||
/>
|
||||
)
|
||||
const display = <PlanDisplay planContent={planContent} planPath={planPath} editorName={editorName} />;
|
||||
|
||||
// Render to string and pass to onDone like local commands do
|
||||
const output = await renderToString(display)
|
||||
onDone(output)
|
||||
return null
|
||||
const output = await renderToString(display);
|
||||
onDone(output);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { Spinner } from '../../components/Spinner.js'
|
||||
import TextInput from '../../components/TextInput.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { toError } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
|
||||
import {
|
||||
addMarketplaceSource,
|
||||
saveMarketplaceToSettings,
|
||||
} from '../../utils/plugins/marketplaceManager.js'
|
||||
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'
|
||||
import type { ViewState } from './types.js'
|
||||
} from 'src/services/analytics/index.js';
|
||||
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
|
||||
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { Spinner } from '../../components/Spinner.js';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { toError } from '../../utils/errors.js';
|
||||
import { logError } from '../../utils/log.js';
|
||||
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js';
|
||||
import { addMarketplaceSource, saveMarketplaceToSettings } from '../../utils/plugins/marketplaceManager.js';
|
||||
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js';
|
||||
import type { ViewState } from './types.js';
|
||||
|
||||
type Props = {
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
cursorOffset: number
|
||||
setCursorOffset: (offset: number) => void
|
||||
error: string | null
|
||||
setError: (error: string | null) => void
|
||||
result: string | null
|
||||
setResult: (result: string | null) => void
|
||||
setViewState: (state: ViewState) => void
|
||||
onAddComplete?: () => void | Promise<void>
|
||||
cliMode?: boolean
|
||||
}
|
||||
inputValue: string;
|
||||
setInputValue: (value: string) => void;
|
||||
cursorOffset: number;
|
||||
setCursorOffset: (offset: number) => void;
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
result: string | null;
|
||||
setResult: (result: string | null) => void;
|
||||
setViewState: (state: ViewState) => void;
|
||||
onAddComplete?: () => void | Promise<void>;
|
||||
cliMode?: boolean;
|
||||
};
|
||||
|
||||
export function AddMarketplace({
|
||||
inputValue,
|
||||
@@ -46,94 +43,87 @@ export function AddMarketplace({
|
||||
onAddComplete,
|
||||
cliMode = false,
|
||||
}: Props): React.ReactNode {
|
||||
const hasAttemptedAutoAdd = useRef(false)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [progressMessage, setProgressMessage] = useState<string>('')
|
||||
const hasAttemptedAutoAdd = useRef(false);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState<string>('');
|
||||
|
||||
const handleAdd = async () => {
|
||||
const input = inputValue.trim()
|
||||
const input = inputValue.trim();
|
||||
if (!input) {
|
||||
setError('Please enter a marketplace source')
|
||||
return
|
||||
setError('Please enter a marketplace source');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = await parseMarketplaceInput(input)
|
||||
const parsed = await parseMarketplaceInput(input);
|
||||
if (!parsed) {
|
||||
setError(
|
||||
'Invalid marketplace source format. Try: owner/repo, https://..., or ./path',
|
||||
)
|
||||
return
|
||||
setError('Invalid marketplace source format. Try: owner/repo, https://..., or ./path');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if parseMarketplaceInput returned an error
|
||||
if ('error' in parsed) {
|
||||
setError(parsed.error)
|
||||
return
|
||||
setError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setProgressMessage('')
|
||||
const { name, resolvedSource } = await addMarketplaceSource(
|
||||
parsed,
|
||||
message => {
|
||||
setProgressMessage(message)
|
||||
},
|
||||
)
|
||||
saveMarketplaceToSettings(name, { source: resolvedSource })
|
||||
clearAllCaches()
|
||||
setLoading(true);
|
||||
setProgressMessage('');
|
||||
const { name, resolvedSource } = await addMarketplaceSource(parsed, message => {
|
||||
setProgressMessage(message);
|
||||
});
|
||||
saveMarketplaceToSettings(name, { source: resolvedSource });
|
||||
clearAllCaches();
|
||||
|
||||
let sourceType = parsed.source
|
||||
let sourceType = parsed.source;
|
||||
if (parsed.source === 'github') {
|
||||
sourceType =
|
||||
parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
sourceType = parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS;
|
||||
}
|
||||
|
||||
logEvent('tengu_marketplace_added', {
|
||||
source_type:
|
||||
sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
source_type: sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
|
||||
if (onAddComplete) {
|
||||
await onAddComplete()
|
||||
await onAddComplete();
|
||||
}
|
||||
|
||||
setProgressMessage('')
|
||||
setLoading(false)
|
||||
setProgressMessage('');
|
||||
setLoading(false);
|
||||
|
||||
if (cliMode) {
|
||||
// In CLI mode, set result to trigger completion
|
||||
setResult(`Successfully added marketplace: ${name}`)
|
||||
setResult(`Successfully added marketplace: ${name}`);
|
||||
} else {
|
||||
// In interactive mode, switch to browse view
|
||||
setViewState({ type: 'browse-marketplace', targetMarketplace: name })
|
||||
setViewState({ type: 'browse-marketplace', targetMarketplace: name });
|
||||
}
|
||||
} catch (err) {
|
||||
const error = toError(err)
|
||||
logError(error)
|
||||
setError(error.message)
|
||||
setProgressMessage('')
|
||||
setLoading(false)
|
||||
const error = toError(err);
|
||||
logError(error);
|
||||
setError(error.message);
|
||||
setProgressMessage('');
|
||||
setLoading(false);
|
||||
|
||||
if (cliMode) {
|
||||
// In CLI mode, set result with error to trigger completion
|
||||
setResult(`Error: ${error.message}`)
|
||||
setResult(`Error: ${error.message}`);
|
||||
} else {
|
||||
setResult(null)
|
||||
setResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-add if inputValue is provided
|
||||
useEffect(() => {
|
||||
if (inputValue && !hasAttemptedAutoAdd.current && !error && !result) {
|
||||
hasAttemptedAutoAdd.current = true
|
||||
void handleAdd()
|
||||
hasAttemptedAutoAdd.current = true;
|
||||
void handleAdd();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []) // Only run once on mount
|
||||
}, []); // Only run once on mount
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -164,9 +154,7 @@ export function AddMarketplace({
|
||||
{isLoading && (
|
||||
<Box marginTop={1}>
|
||||
<Spinner />
|
||||
<Text>
|
||||
{progressMessage || 'Adding marketplace to configuration…'}
|
||||
</Text>
|
||||
<Text>{progressMessage || 'Adding marketplace to configuration…'}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
@@ -184,15 +172,10 @@ export function AddMarketplace({
|
||||
<Text dimColor italic>
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="add" />
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Settings"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="cancel" />
|
||||
</Byline>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user