mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
@@ -0,0 +1,37 @@
|
||||
export type ComposerBranchSelection = {
|
||||
baseBranch: string
|
||||
branchNameOverride: string | undefined
|
||||
branchAutoName: string
|
||||
name: string | undefined
|
||||
lastAutoName: string | undefined
|
||||
}
|
||||
|
||||
export function resolveComposerBranchSelection(args: {
|
||||
refName: string
|
||||
localBranchName: string
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
}): ComposerBranchSelection {
|
||||
const trimmedCurrentName = args.currentName.trim()
|
||||
const shouldAutoName =
|
||||
!trimmedCurrentName ||
|
||||
args.currentName === args.lastAutoName ||
|
||||
args.localBranchName.startsWith(trimmedCurrentName) ||
|
||||
args.refName.startsWith(trimmedCurrentName)
|
||||
if (!shouldAutoName) {
|
||||
return {
|
||||
baseBranch: args.refName,
|
||||
branchNameOverride: undefined,
|
||||
branchAutoName: '',
|
||||
name: undefined,
|
||||
lastAutoName: undefined
|
||||
}
|
||||
}
|
||||
return {
|
||||
baseBranch: args.refName,
|
||||
branchNameOverride: args.localBranchName,
|
||||
branchAutoName: args.localBranchName,
|
||||
name: args.localBranchName,
|
||||
lastAutoName: args.localBranchName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Why: grouping and sorting of Project rows is deterministic shared logic
|
||||
// driven by `selectedView` — it must not depend on fetch ordering. Keeping it
|
||||
// in a pure shared module lets desktop and mobile render Project views the same.
|
||||
import type {
|
||||
GitHubProjectField,
|
||||
GitHubProjectRow,
|
||||
GitHubProjectSort,
|
||||
GitHubProjectTable
|
||||
} from './github-project-types'
|
||||
|
||||
export type ProjectGroup = {
|
||||
/** Stable key used for React reconciliation. */
|
||||
key: string
|
||||
/** Human-readable label used in the group header. */
|
||||
label: string
|
||||
/** Iteration metadata for headers that render a date range + Current pill. */
|
||||
iteration: {
|
||||
startDate: string
|
||||
duration: number
|
||||
completed: boolean
|
||||
} | null
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
|
||||
const EMPTY_GROUP_KEY = '__empty__'
|
||||
|
||||
// Why: use a finite sentinel instead of Infinity so subtractions in the sort
|
||||
// comparator stay finite. `Infinity - Infinity` is NaN, which makes
|
||||
// Array.sort's behavior implementation-defined and skips later tie-breaks.
|
||||
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
|
||||
|
||||
function getFieldValueForGrouping(
|
||||
row: GitHubProjectRow,
|
||||
field: GitHubProjectField
|
||||
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
|
||||
const value = row.fieldValuesByFieldId[field.id]
|
||||
if (!value) {
|
||||
return {
|
||||
key: EMPTY_GROUP_KEY,
|
||||
label: labelForEmpty(field),
|
||||
orderHint: UNKNOWN_INDEX_SENTINEL,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'iteration' && value.kind === 'iteration') {
|
||||
const idx = field.iterations.findIndex((it) => it.id === value.iterationId)
|
||||
const meta = field.iterations.find((it) => it.id === value.iterationId)
|
||||
return {
|
||||
key: value.iterationId,
|
||||
label: value.title || meta?.title || 'Iteration',
|
||||
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
|
||||
iteration: meta
|
||||
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
|
||||
: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'single-select' && value.kind === 'single-select') {
|
||||
const idx = field.options.findIndex((o) => o.id === value.optionId)
|
||||
return {
|
||||
key: value.optionId,
|
||||
label: value.name,
|
||||
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
const label = deriveStringValue(value)
|
||||
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
|
||||
}
|
||||
|
||||
function labelForEmpty(field: GitHubProjectField): string {
|
||||
return `No ${field.name}`
|
||||
}
|
||||
|
||||
function deriveStringValue(value: GitHubProjectRow['fieldValuesByFieldId'][string]): string {
|
||||
switch (value.kind) {
|
||||
case 'text':
|
||||
return value.text
|
||||
case 'number':
|
||||
return String(value.number)
|
||||
case 'date':
|
||||
return value.date
|
||||
case 'single-select':
|
||||
return value.name
|
||||
case 'iteration':
|
||||
return value.title
|
||||
case 'labels':
|
||||
return value.labels.map((l) => l.name).join(', ')
|
||||
case 'users':
|
||||
return value.users.map((u) => u.login).join(', ')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function groupRows(
|
||||
table: GitHubProjectTable,
|
||||
rowsInOrder: GitHubProjectRow[]
|
||||
): ProjectGroup[] {
|
||||
const groupField = table.selectedView.groupByFields[0]
|
||||
if (!groupField) {
|
||||
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
|
||||
}
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
orderHint: number
|
||||
iteration: ProjectGroup['iteration']
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
>()
|
||||
for (const row of rowsInOrder) {
|
||||
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
|
||||
let bucket = buckets.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { label, orderHint, iteration, rows: [] }
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
bucket.rows.push(row)
|
||||
}
|
||||
const entries = Array.from(buckets.entries())
|
||||
// Ordering rules per design doc §Grouping.
|
||||
entries.sort((a, b) => {
|
||||
if (a[0] === EMPTY_GROUP_KEY) {
|
||||
return 1
|
||||
}
|
||||
if (b[0] === EMPTY_GROUP_KEY) {
|
||||
return -1
|
||||
}
|
||||
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
|
||||
return a[1].orderHint - b[1].orderHint
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
return entries.map(([key, v]) => ({
|
||||
key,
|
||||
label: v.label,
|
||||
iteration: v.iteration,
|
||||
rows: v.rows
|
||||
}))
|
||||
}
|
||||
|
||||
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
|
||||
const field = sort.field
|
||||
const aValue = a.fieldValuesByFieldId[field.id]
|
||||
const bValue = b.fieldValuesByFieldId[field.id]
|
||||
if (!aValue && !bValue) {
|
||||
return 0
|
||||
}
|
||||
if (!aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!bValue) {
|
||||
return -1
|
||||
}
|
||||
|
||||
let cmp = 0
|
||||
if (
|
||||
field.kind === 'single-select' &&
|
||||
aValue.kind === 'single-select' &&
|
||||
bValue.kind === 'single-select'
|
||||
) {
|
||||
const aIdx = field.options.findIndex((o) => o.id === aValue.optionId)
|
||||
const bIdx = field.options.findIndex((o) => o.id === bValue.optionId)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (
|
||||
field.kind === 'iteration' &&
|
||||
aValue.kind === 'iteration' &&
|
||||
bValue.kind === 'iteration'
|
||||
) {
|
||||
const aIdx = field.iterations.findIndex((it) => it.id === aValue.iterationId)
|
||||
const bIdx = field.iterations.findIndex((it) => it.id === bValue.iterationId)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
|
||||
cmp = aValue.number - bValue.number
|
||||
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
|
||||
cmp = aValue.date.localeCompare(bValue.date)
|
||||
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
|
||||
cmp = aValue.text.localeCompare(bValue.text)
|
||||
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
|
||||
const aLogin = aValue.users[0]?.login ?? ''
|
||||
const bLogin = bValue.users[0]?.login ?? ''
|
||||
if (!aLogin && !bLogin) {
|
||||
cmp = 0
|
||||
} else if (!aLogin) {
|
||||
cmp = 1
|
||||
} else if (!bLogin) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aLogin.localeCompare(bLogin)
|
||||
}
|
||||
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
|
||||
const aName = aValue.labels[0]?.name ?? ''
|
||||
const bName = bValue.labels[0]?.name ?? ''
|
||||
if (!aName && !bName) {
|
||||
cmp = 0
|
||||
} else if (!aName) {
|
||||
cmp = 1
|
||||
} else if (!bName) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aName.localeCompare(bName)
|
||||
}
|
||||
} else {
|
||||
// Why: unknown sort-field kind — ignore this sort field and fall through
|
||||
// to tie-breaks (and eventually row.position).
|
||||
return 0
|
||||
}
|
||||
return sort.direction === 'DESC' ? -cmp : cmp
|
||||
}
|
||||
|
||||
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
|
||||
const sorts = table.selectedView.sortByFields
|
||||
const out = [...rows]
|
||||
out.sort((a, b) => {
|
||||
for (const sort of sorts) {
|
||||
const cmp = compareSort(a, b, sort)
|
||||
if (cmp !== 0) {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
|
||||
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
|
||||
if (Number.isNaN(start)) {
|
||||
return false
|
||||
}
|
||||
const end = start + iteration.duration * 86_400_000
|
||||
const now = Date.now()
|
||||
return now >= start && now < end
|
||||
}
|
||||
@@ -146,6 +146,9 @@ export type GitLabWorkItem = {
|
||||
/** Stamped by the renderer fetcher / optimistic stubs so cross-project
|
||||
* views can attribute rows. Mirrors GitHubWorkItem.repoId. */
|
||||
repoId: string
|
||||
/** Exact GitLab project that produced this row. Mutations/details must use it
|
||||
* instead of re-resolving the repo preference later. */
|
||||
projectRef?: GitLabProjectRef
|
||||
}
|
||||
|
||||
export type GitLabMRFile = {
|
||||
@@ -245,9 +248,8 @@ export type GitLabWorkItemDetails = {
|
||||
export type GitLabIssueUpdate = {
|
||||
state?: 'opened' | 'closed'
|
||||
title?: string
|
||||
/** Mirrors GitHubIssueUpdate.body — kept ignored on the repoPath-based
|
||||
* flow for the same backward-compat reason. Slug-addressed write paths
|
||||
* cover the body-edit case end-to-end. */
|
||||
/** Why: `glab issue update` handles title/labels/assignees, while body edits
|
||||
* use the REST issue endpoint so mobile can save the markdown description. */
|
||||
body?: string
|
||||
addLabels?: string[]
|
||||
removeLabels?: string[]
|
||||
|
||||
@@ -27,7 +27,8 @@ export const RUNTIME_CAPABILITIES = [
|
||||
'browser.screencast.v1',
|
||||
'terminal.binary-stream.v1',
|
||||
'terminal.multiplex.v1',
|
||||
'workspace-ports.v1'
|
||||
'workspace-ports.v1',
|
||||
'mobile.tasks.v1'
|
||||
] as const
|
||||
|
||||
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { z } from 'zod'
|
||||
import { FEATURE_WALL_MAX_DWELL_MS } from './feature-wall-telemetry'
|
||||
import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers'
|
||||
import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source'
|
||||
|
||||
import { AGENT_HOOK_TARGETS } from './agent-hook-types'
|
||||
import { ONBOARDING_FINAL_STEP } from './constants'
|
||||
@@ -129,15 +130,8 @@ export const workspaceCreateErrorClassSchema = z.enum([
|
||||
])
|
||||
export type WorkspaceCreateErrorClass = z.infer<typeof workspaceCreateErrorClassSchema>
|
||||
|
||||
export const workspaceSourceSchema = z.enum([
|
||||
'command_palette',
|
||||
'sidebar',
|
||||
'shortcut',
|
||||
'drag_drop',
|
||||
'onboarding',
|
||||
'unknown'
|
||||
])
|
||||
export type WorkspaceSource = z.infer<typeof workspaceSourceSchema>
|
||||
export const workspaceSourceSchema = z.enum(WORKSPACE_SOURCE_VALUES)
|
||||
export type { WorkspaceSource }
|
||||
|
||||
export const launchSourceSchema = z.enum([
|
||||
'command_palette',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pickTuiAgent } from './tui-agent-selection'
|
||||
|
||||
describe('pickTuiAgent', () => {
|
||||
it('uses an installed preferred agent', () => {
|
||||
expect(pickTuiAgent('codex', ['claude', 'codex'])).toBe('codex')
|
||||
})
|
||||
|
||||
it('falls back in desktop catalog order when the preference is absent or stale', () => {
|
||||
expect(pickTuiAgent(null, ['cursor', 'codex'])).toBe('codex')
|
||||
expect(pickTuiAgent('gemini', ['cursor', 'codex'])).toBe('codex')
|
||||
})
|
||||
|
||||
it('respects the explicit blank terminal preference', () => {
|
||||
expect(pickTuiAgent('blank', ['cursor', 'claude'])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TuiAgent } from './types'
|
||||
|
||||
// Keep this order in sync with the desktop agent catalog. It defines the
|
||||
// automatic fallback priority when the user has not chosen a default agent.
|
||||
export const TUI_AGENT_AUTO_PICK_ORDER = [
|
||||
'claude',
|
||||
'codex',
|
||||
'grok',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'pi',
|
||||
'gemini',
|
||||
'antigravity',
|
||||
'aider',
|
||||
'goose',
|
||||
'amp',
|
||||
'kilo',
|
||||
'kiro',
|
||||
'crush',
|
||||
'aug',
|
||||
'autohand',
|
||||
'cline',
|
||||
'codebuff',
|
||||
'continue',
|
||||
'cursor',
|
||||
'droid',
|
||||
'kimi',
|
||||
'mistral-vibe',
|
||||
'qwen-code',
|
||||
'rovo',
|
||||
'hermes',
|
||||
'openclaw'
|
||||
] as const satisfies readonly TuiAgent[]
|
||||
|
||||
export function pickTuiAgent(
|
||||
preferred: TuiAgent | 'blank' | null | undefined,
|
||||
detected: Iterable<TuiAgent>
|
||||
): TuiAgent | null {
|
||||
if (preferred === 'blank') {
|
||||
return null
|
||||
}
|
||||
const detectedSet = detected instanceof Set ? detected : new Set(detected)
|
||||
if (preferred && detectedSet.has(preferred)) {
|
||||
return preferred
|
||||
}
|
||||
for (const agent of TUI_AGENT_AUTO_PICK_ORDER) {
|
||||
if (detectedSet.has(agent)) {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from './tui-agent-startup'
|
||||
|
||||
describe('tui agent startup plans', () => {
|
||||
it('uses POSIX quoting when the target shell is Linux', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'claude',
|
||||
prompt: "fix Bob's branch",
|
||||
cmdOverrides: {},
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
expect(plan?.launchCommand).toBe("claude 'fix Bob'\\''s branch'")
|
||||
})
|
||||
|
||||
it('uses PowerShell quoting by default when the target shell is Windows', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'claude',
|
||||
prompt: 'fix Bob\'s "quoted" branch',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
expect(plan?.launchCommand).toBe("claude 'fix Bob''s \"quoted\" branch'")
|
||||
})
|
||||
|
||||
it('uses cmd escaping when requested explicitly', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'claude',
|
||||
prompt: 'fix "quoted" & %PATH%',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})
|
||||
|
||||
expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"')
|
||||
})
|
||||
|
||||
it('clears draft environment variables with the target shell syntax', () => {
|
||||
expect(
|
||||
buildAgentDraftLaunchPlan({
|
||||
agent: 'pi',
|
||||
draft: 'https://github.com/acme/repo/issues/42',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32'
|
||||
})?.launchCommand
|
||||
).toBe('pi; Remove-Item Env:ORCA_PI_PREFILL -ErrorAction SilentlyContinue')
|
||||
|
||||
expect(
|
||||
buildAgentDraftLaunchPlan({
|
||||
agent: 'pi',
|
||||
draft: 'https://github.com/acme/repo/issues/42',
|
||||
cmdOverrides: {},
|
||||
platform: 'win32',
|
||||
shell: 'cmd'
|
||||
})?.launchCommand
|
||||
).toBe('pi & set "ORCA_PI_PREFILL="')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { isShellProcess } from './agent-detection'
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import type { TuiAgent } from './types'
|
||||
|
||||
export type AgentStartupPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
expectedProcess: string
|
||||
followupPrompt: string | null
|
||||
draftPrompt?: string | null
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type AgentStartupShell = 'posix' | 'powershell' | 'cmd'
|
||||
|
||||
function resolveStartupShell(
|
||||
platform: NodeJS.Platform,
|
||||
shell?: AgentStartupShell
|
||||
): AgentStartupShell {
|
||||
return shell ?? (platform === 'win32' ? 'powershell' : 'posix')
|
||||
}
|
||||
|
||||
function quoteStartupArg(value: string, shell: AgentStartupShell): string {
|
||||
if (shell === 'powershell') {
|
||||
return `'${value.replace(/'/g, "''")}'`
|
||||
}
|
||||
if (shell === 'cmd') {
|
||||
return `"${value.replace(/([\^&|<>()%!"])/g, '^$1')}"`
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
function clearEnvCommand(name: string, shell: AgentStartupShell): string {
|
||||
if (shell === 'powershell') {
|
||||
return `Remove-Item Env:${name} -ErrorAction SilentlyContinue`
|
||||
}
|
||||
if (shell === 'cmd') {
|
||||
return `set "${name}="`
|
||||
}
|
||||
return `unset ${name}`
|
||||
}
|
||||
|
||||
function commandSeparator(shell: AgentStartupShell): string {
|
||||
return shell === 'cmd' ? ' & ' : '; '
|
||||
}
|
||||
|
||||
export function buildAgentStartupPlan(args: {
|
||||
agent: TuiAgent
|
||||
prompt: string
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
allowEmptyPromptLaunch?: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args
|
||||
const shell = resolveStartupShell(platform, args.shell)
|
||||
const trimmedPrompt = prompt.trim()
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
|
||||
|
||||
if (!trimmedPrompt) {
|
||||
if (!allowEmptyPromptLaunch) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
agent,
|
||||
launchCommand: baseCommand,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
const quotedPrompt = quoteStartupArg(trimmedPrompt, shell)
|
||||
|
||||
if (config.promptInjectionMode === 'argv') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-prompt') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} --prompt ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-prompt-interactive') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
if (config.promptInjectionMode === 'flag-interactive') {
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} -i ${quotedPrompt}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
launchCommand: baseCommand,
|
||||
expectedProcess: config.expectedProcess,
|
||||
followupPrompt: trimmedPrompt
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentDraftLaunchPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
expectedProcess: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export function buildAgentDraftLaunchPlan(args: {
|
||||
agent: TuiAgent
|
||||
draft: string
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
}): AgentDraftLaunchPlan | null {
|
||||
const { agent, draft, cmdOverrides, platform } = args
|
||||
const shell = resolveStartupShell(platform, args.shell)
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
|
||||
if (config.draftPromptFlag) {
|
||||
const quoted = quoteStartupArg(trimmed, shell)
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`,
|
||||
expectedProcess: config.expectedProcess
|
||||
}
|
||||
}
|
||||
if (config.draftPromptEnvVar) {
|
||||
const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell)
|
||||
return {
|
||||
agent,
|
||||
launchCommand: `${baseCommand}${commandSeparator(shell)}${clearVar}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
env: { [config.draftPromptEnvVar]: trimmed }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export { isShellProcess }
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-lines */
|
||||
import type { SshRemotePtyLease, SshTarget } from './ssh-types'
|
||||
import type { Automation, AutomationRun } from './automations-types'
|
||||
import type { WorkspaceSource } from './telemetry-events'
|
||||
import type { WorkspaceSource } from './workspace-source'
|
||||
import type { GitHubProjectSettings } from './github-project-types'
|
||||
import type {
|
||||
AgentStatusState,
|
||||
@@ -18,7 +18,7 @@ import type { KeybindingOverrides } from './keybindings'
|
||||
|
||||
// Re-exported for backward compat with renderer call sites that import
|
||||
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
|
||||
export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './telemetry-events'
|
||||
export type { WorkspaceSource as WorkspaceCreateTelemetrySource } from './workspace-source'
|
||||
export type { TaskProvider } from './task-providers'
|
||||
export type {
|
||||
GitBranchChangeStatus,
|
||||
@@ -1255,8 +1255,8 @@ export type CreateWorktreeArgs = {
|
||||
linkedIssue?: number
|
||||
linkedPR?: number
|
||||
linkedLinearIssue?: string
|
||||
linkedGitLabMR?: number
|
||||
linkedGitLabIssue?: number
|
||||
linkedGitLabMR?: number
|
||||
pushTarget?: GitPushTarget
|
||||
workspaceStatus?: WorkspaceStatus
|
||||
manualOrder?: number
|
||||
@@ -1997,6 +1997,7 @@ export type TaskResumeState = {
|
||||
githubMode?: 'items' | 'project'
|
||||
githubItemsPreset?: TaskViewPresetId | null
|
||||
githubItemsQuery?: string
|
||||
githubProjectHiddenFieldIdsByView?: Record<string, string[]>
|
||||
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
|
||||
linearQuery?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getLinkedWorkItemSuggestedName,
|
||||
resolveWorkspaceCreateName,
|
||||
slugifyForWorkspaceName
|
||||
} from './workspace-name'
|
||||
|
||||
describe('slugifyForWorkspaceName', () => {
|
||||
it('keeps workspace seed slugs short, ascii-safe, and git-ref-safe', () => {
|
||||
expect(slugifyForWorkspaceName('../../Fix mobile Tasks 🚀')).toBe('fix-mobile-tasks')
|
||||
expect(slugifyForWorkspaceName('feature/add issue drawer')).toBe('feature-add-issue-drawer')
|
||||
expect(slugifyForWorkspaceName('a'.repeat(80))).toBe('a'.repeat(48))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLinkedWorkItemSuggestedName', () => {
|
||||
it('removes duplicated issue and PR numbers from linked titles', () => {
|
||||
expect(getLinkedWorkItemSuggestedName({ title: 'Issue #123: Fix mobile Tasks' })).toBe(
|
||||
'fix-mobile-tasks'
|
||||
)
|
||||
expect(getLinkedWorkItemSuggestedName({ title: 'Add mobile drawer (#812)' })).toBe(
|
||||
'add-mobile-drawer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWorkspaceCreateName', () => {
|
||||
it('preserves explicit user-entered names for the host worktree sanitizer', () => {
|
||||
expect(
|
||||
resolveWorkspaceCreateName({
|
||||
draft: 'feature/something',
|
||||
fallback: 'issue-123'
|
||||
})
|
||||
).toBe('feature/something')
|
||||
expect(
|
||||
resolveWorkspaceCreateName({
|
||||
draft: '日本語 テスト',
|
||||
fallback: 'issue-123'
|
||||
})
|
||||
).toBe('日本語 テスト')
|
||||
})
|
||||
|
||||
it('uses the stable fallback when the draft is blank', () => {
|
||||
expect(resolveWorkspaceCreateName({ draft: ' ', fallback: 'pr-9' })).toBe('pr-9')
|
||||
expect(resolveWorkspaceCreateName({ draft: undefined, fallback: 'issue-4' })).toBe('issue-4')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
export function slugifyForWorkspaceName(input: string): string {
|
||||
return (
|
||||
input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\\/]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
// Why: git check-ref-format rejects any ref containing `..`, so previews
|
||||
// must match the main-process sanitizer before workspace creation.
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[.-]+|[.-]+$/g, '')
|
||||
.slice(0, 48)
|
||||
.replace(/[-._]+$/g, '')
|
||||
)
|
||||
}
|
||||
|
||||
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
|
||||
const withoutLeadingNumber = item.title
|
||||
.trim()
|
||||
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
|
||||
.replace(/^#\d+\s*[:-]\s*/, '')
|
||||
.replace(/\(#\d+\)/gi, '')
|
||||
.replace(/\b#\d+\b/g, '')
|
||||
.trim()
|
||||
const seed = withoutLeadingNumber || item.title.trim()
|
||||
return slugifyForWorkspaceName(seed)
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCreateName(args: {
|
||||
draft: string | undefined
|
||||
fallback: string
|
||||
}): string {
|
||||
return args.draft?.trim() || args.fallback
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const WORKSPACE_SOURCE_VALUES = [
|
||||
'command_palette',
|
||||
'sidebar',
|
||||
'shortcut',
|
||||
'drag_drop',
|
||||
'onboarding',
|
||||
'unknown'
|
||||
] as const
|
||||
|
||||
export type WorkspaceSource = (typeof WORKSPACE_SOURCE_VALUES)[number]
|
||||
Reference in New Issue
Block a user