The open_project pre-flight was supposed to prevent this, but it reads
the project's profile from projectinspectiondata.auxiliary inside the
.project ZIP -- and a plain .project is NOT a ZIP. Verified across
3.5.19.20 .. 3.5.22.10: magic 23 89 ED 33, unzip -l fails, no plaintext
profile string. So inspectProjectFile always throws, the pre-flight
catches and proceeds, and the guard is dead code on real projects.
It also only ever covered open_project -- not the release pipeline.
Consequence in the field: a server bound to SP21 Patch 5 opened and
re-saved a 3.5.19.20 vessel project during release_project_version.
4,639,776 -> 4,688,496 bytes, committed and tagged. The tagged binary
stopped being the software on the boat.
Since the version cannot be recovered from the file, pin it in the repo.
New src/version-pin.ts resolves, most specific first:
1. .codesys-version next to the .project (one line, "3.5.19.20" or
"CODESYS V3.5 SP19"; comments and blanks skipped) -- the only
option when seeding a project with no release history
2. library.md's "CODESYS Development System" row -- every project
gets a pin for free after its first release
Policy is asymmetric so it guards the dangerous path without breaking
existing repos: bump_project_version and release_project_version refuse
on mismatch AND on no-pin; get_project_info, mirror_export and
list_project_libraries only warn on mismatch and proceed when unpinned.
Both saving tools accept allowVersionUpgrade: true to override.
Parsing note: library.md's row carries no dotted profile version, so it
is matched on the SP/Patch label only -- a naive version regex would
otherwise bind to the ScriptEngine's 4.2.0.0. Covered by a regression
test.
25 new unit tests; suite 180/180.
Also fixes an unrelated pre-existing test failure: the ASCII-only script
template check used readdirSync without filtering, so a local untracked
src/scripts/__pycache__/ made it fail with EISDIR.
BUG 1: the README.md version replace was content.replace(/v\d+\.\d+\.\d+\.\d+/g,
newVersion) -- a blanket sweep that rewrote every version-looking string in
the file, corrupting historical fix landmarks, build-archive snapshots, and
even a different project's synced-library version (observed live on
SeaLeopard's README v1.3.0.0 -> v1.3.1.0). Replaced with updateReadmeVersion:
only touches the version token on the first "# " title heading, or a single
"**Version:**" line (Lib001 convention). If no anchor is found (or the
Version-line anchor is ambiguous), nothing is changed and the reason is
reported -- no global-replace fallback.
BUG 2: appendChangelogEntry's "ownership guard" checked for the literal text
'Auto-generated by `bump_project_version`', but the intro this same tool
seeds into new files says 'Auto-appended by `bump_project_version` on
release.' -- the strings never matched, so every one of this tool's own
previously-created Changelog.md files (SeaLeopard, since its v1.0.0.0 seed)
was treated as foreign and silently skipped. The skip only logged to
stderr while release_project_version's caller printed a fixed "Changelog.md:
appended vX" success line regardless. Separately, a genuinely hand-maintained
Keep-a-Changelog file (Lib001's CHANGELOG.md, "## [Unreleased]" / "## [x.y.z.w]
- date") was never understood at all. Confirmed via SeaLeopard commits
3c87d18 / c98c264, which hand-repaired both the missing changelog entries and
the corrupted README.
Replaced with buildChangelogUpdate (pure) + appendChangelogEntry (I/O
wrapper): detects the existing file's heading style from its own headings
(not intro wording) and emits a matching entry -- "ours" style
(## vX.Y.Z.W -- date) inserted before the first existing entry, or
Keep-a-Changelog style (## [X.Y.Z.W] - date) inserted after any
"## [Unreleased]" section (or before the first version heading if none).
An unrecognized format is left untouched with a clear skip reason. The write
is verified by re-reading the file before release_project_version reports
success; a write that didn't happen is now reported as NOT appended.
Rebuilt dist/ (tsc + scripts copy) since the MCP server runs from there.
Tests: 16 new (update-readme-version, build-changelog-update,
append-changelog-entry), full suite: npx vitest --run -- 229 passed, 1
pre-existing unrelated failure (script-manager.test.ts trips on a local
__pycache__ dir under src/scripts, present before this change too).
Both tools silently DISCARDED declarationCode/implementationCode (zod strips
unknown keys): the object was created EMPTY, compiled clean, and did nothing.
Cost a full download/debug cycle on the fp-j1939 bench session 2026-07-17.
Now the params are real: applied after creation via ScriptTextualObject
textual_declaration/textual_implementation.replace() (same proven API as
set_pou_code), failing LOUDLY if provided code cannot be applied.
Also extend findReservedIecIdentifiers beyond time-suffix letters to the full
IEC 61131-3 ST reserved-keyword set, checked case-insensitively ('by'/'BY' are
both the FOR-loop step keyword -- 'by : BYTE;' failed on the bench 2026-07-16).
Sources: IEC 61131-3 (3rd ed.) keyword tables; CODESYS export-format keywords
(content.helpme-codesys.com/en/CODESYS%20Development%20System/_cds_keywords.html).
Standard-function names (MIN/MAX/ABS/...) deliberately excluded: not confirmed
compiler-rejected, and a false positive blocks legitimate code. Scanner now also
checks every name in comma-separated declaration lists and AT %address forms.
Guard wired into create_pou + create_method declarationCode, same as set_pou_code.
Tests: tests/unit/reserved-iec-identifiers.test.ts (8 cases); full suite 214 green.
Live verification on SP21: users.create raised "permission 'Modify' not
granted to user '(nobody)'". The scripts now log in as 'Owner' with empty
password (CODESYS default) when nobody is logged on; new optional
adminUser/adminPassword args override for protected projects.
1. find_object_by_path: final name verification used the original
full_path instead of the dot->slash normalized segments, so
dot-separated paths ('Application.MyPOU') traversed correctly but
failed the final check and returned None.
2. User-arbitrary values (plcPath/plcDirectory, passwords, comments,
project-info fields, device parameter name/value, task event,
device credentials) were interpolated into r"..."/r"""...""" Python
literals unescaped -- a quote or triple-quote in the value broke the
generated script (or injected code). Templates now take pre-escaped
literals via pyStringLiteral().
Reviewed-range: dead49a..e9aa714. Third reviewer finding (task.priority
must be int) was rejected: SP21 ScriptTaskConfigObject.pyi types the
priority/interval/interval_unit setters as str.
- add_library.py line 49 had UTF-8 box-drawing dashes in a comment with no
coding declaration -- latent IronPython 2.7 source-encoding risk.
- build now removes dist/scripts before copying, so deleted/renamed
templates (compile_project.py.bak, probe_app_error_state.py,
set_library_namespace.py) no longer ship in the npm tarball.
- script-manager test now asserts EVERY template is ASCII-only instead of
per-phase lists.
String.replace with a string replacement interprets $$/$& as regex
replacement patterns, corrupting IEC string literals like '$R$N' passed
through tool params (set_pou_code code bodies, write_variable values).
Use a function replacement so values pass through verbatim.
CODESYS opens/saves projects from UNC paths (\server\share\...) only
unreliably -- it tends to fail late and opaquely. Add src/path-guard.ts
(isUncPath + uncPathError) and gate open_project, create_project,
save_project, and launch_codesys_with_project on it: each now returns
isError early telling the user to map a drive (net use Z: \server\share)
or copy the project to a local drive. Mapped drive letters and local
drives (incl. \?\C:\) are not treated as UNC, so existing workflows are
unaffected.
Also bumps version to 0.10.1 (first published release of the 0.10.x line;
includes the prior unreleased phobiCS-tui removal and add_device tool).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Knock-on cleanup from the phobiCS-tui nuke in 4b82b7c. With the TUI gone
the gate had no UI to render -- it just printed a stderr warning and
auto-approved on every call. Ripping it out kills ~470 lines of dead code
and a redundant CLI flag.
Removed:
- src/approve-gate.ts (whole file; runApproveGate + gateOpForTool + helpers)
- tests/unit/approve-gate.test.ts
- bin.ts: --approve-edits CLI option, approveEdits config wiring, startup log
- types.ts: ServerConfig.approveEdits field + its doc comment
- server.ts: import of runApproveGate/gateOpForTool, the `Approve edits:` log,
16 `gateOpForTool({...}); if (blocked) return blocked;` blocks across
every modifying MCP tool handler, and the lone `runApproveGate({...})`
block in set_pou_code.
Folded in (Karstein's prior WIP, gate-free now per his explicit choice
"Single rip commit, I edit your WIP too"):
- New `add_device` MCP tool in server.ts that wraps ScriptDeviceObject.add
for attaching child devices (Modbus TCP Server under Ethernet, Ethernet
under PLC, etc.). The supporting `src/scripts/add_device.py` remains
UNTRACKED in Karstein's working tree -- still his to commit separately.
Without that script the tool will fail at runtime; with it, fine.
Verify:
- `npx tsc --noEmit` clean
- `npm run build` clean
- `npx vitest --run tests/unit/` -> 14 files, 119 tests, all pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The phobiCS-tui CLI/UI is retired in favour of driving the codesys-mcp-sp21-plus
MCP tools directly from Claude Code (see https://docs.anthropic.com/claude-code).
The MCP server itself is unchanged; only the TUI front-end goes.
Removed:
- src/tui/ (browser + approve + shared + entry; 14 files)
- dist/tui/ (compiled output)
- tests/tui/ (14 .test.* files + the mini-mirror fixture tree)
- tsconfig.tui.json
- 3 superpowers plans/specs docs (2026-04-28 phobics-tui v0.1-v0.2, 2026-04-29 v0.3-live-values, 2026-04-28 tui-design)
- package.json: phobiCS-tui bin entry, build:tui script, TUI compile step in build, TUI typecheck step
- package.json: dependencies ink + react + diff (TUI-only); devDependencies @types/diff + @types/react + ink-testing-library
- README.md: ## phobiCS-tui section + ### Inline live values subsection
Git history side:
- Worktree .worktrees/phobics-tui removed (was on feature/phobics-tui-followup @ 7e427e9)
- Local + origin branches deleted:
- feature/phobics-tui (was 08ee361, 0 unmerged vs origin/main)
- feature/phobics-tui-followup (was 7e427e9, 0 unmerged vs origin/main)
- feature/phobics-tui-v0.3-live-values (was 9f4dc48, 0 unmerged vs origin/main)
- All three branches were merged into main, so deleting refs loses no history --
the commits remain reachable through main.
Knock-on (deliberately deferred):
- src/approve-gate.ts and the --approve-edits flag in src/bin.ts / src/server.ts
still exist. With the TUI gone, the gate auto-approves at every prompt and
prints a `[approve-gate] No TTY available -- phobiCS-tui cannot render` warning
to stderr. The infrastructure also still has callers in the uncommitted
add_device work, so a clean rip-out is left for a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Once launch() refused due to a foreign CODESYS, the error string was
cached in lastError and getStatus() returned that frozen snapshot
forever. Closing the foreign CODESYS did not update the status -- only
an MCP restart cleared it. From the user's POV: "every time CODESYS
HAS been open, you get a problem".
Fix: getStatus() now calls revalidateLaunchRefusal() first. If the
launcher is parked in 'error' state with a "Refusing to launch:"
prefix and findConflictingInstances() now returns empty, we transition
back to 'stopped' and clear lastError so the next status call / launch
attempt sees a fresh state. Other 'error' states (process died,
watcher timeout, exe not found) are not auto-cleared -- only the
launch-refusal cache, since that's the one that goes stale on its own
when the user closes the foreign window.
Bump 0.9.13 -> 0.9.14.
Tests: 2 new launcher unit tests pin the auto-clear behaviour and
verify unrelated 'error' states are NOT auto-cleared. 22/22 launcher
tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the pump beyond top-level vars: when a var's declared type
resolves to another mirror .st file, we descend one level and read
each of that type's vars as <var>.<member>.
Parser: parseVarDecls(text) now returns name + declared type per
decl. ARRAY/POINTER/REFERENCE wrappers stripped to the inner type.
parseVarNames is kept as a thin wrapper for the existing callers.
Pump: new resolveTypeMirror(typeName, deviceRoot) dep. Pump itself
doesn't know how to find a type's source -- the caller plugs in a
strategy. server.ts wires a recursive walk under the device root
looking for '<typeName>.st'; the mirror layout guarantees stable
filenames for POU/FB/DUT (every code-bearing object).
Pump's deviceRootFor(absPath) parses the abs path back to the
device root by locating the '/mcp-mirror/<device>/' segment. If
that fails we fall back to the file's parent dir (still works for
same-folder type lookups, just won't find types in sibling folders).
Constructor now accepts partial deps and fills in safe defaults
(no-op resolveTypeMirror -> never descend, matching v0.3
top-level-only behaviour). Existing tests don't need to change.
407/407 tests pass; new coverage:
- parseVarDecls: name+type extraction, ARRAY/POINTER/REFERENCE
stripping, AT %loc prefix, missing-type pathological case
- LiveValuesPump.tick: descends when resolver returns content;
doesn't descend when resolver returns null (primitives)
* feat(live-values): --live-values-interval <ms> CLI flag
Replaces the hardcoded 500ms poll with a configurable interval.
Clamped to [100, 60000] -- below 100 the read_variable IPC round-
trip-per-var dominates and the pump can't keep up; above 60000 a
session shorter than the interval would never see any update.
ServerConfig.liveValuesIntervalMs?: number plumbs through bin.ts
(parseInt + clamp) into the LiveValuesPump constructor and the
'Live-values pump started (<ms>ms)' log line.
Default unchanged at 500ms, so existing --live-values invocations
behave identically.
* test(e2e): regression coverage for the four 2026-04-29 fixes
Five new template-prep assertions:
1. set_symbol_access mutation lookup goes to get_all_signatures FIRST
(PR #7). The configured-view probe is tracking-only and must come
AFTER both compile=False/True all-signatures lookups -- if the order
ever flips, the mutation hits the read-only view and CODESYS rejects
the assignment with 'can only be changed in the list of all
signatures/data types'.
2. set_symbol_access int->SymbolAccess coerce (PR #9). The fallback
block must use type(maximal_access) and call enum_cls(int_val)
BEFORE 'var.configured_access = requested_access'. Without it,
non-zero ints are rejected with 'must be zero'.
3. set_signature_access_bulk same coerce, lazily on the first variable
in the for-loop (PR #9). Verifies loop -> coerce -> assign order.
4. create_project without deviceName preserves the no-swap path
(backwards compat for PR #8). Empty DEVICE_NAME substitutes; the
if-DEVICE_NAME branch is in the template but the runtime gates it.
5. create_project deviceName swap (PRs #8/#10/#11/#12 combined).
Verifies:
- PromptHandling.NONE setter is rendered (script_prompt_handling
is read-only -- earlier code silently failed).
- device_repository accessed via script_engine, not as a builtin.
- update() is the first-attempt mutation, remove+add only fires
'if not update_ok'.
- project.close() + precompilecache delete render after swap.
Vitest: 27/27 in tests/integration/e2e.test.ts (was 22).
---------
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Adds remove_library as the symmetric counterpart to add_library.
Script (src/scripts/remove_library.py):
- IronPython 2.7 / ASCII-only; no f-strings, no KeyboardInterrupt in
except-Exception clauses.
- Placeholder substitution: LIBRARY_NAME (bare) and LIBRARY_FQN_OR_NAME
(bare or "Name, Version (Company)").
- Locates the project Library Manager via the same three-step discovery
used in add_library.py: has_library_manager/get_library_manager on the
project, first-level child walk, then find("Library Manager") fallback.
- Pre-check: walks lm.references using _ref_name_matches logic (handles
placeholder "#Name" and managed "Name, Version (Company)" forms).
If the library is not referenced the script exits SCRIPT_SUCCESS with
the "Library Not Present:" marker -- idempotent, same convention as
add_library's dedup no-op branch.
- If found: calls lm.remove_library(existing_name) per the SP22 stub
(ScriptLibManObject.pyi: remove_library(name: str)), confirms removal
from lm.references, then project.save().
- Emits SCRIPT_SUCCESS or SCRIPT_ERROR with traceback on exception.
Server (src/server.ts):
- Tool registered immediately after add_library (~line 2067).
- Reads "Library Not Present:" marker to pick idempotent vs removed
wording -- same marker-driven pattern as add_library's dedup wording.
- Thin surface: projectFilePath + libraryName (required) +
libraryFqnOrName (optional, for multi-version disambiguation).
Tests (tests/integration/e2e.test.ts):
- Template-prep test asserts: no leftover {PLACEHOLDER}s, substituted
values present, remove_library call present, references walk present,
"Library Not Present" marker present, SCRIPT_SUCCESS/SCRIPT_ERROR
markers present.
- All 22 tests pass.
References:
- helpme-codesys.com scripting engine > ScriptLibManObject
- SP22 stub: Stubs/scriptengine/ScriptLibManObject.pyi (remove_library
at line 455, references property at line 464)
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Task 6 of v0.3 plan (parser + pump class; full --live-values CLI
wiring is Task 7).
parseVarNames(text) extracts variable names from any VAR /
VAR_INPUT / VAR_OUTPUT / VAR_GLOBAL / etc. block. Handles:
- one name per declaration line
- 'AT %QX0.1' location prefix
- := initializer
- inline (* ... *) and // line comments
- multi-line (* ... *) blocks (state threaded across lines)
Doesn't handle the multi-name shorthand 'a, b : INT;' -- vanishingly
rare in practice, cost of getting it wrong is just a missing overlay.
LiveValuesPump owns a setInterval. Each tick:
- reads tui-state.json via the injected readSelection
- reads the POU's .st file from the mirror
- parseVarNames -> per-var read_variable round-trip (errors per-var
are silent, others may succeed)
- writeLiveValues snapshot
Reentrancy guard: if a tick is still in flight when the next
interval fires, skip the new tick rather than queueing. Errors at any
layer are swallowed -- pump must never crash the server.
start()/stop() lifecycle so the server can hand it to the existing
shutdown path.
Task 5 of v0.3 plan.
writeLiveValues(filePath, projectDir, payload) wraps the caller's
{device, pou_name, values} in the v1 envelope (version, updated_at,
project_dir) and writes atomically via <file>.<pid>.tmp + rename.
Creates parent dirs as needed.
Mirrors src/tui/shared/state-write.ts. Kept separate because the
TUI subpackage is ESM and the server is CJS; the duplication is
~15 lines of code that almost never changes.
Task 4 of v0.3 plan.
useLiveValues(filePath, pouName, intervalMs=500) sets up a recursive
setTimeout poll. Returns the values map iff readLiveValues returns
ok AND the snapshot's pou_name matches the requested pouName;
returns null otherwise (cursor not on a POU, file missing/stale,
wrong POU snapshot).
Cancelled flag + setTimeout (not setInterval) so the cleanup is
race-free: if the file read takes longer than intervalMs, the next
tick won't pile up.
Browser computes the live-values file path once via
liveValuesFilePath() (catches Windows LOCALAPPDATA-missing -> null
so we don't crash; pump just won't be able to write either) and
passes the resulting map down to <Viewer liveValues={...}/>.
Task 3 of v0.3 plan.
<Viewer liveValues={...}/> walks each visible line for an identifier
that's a key in the map. First match wins; appends ' ◀ live: <val>'
in green at end of line. No prop -> no overlay (default).
Matches against the original line text, not against highlighter
tokens: the highlighter splits mixed-case names like bRunning into
'b' + 'R' + 'unning' (uppercase-only ident regex) which would never
match a 'bRunning' key. Operating on the line preserves identifiers
intact.
Task 2 of v0.3 plan.
Mirrors src/state-read.ts (the server-side selection reader): same
discriminated union (ok | missing | stale | invalid), but lives in
the ESM TUI subpackage (TUI is the consumer, server is the
producer).
Tighter freshness window than the selection file (5s vs 60s). Stale
overlay would be misleading -- if the pump dies or the runtime goes
offline we want no overlay rather than frozen values.
Task 1 of the v0.3 inline-live-values plan.
LiveValuesPayload is the on-disk shape the server pump writes and
the TUI reads. Same versioning + envelope approach as the existing
selection state file: version=1, ISO updated_at, project_dir +
device + pou_name match the user's current selection so the TUI can
reject stale snapshots.
liveValuesFilePath() lives next to stateFilePath() and resolves to
%LOCALAPPDATA%/codesys-mcp/tui-live-values.json on Windows or
$XDG_STATE_HOME/codesys-mcp/tui-live-values.json (default
~/.local/state) on POSIX. Path joining stays platform-flavoured
even when the unit test forces a different process.platform, so the
suite is host-independent.
Adds tokenizeWithState(line, openComment) -> {tokens, commentLeftOpen}
and tokenizeLines(allLines) which threads the open-comment flag across
lines.
Viewer now tokenizes from line 0 (not just the visible slice) so the
state going into the visible window is correct, then renders the
slice. A line wholly inside a (* block is emitted as a single
'comment' token; PROGRAM and other keywords on those lines no longer
get falsely highlighted.
tokenize(line) kept as a thin wrapper for the single-line callers
(tests, future use).
On Windows, the renamed .command.json / script .py file is briefly
locked by Defender or NTFS rename finalization, which surfaces as a
transient PermissionError when the watcher's open() runs immediately
after the producer's atomicWrite + rename.
Wrap the two reads in process_command() with read_with_retry(), which
retries up to 10 times with 20 ms sleeps before giving up. POSIX
behaviour unchanged (first attempt always succeeds).
Repro on Windows:
npm test
> FAIL tests/unit/ipc.test.ts > sendCommand handles script error
> PermissionError: [Errno 13] Permission denied: '...command.json'
Generalizes runApproveGate beyond set_pou_code by adding:
- runApproveGateOp({slug, oldText, newText}) — writes synthetic
before/after files into a tmpdir, spawns 'phobiCS-tui approve' on
them, cleans up the tmpdir on return. Used for ops that don't have
a clean existing-file -> proposed-file mapping (create/delete/rename
/add).
- gateOpForTool({enabled, slug, oldText, newText}) — MCP-tool-shaped
wrapper. Returns null when the op should proceed (gate disabled,
accepted, or no-existing); returns a {content, isError} block-
response otherwise. Lets each tool gate with one if-statement.
Wired into the 9 modifying tools (with --approve-edits on, each one
prompts via the TUI before applying):
- create_pou all-green diff: name + type + language + parent
- create_property all-green diff: name + type + parent FB
- create_method all-green diff: name + return type + parent FB
- create_dut all-green diff: name + DUT type + parent
- create_gvl all-green diff: name + parent + (optional decl)
- create_folder all-green diff: name + parent
- delete_object all-red diff: object + project
- rename_object del+add: old name -> new name
- add_library all-green diff: library name + project
set_pou_code keeps using the existing runApproveGate (which composes
real merged file content via the IMPL_SENTINEL split, giving the
nicest possible diff against the real mirror file).
tokenize(line) splits a single line into typed tokens:
keyword (cyan) PROGRAM, FUNCTION_BLOCK, IF/THEN/ELSE/END_IF,
FOR/TO/DO, VAR/END_VAR, CASE/OF, AND/OR/XOR/NOT,
TRUE/FALSE, EXTENDS, IMPLEMENTS, etc.
type (magenta) BOOL, INT, DINT, REAL, LREAL, TIME, STRING,
ANY_*, ARRAY, POINTER, REFERENCE, etc.
comment (gray) (* ... *) inline and // line-to-end
string (yellow) 'single' and "double" quoted
text (none) everything else
Identifier matching is case-sensitive uppercase only — matches
typical IEC 61131-3 convention and avoids false positives on lower-
case identifiers like if_x. Multi-line (* ... *) comments are not
joined across lines (out-of-scope for v0.2).
When the cursor is on a POU, d collects all OTHER devices that have
a POU with the same name.
- 0 peers: no-op (nothing to compare)
- exactly 1 peer: open the diff overlay immediately
- 2+ peers: open <CrossPicker> first; user picks with j/k + Enter
The diff overlay uses computeHunks against the full file contents
(not the IMPL_SENTINEL split, since we want to see decl differences
across devices too). Esc/q closes it.
Side-by-side renders old | new in two 50% columns separated by '│'.
Consecutive del/add hunks are paired row-by-row; a longer side gets
blank rows on the shorter side. Context lines mirror on both sides.
Footer now lists 'v toggle side-by-side'.
/ enters filter mode. Typed chars accumulate (case-insensitive
substring match against POU.name). Backspace removes a char. Enter
commits and stays out of input mode (filter persists). Esc clears
the filter and exits.
While the filter is active, devices auto-expand and only POUs whose
name contains the filter text are listed; devices with zero matching
POUs are hidden entirely.
Filter status line ('Filter: <text>') shows above the tree, cyan
while in input mode, dim once committed.
o on a POU row spawns $EDITOR (defaulting to 'code') with the
absolute path. detached + stdio:'ignore' + unref so the editor's
lifetime is independent of the TUI; shell:true so PATH lookup works
on Windows where 'code' is a .cmd shim.
Ignored on device rows (no abs_path).
? toggles a bordered help panel listing all keybindings. While the
panel is open, all other keys are ignored except Esc (which closes
it). The footer text gets a '? help' hint.
formatStaleness(mtimeMs) -> null | 'Xs' | 'Xm Ys' | 'Xh Ym'.
<ResizeWarning columns rows/> warns when terminal is below 80x20.
Browser header now shows 'mirror Xm Ys old' between the project
name and the closing rule when the mirror dir mtime is older than
STALE_THRESHOLD_MS (10s). The resize warning sits between the
header and the split view so it's the first thing the user sees
when they shrink the window.
vitest tests/integration/e2e.test.ts:
- 10 new template-prep assertions, one per Symbol Configuration tool.
Each asserts no leftover {PLACEHOLDER} in the rendered script and
that the relevant CODESYS API method names appear (get_all_signatures,
application.create_symbol_config, configured_access, etc.).
- Drive-by: fix the existing add_library e2e test that was missing the
ALLOW_UNRESOLVED placeholder (added when the resolution gate landed
in d414c77). Test was previously failing on /\{[A-Z_]+\}/ regex.
Suite is now 107/107 green (excluding the orphan .worktrees/phobics-tui
suite that's been failing pre-this-change).
README.md:
- Tool count 31 -> 41.
- New "Symbol Configuration Tools" section under Library Management
with one row per tool.
- Project-structure tool count footnote updated.
docs/function-test-2026-04-28.md:
- Append "Symbol Configuration tools (added 2026-04-28 evening)" block
with a per-tool vitest/live status grid.
- Live SP22 column is deferred: the MCP tool list is negotiated at
Claude Code session start and doesn't refresh mid-session, so the
new tools aren't callable in the session that built them. Document
the round-trip cycle so a fresh session can run it.
- Note the SymbolAccess enum-value probe risk per the plan
(docs/superpowers/plans/2026-04-28-symbol-config-tools.md).
Python 3 ast.parse run against all 10 new + 1 helper scripts -- 11/11
parsed cleanly (catches the obvious IronPython 2.7 syntax issues that
Py3 would also flag).
When --approve-edits is on, set_pou_code:
1. finds the existing mirror .st for the POU (best-effort glob; no
match -> skip the gate, proceed)
2. composes the proposed merged content (decl/impl swapped at the
IMPL_SENTINEL line, keeping the unchanged half)
3. writes the proposal to <existing>.staged
4. spawns 'phobiCS-tui approve <existing> <staged>' with stdio
inherited so the user sees and answers it
5. cleans up .staged regardless of outcome
Exit 0 -> accepted -> apply the change. Exit 1 -> user-facing
'rejected' response (isError=false; the user actively said no).
Exit 2 -> error response (isError=true).
When the flag is off (default), set_pou_code runs unchanged -- this
keeps existing scripted flows from regressing.
Adds an MCP tool the LLM can call to ground modifying calls in what
the user is currently looking at in the phobiCS-tui browser.
Backed by readSelection() from src/state-read.ts.
defaultStateFilePath() is intentionally duplicated from
src/tui/shared/state-paths.ts: the TUI subpackage is ESM and the MCP
server is CJS, so a cross-import would force one or the other to
change module systems for ~15 lines of code that almost never
changes.
readSelection(path) -> discriminated union of:
- ok (the parsed v1 payload)
- missing (file does not exist)
- stale (updated_at older than FRESHNESS_MS = 60s)
- invalid (bad JSON or unknown version)
Lives outside src/tui/ because the MCP server (CJS) consumes it; the
TUI itself only writes the file.
<Browser project readPou writeSelection onQuit/> wires Tree (40%) and
Viewer (60%) into a stateful split view.
Keys: j/k nav, l/Enter/right expand a device, h/left collapse,
q quit.
Two effects fire whenever the cursor sits on a POU:
- debounced 200 ms writeSelection() so rapid j/k presses do not
storm the state file
- lazy readPou() with a cancelled-flag guard so the previous POU's
text never lands after the user has already moved on
<Tree project cursorPath expanded/> renders the project as a
two-level collapsible list: device headers with POU count, and (when
expanded) POU rows with name + kind + LOC count. Cursor row marked
with a leading triangle.
devicePath()/pouPath() are exported keypath helpers used by the tree
state machine to address rows uniformly.
phobiCS-tui dispatcher:
- --version / -v: print version, exit 0
- approve <existing> <proposed>: read both files, render <Approve>,
exit 0 on accept / 1 on reject / 2 on bad args or read error
- no args: print 'browser mode coming' placeholder, exit 0
- SIGTERM/SIGINT during approve: unmount + exit 1
Integration tests cover the no-TTY paths (--version, missing file,
missing args). Accept/reject keybinds are covered by the
ink-testing-library tests in Approve.test.tsx; piping stdin into a
no-TTY ink process is flaky on Windows so we don't try to
integration-test that path.
The shebang line is intentionally absent from the source; the build
script prepends one to dist/tui/index.js.
<Approve fileName oldText newText onDecision/> renders a unified diff
(+/-/space sigils, 4-col line numbers, add/del totals header) and
binds y -> accept, n/q/ESC -> reject via ink useInput.
Tests use ink-testing-library; each keystroke case awaits a microtask
flush before stdin.write so that ink's useEffect-installed 'readable'
listener has actually been attached at the moment the byte arrives.
findProjectRoot(startDir) walks up the filesystem looking for an
mcp-mirror/ subdirectory and returns the dir that contains it. Returns
null if no mirror is found anywhere upward to the FS root.
computeHunks() returns Hunk[] (add | del | ctx) with line numbers on
the new side for add/ctx and the old side for del.
Both inputs are normalized to end with a newline before calling
jsdiff's diffLines, otherwise a missing EOF newline is treated as a
token boundary and the last line shows up as a spurious del+add
pair.
writeSelection() emits the v1 state envelope (version, updated_at,
project_dir, device, selection{kind,name,path,abs_path}, viewer_line)
to <file>.<pid>.tmp then atomic-renames into place. mkdir -p the
parent, no .tmp left behind on success.