Bumps codesys-mcp-sp21-plus to 0.7.0 (main was at 0.6.4 from
intermediate releases) and phobiCS-tui's --version output to v0.2.0.
Headline changes since v0.6.4:
TUI:
- browser keybinds: / (filter), o (open in editor), d (cross-
device diff), r (rescan), ? (help overlay)
- approve mode: v toggles unified <-> side-by-side diff
- viewer: ST syntax highlighting (keywords/types/comments/strings)
- statusbar: stale-mirror indicator + small-terminal resize warn
MCP server:
- --approve-edits now gates ALL 9 modifying tools, not just
set_pou_code: create_pou, create_property, create_method,
create_dut, create_gvl, create_folder, delete_object,
rename_object, add_library
No tag created here; npm publish has to be run from your terminal
because the npm 2FA passkey can't be driven through the bash tool.
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.
Bare 'phobiCS-tui' (or 'phobiCS-tui <path>') now enters browser mode:
walks up from cwd looking for mcp-mirror/, scans the project, and
renders the Browser composer with:
- readPou backed by fs.readFile(absPath)
- writeSelection backed by the atomic state-file writer (path
resolved via stateFilePath() = LOCALAPPDATA / XDG_STATE_HOME)
- onQuit unmount + exit 0
If no mcp-mirror/ is found anywhere upward, exits 1 with a
'Run mirror_export in CODESYS first' hint.
<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
<Viewer pou text scrollTop visibleRows/> renders a POU's source as
line-numbered text (4-col padding) with a bold header
'<name>.st (<kind>, <loc> L)'. Falls back to a dim '(no POU
selected)' when pou or text is null.
No syntax highlighting in v0.1 — that's intentional and tracked as
deferred. Smoke build only; render behavior is exercised through the
browser-mode integration tests in subsequent tasks.
<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.
Resolves the location of the cross-process TUI state file:
- Windows: %LOCALAPPDATA%/codesys-mcp/tui-state.json
- POSIX: $XDG_STATE_HOME/codesys-mcp/tui-state.json, falling back to
$HOME/.local/state/codesys-mcp/tui-state.json
Uses path.win32 / path.posix explicitly so the POSIX branches still
produce POSIX-flavored paths when this runs on a Windows host (test
machine), keeping the unit tests host-independent.
Walks mcp-mirror/<device>/ trees, classifies each .st as
PRG/FB/GVL/STRUCT/ENUM/METHOD/PROPERTY_GETTER/PROPERTY_SETTER/META/OTHER,
counts non-blank LOC, and returns a Project tree.
Property declarators (e.g. FB_Sweep/PropX.st alongside
FB_Sweep/PropX/Get.st) are detected via a Get.st/Set.st child probe so
they don't get classified as METHOD.