mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as a re-export barrel so the import sites did not have to change. This removes the barrel: every consumer now imports from the module that actually declares the type, and `src/shared/types.ts` is deleted. Barrels hide where a type lives, make every consumer look like it depends on the whole domain, and let an unrelated edit invalidate a module that ~2,000 files transitively import. 2,323 import declarations across 2,321 files. Rewritten mechanically: each specifier was resolved to an absolute path via the TypeScript AST and recomputed, rather than string-substituted, so alias forms (`@/../../shared/ types`) and per-specifier `type` modifiers survive. Four cases the mechanical pass had to handle, each found by a gate rather than by reading the diff: - Modules inside `src/shared` import the barrel as `./types`, not `shared/types`. A pre-filter on the latter string skipped 176 of them and left imports dangling at a deleted file, which surfaced as confusing `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>` errors rather than "module not found". - The barrel RENAMED one type on the way through (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name in the owning module has to be re-aliased at each consumer. - Three test files put `;(globalThis as ...)` on the line after the import. TypeScript parses that `;` as the import statement's terminator, so replacing through `statement.getEnd()` deletes it and breaks ASI. The rewrite now stops at the module specifier. - A file that already imported directly from a module got a SECOND import from it, because the barrel re-exported those same names — which trips `import/no-duplicates` under `--deny-warnings`. A post-pass merges declarations sharing a specifier and type-only-ness; the `import type` plus `import` pair from one module is left alone, since that form is allowed. Splitting one barrel import into several genuinely adds lines, which pushed `terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character import must wrap, and neither local type collapses onto one line (101 and 116 characters). Rather than contort a type declaration to fit a line budget, `collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` — they are pure structural operations on the layout tree and independent of PTY ownership. `visible-worktrees.ts` similarly loses its own mini-barrel re-export of `isDefaultBranchWorkspace`, with the four real consumers repointed at the declaring module. No `max-lines` bypass added. Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted first — these projects are `composite: true` and reuse stale caches); the full `pnpm lint` green, not just bare oxlint — the narrower local check is what let the duplicate imports reach CI; max-lines ratchet OK at 344.
273 lines
9.1 KiB
TypeScript
273 lines
9.1 KiB
TypeScript
import { isTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config'
|
|
import type {
|
|
TerminalAgentQuickCommand,
|
|
TerminalCommandQuickCommand,
|
|
TerminalQuickCommand,
|
|
TerminalQuickCommandAction,
|
|
TerminalQuickCommandScope
|
|
} from './terminal-quick-command-types'
|
|
|
|
export const MAX_QUICK_COMMANDS = 40
|
|
export const MAX_QUICK_COMMAND_ID_LENGTH = 80
|
|
export const MAX_QUICK_COMMAND_LABEL_LENGTH = 80
|
|
export const MAX_QUICK_COMMAND_REPO_ID_LENGTH = 200
|
|
export const MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH = 4000
|
|
// Why: agent prompt quick commands still launch through startup commands for
|
|
// argv/flag agents, so this must stay within Orca's Windows shell safety cap.
|
|
export const MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH = 6000
|
|
const REMOVED_PRESET_IDS = new Set(['default-pwd', 'default-git-status'])
|
|
|
|
const DEFAULT_TERMINAL_QUICK_COMMANDS: TerminalQuickCommand[] = []
|
|
|
|
export type TerminalQuickCommandMutation =
|
|
| { type: 'upsert'; command: TerminalQuickCommand }
|
|
| { type: 'delete'; id: string }
|
|
|
|
export function getDefaultTerminalQuickCommands(): TerminalQuickCommand[] {
|
|
return DEFAULT_TERMINAL_QUICK_COMMANDS.map((command) => ({ ...command }))
|
|
}
|
|
|
|
function normalizeTerminalQuickCommandScope(input: unknown): TerminalQuickCommandScope {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
return { type: 'global' }
|
|
}
|
|
const record = input as Record<string, unknown>
|
|
if (record.type !== 'repo') {
|
|
return { type: 'global' }
|
|
}
|
|
const repoId = typeof record.repoId === 'string' ? record.repoId.trim() : ''
|
|
if (!repoId) {
|
|
return { type: 'global' }
|
|
}
|
|
return { type: 'repo', repoId: repoId.slice(0, MAX_QUICK_COMMAND_REPO_ID_LENGTH) }
|
|
}
|
|
|
|
export function getTerminalQuickCommandScope(
|
|
command: TerminalQuickCommand
|
|
): TerminalQuickCommandScope {
|
|
return normalizeTerminalQuickCommandScope(command.scope)
|
|
}
|
|
|
|
export function terminalQuickCommandMatchesRepo(
|
|
command: TerminalQuickCommand,
|
|
repoId: string | null
|
|
): boolean {
|
|
const scope = getTerminalQuickCommandScope(command)
|
|
return scope.type === 'global' || (repoId !== null && scope.repoId === repoId)
|
|
}
|
|
|
|
export function getTerminalQuickCommandAction(
|
|
command: TerminalQuickCommand
|
|
): TerminalQuickCommandAction {
|
|
return command.action === 'agent-prompt' ? 'agent-prompt' : 'terminal-command'
|
|
}
|
|
|
|
export function isTerminalAgentQuickCommand(
|
|
command: TerminalQuickCommand
|
|
): command is TerminalAgentQuickCommand {
|
|
return getTerminalQuickCommandAction(command) === 'agent-prompt'
|
|
}
|
|
|
|
export function supportsTerminalAgentQuickCommand(
|
|
agent: unknown
|
|
): agent is TerminalAgentQuickCommand['agent'] {
|
|
return isTuiAgent(agent) && TUI_AGENT_CONFIG[agent].promptInjectionMode !== 'stdin-after-start'
|
|
}
|
|
|
|
export function getTerminalQuickCommandBody(command: TerminalQuickCommand): string {
|
|
return isTerminalAgentQuickCommand(command) ? command.prompt : command.command
|
|
}
|
|
|
|
export function isTerminalQuickCommandComplete(command: TerminalQuickCommand): boolean {
|
|
return command.label.trim().length > 0 && getTerminalQuickCommandBody(command).trim().length > 0
|
|
}
|
|
|
|
export function normalizeTerminalQuickCommands(input: unknown): TerminalQuickCommand[] {
|
|
if (!Array.isArray(input)) {
|
|
return getDefaultTerminalQuickCommands()
|
|
}
|
|
|
|
const normalized: TerminalQuickCommand[] = []
|
|
const seenIds = new Set<string>()
|
|
|
|
for (const item of input) {
|
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
continue
|
|
}
|
|
const record = item as Record<string, unknown>
|
|
const rawId = typeof record.id === 'string' ? record.id.trim() : ''
|
|
if (REMOVED_PRESET_IDS.has(rawId)) {
|
|
continue
|
|
}
|
|
const hasLabel = typeof record.label === 'string'
|
|
const action: TerminalQuickCommandAction =
|
|
record.action === 'agent-prompt' ? 'agent-prompt' : 'terminal-command'
|
|
const hasCommand = typeof record.command === 'string'
|
|
const hasPrompt = typeof record.prompt === 'string'
|
|
// Why: settings saves on every edit; preserve incomplete rows so a newly
|
|
// added command is not deleted before the user fills in the command text.
|
|
if (!hasLabel && !hasCommand && !hasPrompt) {
|
|
continue
|
|
}
|
|
const agent = supportsTerminalAgentQuickCommand(record.agent) ? record.agent : null
|
|
if (action === 'agent-prompt' && agent === null) {
|
|
continue
|
|
}
|
|
const label = hasLabel ? String(record.label).trim() : ''
|
|
|
|
const idBase = rawId || `quick-command-${normalized.length + 1}`
|
|
let id = idBase.slice(0, MAX_QUICK_COMMAND_ID_LENGTH)
|
|
let suffix = 2
|
|
while (seenIds.has(id)) {
|
|
id = `${idBase.slice(0, MAX_QUICK_COMMAND_ID_LENGTH - 4)}-${suffix}`
|
|
suffix += 1
|
|
}
|
|
seenIds.add(id)
|
|
|
|
const base = {
|
|
id,
|
|
label: label.slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH),
|
|
scope: normalizeTerminalQuickCommandScope(record.scope)
|
|
}
|
|
|
|
if (action === 'agent-prompt') {
|
|
if (agent === null) {
|
|
continue
|
|
}
|
|
const agentId = agent
|
|
normalized.push({
|
|
...base,
|
|
action: 'agent-prompt',
|
|
agent: agentId,
|
|
prompt: (hasPrompt ? String(record.prompt).trimEnd() : '').slice(
|
|
0,
|
|
MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH
|
|
)
|
|
})
|
|
} else {
|
|
const command = hasCommand ? String(record.command).trimEnd() : ''
|
|
normalized.push({
|
|
...base,
|
|
action: 'terminal-command',
|
|
command: command.slice(0, MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH),
|
|
appendEnter: record.appendEnter !== false
|
|
})
|
|
}
|
|
|
|
if (normalized.length >= MAX_QUICK_COMMANDS) {
|
|
break
|
|
}
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function hasExactKeys(record: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
const actual = Object.keys(record)
|
|
return actual.length === keys.length && keys.every((key) => Object.hasOwn(record, key))
|
|
}
|
|
|
|
function isNormalizedTerminalQuickCommandScope(
|
|
value: unknown,
|
|
expected: TerminalQuickCommandScope
|
|
): boolean {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return false
|
|
}
|
|
const scope = value as Record<string, unknown>
|
|
if (expected.type === 'global') {
|
|
return hasExactKeys(scope, ['type']) && scope.type === 'global'
|
|
}
|
|
return (
|
|
hasExactKeys(scope, ['type', 'repoId']) &&
|
|
scope.type === 'repo' &&
|
|
scope.repoId === expected.repoId
|
|
)
|
|
}
|
|
|
|
function isNormalizedTerminalQuickCommand(value: unknown, expected: TerminalQuickCommand): boolean {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return false
|
|
}
|
|
const command = value as Record<string, unknown>
|
|
if (
|
|
command.id !== expected.id ||
|
|
command.label !== expected.label ||
|
|
!isNormalizedTerminalQuickCommandScope(command.scope, expected.scope ?? { type: 'global' })
|
|
) {
|
|
return false
|
|
}
|
|
if (isTerminalAgentQuickCommand(expected)) {
|
|
return (
|
|
hasExactKeys(command, ['id', 'label', 'action', 'agent', 'prompt', 'scope']) &&
|
|
command.action === 'agent-prompt' &&
|
|
command.agent === expected.agent &&
|
|
command.prompt === expected.prompt
|
|
)
|
|
}
|
|
return (
|
|
hasExactKeys(command, ['id', 'label', 'action', 'command', 'appendEnter', 'scope']) &&
|
|
command.action === 'terminal-command' &&
|
|
command.command === expected.command &&
|
|
command.appendEnter === expected.appendEnter
|
|
)
|
|
}
|
|
|
|
// Why: a full-list client must reject any "authoritative" payload that would
|
|
// change under normalization, or its next mutation could persist silent loss.
|
|
export function parseNormalizedTerminalQuickCommands(
|
|
input: unknown
|
|
): TerminalQuickCommand[] | null {
|
|
if (!Array.isArray(input) || input.length > MAX_QUICK_COMMANDS) {
|
|
return null
|
|
}
|
|
const normalized = normalizeTerminalQuickCommands(input)
|
|
if (
|
|
normalized.length !== input.length ||
|
|
normalized.some((command, index) => !isNormalizedTerminalQuickCommand(input[index], command))
|
|
) {
|
|
return null
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// Why: paired clients can edit settings concurrently. Applying one command at
|
|
// the host boundary preserves unrelated commands added by another client.
|
|
export function applyTerminalQuickCommandMutation(
|
|
commands: readonly TerminalQuickCommand[],
|
|
mutation: TerminalQuickCommandMutation
|
|
): TerminalQuickCommand[] {
|
|
if (mutation.type === 'delete') {
|
|
return commands.filter((command) => command.id !== mutation.id)
|
|
}
|
|
const existingIndex = commands.findIndex((command) => command.id === mutation.command.id)
|
|
if (existingIndex === -1) {
|
|
return [...commands, mutation.command]
|
|
}
|
|
return commands.map((command, index) => (index === existingIndex ? mutation.command : command))
|
|
}
|
|
|
|
export function buildTerminalQuickCommandInput(command: TerminalCommandQuickCommand): string {
|
|
return command.appendEnter ? `${command.command}\r` : command.command
|
|
}
|
|
|
|
const LINE_BREAK_RE = /\r\n|\r|\n/
|
|
|
|
// Why: quick-command lines are independent shell commands; one shell command
|
|
// list prevents foreground programs from reading later lines as stdin.
|
|
export function flattenTerminalQuickCommand(
|
|
command: TerminalCommandQuickCommand
|
|
): TerminalCommandQuickCommand {
|
|
if (!LINE_BREAK_RE.test(command.command)) {
|
|
return command
|
|
}
|
|
return {
|
|
...command,
|
|
command: command.command
|
|
.split(LINE_BREAK_RE)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.join('; ')
|
|
}
|
|
}
|