From 0dc098f62b6de0cb85814b9356b03227d2275d69 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 12 Jan 2026 10:31:12 -0800 Subject: [PATCH] feat: add tmux-viewer TUI for viewing CLI test sessions - Add scripts/tmux/tmux-viewer/ with OpenTUI session viewer - Session replay with timeline navigation - GIF export functionality - Themed terminal output display --- .agents/tsconfig.json | 4 +- bun.lock | 55 +- cli/knowledge.md | 2 +- cli/scripts/validate-cli-with-tmux.sh | 26 +- cli/tmux.knowledge.md | 6 +- knowledge.md | 4 + package.json | 4 + scripts/tmux/package.json | 9 + scripts/tmux/tmux-start.sh | 2 +- scripts/tmux/tmux-viewer/README.md | 245 ++++++++ .../tmux-viewer/components/session-viewer.tsx | 551 ++++++++++++++++++ scripts/tmux/tmux-viewer/components/theme.ts | 54 ++ scripts/tmux/tmux-viewer/gif-encoder-2.d.ts | 28 + scripts/tmux/tmux-viewer/gif-exporter.ts | 268 +++++++++ scripts/tmux/tmux-viewer/index.tsx | 206 +++++++ scripts/tmux/tmux-viewer/package.json | 9 + scripts/tmux/tmux-viewer/session-loader.ts | 232 ++++++++ scripts/tmux/tmux-viewer/tsconfig.json | 9 + scripts/tmux/tmux-viewer/types.ts | 76 +++ 19 files changed, 1762 insertions(+), 28 deletions(-) create mode 100644 scripts/tmux/package.json create mode 100644 scripts/tmux/tmux-viewer/README.md create mode 100644 scripts/tmux/tmux-viewer/components/session-viewer.tsx create mode 100644 scripts/tmux/tmux-viewer/components/theme.ts create mode 100644 scripts/tmux/tmux-viewer/gif-encoder-2.d.ts create mode 100644 scripts/tmux/tmux-viewer/gif-exporter.ts create mode 100644 scripts/tmux/tmux-viewer/index.tsx create mode 100644 scripts/tmux/tmux-viewer/package.json create mode 100644 scripts/tmux/tmux-viewer/session-loader.ts create mode 100644 scripts/tmux/tmux-viewer/tsconfig.json create mode 100644 scripts/tmux/tmux-viewer/types.ts diff --git a/.agents/tsconfig.json b/.agents/tsconfig.json index dbb372c16..5b07ae209 100644 --- a/.agents/tsconfig.json +++ b/.agents/tsconfig.json @@ -4,10 +4,12 @@ "baseUrl": ".", "skipLibCheck": true, "types": ["bun", "node"], + "jsx": "react-jsx", + "jsxImportSource": "@opentui/react", "paths": { "@codebuff/sdk": ["../sdk/src/index.ts"], "@codebuff/common/*": ["../common/src/*"] } }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/bun.lock b/bun.lock index 3235dafd8..66dc52169 100644 --- a/bun.lock +++ b/bun.lock @@ -6,11 +6,14 @@ "name": "codebuff-project", "dependencies": { "@t3-oss/env-nextjs": "^0.7.3", + "canvas": "^3.2.0", + "gif-encoder-2": "^1.0.5", "zod": "^4.2.1", }, "devDependencies": { "@tanstack/react-query": "^5.90.12", "@types/bun": "^1.3.5", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.21", "@types/node": "^22.9.0", "@types/node-fetch": "^2.6.12", @@ -32,6 +35,10 @@ "typescript-eslint": "^7.17.0", }, }, + ".agents": { + "name": "@codebuff/.agents", + "version": "0.0.0", + }, "agents": { "name": "@codebuff/agents", "version": "0.0.0", @@ -447,6 +454,8 @@ "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + "@codebuff/.agents": ["@codebuff/.agents@workspace:.agents"], + "@codebuff/agent-runtime": ["@codebuff/agent-runtime@workspace:packages/agent-runtime"], "@codebuff/agents": ["@codebuff/agents@workspace:agents"], @@ -1277,6 +1286,8 @@ "@types/jest": ["@types/jest@29.5.14", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/jsdom": ["@types/jsdom@20.0.1", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -1593,6 +1604,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001752", "", {}, "sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g=="], + "canvas": ["canvas@3.2.1", "", { "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" } }, "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="], @@ -1615,6 +1628,8 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], @@ -1811,8 +1826,12 @@ "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "dedent": ["dedent@1.7.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -2023,6 +2042,8 @@ "exit": ["exit@0.1.2", "", {}, "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], @@ -2143,10 +2164,14 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "gif-encoder-2": ["gif-encoder-2@1.0.5", "", {}, "sha512-fsRAKbZuUoZ7FYGjpFElmflTkKwsn/CzAmL/xDl4558aTAgysIDCUF6AXWO8dmai/ApfZACbPVAM+vPezJXlFg=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "git-raw-commits": ["git-raw-commits@4.0.0", "", { "dependencies": { "dargs": "^8.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "git-raw-commits": "cli.mjs" } }, "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob": ["glob@10.3.10", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.5", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -2259,7 +2284,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], @@ -2721,6 +2746,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "min-document": ["min-document@2.19.2", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A=="], "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], @@ -2733,6 +2760,8 @@ "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], @@ -2751,6 +2780,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -2769,6 +2800,10 @@ "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + "node-abi": ["node-abi@3.85.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], @@ -2983,6 +3018,8 @@ "preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], @@ -3013,6 +3050,8 @@ "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="], + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], @@ -3033,6 +3072,8 @@ "raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], @@ -3195,6 +3236,10 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -3301,6 +3346,8 @@ "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], "teeny-request": ["teeny-request@9.0.0", "", { "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" } }, "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g=="], @@ -3383,6 +3430,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "typanion": ["typanion@3.14.0", "", {}, "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -3869,6 +3918,8 @@ "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "global-directory/ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], + "globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -4023,6 +4074,8 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "react-reconciler/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], diff --git a/cli/knowledge.md b/cli/knowledge.md index 7be0baba0..47ce6d807 100644 --- a/cli/knowledge.md +++ b/cli/knowledge.md @@ -53,7 +53,7 @@ SESSION=$(./scripts/tmux/tmux-cli.sh start) ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 2 --label "after-help" # View session data -bun .agents/tmux-viewer/index.tsx "$SESSION" --json +bun scripts/tmux/tmux-viewer/index.tsx "$SESSION" --json # Clean up ./scripts/tmux/tmux-cli.sh stop "$SESSION" diff --git a/cli/scripts/validate-cli-with-tmux.sh b/cli/scripts/validate-cli-with-tmux.sh index ca160c8ef..3cb6ac61c 100755 --- a/cli/scripts/validate-cli-with-tmux.sh +++ b/cli/scripts/validate-cli-with-tmux.sh @@ -2,32 +2,16 @@ # Simple tmux-based CLI validation script # Usage: ./cli/scripts/validate-cli-with-tmux.sh +# +# Uses scripts/tmux/tmux-start.sh as the single source of truth for +# terminal dimensions and session creation. set -e -SESSION_NAME="cli-validation-$(date +%s)" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" - - -# Check if tmux is available -if ! command -v tmux &> /dev/null; then - echo "❌ tmux not found" - echo "" - echo "📦 Installation:" - echo " macOS: brew install tmux" - echo " Ubuntu: sudo apt-get install tmux" - echo " Arch: sudo pacman -S tmux" - echo "" - exit 1 -fi - - - -# Create tmux session running CLI -tmux new-session -d -s "$SESSION_NAME" \ - -x 120 -y 30 \ - "cd $PROJECT_ROOT && bun --cwd=cli run dev 2>&1" 2>/dev/null +# Use tmux-start.sh to create the session (handles tmux availability check too) +SESSION_NAME=$("$PROJECT_ROOT/scripts/tmux/tmux-start.sh" --name "cli-validation-$(date +%s)" --wait 2) # Capture output at intervals sleep 2 diff --git a/cli/tmux.knowledge.md b/cli/tmux.knowledge.md index 5e3b42360..c213e8d58 100644 --- a/cli/tmux.knowledge.md +++ b/cli/tmux.knowledge.md @@ -51,13 +51,13 @@ Use the **tmux-viewer** to inspect sessions: ```bash # Interactive TUI (for humans) -bun .agents/tmux-viewer/index.tsx +bun scripts/tmux/tmux-viewer/index.tsx # JSON output (for AI consumption) -bun .agents/tmux-viewer/index.tsx --json +bun scripts/tmux/tmux-viewer/index.tsx --json # List available sessions -bun .agents/tmux-viewer/index.tsx --list +bun scripts/tmux/tmux-viewer/index.tsx --list ``` ### CLI Tmux Tester Agent diff --git a/knowledge.md b/knowledge.md index 3b90c1cda..9714569c2 100644 --- a/knowledge.md +++ b/knowledge.md @@ -99,6 +99,10 @@ Prefer `ErrorOr` return values (`success(...)`/`failure(...)` in `common/src/ CLI hook testing note: React 19 + Bun + RTL `renderHook()` is unreliable; prefer integration tests via components for hook behavior. +### CLI tmux Testing + +For testing CLI behavior via tmux, use the helper scripts in `scripts/tmux/`. These handle bracketed paste mode and session logging automatically. Session data is saved to `debug/tmux-sessions/` in YAML format and can be viewed with `bun scripts/tmux/tmux-viewer/index.tsx`. See `scripts/tmux/README.md` for details. + ## Environment Variables Quick rules: diff --git a/package.json b/package.json index a839625a5..d5742353c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "Apache-2.0", "type": "module", "workspaces": [ + ".agents", "common", "web", "packages/*", @@ -35,6 +36,8 @@ }, "dependencies": { "@t3-oss/env-nextjs": "^0.7.3", + "canvas": "^3.2.0", + "gif-encoder-2": "^1.0.5", "zod": "^4.2.1" }, "overrides": { @@ -44,6 +47,7 @@ "devDependencies": { "@tanstack/react-query": "^5.90.12", "@types/bun": "^1.3.5", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.21", "@types/node": "^22.9.0", "@types/node-fetch": "^2.6.12", diff --git a/scripts/tmux/package.json b/scripts/tmux/package.json new file mode 100644 index 000000000..83625897b --- /dev/null +++ b/scripts/tmux/package.json @@ -0,0 +1,9 @@ +{ + "name": "@codebuff/tmux-scripts", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "view-session": "bun run tmux-viewer/index.tsx" + } +} diff --git a/scripts/tmux/tmux-start.sh b/scripts/tmux/tmux-start.sh index 3bd103eed..1f1683970 100755 --- a/scripts/tmux/tmux-start.sh +++ b/scripts/tmux/tmux-start.sh @@ -47,7 +47,7 @@ set -e # Defaults SESSION_NAME="" WIDTH=120 -HEIGHT=80 # Tall enough to capture most output without scrolling +HEIGHT=30 # Reasonable default that matches typical terminal heights WAIT_SECONDS=4 # Parse arguments diff --git a/scripts/tmux/tmux-viewer/README.md b/scripts/tmux/tmux-viewer/README.md new file mode 100644 index 000000000..e7ed9aa8c --- /dev/null +++ b/scripts/tmux/tmux-viewer/README.md @@ -0,0 +1,245 @@ +# tmux-viewer + +Interactive TUI for viewing tmux session logs. Designed to work for **both humans and AIs**. + +## Usage + +```bash +# Interactive TUI (for humans) +bun scripts/tmux/tmux-viewer/index.tsx + +# Start in replay mode (auto-plays through captures like a video) +bun scripts/tmux/tmux-viewer/index.tsx --replay + +# JSON output (for AIs) +bun scripts/tmux/tmux-viewer/index.tsx --json + +# Export as animated GIF +bun scripts/tmux/tmux-viewer/index.tsx --export-gif output.gif + +# Export with custom frame delay (default: 1500ms) +bun scripts/tmux/tmux-viewer/index.tsx --export-gif output.gif --frame-delay 2000 + +# Export with custom font size (default: 14px) +bun scripts/tmux/tmux-viewer/index.tsx --export-gif output.gif --font-size 16 + +# List available sessions +bun scripts/tmux/tmux-viewer/index.tsx --list + +# View most recent session (if no session specified) +bun scripts/tmux/tmux-viewer/index.tsx +``` + +Or using the npm script: + +```bash +cd scripts/tmux && bun run view-session +``` + +## Layout + +The TUI uses a vertical layout designed for clarity: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Session: my-session 120x30 5 cmds 10 captures │ ← Header +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ │ +│ │ [terminal output │ │ ← Capture +│ │ centered in │ │ View +│ │ muted border] │ │ +│ └──────────────────┘ │ +│ │ +├─ ⏸ Paused ──────────────────────────────────────────────────────┤ +│ ┌─○ [1] 12:00:00─┐ ┌─▶ [2] 12:00:05─┐ ┌─○ [3] 12:00:10─┐ │ ← Timeline +│ │ initial-state │ │ after-command │ │ final-state │ │ Cards +│ │ $ codebuff... │ │ $ /help │ │ $ /quit │ │ +│ └────────────────┘ └────────────────┘ └────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ ▶ 2/10 @1.5s space: play/pause +/-: speed ←→: navigate │ ← Footer +└─────────────────────────────────────────────────────────────────┘ +``` + +- **Header**: Session name, dimensions, command/capture counts +- **Capture View**: Terminal output centered with a muted border showing exact capture dimensions +- **Timeline**: Horizontal card-style navigation at the bottom, selected card stays centered +- **Footer**: Playback status, position, speed, and keyboard shortcuts + +## Features + +### For Humans (Interactive TUI) +- **Capture view**: Terminal output centered with visible boundary +- **Timeline panel**: Card-style navigation at the bottom with label and triggering command +- **Auto-centering**: Selected timeline card stays centered in view +- **Metadata display**: Session info, dimensions, command count +- **Replay mode**: Auto-play through captures like a video player +- **Keyboard shortcuts**: + - `←` / `→` or `h` / `l`: Navigate between captures + - `Space`: Play/pause replay + - `+` / `-`: Adjust playback speed (faster/slower) + - `r`: Restart from beginning + - `q` or Ctrl+C: Quit + - Use the `--json` flag on the CLI entrypoint for JSON output + +### Replay Mode + +Replay mode auto-advances through captures chronologically, like a video player: + +```bash +# Start replay immediately +bun scripts/tmux/tmux-viewer/index.tsx my-session --replay + +# Or press Space in the TUI to start/stop replay +``` + +**Playback controls:** +- `Space` - Toggle play/pause +- `+` or `=` - Speed up (shorter interval between captures) +- `-` or `_` - Slow down (longer interval between captures) +- `r` - Restart from the first capture +- `←` / `→` - Navigate captures (automatically pauses replay) + +**Available speeds:** 0.5s, 1.0s, 1.5s (default), 2.0s, 3.0s, 5.0s per capture + +The timeline panel title shows `▶ Playing` or `⏸ Paused`, and the footer shows current position (e.g., `2/10`), playback speed (e.g., `@1.5s`), and controls. + +### For AIs (JSON Output) +Use the `--json` flag to get structured output: + +```json +{ + "session": { + "session": "cli-test-1234567890", + "started": "2025-01-01T12:00:00Z", + "dimensions": { "width": 120, "height": 30 }, + "status": "active" + }, + "commands": [ + { "timestamp": "...", "type": "text", "input": "/help", "auto_enter": true } + ], + "captures": [ + { + "sequence": 1, + "label": "initial-state", + "timestamp": "...", + "after_command": null, + "dimensions": { "width": 120, "height": 30 }, + "path": "debug/tmux-sessions/.../capture-001-initial-state.txt", + "content": "[terminal output]" + } + ], + "timeline": [ + { "timestamp": "...", "type": "command", "data": {...} }, + { "timestamp": "...", "type": "capture", "data": {...} } + ] +} +``` + +## Data Format + +The viewer reads YAML-formatted session data from `debug/tmux-sessions/{session}/`: + +- `session-info.yaml` - Session metadata +- `commands.yaml` - Array of commands sent +- `capture-*.txt` - Capture files with YAML front-matter + +### Session Info (session-info.yaml) +```yaml +session: cli-test-1234567890 +started: 2025-01-01T12:00:00Z +started_local: Wed Jan 1 12:00:00 PST 2025 +dimensions: + width: 120 + height: 30 +status: active +``` + +### Commands (commands.yaml) +```yaml +- timestamp: 2025-01-01T12:00:05Z + type: text + input: "/help" + auto_enter: true +``` + +### Capture Files (capture-001-label.txt) +```yaml +--- +sequence: 1 +label: initial-state +timestamp: 2025-01-01T12:00:30Z +after_command: null +dimensions: + width: 120 + height: 30 +--- +[terminal content here] +``` + +## Integration with cli-ui-tester + +The `@cli-ui-tester` agent can use this viewer to inspect session data: + +```typescript +// In cli-ui-tester output +{ + captures: [ + { path: "debug/tmux-sessions/cli-test-123/capture-001-initial.txt", label: "initial" } + ] +} + +// Parent agent can view the session +// bun scripts/tmux/tmux-viewer/index.tsx cli-test-123 --json +``` + +## GIF Export + +The `--export-gif` flag renders the session replay as an animated GIF, perfect for: +- Sharing CLI demonstrations +- Embedding in documentation +- Bug reports and issue tracking +- Creating tutorials + +### GIF Export Options + +| Option | Description | Default | +|--------|-------------|--------| +| `--export-gif [path]` | Output file path | `-.gif` | +| `--frame-delay ` | Delay between frames in milliseconds | `1500` | +| `--font-size ` | Font size for terminal text | `14` | + +### Examples + +```bash +# Basic export (auto-names the file) +bun scripts/tmux/tmux-viewer/index.tsx my-session --export-gif + +# Specify output path +bun scripts/tmux/tmux-viewer/index.tsx my-session --export-gif demo.gif + +# Fast playback (500ms per frame) +bun scripts/tmux/tmux-viewer/index.tsx my-session --export-gif fast.gif --frame-delay 500 + +# Larger text for readability +bun scripts/tmux/tmux-viewer/index.tsx my-session --export-gif large.gif --font-size 18 +``` + +### GIF Output + +The exported GIF includes: +- Terminal content rendered as monospace text +- Frame labels showing capture sequence number and label +- Timestamps for each frame +- Dark terminal-style background +- Automatic sizing based on terminal dimensions + +## Development + +```bash +# Typecheck +cd scripts/tmux/tmux-viewer && bun x tsc --noEmit + +# Run directly +bun scripts/tmux/tmux-viewer/index.tsx --list +``` diff --git a/scripts/tmux/tmux-viewer/components/session-viewer.tsx b/scripts/tmux/tmux-viewer/components/session-viewer.tsx new file mode 100644 index 000000000..6cb18ba18 --- /dev/null +++ b/scripts/tmux/tmux-viewer/components/session-viewer.tsx @@ -0,0 +1,551 @@ +/** + * SessionViewer - Interactive TUI for viewing tmux session data + * + * Designed to be simple and predictable for both humans and AIs: + * - Humans: navigate captures with arrow keys / vim keys, or use replay mode + * - AIs: typically use the --json flag on the CLI entrypoint instead of the TUI + */ + +import { TextAttributes } from '@opentui/core' +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { ScrollBoxRenderable } from '@opentui/core' + +import { getTheme } from './theme' + +import type { SessionData, Capture } from '../types' +import type { ViewerTheme } from './theme' + +interface SessionViewerProps { + data: SessionData + onExit: () => void + /** + * Reserved for future use if we ever want a TUI hotkey to print JSON. + * For now, AIs should call the CLI with --json instead. + */ + onJsonOutput?: () => void + /** + * Start in replay mode (auto-playing through captures) + */ + startInReplayMode?: boolean +} + +// Available playback speeds (seconds per capture) +const PLAYBACK_SPEEDS = [0.5, 1.0, 1.5, 2.0, 3.0, 5.0] +const DEFAULT_SPEED_INDEX = 2 // 1.5 seconds + +export const SessionViewer: React.FC = ({ + data, + onExit, + startInReplayMode = false, +}) => { + const theme = getTheme() + const captures = data.captures + + const [selectedIndex, setSelectedIndex] = useState(() => + captures.length > 0 ? 0 : -1, + ) + + // Replay state + const [isPlaying, setIsPlaying] = useState(startInReplayMode) + const [speedIndex, setSpeedIndex] = useState(DEFAULT_SPEED_INDEX) + const playbackSpeed = PLAYBACK_SPEEDS[speedIndex] + const timerRef = useRef | null>(null) + + // Auto-advance effect for replay mode + useEffect(() => { + if (!isPlaying || captures.length === 0) { + return + } + + timerRef.current = setTimeout(() => { + setSelectedIndex((prev) => { + const next = prev + 1 + if (next >= captures.length) { + // Reached the end, stop playing + setIsPlaying(false) + return prev + } + return next + }) + }, playbackSpeed * 1000) + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [isPlaying, selectedIndex, playbackSpeed, captures.length]) + + // Replay control functions + const togglePlay = useCallback(() => { + if (captures.length === 0) return + // If at end and pressing play, restart from beginning + if (!isPlaying && selectedIndex >= captures.length - 1) { + setSelectedIndex(0) + } + setIsPlaying((prev) => !prev) + }, [captures.length, isPlaying, selectedIndex]) + + const increaseSpeed = useCallback(() => { + setSpeedIndex((prev) => Math.max(0, prev - 1)) // Lower index = faster + }, []) + + const decreaseSpeed = useCallback(() => { + setSpeedIndex((prev) => Math.min(PLAYBACK_SPEEDS.length - 1, prev + 1)) + }, []) + + // Keyboard input handling (q/Ctrl+C to quit, arrows + vim keys to navigate, space for play/pause) + useEffect(() => { + const handleKey = (key: string) => { + // Quit: q or Ctrl+C + if (key === 'q' || key === '\x03') { + onExit() + return + } + + // Space: toggle play/pause + if (key === ' ') { + togglePlay() + return + } + + // +/= : increase speed (faster) + if (key === '+' || key === '=') { + increaseSpeed() + return + } + + // -/_ : decrease speed (slower) + if (key === '-' || key === '_') { + decreaseSpeed() + return + } + + // r: restart from beginning + if (key === 'r') { + setSelectedIndex(0) + return + } + + if (captures.length === 0) { + return + } + + // Stop playback on manual navigation + const stopAndNavigate = () => { + setIsPlaying(false) + } + + // Left: arrow left or h => previous capture + if (key === '\x1b[D' || key === 'h') { + stopAndNavigate() + setSelectedIndex((prev) => Math.max(0, prev - 1)) + return + } + + // Right: arrow right or l => next capture + if (key === '\x1b[C' || key === 'l') { + stopAndNavigate() + setSelectedIndex((prev) => + Math.min(captures.length - 1, Math.max(0, prev + 1)), + ) + } + } + + const stdin: NodeJS.ReadStream = process.stdin as any + const onData = (chunk: Buffer) => { + handleKey(chunk.toString()) + } + + stdin.setRawMode?.(true) + stdin.resume() + stdin.on('data', onData) + + return () => { + // Remove only this listener to avoid interfering with other handlers + if (typeof (stdin as any).off === 'function') { + ;(stdin as any).off('data', onData) + } else { + stdin.removeListener('data', onData as any) + } + } + }, [captures.length, onExit, togglePlay, increaseSpeed, decreaseSpeed]) + + const selectedCapture: Capture | undefined = + selectedIndex >= 0 && selectedIndex < captures.length + ? captures[selectedIndex] + : undefined + + return ( + + {/* Header */} + + + {/* Main content area */} + + + + + + + {/* Footer / help text with replay controls */} +