From 91f5713cca450b61f3e955153aae999f7c44f0ec Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:14:32 +0200 Subject: [PATCH 01/11] tui(browser): add stale-mirror indicator + resize warning formatStaleness(mtimeMs) -> null | 'Xs' | 'Xm Ys' | 'Xh Ym'. 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. --- src/tui/browser/Browser.tsx | 13 +++++++-- src/tui/browser/Statusbar.tsx | 39 +++++++++++++++++++++++++++ tests/tui/Statusbar.test.tsx | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/tui/browser/Statusbar.tsx create mode 100644 tests/tui/Statusbar.test.tsx diff --git a/src/tui/browser/Browser.tsx b/src/tui/browser/Browser.tsx index d574c53..a4f6841 100644 --- a/src/tui/browser/Browser.tsx +++ b/src/tui/browser/Browser.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text, useInput, useStdout } from 'ink'; import { Project, POU, Selection } from '../shared/types.js'; import { Tree, devicePath, pouPath } from './Tree.js'; import { Viewer } from './Viewer.js'; +import { formatStaleness, ResizeWarning } from './Statusbar.js'; export interface BrowserProps { project: Project; @@ -86,9 +87,17 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro } }); + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + const termRows = stdout?.rows ?? 24; + const stale = formatStaleness(project.mirrorMtimeMs); + return ( - ─ {project.rootDir.split(/[/\\]/).pop()} ─ + + ─ {project.rootDir.split(/[/\\]/).pop()} ─{stale ? ` mirror ${stale} old ` : ' '}─ + + diff --git a/src/tui/browser/Statusbar.tsx b/src/tui/browser/Statusbar.tsx new file mode 100644 index 0000000..c97a53a --- /dev/null +++ b/src/tui/browser/Statusbar.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Text } from 'ink'; + +export const STALE_THRESHOLD_MS = 10_000; +export const MIN_COLUMNS = 80; +export const MIN_ROWS = 20; + +export function formatStaleness(mirrorMtimeMs: number): string | null { + const ageMs = Date.now() - mirrorMtimeMs; + if (ageMs < STALE_THRESHOLD_MS) return null; + + const seconds = Math.floor(ageMs / 1000); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + const remSec = seconds % 60; + return `${minutes}m ${remSec}s`; + } + + const hours = Math.floor(minutes / 60); + const remMin = minutes % 60; + return `${hours}h ${remMin}m`; +} + +export interface ResizeWarningProps { + columns: number; + rows: number; +} + +export function ResizeWarning({ columns, rows }: ResizeWarningProps): React.ReactElement | null { + if (columns < MIN_COLUMNS) { + return Terminal too narrow ({columns} cols, need {MIN_COLUMNS}+); + } + if (rows < MIN_ROWS) { + return Terminal too short ({rows} rows, need {MIN_ROWS}+); + } + return null; +} diff --git a/tests/tui/Statusbar.test.tsx b/tests/tui/Statusbar.test.tsx new file mode 100644 index 0000000..4f2892d --- /dev/null +++ b/tests/tui/Statusbar.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from 'ink-testing-library'; +import { + formatStaleness, + ResizeWarning, + STALE_THRESHOLD_MS, + MIN_COLUMNS, + MIN_ROWS, +} from '../../src/tui/browser/Statusbar.tsx'; + +describe('formatStaleness', () => { + it('returns null when mirror is fresh', () => { + expect(formatStaleness(Date.now() - 1_000)).toBeNull(); + expect(formatStaleness(Date.now() - (STALE_THRESHOLD_MS - 1))).toBeNull(); + }); + + it('returns "Xs" when mirror is between threshold and 60s old', () => { + const ageMs = Math.max(STALE_THRESHOLD_MS, 30_000); + const out = formatStaleness(Date.now() - ageMs); + expect(out).toMatch(/\d+s/); + }); + + it('returns "Xm Ys" when mirror is minutes old', () => { + const out = formatStaleness(Date.now() - 5 * 60_000 - 12_000); + expect(out).toMatch(/5m/); + }); + + it('returns "Xh Ym" when mirror is hours old', () => { + const out = formatStaleness(Date.now() - 2 * 3600_000 - 30 * 60_000); + expect(out).toMatch(/2h/); + }); +}); + +describe('', () => { + it('renders nothing when terminal is large enough', () => { + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('warns when columns are below the minimum', () => { + const { lastFrame } = render(); + expect(lastFrame()).toMatch(/Terminal too narrow/); + }); + + it('warns when rows are below the minimum', () => { + const { lastFrame } = render(); + expect(lastFrame()).toMatch(/Terminal too short/); + }); +}); From 2d73d005ef7e8f3a38771c2b81d5af008faf63ee Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:16:14 +0200 Subject: [PATCH 02/11] tui(browser): add ? help overlay (Esc to close) ? 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. --- src/tui/browser/Browser.tsx | 27 ++++++++++++++++++++++++++- tests/tui/Browser.test.tsx | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/tui/browser/Browser.tsx b/src/tui/browser/Browser.tsx index a4f6841..0dc24ab 100644 --- a/src/tui/browser/Browser.tsx +++ b/src/tui/browser/Browser.tsx @@ -36,6 +36,7 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro const [cursorIdx, setCursorIdx] = React.useState(0); const [text, setText] = React.useState(null); const [scrollTop] = React.useState(0); + const [helpOpen, setHelpOpen] = React.useState(false); const rows = React.useMemo(() => flatten(project, expanded), [project, expanded]); const cursor = rows[Math.min(cursorIdx, rows.length - 1)]; @@ -63,6 +64,14 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro }, [cursor, readPou]); useInput((input, key) => { + if (input === '?') { + setHelpOpen((v) => !v); + return; + } + if (helpOpen) { + if (key.escape) setHelpOpen(false); + return; + } if (input === 'q') return onQuit(); if (input === 'j' || key.downArrow) { setCursorIdx((i) => Math.min(i + 1, rows.length - 1)); @@ -98,6 +107,7 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro ─ {project.rootDir.split(/[/\\]/).pop()} ─{stale ? ` mirror ${stale} old ` : ' '}─ + {helpOpen && } @@ -106,7 +116,22 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro - j/k nav l expand h collapse q quit + j/k nav l expand h collapse ? help q quit + + ); +} + +function HelpOverlay(): React.ReactElement { + return ( + + Keybindings + j / ↓ move cursor down + k / ↑ move cursor up + l / → expand device + h / ← collapse device + ? toggle this help + Esc close help + q quit ); } diff --git a/tests/tui/Browser.test.tsx b/tests/tui/Browser.test.tsx index 08c0ff5..e7382d1 100644 --- a/tests/tui/Browser.test.tsx +++ b/tests/tui/Browser.test.tsx @@ -79,4 +79,41 @@ describe('', () => { await flush(); expect(onQuit).toHaveBeenCalled(); }); + + it('toggles a help overlay on ?', async () => { + const { stdin, lastFrame } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + expect(lastFrame()).not.toContain('Keybindings'); + stdin.write('?'); + await flush(); + expect(lastFrame()).toContain('Keybindings'); + stdin.write('?'); + await flush(); + expect(lastFrame()).not.toContain('Keybindings'); + }); + + it('closes the help overlay on Esc', async () => { + const { stdin, lastFrame } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + stdin.write('?'); + await flush(); + expect(lastFrame()).toContain('Keybindings'); + stdin.write(String.fromCharCode(27)); + await flush(); + expect(lastFrame()).not.toContain('Keybindings'); + }); }); From 4d7cbdb6f63ce45b69e59b611f4bbca2e6c9c79e Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:17:34 +0200 Subject: [PATCH 03/11] tui(browser): r re-scans mcp-mirror/ r calls onRescan() which re-walks the project root and rerenders the Browser with the new tree. Optional prop so tests don't have to wire it. --- src/tui/browser/Browser.tsx | 7 +++++-- src/tui/index.tsx | 17 +++++++++++++++++ tests/tui/Browser.test.tsx | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/tui/browser/Browser.tsx b/src/tui/browser/Browser.tsx index 0dc24ab..5d2ee4a 100644 --- a/src/tui/browser/Browser.tsx +++ b/src/tui/browser/Browser.tsx @@ -10,6 +10,7 @@ export interface BrowserProps { readPou: (pou: POU) => Promise; writeSelection: (s: Selection) => void; onQuit: () => void; + onRescan?: () => void; } interface FlatRow { @@ -31,7 +32,7 @@ function flatten(project: Project, expanded: Set): FlatRow[] { return rows; } -export function Browser({ project, readPou, writeSelection, onQuit }: BrowserProps): React.ReactElement { +export function Browser({ project, readPou, writeSelection, onQuit, onRescan }: BrowserProps): React.ReactElement { const [expanded, setExpanded] = React.useState>(new Set()); const [cursorIdx, setCursorIdx] = React.useState(0); const [text, setText] = React.useState(null); @@ -73,6 +74,7 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro return; } if (input === 'q') return onQuit(); + if (input === 'r' && onRescan) return onRescan(); if (input === 'j' || key.downArrow) { setCursorIdx((i) => Math.min(i + 1, rows.length - 1)); } else if (input === 'k' || key.upArrow) { @@ -116,7 +118,7 @@ export function Browser({ project, readPou, writeSelection, onQuit }: BrowserPro - j/k nav l expand h collapse ? help q quit + j/k nav l expand h collapse r rescan ? help q quit ); } @@ -129,6 +131,7 @@ function HelpOverlay(): React.ReactElement { k / ↑ move cursor up l / → expand device h / ← collapse device + r re-scan mcp-mirror/ ? toggle this help Esc close help q quit diff --git a/src/tui/index.tsx b/src/tui/index.tsx index a3d81f9..e21f44c 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -51,12 +51,29 @@ async function runBrowser(maybeRoot: string | undefined): Promise { resolve(0); }; const readPou = (pou: { absPath: string }) => fs.readFile(pou.absPath, 'utf8'); + const onRescan = async () => { + try { + const next = await walk(root); + app.rerender( + + ); + } catch (err) { + process.stderr.write(`phobiCS-tui: rescan failed: ${(err as Error).message}\n`); + } + }; const app = render( ); }); diff --git a/tests/tui/Browser.test.tsx b/tests/tui/Browser.test.tsx index e7382d1..dd53974 100644 --- a/tests/tui/Browser.test.tsx +++ b/tests/tui/Browser.test.tsx @@ -99,6 +99,23 @@ describe('', () => { expect(lastFrame()).not.toContain('Keybindings'); }); + it('calls onRescan on r', async () => { + const onRescan = vi.fn(); + const { stdin } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + onRescan={onRescan} + /> + ); + await flush(); + stdin.write('r'); + await flush(); + expect(onRescan).toHaveBeenCalled(); + }); + it('closes the help overlay on Esc', async () => { const { stdin, lastFrame } = render( Date: Tue, 28 Apr 2026 23:19:16 +0200 Subject: [PATCH 04/11] tui(browser): o opens highlighted POU in $EDITOR (or VS Code) 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). --- src/tui/browser/Browser.tsx | 9 +++++++-- src/tui/index.tsx | 12 ++++++++++++ tests/tui/Browser.test.tsx | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/tui/browser/Browser.tsx b/src/tui/browser/Browser.tsx index 5d2ee4a..316f99f 100644 --- a/src/tui/browser/Browser.tsx +++ b/src/tui/browser/Browser.tsx @@ -11,6 +11,7 @@ export interface BrowserProps { writeSelection: (s: Selection) => void; onQuit: () => void; onRescan?: () => void; + onOpenInEditor?: (absPath: string) => void; } interface FlatRow { @@ -32,7 +33,7 @@ function flatten(project: Project, expanded: Set): FlatRow[] { return rows; } -export function Browser({ project, readPou, writeSelection, onQuit, onRescan }: BrowserProps): React.ReactElement { +export function Browser({ project, readPou, writeSelection, onQuit, onRescan, onOpenInEditor }: BrowserProps): React.ReactElement { const [expanded, setExpanded] = React.useState>(new Set()); const [cursorIdx, setCursorIdx] = React.useState(0); const [text, setText] = React.useState(null); @@ -75,6 +76,9 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan }: } if (input === 'q') return onQuit(); if (input === 'r' && onRescan) return onRescan(); + if (input === 'o' && onOpenInEditor && cursor?.kind === 'pou' && cursor.pou) { + return onOpenInEditor(cursor.pou.absPath); + } if (input === 'j' || key.downArrow) { setCursorIdx((i) => Math.min(i + 1, rows.length - 1)); } else if (input === 'k' || key.upArrow) { @@ -118,7 +122,7 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan }: - j/k nav l expand h collapse r rescan ? help q quit + j/k nav l expand h collapse o open r rescan ? help q quit ); } @@ -131,6 +135,7 @@ function HelpOverlay(): React.ReactElement { k / ↑ move cursor up l / → expand device h / ← collapse device + o open highlighted POU in $EDITOR (or VS Code) r re-scan mcp-mirror/ ? toggle this help Esc close help diff --git a/src/tui/index.tsx b/src/tui/index.tsx index e21f44c..28502fd 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render } from 'ink'; import * as fs from 'fs/promises'; +import { spawn } from 'child_process'; import { Approve, Decision } from './approve/Approve.js'; import { Browser } from './browser/Browser.js'; import { walk } from './shared/scan.js'; @@ -51,6 +52,15 @@ async function runBrowser(maybeRoot: string | undefined): Promise { resolve(0); }; const readPou = (pou: { absPath: string }) => fs.readFile(pou.absPath, 'utf8'); + const onOpenInEditor = (absPath: string) => { + const editor = process.env.EDITOR || 'code'; + try { + const child = spawn(editor, [absPath], { stdio: 'ignore', detached: true, shell: true }); + child.unref(); + } catch (err) { + process.stderr.write(`phobiCS-tui: open-in-editor failed: ${(err as Error).message}\n`); + } + }; const onRescan = async () => { try { const next = await walk(root); @@ -61,6 +71,7 @@ async function runBrowser(maybeRoot: string | undefined): Promise { writeSelection={onWriteSelection} onQuit={onQuit} onRescan={onRescan} + onOpenInEditor={onOpenInEditor} /> ); } catch (err) { @@ -74,6 +85,7 @@ async function runBrowser(maybeRoot: string | undefined): Promise { writeSelection={onWriteSelection} onQuit={onQuit} onRescan={onRescan} + onOpenInEditor={onOpenInEditor} /> ); }); diff --git a/tests/tui/Browser.test.tsx b/tests/tui/Browser.test.tsx index dd53974..044101f 100644 --- a/tests/tui/Browser.test.tsx +++ b/tests/tui/Browser.test.tsx @@ -99,6 +99,44 @@ describe('', () => { expect(lastFrame()).not.toContain('Keybindings'); }); + it('calls onOpenInEditor on o with the highlighted POU absPath', async () => { + const onOpenInEditor = vi.fn(); + const { stdin } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + onOpenInEditor={onOpenInEditor} + /> + ); + await flush(); + stdin.write('l'); + await flush(); + stdin.write('j'); + await flush(); + stdin.write('o'); + await flush(); + expect(onOpenInEditor).toHaveBeenCalledWith('/abs/PLC_PRG.st'); + }); + + it('does not call onOpenInEditor when cursor is on a device row', async () => { + const onOpenInEditor = vi.fn(); + const { stdin } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + onOpenInEditor={onOpenInEditor} + /> + ); + await flush(); + stdin.write('o'); + await flush(); + expect(onOpenInEditor).not.toHaveBeenCalled(); + }); + it('calls onRescan on r', async () => { const onRescan = vi.fn(); const { stdin } = render( From f0b7852dbb35cf790e5a2907ff791a6645af2dde Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:22:19 +0200 Subject: [PATCH 05/11] tui(browser): / filter mode with live POU-name substring match / 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: ') shows above the tree, cyan while in input mode, dim once committed. --- src/tui/browser/Browser.tsx | 76 ++++++++++++++++++++++++++++++++++--- tests/tui/Browser.test.tsx | 45 ++++++++++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/tui/browser/Browser.tsx b/src/tui/browser/Browser.tsx index 316f99f..623d60c 100644 --- a/src/tui/browser/Browser.tsx +++ b/src/tui/browser/Browser.tsx @@ -21,12 +21,18 @@ interface FlatRow { pou?: POU; } -function flatten(project: Project, expanded: Set): FlatRow[] { +function flatten(project: Project, expanded: Set, filter: string): FlatRow[] { + const f = filter.toLowerCase(); const rows: FlatRow[] = []; for (const dev of project.devices) { + const matchingPous = f + ? dev.pous.filter((p) => p.name.toLowerCase().includes(f)) + : dev.pous; + if (f && matchingPous.length === 0) continue; rows.push({ path: devicePath(dev.name), kind: 'device', device: dev.name }); - if (!expanded.has(devicePath(dev.name))) continue; - for (const p of dev.pous) { + const isExpanded = f ? true : expanded.has(devicePath(dev.name)); + if (!isExpanded) continue; + for (const p of matchingPous) { rows.push({ path: pouPath(dev.name, p.relPath), kind: 'pou', device: dev.name, pou: p }); } } @@ -39,8 +45,31 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on const [text, setText] = React.useState(null); const [scrollTop] = React.useState(0); const [helpOpen, setHelpOpen] = React.useState(false); + const [filterMode, setFilterMode] = React.useState(false); + const [filter, setFilter] = React.useState(''); - const rows = React.useMemo(() => flatten(project, expanded), [project, expanded]); + const filteredProject = React.useMemo(() => { + if (!filter) return project; + const f = filter.toLowerCase(); + return { + ...project, + devices: project.devices + .map((d) => ({ ...d, pous: d.pous.filter((p) => p.name.toLowerCase().includes(f)) })) + .filter((d) => d.pous.length > 0), + }; + }, [project, filter]); + + const effectiveExpanded = React.useMemo(() => { + if (!filter) return expanded; + const next = new Set(expanded); + for (const d of filteredProject.devices) next.add(devicePath(d.name)); + return next; + }, [expanded, filter, filteredProject]); + + const rows = React.useMemo( + () => flatten(filteredProject, effectiveExpanded, ''), + [filteredProject, effectiveExpanded] + ); const cursor = rows[Math.min(cursorIdx, rows.length - 1)]; React.useEffect(() => { @@ -66,6 +95,26 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on }, [cursor, readPou]); useInput((input, key) => { + if (filterMode) { + if (key.escape) { + setFilter(''); + setFilterMode(false); + return; + } + if (key.return) { + setFilterMode(false); + return; + } + if (key.backspace || key.delete) { + setFilter((s) => s.slice(0, -1)); + return; + } + if (input && !key.ctrl && !key.meta) { + setFilter((s) => s + input); + return; + } + return; + } if (input === '?') { setHelpOpen((v) => !v); return; @@ -74,6 +123,15 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on if (key.escape) setHelpOpen(false); return; } + if (input === '/') { + setFilterMode(true); + setCursorIdx(0); + return; + } + if (key.escape && filter) { + setFilter(''); + return; + } if (input === 'q') return onQuit(); if (input === 'r' && onRescan) return onRescan(); if (input === 'o' && onOpenInEditor && cursor?.kind === 'pou' && cursor.pou) { @@ -114,15 +172,20 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on {helpOpen && } + {(filterMode || filter) && ( + + Filter: {filter}{filterMode ? '_' : ''} + + )} - + - j/k nav l expand h collapse o open r rescan ? help q quit + j/k nav l expand h collapse / filter o open r rescan ? help q quit ); } @@ -135,6 +198,7 @@ function HelpOverlay(): React.ReactElement { k / ↑ move cursor up l / → expand device h / ← collapse device + / filter POU list (Enter commits, Esc clears) o open highlighted POU in $EDITOR (or VS Code) r re-scan mcp-mirror/ ? toggle this help diff --git a/tests/tui/Browser.test.tsx b/tests/tui/Browser.test.tsx index 044101f..2977ee6 100644 --- a/tests/tui/Browser.test.tsx +++ b/tests/tui/Browser.test.tsx @@ -99,6 +99,51 @@ describe('', () => { expect(lastFrame()).not.toContain('Keybindings'); }); + it('/ enters filter mode; typed chars filter the POU list', async () => { + const { stdin, lastFrame } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + stdin.write('/'); + await flush(); + expect(lastFrame()).toMatch(/Filter:/); + stdin.write('F'); + stdin.write('B'); + await flush(); + expect(lastFrame()).toContain('FB_X'); + expect(lastFrame()).not.toContain('PLC_PRG'); + }); + + it('Esc cancels filter mode and clears the filter', async () => { + const { stdin, lastFrame } = render( + ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + stdin.write('l'); + await flush(); + stdin.write('/'); + await flush(); + stdin.write('F'); + await flush(); + stdin.write('B'); + await flush(); + expect(lastFrame()).not.toContain('PLC_PRG'); + stdin.write(String.fromCharCode(27)); + await flush(); + expect(lastFrame()).toContain('PLC_PRG'); + expect(lastFrame()).not.toMatch(/Filter:/); + }); + it('calls onOpenInEditor on o with the highlighted POU absPath', async () => { const onOpenInEditor = vi.fn(); const { stdin } = render( From 2a743c29fa212e841f3e778eee92c9ccd891bf21 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:23:45 +0200 Subject: [PATCH 06/11] tui(approve): v toggles unified <-> side-by-side diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'. --- src/tui/approve/Approve.tsx | 88 ++++++++++++++++++++++++++++++++++--- tests/tui/Approve.test.tsx | 27 ++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/tui/approve/Approve.tsx b/src/tui/approve/Approve.tsx index 3c74182..744ede7 100644 --- a/src/tui/approve/Approve.tsx +++ b/src/tui/approve/Approve.tsx @@ -16,8 +16,13 @@ export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps const hunks = React.useMemo(() => computeHunks(oldText, newText), [oldText, newText]); const adds = hunks.filter((h) => h.kind === 'add').length; const dels = hunks.filter((h) => h.kind === 'del').length; + const [sideBySide, setSideBySide] = React.useState(false); useInput((input, key) => { + if (input === 'v') { + setSideBySide((v) => !v); + return; + } if (input === 'y') return onDecision('accept'); if (input === 'n' || input === 'q' || key.escape) return onDecision('reject'); }); @@ -27,18 +32,24 @@ export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps ─ Approve change? {fileName} ─── + {adds} lines, − {dels} lines ─ - - {hunks.map((h, i) => ( - - ))} - + {sideBySide ? : } - y accept n reject q reject & quit ESC reject + y accept n reject v toggle side-by-side q reject & quit ESC reject ); } +function UnifiedView({ hunks }: { hunks: Hunk[] }): React.ReactElement { + return ( + + {hunks.map((h, i) => ( + + ))} + + ); +} + function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement { const sigil = hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' '; const color = hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined; @@ -49,3 +60,68 @@ function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement { ); } + +function pairForSideBySide(hunks: Hunk[]): Array<[Hunk | null, Hunk | null]> { + const out: Array<[Hunk | null, Hunk | null]> = []; + let i = 0; + while (i < hunks.length) { + if (hunks[i].kind === 'ctx') { + out.push([hunks[i], hunks[i]]); + i++; + continue; + } + const dels: Hunk[] = []; + const adds: Hunk[] = []; + while (i < hunks.length && hunks[i].kind !== 'ctx') { + if (hunks[i].kind === 'del') dels.push(hunks[i]); + else adds.push(hunks[i]); + i++; + } + const max = Math.max(dels.length, adds.length); + for (let j = 0; j < max; j++) { + out.push([dels[j] ?? null, adds[j] ?? null]); + } + } + return out; +} + +function SideBySide({ hunks }: { hunks: Hunk[] }): React.ReactElement { + const rows = React.useMemo(() => pairForSideBySide(hunks), [hunks]); + return ( + + {rows.map((row, i) => ( + + ))} + + ); +} + +function SideBySideRow({ left, right }: { left: Hunk | null; right: Hunk | null }): React.ReactElement { + return ( + + + + + + + + + + ); +} + +function HalfLine({ hunk, side }: { hunk: Hunk | null; side: 'left' | 'right' }): React.ReactElement { + if (!hunk) return ; + const color = + hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined; + const sigil = + hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' '; + // For ctx, show ' '; for del on left, show '-'; for add on right, show '+'. + // (Cross-cell pollution like 'add' on the left side shouldn't happen given pairForSideBySide.) + void side; + return ( + + {sigil} {String(hunk.lineNo).padStart(4, ' ')} {hunk.text} + + ); +} diff --git a/tests/tui/Approve.test.tsx b/tests/tui/Approve.test.tsx index 129ebb5..e51c58d 100644 --- a/tests/tui/Approve.test.tsx +++ b/tests/tui/Approve.test.tsx @@ -61,6 +61,33 @@ describe('', () => { expect(decision).toHaveBeenCalledWith('reject'); }); + it('toggles to side-by-side on v and shows both halves', async () => { + const { stdin, lastFrame } = render( + {}} /> + ); + await flush(); + expect(lastFrame()).not.toContain('│'); + stdin.write('v'); + await flush(); + const out = lastFrame()!; + expect(out).toContain('│'); + expect(out).toContain('counter : INT := 0;'); + expect(out).toContain('counter : DINT := 0;'); + }); + + it('toggles back to unified on a second v', async () => { + const { stdin, lastFrame } = render( + {}} /> + ); + await flush(); + stdin.write('v'); + await flush(); + expect(lastFrame()).toContain('│'); + stdin.write('v'); + await flush(); + expect(lastFrame()).not.toContain('│'); + }); + it('calls onDecision("reject") on escape', async () => { const decision = vi.fn(); const { stdin } = render( From e6d833ea1a7ccf9f06e5d5491481c147332f867e Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:26:41 +0200 Subject: [PATCH 07/11] tui(browser): d opens cross-device diff for highlighted POU 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 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. --- src/tui/browser/Browser.tsx | 146 +++++++++++++++++++++++++++++++++++- tests/tui/Browser.test.tsx | 40 +++++++++- 2 files changed, 181 insertions(+), 5 deletions(-) diff --git a/src/tui/browser/Browser.tsx b/src/tui/browser/Browser.tsx index 623d60c..920510c 100644 --- a/src/tui/browser/Browser.tsx +++ b/src/tui/browser/Browser.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { Box, Text, useInput, useStdout } from 'ink'; -import { Project, POU, Selection } from '../shared/types.js'; +import { Project, POU, Selection, Hunk } from '../shared/types.js'; import { Tree, devicePath, pouPath } from './Tree.js'; import { Viewer } from './Viewer.js'; import { formatStaleness, ResizeWarning } from './Statusbar.js'; +import { computeHunks } from '../shared/diff.js'; export interface BrowserProps { project: Project; @@ -47,6 +48,17 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on const [helpOpen, setHelpOpen] = React.useState(false); const [filterMode, setFilterMode] = React.useState(false); const [filter, setFilter] = React.useState(''); + const [crossDiff, setCrossDiff] = React.useState<{ + leftLabel: string; + rightLabel: string; + leftText: string; + rightText: string; + } | null>(null); + const [crossPicker, setCrossPicker] = React.useState<{ + pou: POU; + candidates: Array<{ device: string; pou: POU }>; + cursor: number; + } | null>(null); const filteredProject = React.useMemo(() => { if (!filter) return project; @@ -94,7 +106,73 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on }; }, [cursor, readPou]); + const openCrossDiff = React.useCallback( + async (currentDevice: string, currentPou: POU) => { + const candidates: Array<{ device: string; pou: POU }> = []; + for (const dev of project.devices) { + if (dev.name === currentDevice) continue; + const peer = dev.pous.find((p) => p.name === currentPou.name); + if (peer) candidates.push({ device: dev.name, pou: peer }); + } + if (candidates.length === 0) return; + if (candidates.length === 1) { + const peer = candidates[0]; + const [a, b] = await Promise.all([readPou(currentPou), readPou(peer.pou)]); + setCrossDiff({ + leftLabel: `${currentDevice}/${currentPou.name}`, + rightLabel: `${peer.device}/${peer.pou.name}`, + leftText: a, + rightText: b, + }); + return; + } + setCrossPicker({ pou: currentPou, candidates, cursor: 0 }); + }, + [project, readPou] + ); + useInput((input, key) => { + if (crossDiff) { + if (key.escape || input === 'q') setCrossDiff(null); + return; + } + if (crossPicker) { + if (key.escape) { + setCrossPicker(null); + return; + } + if (input === 'j' || key.downArrow) { + setCrossPicker((p) => + p ? { ...p, cursor: Math.min(p.cursor + 1, p.candidates.length - 1) } : p + ); + return; + } + if (input === 'k' || key.upArrow) { + setCrossPicker((p) => (p ? { ...p, cursor: Math.max(p.cursor - 1, 0) } : p)); + return; + } + if (key.return) { + const peer = crossPicker.candidates[crossPicker.cursor]; + const cur = cursor; + if (cur?.kind === 'pou' && cur.pou) { + const currentDevice = cur.device; + const currentPou = cur.pou; + Promise.all([readPou(currentPou), readPou(peer.pou)]) + .then(([a, b]) => { + setCrossDiff({ + leftLabel: `${currentDevice}/${currentPou.name}`, + rightLabel: `${peer.device}/${peer.pou.name}`, + leftText: a, + rightText: b, + }); + setCrossPicker(null); + }) + .catch(() => setCrossPicker(null)); + } + return; + } + return; + } if (filterMode) { if (key.escape) { setFilter(''); @@ -137,6 +215,10 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on if (input === 'o' && onOpenInEditor && cursor?.kind === 'pou' && cursor.pou) { return onOpenInEditor(cursor.pou.absPath); } + if (input === 'd' && cursor?.kind === 'pou' && cursor.pou) { + void openCrossDiff(cursor.device, cursor.pou); + return; + } if (input === 'j' || key.downArrow) { setCursorIdx((i) => Math.min(i + 1, rows.length - 1)); } else if (input === 'k' || key.upArrow) { @@ -172,6 +254,15 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on {helpOpen && } + {crossDiff && ( + + )} + {crossPicker && } {(filterMode || filter) && ( Filter: {filter}{filterMode ? '_' : ''} @@ -185,7 +276,57 @@ export function Browser({ project, readPou, writeSelection, onQuit, onRescan, on - j/k nav l expand h collapse / filter o open r rescan ? help q quit + j/k nav l expand h collapse / filter o open d cross-diff r rescan ? help q quit + + ); +} + +function CrossPicker({ + state, +}: { + state: { pou: POU; candidates: Array<{ device: string; pou: POU }>; cursor: number }; +}): React.ReactElement { + return ( + + Cross-device diff: pick peer for {state.pou.name} + {state.candidates.map((c, i) => ( + + {i === state.cursor ? '▶ ' : ' '} + {c.device}/{c.pou.name} + + ))} + j/k pick Enter open Esc cancel + + ); +} + +function CrossDeviceDiff({ + leftLabel, + rightLabel, + leftText, + rightText, +}: { + leftLabel: string; + rightLabel: string; + leftText: string; + rightText: string; +}): React.ReactElement { + const hunks = React.useMemo(() => computeHunks(leftText, rightText), [leftText, rightText]); + const adds = hunks.filter((h) => h.kind === 'add').length; + const dels = hunks.filter((h) => h.kind === 'del').length; + return ( + + Cross-device diff: {leftLabel} → {rightLabel} (+{adds} −{dels}) + {hunks.map((h, i) => { + const sigil = h.kind === 'add' ? '+' : h.kind === 'del' ? '-' : ' '; + const color = h.kind === 'add' ? 'green' : h.kind === 'del' ? 'red' : undefined; + return ( + + {sigil} {String(h.lineNo).padStart(4, ' ')} {h.text} + + ); + })} + q / Esc close ); } @@ -200,6 +341,7 @@ function HelpOverlay(): React.ReactElement { h / ← collapse device / filter POU list (Enter commits, Esc clears) o open highlighted POU in $EDITOR (or VS Code) + d diff highlighted POU against same-named POU in another device r re-scan mcp-mirror/ ? toggle this help Esc close help diff --git a/tests/tui/Browser.test.tsx b/tests/tui/Browser.test.tsx index 2977ee6..e1b07d6 100644 --- a/tests/tui/Browser.test.tsx +++ b/tests/tui/Browser.test.tsx @@ -11,8 +11,14 @@ const project: Project = { { name: 'D1', pous: [ - { name: 'PLC_PRG', kind: 'PRG', relPath: 'PLC_PRG.st', absPath: '/abs/PLC_PRG.st', loc: 5, mtimeMs: 0 }, - { name: 'FB_X', kind: 'FB', relPath: 'FB_X.st', absPath: '/abs/FB_X.st', loc: 9, mtimeMs: 0 }, + { name: 'PLC_PRG', kind: 'PRG', relPath: 'PLC_PRG.st', absPath: '/abs/D1/PLC_PRG.st', loc: 5, mtimeMs: 0 }, + { name: 'FB_X', kind: 'FB', relPath: 'FB_X.st', absPath: '/abs/D1/FB_X.st', loc: 9, mtimeMs: 0 }, + ], + }, + { + name: 'D2', + pous: [ + { name: 'PLC_PRG', kind: 'PRG', relPath: 'PLC_PRG.st', absPath: '/abs/D2/PLC_PRG.st', loc: 5, mtimeMs: 0 }, ], }, ], @@ -80,6 +86,34 @@ describe('', () => { expect(onQuit).toHaveBeenCalled(); }); + it('d on a POU with one same-name peer in another device opens the cross-device diff', async () => { + const reads: Record = { + '/abs/D1/PLC_PRG.st': 'PROGRAM PLC_PRG\nVAR\n v : INT;\nEND_VAR', + '/abs/D2/PLC_PRG.st': 'PROGRAM PLC_PRG\nVAR\n v : DINT;\nEND_VAR', + }; + const { stdin, lastFrame } = render( + reads[pou.absPath] ?? ''} + writeSelection={() => {}} + onQuit={() => {}} + /> + ); + await flush(); + stdin.write('l'); + await flush(); + stdin.write('j'); + await flush(); + stdin.write('d'); + await flush(); + // wait for both readPou promises to settle + await new Promise((r) => setTimeout(r, 50)); + const out = lastFrame()!; + expect(out).toMatch(/Cross-device diff/); + expect(out).toContain('D1'); + expect(out).toContain('D2'); + }); + it('toggles a help overlay on ?', async () => { const { stdin, lastFrame } = render( ', () => { await flush(); stdin.write('o'); await flush(); - expect(onOpenInEditor).toHaveBeenCalledWith('/abs/PLC_PRG.st'); + expect(onOpenInEditor).toHaveBeenCalledWith('/abs/D1/PLC_PRG.st'); }); it('does not call onOpenInEditor when cursor is on a device row', async () => { From 3da09f8aba137102bb76ee83fe41580682e319c5 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:29:40 +0200 Subject: [PATCH 08/11] tui(viewer): ST keyword/type/comment/string syntax highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/tui/browser/Viewer.tsx | 25 +++++++- src/tui/browser/highlight.ts | 109 +++++++++++++++++++++++++++++++++++ tests/tui/highlight.test.ts | 57 ++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/tui/browser/highlight.ts create mode 100644 tests/tui/highlight.test.ts diff --git a/src/tui/browser/Viewer.tsx b/src/tui/browser/Viewer.tsx index a2cc331..657d121 100644 --- a/src/tui/browser/Viewer.tsx +++ b/src/tui/browser/Viewer.tsx @@ -1,6 +1,28 @@ import React from 'react'; import { Box, Text } from 'ink'; import { POU } from '../shared/types.js'; +import { tokenize, TokenKind } from './highlight.js'; + +const COLORS: Record = { + keyword: 'cyan', + type: 'magenta', + comment: 'gray', + string: 'yellow', + text: undefined, +}; + +function HighlightedLine({ line }: { line: string }): React.ReactElement { + const tokens = React.useMemo(() => tokenize(line), [line]); + return ( + + {tokens.map((t, i) => ( + + {t.text} + + ))} + + ); +} export interface ViewerProps { pou: POU | null; @@ -26,7 +48,8 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac {slice.map((l, i) => ( - {String(scrollTop + i + 1).padStart(4, ' ')} {l} + {String(scrollTop + i + 1).padStart(4, ' ')} + ))} diff --git a/src/tui/browser/highlight.ts b/src/tui/browser/highlight.ts new file mode 100644 index 0000000..61dccb6 --- /dev/null +++ b/src/tui/browser/highlight.ts @@ -0,0 +1,109 @@ +export type TokenKind = 'keyword' | 'type' | 'comment' | 'string' | 'text'; + +export interface Token { + kind: TokenKind; + text: string; +} + +const KEYWORDS = new Set([ + 'PROGRAM', 'END_PROGRAM', + 'FUNCTION', 'END_FUNCTION', + 'FUNCTION_BLOCK', 'END_FUNCTION_BLOCK', + 'METHOD', 'END_METHOD', + 'PROPERTY', 'END_PROPERTY', + 'INTERFACE', 'END_INTERFACE', + 'STRUCT', 'END_STRUCT', + 'TYPE', 'END_TYPE', + 'ACTION', 'END_ACTION', + 'VAR', 'VAR_INPUT', 'VAR_OUTPUT', 'VAR_IN_OUT', 'VAR_GLOBAL', 'VAR_TEMP', 'VAR_CONFIG', 'VAR_EXTERNAL', 'VAR_STAT', 'END_VAR', + 'IF', 'THEN', 'ELSIF', 'ELSE', 'END_IF', + 'CASE', 'OF', 'END_CASE', + 'FOR', 'TO', 'BY', 'DO', 'END_FOR', + 'WHILE', 'END_WHILE', + 'REPEAT', 'UNTIL', 'END_REPEAT', + 'RETURN', 'EXIT', 'CONTINUE', + 'AND', 'OR', 'XOR', 'NOT', 'MOD', + 'TRUE', 'FALSE', + 'CONSTANT', 'RETAIN', 'PERSISTENT', 'ABSTRACT', 'FINAL', 'PUBLIC', 'PRIVATE', 'PROTECTED', 'INTERNAL', + 'EXTENDS', 'IMPLEMENTS', 'GET', 'SET', 'REFERENCE', 'POINTER', 'ARRAY', + 'WITH', 'AT', + 'SUPER', 'THIS', +]); + +const TYPES = new Set([ + 'BOOL', 'BYTE', 'WORD', 'DWORD', 'LWORD', + 'SINT', 'INT', 'DINT', 'LINT', + 'USINT', 'UINT', 'UDINT', 'ULINT', + 'REAL', 'LREAL', + 'TIME', 'LTIME', 'DATE', 'TIME_OF_DAY', 'TOD', 'LTOD', 'DATE_AND_TIME', 'DT', 'LDT', + 'STRING', 'WSTRING', 'CHAR', 'WCHAR', + 'ANY', 'ANY_BIT', 'ANY_INT', 'ANY_NUM', 'ANY_REAL', 'ANY_STRING', +]); + +const IDENT_RE = /[A-Z_][A-Z0-9_]*/; + +export function tokenize(line: string): Token[] { + const out: Token[] = []; + let i = 0; + 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; + while ((m = re.exec(pending)) !== null) { + const word = m[0]; + const start = m.index; + if (start > last) out.push({ kind: 'text', text: pending.slice(last, start) }); + const kind: TokenKind = KEYWORDS.has(word) ? 'keyword' : TYPES.has(word) ? 'type' : 'text'; + out.push({ kind, text: word }); + last = start + word.length; + } + if (last < pending.length) out.push({ kind: 'text', text: pending.slice(last) }); + pending = ''; + }; + + while (i < line.length) { + // (* ... *) inline comment + 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; + } + continue; + } + // // line comment + if (line[i] === '/' && line[i + 1] === '/') { + flushPending(); + out.push({ kind: 'comment', text: line.slice(i) }); + i = line.length; + continue; + } + // 'single' or "double" strings (no escape handling beyond doubled quotes) + if (line[i] === "'" || line[i] === '"') { + flushPending(); + const quote = line[i]; + let end = i + 1; + while (end < line.length && line[end] !== quote) end++; + const close = end < line.length ? end + 1 : end; + out.push({ kind: 'string', text: line.slice(i, close) }); + i = close; + continue; + } + pending += line[i]; + i++; + } + flushPending(); + // Normalize: empty trailing 'text' tokens are fine; we keep them so the line's column structure is preserved. + return out; +} diff --git a/tests/tui/highlight.test.ts b/tests/tui/highlight.test.ts new file mode 100644 index 0000000..b0f7b73 --- /dev/null +++ b/tests/tui/highlight.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { tokenize, TokenKind } from '../../src/tui/browser/highlight'; + +function kinds(line: string): TokenKind[] { + return tokenize(line).map((t) => t.kind); +} + +function texts(line: string): string[] { + return tokenize(line).map((t) => t.text); +} + +describe('tokenize', () => { + it('returns a single text token for plain identifiers', () => { + expect(tokenize('foo bar baz')).toEqual([{ kind: 'text', text: 'foo bar baz' }]); + }); + + it('flags ST keywords (uppercase)', () => { + const ts = tokenize('PROGRAM PLC_PRG'); + expect(ts.find((t) => t.text === 'PROGRAM')?.kind).toBe('keyword'); + expect(ts.find((t) => t.text === 'PLC_PRG')?.kind).toBe('text'); + }); + + it('flags END_VAR / END_IF compound keywords', () => { + const ts = tokenize('END_VAR END_IF END_FOR'); + expect(ts.filter((t) => t.kind === 'keyword').map((t) => t.text)).toEqual([ + 'END_VAR', + 'END_IF', + 'END_FOR', + ]); + }); + + it('flags IEC types', () => { + const ts = tokenize('counter : INT := 0;'); + expect(ts.find((t) => t.text === 'INT')?.kind).toBe('type'); + }); + + it('does not flag lowercase variants of keywords', () => { + const ts = tokenize('program plc_prg'); + expect(ts.every((t) => t.kind !== 'keyword')).toBe(true); + }); + + it('captures (* ... *) comments inline', () => { + expect(kinds('x := 1; (* a comment *) y := 2;')).toContain('comment'); + expect(texts('x := 1; (* a comment *) y := 2;')).toContain('(* a comment *)'); + }); + + it('captures // line comments to end of line', () => { + const ts = tokenize('x := 1; // trailing'); + expect(ts.at(-1)?.kind).toBe('comment'); + expect(ts.at(-1)?.text).toBe('// trailing'); + }); + + it("captures 'single-quoted' and \"double-quoted\" strings", () => { + expect(texts("s := 'hello';")).toContain("'hello'"); + expect(texts('s := "hi";')).toContain('"hi"'); + }); +}); From e1966c7e835371f91b3dded3b98e78ec52f9ee21 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:34:27 +0200 Subject: [PATCH 09/11] feat(approve-gate): wire 9 modifying tools through phobiCS-tui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/approve-gate.ts | 73 +++++++++++++++++++++++++++++++++ src/server.ts | 66 ++++++++++++++++++++++++++++- tests/unit/approve-gate.test.ts | 55 ++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/approve-gate.ts b/src/approve-gate.ts index 8806501..034395b 100644 --- a/src/approve-gate.ts +++ b/src/approve-gate.ts @@ -1,4 +1,5 @@ import * as fs from 'fs/promises'; +import * as os from 'os'; import * as path from 'path'; import { spawn } from 'child_process'; @@ -93,6 +94,78 @@ export interface RunGateOpts { spawnFn?: typeof spawnApproveTui; } +/** + * Generic approve-gate for tools that don't have a single existing-file -> proposed-file + * mapping. Renders an arbitrary "before" + "after" snapshot through the TUI's approve + * mode, with a descriptive temp-file name conveying the operation. + * + * For pure-create ops, oldText is ''. For pure-delete ops, newText is ''. For rename, + * both sides are short identifier names. The diff display always communicates the op + * intent visually (all-green for create, all-red for delete, single line each for rename). + */ +export interface ApproveGateOp { + /** Short slug used as the synthetic filename — drives what the user sees as ".st". */ + slug: string; + /** Pre-state. Empty string for create operations. */ + oldText: string; + /** Post-state. Empty string for delete operations. */ + newText: string; + /** Test-only override for the spawn function. */ + spawnFn?: typeof spawnApproveTui; +} + +export async function runApproveGateOp(op: ApproveGateOp): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-gate-')); + const safeSlug = op.slug.replace(/[^A-Za-z0-9._-]+/g, '_'); + const oldPath = path.join(dir, `${safeSlug}.before.st`); + const newPath = path.join(dir, `${safeSlug}.after.st`); + await fs.writeFile(oldPath, op.oldText, 'utf8'); + await fs.writeFile(newPath, op.newText, 'utf8'); + try { + const spawnFn = op.spawnFn ?? spawnApproveTui; + return await spawnFn(oldPath, newPath); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** + * MCP-tool-shaped wrapper around runApproveGateOp. + * + * Returns null when the operation should proceed (gate disabled, accepted, or + * mirror file missing for a non-set_pou_code op). Returns an MCP-shaped + * response object when the call should short-circuit (rejected -> isError=false + * with the user-rejection message; error -> isError=true with the spawn error). + */ +export interface BlockResponse { + content: Array<{ type: 'text'; text: string }>; + isError: boolean; +} + +export async function gateOpForTool(opts: { + enabled: boolean; + slug: string; + oldText: string; + newText: string; + spawnFn?: typeof spawnApproveTui; +}): Promise { + if (!opts.enabled) return null; + const gate = await runApproveGateOp({ + slug: opts.slug, + oldText: opts.oldText, + newText: opts.newText, + spawnFn: opts.spawnFn, + }); + if (gate.status === 'accepted' || gate.status === 'no-existing') return null; + if (gate.status === 'rejected') { + return { content: [{ type: 'text', text: gate.message }], isError: false }; + } + return { + content: [{ type: 'text', text: `Approve gate error: ${gate.message}` }], + isError: true, + }; +} + export async function runApproveGate(opts: RunGateOpts): Promise { const existingPath = await findMirrorFile(opts.projectFilePath, opts.pouPath); if (!existingPath) { diff --git a/src/server.ts b/src/server.ts index 1df4f1c..329380e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,7 +26,7 @@ import { inspectProjectFile } from './inspect'; import { parseProfileName } from './detect'; import { decideOpenProjectPreflight } from './preflight'; import { readSelection } from './state-read'; -import { runApproveGate } from './approve-gate'; +import { runApproveGate, gateOpForTool } from './approve-gate'; /** * Classifier for `bump_project_version --level=auto`. @@ -1046,6 +1046,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-pou-${sanParentPath.replace(/[/\\]/g, '_')}-${args.name}`, + oldText: '', + newText: `(* create POU *)\nname: ${args.name}\ntype: ${args.type}\nlanguage: ${args.language}\nparent: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1162,6 +1169,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-property-${sanParentPath.replace(/[/\\]/g, '_')}-${args.propertyName}`, + oldText: '', + newText: `(* create Property *)\nname: ${args.propertyName}\ntype: ${args.propertyType}\nparent FB: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1194,6 +1208,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-method-${sanParentPath.replace(/[/\\]/g, '_')}-${args.methodName}`, + oldText: '', + newText: `(* create Method *)\nname: ${args.methodName}\nreturn type: ${args.returnType ?? '(none)'}\nparent FB: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1363,6 +1384,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-dut-${sanParentPath.replace(/[/\\]/g, '_')}-${args.name}`, + oldText: '', + newText: `(* create DUT *)\nname: ${args.name}\ntype: ${args.dutType}\nparent: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1408,6 +1436,14 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-gvl-${sanParentPath.replace(/[/\\]/g, '_')}-${args.name}`, + oldText: '', + newText: `(* create GVL *)\nname: ${args.name}\nparent: ${sanParentPath}\n` + + (args.declarationCode ? `\nVAR_GLOBAL\n${args.declarationCode}\nEND_VAR\n` : ''), + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1438,6 +1474,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `create-folder-${sanParentPath.replace(/[/\\]/g, '_')}-${args.folderName}`, + oldText: '', + newText: `(* create Folder *)\nname: ${args.folderName}\nparent: ${sanParentPath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1466,6 +1509,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `delete-${sanObjPath.replace(/[/\\]/g, '_')}`, + oldText: `(* DELETE *)\nobject: ${sanObjPath}\nproject: ${args.projectFilePath}\n`, + newText: '', + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1501,6 +1551,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'find_object_by_path'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `rename-${sanObjPath.replace(/[/\\]/g, '_')}`, + oldText: `(* old name *)\n${sanObjPath}\n`, + newText: `(* new name *)\n${sanObjPath.replace(/[^/\\]+$/, args.newName)}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, @@ -1969,6 +2026,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `add-library-${args.libraryName.replace(/[^A-Za-z0-9._-]+/g, '_')}`, + oldText: '', + newText: `(* add Library *)\nname: ${args.libraryName}\nproject: ${args.projectFilePath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return await formatModifyingResponse( result, diff --git a/tests/unit/approve-gate.test.ts b/tests/unit/approve-gate.test.ts index dd4d3bc..a08f30a 100644 --- a/tests/unit/approve-gate.test.ts +++ b/tests/unit/approve-gate.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; -import { composeMergedContent, IMPL_SENTINEL } from '../../src/approve-gate'; +import { describe, it, expect, vi } from 'vitest'; +import * as fs from 'fs/promises'; +import { composeMergedContent, IMPL_SENTINEL, runApproveGateOp } from '../../src/approve-gate'; const existing = [ 'PROGRAM PLC_PRG', @@ -47,3 +48,53 @@ describe('composeMergedContent', () => { expect(out).toContain('x := 1;'); }); }); + +describe('runApproveGateOp', () => { + it('writes before/after files and spawns the TUI with both paths', async () => { + const captured: { existing?: string; staged?: string } = {}; + const spawnFn = vi.fn(async (existingPath: string, stagedPath: string) => { + captured.existing = existingPath; + captured.staged = stagedPath; + const oldText = await fs.readFile(existingPath, 'utf8'); + const newText = await fs.readFile(stagedPath, 'utf8'); + expect(oldText).toBe('OLD'); + expect(newText).toBe('NEW'); + return { status: 'accepted' as const }; + }); + const result = await runApproveGateOp({ + slug: 'create-Application/MyFB', + oldText: 'OLD', + newText: 'NEW', + spawnFn, + }); + expect(result.status).toBe('accepted'); + expect(spawnFn).toHaveBeenCalledOnce(); + }); + + it('passes the rejected status through', async () => { + const spawnFn = vi.fn(async () => ({ + status: 'rejected' as const, + message: 'nope', + })); + const result = await runApproveGateOp({ + slug: 'delete-Foo', + oldText: 'something', + newText: '', + spawnFn, + }); + expect(result.status).toBe('rejected'); + }); + + it('cleans up the temp dir after the spawn returns', async () => { + let dirSeen: string | undefined; + const spawnFn = vi.fn(async (existingPath: string) => { + dirSeen = existingPath.replace(/[\\/][^\\/]+$/, ''); + // dir should exist while we're in here + await fs.access(dirSeen); + return { status: 'accepted' as const }; + }); + await runApproveGateOp({ slug: 'x', oldText: '', newText: 'Y', spawnFn }); + // After: the dir must be gone + await expect(fs.access(dirSeen!)).rejects.toThrow(); + }); +}); From 6d5d9b03858d1aef17a716547da9da9db5d50b6e Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:35:01 +0200 Subject: [PATCH 10/11] docs: README for v0.2 followup (TUI keybinds + 9-tool gating) Updates the phobiCS-tui section to cover the new keybinds (/, o, d, r, ?, v) and documents that --approve-edits now gates all 9 modifying MCP tools (create_pou, create_property, create_method, create_dut, create_gvl, create_folder, delete_object, rename_object, add_library) on top of the existing set_pou_code. Also notes the Viewer syntax highlighting and the statusbar's mirror-staleness indicator + small-terminal resize warning. --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d0a79b8..fb736cd 100644 --- a/README.md +++ b/README.md @@ -297,13 +297,15 @@ This package ships a small ink TUI for browsing CODESYS-exported ST. After insta phobiCS-tui # explicit project directory phobiCS-tui approve # diff prompt; exit 0 = accept, 1 = reject, 2 = error -Browser-mode keys: `j`/`k` (or `↓`/`↑`) move the cursor, `l`/`Enter`/`→` expand a device, `h`/`←` collapse, `q` quits. +Browser-mode keys: `j`/`k` (or `↓`/`↑`) move the cursor, `l`/`Enter`/`→` expand a device, `h`/`←` collapse, `/` filter POUs by name (Enter commits, Esc clears), `o` open the highlighted POU in `$EDITOR` (or VS Code), `d` diff against the same-named POU in another device, `r` re-scan `mcp-mirror/`, `?` toggle the help overlay, `q` quits. -Approve-mode keys: `y` accept, `n`/`q`/`Esc` reject. +Approve-mode keys: `y` accept, `n`/`q`/`Esc` reject, `v` toggle unified ↔ side-by-side diff. -The browser writes the current selection to `%LOCALAPPDATA%/codesys-mcp/tui-state.json` (Windows) or `$XDG_STATE_HOME/codesys-mcp/tui-state.json` (Linux/Mac, defaulting to `~/.local/state/...`). The MCP tool `get_user_selection` reads it so an agent can ground its actions in what the user is looking at. +The browser writes the current selection to `%LOCALAPPDATA%/codesys-mcp/tui-state.json` (Windows) or `$XDG_STATE_HOME/codesys-mcp/tui-state.json` (Linux/Mac, defaulting to `~/.local/state/...`). The MCP tool `get_user_selection` reads it so an agent can ground its actions in what the user is looking at. The header shows mirror staleness when the on-disk export is older than 10 s, and a yellow resize warning appears below 80×20. -Approve mode is opt-in for the MCP server's modifying tools — start the server with `--approve-edits` to wire it in. v0.1 gates only `set_pou_code`; the rest of the modifying tools land in a follow-up. Off by default. +The Viewer applies ST syntax highlighting (cyan keywords, magenta types, gray comments, yellow strings). + +Approve mode is opt-in for the MCP server's modifying tools — start the server with `--approve-edits` to wire it in. The v0.2 followup gates **all 9 modifying tools**: `create_pou`, `create_property`, `create_method`, `create_dut`, `create_gvl`, `create_folder`, `delete_object`, `rename_object`, `add_library` — plus the original `set_pou_code`. Each operation pops a y/n diff prompt; create/delete render as all-green/all-red one-sided diffs, rename as a del+add of the leaf name, and `set_pou_code` as a real diff against the existing mirror file. Off by default. ## MCP Tools From 7e427e93c5ef0066f213ac3af1a95edc84985f39 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:37:19 +0200 Subject: [PATCH 11/11] release: v0.7.0 -- phobiCS-tui v0.2 (TUI keybinds + 9-tool approve-gate) 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. --- package-lock.json | 7 ++++--- package.json | 2 +- src/tui/index.tsx | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index c5de1d0..014ff3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codesys-mcp-sp21-plus", - "version": "0.5.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codesys-mcp-sp21-plus", - "version": "0.5.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", @@ -18,7 +18,8 @@ "zod": "^3.24.3" }, "bin": { - "codesys-mcp-sp21-plus": "dist/bin.js" + "codesys-mcp-sp21-plus": "dist/bin.js", + "phobiCS-tui": "dist/tui/index.js" }, "devDependencies": { "@types/diff": "^5.2.3", diff --git a/package.json b/package.json index 17c0df6..4ddae06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesys-mcp-sp21-plus", - "version": "0.6.4", + "version": "0.7.0", "description": "Codesys-MCP-SP21+ -- fork of luke-harriman/Codesys-MCP carrying CODESYS V3.5 SP22 Patch 1 fixes (and forward-compat with later SPs): script-engine API drift, online/runtime tool auto-login, dual-SHA release classifier, set_pou_code omitted-decl wipe fix, add_library managed-overload, etc. MCP server for CODESYS with persistent UI instance and file-based IPC.", "main": "dist/server.js", "bin": { diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 28502fd..7944523 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -14,7 +14,7 @@ const argv = process.argv.slice(2); async function main(): Promise { if (argv[0] === '--version' || argv[0] === '-v') { - process.stdout.write('phobiCS-tui v0.1.0\n'); + process.stdout.write('phobiCS-tui v0.2.0\n'); return 0; } if (argv[0] === 'approve') return runApprove(argv[1], argv[2]);