Skip to content

feat(automation): reserved ${terminal.*}/${time} tokens and snippet insertion - #93

Merged
rockyway merged 4 commits into
developfrom
feature/automation-reserved-tokens-snippet-insert
Sep 13, 2026
Merged

rockyway merged 4 commits into
developfrom
feature/automation-reserved-tokens-snippet-insert

Conversation

@rockyway

Copy link
Copy Markdown
Contributor

Summary

Plan 042 — reserved message tokens and snippet insertion for automations.

  • Reserved tokens ${terminal.id}, ${terminal.title}, ${terminal.cwd}, ${time} are accepted next to $0/$1/${name} in both Send-to-Terminal and Send-to-Webhook messages (opt-in substitute flag, unchanged). They resolve to the source (watched) terminal of the match — also for parked after-match sends and for schedule sends that have no captures. A pattern's own named group shadows a reserved name; an unknown ${x} still errors.
  • One scanner in Rust (subst.rs) mirrored in TS (automationTokens.ts), shared fixture automationTokenCases.json. The reserved bag is built once per crossing in run_crossing (began is taken before it, so the settle window is not shortened); cwd is process-keyed (EngineHost::cwd_for(pc): OSC-reported cwd first, shared proc_snapshot scan fallback), so a leaf restarted between match and send yields "" rather than the new shell's directory. Title/cwd are scrubbed of control characters before they can reach a paste.
  • Custom webhook bodies JSON-string-escape substituted values (ValueEscape::JsonString, mirrored by webhookValueEscape); preset providers stay raw because they serialise the finished message as a JSON value themselves. Validation, dry-run and the editor preview all know the reserved names (chips in both editors).
  • ✂️ Insert snippet… in both message editors opens the existing Snippets flyout in a new pickOnly mode and splices the snippet text verbatim at the caret (caretInsert.ts) without sending; dismiss leaves the message unchanged and focus returns to the button.

Plan: termflow-fabric/docs/plan/042-automation-terminal-tokens-and-snippet-insert.md (implementer reports 070/071, handoff 072, external reviews 220/221 in the same repo).

Verification

  • cargo test (src-tauri): 1038 passed · bun run typecheck clean · bun run test: 254 suites / 4488 tests · bun run build ok (pre-existing size warnings only) · clippy: no warnings in touched files. cargo test --features integration-tests --no-run compiles (the new AppState::cwd_for tests run on the Linux CI leg).
  • Post-implementation audits (spec-vs-impl, architect) run and acted on.
  • External review, two whole-branch rounds (codex gpt-5.6-sol): 0 blockers, 0 code defects in either round; every oracle/prose finding fixed as a class (5ad6984, f4c3f1f) and mutation-checked — 11 mutants killed and reverted (fixed-string ${time}, Custom rendered raw in both layers, one-name exemption in both layers, reserved-path-first preview, picker z-index below the editor, caret restored before the re-render, unterminated ${ emitting a token in both scanners, unknown dotted name rendering as "").

GUI runbook pending

Plan 042 §7 (11 steps; step 7 checks that the picker paints above the editor). Merge after sign-off.

Noted, out of scope

SendTo::All ("Every watched terminal") is a dead radio — the engine never reads send_to. Backlog item in plan §9.

…nsertion in messages

Send to Terminal and Send to Webhook messages can now name the watched terminal
that fired the rule and the moment the send ran, with the same ${name} grammar
$1/${file} already use:

  ${terminal.id}     the tm- id of the source terminal
  ${terminal.title}  the label the tab strip / picker / activity log show ("" if none)
  ${terminal.cwd}    shell-reported OSC cwd, else the process cwd; looked up only
                     when a substituting message names it, on a blocking worker
  ${time}            local wall-clock when the send runs, YYYY-MM-DD HH:mm:ss

The values come from the pending send's own source terminal (PendingSend.pair.tm
and the decide-time label), never a destination, for immediate, parked and
schedule sends. A pattern's own named group of the same name wins over a reserved
name. One Reserved bag is built per crossing in run_crossing, so both
destinations render the same ${time}. Dry run renders real values; TS and Rust
save validation accept the reserved names (a schedule rule may use them without
a pattern) and still reject ${nope}. The shared grammar fixture gains the rows.

Both message editors get the four chips and an "Insert snippet" button that
opens the existing Snippets flyout (ContextMenu standaloneSubmenu) in a new
pickOnly mode; a picked snippet is spliced verbatim at the caret, the draft is
dirtied through the normal patch, a use is recorded, and the substitute flag is
left alone. The webhook panel now restores focus/caret after an insert like the
action panel does (shared panels/caretInsert.ts). The picker menu sits above the
editor overlay via a ContextMenu className.

Plan 042 (termflow-fabric docs/plan/042-automation-terminal-tokens-and-snippet-insert.md).
… paste-safe values, Custom JSON escaping

Architect + spec-vs-implementation audits of 0cc3846, all findings fixed as one class:

- `EngineHost::cwd_for` is keyed by the crossing's PROCESS (`send.pair.pc`), not its leaf:
  `leaf_to_process` is overwritten on every spawn, and `run_webhook` has no restart guard, so a
  leaf lookup after a Ctrl+R inside a parked wait paired run A's `$1` with run B's directory.
  AppState resolves through the roster's shared `proc_snapshot` instead of a fresh
  `System::new_all()` per crossing. Pinned by a webhook test that replaces the process under
  the same leaf and expects the gone process's cwd to render empty (mutant-checked).
- `began` is taken in `run_crossing` before the bag is built, so the echo/settle window still
  covers the cwd scan; a `JoinError` from the blocking lookup is logged, not swallowed.
- `Reserved::time_from_ms` converts UTC→local from `at_ms` with no second clock read;
  `schedule.rs`'s "only local-time conversion" claim corrected in place.
- `Reserved::for_send` scrubs control characters (ESC/CR/LF…) from title and cwd — both can be
  spelled by a person, an API caller or a directory name, and would close the bracketed paste
  early and submit the rest. The id and time are ours and untouched.
- Custom webhook bodies: substituted values are written as JSON string fragments
  (`subst::ValueEscape::JsonString`, chosen by `automation_webhook::value_escape`; renderer
  mirror `webhookValueEscape` feeds the panel preview and the save-time JSON check). A Windows
  cwd or a quoted title no longer produces a body that saves but posts as invalid JSON. Scope
  change agreed with Tam; recorded in plan 042.
- Picker returns focus to its button on close instead of parking it on <body>.
- Tests: caret restore pinned in both panels (chip and snippet paths), outside-click dismissal,
  JSON-escape unit + engine tests on both sides.
Review 220 (codex sol) found no code defect; every finding was an oracle a
wrong implementation would pass, or a sentence the code did not support.
Each is fixed as a class, at every site of its shape:

- `${time}` was asserted by SHAPE at four sites (subst unit test, the
  schedule send, the webhook crossing, the dry run). A fixed timestamp of
  the right shape passed all four. Now: the unit test pins the value
  against chrono's other local-time path; the engine sends are bracketed
  between two clock reads formatted by chrono directly (not by
  `time_from_ms`, so the bounds do not move with a mutant); the dry run
  asserts the exact rendering of its caller's `now_ms`.
- The Custom-JSON validation test could not tell a validator that
  substituted from one that did not — the untouched body is valid JSON
  too. The shared sample values now carry a `"` (title) and a `\` (cwd)
  in BOTH layers, so only substituted-and-escaped parses, and the tests
  read the rendering itself (`rendered_webhook_body`;
  `renderWebhookBodyForValidation` exported for its test) and parse it
  back to the raw samples.
- The no-pattern exemption was probed with one of four names, in both
  validators. Now all four, at both destinations, with the names spelled
  out (a loop over the constant would shrink with a trimmed constant);
  plus a subst test that `RESERVED_NAMES` and `Reserved::get` agree.
- The TS shadowing test read `{ time: '42' }` through a flat sample map,
  which cannot say which path resolved it. It now pins the observable
  difference: a declared-but-absent group is empty text, an undeclared
  reserved name is a placeholder.
- `build_reserved`'s doc claimed the cwd "a shell was in when it printed
  the match"; the lookup runs when the send runs, after any parked delay.
  Corrected in place.

Mutants killed and reverted: fixed-string `time_from_ms` (4 tests),
Custom provider rendered raw (Rust + TS), `is_reserved` = terminal.id
only (Rust ×3, TS ×4), TS preview taking the reserved path first.
…real caret after review round 2

Review 221 (codex sol, whole branch, second independent pass) again found
no code defect. Its three oracle findings, fixed as classes:

- The picker test named "opens above the editor" observed only the class.
  jsdom cannot paint, so the order is pinned where it is decided: a test
  reads both stylesheets and asserts the picker's z-index is greater than
  the editor's (z-index 1000 on the picker now fails it).
- The shared scanner fixture had no unterminated `${` row and no unknown
  dotted name. Both added; `rendered: null` means "recognised, resolved by
  nothing", honoured by both consumers (a scanner that emits a token to end
  of input, or a resolver that renders an unknown name as "", fails).
- The caret tests spied on the range the panel ASKED for. The test host now
  applies every dispatch through `draftReducer`, and both tests read the
  selection the textarea holds after the longer value rendered — mid-text,
  because a programmatic value set parks the caret at the end and an insert
  at the end could not tell a restored caret from none (a synchronous
  restore before the re-render now fails four tests).

Plus the coverage gap round 1 and 2 both named: `AppState::cwd_for` gets
its own tests (OSC answered without a scan; a silent shell falls back to
the scan of the test's own process; unknown process is None), gated on
`integration-tests` like every `mock_app()` test.
@rockyway
rockyway merged commit b370d69 into develop Sep 13, 2026
5 checks passed
@rockyway
rockyway deleted the feature/automation-reserved-tokens-snippet-insert branch September 13, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants