0
0
Fork 0
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-29 09:08:35 +02:00
commit e0a4685c11
6 changed files with 256 additions and 30 deletions

View file

@ -70,16 +70,16 @@ Ten new tools wrap `ScriptSymbolConfigObject` (CODESYS 3.5.10.0+) per the plan i
| # | Tool | Vitest | Live SP22 | Notes |
|---|-------------------------------|--------|-----------|-------|
| 1 | `find_symbol_config` | OK | pending | Template-prep test asserts marker-walk + path serialiser |
| 2 | `list_all_signatures` | OK | pending | `compile=true/false` both rendered |
| 3 | `list_all_datatypes` | OK | pending | Mirror of #2 over `get_all_datatypes()` |
| 4 | `list_configured_symbols` | OK | pending | Walks signatures + datatypes; serialises `configured_access` / `maximal_access` / `effective_access` per variable |
| 5 | `get_symbol_config_settings` | OK | pending | All 6 knobs + obstacle explanations + available layout calculators |
| 6 | `create_symbol_config` | OK | pending | Idempotent (refuse-with-success if already present); `application.create_symbol_config(exp, opc, guid)` |
| 7 | `set_symbol_config_settings` | OK | pending | Partial-update via per-field APPLY_* flags; refuses direct I/O if obstacles |
| 8 | `set_symbol_access` | OK | pending | Per-variable `configured_access` with enum probe + int-literal fallback |
| 9 | `set_signature_access_bulk` | OK | pending | Iterates `sig.variables`, reports `changed` + `skipped` |
|10 | `export_symbol_xsd` | OK | pending | `get_symbol_configuration_xsd()` bytes -> file (UTF-8) |
| 1 | `find_symbol_config` | OK | OK (2026-04-29) | Returned `count=0` on virgin MCPTest2, then `count=1` (`CodesysRpi/Plc Logic/Application/Symbols`) after create. |
| 2 | `list_all_signatures` | OK | partial | `compile=true` returned `count=0` on the empty-PLC_PRG MCPTest2 -- script ran cleanly, no crash, but the build short-circuited. Re-run on a project with real signatures (e.g. X33). |
| 3 | `list_all_datatypes` | OK | not run | Skipped after #8/#9/#10 destabilised the watcher. |
| 4 | `list_configured_symbols` | OK | OK (2026-04-29) | Returned `signature_count=0, datatype_count=0` on freshly-created Symbol Configuration -- expected. |
| 5 | `get_symbol_config_settings` | OK | OK (2026-04-29) | Pre-set: `SupportOPCUA, XmlIncludeComments` (= 0x20001 default for SP22). Comment filter default is `None`, **not** `Both` as the plan predicted -- plan was wrong. |
| 6 | `create_symbol_config` | OK | OK (2026-04-29) | Created `Application/Symbols`. (One CODESYS crash *immediately after* create on the first run; restart-and-retry succeeded -- see "Caveats" below.) |
| 7 | `set_symbol_config_settings` | OK | OK (2026-04-29) | Wrote `[SupportOPCUA, IncludeComments, IncludeExecutables]`; re-read showed `content_feature_flags_int=19` (configured) / `effective=3` (IncludeExecutables masked off in `effective` -- normal SP22 runtime clamp). |
| 8 | `set_symbol_access` | OK | not run | Skipped after #9 timed out the watcher. Calls `get_all_signatures(True)` on the not-yet-configured fallback path -- same instability as #9. |
| 9 | `set_signature_access_bulk` | OK | crash | `Application.PLC_PRG ReadWrite` -> 60s timeout -> CODESYS exited with 0xFFFFFFFF. Suspected: the `get_all_signatures(True)` second-fallback re-builds, and the build crashes on this project (likely related to the Pi-only IoDrvGPIO managed library). Needs a project with a real, build-clean PLC_PRG. |
|10 | `export_symbol_xsd` | OK | crash | Same failure mode as #9. `get_symbol_configuration_xsd()` may also trigger an internal build. |
**Vitest column**: `tests/integration/e2e.test.ts` -- 10 new template-prep assertions added; full suite 107/107 passing (excluding the orphan `.worktrees/phobics-tui` suite). Each test renders the script with realistic placeholders and asserts:
- no leftover `{PLACEHOLDER}` in the rendered output,
@ -87,7 +87,9 @@ Ten new tools wrap `ScriptSymbolConfigObject` (CODESYS 3.5.10.0+) per the plan i
- the helper functions are pulled in (`find_symbol_config_object`, `ensure_symbol_config`, `symbol_config_path`).
- additionally Python 3 `ast.parse` was run against every script to catch any IronPython 2.7 syntax that Py3 would also flag.
**Live SP22 column**: deferred -- the Claude Code session's MCP tool list was negotiated at session start and doesn't refresh when the MCP server registers new tools mid-session. To run the live cycle, start a new Claude Code session (the symlinked global npm package will pick up the new build automatically) and execute the round-trip from the plan:
**Live SP22 column** (updated 2026-04-29 morning): partial pass -- 5/10 verified end-to-end, 2/10 crashed, 3/10 skipped after the crashes. Symbol-Configuration *creation, settings persistence, and read-only inspection* all work cleanly on SP22 P1. The build-triggering tools (`set_signature_access_bulk`, `set_symbol_access` via fallback, `export_symbol_xsd`) need a project that builds cleanly headless -- MCPTest2's empty PLC_PRG body + IoDrvGPIO managed library cause `application.build()` (called by `get_all_signatures(True)` and likely `get_symbol_configuration_xsd()`) to abort the process with exit 0xFFFFFFFF. Plan to re-run on a project with a real, build-clean POU body (e.g. a fresh `Standard project` template under SP22 with PLC_PRG containing one assignment).
To run the live cycle from scratch on a different project, start a new Claude Code session (the symlinked global npm package will pick up the new build automatically) and execute the round-trip from the plan:
```
1. mcp__codesys__open_project MCPTest2.project
@ -110,3 +112,12 @@ The plan also calls out the `SymbolAccess` enum-value probe risk: the SP22 stub
---
*Function test executed against this fork @ HEAD `f34d002` on 2026-04-28; symbol-config tools added in `db688c2` and verified via vitest the same evening.*
---
## Symbol-config live re-run notes (2026-04-29 morning, HEAD `e948922`)
- **Crash pattern.** Three of the build-triggering tools (`get_all_signatures(True)` on a not-yet-configured Symbol Configuration, `set_signature_access_bulk` via the same fallback, and `export_symbol_xsd`) caused CODESYS to exit with 0xFFFFFFFF (NTSTATUS `STATUS_INVALID_HANDLE` / generic crash) on MCPTest2. The MCP launcher correctly reported state=error; restart-and-retry was always sufficient to recover. The crashes were *project-specific* -- `set_symbol_config_settings` flag-write succeeded on the *second* attempt with no code change between the failed and successful runs, suggesting a transient build-state race rather than a tool defect.
- **Wrapper message fix landed in `e948922`.** `add_library` no longer renders "Library 'X' added" on the dedup no-op path -- it now reads the script's `Library Already Present:` marker and renders "already referenced". Cosmetic todo from this doc closed.
- **Session ledger** (per `feedback_session_ledger.md` user rule): every CODESYS launch / shutdown / kill during this run logged to `~/.claude/projects/<id>/state/session_ledger.jsonl` so a next session knows which CODESYS.exe PIDs are mine to reclaim.
- **Symbol-config side effect on MCPTest2**: a `Symbols` object was created under `CodesysRpi/Plc Logic/Application` and `content_feature_flags=19` was persisted. `git restore` the `.project` binary, or `delete_object Application/Symbols` from a fresh session, to revert.

View file

@ -0,0 +1,116 @@
# phobiCS-tui v0.3 — inline live values
Status: **scoped, ready to implement.** The original blocker (`connect_to_device` script bug) was fixed in the SP21+ fork; see `src/scripts/connect_to_device.py:30-93`.
## Goal
When the user is browsing a POU in `phobiCS-tui` and the runtime is online, overlay each declared variable's live value inline next to its declaration in the Viewer:
```
3 counter : INT := 0; ◀ live: 47
4 bRunning : BOOL; ◀ live: TRUE
```
Updates every 500 ms while the runtime is online. Off when no runtime, no overlay.
## Architecture (one-way pump)
The TUI is the user-facing process; the MCP server is the agent-facing process. They already communicate one-way through `tui-state.json` (TUI writes, server reads via `get_user_selection`).
This adds the **other** direction:
- **Server** writes a `tui-live-values.json` snapshot next to `tui-state.json`.
- **TUI** Viewer reads it on a 500 ms timer and overlays values for the currently-displayed POU.
The server pump only runs when:
1. `--live-values` CLI flag is set on the server, AND
2. `tui-state.json` exists and is fresh (60 s window, same as `get_user_selection`), AND
3. The runtime for that POU's project is online.
## State-file shape
`tui-live-values.json` (atomic write, same `.tmp` + rename pattern as `tui-state.json`):
```json
{
"version": 1,
"updated_at": "2026-04-29T08:14:55.123Z",
"project_dir": "/abs/project/dir",
"device": "CodesysRpi",
"pou_name": "PLC_PRG",
"values": {
"counter": { "value": "47", "type": "INT", "ts": 1745916895100 },
"bRunning": { "value": "TRUE", "type": "BOOL", "ts": 1745916895100 }
}
}
```
`values` is keyed by the bare variable name (no `PLC_PRG.` prefix). The TUI matches by exact string against tokens emitted by the highlighter.
## Tasks
### Task 1: shared types + path helper
- Create `src/tui/shared/live-values.ts`: `LiveValuesPayload` interface.
- Add `liveValuesFilePath()` to `src/tui/shared/state-paths.ts` — same dir as `stateFilePath()`, filename `tui-live-values.json`.
- Tests: 4 cases mirroring the `state-paths` tests.
### Task 2: TUI atomic reader
- Create `src/tui/shared/live-values-read.ts`: `readLiveValues(filePath)` returns `{ status: 'ok' | 'missing' | 'stale' | 'invalid' }` with same `FRESHNESS_MS = 5_000` (tighter than the selection file — values stop being interesting fast).
- Tests: 4 cases (fresh / missing / stale / malformed).
### Task 3: Viewer overlay
- Add `liveValues?: Record<string, { value: string }>` prop to `<Viewer>`.
- Walk the highlighted token rows; if a row contains a `text` token whose trimmed text is a key in `liveValues`, append ` ◀ live: <value>` (in green) at end of row.
- Tests: 3 cases — no liveValues prop = no overlay; with prop = overlay appears for matching var; non-matching vars unchanged.
### Task 4: TUI live-values hook + Browser wiring
- `useLiveValues(filePath, pouName)` hook in `src/tui/browser/useLiveValues.ts`: polls every 500 ms, returns `Record<string, {value: string}> | null`. Returns `null` when payload is missing/stale or `pou_name` doesn't match the requested `pouName`.
- Browser passes `liveValues={...}` to `<Viewer>` when cursor is on a POU.
- Tests: 2 cases — match returns map; mismatch returns null.
### Task 5: server-side reader for live-values write
- Create `src/live-values-write.ts`: `writeLiveValues(filePath, payload)` mirrors `state-write.ts` (atomic `.tmp` + rename).
- Tests: 3 cases (envelope, parent dir auto-create, no .tmp residue).
### Task 6: server-side pump
- Create `src/live-values-pump.ts`:
- `class LiveValuesPump` — owns a `setInterval` (default 500 ms).
- On each tick: read `tui-state.json` via existing `readSelection`. If status != ok or selection is older than freshness, skip.
- Read the POU's declaration from the mirror (already exposed via `mcp-mirror/<device>/.../<POU>.st`).
- Parse the `VAR ... END_VAR` block to get var names. Use the existing parsing helpers if any; otherwise a minimal regex `\b([A-Za-z_]\w*)\s*:` is fine (skip lines starting with `(*` or `//`).
- For each var, call into the existing `read_variable` script via `executor.executeScript()`. Collect `{ name -> value }`.
- `writeLiveValues(...)` the payload.
- Errors are silent (debug-log only) — pump must never crash.
- `start()` / `stop()` lifecycle.
- Tests: parse VAR block from canned input; pump tick writes file with correct structure (mock executor + fs).
### Task 7: server-side wiring
- `ServerConfig.liveValues?: boolean` in `src/types.ts`.
- `bin.ts`: `--live-values` flag, plumb into config, banner `Live values: ENABLED (poll 500ms)`.
- In `startMcpServer`: if `config.liveValues`, instantiate `LiveValuesPump` and `start()` it; `stop()` in shutdown handler.
- Tests: pump starts/stops in a unit test using a fake interval.
### Task 8: README + commit
- Document `--live-values` and the inline-value display in the `## phobiCS-tui` section.
- Commit + open PR.
## Out of scope for v0.3
- Sub-property values (`PLC_PRG.fbX.bSomething`) — only top-level vars on the displayed POU.
- Write-from-TUI (changing values from the keyboard). Stays in `write_variable` MCP tool.
- Custom poll interval CLI flag — fixed at 500 ms in v0.3.
- ARRAY / STRUCT pretty-printing — show whatever `read_variable` returns as a string.
## Risk / known unknowns
- `read_variable` per var is N round-trips per tick. 500 ms × 10 vars = 50 ms/var headroom. Existing `read_variable` script might be slow enough to make this infeasible for large POUs. If so: switch to a single-script-per-tick that reads N vars in one CODESYS call.
- The CODESYS UI may pop a credential dialog mid-pump. Pump must catch and back off (longer interval after first failure, e.g. 5 s).
- TUI 500 ms file poll on Windows may cause noticeable disk I/O. Mitigate: only poll while `pou_name` is set (i.e., user is on a POU row).

View file

@ -1776,6 +1776,13 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
},
['ensure_project_open', 'ensure_online_connection']
);
const blocked = await gateOpForTool({
enabled: !!config.approveEdits,
slug: `write-${args.variablePath.replace(/[^A-Za-z0-9._-]+/g, '_')}`,
oldText: `(* live variable write *)\nvariable: ${args.variablePath}\nproject: ${args.projectFilePath}\n`,
newText: `(* live variable write *)\nvariable: ${args.variablePath}\nvalue: ${args.value}\nproject: ${args.projectFilePath}\n`,
});
if (blocked) return blocked;
const result = await executor.executeScript(script);
return formatToolResponse(
result,
@ -1808,6 +1815,13 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
},
['register_device_credentials', 'ensure_project_open', 'ensure_online_connection']
);
const blocked = await gateOpForTool({
enabled: !!config.approveEdits,
slug: `download-${args.projectFilePath.replace(/[^A-Za-z0-9._-]+/g, '_').slice(-40)}`,
oldText: '',
newText: `(* DOWNLOAD TO DEVICE *)\nproject: ${args.projectFilePath}\nWARNING: pushes the compiled application to the live PLC.\n`,
});
if (blocked) return blocked;
// Tool-side timeout = wait window + 120s headroom for the actual download
const ipcTimeoutMs = (waitSec + 120) * 1000;
const result = await executor.executeScript(script, ipcTimeoutMs);
@ -1832,6 +1846,13 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
},
['ensure_project_open', 'ensure_online_connection']
);
const blocked = await gateOpForTool({
enabled: !!config.approveEdits,
slug: `app-${args.action}`,
oldText: '',
newText: `(* PLC application ${args.action.toUpperCase()} *)\nproject: ${args.projectFilePath}\n`,
});
if (blocked) return blocked;
const result = await executor.executeScript(script);
return formatToolResponse(
result,
@ -2889,6 +2910,15 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
livenessPort?: number;
connectTimeoutMs?: number;
}) => {
const host = args.host ?? 'codesys-pi.local';
const service = args.service ?? 'codesyscontrol';
const blocked = await gateOpForTool({
enabled: !!config.approveEdits,
slug: `restart-${host.replace(/[^A-Za-z0-9._-]+/g, '_')}`,
oldText: '',
newText: `(* RESTART RUNTIME via SSH *)\nhost: ${host}\nservice: ${service}\nWARNING: kicks the live PLC runtime; running app stops momentarily.\n`,
});
if (blocked) return blocked;
try {
const res = await restartCodesysRuntime(args);
const ok = res.restartExitCode === 0 && (res.listening === true || res.listening === null);

View file

@ -1,7 +1,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import { POU } from '../shared/types.js';
import { tokenize, TokenKind } from './highlight.js';
import { Token, tokenizeLines, TokenKind } from './highlight.js';
const COLORS: Record<TokenKind, string | undefined> = {
keyword: 'cyan',
@ -11,8 +11,7 @@ const COLORS: Record<TokenKind, string | undefined> = {
text: undefined,
};
function HighlightedLine({ line }: { line: string }): React.ReactElement {
const tokens = React.useMemo(() => tokenize(line), [line]);
function HighlightedTokens({ tokens }: { tokens: Token[] }): React.ReactElement {
return (
<Text>
{tokens.map((t, i) => (
@ -40,16 +39,19 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac
);
}
const lines = text.split(/\r?\n/);
const slice = lines.slice(scrollTop, scrollTop + visibleRows);
// Tokenize from line 0 so multi-line (* ... *) state is correct, then
// slice the visible window. Cheap; line count is bounded by file size.
const allTokens = React.useMemo(() => tokenizeLines(lines), [text]);
const sliceTokens = allTokens.slice(scrollTop, scrollTop + visibleRows);
return (
<Box flexDirection="column">
<Text bold>
{pou.name}.st ({pou.kind}, {pou.loc} L)
</Text>
{slice.map((l, i) => (
{sliceTokens.map((tokens, i) => (
<Text key={scrollTop + i}>
<Text dimColor>{String(scrollTop + i + 1).padStart(4, ' ')} </Text>
<HighlightedLine line={l} />
<HighlightedTokens tokens={tokens} />
</Text>
))}
</Box>

View file

@ -42,17 +42,25 @@ const TYPES = new Set([
const IDENT_RE = /[A-Z_][A-Z0-9_]*/;
export function tokenize(line: string): Token[] {
/**
* Tokenize a single line. If `openComment` is true, the line is treated as
* starting inside a (* ... *) block; output includes a `commentLeftOpen`
* flag so callers can continue the state across lines.
*/
export interface TokenizeResult {
tokens: Token[];
/** True if the line ended without closing a (* block. */
commentLeftOpen: boolean;
}
export function tokenizeWithState(line: string, openComment: boolean): TokenizeResult {
const out: Token[] = [];
let i = 0;
let inComment = openComment;
let pending = '';
const flushPending = () => {
if (!pending) return;
// Walk pending and split at every uppercase identifier boundary, so
// identifiers (whether keyword/type or plain) come out as their own
// tokens. Anything else (whitespace, punctuation, lowercase ids) is
// emitted as 'text'.
const re = /[A-Z_][A-Z0-9_]*/g;
let last = 0;
let m: RegExpExecArray | null;
@ -68,18 +76,29 @@ export function tokenize(line: string): Token[] {
pending = '';
};
// If line started inside a comment, eat up to "*)" or end-of-line.
if (inComment) {
const end = line.indexOf('*)');
if (end < 0) {
out.push({ kind: 'comment', text: line });
return { tokens: out, commentLeftOpen: true };
}
out.push({ kind: 'comment', text: line.slice(0, end + 2) });
i = end + 2;
inComment = false;
}
while (i < line.length) {
// (* ... *) inline comment
// (* ... *) — may be inline or open-ended.
if (line[i] === '(' && line[i + 1] === '*') {
flushPending();
const end = line.indexOf('*)', i + 2);
if (end < 0) {
out.push({ kind: 'comment', text: line.slice(i) });
i = line.length;
} else {
out.push({ kind: 'comment', text: line.slice(i, end + 2) });
i = end + 2;
return { tokens: out, commentLeftOpen: true };
}
out.push({ kind: 'comment', text: line.slice(i, end + 2) });
i = end + 2;
continue;
}
// // line comment
@ -89,7 +108,7 @@ export function tokenize(line: string): Token[] {
i = line.length;
continue;
}
// 'single' or "double" strings (no escape handling beyond doubled quotes)
// 'single' or "double" strings
if (line[i] === "'" || line[i] === '"') {
flushPending();
const quote = line[i];
@ -104,6 +123,21 @@ export function tokenize(line: string): Token[] {
i++;
}
flushPending();
// Normalize: empty trailing 'text' tokens are fine; we keep them so the line's column structure is preserved.
return { tokens: out, commentLeftOpen: false };
}
export function tokenize(line: string): Token[] {
return tokenizeWithState(line, false).tokens;
}
/** Tokenize a contiguous block of lines, threading (* ... *) state across line boundaries. */
export function tokenizeLines(lines: string[]): Token[][] {
const out: Token[][] = [];
let openComment = false;
for (const line of lines) {
const r = tokenizeWithState(line, openComment);
out.push(r.tokens);
openComment = r.commentLeftOpen;
}
return out;
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { tokenize, TokenKind } from '../../src/tui/browser/highlight';
import { tokenize, tokenizeLines, TokenKind } from '../../src/tui/browser/highlight';
function kinds(line: string): TokenKind[] {
return tokenize(line).map((t) => t.kind);
@ -55,3 +55,36 @@ describe('tokenize', () => {
expect(texts('s := "hi";')).toContain('"hi"');
});
});
describe('tokenizeLines', () => {
it('treats every line of a multi-line (* ... *) block as comment', () => {
const lines = [
'x := 1; (* start',
' middle keep PROGRAM unhighlighted',
' still in comment',
'end *) y := 2;',
];
const out = tokenizeLines(lines);
// line 0: leading "x := 1; " is text/keyword-free, then "(* start" is comment
expect(out[0].some((t) => t.kind === 'comment' && t.text.includes('(* start'))).toBe(true);
// line 1: ENTIRE line should be a single comment token
expect(out[1]).toEqual([{ kind: 'comment', text: ' middle keep PROGRAM unhighlighted' }]);
// line 2: ENTIRE line is comment
expect(out[2]).toEqual([{ kind: 'comment', text: ' still in comment' }]);
// line 3: starts comment, then `*) y := 2;` is text after close
expect(out[3].some((t) => t.kind === 'comment' && t.text.startsWith('end *)'))).toBe(true);
expect(out[3].some((t) => t.kind === 'text' && t.text.includes('y := 2'))).toBe(true);
});
it('handles single-line input identically to tokenize', () => {
const single = 'PROGRAM PLC_PRG';
expect(tokenizeLines([single])).toEqual([tokenize(single)]);
});
it('keeps PROGRAM keyword highlighted on a line with no open block', () => {
const lines = ['PROGRAM A', '(* block *) END_PROGRAM'];
const out = tokenizeLines(lines);
expect(out[0].find((t) => t.text === 'PROGRAM')?.kind).toBe('keyword');
expect(out[1].find((t) => t.text === 'END_PROGRAM')?.kind).toBe('keyword');
});
});