Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions e2e/macros.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,38 @@ test('saves select field options after raw textarea editing', async ({ appHarnes
])
})

test('saves parameterized wait steps in seconds', async ({ appHarness, mainWindow }) => {
const macrosWindow = await appHarness.openMacrosWindow(mainWindow)

await macrosWindow.getByRole('button', { name: 'New Macro' }).click()
await macrosWindow.getByPlaceholder('Macro Title').fill('Parameterized Wait Macro')
await macrosWindow
.locator('select')
.filter({ has: macrosWindow.locator('option', { hasText: '+ Add Step...' }) })
.selectOption('wait_time')

await macrosWindow.getByPlaceholder('3 or {Delay}').fill('{Delay}')
await expect(macrosWindow.getByText('seconds')).toBeVisible()

await macrosWindow.getByRole('button', { name: 'Sync from Steps' }).click()
await expect(macrosWindow.getByText('Detected:')).toBeVisible()
await expect(macrosWindow.getByText('Delay')).toBeVisible()

await macrosWindow.getByRole('button', { name: 'Save Changes' }).click()

const savedMacro = await macrosWindow.evaluate(async () => {
const macros = await window.terminay.getMacros()
return macros.find((macro) => macro.title === 'Parameterized Wait Macro') ?? null
})

expect(savedMacro?.steps[0]).toMatchObject({
type: 'wait_time',
durationSeconds: '{Delay}',
})
expect('durationMs' in (savedMacro?.steps[0] ?? {})).toBe(false)
expect(savedMacro?.fields.some((field) => field.name === 'Delay')).toBe(true)
})

test('clears finished macro runs from the queue', async ({ appHarness, mainWindow }) => {
await appHarness.openMacroLauncher(mainWindow)
await mainWindow.getByRole('button', { name: 'Create a pull request' }).click()
Expand Down
4 changes: 3 additions & 1 deletion specs/MACROS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Type steps support Eta templates. The renderer is configured for plain terminal

Template rendering is centralized in `src/macroSettings.ts` through `renderMacroTemplate(...)`. Runtime execution in `src/App.tsx` uses that renderer for each `type` step before writing to the terminal.

Wait steps store user-facing durations in seconds through `durationSeconds`. Runtime execution renders the duration and converts it to milliseconds only when scheduling the delay or inactivity timer. Older saved `durationMs` values are migrated to seconds during normalization.

## Fields

Macro fields are stored on the macro definition and keyed by `field.name`. Supported field types are:
Expand All @@ -35,7 +37,7 @@ When the user runs a macro, `src/App.tsx` opens a parameter modal if the macro h
- renders a live preview with `tryRenderMacroTemplate(...)`
- executes the macro only after the user submits the form

`Sync from Steps` detects both legacy `{{Field}}` placeholders and common Eta identifiers inside template tags. This detection is a convenience for creating fields; explicit fields are preserved on save even when they are not currently detected in a step.
`Sync from Steps` detects both legacy `{{Field}}` placeholders and common Eta identifiers inside template tags. For wait durations, it also detects single-brace fields such as `{Delay}`. This detection is a convenience for creating fields; explicit fields are preserved on save even when they are not currently detected in a step.

## Select Fields

Expand Down
26 changes: 21 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ import {
getCommandShortcut,
getCommandShortcutLabel,
} from './keyboardShortcuts';
import { renderMacroTemplate, tryRenderMacroTemplate } from './macroSettings';
import {
renderMacroDurationMs,
renderMacroTemplate,
tryRenderMacroTemplate,
} from './macroSettings';
import { getPathRelativeToRoot } from './pathUtils';
import { computeDropIndex } from './projectTabDrag';
import {
Expand Down Expand Up @@ -1495,6 +1499,15 @@ function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}

function formatMacroDurationSeconds(durationSeconds: string): string {
const numericDurationSeconds = Number(durationSeconds);
if (durationSeconds.trim() && Number.isFinite(numericDurationSeconds)) {
return String(Math.max(0, Math.round(numericDurationSeconds * 10) / 10));
}

return durationSeconds.trim() || '0';
}

function describeMacroStep(step: MacroDefinition['steps'][number]): string {
switch (step.type) {
case 'type':
Expand All @@ -1507,9 +1520,9 @@ function describeMacroStep(step: MacroDefinition['steps'][number]): string {
case 'secret':
return 'Insert secret';
case 'wait_time':
return `Wait ${Math.max(0, Math.round(step.durationMs / 100) / 10)}s`;
return `Wait ${formatMacroDurationSeconds(step.durationSeconds)}s`;
case 'wait_inactivity':
return `Wait for inactivity ${Math.max(0, Math.round(step.durationMs / 100) / 10)}s`;
return `Wait for inactivity ${formatMacroDurationSeconds(step.durationSeconds)}s`;
case 'select_line':
return 'Select current line';
case 'paste':
Expand Down Expand Up @@ -3219,12 +3232,15 @@ const ProjectWorkspace = forwardRef<
}
break;
case 'wait_time':
await waitForDelay(step.durationMs, abortController.signal);
await waitForDelay(
renderMacroDurationMs(step.durationSeconds, values),
abortController.signal,
);
break;
case 'wait_inactivity':
await waitForSessionInactivity(
sessionId,
step.durationMs,
renderMacroDurationMs(step.durationSeconds, values),
abortController.signal,
);
break;
Expand Down
15 changes: 8 additions & 7 deletions src/components/MacrosWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ function createEmptyStep(type: MacroStep['type']): MacroStep {
case 'secret':
return { id, type, secretId: '' }
case 'wait_time':
return { id, type, durationMs: 1000 }
return { id, type, durationSeconds: '1' }
case 'wait_inactivity':
return { id, type, durationMs: 3000 }
return { id, type, durationSeconds: '3' }
case 'select_line':
case 'paste':
return { id, type }
Expand Down Expand Up @@ -446,12 +446,13 @@ function StepItem({
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
<input
className="settings-input-text"
type="number"
value={step.durationMs}
style={{ width: 100 }}
onChange={(e) => onUpdateStep(s => ({ ...s, durationMs: parseInt(e.target.value, 10) || 0 } as MacroStep))}
type="text"
value={step.durationSeconds}
style={{ width: 160 }}
onChange={(e) => onUpdateStep(s => ({ ...s, durationSeconds: e.target.value } as MacroStep))}
placeholder="3 or {Delay}"
/>
<span style={{ fontSize: 12, color: 'var(--settings-text-muted)' }}>ms</span>
<span style={{ fontSize: 12, color: 'var(--settings-text-muted)' }}>seconds</span>
</div>
)}

Expand Down
146 changes: 127 additions & 19 deletions src/macroSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from './types/macros'

const placeholderPattern = /{{\s*([^{}]+?)\s*}}/g
const singleBracePlaceholderPattern = /{\s*([^{}]+?)\s*}/g
const etaTagPattern = /<%[-_]?\s*[~=]?([\s\S]*?)\s*[-_]?%>/g
const etaRenderer = new Eta({ autoEscape: false, useWith: true })
const jsKeywords = new Set([
Expand Down Expand Up @@ -79,7 +80,7 @@ export const defaultMacros: MacroDefinition[] = [
steps: [
{ id: 'step-1', type: 'type', content: 'sudo apt-get update' },
{ id: 'step-2', type: 'key', key: 'Enter' },
{ id: 'step-3', type: 'wait_inactivity', durationMs: 3000 },
{ id: 'step-3', type: 'wait_inactivity', durationSeconds: '3' },
{ id: 'step-4', type: 'type', content: 'sudo apt-get upgrade -y' },
{ id: 'step-5', type: 'key', key: 'Enter' },
],
Expand Down Expand Up @@ -149,6 +150,31 @@ function normalizeNumber(value: unknown, fallback = 0): number {
return Number.isFinite(value) ? Number(value) : fallback
}

function formatSeconds(value: number): string {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)))
}

function normalizeDurationSeconds(record: Record<string, unknown>, fallbackSeconds: string): string {
if (typeof record.durationSeconds === 'string') {
return record.durationSeconds
}

if (typeof record.durationSeconds === 'number' && Number.isFinite(record.durationSeconds)) {
return formatSeconds(record.durationSeconds)
}

if (typeof record.durationMs === 'number' && Number.isFinite(record.durationMs)) {
return formatSeconds(record.durationMs / 1000)
}

if (typeof record.durationMs === 'string') {
const parsedDurationMs = Number(record.durationMs)
return Number.isFinite(parsedDurationMs) ? formatSeconds(parsedDurationMs / 1000) : record.durationMs
}

return fallbackSeconds
}

function normalizeFieldType(value: unknown): MacroFieldType {
switch (value) {
case 'textarea':
Expand Down Expand Up @@ -253,9 +279,9 @@ function normalizeStep(input: unknown, index: number): MacroStep {
case 'secret':
return { id, type, secretId: normalizeString(record.secretId) }
case 'wait_time':
return { id, type, durationMs: normalizeNumber(record.durationMs, 1000) }
return { id, type, durationSeconds: normalizeDurationSeconds(record, '1') }
case 'wait_inactivity':
return { id, type, durationMs: normalizeNumber(record.durationMs, 3000) }
return { id, type, durationSeconds: normalizeDurationSeconds(record, '3') }
case 'select_line':
case 'paste':
return { id, type }
Expand All @@ -280,6 +306,31 @@ function extractTemplatePlaceholders(template: string): string[] {
return placeholders
}

function extractSingleBracePlaceholders(template: string): string[] {
const seen = new Set<string>()
const placeholders: string[] = []

for (const match of template.matchAll(singleBracePlaceholderPattern)) {
if (!isSingleBracePlaceholderMatch(template, match[0], match.index ?? 0)) {
continue
}

const placeholder = match[1]?.trim()
if (!placeholder || seen.has(placeholder)) {
continue
}

seen.add(placeholder)
placeholders.push(placeholder)
}

return placeholders
}

function isSingleBracePlaceholderMatch(template: string, match: string, index: number): boolean {
return template[index - 1] !== '{' && template[index + match.length] !== '}'
}

function stripJavaScriptLiterals(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, ' ')
Expand Down Expand Up @@ -352,18 +403,42 @@ function extractMacroPlaceholders(template: string): string[] {
return placeholders
}

function extractDurationPlaceholders(template: string): string[] {
const seen = new Set<string>()
const placeholders: string[] = []

for (const placeholder of [...extractMacroPlaceholders(template), ...extractSingleBracePlaceholders(template)]) {
if (!seen.has(placeholder)) {
seen.add(placeholder)
placeholders.push(placeholder)
}
}

return placeholders
}

function extractStepPlaceholders(step: MacroStep): string[] {
switch (step.type) {
case 'type':
return extractMacroPlaceholders(step.content)
case 'wait_time':
case 'wait_inactivity':
return extractDurationPlaceholders(step.durationSeconds)
default:
return []
}
}

export function extractAllMacroPlaceholders(macro: MacroDefinition): string[] {
const seen = new Set<string>()
const placeholders: string[] = []

for (const step of macro.steps) {
if (step.type === 'type') {
const stepPlaceholders = extractMacroPlaceholders(step.content)
for (const p of stepPlaceholders) {
if (!seen.has(p)) {
seen.add(p)
placeholders.push(p)
}
const stepPlaceholders = extractStepPlaceholders(step)
for (const p of stepPlaceholders) {
if (!seen.has(p)) {
seen.add(p)
placeholders.push(p)
}
}
}
Expand All @@ -390,6 +465,41 @@ export function renderMacroTemplate(template: string, values: Record<string, Mac
})
}

function renderSingleBraceMacroTemplate(template: string, values: Record<string, MacroFieldValue>): string {
return template.replace(singleBracePlaceholderPattern, (match: string, token: string, index: number) => {
if (!isSingleBracePlaceholderMatch(template, match, index)) {
return match
}

const key = token.trim()
const value = values[key]

if (typeof value === 'boolean') {
return value ? 'true' : 'false'
}

if (typeof value === 'number') {
return `${value}`
}

return typeof value === 'string' ? value : ''
})
}

export function renderMacroDurationMs(
durationSeconds: string,
values: Record<string, MacroFieldValue>,
): number {
const renderedSeconds = renderSingleBraceMacroTemplate(renderMacroTemplate(durationSeconds, values), values).trim()
const seconds = Number(renderedSeconds)

if (!renderedSeconds || !Number.isFinite(seconds) || seconds < 0) {
throw new Error(`Wait duration "${renderedSeconds || durationSeconds}" must resolve to a non-negative number of seconds.`)
}

return Math.round(seconds * 1000)
}

export function tryRenderMacroTemplate(template: string, values: Record<string, MacroFieldValue>): string {
try {
return renderMacroTemplate(template, values)
Expand All @@ -404,13 +514,11 @@ export function mergeFieldsWithSteps(steps: MacroStep[], fields: MacroFieldDefin
const seen = new Set<string>()

for (const step of steps) {
if (step.type === 'type') {
const stepPlaceholders = extractMacroPlaceholders(step.content)
for (const p of stepPlaceholders) {
if (!seen.has(p)) {
seen.add(p)
placeholders.push(p)
}
const stepPlaceholders = extractStepPlaceholders(step)
for (const p of stepPlaceholders) {
if (!seen.has(p)) {
seen.add(p)
placeholders.push(p)
}
}
}
Expand Down Expand Up @@ -454,9 +562,9 @@ function deriveLegacyTemplate(steps: MacroStep[]): Pick<MacroDefinition, 'submit
case 'secret':
return `[secret:${step.secretId}]`
case 'wait_time':
return `[wait:${step.durationMs}]`
return `[wait:${step.durationSeconds}s]`
case 'wait_inactivity':
return `[wait-inactive:${step.durationMs}]`
return `[wait-inactive:${step.durationSeconds}s]`
case 'select_line':
return '[select-line]'
case 'paste':
Expand Down
8 changes: 4 additions & 4 deletions src/types/macros.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,17 @@ export type MacroStepType =
| 'type' // Types a string (supports {{Field}} variables)
| 'key' // Presses a specific key
| 'secret' // Pastes a stored secret
| 'wait_time' // Pauses execution for X milliseconds
| 'wait_inactivity' // Pauses execution until terminal stops outputting data for X ms
| 'wait_time' // Pauses execution for X seconds
| 'wait_inactivity' // Pauses execution until terminal stops outputting data for X seconds
| 'select_line' // Sends an ANSI sequence to select the current line
| 'paste' // Pastes current clipboard contents

export type MacroStep =
| { id: string; type: 'type'; content: string }
| { id: string; type: 'key'; key: string }
| { id: string; type: 'secret'; secretId: string }
| { id: string; type: 'wait_time'; durationMs: number }
| { id: string; type: 'wait_inactivity'; durationMs: number }
| { id: string; type: 'wait_time'; durationSeconds: string }
| { id: string; type: 'wait_inactivity'; durationSeconds: string }
| { id: string; type: 'select_line' }
| { id: string; type: 'paste' }

Expand Down
Loading