Skip to content

Conversation

@Julusian
Copy link
Member

@Julusian Julusian commented Sep 4, 2025

This is a WIP, and is in the process of being applied to all the internal code, please raise any objections/concerns around the changes asap, it would be good to know if anything needs reworking before every internal action is updated and tested for the changes.

This starts on implementing #2345, starting with considering just the internal actions and feedbacks.

The aim is to figure out how this will work technically, while it is easier to do on just the code in this repo before attempting to roll it out to modules.

The ui looks pretty similar to what was done for the graphics overhaul. Fields which support this gain a new button to the right of the field, which toggles it between the usual mode and expressions:
image

Internally, the options object now contains something of the form: { isExpression: false, value: 123 }, with the execution auto-parsing, and the ui updating this. It is opt-in per entity for now, to avoid breaking everything while this is WIP, that may or may not remain once complete.
This data change requires upgrade scripts per-action. While it probably could be mostly automated, many actions need input fields to also be combined (they already have a checkbox to toggle between value and expression mode, and separate fields for each mode), so doing it all manually makes handling this saner/safer

Summary by CodeRabbit

  • New Features

    • Expression-mode toggle for many option fields (enter expressions or plain values).
    • New built-in local variable "this:location" for button location references.
    • Option defaults can store expression or value wrappers to enable expression-aware inputs.
  • Refactor

    • Modernized option parsing and execution plumbing to consistently support expression-enabled fields and improve UI behavior.
  • Tests / Docs

    • Added comprehensive location parsing tests and docs entry for this:location.

✏️ Tip: You can customize this high-level summary in your review settings.

@Julusian Julusian added this to the v4.2 milestone Sep 4, 2025
@github-project-automation github-project-automation bot moved this to In Progress in Companion Plan Sep 4, 2025
@Julusian Julusian changed the title feat: support expressions for all internal action fields #2345 feat: support expressions for most internal action fields #2345 Sep 6, 2025
@peternewman
Copy link
Contributor

The ui looks pretty similar to what was done for the graphics overhaul. Fields which support this gain a new button to the right of the field, which toggles it between the usual mode and expressions: image

Does that mean all those options actually support it, or only the last one? What's the use case for entering a surface/group name by expression (as opposed to it's ID)?

FWIW I think to actually solve #3235 it needs some new actions.

@Julusian
Copy link
Member Author

Julusian commented Sep 9, 2025

Does that mean all those options actually support it, or only the last one? What's the use case for entering a surface/group name by expression (as opposed to it's ID)?

in the screenshot, all of them do with the last one currently being set to expression mode.
I havent updated every field of every action for this, as some do seem pointless to do.

FWIW I think to actually solve #3235 it needs some new actions.

Or maybe it just needs a variable listing all the surfaces? Its already possible to create loops #3619

What's the use case for entering a surface/group name by expression (as opposed to it's ID)?

I dont have one, but Im sure someone will. we already support taking it from a variable (using a few fields and isVisible to do so), so changing it to expressions is largely to simplify and unify it.

@Julusian
Copy link
Member Author

@dnmeid any thoughts on the approach taken here? (how the values are stored, and how the ui works)
With 4.1 now released, I would like to wrap this up and get it into 4.2 soon; then to think about doing the same for modules

@coderabbitai
Copy link

coderabbitai bot commented Jan 18, 2026

📝 Walkthrough

Walkthrough

Refactors internal execution to use options-based action/feedback types and adds expression-aware option handling, parsers, and utilities; removes InternalModuleUtils from constructors and updates many module signatures and client entity shapes to include expression metadata.

Changes

Cohort / File(s) Summary
Shared model updates
shared-lib/lib/Model/EntityDefinitionModel.ts, shared-lib/lib/Model/Options.ts
Added optionsSupportExpressions to ClientEntityDefinition; added expressionDescription, disableAutoExpression, ExpressionOrValue<T>, ExpressionableOptionsObject, and isExpressionOrValue() to options model.
Entity emission & defaults
companion/lib/Instance/Connection/ChildHandlerLegacy.ts, companion/lib/Instance/Connection/Thread/HostContext.ts, companion/lib/Instance/Definitions.ts
Emit optionsSupportExpressions: false on legacy action/feedback definitions; when optionsSupportExpressions true, wrap option defaults as ExpressionOrValue in createEntityItem.
Config field shapes (instance/surface/connection)
companion/lib/Instance/Surface/Thread/ConfigFields.ts, companion/lib/Instance/Connection/ConfigFieldsLegacy.ts, companion/lib/Instance/Connection/Thread/ConfigFields.ts
Expanded translateCommonFields return to include expressionDescription and disableAutoExpression (set to undefined / true for legacy/1.x).
Expression parsing & utilities
companion/lib/Internal/Util.ts, companion/lib/Variables/VariablesAndExpressionParser.ts
Added ParseLocationString, CHOICES_LOCATION, converters (convertOldLocationToExpressionOrValue, convertSimplePropertyToExpressionValue, convertOldSplitOptionToExpression) and parser APIs parseEntityOptions() / parseEntityOption() to support expression/variable evaluation and referenced-variable collection.
Core internal refactor: types & signatures
companion/lib/Internal/Types.ts, companion/lib/Internal/{Controller,BuildingBlocks,Controls,ActionRecorder,Instance,Surface,System,Variables,Triggers,CustomVariables,Page,Time,Controls}.ts
Introduced ActionForInternalExecution / FeedbackForInternalExecution, updated many executeAction/executeFeedback signatures, removed InternalModuleUtils constructor params, added actionUpgrade() hooks, migrated code to use action.options / feedback.options and parser helpers.
Definitions & visitors
companion/lib/Instance/Definitions.ts, companion/lib/Resources/Visitors/{EntityInstanceVisitor,ReferencesCollector}.ts
Wrap/unwrap option defaults and visitor logic to handle ExpressionOrValue payloads; added unwrapping helpers in references collector.
Controls & preview adaptations
companion/lib/Internal/Controls.ts, companion/lib/Preview/Graphics.ts, companion/lib/Controls/Controller.ts
Reworked location parsing with ParseLocationString and stringify helpers; preview uses resolved locations and referencedVariableIds; refined NewFeedbackValue typing to include VariableValue.
Variables & custom/local variable changes
companion/lib/Internal/Variables.ts, companion/lib/Variables/{CustomVariable,Values}.ts
Parser-driven variable evaluation, actionUpgrade support, constructor signature removals, createVariable() now accepts `VariableValue
UI: expression support and components
webui/src/Components/FieldOrExpression.tsx, webui/src/Controls/{OptionsInputField.tsx,EntityCommonCells.tsx,LocalVariablesStore.tsx}, webui/src/Triggers/EventEditor.tsx
Added FieldOrExpression component; OptionsInputField gains fieldSupportsExpression and wraps inputs for expression mode; local variables list adds this:location; EventEditor passes fieldSupportsExpression={false} for events.
UI styling
webui/src/scss/{_button-edit.scss,_common.scss}
Added .field-with-expression layout and minor spacing/icon tweaks.
Legacy compatibility & tests/docs
companion/lib/Instance/Connection/ChildHandlerLegacy.ts, companion/test/Internal/ParseLocationString.test.ts, docs/user-guide/3_config/variables.md
Legacy handlers emit expression flags as not supported; added comprehensive ParseLocationString tests; documented this:location built-in variable.

Poem

✨ Old utils fall away like leaves,
Options learn to whisper code or dreams,
Parsers map places, strings to keys,
UI toggles logic with a gleam—
Small types, big changes; neat new seams 🌿

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding expression support for internal action fields, with reference to the tracking issue #2345.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
webui/src/Controls/OptionsInputField.tsx (1)

14-80: Handle legacy unwrapped values when expression support is on.

rawValue is cast to ExpressionOrValue without a runtime guard. If old data still stores primitives, value becomes undefined and the control renders blank. It’d be safer to normalize with isExpressionOrValue and fall back to { isExpression:false, value: rawValue }.

🔧 Suggested fix
-import type { ExpressionOrValue, SomeCompanionInputField } from '@companion-app/shared/Model/Options.js'
+import { isExpressionOrValue, type ExpressionOrValue, type SomeCompanionInputField } from '@companion-app/shared/Model/Options.js'
@@
-	const value = fieldsSupportExpressions ? (rawValue as ExpressionOrValue<any>)?.value : rawValue
+	const normalizedExpressionValue = fieldsSupportExpressions
+		? isExpressionOrValue(rawValue)
+			? rawValue
+			: { isExpression: false, value: rawValue }
+		: undefined
+
+	const value = fieldsSupportExpressions ? normalizedExpressionValue?.value : rawValue
@@
-		const rawExpressionValue = (rawValue as ExpressionOrValue<any>) || { isExpression: false, value: undefined }
+		const rawExpressionValue = normalizedExpressionValue || { isExpression: false, value: undefined }

Also applies to: 213-219

companion/lib/Internal/Surface.ts (1)

126-134: Guard against nullish/empty surfaceId results from expressions.

String(undefined) becomes "undefined", which can lead to attempts to control a phantom surface instead of short‑circuiting.

✅ Suggested fix
-	let surfaceId: string | undefined = String(options.surfaceId).trim()
+	if (options.surfaceId === undefined || options.surfaceId === null) return undefined
+	let surfaceId = String(options.surfaceId).trim()
+	if (!surfaceId) return undefined
🧹 Nitpick comments (3)
webui/src/scss/_common.scss (1)

352-369: Small layout bug: justify-self won’t work in flex here.

Since .field-with-expression is display: flex, justify-self on .expression-toggle-button is ignored. Use margin-left: auto or align-self instead so the toggle actually aligns to the end.

💡 Suggested tweak
 .field-with-expression {
 	display: flex;
 	gap: 0.5rem;
 
 	.expression-field {
 		flex-grow: 1;
 	}
 
 	.expression-toggle-button {
-		justify-self: flex-end;
+		margin-left: auto;
 
 		.btn {
 			height: 100%;
 			width: 3em;
 		}
 	}
 }
docs/user-guide/3_config/variables.md (1)

116-117: Nice addition! Consider clarifying the format.

Thanks for documenting the new this:location variable! The entry looks great and follows the existing pattern perfectly.

One small suggestion: it might be helpful to briefly mention what format the location value takes (e.g., is it something like "1/2/3", an object, or another format?). This would help users understand what they'll get when they use it in their expressions or actions.

companion/lib/Internal/Controls.ts (1)

746-764: Good catch on recursively upgrading child actions!

The recursive this.actionUpgrade(newChildAction, controlId) call (line 748) ensures newly created child actions get fully upgraded.

The TODO on line 763 about recursive feedback upgrades is worth tracking. Would you like me to open an issue for this, or is it something you're planning to address in this PR?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
companion/lib/Internal/Controls.ts (1)

747-826: Potential gap: bank_current_step action may keep legacy step values unwrapped.
When step already exists (old literal), the upgrade path skips wrapping it into { isExpression, value }. That can leave mixed option shapes and confuse expression toggling or parsing.

✅ Suggested fix
} else if (action.definitionId === 'bank_current_step') {
	changed = convertOldLocationToExpressionOrValue(action.options) || changed

	if (action.options.step === undefined) {
		convertOldSplitOptionToExpression(
			action.options,
			{
				useVariables: 'step_from_expression',
				variable: 'step_expression',
				simple: 'step',
				result: 'step',
			},
			true
		)
		changed = true
	}
+	changed = convertSimplePropertyToExpressionValue(action.options, 'step') || changed
}

Also noticed the TODO about recursively upgrading the new feedback—if you still want that, I’m happy to help sketch a follow-up.

♻️ Duplicate comments (2)
webui/src/Components/FieldOrExpression.tsx (1)

42-50: Consider coercing to string when switching to expression mode.

Just a friendly heads-up - when toggling to expression mode from a non-string field (like a number or color picker), value.value might not be a string. The ExpressionOrValue<T> type expects value: string when isExpression: true.

While stringifyVariableValue handles the conversion at render time (line 63), the stored state could be inconsistent with the type contract. You might want to coerce when switching modes:

💡 Suggested approach
 const setIsExpression = useCallback(
 	(isExpression: boolean) => {
 		setValue({
 			isExpression,
-			value: value.value,
+			value: isExpression ? (stringifyVariableValue(value.value) ?? '') : value.value,
 		})
 	},
 	[setValue, value]
 )
companion/lib/Internal/Variables.ts (1)

348-367: Pipeline blocker: nocommit comment needs to be resolved.

Hey! The CI is failing because of the nocommit comment on line 350. It looks like there's also some commented-out code below that could be cleaned up.

I understand this is WIP, but to get CI green you'll need to either:

  1. Address the TODO and remove the nocommit marker
  2. Convert it to a regular TODO comment if it's intentional WIP that can be merged

The commented-out block (lines 354-366) also looks like remnants from the old implementation - feel free to remove that when you clean this up!

🔧 Suggested cleanup
-		// nocommit - this needs to be adjusted to handle triggers/expression variables
+		// TODO - this needs to be adjusted to handle triggers/expression variables
 		const location = ParseLocationString(String(action.options.location), extras.location)
 		const theControlId = location ? this.#pageStore.getControlIdAt(location) : null
-
-		// let theControlId: string | null = null
-		// if (action.rawOptions.location_target === 'this') {
-		// 	// This could be any type of control (button, trigger, etc)
-		// 	theControlId = extras.controlId
-		// } else {
-		// 	// Parse the location of a button
-		// 	const result = this.#internalUtils.parseInternalControlReferenceForActionOrFeedback(
-		// 		extras,
-		// 		action.rawOptions,
-		// 		true
-		// 	)
-		// 	theControlId = result.location ? this.#pageStore.getControlIdAt(result.location) : null
-		// }

@Julusian Julusian force-pushed the feat/expressions-internal-actions branch from 4ba9ea5 to c92aaae Compare January 18, 2026 22:39
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
companion/lib/Variables/CustomVariable.ts (1)

205-216: Align createVariable validation with the widened VariableValue signature.

Right now the signature accepts VariableValue | undefined, but runtime rejects anything non-string. That mismatch will surprise callers and makes the signature misleading. Either revert the signature back to string or broaden the runtime guard (and TRPC input) to the allowed VariableValue union.

💡 Possible adjustment (align guard with allowed VariableValue types)
-	if (typeof defaultVal !== 'string') {
-		return 'Bad default value'
-	}
+	if (
+		defaultVal !== undefined &&
+		typeof defaultVal !== 'string' &&
+		typeof defaultVal !== 'number' &&
+		typeof defaultVal !== 'boolean'
+	) {
+		return 'Bad default value'
+	}
♻️ Duplicate comments (5)
companion/lib/Resources/Visitors/ReferencesCollector.ts (1)

101-106: Skip expression-mode values when collecting references.

#getAndUnwrapPropertyValue returns .value even when isExpression is true, so expression strings get treated as literal IDs/variables. That can silently add incorrect references.

🔧 Suggested fix
 	`#getAndUnwrapPropertyValue`(obj: Record<string, any>, propName: string): any {
 		const value = obj[propName]
-		if (isExpressionOrValue(value)) {
-			return value.value
-		}
+		if (isExpressionOrValue(value)) {
+			return value.isExpression ? undefined : value.value
+		}
 		return value
 	}
webui/src/Components/FieldOrExpression.tsx (2)

42-47: Coerce to string when switching into expression mode.

When toggling to expression mode, value.value can still be a number/boolean, which may break later parsing or validation. Coerce to string at the toggle boundary.

🔧 Suggested fix
 	const setIsExpression = useCallback(
 		(isExpression: boolean) => {
 			setValue({
 				isExpression,
-				value: value.value,
+				value: isExpression ? String(value.value ?? '') : value.value,
 			})
 		},
 		[setValue, value]
 	)

75-75: Tiny UI polish: remove the trailing space in the title.

✏️ Quick fix
-					title={value.isExpression ? 'Expression mode ' : 'Value mode'}
+					title={value.isExpression ? 'Expression mode' : 'Value mode'}
companion/lib/Internal/System.ts (1)

329-334: Upgrade should convert message, not custom_log.

This still targets the wrong option id, so legacy custom_log actions won’t migrate to the expression/value shape.

💡 Suggested fix
-		if (action.definitionId === 'custom_log') {
-			changed = convertSimplePropertyToExpressionValue(action.options, 'custom_log') || changed
+		if (action.definitionId === 'custom_log') {
+			changed = convertSimplePropertyToExpressionValue(action.options, 'message') || changed
 		} else if (action.definitionId === 'exec') {
companion/lib/Internal/Variables.ts (1)

350-366: Heads up: nocommit comment will fail CI.

The ESLint no-warning-comments rule is catching the nocommit comment on line 350. The commented-out code block (lines 354-366) also looks like remnants from the old implementation that should be cleaned up.

I see this is still WIP for handling triggers/expression variables. When you're ready to finalize, you could either:

  1. Address the TODO and remove the comment, or
  2. Convert it to a regular TODO (without "nocommit") if it's intentional WIP that shouldn't block the PR

No rush - just flagging so it doesn't surprise you in CI! 😊

🧹 Nitpick comments (4)
webui/src/scss/_common.scss (1)

353-369: Nice addition for the expression toggle UI! 👍

The layout structure looks good and will work as intended. One small note: on line 362, justify-self: flex-end doesn't actually do anything in a Flexbox context—it's a Grid property. The button ends up on the right side anyway because .expression-field has flex-grow: 1, so everything still works fine!

If you'd like to tidy this up, you could simply remove that line since it's not contributing to the layout. But totally optional—no functional impact either way.

💅 Optional cleanup
 	.expression-toggle-button {
-		justify-self: flex-end;
-
 		.btn {
 			height: 100%;
 			width: 3em;
 		}
 	}
docs/user-guide/3_config/variables.md (1)

116-117: Nice addition! Consider clarifying the location format.

The documentation follows the established pattern perfectly and is a welcome addition to the builtin local variables section.

Since "location" is likely a composite value (unlike the simple numeric values for page, column, and row), it might be helpful to clarify what format this variable contains. For example, does it return something like "1/2/3" or "page:1,row:2,col:3" or another format? Adding a brief note about the format or a small example would help users understand what to expect when they use this variable.

📝 Optional enhancement suggestion

Consider adding a brief format description, for example:

 - Location of the button
-  - Variable: `this:location`
+  - Variable: `this:location` (format: `page/row/column`)

Or add an example if that would be clearer for users.

companion/lib/Variables/VariablesAndExpressionParser.ts (1)

169-171: Type assertion to as any noted.

The as any cast at line 170 is understandable given the flexible ExpressionOrValue<JsonValue> typing, but worth keeping an eye on. If the type system evolves, this could be tightened up later.

companion/lib/Internal/Surface.ts (1)

349-360: Duplicate condition check in feedbackUpgrade.

Lines 352-354 and 355-357 both check feedback.definitionId === 'surface_on_page'. These could be combined into a single block:

♻️ Suggested consolidation
 	feedbackUpgrade(feedback: FeedbackEntityModel, _controlId: string): FeedbackEntityModel | void {
 		let changed = false
 
 		if (feedback.definitionId === 'surface_on_page') {
 			changed = convertSimplePropertyToExpressionValue(feedback.options, 'surfaceId', 'controller', 'self') || changed
-		}
-		if (feedback.definitionId === 'surface_on_page') {
 			changed = convertSimplePropertyToExpressionValue(feedback.options, 'page') || changed
 		}
 
 		if (changed) return feedback
 	}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
companion/lib/Internal/Surface.ts (1)

126-135: Guard against String(undefined) in #fetchSurfaceId.

If surfaceId is missing, String(undefined) becomes "undefined" and bypasses the !surfaceId checks later. Consider a nullish guard before coercion (Line 130).

💡 Suggested fix
-		let surfaceId: string | undefined = String(options.surfaceId).trim()
+		const rawSurfaceId = options.surfaceId
+		if (rawSurfaceId === undefined || rawSurfaceId === null) return undefined
+		let surfaceId: string | undefined = String(rawSurfaceId).trim()
+		if (!surfaceId) return undefined
♻️ Duplicate comments (8)
webui/src/Controls/OptionsInputField.tsx (1)

212-220: Potential null deref on localVariablesStore (duplicate).
Line 217 still uses localVariablesStore! even though the prop allows null.

companion/lib/Internal/BuildingBlocks.ts (1)

287-299: Lint still warns about execution_mode stringification (Line 298).

Even if the value should be a string, the lint failure will keep biting; a defensive String() avoids it.

💡 Suggested fix
-					this.#logger.error(`Unknown execution mode: ${action.options.execution_mode}`)
+					this.#logger.error(`Unknown execution mode: ${String(action.options.execution_mode)}`)
companion/lib/Internal/CustomVariables.ts (1)

272-293: Lint still warns about String(action.options.name) (Line 274+).

This is the same [object Object] concern flagged earlier; consider narrowing to a string once and reusing it.

💡 Suggested fix
 		if (action.definitionId === 'custom_variable_set_value') {
-			if (this.#variableController.custom.hasCustomVariable(String(action.options.name))) {
-				this.#variableController.custom.setValue(
-					String(action.options.name),
-					action.options.value as CompanionVariableValue
-				)
-			} else if (action.options.create) {
-				this.#variableController.custom.createVariable(
-					String(action.options.name),
-					action.options.value as CompanionVariableValue
-				)
-			} else {
-				this.#logger.warn(`Custom variable "${action.options.name}" not found`)
-			}
+			const name = action.options.name as string
+			if (this.#variableController.custom.hasCustomVariable(name)) {
+				this.#variableController.custom.setValue(name, action.options.value as CompanionVariableValue)
+			} else if (action.options.create) {
+				this.#variableController.custom.createVariable(name, action.options.value as CompanionVariableValue)
+			} else {
+				this.#logger.warn(`Custom variable "${name}" not found`)
+			}
 			return true
 		} else if (action.definitionId === 'custom_variable_reset_to_default') {
-			this.#variableController.custom.resetValueToDefault(String(action.options.name))
+			this.#variableController.custom.resetValueToDefault(action.options.name as string)
 			return true
 		} else if (action.definitionId === 'custom_variable_sync_to_default') {
-			this.#variableController.custom.syncValueToDefault(String(action.options.name))
+			this.#variableController.custom.syncValueToDefault(action.options.name as string)
 			return true
companion/lib/Internal/System.ts (1)

329-341: Upgrade uses the wrong key for custom_log (Line 333).

The option id is message, so the upgrade won’t convert the right field.

💡 Suggested fix
-		if (action.definitionId === 'custom_log') {
-			changed = convertSimplePropertyToExpressionValue(action.options, 'custom_log') || changed
+		if (action.definitionId === 'custom_log') {
+			changed = convertSimplePropertyToExpressionValue(action.options, 'message') || changed
companion/lib/Internal/Controls.ts (2)

942-960: Same lint issue with trigger_id.

💡 Suggested fix
 		} else if (action.definitionId === 'panic_trigger') {
-			const rawControlId = String(action.options.trigger_id)
+			const triggerValue = action.options.trigger_id
+			const rawControlId = typeof triggerValue === 'string' ? triggerValue : String(triggerValue ?? '')

896-926: Fix lint failure: String() on potentially object action.options.location.

The static analysis flags that action.options.location could be an ExpressionOrValue object. However, since optionsSupportExpressions: true and the parser runs before executeAction, the value should already be parsed to a string. But the linter doesn't know this.

💡 Suggested fix to satisfy the linter
 		} else if (action.definitionId === 'panic_bank') {
 			// Special case handling for special modes
-			const rawControlId = String(action.options.location).trim().toLowerCase()
+			const locationValue = action.options.location
+			const rawControlId = (typeof locationValue === 'string' ? locationValue : String(locationValue ?? '')).trim().toLowerCase()
companion/lib/Internal/ActionRecorder.ts (1)

250-251: Fix lint failure: String() on potentially object values.

The static analysis correctly flags that action.options.step and action.options.set could be ExpressionOrValue objects, which would stringify to "[object Object]". Since the actionUpgrade method converts these to ExpressionOrValue, the parsed value should already be a string after auto-parsing, but TypeScript doesn't know that.

💡 Suggested fix
-		let stepId = String(action.options.step)
-		let setId = String(action.options.set)
+		let stepId = String(action.options.step ?? '')
+		let setId = String(action.options.set ?? '')

Or if the values might still be objects in edge cases:

-		let stepId = String(action.options.step)
-		let setId = String(action.options.set)
+		const stepRaw = action.options.step
+		const setRaw = action.options.set
+		let stepId = typeof stepRaw === 'object' ? String(stepRaw?.value ?? '') : String(stepRaw ?? '')
+		let setId = typeof setRaw === 'object' ? String(setRaw?.value ?? '') : String(setRaw ?? '')
companion/lib/Internal/Variables.ts (1)

348-367: Remove nocommit comment and dead code to unblock CI.

The ESLint no-warning-comments rule is catching the nocommit comment, which will block the build. The commented-out code block also appears to be remnants from the old implementation that should be cleaned up.

If this is intentional WIP, consider converting to a regular TODO comment (without "nocommit") so CI can pass.

💡 Suggested cleanup
 	`#updateLocalVariableValue`(
 		action: ActionForInternalExecution,
 		extras: RunActionExtras,
 		updateValue: (
 			entityPool: ControlEntityListPoolBase,
 			listId: SomeSocketEntityLocation,
 			variableEntity: ControlEntityInstance
 		) => void
 	) {
 		if (!action.options.name) return

-		// nocommit - this needs to be adjusted to handle triggers/expression variables
+		// TODO - this needs to be adjusted to handle triggers/expression variables
 		const location = ParseLocationString(String(action.options.location), extras.location)
 		const theControlId = location ? this.#pageStore.getControlIdAt(location) : null
-
-		// let theControlId: string | null = null
-		// if (action.rawOptions.location_target === 'this') {
-		// 	// This could be any type of control (button, trigger, etc)
-		// 	theControlId = extras.controlId
-		// } else {
-		// 	// Parse the location of a button
-		// 	const result = this.#internalUtils.parseInternalControlReferenceForActionOrFeedback(
-		// 		extras,
-		// 		action.rawOptions,
-		// 		true
-		// 	)
-		// 	theControlId = result.location ? this.#pageStore.getControlIdAt(result.location) : null
-		// }
 		if (!theControlId) return
🧹 Nitpick comments (2)
webui/src/Controls/OptionsInputField.tsx (1)

65-80: Make ExpressionOrValue handling resilient to legacy raw values.
If a stored option is still a primitive, (rawValue as ExpressionOrValue).value becomes undefined and FieldOrExpression can receive a non-object. A small guard keeps things safe during migration.

♻️ Suggested guard
-import type { ExpressionOrValue, SomeCompanionInputField } from '@companion-app/shared/Model/Options.js'
+import { isExpressionOrValue, type ExpressionOrValue, type SomeCompanionInputField } from '@companion-app/shared/Model/Options.js'
@@
-	const value = fieldsSupportExpressions ? (rawValue as ExpressionOrValue<any>)?.value : rawValue
+	const rawExpressionValue = isExpressionOrValue(rawValue)
+		? rawValue
+		: { isExpression: false, value: rawValue }
+	const value = fieldsSupportExpressions ? rawExpressionValue.value : rawValue
companion/lib/Internal/Controller.ts (1)

295-359: Feedback value retrieval with proper error handling.

The try/catch/finally structure is a nice improvement for robustness. One small suggestion: in the catch block, you might want to clear feedback.referencedVariables to avoid stale variable subscriptions if parsing fails.

💡 Suggested improvement
 		} catch (e: any) {
+			feedback.referencedVariables = null
 			this.#logger.warn(
 				`Feedback get value failed: ${JSON.stringify(feedback.entityModel)} - ${e?.message ?? e} ${e?.stack}`
 			)
 			return undefined

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
companion/lib/Internal/Surface.ts (1)

130-163: Guard against missing surfaceId to avoid "undefined" lookups.

String(undefined) becomes "undefined", which is truthy and can trigger invalid surface operations. Consider early-returning on null/empty values.

🛠️ Suggested fix
-		let surfaceId: string | undefined = String(options.surfaceId).trim()
+		if (options.surfaceId == null) return undefined
+		let surfaceId: string | undefined = String(options.surfaceId).trim()
+		if (!surfaceId) return undefined
♻️ Duplicate comments (3)
companion/lib/Preview/Graphics.ts (1)

170-185: Remove nocommit comments to unblock lint.
CI fails the no-warning-comments rule at Line 170 and Line 256. Please strip those inline notes.

🧹 Suggested cleanup
-		const locationValue = parser.parseEntityOption(options.location, 'variables') // nocommit - this looks wrong
+		const locationValue = parser.parseEntityOption(options.location, 'variables')
...
-		const locationValue = parser.parseEntityOption(previewSession.options.location, 'variables') // nocommit - this looks wrong
+		const locationValue = parser.parseEntityOption(previewSession.options.location, 'variables')

Also applies to: 255-282

companion/lib/Internal/Variables.ts (1)

347-374: nocommit comment still breaks lint — needs removal/rename.

CI will fail due to no-warning-comments. Switching to a normal TODO is enough.

🛠️ Suggested fix
-		// nocommit - this needs to be adjusted to handle triggers/expression variables
+		// TODO: this needs to be adjusted to handle triggers/expression variables
companion/lib/Internal/System.ts (1)

333-345: Upgrade should target message, not custom_log.

The option id is message, so the current conversion is a no-op for existing actions.

🛠️ Suggested fix
-			changed = convertSimplePropertyToExpressionValue(action.options, 'custom_log') || changed
+			changed = convertSimplePropertyToExpressionValue(action.options, 'message') || changed
🧹 Nitpick comments (3)
shared-lib/lib/Model/Options.ts (1)

249-257: Consider strengthening the type guard slightly.

The isExpressionOrValue guard checks for isExpression being a boolean, but doesn't verify the value property exists. While this likely works fine in practice, a stricter check could prevent edge cases where an object happens to have just isExpression.

💡 Optional enhancement
 export function isExpressionOrValue(input: any): input is ExpressionOrValue<any> {
-	return !!input && typeof input === 'object' && 'isExpression' in input && typeof input.isExpression === 'boolean'
+	return !!input && typeof input === 'object' && 'isExpression' in input && typeof input.isExpression === 'boolean' && 'value' in input
 }
companion/lib/Internal/Triggers.ts (1)

125-151: Friendly heads-up: silent success on missing trigger ID.

When stringifyVariableValue(action.options.trigger_id) returns falsy, you return true (success). This is probably intentional to avoid breaking flows, but it might make debugging tricky if someone misconfigures their trigger.

Would it be helpful to log a warning here, similar to what's done in CustomVariables.ts with the "Custom variable not found" warning? Totally optional, just a thought!

companion/lib/Internal/Util.ts (1)

9-75: The TODOs acknowledge this needs love - happy to help when you're ready!

The ParseLocationString function works, but I noticed the TODOs on lines 44 and 46 calling out that this parsing is "horrible" and needs reworking. 😄

The nested conditionals and multiple format support (legacy bank IDs, row/column, page/row/column) make this quite complex. When you're ready to tackle simplification, feel free to reach out - happy to help brainstorm a cleaner approach!

One small observation: if parts[0] is something like "abc" (non-numeric), Number(parts[0]) will return NaN, which sanitisePageNumber passes through. This might cause unexpected behavior downstream.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (4)
companion/lib/Internal/Util.ts (1)

145-149: Escape backticks/${} when embedding variable strings.

If options[keys.variable] contains backticks or ${, the generated template literal can break parsing. Consider escaping those characters.

🔧 Suggested hardening
-			const variableName = stringifyVariableValue(options[keys.variable])
+			const variableName = stringifyVariableValue(options[keys.variable])
 			options[keys.result] = {
 				isExpression: true,
-				value: !variableName ? '' : `parseVariables(\`${variableName}\`)`,
+				value: !variableName ? '' : `parseVariables(\`${String(variableName).replace(/[`\\$]/g, '\\$&')}\`)`,
 			} satisfies ExpressionOrValue<string>
companion/lib/Internal/Controls.ts (1)

146-154: Avoid stringifying ExpressionOrValue objects.

Line 153 uses String(options.location), which can yield "[object Object]" if options.location is still wrapped. Unwrapping via stringifyVariableValue keeps this safe.

✅ Suggested fix
-		const location = ParseLocationString(String(options.location), extras.location)
+		const location = ParseLocationString(stringifyVariableValue(options.location) ?? '', extras.location)
companion/lib/Variables/VariablesAndExpressionParser.ts (1)

189-189: Please remove the nocommit marker (lint failure).

Line 189 violates the no-warning-comments rule and currently fails CI.

🧹 Suggested cleanup
-			// nocommit - check value is valid according to the rules
webui/src/Components/FieldOrExpression.tsx (1)

42-47: Coerce values when switching into expression mode.

When toggling into expression mode, value.value can be non‑string (number/boolean/object). That violates ExpressionOrValue’s isExpression: true shape and can later break parsing if the user toggles without editing. Coerce to a string at the point of toggle.

🔧 Proposed fix
 const setIsExpression = useCallback(
 	(isExpression: boolean) => {
 		setValue({
 			isExpression,
-			value: value.value,
+			value: isExpression ? (stringifyVariableValue(value.value) ?? '') : value.value,
 		})
 	},
 	[setValue, value]
 )

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
companion/lib/Controls/Controller.ts (1)

767-771: Nice type refinement from any to a specific union type!

Replacing any with CompanionFeedbackButtonStyleResult | VariableValue is a welcome improvement for type safety. This aligns well with the expression-enabled option handling introduced in this PR.

One small thing to consider for a future pass: Line 719 still uses Record<string, Record<string, any>> for the intermediate values object. You might want to update that for consistency at some point, though it's not critical since the data flows into typed structures anyway.

💡 Optional: Consider aligning the intermediate values type
-		const values: Record<string, Record<string, any>> = {}
+		const values: Record<string, Record<string, CompanionFeedbackButtonStyleResult | VariableValue>> = {}

This would make the typing consistent throughout the updateFeedbackValues method.

wip
wip: controls
wip: custom variables
wip: action recorder
wip: fix visitor
wip: convert more actions
wip: ux
fix
wip: types
wip: finish internal surface actions/feedback
wip: something starting to work
wip: fb test
fix: entitymanager parsing
wip: refactor internal 'module' in preparation
wip: refactor
wip: refactor InstanceEntityManager
review
lint
renaming
fix types
@Julusian Julusian force-pushed the feat/expressions-internal-actions branch from f330e2a to 4319505 Compare January 19, 2026 21:20
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
webui/src/Controls/OptionsInputField.tsx (1)

212-227: Potential null dereference on localVariablesStore.

Hey there! 👋 At line 217, localVariablesStore is passed directly to FieldOrExpression, but the condition at line 212 only checks fieldsSupportExpressions, not whether localVariablesStore is non-null. If someone passes fieldSupportsExpression: true with a null localVariablesStore, this could cause issues at runtime.

A small defensive check would make this more robust:

💡 Suggested safeguard
-	} else if (fieldsSupportExpressions) {
+	} else if (fieldsSupportExpressions && localVariablesStore) {
 		const rawExpressionValue = (rawValue as ExpressionOrValue<any>) || { isExpression: false, value: undefined }
 
 		control = (
 			<FieldOrExpression
-				localVariablesStore={localVariablesStore}
+				localVariablesStore={localVariablesStore}
🧹 Nitpick comments (4)
companion/lib/Variables/CustomVariable.ts (1)

199-205: Align default-value typing and validation with the new signature

Nice change! Since createVariable now accepts VariableValue | undefined, the surrounding docstring and setter signatures still mention string, and the TRPC create input still enforces z.string(). If the intent is to allow non-string defaults, consider updating the doc comment and setVariableDefaultValue signature (and optionally relaxing/validating the TRPC input) so the public surface is consistent. If strings are still the only allowed defaults, it may be clearer to keep the signature string and coerce/validate at the boundary instead.

companion/lib/Internal/CustomVariables.ts (1)

107-269: Comprehensive upgrade logic for legacy actions.

This is a thorough migration path that consolidates many legacy action types (math operations, string operations, JSON path, etc.) into the unified custom_variable_set_value action. The expression generation for each legacy type looks correct.

One thing to consider: the wrapValue helper (lines 109-117) uses double quotes for non-variable string wrapping in parseVariables("${val}"). If val contains double quotes, this could break the generated expression. You might want to escape them:

💡 Suggested improvement
 const wrapValue = (val: string | number) => {
 	if (!isNaN(Number(val))) {
 		return Number(val)
 	} else if (typeof val === 'string' && val.trim().match(variableRegex)) {
 		return val.trim()
 	} else {
-		return `parseVariables("${val}")`
+		return `parseVariables("${String(val).replace(/"/g, '\\"')}")`
 	}
 }
companion/lib/Internal/Instance.ts (1)

352-420: Feedback execution with type casts.

The as any casts for color properties (e.g., feedback.options.error_fg as any) are necessary because CompanionOptionValues types options as unknown. This is a pragmatic approach.

Consider adding a brief comment explaining why the casts are needed, to help future maintainers:

// Color options are typed as unknown in CompanionOptionValues, cast needed
color: feedback.options.error_fg as any,
companion/lib/Internal/Controls.ts (1)

821-978: Action execution handles special modes and color parsing.

The panic_bank handling (lines 885-915) properly checks for special modes (this-run, this-all-runs) before falling back to location parsing.

For color actions, using parseColorToNumber(action.options.color as any) || 0 provides a safe default of 0 (black) if parsing fails.

One small suggestion for button_text (line 881): you're passing action.options.label directly to styleSetFields. If this could be a non-string value, you might want to stringify it:

💡 Suggested safeguard
 if (control && control.supportsStyle) {
-	control.styleSetFields({ text: action.options.label })
+	control.styleSetFields({ text: stringifyVariableValue(action.options.label) ?? '' })
 }

@Julusian Julusian merged commit 1cd3232 into main Jan 19, 2026
18 checks passed
@Julusian Julusian deleted the feat/expressions-internal-actions branch January 19, 2026 21:48
@github-project-automation github-project-automation bot moved this from In Progress to Done in Companion Plan Jan 19, 2026
Julusian added a commit that referenced this pull request Jan 20, 2026
It was half replaced during #3617, but got restored due to some merge conflicts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

3 participants