diff --git a/.gitignore b/.gitignore index 28a5490..1996159 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ dist # Local scratch notes (do not commit) docs/wip/ +refs diff --git a/Makefile b/Makefile index f4eb90b..9101d1f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -.PHONY: all build test tests test-unit test-integration test-at test-branch test-all typecheck clean install uninstall dev help +.PHONY: all build test tests test-unit test-integration test-at test-branch test-all typecheck clean install install-user uninstall uninstall-user dev help BIN := roboppi @@ -15,6 +15,12 @@ BUN_FLAGS ?= # Extra flags passed to `bun build` (e.g. BUN_BUILD_FLAGS=--compile-exec-argv=--smol) BUN_BUILD_FLAGS ?= +# Install locations (override like: make install PREFIX=/opt) +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +INSTALL ?= install +SUDO ?= sudo + all: build ## Build the binary (default) build: ## Build a single binary (roboppi) @@ -46,11 +52,27 @@ typecheck: ## Run TypeScript type checker clean: ## Remove build artifacts rm -f $(BIN) $(EXTRA_BINS) -install: build ## Install binary to /usr/local/bin - install -m 755 $(BIN) /usr/local/bin/$(BIN) - -uninstall: ## Remove binary from /usr/local/bin - rm -f /usr/local/bin/$(BIN) $(addprefix /usr/local/bin/,$(EXTRA_BINS)) +install: build ## Install binary to PREFIX/bin (default: /usr/local/bin; uses sudo if needed) + @if [ -w "$(BINDIR)" ] || [ -w "$(PREFIX)" ]; then \ + mkdir -p "$(BINDIR)"; \ + $(INSTALL) -m 755 "$(BIN)" "$(BINDIR)/$(BIN)"; \ + else \ + $(SUDO) mkdir -p "$(BINDIR)"; \ + $(SUDO) $(INSTALL) -m 755 "$(BIN)" "$(BINDIR)/$(BIN)"; \ + fi + +install-user: build ## Install binary to ~/.local/bin + @$(MAKE) install PREFIX="$(HOME)/.local" + +uninstall: ## Remove binary from PREFIX/bin (uses sudo if needed) + @if [ -w "$(BINDIR)" ]; then \ + rm -f "$(BINDIR)/$(BIN)" $(addprefix $(BINDIR)/,$(EXTRA_BINS)); \ + else \ + $(SUDO) rm -f "$(BINDIR)/$(BIN)" $(addprefix $(BINDIR)/,$(EXTRA_BINS)); \ + fi + +uninstall-user: ## Remove binary from ~/.local/bin + @$(MAKE) uninstall PREFIX="$(HOME)/.local" dev: ## Run in dev mode (no build) $(BUN) $(BUN_FLAGS) run $(SRC) diff --git a/bun.lock b/bun.lock index 5fa6d88..833da2c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "roboppi", "dependencies": { + "@opentui/core": "0.1.80", "yaml": "^2.8.2", }, "devDependencies": { @@ -14,16 +15,200 @@ }, }, "packages": { + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + + "@opentui/core": ["@opentui/core@0.1.80", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.80", "@opentui/core-darwin-x64": "0.1.80", "@opentui/core-linux-arm64": "0.1.80", "@opentui/core-linux-x64": "0.1.80", "@opentui/core-win32-arm64": "0.1.80", "@opentui/core-win32-x64": "0.1.80", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-fkgk8XiO1pauTrWzh3uRL4JNYGPINegv/JoTa1POygV9Rdwu0i4KseicwtDtvwEp6q7p+OdspmNqi6/DqPYqEQ=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.80", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B1KXimgNzwXY9KS/fZqAnUOj1TSmMmfmm6BJKZwXM65S2hYyuHeZ/E7qN6e4Wqr3dVhzMxH06cR92qf30Qs2QA=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.80", "", { "os": "darwin", "cpu": "x64" }, "sha512-gthbIutmy4VmeOiMacUVTVkB0N/Uc4bLU1Yy2hM23RoJBmfiMgDuf+tpCTYKaJpg4UmWHGP4Q11CNSGCNeNW4g=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.80", "", { "os": "linux", "cpu": "arm64" }, "sha512-tfGq8BDF144CKKAZP97kXmZYBOKCz2Q1XDTpzz6doxHUy5/4pLc0TnJEOZj/Vhzq37wBoG6TGRrCXgHvDAps1Q=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.80", "", { "os": "linux", "cpu": "x64" }, "sha512-IrlHbzSUy/p6GWelToOkk/PG5qh7haLL+dr/vL83pdSGfh+eJIBHta+EjycvBGr5SxVzCVJFmXUi4QZ47S+o2w=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.80", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZgXAClJSc7GgaTftWcmyO5psMYsO+S8XHnxwGm0rXhs/bAz52kH46wKO8wfPAfqUsIYpe1UGgaAejv104w7yEQ=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.80", "", { "os": "win32", "cpu": "x64" }, "sha512-8qGyKHfiU/VOYhfER7YWuIQDb2XBfpiyMkUjdwXYPYIPJ0A0vcAulg9CPNrRb+hzzAthH5nmX8gnu8yPsE/n4w=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="], + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="], + + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="], + + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="], + + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + + "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], } } diff --git a/package.json b/package.json index 594f781..998006d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "typescript": "^5.7.0" }, "dependencies": { + "@opentui/core": "0.1.80", "yaml": "^2.8.2" } } diff --git a/src/cli.ts b/src/cli.ts index 08d6482..ca7d1e9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import type { AgentCoreConfig } from "./core/agentcore.js"; import type { LogLevel } from "./core/observability.js"; import { startCoreRuntime } from "./core/core-runtime.js"; +import { CoreServerTui } from "./tui/core-server-tui.js"; import { ExecutionBudget } from "./core/execution-budget.js"; import { BackpressureController } from "./core/backpressure.js"; import { CircuitBreakerRegistry } from "./core/circuit-breaker.js"; @@ -29,6 +30,7 @@ import type { WorkerTask, Job } from "./types/index.js"; interface SharedOptions { help: boolean; version: boolean; + tui: boolean | undefined; concurrency: number | undefined; rps: number | undefined; maxCost: number | undefined; @@ -56,7 +58,9 @@ const HELP_TEXT = ` roboppi — execution-control runtime for agentic workers USAGE - roboppi [options] Start Core IPC server mode + roboppi Show help (interactive) + roboppi server [options] Start Core IPC server mode (default: TUI when interactive) + roboppi [options] Start Core IPC server mode (when non-interactive) roboppi run [options] Run a one-shot worker task roboppi workflow [options] Run a workflow YAML roboppi daemon [options] Run daemon mode (resident workflows) @@ -64,6 +68,7 @@ USAGE IPC SERVER MODE Reads JSON Lines from stdin, writes responses to stdout. Logs go to stderr. + TUI is enabled by default when stderr is a TTY; disable with --no-tui. RUN MODE OPTIONS --worker Worker to use: opencode, claude-code, codex (required) @@ -88,6 +93,9 @@ SHARED OPTIONS --log-level Log level: debug|info|warn|error|fatal (default: info) + --tui Force TUI logs UI (stderr) + --no-tui, --plain Disable TUI; emit JSON logs to stderr + --help, -h Show this help message --version, -v Show version @@ -139,6 +147,7 @@ function parseCliArgs(argv: string[]): CliMode { const shared: SharedOptions = { help: false, version: false, + tui: undefined, concurrency: undefined, rps: undefined, maxCost: undefined, @@ -224,6 +233,8 @@ function parseCliArgs(argv: string[]): CliMode { switch (arg) { case "--help": case "-h": shared.help = true; break; case "--version": case "-v": shared.version = true; break; + case "--tui": shared.tui = true; break; + case "--no-tui": case "--plain": shared.tui = false; break; case "--concurrency": shared.concurrency = nextInt(); break; case "--rps": shared.rps = nextInt(); break; case "--max-cost": shared.maxCost = nextInt(); break; @@ -246,6 +257,21 @@ function parseCliArgs(argv: string[]): CliMode { return { mode: "server", opts: shared }; } +function parseEnvBool(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const v = value.trim().toLowerCase(); + if (v === "1" || v === "true" || v === "yes" || v === "on") return true; + if (v === "0" || v === "false" || v === "no" || v === "off") return false; + return undefined; +} + +function resolveTuiMode(cliTui: boolean | undefined, envTui: string | undefined): boolean { + if (cliTui !== undefined) return cliTui; + const envVal = parseEnvBool(envTui); + if (envVal !== undefined) return envVal; + return !!process.stderr.isTTY; +} + function buildConfig(opts: SharedOptions): AgentCoreConfig { const config: AgentCoreConfig = {}; if (opts.concurrency !== undefined || opts.rps !== undefined || opts.maxCost !== undefined) { @@ -444,10 +470,23 @@ async function executeRun(opts: SharedOptions, run: RunOptions): Promise { function startServer(opts: SharedOptions): void { const config = buildConfig(opts); - startCoreRuntime({ - config, - loggerComponent: "cli", - }); + const useTui = resolveTuiMode(opts.tui, process.env.ROBOPPI_TUI); + if (useTui && process.stderr.isTTY) { + // In Core stdio IPC mode, stdin is reserved for JSONL; use a passive renderer. + const tui = new CoreServerTui({ title: "roboppi core (IPC server)" }); + tui.start(); + process.on("exit", () => { + try { tui.stop(); } catch { /* ignore */ } + }); + startCoreRuntime({ + config, + loggerComponent: "cli", + logSink: tui.logSink, + }); + return; + } + + startCoreRuntime({ config, loggerComponent: "cli" }); } // ── Main ────────────────────────────────────────────────────────── @@ -455,6 +494,13 @@ function startServer(opts: SharedOptions): void { async function main(): Promise { const argv = process.argv.slice(2); + // Default: show help in interactive TTY. + // Starting the Core IPC server is explicit via `roboppi server` (or by passing options). + if (argv.length === 0 && process.stdin.isTTY && process.stderr.isTTY) { + console.log(HELP_TEXT); + process.exit(0); + } + if (argv[0] === "workflow") { const { runWorkflowCli } = await import("./workflow/run.js"); await runWorkflowCli(argv.slice(1)); @@ -467,6 +513,21 @@ async function main(): Promise { return; } + // Explicit server subcommand. + if (argv[0] === "server") { + const parsed = parseCliArgs(argv.slice(1)); + if (parsed.opts.help) { + console.log(HELP_TEXT); + process.exit(0); + } + if (parsed.opts.version) { + console.log(`roboppi ${VERSION}`); + process.exit(0); + } + startServer(parsed.opts); + return; + } + // Alias: `roboppi agent ...` == `roboppi run ...` const parsed = argv[0] === "agent" ? parseCliArgs(["run", ...argv.slice(1)]) diff --git a/src/core/agentcore.ts b/src/core/agentcore.ts index 610d206..a7d12e3 100644 --- a/src/core/agentcore.ts +++ b/src/core/agentcore.ts @@ -64,7 +64,7 @@ export class AgentCore { private readonly activePermitsByJob = new Map(); // jobId → permitId private started = false; - constructor(protocol: IpcProtocol, config?: AgentCoreConfig) { + constructor(protocol: IpcProtocol, config?: AgentCoreConfig, observability?: ObservabilityProvider) { this.protocol = protocol; // Initialize core components @@ -75,7 +75,7 @@ export class AgentCore { this.permitGate = new PermitGate(this.budget, this.circuitBreakers, this.backpressure); this.watchdog = new Watchdog(config?.watchdog); this.escalation = new EscalationManager(config?.escalation); - this.observability = new ObservabilityProvider(config?.logLevel); + this.observability = observability ?? new ObservabilityProvider(config?.logLevel); this.workerGateway = new WorkerDelegationGateway(); const logger = this.observability.createLogger("AgentCore"); diff --git a/src/core/core-runtime.ts b/src/core/core-runtime.ts index 133b81b..9e3be1f 100644 --- a/src/core/core-runtime.ts +++ b/src/core/core-runtime.ts @@ -1,7 +1,7 @@ import { JsonLinesTransport } from "../ipc/json-lines-transport.js"; import { IpcProtocol } from "../ipc/protocol.js"; import { AgentCore, type AgentCoreConfig } from "./agentcore.js"; -import { Logger } from "./observability.js"; +import { Logger, ObservabilityProvider, type LogSink } from "./observability.js"; import { ProcessManager } from "../worker/process-manager.js"; import { OpenCodeAdapter } from "../worker/adapters/opencode-adapter.js"; @@ -16,6 +16,7 @@ import { Readable, Writable } from "node:stream"; export interface CoreRuntimeOptions { config?: AgentCoreConfig; loggerComponent?: string; + logSink?: LogSink; } function isNonInteractive(): boolean { @@ -65,7 +66,7 @@ function parseDurationMs(input: string): number { export function startCoreRuntime(options: CoreRuntimeOptions = {}): void { const config = options.config; - const logger = new Logger(options.loggerComponent ?? "main", config?.logLevel ?? "info"); + const logger = new Logger(options.loggerComponent ?? "main", config?.logLevel ?? "info", options.logSink); // Mark this process as the Core runtime for logging/diagnostics. if (!process.env.ROBOPPI_COMPONENT) process.env.ROBOPPI_COMPONENT = "core"; @@ -131,7 +132,8 @@ export function startCoreRuntime(options: CoreRuntimeOptions = {}): void { const transport = new JsonLinesTransport(stdin, stdout); const protocol = new IpcProtocol(transport); - const core = new AgentCore(protocol, config); + const observability = new ObservabilityProvider(config?.logLevel ?? "info", options.logSink); + const core = new AgentCore(protocol, config, observability); // Keepalive output for environments that kill silent subprocesses. // Enabled by default when non-interactive; can be overridden via env. diff --git a/src/core/observability.ts b/src/core/observability.ts index ed28a15..e3792e8 100644 --- a/src/core/observability.ts +++ b/src/core/observability.ts @@ -19,12 +19,20 @@ export interface LogEntry { data?: unknown; } +export type LogSink = (entry: LogEntry) => void; + +const defaultLogSink: LogSink = (entry) => { + // Output to stderr (stdout is reserved for IPC) + process.stderr.write(JSON.stringify(entry) + "\n"); +}; + export class Logger { private _traceId?: string; constructor( private readonly component: string, private readonly minLevel: LogLevel = "debug", + private readonly sink: LogSink = defaultLogSink, ) {} setTraceId(traceId: string): void { @@ -67,8 +75,7 @@ export class Logger { if (data !== undefined) { entry.data = data; } - // Output to stderr (stdout is reserved for IPC) - process.stderr.write(JSON.stringify(entry) + "\n"); + this.sink(entry); } } @@ -173,13 +180,15 @@ export class MetricsCollector { export class ObservabilityProvider { private readonly metrics = new MetricsCollector(); private readonly minLevel: LogLevel; + private readonly sink: LogSink; - constructor(minLevel: LogLevel = "debug") { + constructor(minLevel: LogLevel = "debug", sink?: LogSink) { this.minLevel = minLevel; + this.sink = sink ?? defaultLogSink; } createLogger(component: string): Logger { - return new Logger(component, this.minLevel); + return new Logger(component, this.minLevel, this.sink); } getMetrics(): MetricsCollector { diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index 88376dc..7a895a1 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -511,6 +511,7 @@ export class Daemon { executorEnv, this.shutdownAbortController.signal, branchContext, + this.supervised, ); const result = await executor.execute(); diff --git a/src/scheduler/supervisor.ts b/src/scheduler/supervisor.ts index 72b3cc2..218a0bd 100644 --- a/src/scheduler/supervisor.ts +++ b/src/scheduler/supervisor.ts @@ -24,6 +24,8 @@ export interface SupervisorConfig { gracefulShutdownMs?: number; maxRestarts?: number; restartWindowMs?: number; + /** When set, Core stderr lines are forwarded as callbacks instead of piped to process.stderr. */ + onCoreStderrLine?: (line: string) => void; } const DEFAULT_CONFIG: SupervisorConfig = { @@ -602,6 +604,13 @@ export class Supervisor { } private forwardCoreStderr(stream: unknown): void { + const onLine = this.config.onCoreStderrLine; + + if (onLine) { + this.forwardCoreStderrAsLines(stream, onLine); + return; + } + // Best-effort: forward Core stderr to this process's stderr. // Supports both Web ReadableStream and NodeJS.ReadableStream. const s = stream as any; @@ -648,6 +657,56 @@ export class Supervisor { } } + private forwardCoreStderrAsLines(stream: unknown, onLine: (line: string) => void): void { + const s = stream as any; + let buffer = ""; + + const processChunk = (chunk: Uint8Array | Buffer | string) => { + const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk); + buffer += text; + + let newlineIdx: number; + while ((newlineIdx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIdx); + buffer = buffer.slice(newlineIdx + 1); + onLine(line); + } + }; + + // Handle Web ReadableStream + if (s && typeof s.getReader === "function") { + const reader = (s as ReadableStream).getReader(); + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value && value.byteLength > 0) processChunk(value); + } + } catch { /* ignore */ } + finally { + // Flush remaining buffer + if (buffer) onLine(buffer); + try { reader.releaseLock(); } catch { /* ignore */ } + } + })().catch(() => {}); + return; + } + + // Handle Node.js ReadableStream + if (s && typeof s.on === "function") { + try { + s.on("data", (chunk: unknown) => { + try { processChunk(chunk as any); } catch { /* ignore */ } + }); + s.on("end", () => { + if (buffer) onLine(buffer); + }); + s.on("error", () => {}); + } catch { /* ignore */ } + } + } + isRunning(): boolean { return this.proc !== null; } diff --git a/src/tui/components/detail-pane.ts b/src/tui/components/detail-pane.ts new file mode 100644 index 0000000..039f30d --- /dev/null +++ b/src/tui/components/detail-pane.ts @@ -0,0 +1,56 @@ +import type { WorkflowUiState } from "../state-store.js"; +import { renderOverviewTab } from "./tabs/overview-tab.js"; +import { renderLogsTab } from "./tabs/logs-tab.js"; +import { renderDiffsTab } from "./tabs/diffs-tab.js"; +import { renderResultTab } from "./tabs/result-tab.js"; +import { renderCoreTab } from "./tabs/core-tab.js"; +import { renderHelpTab } from "./tabs/help-tab.js"; + +const TABS = [ + { key: "1", id: "overview", label: "Overview" }, + { key: "2", id: "logs", label: "Logs" }, + { key: "3", id: "diffs", label: "Diffs" }, + { key: "4", id: "result", label: "Result" }, + { key: "5", id: "core", label: "Core" }, + { key: "6", id: "help", label: "Help" }, +] as const; + +export function renderDetailPane(state: WorkflowUiState, width: number, height: number): string { + // Tab bar + const tabBar = TABS.map((t) => { + const active = state.selectedTab === t.id; + if (active) { + return `\x1b[1m\x1b[4m${t.key}:${t.label}\x1b[0m`; + } + return `\x1b[90m${t.key}:${t.label}\x1b[0m`; + }).join(" "); + + const contentHeight = height - 2; // tab bar + separator + const step = state.selectedStepId ? state.steps.get(state.selectedStepId) : undefined; + + let content: string; + switch (state.selectedTab) { + case "overview": + content = renderOverviewTab(state, step, width, contentHeight); + break; + case "logs": + content = renderLogsTab(step, width, contentHeight); + break; + case "diffs": + content = renderDiffsTab(step, width, contentHeight); + break; + case "result": + content = renderResultTab(step, width, contentHeight); + break; + case "core": + content = renderCoreTab(state, width, contentHeight); + break; + case "help": + content = renderHelpTab(width, contentHeight); + break; + default: + content = ""; + } + + return tabBar + "\n\x1b[90m" + "\u2500".repeat(width) + "\x1b[0m\n" + content; +} diff --git a/src/tui/components/header.ts b/src/tui/components/header.ts new file mode 100644 index 0000000..a69c324 --- /dev/null +++ b/src/tui/components/header.ts @@ -0,0 +1,74 @@ +import type { WorkflowUiState } from "../state-store.js"; + +const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]; +let spinnerIdx = 0; + +export function renderHeader(state: WorkflowUiState, width: number): string { + spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length; + + const name = state.name ?? "workflow"; + const mode = state.supervised ? "supervised" : "direct"; + const status = state.status ?? "PENDING"; + + const elapsed = state.startedAt + ? formatElapsed(Date.now() - state.startedAt) + : "0s"; + + const statusColor = getStatusColor(status); + const spinner = isRunningStatus(status) ? SPINNER_FRAMES[spinnerIdx] + " " : ""; + + const counts = countStatuses(state); + const countStr = formatCounts(counts); + + const isFinished = status !== "RUNNING" && status !== "PENDING"; + const line1 = `\x1b[1m${name}\x1b[0m ${spinner}${statusColor}${status}\x1b[0m \x1b[90m${mode} \u00B7 ${elapsed}\x1b[0m`; + const hints = isFinished + ? "j/k:move 1-6:tabs q:exit" + : "j/k:move 1-6:tabs Ctrl+C:cancel q:quit"; + const line2 = `\x1b[90m${countStr} \u2502 ${hints}\x1b[0m`; + const separator = "\x1b[90m" + "\u2500".repeat(width) + "\x1b[0m"; + + return line1 + "\n" + line2 + "\n" + separator; +} + +function formatElapsed(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + if (m < 60) return `${m}m${rem}s`; + const h = Math.floor(m / 60); + return `${h}h${m % 60}m`; +} + +function getStatusColor(status: string): string { + switch (status) { + case "RUNNING": return "\x1b[33m"; + case "SUCCEEDED": return "\x1b[32m"; + case "FAILED": return "\x1b[31m"; + case "TIMED_OUT": return "\x1b[31m"; + case "CANCELLED": return "\x1b[31m"; + default: return "\x1b[90m"; + } +} + +function isRunningStatus(status: string): boolean { + return status === "RUNNING"; +} + +function countStatuses(state: WorkflowUiState): Record { + const counts: Record = {}; + for (const step of state.steps.values()) { + counts[step.status] = (counts[step.status] ?? 0) + 1; + } + return counts; +} + +function formatCounts(counts: Record): string { + const parts: string[] = []; + const order = ["RUNNING", "CHECKING", "PENDING", "READY", "SUCCEEDED", "FAILED", "SKIPPED", "INCOMPLETE", "CANCELLED"]; + for (const s of order) { + if (counts[s]) parts.push(`${s.toLowerCase()}:${counts[s]}`); + } + return parts.join(" "); +} diff --git a/src/tui/components/step-list.ts b/src/tui/components/step-list.ts new file mode 100644 index 0000000..46b67c1 --- /dev/null +++ b/src/tui/components/step-list.ts @@ -0,0 +1,71 @@ +import type { WorkflowUiState, StepUiState } from "../state-store.js"; + +const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"]; +let spinnerIdx = 0; + +const STATUS_ICONS: Record = { + PENDING: "\x1b[90m\u25CB\x1b[0m", + READY: "\x1b[36m\u25CE\x1b[0m", + RUNNING: "\x1b[33m\u25CF\x1b[0m", + CHECKING: "\x1b[35m\u25C9\x1b[0m", + SUCCEEDED: "\x1b[32m\u2713\x1b[0m", + FAILED: "\x1b[31m\u2717\x1b[0m", + INCOMPLETE: "\x1b[90m\u25D1\x1b[0m", + SKIPPED: "\x1b[90m\u2298\x1b[0m", + CANCELLED: "\x1b[31m\u2298\x1b[0m", +}; + +export function renderStepList(state: WorkflowUiState, width: number, height: number): string { + spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length; + + const lines: string[] = []; + const stepOrder = state.stepOrder; + + for (const stepId of stepOrder) { + const step = state.steps.get(stepId); + if (!step) continue; + + const isSelected = stepId === state.selectedStepId; + const prefix = isSelected ? "\x1b[7m" : ""; + const suffix = isSelected ? "\x1b[0m" : ""; + + const icon = getStepIcon(step); + const iter = step.maxIterations > 1 ? ` ${step.iteration}/${step.maxIterations}` : ""; + const duration = step.startedAt ? ` ${formatDuration(step)}` : ""; + const phase = step.phase ? ` \x1b[90m${step.phase}\x1b[0m` : ""; + + const truncId = stepId.length > width - 12 + ? stepId.slice(0, width - 15) + "..." + : stepId; + + const line = `${prefix} ${icon} ${truncId}${iter}${duration}${phase}${suffix}`; + lines.push(line); + } + + // Pad to fill height + while (lines.length < height) { + lines.push(""); + } + + return lines.slice(0, height).join("\n"); +} + +function getStepIcon(step: StepUiState): string { + if (step.status === "RUNNING") { + return `\x1b[33m${SPINNER_FRAMES[spinnerIdx]}\x1b[0m`; + } + if (step.status === "CHECKING") { + return `\x1b[35m${SPINNER_FRAMES[spinnerIdx]}\x1b[0m`; + } + return STATUS_ICONS[step.status] ?? "\x1b[90m?\x1b[0m"; +} + +function formatDuration(step: StepUiState): string { + if (!step.startedAt) return ""; + const end = step.completedAt ?? Date.now(); + const ms = end - step.startedAt; + const s = Math.floor(ms / 1000); + if (s < 60) return `\x1b[90m${s}s\x1b[0m`; + const m = Math.floor(s / 60); + return `\x1b[90m${m}m${s % 60}s\x1b[0m`; +} diff --git a/src/tui/components/tabs/core-tab.ts b/src/tui/components/tabs/core-tab.ts new file mode 100644 index 0000000..0d15c8a --- /dev/null +++ b/src/tui/components/tabs/core-tab.ts @@ -0,0 +1,34 @@ +import type { WorkflowUiState } from "../../state-store.js"; + +export function renderCoreTab( + state: WorkflowUiState, + width: number, + height: number, +): string { + if (!state.supervised) { + return padLines(["\x1b[90mCore tab is only available in supervised mode\x1b[0m"], height); + } + + const lines: string[] = []; + lines.push("\x1b[1mCore Logs\x1b[0m"); + lines.push(""); + + const logLines = state.coreLogs.lines(); + if (logLines.length === 0) { + lines.push("\x1b[90mNo core logs yet\x1b[0m"); + } else { + // Show last N lines + const visible = logLines.slice(-(height - 3)); + for (const l of visible) { + const trunc = l.length > width ? l.slice(0, width - 3) + "..." : l; + lines.push(`\x1b[90m${trunc}\x1b[0m`); + } + } + + return padLines(lines, height); +} + +function padLines(lines: string[], height: number): string { + while (lines.length < height) lines.push(""); + return lines.slice(0, height).join("\n"); +} diff --git a/src/tui/components/tabs/diffs-tab.ts b/src/tui/components/tabs/diffs-tab.ts new file mode 100644 index 0000000..36e80e5 --- /dev/null +++ b/src/tui/components/tabs/diffs-tab.ts @@ -0,0 +1,59 @@ +import type { StepUiState } from "../../state-store.js"; + +export function renderDiffsTab( + step: StepUiState | undefined, + _width: number, + height: number, +): string { + if (!step) { + return padLines(["\x1b[90mNo step selected\x1b[0m"], height); + } + + const patches = step.patches; + if (patches.order.length === 0) { + return padLines(["\x1b[90mNo diffs available\x1b[0m"], height); + } + + const lines: string[] = []; + const patchCount = patches.order.length; + const fileCount = patches.byFilePath.size; + lines.push( + `\x1b[1mPatches:\x1b[0m ${patchCount} patch${patchCount === 1 ? "" : "es"} across ${fileCount} file${fileCount === 1 ? "" : "s"}`, + ); + lines.push(""); + + // List files with patch counts + for (const [filePath, patchIds] of patches.byFilePath.entries()) { + lines.push(`\x1b[36m${filePath}\x1b[0m (${patchIds.length} patch${patchIds.length > 1 ? "es" : ""})`); + } + + // Show most recent patch content + if (patches.order.length > 0) { + const latestId = patches.order[patches.order.length - 1]!; + const latest = patches.byId.get(latestId); + if (latest) { + lines.push(""); + lines.push(`\x1b[1m\u2500\u2500 Latest: ${latest.filePath} \u2500\u2500\x1b[0m`); + + const diffLines = latest.diff.split("\n"); + for (const dl of diffLines) { + if (dl.startsWith("+")) { + lines.push(`\x1b[32m${dl}\x1b[0m`); + } else if (dl.startsWith("-")) { + lines.push(`\x1b[31m${dl}\x1b[0m`); + } else if (dl.startsWith("@@")) { + lines.push(`\x1b[36m${dl}\x1b[0m`); + } else { + lines.push(dl); + } + } + } + } + + return padLines(lines, height); +} + +function padLines(lines: string[], height: number): string { + while (lines.length < height) lines.push(""); + return lines.slice(0, height).join("\n"); +} diff --git a/src/tui/components/tabs/help-tab.ts b/src/tui/components/tabs/help-tab.ts new file mode 100644 index 0000000..39f26c7 --- /dev/null +++ b/src/tui/components/tabs/help-tab.ts @@ -0,0 +1,37 @@ +export function renderHelpTab(_width: number, height: number): string { + const lines = [ + "\x1b[1mKeyboard Shortcuts\x1b[0m", + "", + " \x1b[1mj/k\x1b[0m or \x1b[1m\u2191/\u2193\x1b[0m Navigate steps", + " \x1b[1mEnter\x1b[0m Lock step selection", + " \x1b[1mf\x1b[0m Follow running step", + " \x1b[1mSpace\x1b[0m Toggle follow mode", + " \x1b[1m1-6\x1b[0m Switch tabs", + " \x1b[1mCtrl+C\x1b[0m Cancel workflow", + " \x1b[1mq\x1b[0m Quit", + "", + "\x1b[1mStatus Legend\x1b[0m", + "", + " \x1b[90m\u25CB\x1b[0m PENDING Step waiting for dependencies", + " \x1b[36m\u25CE\x1b[0m READY Dependencies resolved, queued", + " \x1b[33m\u25CF\x1b[0m RUNNING Worker executing", + " \x1b[35m\u25C9\x1b[0m CHECKING Completion check running", + " \x1b[32m\u2713\x1b[0m SUCCEEDED Step completed successfully", + " \x1b[31m\u2717\x1b[0m FAILED Step failed", + " \x1b[90m\u25D1\x1b[0m INCOMPLETE Max iterations reached", + " \x1b[90m\u2298\x1b[0m SKIPPED Dependency failed", + " \x1b[31m\u2298\x1b[0m CANCELLED Workflow cancelled", + "", + "\x1b[1mTabs\x1b[0m", + "", + " 1: Overview Step summary + workflow counts", + " 2: Logs stdout/stderr/progress streams", + " 3: Diffs File patches and diffs", + " 4: Result Final WorkerResult details", + " 5: Core Core stderr logs (supervised)", + " 6: Help This help screen", + ]; + + while (lines.length < height) lines.push(""); + return lines.slice(0, height).join("\n"); +} diff --git a/src/tui/components/tabs/logs-tab.ts b/src/tui/components/tabs/logs-tab.ts new file mode 100644 index 0000000..6b3b2b2 --- /dev/null +++ b/src/tui/components/tabs/logs-tab.ts @@ -0,0 +1,54 @@ +import type { StepUiState } from "../../state-store.js"; + +export function renderLogsTab( + step: StepUiState | undefined, + width: number, + height: number, +): string { + if (!step) { + return padLines(["\x1b[90mNo step selected\x1b[0m"], height); + } + + // Merge all log sources + const entries: { channel: string; line: string }[] = []; + + for (const line of step.logs.stdout.lines()) { + entries.push({ channel: "out", line }); + } + for (const line of step.logs.stderr.lines()) { + entries.push({ channel: "err", line }); + } + for (const line of step.logs.progress.lines()) { + entries.push({ channel: "prg", line }); + } + + if (entries.length === 0) { + return padLines(["\x1b[90mNo logs yet\x1b[0m"], height); + } + + // Show last N lines that fit + const visibleEntries = entries.slice(-height); + const lines = visibleEntries.map((e) => { + const prefix = getChannelPrefix(e.channel); + const truncated = e.line.length > width - 6 + ? e.line.slice(0, width - 9) + "..." + : e.line; + return `${prefix} ${truncated}`; + }); + + return padLines(lines, height); +} + +function getChannelPrefix(channel: string): string { + switch (channel) { + case "out": return "\x1b[90m[out]\x1b[0m"; + case "err": return "\x1b[31m[err]\x1b[0m"; + case "prg": return "\x1b[36m[prg]\x1b[0m"; + default: return "\x1b[90m[???]\x1b[0m"; + } +} + +function padLines(lines: string[], height: number): string { + while (lines.length < height) lines.push(""); + return lines.slice(0, height).join("\n"); +} diff --git a/src/tui/components/tabs/overview-tab.ts b/src/tui/components/tabs/overview-tab.ts new file mode 100644 index 0000000..7a30222 --- /dev/null +++ b/src/tui/components/tabs/overview-tab.ts @@ -0,0 +1,62 @@ +import type { WorkflowUiState, StepUiState } from "../../state-store.js"; + +export function renderOverviewTab( + state: WorkflowUiState, + step: StepUiState | undefined, + _width: number, + height: number, +): string { + const lines: string[] = []; + + if (!step) { + lines.push("\x1b[90mNo step selected\x1b[0m"); + return padLines(lines, height); + } + + lines.push(`\x1b[1mStep:\x1b[0m ${step.stepId}`); + lines.push(`\x1b[1mStatus:\x1b[0m ${step.status}${step.phase ? ` (${step.phase})` : ""}`); + lines.push(`\x1b[1mIteration:\x1b[0m ${step.iteration}/${step.maxIterations}`); + + if (step.startedAt) { + const elapsed = (step.completedAt ?? Date.now()) - step.startedAt; + lines.push(`\x1b[1mDuration:\x1b[0m ${formatMs(elapsed)}`); + } + + if (step.error) { + lines.push(`\x1b[31m\x1b[1mError:\x1b[0m ${step.error}`); + } + + if (step.progress) { + const pct = step.progress.percent !== undefined ? ` (${step.progress.percent}%)` : ""; + lines.push(`\x1b[1mProgress:\x1b[0m ${step.progress.message}${pct}`); + } + + // Workflow summary + lines.push(""); + lines.push("\x1b[1m\u2500\u2500 Workflow Summary \u2500\u2500\x1b[0m"); + + const counts: Record = {}; + for (const s of state.steps.values()) { + counts[s.status] = (counts[s.status] ?? 0) + 1; + } + + const total = state.steps.size; + lines.push(`Total steps: ${total}`); + for (const [status, count] of Object.entries(counts).sort()) { + lines.push(` ${status}: ${count}`); + } + + return padLines(lines, height); +} + +function formatMs(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + return `${m}m${s % 60}s`; +} + +function padLines(lines: string[], height: number): string { + while (lines.length < height) lines.push(""); + return lines.slice(0, height).join("\n"); +} diff --git a/src/tui/components/tabs/result-tab.ts b/src/tui/components/tabs/result-tab.ts new file mode 100644 index 0000000..9f6045c --- /dev/null +++ b/src/tui/components/tabs/result-tab.ts @@ -0,0 +1,69 @@ +import type { StepUiState } from "../../state-store.js"; + +export function renderResultTab( + step: StepUiState | undefined, + width: number, + height: number, +): string { + if (!step) { + return padLines(["\x1b[90mNo step selected\x1b[0m"], height); + } + + if (!step.result) { + if (step.status === "RUNNING" || step.status === "CHECKING") { + return padLines(["\x1b[90mStep still running...\x1b[0m"], height); + } + return padLines(["\x1b[90mNo result available\x1b[0m"], height); + } + + const r = step.result; + const lines: string[] = []; + + lines.push(`\x1b[1mStatus:\x1b[0m ${r.status}`); + if (r.exitCode !== undefined) { + lines.push(`\x1b[1mExit Code:\x1b[0m ${r.exitCode}`); + } + if (r.errorClass) { + lines.push(`\x1b[1mError Class:\x1b[0m ${r.errorClass}`); + } + if (r.durationMs !== undefined) { + lines.push(`\x1b[1mDuration:\x1b[0m ${(r.durationMs / 1000).toFixed(1)}s`); + } + + if (r.cost) { + lines.push(""); + lines.push("\x1b[1mCost:\x1b[0m"); + if (r.cost.wallTimeMs !== undefined) { + lines.push(` Wall time: ${(r.cost.wallTimeMs / 1000).toFixed(1)}s`); + } + } + + if (r.artifacts && r.artifacts.length > 0) { + lines.push(""); + lines.push(`\x1b[1mArtifacts:\x1b[0m (${r.artifacts.length})`); + for (const a of r.artifacts.slice(0, 20)) { + const ref = a.ref ?? "(no ref)"; + lines.push(` ${a.type}: ${ref}`); + } + } + + if (r.observations && r.observations.length > 0) { + lines.push(""); + lines.push(`\x1b[1mObservations:\x1b[0m`); + for (const obs of r.observations.slice(0, 10)) { + if (obs.summary) { + const trunc = obs.summary.length > width - 4 + ? obs.summary.slice(0, width - 7) + "..." + : obs.summary; + lines.push(` ${trunc}`); + } + } + } + + return padLines(lines, height); +} + +function padLines(lines: string[], height: number): string { + while (lines.length < height) lines.push(""); + return lines.slice(0, height).join("\n"); +} diff --git a/src/tui/core-server-tui.ts b/src/tui/core-server-tui.ts new file mode 100644 index 0000000..3c2f81b --- /dev/null +++ b/src/tui/core-server-tui.ts @@ -0,0 +1,135 @@ +import type { LogEntry, LogSink } from "../core/observability.js"; +import { RingBuffer } from "./ring-buffer.js"; +import type { TuiRenderer } from "./opentui-platform.js"; +import { PassiveAnsiRenderer } from "./passive-ansi-renderer.js"; + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +function formatTime(ts: number): string { + const d = new Date(ts); + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; +} + +function safeJson(v: unknown): string { + try { + return JSON.stringify(v); + } catch { + return "[unserializable]"; + } +} + +function truncate(s: string, max: number): string { + if (max <= 0) return ""; + if (s.length <= max) return s; + if (max <= 3) return s.slice(0, max); + return s.slice(0, max - 3) + "..."; +} + +function levelColor(level: string): string { + switch (level) { + case "debug": + return "\x1b[90m"; + case "info": + return "\x1b[36m"; + case "warn": + return "\x1b[33m"; + case "error": + case "fatal": + return "\x1b[31m"; + default: + return ""; + } +} + +function formatLogLine(entry: LogEntry, width: number): string { + const t = formatTime(entry.timestamp); + const lvl = entry.level; + const lvlColored = `${levelColor(lvl)}${lvl}\x1b[0m`; + const base = `${t} ${lvlColored} ${entry.component}: ${entry.message}`; + const data = entry.data !== undefined ? ` ${truncate(safeJson(entry.data), Math.max(0, width - base.length - 1))}` : ""; + return truncate(base + data, width); +} + +export interface CoreServerTuiOptions { + title?: string; + renderer?: TuiRenderer; +} + +export class CoreServerTui { + private readonly renderer: TuiRenderer; + private readonly title: string; + + private readonly startedAt = Date.now(); + private readonly logs = new RingBuffer({ maxLines: 3000, maxBytes: 1024 * 1024 }); + private renderTimer: ReturnType | null = null; + private dirty = true; + + private transport: string = "stdio"; + + constructor(opts: CoreServerTuiOptions = {}) { + this.renderer = opts.renderer ?? new PassiveAnsiRenderer(); + this.title = opts.title ?? "roboppi core"; + } + + get logSink(): LogSink { + return (entry) => { + const width = (process.stderr.columns ?? 120); + this.logs.push(formatLogLine(entry, width)); + + // Best-effort: infer transport from the startup log. + if (entry.message.includes("awaiting IPC messages")) { + const data = entry.data; + if (data && typeof data === "object") { + const transport = (data as Record).transport; + if (typeof transport === "string") this.transport = transport; + } + } + + this.dirty = true; + }; + } + + start(): void { + this.renderer.start(); + + this.renderTimer = setInterval(() => { + if (!this.dirty) return; + this.dirty = false; + this.renderFrame(); + }, 100); + + this.renderFrame(); + } + + stop(): void { + if (this.renderTimer) { + clearInterval(this.renderTimer); + this.renderTimer = null; + } + this.renderer.destroy(); + } + + private renderFrame(): void { + const columns = process.stderr.columns ?? 80; + const rows = process.stderr.rows ?? 24; + + const uptimeS = Math.floor((Date.now() - this.startedAt) / 1000); + const header1 = `\x1b[1m${this.title}\x1b[0m transport=${this.transport} uptime=${uptimeS}s`; + const header2 = `stdin/stdout: JSONL IPC (server) logs: TUI (stderr) help: --help stop: Ctrl+C plain: --no-tui`; + const sep = "-".repeat(Math.max(0, Math.min(columns, 120))); + + const headerLines = [truncate(header1, columns), truncate(header2, columns), sep]; + const bodyHeight = Math.max(0, rows - headerLines.length - 1); + + const lines = this.logs.lines(); + const visible = lines.slice(Math.max(0, lines.length - bodyHeight)); + const padded: string[] = []; + for (const line of visible) padded.push(truncate(line, columns)); + while (padded.length < bodyHeight) padded.unshift(""); + + const out = headerLines.join("\n") + "\n" + padded.join("\n") + "\n"; + this.renderer.render(out); + } +} diff --git a/src/tui/exec-event.ts b/src/tui/exec-event.ts new file mode 100644 index 0000000..9c21de2 --- /dev/null +++ b/src/tui/exec-event.ts @@ -0,0 +1,75 @@ +import type { WorkerEvent } from "../worker/worker-adapter.js"; +import type { WorkerResult } from "../types/index.js"; +import type { StepStatus, WorkflowStatus } from "../workflow/types.js"; + +export interface ExecEventSink { + emit(event: ExecEvent): void; +} + +export type ExecEvent = + | { + type: "workflow_started"; + workflowId: string; + name: string; + workspaceDir: string; + supervised: boolean; + startedAt: number; + definitionSummary?: { + steps: string[]; + concurrency?: number; + timeout: string; + }; + } + | { + type: "workflow_finished"; + status: WorkflowStatus; + completedAt: number; + } + | { + type: "step_state"; + stepId: string; + status: StepStatus; + iteration: number; + maxIterations: number; + startedAt?: number; + completedAt?: number; + error?: string; + } + | { + type: "step_phase"; + stepId: string; + phase: + | "waiting_deps" + | "ready" + | "submitting_job" + | "waiting_permit" + | "executing" + | "checking" + | "collecting_outputs" + | "finalizing"; + at: number; + detail?: Record; + } + | { + type: "worker_event"; + stepId: string; + ts: number; + event: WorkerEvent; + } + | { + type: "worker_result"; + stepId: string; + ts: number; + result: WorkerResult; + } + | { + type: "core_log"; + ts: number; + line: string; + } + | { + type: "warning"; + ts: number; + message: string; + data?: unknown; + }; diff --git a/src/tui/index.ts b/src/tui/index.ts new file mode 100644 index 0000000..4c37213 --- /dev/null +++ b/src/tui/index.ts @@ -0,0 +1,4 @@ +export * from "./exec-event.js"; +export * from "./noop-sink.js"; +export * from "./ring-buffer.js"; +export * from "./state-store.js"; diff --git a/src/tui/noop-sink.ts b/src/tui/noop-sink.ts new file mode 100644 index 0000000..6a52088 --- /dev/null +++ b/src/tui/noop-sink.ts @@ -0,0 +1,7 @@ +import type { ExecEventSink, ExecEvent } from "./exec-event.js"; + +export class NoopExecEventSink implements ExecEventSink { + emit(_event: ExecEvent): void { + // intentionally empty + } +} diff --git a/src/tui/opentui-platform-packages.d.ts b/src/tui/opentui-platform-packages.d.ts new file mode 100644 index 0000000..dce0879 --- /dev/null +++ b/src/tui/opentui-platform-packages.d.ts @@ -0,0 +1,29 @@ +declare module "@opentui/core-linux-x64/index.ts" { + const libPath: string; + export default libPath; +} + +declare module "@opentui/core-linux-arm64/index.ts" { + const libPath: string; + export default libPath; +} + +declare module "@opentui/core-darwin-x64/index.ts" { + const libPath: string; + export default libPath; +} + +declare module "@opentui/core-darwin-arm64/index.ts" { + const libPath: string; + export default libPath; +} + +declare module "@opentui/core-win32-x64/index.ts" { + const libPath: string; + export default libPath; +} + +declare module "@opentui/core-win32-arm64/index.ts" { + const libPath: string; + export default libPath; +} diff --git a/src/tui/opentui-platform.ts b/src/tui/opentui-platform.ts new file mode 100644 index 0000000..aaf3027 --- /dev/null +++ b/src/tui/opentui-platform.ts @@ -0,0 +1,577 @@ +import type { RGBA, StyledText, KeyEvent, CliRenderer, TextRenderable } from "@opentui/core"; + +export interface TuiRenderer { + start(): void; + render(content: string): void; + destroy(): void; + onKeypress(handler: (key: string) => void): void; +} + +export async function createOpenTuiRenderer(): Promise { + await ensureOpenTuiPlatformBundled(); + + const core = await import("@opentui/core"); + const renderer = await core.createCliRenderer({ + stdin: process.stdin, + // Render to stderr so stdout stays usable for plain output. + stdout: process.stderr, + exitOnCtrlC: false, + useAlternateScreen: true, + useMouse: false, + targetFps: 30, + }); + + const text = new core.TextRenderable(renderer, { + id: "roboppi.workflow.tui", + width: "100%", + height: "100%", + wrapMode: "none", + selectable: false, + }); + renderer.root.add(text); + + return new OpenTuiRenderer( + renderer, + text, + core.StyledText, + (color: string) => core.parseColor(color), + ); +} + +async function ensureOpenTuiPlatformBundled(): Promise { + const key = `${process.platform}-${process.arch}`; + + // IMPORTANT: + // - OpenTUI loads its native shared library via dynamic import of + // `@opentui/core-${platform}-${arch}/index.ts`. + // - `bun build --compile` will not reliably include these optional + // platform packages unless we reference them explicitly. + // - Wrap each import in try/catch so builds succeed even when other + // platform packages are not installed. + const tryImports = async (): Promise => { + switch (key) { + case "linux-x64": + try { + await import("@opentui/core-linux-x64/index.ts"); + return true; + } catch { + return false; + } + case "linux-arm64": + try { + await import("@opentui/core-linux-arm64/index.ts"); + return true; + } catch { + return false; + } + case "darwin-x64": + try { + await import("@opentui/core-darwin-x64/index.ts"); + return true; + } catch { + return false; + } + case "darwin-arm64": + try { + await import("@opentui/core-darwin-arm64/index.ts"); + return true; + } catch { + return false; + } + case "win32-x64": + try { + await import("@opentui/core-win32-x64/index.ts"); + return true; + } catch { + return false; + } + case "win32-arm64": + try { + await import("@opentui/core-win32-arm64/index.ts"); + return true; + } catch { + return false; + } + default: + return false; + } + }; + + if (await tryImports()) return; + + // Best-effort fallback: try any installed platform package. + // This keeps the error message readable when `key` is unexpected. + try { + await import("@opentui/core-linux-x64/index.ts"); + return; + } catch { + // ignore + } + try { + await import("@opentui/core-linux-arm64/index.ts"); + return; + } catch { + // ignore + } + try { + await import("@opentui/core-darwin-x64/index.ts"); + return; + } catch { + // ignore + } + try { + await import("@opentui/core-darwin-arm64/index.ts"); + return; + } catch { + // ignore + } + try { + await import("@opentui/core-win32-x64/index.ts"); + return; + } catch { + // ignore + } + try { + await import("@opentui/core-win32-arm64/index.ts"); + return; + } catch { + // ignore + } + + throw new Error( + `OpenTUI native platform package not available for ${key}. ` + + `Ensure @opentui/core optionalDependencies installed (or install @opentui/core-${key}).`, + ); +} + +class OpenTuiRenderer implements TuiRenderer { + private started = false; + private keypressHandler: ((key: string) => void) | null = null; + private keyListener: ((event: KeyEvent) => void) | null = null; + + private readonly colorCache = new Map(); + + constructor( + private readonly renderer: CliRenderer, + private readonly text: TextRenderable, + private readonly StyledTextCtor: typeof StyledText, + private readonly parseColor: (input: string) => RGBA, + ) {} + + start(): void { + if (this.started) return; + this.started = true; + this.renderer.start(); + } + + render(content: string): void { + this.text.content = this.ansiToStyledText(content); + } + + destroy(): void { + if (this.keyListener) { + try { + this.renderer.keyInput.removeListener("keypress", this.keyListener); + } catch { + // ignore + } + this.keyListener = null; + } + + try { + this.renderer.destroy(); + } catch { + // ignore + } + } + + onKeypress(handler: (key: string) => void): void { + this.keypressHandler = handler; + + if (this.keyListener) return; + this.keyListener = (event: KeyEvent) => { + const key = normalizeKeyEvent(event); + if (!key) return; + this.keypressHandler?.(key); + }; + this.renderer.keyInput.on("keypress", this.keyListener); + } + + private ansiToStyledText(input: string): StyledText | string { + // Fast path: no ANSI SGR codes. + if (!input.includes("\x1b[")) return input; + + type StyleState = { + fg?: RGBA; + bg?: RGBA; + bold: boolean; + dim: boolean; + italic: boolean; + underline: boolean; + inverse: boolean; + blink: boolean; + hidden: boolean; + strikethrough: boolean; + }; + + const style: StyleState = { + bold: false, + dim: false, + italic: false, + underline: false, + inverse: false, + blink: false, + hidden: false, + strikethrough: false, + }; + + const chunks: Array<{ + __isChunk: true; + text: string; + fg?: RGBA; + bg?: RGBA; + attributes?: number; + }> = []; + + const pushChunk = (text: string): void => { + if (!text) return; + + let attributes = 0; + if (style.bold) attributes |= 1 << 0; + if (style.dim) attributes |= 1 << 1; + if (style.italic) attributes |= 1 << 2; + if (style.underline) attributes |= 1 << 3; + if (style.blink) attributes |= 1 << 4; + if (style.inverse) attributes |= 1 << 5; + if (style.hidden) attributes |= 1 << 6; + if (style.strikethrough) attributes |= 1 << 7; + + const last = chunks.length > 0 ? chunks[chunks.length - 1] : undefined; + if ( + last && + last.fg === style.fg && + last.bg === style.bg && + (last.attributes ?? 0) === attributes + ) { + last.text += text; + return; + } + + const chunk: { + __isChunk: true; + text: string; + fg?: RGBA; + bg?: RGBA; + attributes?: number; + } = { + __isChunk: true, + text, + }; + if (style.fg) chunk.fg = style.fg; + if (style.bg) chunk.bg = style.bg; + if (attributes !== 0) chunk.attributes = attributes; + chunks.push(chunk); + }; + + const setFg = (name: string | undefined): void => { + if (!name) { + style.fg = undefined; + return; + } + const cached = this.colorCache.get(name); + if (cached) { + style.fg = cached; + return; + } + const parsed = this.parseColor(name); + this.colorCache.set(name, parsed); + style.fg = parsed; + }; + + const setBg = (name: string | undefined): void => { + if (!name) { + style.bg = undefined; + return; + } + const key = `bg:${name}`; + const cached = this.colorCache.get(key); + if (cached) { + style.bg = cached; + return; + } + const parsed = this.parseColor(name); + this.colorCache.set(key, parsed); + style.bg = parsed; + }; + + const reset = (): void => { + style.fg = undefined; + style.bg = undefined; + style.bold = false; + style.dim = false; + style.italic = false; + style.underline = false; + style.inverse = false; + style.blink = false; + style.hidden = false; + style.strikethrough = false; + }; + + const applySgr = (params: number[]): void => { + const effective = params.length === 0 ? [0] : params; + for (const p of effective) { + if (p === 0) { + reset(); + continue; + } + if (p === 1) { + style.bold = true; + continue; + } + if (p === 2) { + style.dim = true; + continue; + } + if (p === 3) { + style.italic = true; + continue; + } + if (p === 4) { + style.underline = true; + continue; + } + if (p === 5) { + style.blink = true; + continue; + } + if (p === 7) { + style.inverse = true; + continue; + } + if (p === 8) { + style.hidden = true; + continue; + } + if (p === 9) { + style.strikethrough = true; + continue; + } + if (p === 22) { + style.bold = false; + style.dim = false; + continue; + } + if (p === 23) { + style.italic = false; + continue; + } + if (p === 24) { + style.underline = false; + continue; + } + if (p === 25) { + style.blink = false; + continue; + } + if (p === 27) { + style.inverse = false; + continue; + } + if (p === 28) { + style.hidden = false; + continue; + } + if (p === 29) { + style.strikethrough = false; + continue; + } + + // Foreground colors + if (p >= 30 && p <= 37) { + setFg(sgrColorName(p, false)); + continue; + } + if (p === 39) { + setFg(undefined); + continue; + } + if (p >= 90 && p <= 97) { + setFg(sgrColorName(p, false)); + continue; + } + + // Background colors + if (p >= 40 && p <= 47) { + setBg(sgrColorName(p, true)); + continue; + } + if (p === 49) { + setBg(undefined); + continue; + } + if (p >= 100 && p <= 107) { + setBg(sgrColorName(p, true)); + continue; + } + } + }; + + const sgrRe = /\x1b\[([0-9;]*)m/g; + let lastIndex = 0; + while (true) { + const match = sgrRe.exec(input); + if (!match) break; + + const idx = match.index; + if (idx > lastIndex) { + pushChunk(input.slice(lastIndex, idx)); + } + + const rawParams = match[1] ?? ""; + const params = rawParams.length === 0 + ? [] + : rawParams + .split(";") + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n)); + applySgr(params); + lastIndex = sgrRe.lastIndex; + } + + if (lastIndex < input.length) { + pushChunk(input.slice(lastIndex)); + } + + return new this.StyledTextCtor(chunks); + } +} + +function sgrColorName(code: number, isBg: boolean): string | undefined { + const base: Record = isBg + ? { + 40: "black", + 41: "red", + 42: "green", + 43: "yellow", + 44: "blue", + 45: "magenta", + 46: "cyan", + 47: "white", + } + : { + 30: "black", + 31: "red", + 32: "green", + 33: "yellow", + 34: "blue", + 35: "magenta", + 36: "cyan", + 37: "white", + }; + + const bright: Record = isBg + ? { + 100: "brightBlack", + 101: "brightRed", + 102: "brightGreen", + 103: "brightYellow", + 104: "brightBlue", + 105: "brightMagenta", + 106: "brightCyan", + 107: "brightWhite", + } + : { + 90: "brightBlack", + 91: "brightRed", + 92: "brightGreen", + 93: "brightYellow", + 94: "brightBlue", + 95: "brightMagenta", + 96: "brightCyan", + 97: "brightWhite", + }; + + return base[code] ?? bright[code]; +} + +function normalizeKeyEvent(event: KeyEvent): string | null { + // Ctrl+C + if (event.ctrl && event.name === "c") return "\x03"; + + // Arrows + if (event.name === "up") return "\x1b[A"; + if (event.name === "down") return "\x1b[B"; + + // Enter + if (event.name === "return" || event.name === "linefeed") return "\r"; + + // Space + if (event.name === "space") return " "; + + // Letters/digits/punctuation + if (typeof event.name === "string" && event.name.length === 1) { + return event.name; + } + + return null; +} + +/** + * Fallback plain-text renderer that writes to stderr using ANSI escape codes. + * Uses alternate screen buffer for clean terminal management. + */ +export class PlainRenderer implements TuiRenderer { + private keypressHandler: ((key: string) => void) | null = null; + private stdinRawMode = false; + private stdinDataListener: ((data: Buffer) => void) | null = null; + + start(): void { + // Switch to alternate screen buffer + process.stderr.write("\x1b[?1049h"); + // Hide cursor + process.stderr.write("\x1b[?25l"); + // Set up stdin for keypress events + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + process.stdin.resume(); + this.stdinRawMode = true; + + if (!this.stdinDataListener) { + this.stdinDataListener = (data: Buffer) => { + const key = data.toString(); + this.keypressHandler?.(key); + }; + process.stdin.on("data", this.stdinDataListener); + } + } + } + + render(content: string): void { + // Clear screen and move to top-left + process.stderr.write("\x1b[2J\x1b[H"); + process.stderr.write(content); + } + + destroy(): void { + if (this.stdinDataListener) { + try { + process.stdin.removeListener("data", this.stdinDataListener); + } catch { + // ignore + } + this.stdinDataListener = null; + } + if (this.stdinRawMode) { + process.stdin.setRawMode(false); + process.stdin.pause(); + } + // Show cursor + process.stderr.write("\x1b[?25h"); + // Switch back from alternate screen + process.stderr.write("\x1b[?1049l"); + } + + onKeypress(handler: (key: string) => void): void { + this.keypressHandler = handler; + } +} diff --git a/src/tui/passive-ansi-renderer.ts b/src/tui/passive-ansi-renderer.ts new file mode 100644 index 0000000..551c828 --- /dev/null +++ b/src/tui/passive-ansi-renderer.ts @@ -0,0 +1,31 @@ +import type { TuiRenderer } from "./opentui-platform.js"; + +/** + * Passive ANSI renderer for cases where stdin is reserved for IPC. + * Uses stderr + alternate screen, but does not touch stdin. + */ +export class PassiveAnsiRenderer implements TuiRenderer { + start(): void { + // Switch to alternate screen buffer + process.stderr.write("\x1b[?1049h"); + // Hide cursor + process.stderr.write("\x1b[?25l"); + } + + render(content: string): void { + // Clear screen and move to top-left + process.stderr.write("\x1b[2J\x1b[H"); + process.stderr.write(content); + } + + destroy(): void { + // Show cursor + process.stderr.write("\x1b[?25h"); + // Switch back from alternate screen + process.stderr.write("\x1b[?1049l"); + } + + onKeypress(_handler: (key: string) => void): void { + // No-op: stdin is reserved for IPC JSONL. + } +} diff --git a/src/tui/ring-buffer.ts b/src/tui/ring-buffer.ts new file mode 100644 index 0000000..222c5e5 --- /dev/null +++ b/src/tui/ring-buffer.ts @@ -0,0 +1,56 @@ +const DEFAULT_MAX_LINES = 5000; +const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; // 2MB + +export interface RingBufferOptions { + maxLines: number; + maxBytes?: number; +} + +export class RingBuffer { + private buf: T[] = []; + private _totalBytes = 0; + private readonly maxLines: number; + private readonly maxBytes: number; + + constructor(opts?: Partial) { + this.maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES; + this.maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES; + } + + push(item: T): void { + const itemBytes = item.length; + + // Add the item first + this.buf.push(item); + this._totalBytes += itemBytes; + + // Evict oldest to satisfy maxLines + while (this.buf.length > this.maxLines) { + const evicted = this.buf.shift()!; + this._totalBytes -= evicted.length; + } + + // Evict oldest to satisfy maxBytes + while (this._totalBytes > this.maxBytes && this.buf.length > 0) { + const evicted = this.buf.shift()!; + this._totalBytes -= evicted.length; + } + } + + lines(): T[] { + return this.buf.slice(); + } + + get length(): number { + return this.buf.length; + } + + get totalBytes(): number { + return this._totalBytes; + } + + clear(): void { + this.buf = []; + this._totalBytes = 0; + } +} diff --git a/src/tui/state-store.ts b/src/tui/state-store.ts new file mode 100644 index 0000000..cc2fcd2 --- /dev/null +++ b/src/tui/state-store.ts @@ -0,0 +1,261 @@ +import type { WorkerResult } from "../types/index.js"; +import type { ExecEventSink, ExecEvent } from "./exec-event.js"; +import { RingBuffer } from "./ring-buffer.js"; +import { generateId } from "../types/index.js"; + +export type LogChannel = "stdout" | "stderr" | "progress" | "core" | "runner"; + +export interface PatchEntry { + id: string; + stepId: string; + ts: number; + filePath: string; + diff: string; +} + +export interface StepUiState { + stepId: string; + status: string; + phase?: string; + iteration: number; + maxIterations: number; + startedAt?: number; + completedAt?: number; + error?: string; + progress?: { ts: number; message: string; percent?: number }; + logs: { + stdout: RingBuffer; + stderr: RingBuffer; + progress: RingBuffer; + }; + patches: { + byId: Map; + order: string[]; + byFilePath: Map; + }; + result?: WorkerResult; +} + +export interface WorkflowUiState { + workflowId?: string; + name?: string; + workspaceDir?: string; + supervised?: boolean; + startedAt?: number; + finishedAt?: number; + status?: string; + steps: Map; + stepOrder: string[]; + selectedStepId?: string; + followMode: "selected" | "running"; + selectedTab: "overview" | "logs" | "diffs" | "result" | "core" | "help"; + coreLogs: RingBuffer; + warnings: RingBuffer; +} + +export interface TuiStateStoreOptions { + supervised?: boolean; + logLimitLines?: number; + logLimitBytes?: number; +} + +export class TuiStateStore implements ExecEventSink { + readonly state: WorkflowUiState; + dirty = false; + + private readonly logLimitLines: number; + private readonly logLimitBytes: number; + + constructor(opts?: TuiStateStoreOptions) { + this.logLimitLines = opts?.logLimitLines ?? 5000; + this.logLimitBytes = opts?.logLimitBytes ?? 2 * 1024 * 1024; + + this.state = { + supervised: opts?.supervised, + steps: new Map(), + stepOrder: [], + followMode: "running", + selectedTab: "overview", + coreLogs: new RingBuffer({ + maxLines: this.logLimitLines, + maxBytes: this.logLimitBytes, + }), + warnings: new RingBuffer({ + maxLines: this.logLimitLines, + maxBytes: this.logLimitBytes, + }), + }; + } + + emit(event: ExecEvent): void { + this.dirty = true; + + switch (event.type) { + case "workflow_started": + this.reduceWorkflowStarted(event); + break; + case "workflow_finished": + this.reduceWorkflowFinished(event); + break; + case "step_state": + this.reduceStepState(event); + break; + case "step_phase": + this.reduceStepPhase(event); + break; + case "worker_event": + this.reduceWorkerEvent(event); + break; + case "worker_result": + this.reduceWorkerResult(event); + break; + case "core_log": + this.reduceCoreLog(event); + break; + case "warning": + this.reduceWarning(event); + break; + } + } + + getOrCreateStep(stepId: string): StepUiState { + let step = this.state.steps.get(stepId); + if (!step) { + step = this.makeStepUiState(stepId); + this.state.steps.set(stepId, step); + if (!this.state.stepOrder.includes(stepId)) { + this.state.stepOrder.push(stepId); + } + } + return step; + } + + private makeStepUiState(stepId: string): StepUiState { + const logOpts = { + maxLines: this.logLimitLines, + maxBytes: this.logLimitBytes, + }; + return { + stepId, + status: "PENDING", + iteration: 0, + maxIterations: 1, + logs: { + stdout: new RingBuffer(logOpts), + stderr: new RingBuffer(logOpts), + progress: new RingBuffer(logOpts), + }, + patches: { + byId: new Map(), + order: [], + byFilePath: new Map(), + }, + }; + } + + private reduceWorkflowStarted( + event: Extract, + ): void { + this.state.workflowId = event.workflowId; + this.state.name = event.name; + this.state.workspaceDir = event.workspaceDir; + this.state.supervised = event.supervised; + this.state.startedAt = event.startedAt; + this.state.status = "RUNNING"; + + if (event.definitionSummary) { + for (const stepId of event.definitionSummary.steps) { + this.getOrCreateStep(stepId); + } + } + } + + private reduceWorkflowFinished( + event: Extract, + ): void { + this.state.status = event.status; + this.state.finishedAt = event.completedAt; + } + + private reduceStepState( + event: Extract, + ): void { + const step = this.getOrCreateStep(event.stepId); + step.status = event.status; + step.iteration = event.iteration; + step.maxIterations = event.maxIterations; + if (event.startedAt !== undefined) step.startedAt = event.startedAt; + if (event.completedAt !== undefined) step.completedAt = event.completedAt; + if (event.error !== undefined) step.error = event.error; + } + + private reduceStepPhase( + event: Extract, + ): void { + const step = this.getOrCreateStep(event.stepId); + step.phase = event.phase; + } + + private reduceWorkerEvent( + event: Extract, + ): void { + const step = this.getOrCreateStep(event.stepId); + const we = event.event; + + switch (we.type) { + case "stdout": + step.logs.stdout.push(we.data); + break; + case "stderr": + step.logs.stderr.push(we.data); + break; + case "progress": + step.logs.progress.push(we.message); + step.progress = { + ts: event.ts, + message: we.message, + percent: we.percent, + }; + break; + case "patch": { + const id = generateId(); + const entry: PatchEntry = { + id, + stepId: event.stepId, + ts: event.ts, + filePath: we.filePath, + diff: we.diff, + }; + step.patches.byId.set(id, entry); + step.patches.order.push(id); + + const existing = step.patches.byFilePath.get(we.filePath); + if (existing) { + existing.push(id); + } else { + step.patches.byFilePath.set(we.filePath, [id]); + } + break; + } + } + } + + private reduceWorkerResult( + event: Extract, + ): void { + const step = this.getOrCreateStep(event.stepId); + step.result = event.result; + } + + private reduceCoreLog( + event: Extract, + ): void { + this.state.coreLogs.push(event.line); + } + + private reduceWarning( + event: Extract, + ): void { + this.state.warnings.push(event.message); + } +} diff --git a/src/tui/workflow-tui.ts b/src/tui/workflow-tui.ts new file mode 100644 index 0000000..2e1c917 --- /dev/null +++ b/src/tui/workflow-tui.ts @@ -0,0 +1,203 @@ +import type { TuiRenderer } from "./opentui-platform.js"; +import { PlainRenderer } from "./opentui-platform.js"; +import type { TuiStateStore } from "./state-store.js"; +import { renderHeader } from "./components/header.js"; +import { renderStepList } from "./components/step-list.js"; +import { renderDetailPane } from "./components/detail-pane.js"; + +export class WorkflowTui { + private renderer: TuiRenderer; + private renderTimer: ReturnType | null = null; + private readonly abortController = new AbortController(); + private finished = false; + private dismissResolve: (() => void) | null = null; + + constructor( + private readonly store: TuiStateStore, + renderer?: TuiRenderer, + ) { + this.renderer = renderer ?? new PlainRenderer(); + } + + get signal(): AbortSignal { + return this.abortController.signal; + } + + start(): void { + this.renderer.start(); + + // Handle key presses + this.renderer.onKeypress((key) => this.handleKey(key)); + + // Render loop at ~30fps, only when dirty + this.renderTimer = setInterval(() => { + if (this.store.dirty) { + this.store.dirty = false; + this.renderFrame(); + } + }, 33); + + // Initial render + this.renderFrame(); + } + + /** + * Mark workflow as finished and wait for user to dismiss (q / Ctrl+C). + * The TUI stays open showing the final state. Resolves when user presses + * a dismiss key, then cleans up the renderer. + */ + async waitForDismiss(): Promise { + this.finished = true; + // Force a final render so the user sees the completed state + this.store.dirty = true; + + await new Promise((resolve) => { + this.dismissResolve = resolve; + }); + + this.stop(); + } + + stop(): void { + if (this.renderTimer) { + clearInterval(this.renderTimer); + this.renderTimer = null; + } + this.renderer.destroy(); + } + + private handleKey(key: string): void { + const state = this.store.state; + + // Ctrl+C + if (key === "\x03") { + if (this.finished) { + this.dismissResolve?.(); + return; + } + this.abortController.abort(); + return; + } + + // q - quit + if (key === "q") { + if (this.finished) { + this.dismissResolve?.(); + return; + } + this.abortController.abort(); + return; + } + + // j / down arrow - move selection down + if (key === "j" || key === "\x1b[B") { + this.moveSelection(1); + return; + } + + // k / up arrow - move selection up + if (key === "k" || key === "\x1b[A") { + this.moveSelection(-1); + return; + } + + // Tab numbers 1-6 for tab switching + if (key >= "1" && key <= "6") { + const tabs = ["overview", "logs", "diffs", "result", "core", "help"] as const; + const idx = parseInt(key) - 1; + if (idx < tabs.length) { + state.selectedTab = tabs[idx]!; + this.store.dirty = true; + } + return; + } + + // Enter - lock selection + if (key === "\r" || key === "\n") { + state.followMode = "selected"; + this.store.dirty = true; + return; + } + + // f - follow running step + if (key === "f") { + state.followMode = "running"; + this.store.dirty = true; + return; + } + + // space - toggle follow mode + if (key === " ") { + state.followMode = state.followMode === "running" ? "selected" : "running"; + this.store.dirty = true; + return; + } + } + + private moveSelection(delta: number): void { + const state = this.store.state; + const stepOrder = state.stepOrder; + if (stepOrder.length === 0) return; + + const currentIdx = state.selectedStepId + ? stepOrder.indexOf(state.selectedStepId) + : -1; + + let newIdx = currentIdx + delta; + if (newIdx < 0) newIdx = 0; + if (newIdx >= stepOrder.length) newIdx = stepOrder.length - 1; + + state.selectedStepId = stepOrder[newIdx]; + state.followMode = "selected"; + this.store.dirty = true; + } + + private renderFrame(): void { + const state = this.store.state; + + // Auto-follow: update selected step to latest running step + if (state.followMode === "running") { + const runningStep = state.stepOrder.find((id) => { + const s = state.steps.get(id); + return s && (s.status === "RUNNING" || s.status === "CHECKING"); + }); + if (runningStep) { + state.selectedStepId = runningStep; + } + } + + // Default selection + if (!state.selectedStepId && state.stepOrder.length > 0) { + state.selectedStepId = state.stepOrder[0]; + } + + try { + const { columns = 80, rows = 24 } = process.stderr; + const header = renderHeader(state, columns); + const contentHeight = rows - header.split("\n").length - 1; + + const leftWidth = Math.min(Math.max(24, Math.floor(columns * 0.3)), 40); + const rightWidth = columns - leftWidth - 3; // 3 for separator + + const stepList = renderStepList(state, leftWidth, contentHeight); + const detail = renderDetailPane(state, rightWidth, contentHeight); + + // Combine left and right panes + const leftLines = stepList.split("\n"); + const rightLines = detail.split("\n"); + const maxLines = Math.max(leftLines.length, rightLines.length, contentHeight); + + const bodyLines: string[] = []; + for (let i = 0; i < maxLines; i++) { + const left = (leftLines[i] ?? "").padEnd(leftWidth); + const right = rightLines[i] ?? ""; + bodyLines.push(`${left.slice(0, leftWidth)} \x1b[90m\u2502\x1b[0m ${right}`); + } + + const output = header + "\n" + bodyLines.join("\n"); + this.renderer.render(output); + } catch { + // Ignore render errors + } + } +} diff --git a/src/types/native-assets.d.ts b/src/types/native-assets.d.ts new file mode 100644 index 0000000..48b6bff --- /dev/null +++ b/src/types/native-assets.d.ts @@ -0,0 +1,14 @@ +declare module "*.so" { + const path: string; + export default path; +} + +declare module "*.dylib" { + const path: string; + export default path; +} + +declare module "*.dll" { + const path: string; + export default path; +} diff --git a/src/workflow/core-ipc-step-runner.ts b/src/workflow/core-ipc-step-runner.ts index 56c5fbf..4e3354d 100644 --- a/src/workflow/core-ipc-step-runner.ts +++ b/src/workflow/core-ipc-step-runner.ts @@ -30,6 +30,8 @@ import { generateId, ErrorClass, } from "../types/index.js"; +import type { ExecEventSink } from "../tui/exec-event.js"; +import { NoopExecEventSink } from "../tui/noop-sink.js"; function isPermitGranted( msg: PermitGrantedMessage | PermitRejectedMessage, @@ -45,6 +47,15 @@ export interface CoreIpcStepRunnerOptions { /** IPC request/response timeout for ack/permit/cancel (defaults to IpcProtocol default). */ ipcRequestTimeoutMs?: number; + + /** + * If true, capture Core's stderr as line events (ExecEvent: core_log). + * When enabled, Core stderr is NOT forwarded directly to this process's stderr. + */ + captureCoreStderr?: boolean; + + /** Optional event sink for TUI instrumentation. */ + sink?: ExecEventSink; } export class CoreIpcStepRunner implements StepRunner { @@ -52,6 +63,7 @@ export class CoreIpcStepRunner implements StepRunner { private readonly coreEntryPoint: string; private readonly verbose: boolean; private readonly ownsCoreProcess: boolean; + private readonly sink: ExecEventSink; private ipc: IpcProtocol | null = null; private readonly waiters = new Map void }>(); @@ -59,9 +71,23 @@ export class CoreIpcStepRunner implements StepRunner { constructor(options: CoreIpcStepRunnerOptions = {}) { this.verbose = options.verbose ?? false; this.coreEntryPoint = options.coreEntryPoint ?? "src/index.ts"; + this.sink = options.sink ?? new NoopExecEventSink(); + + const captureCoreStderr = options.captureCoreStderr ?? false; this.supervisor = new Supervisor({ coreEntryPoint: this.coreEntryPoint, + ...(captureCoreStderr + ? { + onCoreStderrLine: (line: string) => { + this.sink.emit({ + type: "core_log", + ts: Date.now(), + line, + }); + }, + } + : {}), ...(options.ipcRequestTimeoutMs !== undefined ? { ipc: { requestTimeoutMs: options.ipcRequestTimeoutMs } } : {}), @@ -276,6 +302,7 @@ export class CoreIpcStepRunner implements StepRunner { try { await this.submitJob(ipc, job, parentAbort); submitted = true; + this.sink.emit({ type: "step_phase", stepId, phase: "submitting_job", at: Date.now() }); const deadlineAt = Date.now() + task.timeoutMs; // Update job payload budget to reflect the post-ack execution deadline. @@ -283,7 +310,9 @@ export class CoreIpcStepRunner implements StepRunner { const payload = job.payload as { budget: { deadlineAt: number } }; payload.budget.deadlineAt = deadlineAt; + this.sink.emit({ type: "step_phase", stepId, phase: "waiting_permit", at: Date.now() }); await this.requestPermitUntilGranted(ipc, job, deadlineAt, parentAbort); + this.sink.emit({ type: "step_phase", stepId, phase: "executing", at: Date.now() }); execScoped = createScopedAbort(parentAbort, deadlineAt); onExecAbort = () => { @@ -319,6 +348,8 @@ export class CoreIpcStepRunner implements StepRunner { if (result.durationMs === 0) result.durationMs = wallTimeMs; } + this.sink.emit({ type: "worker_result", stepId, ts: Date.now(), result }); + if (this.verbose) { const text = extractWorkerText(result); if (text.trim()) { @@ -361,6 +392,7 @@ export class CoreIpcStepRunner implements StepRunner { errorClass, }; } finally { + this.sink.emit({ type: "step_phase", stepId, phase: "finalizing", at: Date.now() }); parentAbort.removeEventListener("abort", onParentAbort); if (execScoped && onExecAbort) { execScoped.signal.removeEventListener("abort", onExecAbort); diff --git a/src/workflow/executor.ts b/src/workflow/executor.ts index 462848e..c94560c 100644 --- a/src/workflow/executor.ts +++ b/src/workflow/executor.ts @@ -19,6 +19,8 @@ import { resolveTaskLike } from "./resolve-worker-task.js"; import type { CompletionDecisionSource } from "./completion-decision.js"; import type { BranchRuntimeContext } from "./branch-context.js"; import { toBranchWorkflowMeta } from "./branch-context.js"; +import type { ExecEventSink } from "../tui/exec-event.js"; +import { NoopExecEventSink } from "../tui/noop-sink.js"; // --------------------------------------------------------------------------- // Public interfaces @@ -160,6 +162,8 @@ export class WorkflowExecutor { private readonly env?: Record, private readonly abortSignal?: AbortSignal, private readonly branchContext?: BranchRuntimeContext, + private readonly supervised: boolean = false, + private readonly sink: ExecEventSink = new NoopExecEventSink(), ) { this.stepDefs = definition.steps; this.concurrency = @@ -189,6 +193,19 @@ export class WorkflowExecutor { startedAt, this.buildWorkflowMetaExtras(), ); + this.sink.emit({ + type: "workflow_started", + workflowId, + name: this.definition.name, + workspaceDir: this.workspaceDir, + supervised: this.supervised, + startedAt, + definitionSummary: { + steps: Object.keys(this.stepDefs), + concurrency: this.concurrency === Infinity ? undefined : this.concurrency, + timeout: this.definition.timeout, + }, + }); for (const [stepId, stepDef] of Object.entries(this.stepDefs)) { const maxIter = stepDef.max_iterations ?? 1; this.steps[stepId] = { @@ -243,6 +260,11 @@ export class WorkflowExecutor { this.abortSignal.removeEventListener("abort", onExternalAbort); } completedAt = Date.now(); + this.sink.emit({ + type: "workflow_finished", + status: workflowStatus, + completedAt, + }); await this.contextManager.writeWorkflowMeta({ id: workflowId, name: this.definition.name, @@ -351,6 +373,13 @@ export class WorkflowExecutor { if (deps.length === 0) { state.status = StepStatus.READY; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + }); continue; } @@ -374,9 +403,23 @@ export class WorkflowExecutor { if (shouldSkip) { state.status = StepStatus.SKIPPED; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + }); this.notify(); } else if (allResolved) { state.status = StepStatus.READY; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + }); } } } @@ -401,6 +444,21 @@ export class WorkflowExecutor { state.startedAt = Date.now(); this.runningCount++; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + startedAt: state.startedAt, + }); + this.sink.emit({ + type: "step_phase", + stepId, + phase: "executing", + at: Date.now(), + }); + // Fire-and-forget — the promise resolves when step reaches terminal state this.runStepLifecycle(stepId).catch(() => { // Ensure we always decrement and notify even on unexpected errors @@ -441,6 +499,14 @@ export class WorkflowExecutor { // Run the main worker state.status = StepStatus.RUNNING; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + startedAt: state.startedAt, + }); this.notify(); const stepAbort = this.createStepAbort(); @@ -515,6 +581,12 @@ export class WorkflowExecutor { const collectOutputsIfDefined = async () => { if (stepDef.outputs && stepDef.outputs.length > 0) { + this.sink.emit({ + type: "step_phase", + stepId, + phase: "collecting_outputs", + at: Date.now(), + }); await this.contextManager.collectOutputs(stepId, stepDef.outputs, this.workspaceDir); } }; @@ -524,11 +596,32 @@ export class WorkflowExecutor { await collectOutputsIfDefined(); state.status = StepStatus.SUCCEEDED; state.completedAt = Date.now(); + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + completedAt: state.completedAt, + }); return; } // Run completion check state.status = StepStatus.CHECKING; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + }); + this.sink.emit({ + type: "step_phase", + stepId, + phase: "checking", + at: Date.now(), + }); this.notify(); const checkAbort = this.createStepAbort(); @@ -591,6 +684,14 @@ export class WorkflowExecutor { if (effectiveCheckResult.complete) { state.status = StepStatus.SUCCEEDED; state.completedAt = Date.now(); + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + completedAt: state.completedAt, + }); return; } @@ -646,12 +747,27 @@ export class WorkflowExecutor { } else { state.status = StepStatus.INCOMPLETE; state.completedAt = Date.now(); + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + completedAt: state.completedAt, + }); } return; } // Loop: increment iteration and re-run state.iteration++; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + }); retryCount = 0; // reset retry count for new iteration } } finally { @@ -1108,6 +1224,15 @@ export class WorkflowExecutor { state.status = StepStatus.FAILED; state.completedAt = Date.now(); state.error = error; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + completedAt: state.completedAt, + error, + }); } private handleFailurePolicy( @@ -1137,6 +1262,13 @@ export class WorkflowExecutor { const deps = stepDef.depends_on ?? []; if (deps.includes(failedStepId)) { state.status = StepStatus.SKIPPED; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + }); } } // Transitively skip dependents of skipped steps @@ -1151,6 +1283,13 @@ export class WorkflowExecutor { const depState = this.steps[depId]; if (depState && depState.status === StepStatus.SKIPPED) { state.status = StepStatus.SKIPPED; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + }); changed = true; break; } @@ -1164,15 +1303,30 @@ export class WorkflowExecutor { // ----------------------------------------------------------------------- private handleWorkflowTimeout(): void { - for (const [_stepId, state] of Object.entries(this.steps)) { + for (const [stepId, state] of Object.entries(this.steps)) { if (state.status === StepStatus.RUNNING || state.status === StepStatus.CHECKING) { state.status = StepStatus.CANCELLED; state.completedAt = Date.now(); + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + completedAt: state.completedAt, + }); } else if ( state.status === StepStatus.PENDING || state.status === StepStatus.READY ) { state.status = StepStatus.SKIPPED; + this.sink.emit({ + type: "step_state", + stepId, + status: state.status, + iteration: state.iteration, + maxIterations: state.maxIterations, + }); } } } diff --git a/src/workflow/multi-worker-step-runner.ts b/src/workflow/multi-worker-step-runner.ts index ddfb945..0e4e9fe 100644 --- a/src/workflow/multi-worker-step-runner.ts +++ b/src/workflow/multi-worker-step-runner.ts @@ -29,6 +29,8 @@ import { ErrorClass, } from "../types/index.js"; import type { WorkerTask, WorkerResult } from "../types/index.js"; +import type { ExecEventSink } from "../tui/exec-event.js"; +import { NoopExecEventSink } from "../tui/noop-sink.js"; type LlmWorker = Exclude; @@ -38,7 +40,10 @@ export class MultiWorkerStepRunner implements StepRunner { private readonly adapters: Record; - constructor(private readonly verbose: boolean = false) { + constructor( + private readonly verbose: boolean = false, + private readonly sink: ExecEventSink = new NoopExecEventSink(), + ) { this.shell = new ShellStepRunner(verbose); this.pm = new ProcessManager(); @@ -156,6 +161,13 @@ export class MultiWorkerStepRunner implements StepRunner { ): Promise { const result = await this.runWorkerTask(stepId, adapter, task); + this.sink.emit({ + type: "worker_result", + stepId, + ts: Date.now(), + result, + }); + if (result.status === WorkerStatus.SUCCEEDED) { return { status: "SUCCEEDED", @@ -214,17 +226,23 @@ export class MultiWorkerStepRunner implements StepRunner { taskForAdapter.abortSignal.addEventListener("abort", onAbort, { once: true }); } - const streamDone = this.verbose - ? (async () => { - try { - for await (const ev of adapter.streamEvents(handle)) { - this.printWorkerEvent(stepId, ev); - } - } catch { - // Ignore stream errors on cancellation + const streamDone = (async () => { + try { + for await (const ev of adapter.streamEvents(handle)) { + this.sink.emit({ + type: "worker_event", + stepId, + ts: Date.now(), + event: ev, + }); + if (this.verbose) { + this.printWorkerEvent(stepId, ev); } - })() - : Promise.resolve(); + } + } catch { + // Ignore stream errors on cancellation + } + })(); try { const result = await adapter.awaitResult(handle); diff --git a/src/workflow/run.ts b/src/workflow/run.ts index 32ba0a0..4ff1fb4 100644 --- a/src/workflow/run.ts +++ b/src/workflow/run.ts @@ -24,6 +24,10 @@ import { resolveBranchRuntimeContext, type BranchRuntimeContext, } from "./branch-context.js"; +import { TuiStateStore, NoopExecEventSink } from "../tui/index.js"; +import type { ExecEventSink } from "../tui/index.js"; +import { WorkflowTui } from "../tui/workflow-tui.js"; +import { createOpenTuiRenderer, PlainRenderer } from "../tui/opentui-platform.js"; function isRunningUnderBun(): boolean { const base = path.basename(process.execPath).toLowerCase(); @@ -172,6 +176,21 @@ function resolveKeepaliveIntervalMs(keepaliveInterval: string | undefined): numb } } +function resolveTuiMode( + cliTui: boolean | undefined, + envTui: string | undefined, +): boolean { + // 1. Explicit CLI flag + if (cliTui !== undefined) return cliTui; + + // 2. Environment variable + const envVal = parseEnvBool(envTui); + if (envVal !== undefined) return envVal; + + // 3. Auto: enabled when stderr is a TTY + return !!process.stderr.isTTY; +} + // ── Main ──────────────────────────────────────────────────────── const STATUS_ICON: Record = { @@ -238,6 +257,7 @@ export async function runWorkflowCli(argv: string[]): Promise { let cliAllowProtectedBranch = false; const agentsFiles: string[] = []; let coreEntryPointOverride: string | undefined; + let tuiMode: boolean | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]!; @@ -280,6 +300,10 @@ export async function runWorkflowCli(argv: string[]): Promise { } else if (arg === "--core" || arg === "--core-entrypoint") { i++; coreEntryPointOverride = args[i] ?? ""; + } else if (arg === "--tui") { + tuiMode = true; + } else if (arg === "--no-tui") { + tuiMode = false; } else if (arg === "--help" || arg === "-h") { console.log(`Usage: roboppi workflow [options] @@ -299,6 +323,8 @@ Options: --protected-branches Protected work branches (default: main,master,release/*) --allow-protected-branch Allow execution on protected branch (dangerous) --agents Agent catalog YAML (repeatable; merged with ROBOPPI_AGENTS_FILE; CLI wins on conflicts) + --tui Enable TUI mode (interactive workflow visualization) + --no-tui Disable TUI mode (plain output) --core Core entrypoint for supervised mode (default: auto) --help, -h Show help`); process.exit(0); @@ -385,14 +411,23 @@ Options: process.env.ROBOPPI_SUPERVISED_IPC_TRANSPORT = isNonInteractive() ? "socket" : "stdio"; } + // TUI setup + const useTui = resolveTuiMode(tuiMode, process.env.ROBOPPI_TUI); + const sink: ExecEventSink = useTui + ? new TuiStateStore({ supervised }) + : new NoopExecEventSink(); + let tui: WorkflowTui | null = null; + // 4. Execute const runner = supervised ? new CoreIpcStepRunner({ verbose, coreEntryPoint: resolveCoreEntryPointForSupervised(coreEntryPointOverride), ipcRequestTimeoutMs: resolveIpcRequestTimeoutMs(true, ipcRequestTimeout), + captureCoreStderr: useTui && sink instanceof TuiStateStore, + sink, }) - : new MultiWorkerStepRunner(verbose); + : new MultiWorkerStepRunner(verbose, sink); let shuttingDown = false; const shutdown = async (reason: string, exitCode: number) => { @@ -416,6 +451,13 @@ Options: process.on("SIGTERM", () => { void shutdown("SIGTERM", 143); }); + } else if (useTui) { + // TUI handles Ctrl+C via its own abort controller + // But we still need SIGTERM handling + process.on("SIGTERM", () => { + if (tui) tui.stop(); + process.exit(143); + }); } const ctx = new ContextManager(contextDir); @@ -456,28 +498,55 @@ Options: } const executorEnv = Object.keys(workflowEnv).length > 0 ? workflowEnv : undefined; + let workflowAbortSignal: AbortSignal | undefined; + + if (useTui && sink instanceof TuiStateStore) { + let renderer; + try { + renderer = await createOpenTuiRenderer(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + sink.emit({ + type: "warning", + ts: Date.now(), + message: `OpenTUI init failed; falling back to plain renderer: ${msg}`, + }); + renderer = new PlainRenderer(); + } + + tui = new WorkflowTui(sink, renderer); + workflowAbortSignal = tui.signal; + } + const executor = new WorkflowExecutor( definition, ctx, runner, ws, executorEnv, - undefined, + workflowAbortSignal, branchContext, + supervised, + sink, ); const startTime = Date.now(); const keepaliveEnabled = resolveKeepaliveEnabled(keepalive); - const keepaliveIntervalMs = keepaliveEnabled ? resolveKeepaliveIntervalMs(keepaliveInterval) : 0; + const effectiveKeepalive = useTui ? false : keepaliveEnabled; + const keepaliveIntervalMs = effectiveKeepalive ? resolveKeepaliveIntervalMs(keepaliveInterval) : 0; let keepaliveTimer: ReturnType | null = null; - if (keepaliveEnabled) { + if (effectiveKeepalive) { keepaliveTimer = setInterval(() => { const elapsedS = Math.floor((Date.now() - startTime) / 1000); process.stderr.write(`[workflow] keepalive: running (${elapsedS}s elapsed)\n`); }, keepaliveIntervalMs); } + if (tui) { + tui.start(); + } + let state; try { state = await executor.execute(); @@ -493,9 +562,15 @@ Options: } } } + + // TUI mode: keep TUI open on the final state until user dismisses + if (tui) { + await tui.waitForDismiss(); + } + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - // 5. Report results + // 5. Report results (printed after TUI exits alternate screen) console.log(""); console.log("\x1b[1m─── Results ───\x1b[0m"); console.log(""); diff --git a/test/unit/tui/ring-buffer.test.ts b/test/unit/tui/ring-buffer.test.ts new file mode 100644 index 0000000..d775277 --- /dev/null +++ b/test/unit/tui/ring-buffer.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect } from "bun:test"; +import { RingBuffer } from "../../../src/tui/ring-buffer.js"; + +describe("RingBuffer", () => { + test("pushes and retrieves items in insertion order", () => { + const buf = new RingBuffer({ maxLines: 10 }); + buf.push("a"); + buf.push("b"); + buf.push("c"); + expect(buf.lines()).toEqual(["a", "b", "c"]); + }); + + test("evicts oldest when maxLines exceeded", () => { + const buf = new RingBuffer({ maxLines: 3 }); + buf.push("a"); + buf.push("b"); + buf.push("c"); + buf.push("d"); + expect(buf.lines()).toEqual(["b", "c", "d"]); + expect(buf.length).toBe(3); + }); + + test("evicts oldest when maxBytes exceeded", () => { + // Each item is 5 chars, maxBytes=12 means at most 2 items fit + const buf = new RingBuffer({ maxLines: 100, maxBytes: 12 }); + buf.push("aaaaa"); // 5 bytes, total=5 + buf.push("bbbbb"); // 5 bytes, total=10 + buf.push("ccccc"); // 5 bytes, total=15 > 12, evict "aaaaa" => total=10 + expect(buf.lines()).toEqual(["bbbbb", "ccccc"]); + expect(buf.totalBytes).toBe(10); + }); + + test("handles combined line and byte limits", () => { + // maxLines=5, maxBytes=8 + const buf = new RingBuffer({ maxLines: 5, maxBytes: 8 }); + buf.push("aaa"); // 3 bytes + buf.push("bbb"); // 3 bytes, total=6 + buf.push("ccc"); // 3 bytes, total=9 > 8, evict "aaa" => total=6 + expect(buf.lines()).toEqual(["bbb", "ccc"]); + expect(buf.length).toBe(2); + expect(buf.totalBytes).toBe(6); + }); + + test("clear() resets state", () => { + const buf = new RingBuffer({ maxLines: 10 }); + buf.push("a"); + buf.push("b"); + buf.clear(); + expect(buf.lines()).toEqual([]); + expect(buf.length).toBe(0); + expect(buf.totalBytes).toBe(0); + }); + + test("length and totalBytes track correctly", () => { + const buf = new RingBuffer({ maxLines: 10 }); + expect(buf.length).toBe(0); + expect(buf.totalBytes).toBe(0); + + buf.push("hello"); // 5 bytes + expect(buf.length).toBe(1); + expect(buf.totalBytes).toBe(5); + + buf.push("world!"); // 6 bytes + expect(buf.length).toBe(2); + expect(buf.totalBytes).toBe(11); + }); + + test("works with empty buffer", () => { + const buf = new RingBuffer({ maxLines: 10 }); + expect(buf.lines()).toEqual([]); + expect(buf.length).toBe(0); + expect(buf.totalBytes).toBe(0); + }); + + test("uses default limits when no options provided", () => { + const buf = new RingBuffer(); + // Should not throw when pushing items + for (let i = 0; i < 100; i++) { + buf.push(`line ${i}`); + } + expect(buf.length).toBe(100); + }); + + test("lines() returns a copy, not the internal buffer", () => { + const buf = new RingBuffer({ maxLines: 10 }); + buf.push("a"); + const snapshot = buf.lines(); + buf.push("b"); + expect(snapshot).toEqual(["a"]); + expect(buf.lines()).toEqual(["a", "b"]); + }); +}); diff --git a/test/unit/tui/state-store.test.ts b/test/unit/tui/state-store.test.ts new file mode 100644 index 0000000..c0e17a4 --- /dev/null +++ b/test/unit/tui/state-store.test.ts @@ -0,0 +1,300 @@ +import { describe, test, expect } from "bun:test"; +import { TuiStateStore } from "../../../src/tui/state-store.js"; +import type { ExecEvent } from "../../../src/tui/exec-event.js"; +import { WorkflowStatus, StepStatus } from "../../../src/workflow/types.js"; +import { WorkerStatus } from "../../../src/types/worker-result.js"; + +describe("TuiStateStore", () => { + test("initializes with default state", () => { + const store = new TuiStateStore(); + expect(store.state.steps.size).toBe(0); + expect(store.state.stepOrder).toEqual([]); + expect(store.state.followMode).toBe("running"); + expect(store.state.selectedTab).toBe("overview"); + expect(store.state.coreLogs.length).toBe(0); + expect(store.state.warnings.length).toBe(0); + expect(store.dirty).toBe(false); + }); + + test("handles workflow_started event", () => { + const store = new TuiStateStore(); + const event: ExecEvent = { + type: "workflow_started", + workflowId: "wf-1", + name: "test-workflow", + workspaceDir: "/tmp/ws", + supervised: true, + startedAt: 1000, + definitionSummary: { + steps: ["step-a", "step-b"], + concurrency: 2, + timeout: "30m", + }, + }; + + store.emit(event); + + expect(store.state.workflowId).toBe("wf-1"); + expect(store.state.name).toBe("test-workflow"); + expect(store.state.workspaceDir).toBe("/tmp/ws"); + expect(store.state.supervised).toBe(true); + expect(store.state.startedAt).toBe(1000); + expect(store.state.status).toBe("RUNNING"); + expect(store.state.steps.size).toBe(2); + expect(store.state.stepOrder).toEqual(["step-a", "step-b"]); + expect(store.state.steps.get("step-a")?.status).toBe("PENDING"); + expect(store.state.steps.get("step-b")?.status).toBe("PENDING"); + }); + + test("handles workflow_finished event", () => { + const store = new TuiStateStore(); + store.emit({ + type: "workflow_started", + workflowId: "wf-1", + name: "test", + workspaceDir: "/tmp", + supervised: false, + startedAt: 1000, + }); + store.emit({ + type: "workflow_finished", + status: WorkflowStatus.SUCCEEDED, + completedAt: 2000, + }); + + expect(store.state.status).toBe(WorkflowStatus.SUCCEEDED); + expect(store.state.finishedAt).toBe(2000); + }); + + test("handles step_state event (create + update)", () => { + const store = new TuiStateStore(); + + // First event creates the step + store.emit({ + type: "step_state", + stepId: "s1", + status: StepStatus.RUNNING, + iteration: 1, + maxIterations: 3, + startedAt: 1000, + }); + + const step = store.state.steps.get("s1")!; + expect(step.status).toBe("RUNNING"); + expect(step.iteration).toBe(1); + expect(step.maxIterations).toBe(3); + expect(step.startedAt).toBe(1000); + + // Second event updates it + store.emit({ + type: "step_state", + stepId: "s1", + status: StepStatus.SUCCEEDED, + iteration: 2, + maxIterations: 3, + completedAt: 2000, + }); + + expect(step.status).toBe("SUCCEEDED"); + expect(step.iteration).toBe(2); + expect(step.completedAt).toBe(2000); + // startedAt should still be set from before + expect(step.startedAt).toBe(1000); + }); + + test("handles step_phase event", () => { + const store = new TuiStateStore(); + store.emit({ + type: "step_phase", + stepId: "s1", + phase: "executing", + at: 1000, + }); + + const step = store.state.steps.get("s1")!; + expect(step.phase).toBe("executing"); + }); + + test("handles worker_event stdout", () => { + const store = new TuiStateStore(); + store.emit({ + type: "worker_event", + stepId: "s1", + ts: 1000, + event: { type: "stdout", data: "hello world" }, + }); + + const step = store.state.steps.get("s1")!; + expect(step.logs.stdout.lines()).toEqual(["hello world"]); + }); + + test("handles worker_event stderr", () => { + const store = new TuiStateStore(); + store.emit({ + type: "worker_event", + stepId: "s1", + ts: 1000, + event: { type: "stderr", data: "error msg" }, + }); + + const step = store.state.steps.get("s1")!; + expect(step.logs.stderr.lines()).toEqual(["error msg"]); + }); + + test("handles worker_event progress", () => { + const store = new TuiStateStore(); + store.emit({ + type: "worker_event", + stepId: "s1", + ts: 1500, + event: { type: "progress", message: "50% done", percent: 50 }, + }); + + const step = store.state.steps.get("s1")!; + expect(step.logs.progress.lines()).toEqual(["50% done"]); + expect(step.progress).toEqual({ + ts: 1500, + message: "50% done", + percent: 50, + }); + }); + + test("handles worker_event patch", () => { + const store = new TuiStateStore(); + store.emit({ + type: "worker_event", + stepId: "s1", + ts: 1000, + event: { type: "patch", filePath: "src/foo.ts", diff: "+line1" }, + }); + + const step = store.state.steps.get("s1")!; + expect(step.patches.order.length).toBe(1); + const patchId = step.patches.order[0]!; + const entry = step.patches.byId.get(patchId)!; + expect(entry.stepId).toBe("s1"); + expect(entry.filePath).toBe("src/foo.ts"); + expect(entry.diff).toBe("+line1"); + expect(step.patches.byFilePath.get("src/foo.ts")).toEqual([patchId]); + }); + + test("handles worker_result event", () => { + const store = new TuiStateStore(); + const result = { + status: WorkerStatus.SUCCEEDED, + artifacts: [], + observations: [], + cost: { wallTimeMs: 500 }, + durationMs: 500, + }; + store.emit({ + type: "worker_result", + stepId: "s1", + ts: 2000, + result, + }); + + const step = store.state.steps.get("s1")!; + expect(step.result).toBe(result); + }); + + test("handles core_log event", () => { + const store = new TuiStateStore(); + store.emit({ type: "core_log", ts: 1000, line: "core started" }); + store.emit({ type: "core_log", ts: 1001, line: "permit issued" }); + + expect(store.state.coreLogs.lines()).toEqual([ + "core started", + "permit issued", + ]); + }); + + test("handles warning event", () => { + const store = new TuiStateStore(); + store.emit({ + type: "warning", + ts: 1000, + message: "high latency detected", + }); + + expect(store.state.warnings.lines()).toEqual(["high latency detected"]); + }); + + test("sets dirty flag on emit", () => { + const store = new TuiStateStore(); + expect(store.dirty).toBe(false); + + store.emit({ type: "core_log", ts: 1000, line: "test" }); + expect(store.dirty).toBe(true); + + // Consumer resets dirty + store.dirty = false; + expect(store.dirty).toBe(false); + + store.emit({ type: "warning", ts: 1001, message: "warn" }); + expect(store.dirty).toBe(true); + }); + + test("creates placeholder step for unknown stepId", () => { + const store = new TuiStateStore(); + const step = store.getOrCreateStep("unknown-step"); + + expect(step.stepId).toBe("unknown-step"); + expect(step.status).toBe("PENDING"); + expect(step.iteration).toBe(0); + expect(step.maxIterations).toBe(1); + expect(store.state.stepOrder).toContain("unknown-step"); + }); + + test("patch index tracks byId, order, byFilePath correctly", () => { + const store = new TuiStateStore(); + + // Two patches to same file + store.emit({ + type: "worker_event", + stepId: "s1", + ts: 1000, + event: { type: "patch", filePath: "src/a.ts", diff: "+first" }, + }); + store.emit({ + type: "worker_event", + stepId: "s1", + ts: 1001, + event: { type: "patch", filePath: "src/a.ts", diff: "+second" }, + }); + + // One patch to a different file + store.emit({ + type: "worker_event", + stepId: "s1", + ts: 1002, + event: { type: "patch", filePath: "src/b.ts", diff: "+other" }, + }); + + const step = store.state.steps.get("s1")!; + expect(step.patches.order.length).toBe(3); + expect(step.patches.byId.size).toBe(3); + + // byFilePath should group correctly + const aPatches = step.patches.byFilePath.get("src/a.ts")!; + expect(aPatches.length).toBe(2); + const bPatches = step.patches.byFilePath.get("src/b.ts")!; + expect(bPatches.length).toBe(1); + + // Verify ordering matches insertion + expect(step.patches.order[0]).toBe(aPatches[0]!); + expect(step.patches.order[1]).toBe(aPatches[1]!); + expect(step.patches.order[2]).toBe(bPatches[0]!); + + // Verify entries have correct data + const firstEntry = step.patches.byId.get(aPatches[0]!)!; + expect(firstEntry.diff).toBe("+first"); + const secondEntry = step.patches.byId.get(aPatches[1]!)!; + expect(secondEntry.diff).toBe("+second"); + }); + + test("accepts supervised option in constructor", () => { + const store = new TuiStateStore({ supervised: true }); + expect(store.state.supervised).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index b0d4816..f8f7d05 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true,