mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-26 10:05:51 +00:00
* fix: 终端内容溢出 viewport 时的重影 bug 主屏幕模式下 frame 持续溢出 viewport 时,cursor-restore LF 把内容滚入 scrollback 导致相对光标追踪漂移,可见区 diff 落到错误行产生重影(重复 banner / 错位)。 扩展 log-update overflow 分支为无条件 fullReset(含 \x1b[3J 清 scrollback), 并将主屏 self-healing 清屏从 ERASE_SCREEN (CSI 2 J) 换成 ERASE_DOWN (CSI J), 避免 xterm.js / VSCode 集成终端的 scrollback 边界副作用。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 3 个孤立诊断脚本 - scripts/verify-autofix-pr.ts: 一次性 autofix-pr 验证脚本,全仓零引用 - scripts/smoke-test-commands.ts: 开发期冒烟测试脚本,无任何 import - scripts/probe-subscription-endpoints.ts: 手动 endpoint 探针,无引用 均不在 package.json scripts、build.ts、vite.config.ts、CI workflows 中。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 self-hosted-runner stub 及其 cli.tsx fast-path - 删除 src/self-hosted-runner/main.ts(自动生成的 Promise.resolve() stub) - 同步移除 src/entrypoints/cli.tsx 中 feature('SELF_HOSTED_RUNNER') 守卫的 fast-path 分支 - 该 flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码 删除 stub 单独会留下未解析的动态 import,必须协同拆除。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 agentSdkTypes 中三个 not-implemented stub 移除 watchScheduledTasks、buildMissedTaskNotification、connectRemoteControl 三个 stub 函数(函数体仅 throw new Error('not implemented')),以及仅被这些 stub 引用的孤儿类型(ScheduledTasksHandle、ConnectRemoteControlOptions、RemoteControlHandle、InboundPrompt 等)。 全仓零外部引用。buildMissedTaskNotification 在 src/utils/cronScheduler.ts 有真实可用实现,未受影响。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 Cursor.ts 中未引用的 kill ring 访问器 - 删除 getKillRingItem、getKillRingSize、clearKillRing、canYankPop(全仓零引用的独立 export) - 移除 VIM_WORD_CHAR_REGEX 的 export 关键字(仍由 isVimWordChar 内部使用,保留常量本体) kill ring 特性本身仍活跃(getLastKill/pushToKillRing/yankPop 在 useSearchInput/useTextInput 使用),仅这几个孤儿 helper 未接入。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 insights.ts 中未引用的导出 - 删除 deduplicateSessionBranches(全仓零调用,含 JSDoc) - 删除 buildExportData(全仓零调用,原 S3 上传路径实际用 HTML 而非 JSON) - InsightsExport 仅移除 export 关键字(保留类型本体,仍作为内部返回类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 autonomyCommandSpec.ts 中未引用的导出 - 删除 AUTONOMY_CLI(CLI 子命令描述对象,零引用;handler 仅用 AUTONOMY_USAGE) - 删除 AUTONOMY_COMMAND_DESCRIPTION(值已在 main.tsx:5181 内联) - ParsedAutonomyCommand 仅移除 export 关键字(保留类型作为 parseAutonomyArgs 返回类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 binaryCheck/claudeAiLimits/codeIndexing 中未引用的导出 - binaryCheck.ts: 删除 clearBinaryCache(零调用,binaryCache 仍由 isBinaryInstalled 使用) - claudeAiLimits.ts: 删除 RATE_LIMIT_DISPLAY_NAMES 常量 + getRateLimitDisplayName(互为唯一消费者) - codeIndexing.ts: 删除 detectCodeIndexingFromMcpTool(同胞 detectCodeIndexingFromCommand/McpServerName 仍活跃) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除多处仅内部使用的 export 关键字 下列符号均仅在本文件内被引用,export 关键字冗余;保留符号本体不动: - internalLogging.ts: getContainerId(line 88 内部调用) - api/errors.ts: isMediaSizeError(line 151 内部调用) - api/withRetry.ts: parseMaxTokensContextOverflowError(line 389/724 内部调用) - statsCache.ts: STATS_CACHE_VERSION(7 处内部使用) - startupProfiler.ts: logStartupPerf(line 128 内部调用) - bashCommandHelpers.ts: CommandIdentityCheckers(3 处内部参数类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 清理注释代码块与 legacy shim 注释代码(已死的、引用不存在符号的注释块): - Onboarding.tsx: 注释化的 preflight if-block(引用不存在的 preflightStep) - ultraplan.tsx: 两处引用不存在符号的注释(ULTRAPLAN_INSTRUCTIONS、getUltraplanModel) - types/hooks.ts: 禁用的 type-fest IsEqual 类型断言块 - types/global.d.ts: 已被真实模块取代的 Ultraplan ambient declares - types/textInputTypes.ts: 注释化的 onMessage interface 成员 legacy shim: - cli/bg.ts: 删除 handleBgFlag 别名 export(同胞 handleBgStart 已被所有调用点使用) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 ccshareResume stub 及 main.tsx 的 ccshare fast-path - 删除 src/utils/ccshareResume.ts(parseCcshareId 恒返回 null、loadCcshare 恒抛错的 stub) - 同步移除 src/main.tsx 中 USER_TYPE === 'ant' 守卫下的 if (ccshareId) {...} else {...} 双分支 - 提升 else 块(文件路径 resume 处理)为直接进入 if (options.resume) 块内 ccshare 是 Anthropic 内部特性(go/ccshare URL),stub 未实现导致 ccshareId 恒为 null,整个 ccshare 分支永不进入;保留的文件路径 resume 路径不变。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 environment-runner stub 及其 cli.tsx fast-path 与 self-hosted-runner 相同模式的 sibling(工作流 1 verifier 建议同步处理): - 删除 src/environment-runner/main.ts(自动生成的 Promise.resolve() stub) - 同步移除 src/entrypoints/cli.tsx 中 feature('BYOC_ENVIRONMENT_RUNNER') 守卫的 fast-path 分支 - 清理两个空目录(src/self-hosted-runner/、src/environment-runner/) BYOC_ENVIRONMENT_RUNNER flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除孤立诊断脚本 probe-local-wiring.ts #!/usr/bin/env bun shebang 的手动诊断脚本,全仓零引用,不在 package.json/build.ts/vite.config.ts/CI workflows 中。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 ultrareview preflight stub 及其测试 - 删除 src/services/api/ultrareviewPreflight.ts(自动生成的 stub) - 删除 src/commands/review/UltrareviewPreflightDialog.tsx(依赖前者的 UI stub) - 删除 src/services/api/__tests__/ultrareviewPreflight.test.ts(测试已删代码) - 同步移除 ultrareviewCommand.test.tsx 中对 UltrareviewPreflightDialog 的 mock Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 cachedMCConfig stub 及 prompts.ts 的 CACHED_MICROCOMPACT 死代码 - 删除 src/services/compact/cachedMCConfig.ts(自动生成的 stub) - 同步移除 src/constants/prompts.ts 中依赖该 stub 的代码: - getCachedMCConfigForFRC 变量(feature('CACHED_MICROCOMPACT') 守卫的 require) - getFunctionResultClearingSection 函数(约 18 行) - systemPrompt 数组中的 frc section 调用与注册 CACHED_MICROCOMPACT 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 goalAudit stub 及其测试引用 - 删除 src/services/goal/goalAudit.ts(导出 COMPLETION_AUDIT_RULES/BLOCKED_AUDIT_RULES/isGoalTerminal 等未引用的 stub) - 同步移除 tests/integration/goal-lifecycle.test.ts 中对 goalAudit 的 import 和一个测试用例(budget_limited is terminal) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 agentSdkTypes 第二批 not-implemented stub 移除运行时函数体仅为 throw new Error 或 placeholder 的 stub: - createSdkMcpToolDefinition、createSdkMcpServer - query 函数重载与实现 - unstable_v2_* 系列函数 - session 操作 stub(getSessionMessages/listSessions/getSessionInfo/renameSession/tagSession/forkSession) - AbortError 类 保留所有 export type 重导出和类型别名(仍是公共类型面)。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 Tool.ts 中 backwards-compat 重导出 shim 删除 "// Re-export progress types for backwards compatibility" 注释块及其重导出语句。所有消费方已直接从 src/types/tools.js 导入,无需重导出转发。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 bootstrap/state.ts 中 4 个未引用的 export - clearRegisteredHooks(STATE.registeredHooks 仍由其他函数管理) - getInvokedSkills(getInvokedSkillsForAgent 是活跃入口) - getSessionSource(setSessionSource 仍活跃,sessionSource state 字段保留) - markScrollActivity(scrollDraining/getIsScrollDraining/waitForScrollDrain 仍活跃) 仅删除孤儿访问器,不动模块级 state 副作用。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 src/ 下多处未引用的导出 涉及 18 个文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除: - bridge/bridgeStatusUtil.ts、components/TrustDialog/utils.ts、context/stats.tsx - keybindings/loadUserBindings.ts、memdir/paths.ts、remote/sdkMessageAdapter.ts - services/acp/utils.ts(删除 nodeToWebReadable,全仓零引用) - services/api/metricsOptOut.ts、services/lsp/LSPDiagnosticRegistry.ts、services/lsp/manager.ts - services/mcp/utils.ts、services/skillLearning/projectContext.ts - services/teamMemorySync/secretScanner.ts、services/teamMemorySync/watcher.ts - skills/loadSkillsDir.ts、utils/attachments.ts、utils/filePersistence/filePersistence.ts - utils/messageQueueManager.ts Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 packages/ 下多处未引用的导出 涉及 11 个 workspace 包文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除: - @ant/ink/core/termio/csi.ts(eraseLine) - acp-link/manager/types.ts、acp-link/ws-message.ts - builtin-tools/AgentTool/agentMemory.ts、BashTool/bashSecurity.ts、BashTool/sedEditParser.ts - builtin-tools/ConfigTool/supportedSettings.ts、FileEditTool/utils.ts - remote-control-server/store.ts、transport/event-bus.ts、types/messages.ts Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * Revert "fix: 终端内容溢出 viewport 时的重影 bug" This reverts commit3d18e1da58. * revert: 移除主屏幕周期性 self-healing 重绘 回退f69c7051中引入的 ink.tsx self-healing 机制(lastMainScreenHealTime 字段 + 每 5 秒触发全量重绘 + needsEraseBeforePaint 主屏幕分支)。该机制在 workflow 面板持续刷新场景下表现为可见的"重复刷新",且修复效果不稳定。 alt-screen 的 needsEraseBeforePaint 路径和 prevFrameContaminated 字段保留, 它们仍服务于 handleResize / layout shift / selection 高亮。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * fix: /workflows 面板默认只显示运行中 run,根治 tab 行乱码 之前几次渲染层修复都失败,因为没动 tab 列表的数据源:打开 /workflows 会 自动 hydrate 最多 20 个历史 done/killed run,全部塞进一行 TabsBar,超出 终端宽度后 Ink 把字符画到屏外造成重影乱码。 - selectors.ts 加 filterActiveRuns(只留 status === 'running')和 capTabsForDisplay(超额 fold 成 +N)两个 pure function - WorkflowsPanel 接线 activeRuns:focus clamp、focused、nextTab/prevTab、 TabsBar 全部基于过滤后的 activeRuns - TabsBar 复用 truncateLabel 限制每个 tab 名 18 字符 + 最多 6 个 tab, 多余显示 +N,从结构上钉死单行总宽度 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * fix: /workflows 面板 phase 状态在脚本省略 phase() 时显示错乱 ultracode canonical pipeline 脚本常在 agent() 直接传 opts.phase 而不调 phase() hook,导致 phase_started 从未发出;同时 phase_done 只在下次 phase() 触发,上一 个 phase 在 run.phases 里一直停在 running。mergePhases 之前把 actual 当权威, 于是出现 "Map 8/8 全 done 还显示 running、Find 1/4 running 反而显示 pending"。 改为派生层修复:mergePhases 新增 derivePhaseStatus——actual.status==='done' 权威;否则有 agents 就按 agents 状态推(全 done→done,否则 running);否则看 actual 是否 running。再补一层遍历,让只在 agents 上出现的 phase 也进 sidebar。 不改 store 状态语义,已有 state.json 无需迁移。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * docs: 更新 readme * fix: ACP 模式未读取 settings.local.json entry.ts 在 ACP 握手期调用的 applySafeConfigEnvironmentVariables 触发了 loadSettingsFromDisk,此时 getOriginalCwd() 还是进程启动 cwd(非项目目录), 导致 localSettings/projectSettings 按错误路径解析为空并被 session cache 锁住, 后续 createSession 里 setOriginalCwd 也无法纠正。在 setOriginalCwd 与 chdir 之后清缓存并重新应用,让 settings.local.json 和项目级 env 对 readSettingsPermissionMode 及下游可见。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> --------- Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
519 lines
15 KiB
TypeScript
519 lines
15 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
|
import type { Permutations } from 'src/types/utils.js'
|
|
import { getSessionId } from '../bootstrap/state.js'
|
|
import type { AppState } from '../state/AppState.js'
|
|
import type {
|
|
QueueOperation,
|
|
QueueOperationMessage,
|
|
} from '../types/messageQueueTypes.js'
|
|
import type {
|
|
EditablePromptInputMode,
|
|
PromptInputMode,
|
|
QueuedCommand,
|
|
QueuePriority,
|
|
} from '../types/textInputTypes.js'
|
|
import type { PastedContent } from './config.js'
|
|
import { extractTextContent } from './messages.js'
|
|
import { objectGroupBy } from './objectGroupBy.js'
|
|
import { recordQueueOperation } from './sessionStorage.js'
|
|
import { createSignal } from './signal.js'
|
|
|
|
export type SetAppState = (f: (prev: AppState) => AppState) => void
|
|
|
|
// ============================================================================
|
|
// Logging helper
|
|
// ============================================================================
|
|
|
|
function logOperation(operation: QueueOperation, content?: string): void {
|
|
const sessionId = getSessionId()
|
|
const queueOp: QueueOperationMessage = {
|
|
type: 'queue-operation',
|
|
operation,
|
|
timestamp: new Date().toISOString(),
|
|
sessionId,
|
|
...(content !== undefined && { content }),
|
|
}
|
|
void recordQueueOperation(queueOp)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unified command queue (module-level, independent of React state)
|
|
//
|
|
// All commands — user input, task notifications, orphaned permissions — go
|
|
// through this single queue. React components subscribe via
|
|
// useSyncExternalStore (subscribeToCommandQueue / getCommandQueueSnapshot).
|
|
// Non-React code (print.ts streaming loop) reads directly via
|
|
// getCommandQueue() / getCommandQueueLength().
|
|
//
|
|
// Priority determines dequeue order: 'now' > 'next' > 'later'.
|
|
// Within the same priority, commands are processed FIFO.
|
|
// ============================================================================
|
|
|
|
const commandQueue: QueuedCommand[] = []
|
|
/** Frozen snapshot — recreated on every mutation for useSyncExternalStore. */
|
|
let snapshot: readonly QueuedCommand[] = Object.freeze([])
|
|
const queueChanged = createSignal()
|
|
|
|
function notifySubscribers(): void {
|
|
snapshot = Object.freeze([...commandQueue])
|
|
queueChanged.emit()
|
|
}
|
|
|
|
// ============================================================================
|
|
// useSyncExternalStore interface
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Subscribe to command queue changes.
|
|
* Compatible with React's useSyncExternalStore.
|
|
*/
|
|
export const subscribeToCommandQueue = queueChanged.subscribe
|
|
|
|
/**
|
|
* Get current snapshot of the command queue.
|
|
* Compatible with React's useSyncExternalStore.
|
|
* Returns a frozen array that only changes reference on mutation.
|
|
*/
|
|
export function getCommandQueueSnapshot(): readonly QueuedCommand[] {
|
|
return snapshot
|
|
}
|
|
|
|
// ============================================================================
|
|
// Read operations (for non-React code)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get a mutable copy of the current queue.
|
|
* Use for one-off reads where you need the actual commands.
|
|
*/
|
|
export function getCommandQueue(): QueuedCommand[] {
|
|
return [...commandQueue]
|
|
}
|
|
|
|
/**
|
|
* Get the current queue length without copying.
|
|
*/
|
|
export function getCommandQueueLength(): number {
|
|
return commandQueue.length
|
|
}
|
|
|
|
/**
|
|
* Check if there are commands in the queue.
|
|
*/
|
|
export function hasCommandsInQueue(): boolean {
|
|
return commandQueue.length > 0
|
|
}
|
|
|
|
/**
|
|
* Trigger a re-check by notifying subscribers.
|
|
* Use after async processing completes to ensure remaining commands
|
|
* are picked up by useSyncExternalStore consumers.
|
|
*/
|
|
export function recheckCommandQueue(): void {
|
|
if (commandQueue.length > 0) {
|
|
notifySubscribers()
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Write operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Add a command to the queue.
|
|
* Used for user-initiated commands (prompt, bash, orphaned-permission).
|
|
* Defaults priority to 'next' (processed before task notifications).
|
|
*/
|
|
export function enqueue(command: QueuedCommand): void {
|
|
commandQueue.push({ ...command, priority: command.priority ?? 'next' })
|
|
notifySubscribers()
|
|
logOperation(
|
|
'enqueue',
|
|
typeof command.value === 'string' ? command.value : undefined,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Add a task notification to the queue.
|
|
* Convenience wrapper that defaults priority to 'later' so user input
|
|
* is never starved by system messages.
|
|
*/
|
|
export function enqueuePendingNotification(command: QueuedCommand): void {
|
|
commandQueue.push({ ...command, priority: command.priority ?? 'later' })
|
|
notifySubscribers()
|
|
logOperation(
|
|
'enqueue',
|
|
typeof command.value === 'string' ? command.value : undefined,
|
|
)
|
|
}
|
|
|
|
const PRIORITY_ORDER: Record<QueuePriority, number> = {
|
|
now: 0,
|
|
next: 1,
|
|
later: 2,
|
|
}
|
|
|
|
/**
|
|
* Remove and return the highest-priority command, or undefined if empty.
|
|
* Within the same priority level, commands are dequeued FIFO.
|
|
*
|
|
* An optional `filter` narrows the candidates: only commands for which the
|
|
* predicate returns `true` are considered. Non-matching commands stay in the
|
|
* queue untouched. This lets between-turn drains (SDK, REPL) restrict to
|
|
* main-thread commands (`cmd.agentId === undefined`) without restructuring
|
|
* the existing while-loop patterns.
|
|
*/
|
|
export function dequeue(
|
|
filter?: (cmd: QueuedCommand) => boolean,
|
|
): QueuedCommand | undefined {
|
|
if (commandQueue.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
// Find the first command with the highest priority (respecting filter)
|
|
let bestIdx = -1
|
|
let bestPriority = Infinity
|
|
for (let i = 0; i < commandQueue.length; i++) {
|
|
const cmd = commandQueue[i]!
|
|
if (filter && !filter(cmd)) continue
|
|
const priority = PRIORITY_ORDER[cmd.priority ?? 'next']
|
|
if (priority < bestPriority) {
|
|
bestIdx = i
|
|
bestPriority = priority
|
|
}
|
|
}
|
|
|
|
if (bestIdx === -1) return undefined
|
|
|
|
const [dequeued] = commandQueue.splice(bestIdx, 1)
|
|
notifySubscribers()
|
|
logOperation('dequeue')
|
|
return dequeued
|
|
}
|
|
|
|
/**
|
|
* Remove and return all commands from the queue.
|
|
* Logs a dequeue operation for each command.
|
|
*/
|
|
export function dequeueAll(): QueuedCommand[] {
|
|
if (commandQueue.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const commands = [...commandQueue]
|
|
commandQueue.length = 0
|
|
notifySubscribers()
|
|
|
|
for (const _cmd of commands) {
|
|
logOperation('dequeue')
|
|
}
|
|
|
|
return commands
|
|
}
|
|
|
|
/**
|
|
* Return the highest-priority command without removing it, or undefined if empty.
|
|
* Accepts an optional `filter` — only commands passing the predicate are considered.
|
|
*/
|
|
export function peek(
|
|
filter?: (cmd: QueuedCommand) => boolean,
|
|
): QueuedCommand | undefined {
|
|
if (commandQueue.length === 0) {
|
|
return undefined
|
|
}
|
|
let bestIdx = -1
|
|
let bestPriority = Infinity
|
|
for (let i = 0; i < commandQueue.length; i++) {
|
|
const cmd = commandQueue[i]!
|
|
if (filter && !filter(cmd)) continue
|
|
const priority = PRIORITY_ORDER[cmd.priority ?? 'next']
|
|
if (priority < bestPriority) {
|
|
bestIdx = i
|
|
bestPriority = priority
|
|
}
|
|
}
|
|
if (bestIdx === -1) return undefined
|
|
return commandQueue[bestIdx]
|
|
}
|
|
|
|
/**
|
|
* Remove and return all commands matching a predicate, preserving priority order.
|
|
* Non-matching commands stay in the queue.
|
|
*/
|
|
export function dequeueAllMatching(
|
|
predicate: (cmd: QueuedCommand) => boolean,
|
|
): QueuedCommand[] {
|
|
const matched: QueuedCommand[] = []
|
|
const remaining: QueuedCommand[] = []
|
|
for (const cmd of commandQueue) {
|
|
if (predicate(cmd)) {
|
|
matched.push(cmd)
|
|
} else {
|
|
remaining.push(cmd)
|
|
}
|
|
}
|
|
if (matched.length === 0) {
|
|
return []
|
|
}
|
|
commandQueue.length = 0
|
|
commandQueue.push(...remaining)
|
|
notifySubscribers()
|
|
for (const _cmd of matched) {
|
|
logOperation('dequeue')
|
|
}
|
|
return matched
|
|
}
|
|
|
|
/**
|
|
* Remove specific commands from the queue by reference identity.
|
|
* Callers must pass the same object references that are in the queue
|
|
* (e.g. from getCommandsByMaxPriority). Logs a 'remove' operation for each.
|
|
*/
|
|
export function remove(commandsToRemove: QueuedCommand[]): void {
|
|
if (commandsToRemove.length === 0) {
|
|
return
|
|
}
|
|
|
|
const before = commandQueue.length
|
|
for (let i = commandQueue.length - 1; i >= 0; i--) {
|
|
if (commandsToRemove.includes(commandQueue[i]!)) {
|
|
commandQueue.splice(i, 1)
|
|
}
|
|
}
|
|
|
|
if (commandQueue.length !== before) {
|
|
notifySubscribers()
|
|
}
|
|
|
|
for (const _cmd of commandsToRemove) {
|
|
logOperation('remove')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove commands matching a predicate.
|
|
* Returns the removed commands.
|
|
*/
|
|
export function removeByFilter(
|
|
predicate: (cmd: QueuedCommand) => boolean,
|
|
): QueuedCommand[] {
|
|
const removed: QueuedCommand[] = []
|
|
for (let i = commandQueue.length - 1; i >= 0; i--) {
|
|
if (predicate(commandQueue[i]!)) {
|
|
removed.unshift(commandQueue.splice(i, 1)[0]!)
|
|
}
|
|
}
|
|
|
|
if (removed.length > 0) {
|
|
notifySubscribers()
|
|
for (const _cmd of removed) {
|
|
logOperation('remove')
|
|
}
|
|
}
|
|
|
|
return removed
|
|
}
|
|
|
|
/**
|
|
* Clear all commands from the queue.
|
|
* Used by ESC cancellation to discard queued notifications.
|
|
*/
|
|
export function clearCommandQueue(): void {
|
|
if (commandQueue.length === 0) {
|
|
return
|
|
}
|
|
commandQueue.length = 0
|
|
notifySubscribers()
|
|
}
|
|
|
|
/**
|
|
* Clear all commands and reset snapshot.
|
|
* Used for test cleanup.
|
|
*/
|
|
export function resetCommandQueue(): void {
|
|
commandQueue.length = 0
|
|
snapshot = Object.freeze([])
|
|
}
|
|
|
|
// ============================================================================
|
|
// Editable mode helpers
|
|
// ============================================================================
|
|
|
|
const NON_EDITABLE_MODES = new Set<PromptInputMode>([
|
|
'task-notification',
|
|
] satisfies Permutations<Exclude<PromptInputMode, EditablePromptInputMode>>)
|
|
|
|
export function isPromptInputModeEditable(
|
|
mode: PromptInputMode,
|
|
): mode is EditablePromptInputMode {
|
|
return !NON_EDITABLE_MODES.has(mode)
|
|
}
|
|
|
|
/**
|
|
* Whether this queued command can be pulled into the input buffer via UP/ESC.
|
|
* System-generated commands (proactive ticks, scheduled tasks, plan
|
|
* verification, channel messages) contain raw XML and must not leak into
|
|
* the user's input.
|
|
*/
|
|
export function isQueuedCommandEditable(cmd: QueuedCommand): boolean {
|
|
return isPromptInputModeEditable(cmd.mode) && !cmd.isMeta
|
|
}
|
|
|
|
/**
|
|
* Whether this queued command should render in the queue preview under the
|
|
* prompt. Superset of editable — channel messages show (so the keyboard user
|
|
* sees what arrived) but stay non-editable (raw XML).
|
|
*/
|
|
export function isQueuedCommandVisible(cmd: QueuedCommand): boolean {
|
|
if (
|
|
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
|
|
(cmd as Record<string, unknown>).origin !== undefined &&
|
|
((cmd as Record<string, unknown>).origin as Record<string, unknown>)
|
|
?.kind === 'channel'
|
|
)
|
|
return true
|
|
return isQueuedCommandEditable(cmd)
|
|
}
|
|
|
|
/**
|
|
* Extract text from a queued command value.
|
|
* For strings, returns the string.
|
|
* For ContentBlockParam[], extracts text from text blocks.
|
|
*/
|
|
function extractTextFromValue(value: string | ContentBlockParam[]): string {
|
|
return typeof value === 'string' ? value : extractTextContent(value, '\n')
|
|
}
|
|
|
|
/**
|
|
* Extract images from ContentBlockParam[] and convert to PastedContent format.
|
|
* Returns empty array for string values or if no images found.
|
|
*/
|
|
function extractImagesFromValue(
|
|
value: string | ContentBlockParam[],
|
|
startId: number,
|
|
): PastedContent[] {
|
|
if (typeof value === 'string') {
|
|
return []
|
|
}
|
|
|
|
const images: PastedContent[] = []
|
|
let imageIndex = 0
|
|
for (const block of value) {
|
|
if (block.type === 'image' && block.source.type === 'base64') {
|
|
images.push({
|
|
id: startId + imageIndex,
|
|
type: 'image',
|
|
content: block.source.data,
|
|
mediaType: block.source.media_type,
|
|
filename: `image${imageIndex + 1}`,
|
|
})
|
|
imageIndex++
|
|
}
|
|
}
|
|
return images
|
|
}
|
|
|
|
export type PopAllEditableResult = {
|
|
text: string
|
|
cursorOffset: number
|
|
images: PastedContent[]
|
|
}
|
|
|
|
/**
|
|
* Pop all editable commands and combine them with current input for editing.
|
|
* Notification modes (task-notification) are left in the queue
|
|
* to be auto-processed later.
|
|
* Returns object with combined text, cursor offset, and images to restore.
|
|
* Returns undefined if no editable commands in queue.
|
|
*/
|
|
export function popAllEditable(
|
|
currentInput: string,
|
|
currentCursorOffset: number,
|
|
): PopAllEditableResult | undefined {
|
|
if (commandQueue.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
const { editable = [], nonEditable = [] } = objectGroupBy(
|
|
[...commandQueue],
|
|
cmd => (isQueuedCommandEditable(cmd) ? 'editable' : 'nonEditable'),
|
|
)
|
|
|
|
if (editable.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
// Extract text from queued commands (handles both strings and ContentBlockParam[])
|
|
const queuedTexts = editable.map(cmd => extractTextFromValue(cmd.value))
|
|
const newInput = [...queuedTexts, currentInput].filter(Boolean).join('\n')
|
|
|
|
// Calculate cursor offset: length of joined queued commands + 1 + current cursor offset
|
|
const cursorOffset = queuedTexts.join('\n').length + 1 + currentCursorOffset
|
|
|
|
// Extract images from queued commands
|
|
const images: PastedContent[] = []
|
|
let nextImageId = Date.now() // Use timestamp as base for unique IDs
|
|
for (const cmd of editable) {
|
|
// handlePromptSubmit queues images in pastedContents (value is a string).
|
|
// Preserve the original PastedContent id so imageStore lookups still work.
|
|
if (cmd.pastedContents) {
|
|
for (const content of Object.values(cmd.pastedContents)) {
|
|
if (content.type === 'image') {
|
|
images.push(content)
|
|
}
|
|
}
|
|
}
|
|
// Bridge/remote commands may embed images directly in ContentBlockParam[].
|
|
const cmdImages = extractImagesFromValue(cmd.value, nextImageId)
|
|
images.push(...cmdImages)
|
|
nextImageId += cmdImages.length
|
|
}
|
|
|
|
for (const command of editable) {
|
|
logOperation(
|
|
'popAll',
|
|
typeof command.value === 'string' ? command.value : undefined,
|
|
)
|
|
}
|
|
|
|
// Replace queue contents with only the non-editable commands
|
|
commandQueue.length = 0
|
|
commandQueue.push(...nonEditable)
|
|
notifySubscribers()
|
|
|
|
return { text: newInput, cursorOffset, images }
|
|
}
|
|
|
|
/**
|
|
* Get commands at or above a given priority level without removing them.
|
|
* Useful for mid-chain draining where only urgent items should be processed.
|
|
*
|
|
* Priority order: 'now' (0) > 'next' (1) > 'later' (2).
|
|
* Passing 'now' returns only now-priority commands; 'later' returns everything.
|
|
*/
|
|
export function getCommandsByMaxPriority(
|
|
maxPriority: QueuePriority,
|
|
): QueuedCommand[] {
|
|
const threshold = PRIORITY_ORDER[maxPriority]
|
|
return commandQueue.filter(
|
|
cmd => PRIORITY_ORDER[cmd.priority ?? 'next'] <= threshold,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Returns true if the command is a slash command that should be routed through
|
|
* processSlashCommand rather than sent to the model as text.
|
|
*
|
|
* Commands with `skipSlashCommands` are usually treated as plain text, except
|
|
* Remote Control bridge messages (`bridgeOrigin`) that are re-validated later
|
|
* through isBridgeSafeCommand().
|
|
*/
|
|
export function isSlashCommand(cmd: QueuedCommand): boolean {
|
|
return (
|
|
typeof cmd.value === 'string' &&
|
|
cmd.value.trim().startsWith('/') &&
|
|
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
|
|
)
|
|
}
|