diff --git a/README.md b/README.md index 068d909..31164e8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ paikea ## Features -- **Full-screen TUI** — header, timeline, thinking pane, result pane, prompt input, status bar +- **Full-screen TUI** — React/Ink components with flexbox layout - **Local LLMs** — streams via Docker Model Runner (OpenAI-compatible API at `localhost:12434`) - **Model switching** — Tab / Shift+Tab cycles through available models, or `M` to pick from a list - **Thinking support** — detects and renders chain-of-thought from Qwen3, DeepSeek-R1, OpenAI o-series @@ -99,22 +99,22 @@ src/ │ ├── commands/ │ │ ├── init.ts # paikea init │ │ ├── doc.ts # paikea doc -│ │ ├── run.ts # paikea (TUI) -│ │ └── tui-main.ts # main TUI loop + keyboard handling +│ │ └── run.ts # paikea (TUI) │ └── prompts/ │ └── stack-questions.ts # init wizard (language, framework, db, services) -├── components/ -│ ├── header.ts # top bar -│ ├── timeline.ts # message history -│ ├── thinking-pane.ts # chain-of-thought display -│ ├── result-pane.ts # LLM response rendering -│ ├── prompt-input.ts # text input area -│ └── status-bar.ts # bottom bar (model, skills, openspec steps) -├── renderer/ -│ ├── terminal.ts # raw mode, event stream, cursor -│ ├── layout.ts # 6-zone responsive layout -│ ├── theme.ts # colors, symbols, spacing -│ └── draw.ts # box drawing, text wrapping +├── tui/ +│ ├── index.tsx # TUI entry (Ink render) +│ ├── App.tsx # Main app component + agent loop +│ ├── theme.tsx # colors, symbols, frise palette +│ └── components/ +│ ├── Header.tsx # top bar with logo + model pill +│ ├── Timeline.tsx # OpenSpec step progress +│ ├── ThinkingPane.tsx # chain-of-thought display +│ ├── AgentSteps.tsx # tool call/result visualization +│ ├── ResultPane.tsx # LLM response rendering +│ ├── PromptInput.tsx # text input with suggestions +│ ├── StatusBar.tsx # bottom bar (tokens, toolbar) +│ └── CommandPalette.tsx # action palette overlay ├── services/ │ ├── dmr-client.ts # Docker Model Runner API (SSE streaming) │ ├── model-registry.ts # model fetching + cycling @@ -128,7 +128,6 @@ src/ ├── rules/ │ └── registry.ts # loads *.md rule files ├── state/ -│ ├── store.ts # reactive state store │ └── session.ts # session save/load └── types.ts # shared types ``` @@ -139,7 +138,7 @@ src/ |-------|---------| | CLI parser | [clipse](https://github.com/gouz/clipse) | | Interactive prompts | [@clack/prompts](https://github.com/bombshell-dev/clack) | -| TUI rendering | [@hexie/tui](https://github.com/anomalyco/hexie) | +| TUI rendering | [Ink](https://github.com/vadimdemedes/ink) + [TermUI](https://github.com/Arindam200/termui) | | LLM backend | [Docker Model Runner](https://docs.docker.com/desktop/features/model-runner/) | | Linter/formatter | [Biome](https://biomejs.dev) | | Runtime | [Bun](https://bun.sh) | diff --git a/bun.lock b/bun.lock index fa30b56..ab8e228 100644 --- a/bun.lock +++ b/bun.lock @@ -6,8 +6,11 @@ "name": "paikea", "dependencies": { "@clack/prompts": "^1.7.0", - "@hexie/tui": "^0.0.2", + "@types/react": "^19.2.17", "clipse": "^0.0.18", + "ink": "^7.1.0", + "react": "^19.2.7", + "termui": "^1.5.0", }, "devDependencies": { "@biomejs/biome": "^2.5.3", @@ -19,6 +22,8 @@ }, }, "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="], @@ -41,26 +46,138 @@ "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], - "@hexie/tui": ["@hexie/tui@0.0.2", "", {}, "sha512-oZAJlV3aQgvyPS3rTMM3mGbuVyU14VLxhwAs1Pb2XD6CYQhnLtzEH+W2bBUsZQOI8FUj6AHA0HQ6iAis61yIJg=="], + "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], + + "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], + + "@pnpm/npm-conf": ["@pnpm/npm-conf@3.0.3", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@6.1.1", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw=="], + "clipse": ["clipse@0.0.18", "", { "peerDependencies": { "typescript": "^5.8.3" } }, "sha512-BSgc0HlsM6FB0H0ZJ0ySwQ2r8f26PwGIDwZPCWIJPpmjFdxZeLoKeE6AITKA0gpx1CHzAQbf75p+CZLS/VBJSQ=="], + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + + "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], + + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "ink": ["ink@7.1.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + + "ky": ["ky@1.14.3", "", {}, "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw=="], + + "latest-version": ["latest-version@9.0.0", "", { "dependencies": { "package-json": "^10.0.0" } }, "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "package-json": ["package-json@10.0.1", "", { "dependencies": { "ky": "^1.2.0", "registry-auth-token": "^5.0.2", "registry-url": "^6.0.1", "semver": "^7.6.0" } }, "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg=="], + + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + + "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], + + "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.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + + "registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="], + + "registry-url": ["registry-url@6.0.1", "", { "dependencies": { "rc": "1.2.8" } }, "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q=="], + + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + + "termui": ["termui@1.5.0", "", { "dependencies": { "latest-version": "^9.0.0", "semver": "^7.6.0", "strip-ansi": "^7.1.0" }, "peerDependencies": { "ink": "^5.0.0", "react": "^18.0.0" }, "bin": { "termui": "dist/cli.js" } }, "sha512-x5d6AJdioBKwFUkCU51dlbpDs7FC3jdF3VC5j+joK9glm5DYWAPKlEWmVnjzaVuHG1OPWxSBKMqmL/LCjN87uA=="], + + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], } } diff --git a/openspec/changes/migrate-to-ink-termui/design.md b/openspec/changes/migrate-to-ink-termui/design.md new file mode 100644 index 0000000..82809ce --- /dev/null +++ b/openspec/changes/migrate-to-ink-termui/design.md @@ -0,0 +1,100 @@ +# Design: Migrate TUI to Ink + TermUI + +## Architecture Change + +``` +BEFORE (imperative) AFTER (React/JSX) +───────────────── ───────────────── +terminal.ts (164L) render() — 1 line +store.ts (98L) useState/useReducer +layout.ts (81L) (flexbox) +draw.ts (138L) + +input-decoder.ts (248L) useInput() hook +8 composants .ts → renderXxx(term) 8 composants .tsx → fonctions React +tui-main.ts (708L) App.tsx + hooks +``` + +## New File Structure + +``` +src/tui/ +├── index.tsx # Ink render entry point +├── App.tsx # Main component: state, keyboard, agent loop +├── theme.tsx # Colors, symbols, frise palette +└── components/ + ├── Header.tsx # 🏄 paikea + model pill + hints + ├── Timeline.tsx # OpenSpec step progress bar + ├── ThinkingPane.tsx # Chain-of-thought display + ├── AgentSteps.tsx # Tool call/result visualization + ├── ResultPane.tsx # LLM response with markdown styling + ├── PromptInput.tsx # Text input with ghost suggestions + ├── StatusBar.tsx # Tokens, toolbar buttons, streaming + └── CommandPalette.tsx # Action palette overlay +``` + +## Dependencies + +| Package | Purpose | +|---------|---------| +| `ink` | React renderer for terminals | +| `react` | Component model | +| `@types/react` | TypeScript types | +| `termui` | 101+ components, themes, hooks | + +## State Management + +Replace custom `Store` class with React state in `App.tsx`: + +```tsx +const [state, setState] = useState({...}); +``` + +All `store.set()` calls become `setState()` calls. The `drawUI()` function is eliminated — React re-renders automatically on state changes. + +## Keyboard Handling + +Replace 248-line custom input decoder with Ink's `useInput()` hook: + +```tsx +useInput((input, key) => { + if (key.escape) quit(); + if (key.return) handleSubmit(); + if (key.tab && key.shift) prevModel(); + // ... +}); +``` + +## Layout + +Replace manual 7-zone layout calculation with flexbox: + +```tsx + +
+ + + + + + + + + +``` + +## Agent Loop + +The `runAgentLoop()` function remains async but triggers `setState()` instead of `store.set()` + `drawUI()`. React handles re-rendering. + +## Deleted Files + +| File | Reason | +|------|--------| +| `src/renderer/terminal.ts` | Ink manages terminal lifecycle | +| `src/renderer/draw.ts` | ``, `` primitives | +| `src/renderer/layout.ts` | Flexbox via Yoga | +| `src/renderer/input-decoder.ts` | `useInput()` hook | +| `src/renderer/theme.ts` | Replaced by `src/tui/theme.tsx` | +| `src/state/store.ts` | React state | +| `src/components/*.ts` | Replaced by `src/tui/components/*.tsx` | +| `src/cli/commands/tui-main.ts` | Replaced by `src/tui/App.tsx` | diff --git a/openspec/changes/migrate-to-ink-termui/proposal.md b/openspec/changes/migrate-to-ink-termui/proposal.md new file mode 100644 index 0000000..d3582e5 --- /dev/null +++ b/openspec/changes/migrate-to-ink-termui/proposal.md @@ -0,0 +1,30 @@ +# Migrate TUI from @hexie/tui to Ink + TermUI + +## Problem + +Paikea's TUI is built on `@hexie/tui`, a minimal cell-based rendering library. Every component manually calculates row/col positions, draws boxes character by character, and manages its own ANSI input decoder. This imperative approach leads to: + +- ~1500 lines of boilerplate for layout, drawing, and input handling +- No flexbox — manual 7-zone layout calculation +- Custom ANSI/VT input decoder (248 lines) because @hexie/tui only supports Kitty keyboard protocol +- Custom reactive state store (98 lines) instead of using React's built-in state +- No access to a rich component ecosystem (tables, spinners, modals, etc.) + +## Solution + +Replace `@hexie/tui` with **Ink** (React for terminals) + **TermUI** (101+ components, themes, hooks). This shifts the architecture from imperative cell rendering to declarative React components with flexbox layout. + +## Scope + +- Rewrite all 8 TUI components as React/TSX composants +- Replace manual layout with Ink's flexbox (Yoga engine) +- Replace custom input decoder with Ink's `useInput()` hook +- Replace custom state store with React `useState`/`useReducer` +- Replace manual terminal lifecycle with Ink's `render()` +- Keep all services, tools, skills, rules, types unchanged + +## Out of scope + +- Migrating to OpenTUI (Zig native) — deferred to future +- Changing the agent loop logic +- Modifying the LLM streaming protocol diff --git a/openspec/changes/migrate-to-ink-termui/tasks.md b/openspec/changes/migrate-to-ink-termui/tasks.md new file mode 100644 index 0000000..56b6cea --- /dev/null +++ b/openspec/changes/migrate-to-ink-termui/tasks.md @@ -0,0 +1,55 @@ +# Tasks: Migrate TUI to Ink + TermUI + +## Phase 1: Setup + +- [x] Install dependencies: `ink`, `react`, `@types/react`, `termui` +- [x] Create `src/tui/` directory structure +- [x] Create `src/tui/index.tsx` — Ink render entry point +- [x] Update `src/cli/commands/run.ts` — import from `src/tui/` instead of `tui-main` + +## Phase 2: Theme + +- [x] Create `src/tui/theme.tsx` — colors, symbols, frise palette +- [x] Map all `@hexie/tui` Color types to plain hex strings + +## Phase 3: Layout Components + +- [x] Create `Header.tsx` — logo 🏄, model pill, keyboard hints, frise +- [x] Create `Timeline.tsx` — OpenSpec step progress with flexbox +- [x] Create `StatusBar.tsx` — tokens, toolbar buttons, streaming indicator + +## Phase 4: Content Components + +- [x] Create `ThinkingPane.tsx` — chain-of-thought with border +- [x] Create `AgentSteps.tsx` — tool calls/results with icons +- [x] Create `ResultPane.tsx` — LLM response with markdown-like styling + +## Phase 5: Input & Overlays + +- [x] Create `PromptInput.tsx` — text input with ghost suggestions +- [x] Create `CommandPalette.tsx` — action palette overlay + +## Phase 6: Main App + +- [x] Create `App.tsx` — all state via useState/useReducer +- [x] Implement `useInput()` keyboard handler (palette, toolbar, normal modes) +- [x] Migrate `runAgentLoop()` — setState instead of store.set + drawUI +- [x] Migrate `handleSubmit()` — message management, session save + +## Phase 7: Cleanup + +- [x] Delete `src/renderer/` (terminal.ts, draw.ts, layout.ts, input-decoder.ts, theme.ts) +- [x] Delete `src/components/` (8 files) +- [x] Delete `src/state/store.ts` +- [x] Delete `src/cli/commands/tui-main.ts` +- [x] Remove `@hexie/tui` from package.json +- [x] Delete obsolete tests (store.test.ts, layout.test.ts, input-decoder.test.ts) +- [x] Update README.md architecture section + +## Phase 8: Validation + +- [x] Fix type errors in new TUI files +- [x] Fix pre-existing type error in openspec-hook.ts (`as const` → `as Step["status"]`) +- [x] Lint passes (0 errors) +- [x] Typecheck passes (0 errors) +- [x] Tests pass (25/25) diff --git a/openspec/specs/tui-architecture/spec.md b/openspec/specs/tui-architecture/spec.md new file mode 100644 index 0000000..19b4a1e --- /dev/null +++ b/openspec/specs/tui-architecture/spec.md @@ -0,0 +1,86 @@ +# TUI Architecture Spec + +## Overview + +Paikea's TUI is built on **Ink** (React for terminals) with **TermUI** components. It renders a full-screen terminal interface with 7 visual zones managed via flexbox layout. + +## Stack + +| Layer | Library | +|-------|---------| +| Component model | React 19 | +| Terminal renderer | Ink 7 | +| Components | TermUI 1.5 | +| Layout | Yoga (flexbox) | +| Runtime | Bun | + +## Component Tree + +``` +App +├── Header (fixed: logo + model + hints + frise) +├── Timeline (fixed: OpenSpec step progress) +├── Box flexGrow=1 (scrollable content area) +│ ├── ThinkingPane (conditional: chain-of-thought) +│ ├── AgentSteps (conditional: tool calls/results) +│ └── ResultPane (LLM response) +├── PromptInput (fixed: text input + suggestions) +└── StatusBar (fixed: tokens + toolbar) +``` + +## State + +All state lives in `App.tsx` via `useState`: + +```typescript +interface AppState { + model: Model | null; + models: Model[]; + messages: Message[]; + steps: Step[]; + thinking: ThinkingState; + result: string; + scrollOffset: number; + thinkingVisible: boolean; + streaming: boolean; + agentSteps: AgentStep[]; + uiMode: "normal" | "palette" | "toolbar"; + toolbarIndex: number; + paletteIndex: number; + suggestions: string[]; + suggestionIndex: number; +} +``` + +## Keyboard Modes + +| Mode | Trigger | Keys | +|------|---------|------| +| Normal | default | typing, Enter, Tab, Esc, arrows | +| Toolbar | ↑ | Tab cycles, Enter executes, Esc/↓ exits | +| Palette | `:` | ↑↓ navigate, Enter executes, Esc exits | + +## Agent Loop + +Async function in `App.tsx`: +1. Build messages array with system prompt +2. Stream LLM response via `streamChat()` +3. If tool calls returned → execute → append results → loop (max 10 iterations) +4. If text only → display in ResultPane +5. Save session to `.paikea/sessions/` + +## Files + +| File | Lines | Purpose | +|------|-------|---------| +| `index.tsx` | 8 | Ink render entry | +| `App.tsx` | ~650 | State + keyboard + agent loop | +| `theme.tsx` | ~55 | Colors, symbols, frise | +| `components/Header.tsx` | ~45 | Top bar | +| `components/Timeline.tsx` | ~40 | Step progress | +| `components/ThinkingPane.tsx` | ~30 | Thinking display | +| `components/AgentSteps.tsx` | ~95 | Tool visualization | +| `components/ResultPane.tsx` | ~85 | LLM response | +| `components/PromptInput.tsx` | ~65 | Input area | +| `components/StatusBar.tsx` | ~55 | Bottom bar | +| `components/CommandPalette.tsx` | ~45 | Action overlay | diff --git a/package.json b/package.json index b873339..f1f8a59 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,11 @@ }, "dependencies": { "@clack/prompts": "^1.7.0", - "@hexie/tui": "^0.0.2", - "clipse": "^0.0.18" + "@types/react": "^19.2.17", + "clipse": "^0.0.18", + "ink": "^7.1.0", + "react": "^19.2.7", + "termui": "^1.5.0" }, "devDependencies": { "@biomejs/biome": "^2.5.3", diff --git a/src/__tests__/input-decoder.test.ts b/src/__tests__/input-decoder.test.ts deleted file mode 100644 index eafcf44..0000000 --- a/src/__tests__/input-decoder.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { decodeInput, type KeyEvent } from "../renderer/input-decoder"; - -function bytes(...vals: (number | string)[]): Uint8Array { - const out: number[] = []; - for (const v of vals) { - if (typeof v === "number") out.push(v); - else for (const ch of v) out.push(ch.charCodeAt(0)); - } - return new Uint8Array(out); -} - -function codes(events: KeyEvent[]) { - return events.map((e) => e.code); -} - -describe("decodeInput", () => { - it("decodes printable ASCII characters", () => { - const { events, consumed } = decodeInput(bytes("hi")); - expect(consumed).toBe(2); - expect(codes(events)).toEqual([{ char: "h" }, { char: "i" }]); - }); - - it("decodes Enter, Tab and Backspace", () => { - const { events } = decodeInput(bytes(0x0d, 0x09, 0x7f)); - expect(codes(events)).toEqual(["Enter", "Tab", "Backspace"]); - }); - - it("decodes a lone Escape only once more bytes arrive (keeps it pending)", () => { - const { events, consumed } = decodeInput(bytes(0x1b)); - expect(events).toHaveLength(0); - expect(consumed).toBe(0); // left pending for the timeout flush - }); - - it("decodes arrow keys (CSI)", () => { - const { events } = decodeInput(bytes(0x1b, "[", "A", 0x1b, "[", "D")); - expect(codes(events)).toEqual(["Up", "Left"]); - }); - - it("decodes modified arrow keys", () => { - const { events } = decodeInput(bytes(0x1b, "[", "1;5", "C")); // Ctrl+Right - expect(events[0]?.code).toBe("Right"); - expect(events[0]?.modifiers.ctrl).toBe(true); - }); - - it("decodes Shift+Tab as Tab+shift", () => { - const { events } = decodeInput(bytes(0x1b, "[", "Z")); - expect(events[0]?.code).toBe("Tab"); - expect(events[0]?.modifiers.shift).toBe(true); - }); - - it("decodes Delete / PageUp / PageDown (CSI ~)", () => { - const { events } = decodeInput( - bytes(0x1b, "[", "3~", 0x1b, "[", "5~", 0x1b, "[", "6~"), - ); - expect(codes(events)).toEqual(["Delete", "PageUp", "PageDown"]); - }); - - it("decodes SS3 function-key style arrows", () => { - const { events } = decodeInput(bytes(0x1b, "O", "A")); - expect(events[0]?.code).toBe("Up"); - }); - - it("decodes Ctrl+C and Ctrl+D", () => { - const { events } = decodeInput(bytes(0x03, 0x04)); - expect(events[0]?.code).toEqual({ char: "c" }); - expect(events[0]?.modifiers.ctrl).toBe(true); - expect(events[1]?.code).toEqual({ char: "d" }); - expect(events[1]?.modifiers.ctrl).toBe(true); - }); - - it("swallows mouse reports without emitting keys", () => { - const { events } = decodeInput(bytes(0x1b, "[", "<0;10;10", "M")); - expect(events).toHaveLength(0); - }); - - it("keeps an incomplete CSI sequence pending", () => { - const { events, consumed } = decodeInput(bytes("a", 0x1b, "[")); - expect(codes(events)).toEqual([{ char: "a" }]); - expect(consumed).toBe(1); // ESC[ left for the next chunk - }); - - it("decodes UTF-8 multibyte characters", () => { - const { events } = decodeInput(new TextEncoder().encode("é")); - expect(events[0]?.code).toEqual({ char: "é" }); - }); - - it("keeps an incomplete UTF-8 sequence pending", () => { - const full = new TextEncoder().encode("é"); // 2 bytes - const { events, consumed } = decodeInput(full.slice(0, 1)); - expect(events).toHaveLength(0); - expect(consumed).toBe(0); - }); -}); diff --git a/src/__tests__/layout.test.ts b/src/__tests__/layout.test.ts deleted file mode 100644 index 8c0dd18..0000000 --- a/src/__tests__/layout.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { calculateLayout } from "../renderer/layout"; - -describe("calculateLayout", () => { - it("should calculate layout for standard terminal", () => { - const layout = calculateLayout(24, 80, false); - - expect(layout.header.row).toBe(0); - expect(layout.header.height).toBe(1); - expect(layout.timeline.row).toBe(1); - expect(layout.timeline.height).toBe(1); - expect(layout.input.row).toBe(20); - expect(layout.input.height).toBe(3); - expect(layout.status.row).toBe(23); - expect(layout.status.height).toBe(1); - }); - - it("should calculate layout with thinking visible", () => { - const layout = calculateLayout(24, 80, true); - - expect(layout.thinking.height).toBeGreaterThan(0); - expect(layout.result.height).toBeGreaterThan(0); - }); - - it("should calculate layout with thinking hidden", () => { - const layout = calculateLayout(24, 80, false); - - expect(layout.thinking.height).toBe(0); - expect(layout.result.height).toBeGreaterThan(0); - }); - - it("should handle small terminals", () => { - const layout = calculateLayout(10, 40, false); - - expect(layout.header.width).toBeLessThanOrEqual(40); - expect(layout.result.width).toBeLessThanOrEqual(40); - }); - - it("should have consistent row positions", () => { - const layout = calculateLayout(24, 80, false); - - expect(layout.header.row).toBe(0); - expect(layout.timeline.row).toBe(layout.header.row + layout.header.height); - expect(layout.status.row).toBe(23); - expect(layout.input.row).toBe(layout.status.row - layout.input.height); - }); - - it("should allocate space correctly with thinking", () => { - const layoutWithThinking = calculateLayout(24, 80, true); - const layoutWithoutThinking = calculateLayout(24, 80, false); - - expect(layoutWithThinking.result.height).toBeLessThan( - layoutWithoutThinking.result.height, - ); - }); - - it("should include agentSteps zone", () => { - const layout = calculateLayout(24, 80, false, 4); - - expect(layout.agentSteps.height).toBe(4); - expect(layout.agentSteps.row).toBe( - layout.thinking.row + layout.thinking.height, - ); - }); - - it("should hide agentSteps when count is 0", () => { - const layout = calculateLayout(24, 80, false, 0); - - expect(layout.agentSteps.height).toBe(0); - }); - - it("should cap agentSteps at 6", () => { - const layout = calculateLayout(24, 80, false, 10); - - expect(layout.agentSteps.height).toBe(6); - }); -}); diff --git a/src/__tests__/store.test.ts b/src/__tests__/store.test.ts deleted file mode 100644 index e6dde0b..0000000 --- a/src/__tests__/store.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { beforeEach, describe, expect, it } from "bun:test"; -import { store } from "../state/store"; -import type { AgentStep } from "../types"; - -describe("Store", () => { - beforeEach(() => { - store.set({ - model: null, - models: [], - messages: [], - steps: [], - thinking: { active: false, content: "", tokens: 0 }, - result: "", - prompt: "", - scrollOffset: 0, - thinkingVisible: true, - streaming: false, - agentSteps: [], - toolCallsStreaming: new Map(), - }); - }); - - it("should return initial state", () => { - const state = store.get(); - expect(state.model).toBeNull(); - expect(state.messages).toEqual([]); - expect(state.result).toBe(""); - }); - - it("should update state with set()", () => { - store.set({ result: "hello" }); - expect(store.get().result).toBe("hello"); - }); - - it("should add messages", () => { - store.addMessage({ role: "user", content: "test", timestamp: 123 }); - expect(store.get().messages).toHaveLength(1); - expect(store.get().messages[0]?.content).toBe("test"); - }); - - it("should set thinking partially", () => { - store.setThinking({ content: "thinking..." }); - expect(store.get().thinking.content).toBe("thinking..."); - expect(store.get().thinking.tokens).toBe(0); - }); - - it("should append result", () => { - store.set({ result: "hello" }); - store.appendResult(" world"); - expect(store.get().result).toBe("hello world"); - }); - - it("should clear result", () => { - store.set({ result: "hello" }); - store.clearResult(); - expect(store.get().result).toBe(""); - }); - - it("should set steps", () => { - const steps = [{ id: "1", name: "Step 1", status: "done" as const }]; - store.setSteps(steps); - expect(store.get().steps).toEqual(steps); - }); - - it("should notify listeners on state change", () => { - let notified = false; - store.subscribe(() => { - notified = true; - }); - store.set({ result: "test" }); - expect(notified).toBe(true); - }); - - it("should unsubscribe listeners", () => { - let count = 0; - const unsub = store.subscribe(() => { - count++; - }); - store.set({ result: "a" }); - unsub(); - store.set({ result: "b" }); - expect(count).toBe(1); - }); - - it("should add agent steps", () => { - const step: AgentStep = { - id: "s1", - type: "tool_call", - name: "web_search", - status: "running", - timestamp: Date.now(), - }; - store.addAgentStep(step); - expect(store.get().agentSteps).toHaveLength(1); - expect(store.get().agentSteps[0]?.name).toBe("web_search"); - }); - - it("should update agent steps", () => { - const step: AgentStep = { - id: "s1", - type: "tool_call", - name: "web_search", - status: "running", - timestamp: Date.now(), - }; - store.addAgentStep(step); - store.updateAgentStep("s1", { status: "done", content: "Found 5 results" }); - expect(store.get().agentSteps[0]?.status).toBe("done"); - expect(store.get().agentSteps[0]?.content).toBe("Found 5 results"); - }); - - it("should clear agent steps", () => { - store.addAgentStep({ - id: "s1", - type: "tool_call", - name: "web_search", - status: "running", - timestamp: Date.now(), - }); - store.clearAgentSteps(); - expect(store.get().agentSteps).toHaveLength(0); - }); -}); diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 5628cbe..53af0ca 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -1,6 +1,6 @@ import { outro } from "@clack/prompts"; import { listModels } from "../../services/dmr-client"; -import { startTUI } from "./tui-main"; +import { startTUI } from "../../tui"; export async function runHandler() { const models = await listModels(); diff --git a/src/cli/commands/tui-main.ts b/src/cli/commands/tui-main.ts deleted file mode 100644 index 619e891..0000000 --- a/src/cli/commands/tui-main.ts +++ /dev/null @@ -1,708 +0,0 @@ -import { confirm } from "@clack/prompts"; -import { renderAgentSteps } from "../../components/agent-steps"; -import { renderCommandPalette } from "../../components/command-palette"; -import { renderHeader } from "../../components/header"; -import { renderPromptInput } from "../../components/prompt-input"; -import { renderResultPane } from "../../components/result-pane"; -import { renderStatusBar } from "../../components/status-bar"; -import { renderThinkingPane } from "../../components/thinking-pane"; -import { renderTimeline } from "../../components/timeline"; -import { calculateLayout } from "../../renderer/layout"; -import { - cleanupTerminal, - getEventStream, - getTerminal, - initTerminal, -} from "../../renderer/terminal"; -import { buildRulesPrompt, loadRules } from "../../rules/registry"; -import { streamChat } from "../../services/dmr-client"; -import { - fetchModels, - nextModel, - prevModel, -} from "../../services/model-registry"; -import { detectOpenSpecSteps } from "../../services/openspec-hook"; -import { - computeSuggestions, - getCurrentStepId, -} from "../../services/suggestions"; -import { hasThinkingSupport } from "../../services/thinking-parser"; -import { buildSkillsPrompt, loadSkills } from "../../skills/registry"; -import { createSessionId, saveSession } from "../../state/session"; -import { store } from "../../state/store"; -import { executeTool } from "../../tools/executor"; -import { loadTools } from "../../tools/registry"; -import type { Action, ToolCall, ToolDefinition } from "../../types"; - -const MAX_TOOL_ITERATIONS = 10; - -export async function startTUI() { - const term = await initTerminal(); - if (!term) return; - - const stream = getEventStream(); - if (!stream) return; - - const cwd = process.cwd(); - - const models = await fetchModels(); - if (models.length === 0) { - console.error( - "No models found in Docker Model Runner. Run: docker model pull ai/qwen3", - ); - await cleanupTerminal(); - return; - } - - const steps = detectOpenSpecSteps(cwd); - store.set({ - models, - model: models[0], - steps, - }); - - // Initial suggestions - const stepId = getCurrentStepId(steps); - const initialSuggestions = computeSuggestions("", stepId); - store.set({ suggestions: initialSuggestions }); - - const skills = loadSkills(cwd); - const rules = loadRules(); - const tools = loadTools(cwd); - const systemPrompt = buildSkillsPrompt(skills) + buildRulesPrompt(rules); - - let prompt = ""; - let cursorPos = 0; - const sessionId = createSessionId(); - - let quitting = false; - async function quit() { - if (quitting) return; - quitting = true; - streamAbort?.abort(); - await cleanupTerminal(); - process.exit(0); - } - - // Controls the in-flight generation so it can be cancelled from the keyboard. - let streamAbort: AbortController | null = null; - function cancelStreaming(): boolean { - if (!store.get().streaming) return false; - streamAbort?.abort(); - return true; - } - - // Actions for palette and toolbar - const actions: Action[] = [ - { - id: "model", - label: "Switch Model", - shortcut: "Tab", - action: () => { - const newModel = nextModel(); - if (newModel) { - store.set({ model: newModel, steps: detectOpenSpecSteps(cwd) }); - } - }, - }, - { - id: "thinking", - label: "Toggle Thinking", - shortcut: "", - action: () => - store.set({ thinkingVisible: !store.get().thinkingVisible }), - }, - { - id: "clear", - label: "Clear", - shortcut: "", - action: () => { - store.clearResult(); - store.setThinking({ active: false, content: "", tokens: 0 }); - store.clearAgentSteps(); - store.set({ scrollOffset: 0 }); - }, - }, - { - id: "quit", - label: "Quit", - shortcut: "Esc", - action: () => { - void quit(); - }, - }, - ]; - - function drawUI() { - const s = store.get(); - const { rows, cols } = term; - const layout = calculateLayout( - rows, - cols, - s.thinkingVisible, - s.agentSteps.length, - ); - - renderHeader(term, layout, s.model?.name ?? "none"); - renderTimeline(term, layout, s.steps); - renderThinkingPane(term, layout, s.thinking.content, s.thinkingVisible); - renderAgentSteps(term, layout, s.agentSteps); - renderResultPane(term, layout, s.result, s.scrollOffset); - renderPromptInput( - term, - layout, - prompt, - cursorPos, - s.uiMode === "normal", - s.suggestions, - s.suggestionIndex, - ); - renderStatusBar( - term, - layout, - s.model?.name ?? "none", - s.thinking.tokens, - s.streaming, - s.toolbarIndex, - s.uiMode === "toolbar", - ); - - // Render command palette on top - if (s.uiMode === "palette") { - renderCommandPalette(term, actions, s.paletteIndex, rows, cols); - } - - term.render(); - } - - async function confirmDestructive(message: string): Promise { - const result = await confirm({ message }); - return result === true; - } - - async function handleSubmit() { - if (!prompt.trim() || !store.get().model || store.get().streaming) return; - - const userPrompt = prompt; - prompt = ""; - cursorPos = 0; - - store.addMessage({ - role: "user", - content: userPrompt, - timestamp: Date.now(), - }); - store.set({ - streaming: true, - result: "", - agentSteps: [], - suggestions: [], - scrollOffset: 0, - }); - - const model = store.get().model; - if (!model) return; - const useThinking = hasThinkingSupport(model.id); - - const messages: { - role: string; - content: string; - tool_call_id?: string; - name?: string; - }[] = [{ role: "system", content: systemPrompt }]; - - for (const msg of store.get().messages) { - messages.push({ role: msg.role, content: msg.content }); - } - - streamAbort = new AbortController(); - try { - await runAgentLoop( - messages, - model.id, - useThinking, - tools, - cwd, - drawUI, - confirmDestructive, - streamAbort.signal, - ); - } catch (err) { - // A user-initiated cancel aborts the fetch; keep whatever streamed so far. - if (streamAbort.signal.aborted) { - store.set({ - result: `${store.get().result}\n\n[Generation cancelled]`, - }); - } else { - store.set({ result: `Error: ${err}` }); - } - } finally { - streamAbort = null; - } - - const finalResult = store.get().result; - store.addMessage({ - role: "assistant", - content: finalResult, - timestamp: Date.now(), - }); - - store.set({ streaming: false }); - - // Sync OpenSpec to vault - try { - const { syncOpenSpecToVault } = await import( - "../../services/openspec-sync" - ); - syncOpenSpecToVault(cwd); - } catch { - // Silent fail — vault sync is best-effort - } - - saveSession({ - id: sessionId, - date: new Date().toISOString().split("T")[0] ?? new Date().toISOString(), - model: model.name, - messages: store.get().messages, - }); - - drawUI(); - } - - function executeAction(action: Action | undefined) { - if (!action) return; - action.action(); - store.set({ uiMode: "normal" }); - drawUI(); - } - - (async () => { - for await (const event of stream) { - if (event.type === "key") { - const { code, modifiers } = event; - const s = store.get(); - - // Ctrl+C / Ctrl+D: cancel an in-flight generation first, otherwise - // quit. Raw mode means these never arrive as SIGINT, so we handle the - // bytes ourselves. - if ( - modifiers.ctrl && - typeof code === "object" && - (code.char === "c" || code.char === "d") - ) { - if (cancelStreaming()) { - drawUI(); - continue; - } - await quit(); - continue; - } - - // Palette mode - if (s.uiMode === "palette") { - if (code === "Escape") { - store.set({ uiMode: "normal" }); - drawUI(); - continue; - } - if (code === "Up") { - store.set({ - paletteIndex: Math.max(0, s.paletteIndex - 1), - }); - drawUI(); - continue; - } - if (code === "Down") { - store.set({ - paletteIndex: Math.min(actions.length - 1, s.paletteIndex + 1), - }); - drawUI(); - continue; - } - if (code === "Enter") { - executeAction(actions[s.paletteIndex]); - continue; - } - continue; - } - - // Toolbar mode - if (s.uiMode === "toolbar") { - if (code === "Escape") { - store.set({ uiMode: "normal" }); - drawUI(); - continue; - } - if (code === "Tab") { - store.set({ - toolbarIndex: (s.toolbarIndex + 1) % 4, - }); - drawUI(); - continue; - } - if (code === "Enter") { - // Execute toolbar action by index - const toolbarActions = [ - actions[0], // Model - actions[1], // Thinking - actions[2], // Clear - actions[3], // Quit (palette) - ]; - if (s.toolbarIndex === 3) { - // Palette button opens palette - store.set({ uiMode: "palette", toolbarIndex: 0 }); - } else { - executeAction(toolbarActions[s.toolbarIndex]); - } - drawUI(); - continue; - } - continue; - } - - // Normal mode - if (code === "Escape") { - // Escape cancels a running generation, or quits when idle. - if (cancelStreaming()) { - drawUI(); - continue; - } - await quit(); - continue; - } - - // Enter to submit (only when prompt is focused and not already busy). - // Launched detached so the event loop stays responsive while streaming, - // which is what lets Escape / Ctrl+C cancel mid-generation. - if ( - code === "Enter" && - !modifiers.ctrl && - !modifiers.alt && - s.uiMode === "normal" - ) { - if (!s.streaming) { - void handleSubmit(); - } - drawUI(); - continue; - } - - // Scroll the response pane. - if (code === "PageUp" || (code === "Up" && modifiers.shift)) { - store.set({ scrollOffset: Math.max(0, s.scrollOffset - 5) }); - drawUI(); - continue; - } - if (code === "PageDown" || (code === "Down" && modifiers.shift)) { - store.set({ scrollOffset: s.scrollOffset + 5 }); - drawUI(); - continue; - } - - // Shift+Tab cycles to the previous model. - if (code === "Tab" && modifiers.shift) { - const newModel = prevModel(); - if (newModel) { - store.set({ model: newModel, steps: detectOpenSpecSteps(cwd) }); - } - drawUI(); - continue; - } - - // Tab to accept suggestion or next model - if (code === "Tab" && !modifiers.shift) { - const s2 = store.get(); - if (s2.suggestions.length > 0 && s2.uiMode === "normal") { - // Accept suggestion - const idx = s2.suggestionIndex >= 0 ? s2.suggestionIndex : 0; - const suggestion = s2.suggestions[idx]; - if (suggestion) { - prompt = suggestion; - cursorPos = prompt.length; - } - } else { - const newModel = nextModel(); - if (newModel) { - store.set({ model: newModel, steps: detectOpenSpecSteps(cwd) }); - } - } - drawUI(); - continue; - } - - // : to open palette - if (typeof code === "object" && code.char === ":") { - store.set({ uiMode: "palette", paletteIndex: 0 }); - drawUI(); - continue; - } - - // Arrow keys for toolbar navigation - if (code === "Left") { - if (s.uiMode === "normal") { - cursorPos = Math.max(0, cursorPos - 1); - } - drawUI(); - continue; - } - if (code === "Right") { - if (s.uiMode === "normal") { - cursorPos = Math.min(prompt.length, cursorPos + 1); - } - drawUI(); - continue; - } - - // Cursor up/down to enter toolbar mode - if (code === "Up") { - store.set({ uiMode: "toolbar", toolbarIndex: 0 }); - drawUI(); - continue; - } - if (code === "Down") { - // Down exits toolbar back to prompt - store.set({ uiMode: "normal" }); - drawUI(); - continue; - } - - // Prompt editing (only in normal mode) - if (s.uiMode === "normal") { - let promptChanged = false; - if (code === "Backspace") { - prompt = prompt.slice(0, cursorPos - 1) + prompt.slice(cursorPos); - cursorPos = Math.max(0, cursorPos - 1); - promptChanged = true; - } else if (code === "Delete") { - prompt = prompt.slice(0, cursorPos) + prompt.slice(cursorPos + 1); - promptChanged = true; - } else if ( - code === "Home" || - (modifiers.ctrl && typeof code === "object" && code.char === "a") - ) { - cursorPos = 0; - } else if ( - code === "End" || - (modifiers.ctrl && typeof code === "object" && code.char === "e") - ) { - cursorPos = prompt.length; - } else if ( - modifiers.ctrl && - typeof code === "object" && - code.char === "u" - ) { - // Kill from cursor to line start - prompt = prompt.slice(cursorPos); - cursorPos = 0; - promptChanged = true; - } else if ( - modifiers.ctrl && - typeof code === "object" && - code.char === "w" - ) { - // Kill the word before the cursor - const before = prompt.slice(0, cursorPos); - const trimmed = before.replace(/\s*\S+\s*$/, ""); - prompt = trimmed + prompt.slice(cursorPos); - cursorPos = trimmed.length; - promptChanged = true; - } else if ( - typeof code === "object" && - code.char && - !modifiers.ctrl && - !modifiers.alt - ) { - prompt = - prompt.slice(0, cursorPos) + code.char + prompt.slice(cursorPos); - cursorPos += code.char.length; - promptChanged = true; - } - - // Recompute suggestions on prompt change - if (promptChanged) { - const stepId = getCurrentStepId(s.steps); - const suggestions = computeSuggestions(prompt, stepId); - store.set({ suggestions, suggestionIndex: -1 }); - } - } - - drawUI(); - } - } - })(); - - // Redraw on terminal resize so the layout tracks the new dimensions. - getTerminal()?.on("resize", () => drawUI()); - - drawUI(); - - process.on("SIGINT", () => { - void quit(); - }); - - process.on("SIGTERM", () => { - void quit(); - }); -} - -async function runAgentLoop( - messages: { - role: string; - content: string; - tool_call_id?: string; - name?: string; - }[], - modelId: string, - useThinking: boolean, - tools: ToolDefinition[], - projectDir: string, - drawUI: () => void, - confirmFn: (message: string) => Promise, - signal?: AbortSignal, -) { - let iterations = 0; - - while (iterations < MAX_TOOL_ITERATIONS) { - if (signal?.aborted) return; - iterations++; - let thinkingContent = ""; - let resultContent = ""; - const toolCallsByName = new Map< - string, - { id: string; name: string; argsBuffer: string } - >(); - - if (useThinking) { - for await (const chunk of streamChat(messages, modelId, tools, signal)) { - if (chunk.type === "thinking") { - thinkingContent += chunk.text; - store.setThinking({ - content: thinkingContent, - tokens: thinkingContent.length, - }); - } else if (chunk.type === "content") { - resultContent += chunk.text; - store.set({ result: resultContent }); - } else if (chunk.type === "tool_call") { - accumulateToolCall( - toolCallsByName, - chunk.id, - chunk.name, - chunk.arguments, - ); - } - drawUI(); - } - } else { - for await (const chunk of streamChat(messages, modelId, tools, signal)) { - if (chunk.type === "content") { - resultContent += chunk.text; - store.set({ result: resultContent }); - } else if (chunk.type === "tool_call") { - accumulateToolCall( - toolCallsByName, - chunk.id, - chunk.name, - chunk.arguments, - ); - } - drawUI(); - } - } - - // No tool calls → done - if (toolCallsByName.size === 0) { - store.set({ result: resultContent }); - return; - } - - // Execute each tool call - const completedToolCalls: ToolCall[] = []; - for (const [, acc] of toolCallsByName) { - let args: Record = {}; - try { - args = JSON.parse(acc.argsBuffer) as Record; - } catch { - // malformed args - } - - const toolCall: ToolCall = { - id: acc.id, - name: acc.name, - arguments: args, - }; - completedToolCalls.push(toolCall); - - // Register agent step - const stepId = `step_${Date.now()}_${acc.id}`; - store.addAgentStep({ - id: stepId, - type: "tool_call", - name: acc.name, - arguments: args, - status: "running", - timestamp: Date.now(), - }); - drawUI(); - - // Find tool definition - const toolDef = tools.find((t) => t.name === acc.name); - if (!toolDef) { - store.updateAgentStep(stepId, { - status: "error", - content: `Unknown tool: ${acc.name}`, - }); - continue; - } - - // Execute - const result = await executeTool( - toolCall, - toolDef, - projectDir, - confirmFn, - ); - - // Update step with result - store.updateAgentStep(stepId, { - type: "tool_result", - content: result.success ? result.result : `Error: ${result.result}`, - status: result.success ? "done" : "error", - }); - drawUI(); - - // Feed result back to messages - messages.push({ - role: "assistant", - content: "", - }); - messages.push({ - role: "tool", - tool_call_id: result.toolCallId, - name: result.name, - content: result.result, - }); - } - - // Clear agent steps after a short delay concept — keep them visible - // They'll be cleared on next user message - } - - store.set({ - result: `${store.get().result}\n\n[Max tool iterations reached]`, - }); -} - -function accumulateToolCall( - acc: Map, - id: string, - name: string, - argsChunk: string, -) { - let existing = acc.get(id); - if (!existing) { - existing = { id, name, argsBuffer: "" }; - acc.set(id, existing); - } - existing.argsBuffer += argsChunk; -} diff --git a/src/components/agent-steps.ts b/src/components/agent-steps.ts deleted file mode 100644 index c766094..0000000 --- a/src/components/agent-steps.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { Terminal } from "@hexie/tui"; -import { drawPanel, drawSectionLabel } from "../renderer/draw"; -import { theme } from "../renderer/theme"; -import type { AgentStep, Layout } from "../types"; - -const TOOL_ICONS: Record = { - web_search: "🔍", - get_webpage: "🌐", - read_file: "📖", - list_files: "📂", - write_file: "📝", - shell_exec: "💻", -}; - -export function renderAgentSteps( - term: Terminal, - layout: Layout, - steps: AgentStep[], -) { - if (steps.length === 0) return; - - const { row, col, width, height } = layout.agentSteps; - if (height === 0) return; - - // Background - drawPanel(term, row, col, width, height, theme.bg.panelAlt); - - // Section label - drawSectionLabel(term, row, col, "agent", theme.fg.warning); - - const visibleSteps = steps.slice(-Math.max(0, height - 2)); - - for (let i = 0; i < visibleSteps.length; i++) { - const step = visibleSteps[i]; - if (!step) continue; - - const stepRow = row + 1 + i; - if (stepRow >= row + height) break; - - const icon = getStepIcon(step); - const text = getStepText(step); - const indicator = getStepIndicator(step); - const display = ` ${icon} ${text} ${indicator}`; - - const truncated = - display.length > width - 2 ? `${display.slice(0, width - 3)}…` : display; - const color = getStepColor(step); - - term.putText(stepRow, col, truncated, { fg: color }); - } -} - -function getStepIcon(step: AgentStep): string { - if (step.type === "tool_call" || step.type === "tool_result") { - return TOOL_ICONS[step.name ?? ""] ?? "🔧"; - } - return "💬"; -} - -function getStepText(step: AgentStep): string { - switch (step.type) { - case "tool_call": { - const args = step.arguments - ? Object.values(step.arguments).join(", ") - : ""; - return `${step.name}(${truncateStr(args, 40)})`; - } - case "tool_result": - return `${step.name}: ${truncateStr(step.content ?? "", 50)}`; - case "thinking": - return truncateStr(step.content ?? "", 50); - case "content": - return truncateStr(step.content ?? "", 50); - default: - return ""; - } -} - -function getStepIndicator(step: AgentStep): string { - switch (step.status) { - case "running": - return ( - theme.symbols.spinner[ - Math.floor(Date.now() / 100) % theme.symbols.spinner.length - ] ?? "" - ); - case "done": - return theme.symbols.check; - case "error": - return theme.symbols.cross; - default: - return ""; - } -} - -function getStepColor(step: AgentStep): typeof theme.fg.primary { - if (step.status === "error") return theme.fg.error; - if (step.status === "running") return theme.fg.accent; - if (step.type === "tool_call") return theme.fg.warning; - if (step.type === "tool_result") return theme.fg.success; - return theme.fg.secondary; -} - -function truncateStr(text: string, max: number): string { - const single = text.replace(/\n/g, " ").trim(); - return single.length > max ? `${single.slice(0, max - 1)}…` : single; -} diff --git a/src/components/command-palette.ts b/src/components/command-palette.ts deleted file mode 100644 index f7734cc..0000000 --- a/src/components/command-palette.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Terminal } from "@hexie/tui"; -import { theme } from "../renderer/theme"; -import type { Action } from "../types"; - -export function renderCommandPalette( - term: Terminal, - actions: Action[], - selectedIndex: number, - rows: number, - cols: number, -) { - const paletteHeight = Math.min(actions.length + 2, rows - 4); - const paletteWidth = Math.min(40, cols - 4); - const startRow = Math.floor((rows - paletteHeight) / 2); - const startCol = Math.floor((cols - paletteWidth) / 2); - - // Background overlay - for (let r = startRow; r < startRow + paletteHeight; r++) { - for (let c = startCol; c < startCol + paletteWidth; c++) { - term.putChar(r, c, " ", { bg: theme.bg.panelAlt }); - } - } - - // Title - const title = " Actions "; - term.putText( - startRow, - startCol + Math.floor((paletteWidth - title.length) / 2), - title, - { fg: theme.fg.accent, bg: theme.bg.panelAlt, bold: true }, - ); - - // Separator - for (let c = startCol; c < startCol + paletteWidth; c++) { - term.putChar(startRow + 1, c, "─", { - fg: theme.fg.dim, - bg: theme.bg.panelAlt, - }); - } - - // Actions list - for (let i = 0; i < actions.length; i++) { - const action = actions[i]; - const row = startRow + 2 + i; - const isSelected = i === selectedIndex; - - // Background - for (let c = startCol; c < startCol + paletteWidth; c++) { - term.putChar(row, c, " ", { - bg: isSelected ? theme.bg.status : theme.bg.panelAlt, - }); - } - - // Selection indicator - const prefix = isSelected ? "❯ " : " "; - term.putText(row, startCol + 1, prefix, { - fg: isSelected ? theme.fg.accent : theme.fg.dim, - bg: isSelected ? theme.bg.status : theme.bg.panelAlt, - }); - - // Action label - term.putText(row, startCol + 3, action.label, { - fg: isSelected ? theme.fg.primary : theme.fg.secondary, - bg: isSelected ? theme.bg.status : theme.bg.panelAlt, - }); - - // Shortcut - if (action.shortcut) { - term.putText( - row, - startCol + paletteWidth - action.shortcut.length - 2, - action.shortcut, - { - fg: theme.fg.dim, - bg: isSelected ? theme.bg.status : theme.bg.panelAlt, - }, - ); - } - } -} diff --git a/src/components/header.ts b/src/components/header.ts deleted file mode 100644 index 0b3606d..0000000 --- a/src/components/header.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Terminal } from "@hexie/tui"; -import { drawFrise } from "../renderer/draw"; -import type { Layout } from "../renderer/layout"; -import { theme } from "../renderer/theme"; - -export function renderHeader( - term: Terminal, - layout: Layout, - modelName: string, -) { - const { row, width } = layout.header; - - // Dark background - for (let c = 0; c < width; c++) { - term.putChar(row, c, " ", { bg: theme.bg.header }); - } - - // Title - term.putText(row, 1, "🏄 paikea", { - bold: true, - fg: theme.fg.accent, - bg: theme.bg.header, - }); - - // Model pill - const pill = ` ${theme.symbols.gear} ${modelName} `; - term.putText(row, 12, pill, { - fg: theme.fg.primary, - bg: theme.bg.panel, - }); - - // Shortcuts hint - const hint = "enter send · tab model · ↑ tools · : palette · esc quit"; - term.putText(row, width - hint.length - 1, hint, { - fg: theme.fg.dim, - bg: theme.bg.header, - }); - - // Frise below header (row 1) - if (layout.header.height >= 1) { - drawFrise(term, row + 1, 0, width); - } -} diff --git a/src/components/prompt-input.ts b/src/components/prompt-input.ts deleted file mode 100644 index 6c81abe..0000000 --- a/src/components/prompt-input.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Terminal } from "@hexie/tui"; -import { drawPanel } from "../renderer/draw"; -import type { Layout } from "../renderer/layout"; -import { theme } from "../renderer/theme"; - -export function renderPromptInput( - term: Terminal, - layout: Layout, - prompt: string, - cursorPos: number, - focused: boolean, - suggestions: string[], - suggestionIndex: number, -) { - const { row, col, width, height } = layout.input; - - // Subtle background - drawPanel(term, row, col, width, height, theme.bg.input); - - // Top border line - const lineColor = focused ? theme.fg.accent : theme.fg.dim; - for (let c = 0; c < width; c++) { - term.putChar(row, col + c, "─", { fg: lineColor, dim: true }); - } - - // Prompt prefix - const prefix = "❯ "; - term.putText(row + 1, col + 1, prefix, { - fg: theme.fg.accent, - bold: true, - }); - - // Input text - const displayText = prompt || ""; - const maxChars = width - 6; - const visibleText = displayText.slice(0, maxChars); - - if (visibleText) { - term.putText(row + 1, col + 3, visibleText, { - fg: theme.fg.primary, - }); - } else { - // Placeholder - term.putText(row + 1, col + 3, "type your prompt...", { - fg: theme.fg.dim, - }); - } - - // Cursor - if (focused) { - const cursorCol = col + 3 + Math.min(cursorPos, maxChars); - term.putChar(row + 1, cursorCol, "█", { fg: theme.fg.accent }); - } - - // Suggestion (bottom row) - const suggestion = - suggestionIndex >= 0 ? suggestions[suggestionIndex] : suggestions[0]; - - if (suggestion && focused) { - // Show suggestion as ghost text after the prompt - const suggestionRow = row + 2; - const remaining = suggestion.slice(prompt.length); - if (remaining) { - term.putText(suggestionRow, col + 3, remaining, { - fg: theme.fg.dim, - italic: true, - }); - } - - // Tab hint - const tabHint = "tab →"; - term.putText(suggestionRow, col + width - tabHint.length - 1, tabHint, { - fg: theme.fg.dim, - dim: true, - }); - } else { - // Default hint - const hint = "Enter to send"; - term.putText(row + 2, col + width - hint.length - 1, hint, { - fg: theme.fg.dim, - dim: true, - }); - } -} diff --git a/src/components/result-pane.ts b/src/components/result-pane.ts deleted file mode 100644 index 85cf475..0000000 --- a/src/components/result-pane.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Attributes, Terminal } from "@hexie/tui"; -import { drawPanel, drawSectionLabel, drawTextWrapped } from "../renderer/draw"; -import type { Layout } from "../renderer/layout"; -import { theme } from "../renderer/theme"; - -export function renderResultPane( - term: Terminal, - layout: Layout, - content: string, - scrollOffset: number, -) { - const { row, col, width, height } = layout.result; - - // Subtle background - drawPanel(term, row, col, width, height, theme.bg.panel); - - // Section label - drawSectionLabel(term, row, col, "response", theme.fg.accent); - - if (content) { - const lines = content.split("\n"); - const visibleLines = lines.slice(scrollOffset, scrollOffset + height - 2); - const contentWidth = width - 4; - - for (let i = 0; i < visibleLines.length; i++) { - const line = visibleLines[i] ?? ""; - let displayLine = line; - let attrs: Attributes = { fg: theme.fg.primary }; - - // Headers - if (line.startsWith("# ")) { - displayLine = line.slice(2); - attrs = { fg: theme.fg.accent, bold: true }; - } else if (line.startsWith("## ")) { - displayLine = line.slice(3); - attrs = { fg: theme.fg.accent }; - } else if (line.startsWith("### ")) { - displayLine = line.slice(4); - attrs = { fg: theme.fg.warning }; - } - // Bold - else if (line.startsWith("**") && line.endsWith("**")) { - displayLine = line.slice(2, -2); - attrs = { bold: true }; - } - // Code block - else if (line.startsWith("```")) { - attrs = { fg: theme.fg.dim }; - } - // List - else if (line.startsWith("- ")) { - displayLine = ` ${theme.symbols.dot} ${line.slice(2)}`; - attrs = { fg: theme.fg.secondary }; - } - // Numbered list - else if (/^\d+\.\s/.test(line)) { - attrs = { fg: theme.fg.secondary }; - } - - drawTextWrapped( - term, - row + 1 + i, - col + 2, - contentWidth, - displayLine, - attrs, - ); - } - } else { - const spinner = - theme.symbols.spinner[ - Math.floor(Date.now() / 100) % theme.symbols.spinner.length - ]; - term.putText(row + 1, col + 2, `${spinner} awaiting response...`, { - fg: theme.fg.dim, - }); - } -} diff --git a/src/components/status-bar.ts b/src/components/status-bar.ts deleted file mode 100644 index c2ed4ee..0000000 --- a/src/components/status-bar.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Terminal } from "@hexie/tui"; -import type { Layout } from "../renderer/layout"; -import { theme } from "../renderer/theme"; - -interface ToolbarButton { - label: string; - id: string; -} - -const TOOLBAR_BUTTONS: ToolbarButton[] = [ - { label: "⚙ Model", id: "model" }, - { label: "💭 Thinking", id: "thinking" }, - { label: "🗑 Clear", id: "clear" }, - { label: "📋 Palette", id: "palette" }, -]; - -export function renderStatusBar( - term: Terminal, - layout: Layout, - _modelName: string, - tokens: number, - streaming: boolean, - toolbarIndex: number, - isToolbarMode: boolean, -) { - const { row, width } = layout.status; - - // Dark background - for (let c = 0; c < width; c++) { - term.putChar(row, c, " ", { bg: theme.bg.status }); - } - - // Left: token count - const tokenText = ` tokens ${tokens}`; - term.putText(row, 1, tokenText, { - fg: theme.fg.dim, - bg: theme.bg.status, - }); - - // Center: toolbar buttons (when not streaming) - if (!streaming) { - const buttonsStart = Math.floor(width / 2 - 20); - let offset = buttonsStart; - - for (let i = 0; i < TOOLBAR_BUTTONS.length; i++) { - const btn = TOOLBAR_BUTTONS[i]; - const isSelected = isToolbarMode && i === toolbarIndex; - - // Button background - const btnText = ` ${btn.label} `; - for (let c = offset; c < offset + btnText.length; c++) { - term.putChar(row, c, " ", { - bg: isSelected ? theme.bg.panelAlt : theme.bg.status, - }); - } - - term.putText(row, offset, btnText, { - fg: isSelected ? theme.fg.accent : theme.fg.dim, - bg: isSelected ? theme.bg.panelAlt : theme.bg.status, - bold: isSelected, - }); - - offset += btnText.length + 1; - } - } else { - // Streaming indicator - const spinner = - theme.symbols.spinner[ - Math.floor(Date.now() / 100) % theme.symbols.spinner.length - ]; - const streamText = ` ${spinner} streaming `; - term.putText( - row, - Math.floor(width / 2 - streamText.length / 2), - streamText, - { - fg: theme.fg.accent, - bg: theme.bg.status, - bold: true, - }, - ); - } - - // Right: mode hint - let rightText = ""; - if (streaming) { - rightText = ""; - } else if (isToolbarMode) { - rightText = " Tab/Enter "; - } else { - rightText = " : palette "; - } - term.putText(row, width - rightText.length - 1, rightText, { - fg: theme.fg.dim, - bg: theme.bg.status, - }); -} - -export function getToolbarActions(): ToolbarButton[] { - return TOOLBAR_BUTTONS; -} diff --git a/src/components/thinking-pane.ts b/src/components/thinking-pane.ts deleted file mode 100644 index 7aa95f4..0000000 --- a/src/components/thinking-pane.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Terminal } from "@hexie/tui"; -import { drawPanel, drawSectionLabel, drawTextWrapped } from "../renderer/draw"; -import type { Layout } from "../renderer/layout"; -import { theme } from "../renderer/theme"; - -export function renderThinkingPane( - term: Terminal, - layout: Layout, - content: string, - visible: boolean, -) { - if (!visible) return; - - const { row, col, width, height } = layout.thinking; - if (height === 0) return; - - // Background - drawPanel(term, row, col, width, height, theme.bg.thinking); - - // Section label - drawSectionLabel(term, row, col, "thinking", theme.fg.thinking); - - if (content) { - const lines = content.split("\n"); - const visibleLines = lines.slice(0, height - 2); - const contentWidth = width - 4; - - for (let i = 0; i < visibleLines.length; i++) { - const line = visibleLines[i] ?? ""; - drawTextWrapped(term, row + 1 + i, col + 2, contentWidth, line, { - fg: theme.fg.thinking, - }); - } - } else { - const spinner = - theme.symbols.spinner[ - Math.floor(Date.now() / 100) % theme.symbols.spinner.length - ]; - term.putText(row + 1, col + 2, `${spinner} thinking...`, { - fg: theme.fg.thinking, - }); - } -} diff --git a/src/components/timeline.ts b/src/components/timeline.ts deleted file mode 100644 index c8eaba4..0000000 --- a/src/components/timeline.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Terminal } from "@hexie/tui"; -import type { Layout } from "../renderer/layout"; -import { theme } from "../renderer/theme"; -import type { Step } from "../types"; - -export function renderTimeline(term: Terminal, layout: Layout, steps: Step[]) { - const { row, width } = layout.timeline; - - // Clear - for (let c = 0; c < width; c++) { - term.putChar(row, c, " "); - } - - let col = 2; - - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - if (!step) continue; - - // Symbol - if (step.status === "done") { - term.putChar(row, col, theme.symbols.bullet, { fg: theme.fg.success }); - } else if (step.status === "current") { - term.putChar(row, col, theme.symbols.bullet, { - fg: theme.fg.accent, - bold: true, - }); - } else { - term.putChar(row, col, theme.symbols.empty, { fg: theme.fg.dim }); - } - - col += 2; - - // Name - const nameAttrs: { fg: typeof theme.fg.primary; bold?: boolean } = - step.status === "current" - ? { fg: theme.fg.accent, bold: true } - : step.status === "done" - ? { fg: theme.fg.success } - : { fg: theme.fg.dim }; - - term.putText(row, col, step.name, nameAttrs); - col += step.name.length; - - // Divider - if (i < steps.length - 1) { - term.putChar(row, col, " "); - col += 1; - term.putChar(row, col, theme.symbols.divider, { fg: theme.fg.dim }); - col += 1; - term.putChar(row, col, theme.symbols.divider, { fg: theme.fg.dim }); - col += 1; - term.putChar(row, col, theme.symbols.divider, { fg: theme.fg.dim }); - col += 1; - term.putChar(row, col, " "); - col += 1; - } - } -} diff --git a/src/renderer/draw.ts b/src/renderer/draw.ts deleted file mode 100644 index 4f9fb3b..0000000 --- a/src/renderer/draw.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { Attributes, Terminal } from "@hexie/tui"; -import { FRISE_CHARS, friseColor, theme } from "./theme"; - -export function drawBox( - term: Terminal, - row: number, - col: number, - width: number, - height: number, - title?: string, - attrs?: Attributes, -) { - const box = theme.box; - const style: Attributes = { fg: theme.fg.dim, ...attrs }; - - term.putChar(row, col, box.topLeft, style); - for (let i = 1; i < width - 1; i++) { - term.putChar(row, col + i, box.horizontal, style); - } - term.putChar(row, col + width - 1, box.topRight, style); - - if (title) { - const titleText = ` ${title} `; - const startCol = col + Math.floor((width - titleText.length) / 2); - term.putText(row, startCol, titleText, { fg: theme.fg.accent, bold: true }); - } - - for (let r = 1; r < height - 1; r++) { - term.putChar(row + r, col, box.vertical, style); - term.putChar(row + r, col + width - 1, box.vertical, style); - } - - term.putChar(row + height - 1, col, box.bottomLeft, style); - for (let i = 1; i < width - 1; i++) { - term.putChar(row + height - 1, col + i, box.horizontal, style); - } - term.putChar(row + height - 1, col + width - 1, box.bottomRight, style); -} - -export function drawPanel( - term: Terminal, - row: number, - col: number, - width: number, - height: number, - bg?: typeof theme.bg.panel, -) { - const bgColor = bg ?? theme.bg.panel; - for (let r = 0; r < height; r++) { - for (let c = 0; c < width; c++) { - term.putChar(row + r, col + c, " ", { bg: bgColor }); - } - } -} - -export function drawFrise( - term: Terminal, - row: number, - col: number, - width: number, -) { - for (let c = 0; c < width; c++) { - const _paletteIdx = Math.floor(c * friseColor(c).toString().length) % 6; - const charIdx = c % FRISE_CHARS.length; - const color = friseColor(c); - term.putChar(row, col + c, FRISE_CHARS[charIdx] ?? "░", { - fg: color, - }); - } -} - -export function drawSectionLabel( - term: Terminal, - row: number, - col: number, - label: string, - color?: typeof theme.fg.accent, -) { - const c = color ?? theme.fg.muted; - term.putText(row, col, `── ${label} `, { fg: c }); - const labelEnd = col + label.length + 5; - for (let i = 0; i < 20; i++) { - term.putChar(row, labelEnd + i, "─", { fg: theme.fg.dim, dim: true }); - } -} - -export function drawTextWrapped( - term: Terminal, - row: number, - col: number, - width: number, - text: string, - attrs?: Attributes, -): number { - const style: Attributes = { ...attrs }; - let currentRow = row; - let currentCol = col; - let line = ""; - - for (const char of text) { - if (char === "\n") { - term.putText(currentRow, currentCol, line, style); - currentRow++; - currentCol = col; - line = ""; - continue; - } - - line += char; - if (line.length >= width - 1) { - term.putText(currentRow, currentCol, line, style); - currentRow++; - currentCol = col; - line = ""; - } - } - - if (line) { - term.putText(currentRow, currentCol, line, style); - currentRow++; - } - - return currentRow; -} - -export function clearArea( - term: Terminal, - row: number, - col: number, - width: number, - height: number, -) { - for (let r = 0; r < height; r++) { - for (let c = 0; c < width; c++) { - term.putChar(row + r, col + c, " "); - } - } -} diff --git a/src/renderer/input-decoder.ts b/src/renderer/input-decoder.ts deleted file mode 100644 index 925c797..0000000 --- a/src/renderer/input-decoder.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Self-contained ANSI/VT input decoder. - * - * The bundled `@hexie/tui` decoder only emits usable events when the Kitty - * keyboard protocol is negotiated with the terminal. Terminals that don't - * speak Kitty (macOS Terminal.app, most legacy emulators) send classic VT - * sequences, which that decoder silently drops. This decoder handles the - * classic sequences directly so the TUI works everywhere. - * - * It is intentionally pure: `decodeInput` turns a byte buffer into events plus - * a count of consumed bytes, leaving any incomplete trailing sequence for the - * next chunk. The escape-disambiguation timing lives in the caller (terminal.ts). - */ - -export type KeyCode = string | { char: string }; - -export interface KeyModifiers { - ctrl: boolean; - alt: boolean; - shift: boolean; - meta: boolean; -} - -export interface KeyEvent { - type: "key"; - code: KeyCode; - modifiers: KeyModifiers; -} - -export interface DecodeResult { - events: KeyEvent[]; - /** Number of leading bytes consumed; the rest is an incomplete tail. */ - consumed: number; -} - -const ESC = 0x1b; - -function mods(p: Partial = {}): KeyModifiers { - return { - ctrl: p.ctrl ?? false, - alt: p.alt ?? false, - shift: p.shift ?? false, - meta: p.meta ?? false, - }; -} - -function key(code: KeyCode, m: Partial = {}): KeyEvent { - return { type: "key", code, modifiers: mods(m) }; -} - -/** xterm modifier param (e.g. the `5` in `ESC[1;5A`) → modifier flags. */ -function decodeModifierParam(param: number | undefined): Partial { - if (!param || param <= 1) return {}; - const bits = param - 1; - return { - shift: (bits & 1) !== 0, - alt: (bits & 2) !== 0, - ctrl: (bits & 4) !== 0, - meta: (bits & 8) !== 0, - }; -} - -const CSI_LETTER: Record = { - A: "Up", - B: "Down", - C: "Right", - D: "Left", - H: "Home", - F: "End", -}; - -const CSI_TILDE: Record = { - 1: "Home", - 2: "Insert", - 3: "Delete", - 4: "End", - 5: "PageUp", - 6: "PageDown", - 7: "Home", - 8: "End", -}; - -function isCsiFinal(byte: number): boolean { - return byte >= 0x40 && byte <= 0x7e; -} - -/** Parse a CSI body (params between `ESC[` and the final byte). */ -function parseCsi(params: string, finalByte: number): KeyEvent | null { - const final = String.fromCharCode(finalByte); - - // Mouse reports (we never enable mouse tracking) — swallow silently. - if (params.startsWith("<") || final === "M" || final === "m") return null; - - const parts = params.split(";").map((p) => Number.parseInt(p, 10)); - const modifiers = decodeModifierParam(parts[1]); - - if (final === "Z") return key("Tab", { shift: true }); // Shift+Tab (BackTab) - - if (final === "~") { - const name = CSI_TILDE[parts[0] ?? 0]; - return name ? key(name, modifiers) : null; - } - - const name = CSI_LETTER[final]; - return name ? key(name, modifiers) : null; -} - -const SS3_MAP: Record = { - A: "Up", - B: "Down", - C: "Right", - D: "Left", - H: "Home", - F: "End", - P: "F1", - Q: "F2", - R: "F3", - S: "F4", -}; - -/** - * Decode as many complete key events as possible from `buf`. - * - * A lone trailing `ESC` (or an incomplete escape sequence) is left unconsumed - * so the caller can disambiguate it against a timeout. - */ -export function decodeInput(buf: Uint8Array): DecodeResult { - const events: KeyEvent[] = []; - const len = buf.length; - let i = 0; - - while (i < len) { - const b = buf[i] as number; - - if (b === ESC) { - if (i + 1 >= len) break; // lone/incomplete ESC → wait for more bytes - const b1 = buf[i + 1] as number; - - if (b1 === 0x5b) { - // CSI: ESC [ params final - let j = i + 2; - while (j < len && !isCsiFinal(buf[j] as number)) j++; - if (j >= len) break; // incomplete CSI → wait - const params = String.fromCharCode(...buf.slice(i + 2, j)); - const ev = parseCsi(params, buf[j] as number); - if (ev) events.push(ev); - i = j + 1; - continue; - } - - if (b1 === 0x4f) { - // SS3: ESC O (application cursor / function keys) - if (i + 2 >= len) break; - const name = SS3_MAP[String.fromCharCode(buf[i + 2] as number)]; - if (name) events.push(key(name)); - i += 3; - continue; - } - - if (b1 === ESC) { - // ESC ESC → a standalone Escape followed by more input - events.push(key("Escape")); - i += 1; - continue; - } - - if (b1 >= 0x20 && b1 < 0x7f) { - // Alt/Meta + printable char (ESC prefix) - events.push(key({ char: String.fromCharCode(b1) }, { alt: true })); - i += 2; - continue; - } - - // ESC + control byte we don't model → treat the ESC as Escape. - events.push(key("Escape")); - i += 1; - continue; - } - - if (b === 0x0d || b === 0x0a) { - events.push(key("Enter")); - i += 1; - continue; - } - if (b === 0x09) { - events.push(key("Tab")); - i += 1; - continue; - } - if (b === 0x7f || b === 0x08) { - events.push(key("Backspace")); - i += 1; - continue; - } - // Common Ctrl+ shortcuts (raw mode delivers these as low bytes). - if (b === 0x03) { - events.push(key({ char: "c" }, { ctrl: true })); - i += 1; - continue; - } - if (b === 0x04) { - events.push(key({ char: "d" }, { ctrl: true })); - i += 1; - continue; - } - if (b === 0x01) { - events.push(key({ char: "a" }, { ctrl: true })); - i += 1; - continue; - } - if (b === 0x05) { - events.push(key({ char: "e" }, { ctrl: true })); - i += 1; - continue; - } - if (b === 0x15) { - events.push(key({ char: "u" }, { ctrl: true })); // Ctrl+U (kill line) - i += 1; - continue; - } - if (b === 0x17) { - events.push(key({ char: "w" }, { ctrl: true })); // Ctrl+W (kill word) - i += 1; - continue; - } - if (b < 0x20) { - i += 1; // other control bytes: ignore - continue; - } - if (b < 0x80) { - events.push(key({ char: String.fromCharCode(b) })); - i += 1; - continue; - } - - // UTF-8 multi-byte sequence. - let n = 1; - if (b >= 0xf0) n = 4; - else if (b >= 0xe0) n = 3; - else if (b >= 0xc0) n = 2; - if (i + n > len) break; // incomplete UTF-8 → wait - const char = new TextDecoder().decode(buf.slice(i, i + n)); - events.push(key({ char })); - i += n; - } - - return { events, consumed: i }; -} diff --git a/src/renderer/layout.ts b/src/renderer/layout.ts deleted file mode 100644 index 75e70be..0000000 --- a/src/renderer/layout.ts +++ /dev/null @@ -1,81 +0,0 @@ -export interface Layout { - header: { row: number; col: number; width: number; height: number }; - timeline: { row: number; col: number; width: number; height: number }; - thinking: { row: number; col: number; width: number; height: number }; - agentSteps: { row: number; col: number; width: number; height: number }; - result: { row: number; col: number; width: number; height: number }; - input: { row: number; col: number; width: number; height: number }; - status: { row: number; col: number; width: number; height: number }; -} - -export function calculateLayout( - rows: number, - cols: number, - thinkingVisible: boolean, - agentStepCount = 0, -): Layout { - const headerHeight = 1; - const timelineHeight = 1; - const inputHeight = 3; - const statusHeight = 1; - const padding = 1; - - const fixedHeight = - headerHeight + timelineHeight + inputHeight + statusHeight; - const availableHeight = rows - fixedHeight; - - const thinkingHeight = thinkingVisible - ? Math.floor(availableHeight * 0.3) - : 0; - const agentStepsHeight = agentStepCount > 0 ? Math.min(agentStepCount, 6) : 0; - const resultHeight = availableHeight - thinkingHeight - agentStepsHeight; - - let currentRow = 0; - - const header = { row: currentRow, col: 0, width: cols, height: headerHeight }; - currentRow += headerHeight; - - const timeline = { - row: currentRow, - col: 0, - width: cols, - height: timelineHeight, - }; - currentRow += timelineHeight; - - const thinking = { - row: currentRow, - col: padding, - width: cols - padding * 2, - height: thinkingHeight, - }; - currentRow += thinkingHeight; - - const agentSteps = { - row: currentRow, - col: padding, - width: cols - padding * 2, - height: agentStepsHeight, - }; - currentRow += agentStepsHeight; - - const result = { - row: currentRow, - col: padding, - width: cols - padding * 2, - height: resultHeight, - }; - currentRow += resultHeight; - - const input = { - row: currentRow, - col: padding, - width: cols - padding * 2, - height: inputHeight, - }; - currentRow += inputHeight; - - const status = { row: currentRow, col: 0, width: cols, height: statusHeight }; - - return { header, timeline, thinking, agentSteps, result, input, status }; -} diff --git a/src/renderer/terminal.ts b/src/renderer/terminal.ts deleted file mode 100644 index 9ec9efe..0000000 --- a/src/renderer/terminal.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { Terminal } from "@hexie/tui"; -import { decodeInput, type KeyEvent } from "./input-decoder"; - -/** How long to wait after a lone ESC before treating it as the Escape key. */ -const ESC_FLUSH_MS = 40; - -let term: Terminal | null = null; -let stream: InputStream | null = null; - -/** - * Async, closeable stream of decoded key events read straight from stdin. - * - * We decode classic VT sequences ourselves (see input-decoder.ts) rather than - * relying on the library's Kitty-only decoder, and disambiguate a bare ESC from - * the start of an escape sequence with a short flush timer. - */ -class InputStream implements AsyncIterable { - private queue: KeyEvent[] = []; - private resolvers: Array<(r: IteratorResult) => void> = []; - private pending = new Uint8Array(0); - private escTimer: ReturnType | null = null; - private closed = false; - private reader: ReadableStreamDefaultReader; - - constructor() { - this.reader = Bun.stdin - .stream() - .getReader() as ReadableStreamDefaultReader; - void this.readLoop(); - } - - private emit(event: KeyEvent) { - const resolver = this.resolvers.shift(); - if (resolver) { - resolver({ value: event, done: false }); - } else { - this.queue.push(event); - } - } - - private clearEscTimer() { - if (this.escTimer) { - clearTimeout(this.escTimer); - this.escTimer = null; - } - } - - /** Schedule flushing a stalled ESC-prefixed tail as a standalone Escape. */ - private scheduleEscFlush() { - this.clearEscTimer(); - this.escTimer = setTimeout(() => { - this.escTimer = null; - if (this.pending.length > 0 && this.pending[0] === 0x1b) { - this.pending = new Uint8Array(0); - this.emit({ - type: "key", - code: "Escape", - modifiers: { ctrl: false, alt: false, shift: false, meta: false }, - }); - } - }, ESC_FLUSH_MS); - } - - private async readLoop() { - while (!this.closed) { - let value: Uint8Array | undefined; - try { - const chunk = await this.reader.read(); - if (chunk.done) break; - value = chunk.value; - } catch { - break; - } - if (this.closed) break; - if (!value || value.length === 0) continue; - - this.clearEscTimer(); - - // Append to any incomplete tail from the previous chunk. - const merged = new Uint8Array(this.pending.length + value.length); - merged.set(this.pending, 0); - merged.set(value, this.pending.length); - - const { events, consumed } = decodeInput(merged); - this.pending = merged.slice(consumed); - for (const ev of events) this.emit(ev); - - // A leftover ESC-prefixed tail is either a bare Escape or a sequence - // that arrived split — flush it as Escape if no more bytes follow. - if (this.pending.length > 0 && this.pending[0] === 0x1b) { - this.scheduleEscFlush(); - } - } - for (const resolver of this.resolvers) { - resolver({ value: undefined as unknown as KeyEvent, done: true }); - } - this.resolvers = []; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - while (!this.closed) { - const queued = this.queue.shift(); - if (queued) { - yield queued; - continue; - } - const result = await new Promise>((resolve) => { - this.resolvers.push(resolve); - }); - if (result.done) break; - yield result.value; - } - } - - close() { - this.closed = true; - this.clearEscTimer(); - try { - void this.reader.cancel(); - } catch { - // ignore - } - for (const resolver of this.resolvers) { - resolver({ value: undefined as unknown as KeyEvent, done: true }); - } - this.resolvers = []; - this.queue = []; - } -} - -export async function initTerminal() { - // Terminal.open() already puts stdin into raw mode (no echo, no canonical - // line editing, ISIG off) so Ctrl+C reaches us as a byte we handle ourselves. - term = Terminal.open({ syncUpdate: true }); - - term.write("\x1b[?1049h"); // enter alternate screen - term.hideCursor(); - term.clearScreen(); - term.render(); - - stream = new InputStream(); - return term; -} - -export function getEventStream() { - return stream; -} - -export function getTerminal() { - return term; -} - -export async function cleanupTerminal() { - if (stream) { - stream.close(); - stream = null; - } - if (term) { - term.write("\x1b[?1049l"); // leave alternate screen - term.showCursor(); - term.close(); // restores raw mode + removes signal handlers - term = null; - } -} diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts deleted file mode 100644 index 1adc56c..0000000 --- a/src/renderer/theme.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Color } from "@hexie/tui"; - -// Gradient palette for the frise -const FRISE_PALETTE: Color[] = [ - "#ff6b6b" as Color, - "#ff8e53" as Color, - "#ffc93c" as Color, - "#6bcb77" as Color, - "#4ecdc4" as Color, - "#45b7d1" as Color, - "#6c5ce7" as Color, - "#a29bfe" as Color, - "#fd79a8" as Color, - "#e17055" as Color, -]; - -export function friseColor(index: number): Color { - return FRISE_PALETTE[index % FRISE_PALETTE.length] ?? FRISE_PALETTE[0]; -} - -export const FRISE_CHARS = ["░", "▒", "▓", "█", "▓", "▒", "░"]; - -export const theme = { - fg: { - primary: "#e0e0e0" as Color, - secondary: "#8a8a8a" as Color, - dim: "#555555" as Color, - accent: "#45b7d1" as Color, - success: "#6bcb77" as Color, - warning: "#ffc93c" as Color, - error: "#ff6b6b" as Color, - thinking: "#a29bfe" as Color, - muted: "#636e72" as Color, - }, - bg: { - header: "#0d1117" as Color, - panel: "#161b22" as Color, - panelAlt: "#1c2333" as Color, - status: "#0d1117" as Color, - input: "#161b22" as Color, - thinking: "#1a1530" as Color, - }, - symbols: { - bullet: "●", - empty: "○", - divider: "·", - dot: "•", - arrow: "→", - spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], - check: "✓", - cross: "✗", - gear: "⚙", - }, - box: { - topLeft: "╭", - topRight: "╮", - bottomLeft: "╰", - bottomRight: "╯", - horizontal: "─", - vertical: "│", - }, -}; diff --git a/src/services/openspec-hook.ts b/src/services/openspec-hook.ts index 5483307..8b4197a 100644 --- a/src/services/openspec-hook.ts +++ b/src/services/openspec-hook.ts @@ -18,7 +18,7 @@ export function detectOpenSpecSteps(projectDir: string): Step[] { if (!existsSync(changesDir)) { return WORKFLOW_STEPS.map((s, i) => ({ ...s, - status: (i === 0 ? "current" : "pending") as const, + status: (i === 0 ? "current" : "pending") as Step["status"], })); } @@ -26,7 +26,7 @@ export function detectOpenSpecSteps(projectDir: string): Step[] { if (changes.length === 0) { return WORKFLOW_STEPS.map((s, i) => ({ ...s, - status: (i === 0 ? "current" : "pending") as const, + status: (i === 0 ? "current" : "pending") as Step["status"], })); } diff --git a/src/state/store.ts b/src/state/store.ts deleted file mode 100644 index fe596bb..0000000 --- a/src/state/store.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { - AgentStep, - AppState, - Message, - Step, - ThinkingState, -} from "../types"; - -type Listener = () => void; - -class Store { - private state: AppState = { - model: null, - models: [], - messages: [], - steps: [], - thinking: { active: false, content: "", tokens: 0 }, - result: "", - prompt: "", - scrollOffset: 0, - thinkingVisible: true, - streaming: false, - agentSteps: [], - toolCallsStreaming: new Map(), - uiMode: "normal", - toolbarIndex: 0, - paletteIndex: 0, - suggestions: [], - suggestionIndex: -1, - }; - - private listeners: Listener[] = []; - - get(): AppState { - return this.state; - } - - set(partial: Partial) { - this.state = { ...this.state, ...partial }; - this.notify(); - } - - subscribe(fn: Listener): () => void { - this.listeners.push(fn); - return () => { - this.listeners = this.listeners.filter((l) => l !== fn); - }; - } - - private notify() { - for (const fn of this.listeners) fn(); - } - - addMessage(msg: Message) { - this.state.messages.push(msg); - this.notify(); - } - - setThinking(thinking: Partial) { - this.state.thinking = { ...this.state.thinking, ...thinking }; - this.notify(); - } - - appendResult(text: string) { - this.state.result += text; - this.notify(); - } - - clearResult() { - this.state.result = ""; - this.notify(); - } - - setSteps(steps: Step[]) { - this.state.steps = steps; - this.notify(); - } - - addAgentStep(step: AgentStep) { - this.state.agentSteps.push(step); - this.notify(); - } - - updateAgentStep(id: string, update: Partial) { - const step = this.state.agentSteps.find((s) => s.id === id); - if (step) { - Object.assign(step, update); - this.notify(); - } - } - - clearAgentSteps() { - this.state.agentSteps = []; - this.notify(); - } -} - -export const store = new Store(); diff --git a/src/tui/App.tsx b/src/tui/App.tsx new file mode 100644 index 0000000..bc58484 --- /dev/null +++ b/src/tui/App.tsx @@ -0,0 +1,652 @@ +import { Box, useApp, useInput } from "ink"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { buildRulesPrompt, loadRules } from "../rules/registry"; +import { streamChat } from "../services/dmr-client"; +import { fetchModels, nextModel, prevModel } from "../services/model-registry"; +import { detectOpenSpecSteps } from "../services/openspec-hook"; +import { computeSuggestions, getCurrentStepId } from "../services/suggestions"; +import { hasThinkingSupport } from "../services/thinking-parser"; +import { buildSkillsPrompt, loadSkills } from "../skills/registry"; +import { createSessionId, saveSession } from "../state/session"; +import { executeTool } from "../tools/executor"; +import { loadTools } from "../tools/registry"; +import type { + Action, + AgentStep, + Message, + Model, + Step, + ToolCall, + ToolDefinition, +} from "../types"; +import { AgentSteps } from "./components/AgentSteps"; +import { CommandPalette } from "./components/CommandPalette"; +import { Header } from "./components/Header"; +import { PromptInput } from "./components/PromptInput"; +import { ResultPane } from "./components/ResultPane"; +import { StatusBar } from "./components/StatusBar"; +import { ThinkingPane } from "./components/ThinkingPane"; +import { Timeline } from "./components/Timeline"; + +const MAX_TOOL_ITERATIONS = 10; + +type UIMode = "normal" | "palette" | "toolbar"; + +interface AppState { + model: Model | null; + models: Model[]; + messages: Message[]; + steps: Step[]; + thinking: { active: boolean; content: string; tokens: number }; + result: string; + scrollOffset: number; + thinkingVisible: boolean; + streaming: boolean; + agentSteps: AgentStep[]; + uiMode: UIMode; + toolbarIndex: number; + paletteIndex: number; + suggestions: string[]; + suggestionIndex: number; +} + +export function App() { + const { exit } = useApp(); + + const [state, setState] = useState({ + model: null, + models: [], + messages: [], + steps: [], + thinking: { active: false, content: "", tokens: 0 }, + result: "", + scrollOffset: 0, + thinkingVisible: true, + streaming: false, + agentSteps: [], + uiMode: "normal", + toolbarIndex: 0, + paletteIndex: 0, + suggestions: [], + suggestionIndex: -1, + }); + + const [prompt, setPrompt] = useState(""); + const [cursorPos, setCursorPos] = useState(0); + const streamAbortRef = useRef(null); + const cwd = useRef(process.cwd()); + const sessionId = useRef(createSessionId()); + const systemPromptRef = useRef(""); + const toolsRef = useRef([]); + + // Initialize + useEffect(() => { + async function init() { + const models = await fetchModels(); + if (models.length === 0) { + console.error( + "No models found in Docker Model Runner. Run: docker model pull ai/qwen3", + ); + exit(); + return; + } + + const steps = detectOpenSpecSteps(cwd.current); + const stepId = getCurrentStepId(steps); + const initialSuggestions = computeSuggestions("", stepId); + + const skills = loadSkills(cwd.current); + const rules = loadRules(); + const tools = loadTools(cwd.current); + toolsRef.current = tools; + systemPromptRef.current = + buildSkillsPrompt(skills) + buildRulesPrompt(rules); + + setState((s) => ({ + ...s, + models, + model: models[0] ?? null, + steps, + suggestions: initialSuggestions, + })); + } + void init(); + }, [exit]); + + const update = useCallback((partial: Partial) => { + setState((s) => ({ ...s, ...partial })); + }, []); + + const handleSubmit = useCallback(async () => { + if (!prompt.trim() || !state.model || state.streaming) return; + + const userPrompt = prompt; + setPrompt(""); + setCursorPos(0); + + const userMessage: Message = { + role: "user", + content: userPrompt, + timestamp: Date.now(), + }; + + update({ + streaming: true, + result: "", + agentSteps: [], + suggestions: [], + scrollOffset: 0, + messages: [...state.messages, userMessage], + }); + + const model = state.model; + if (!model) return; + const useThinking = hasThinkingSupport(model.id); + + const messages: { + role: string; + content: string; + tool_call_id?: string; + name?: string; + }[] = [{ role: "system", content: systemPromptRef.current }]; + + for (const msg of [...state.messages, userMessage]) { + messages.push({ role: msg.role, content: msg.content }); + } + + streamAbortRef.current = new AbortController(); + try { + await runAgentLoop( + messages, + model.id, + useThinking, + toolsRef.current, + cwd.current, + update, + streamAbortRef.current.signal, + ); + } catch (err) { + if (streamAbortRef.current?.signal.aborted) { + update({ + result: `${state.result}\n\n[Generation cancelled]`, + }); + } else { + update({ result: `Error: ${err}` }); + } + } finally { + streamAbortRef.current = null; + } + + setState((s) => { + const finalResult = s.result; + const assistantMessage: Message = { + role: "assistant", + content: finalResult, + timestamp: Date.now(), + }; + const newMessages = [...s.messages, assistantMessage]; + + saveSession({ + id: sessionId.current, + date: + new Date().toISOString().split("T")[0] ?? new Date().toISOString(), + model: model.name, + messages: newMessages, + }); + + // Sync OpenSpec to vault + try { + import("../services/openspec-sync").then((m) => + m.syncOpenSpecToVault(cwd.current), + ); + } catch { + // Silent fail + } + + return { ...s, streaming: false, messages: newMessages }; + }); + }, [ + prompt, + state.model, + state.streaming, + state.messages, + state.result, + update, + ]); + + const cancelStreaming = useCallback(() => { + if (!state.streaming) return false; + streamAbortRef.current?.abort(); + return true; + }, [state.streaming]); + + const quit = useCallback(() => { + streamAbortRef.current?.abort(); + exit(); + }, [exit]); + + // Actions for palette and toolbar + const actions: Action[] = [ + { + id: "model", + label: "Switch Model", + shortcut: "Tab", + action: () => { + const newModel = nextModel(); + if (newModel) { + update({ + model: newModel, + steps: detectOpenSpecSteps(cwd.current), + }); + } + }, + }, + { + id: "thinking", + label: "Toggle Thinking", + shortcut: "", + action: () => update({ thinkingVisible: !state.thinkingVisible }), + }, + { + id: "clear", + label: "Clear", + shortcut: "", + action: () => + update({ + result: "", + thinking: { active: false, content: "", tokens: 0 }, + agentSteps: [], + scrollOffset: 0, + }), + }, + { + id: "quit", + label: "Quit", + shortcut: "Esc", + action: () => quit(), + }, + ]; + + const executeAction = useCallback( + (action: Action | undefined) => { + if (!action) return; + action.action(); + update({ uiMode: "normal" }); + }, + [update], + ); + + // Keyboard handler + useInput((input, key) => { + const s = state; + + // Ctrl+C / Ctrl+D + if (key.ctrl && (input === "c" || input === "d")) { + if (cancelStreaming()) return; + quit(); + return; + } + + // Palette mode + if (s.uiMode === "palette") { + if (key.escape) { + update({ uiMode: "normal" }); + return; + } + if (key.upArrow) { + update({ paletteIndex: Math.max(0, s.paletteIndex - 1) }); + return; + } + if (key.downArrow) { + update({ + paletteIndex: Math.min(actions.length - 1, s.paletteIndex + 1), + }); + return; + } + if (key.return) { + executeAction(actions[s.paletteIndex]); + return; + } + return; + } + + // Toolbar mode + if (s.uiMode === "toolbar") { + if (key.escape) { + update({ uiMode: "normal" }); + return; + } + if (key.tab) { + update({ toolbarIndex: (s.toolbarIndex + 1) % 4 }); + return; + } + if (key.return) { + if (s.toolbarIndex === 3) { + update({ uiMode: "palette", toolbarIndex: 0 }); + } else { + executeAction(actions[s.toolbarIndex]); + } + return; + } + return; + } + + // Normal mode + if (key.escape) { + if (cancelStreaming()) return; + quit(); + return; + } + + if (key.return && !key.ctrl) { + if (!s.streaming) { + void handleSubmit(); + } + return; + } + + // Scroll + if (key.pageUp || (key.upArrow && key.shift)) { + update({ scrollOffset: Math.max(0, s.scrollOffset - 5) }); + return; + } + if (key.pageDown || (key.downArrow && key.shift)) { + update({ scrollOffset: s.scrollOffset + 5 }); + return; + } + + // Shift+Tab = prev model + if (key.tab && key.shift) { + const newModel = prevModel(); + if (newModel) { + update({ model: newModel, steps: detectOpenSpecSteps(cwd.current) }); + } + return; + } + + // Tab = accept suggestion or next model + if (key.tab) { + if (s.suggestions.length > 0 && s.uiMode === "normal") { + const idx = s.suggestionIndex >= 0 ? s.suggestionIndex : 0; + const suggestion = s.suggestions[idx]; + if (suggestion) { + setPrompt(suggestion); + setCursorPos(suggestion.length); + } + } else { + const newModel = nextModel(); + if (newModel) { + update({ + model: newModel, + steps: detectOpenSpecSteps(cwd.current), + }); + } + } + return; + } + + // : to open palette + if (input === ":") { + update({ uiMode: "palette", paletteIndex: 0 }); + return; + } + + // Arrow keys + if (key.leftArrow) { + setCursorPos((p) => Math.max(0, p - 1)); + return; + } + if (key.rightArrow) { + setCursorPos((p) => Math.min(prompt.length, p + 1)); + return; + } + if (key.upArrow) { + update({ uiMode: "toolbar", toolbarIndex: 0 }); + return; + } + if (key.downArrow) { + update({ uiMode: "normal" }); + return; + } + + // Prompt editing + if (key.backspace) { + setPrompt((p) => p.slice(0, cursorPos - 1) + p.slice(cursorPos)); + setCursorPos((p) => Math.max(0, p - 1)); + const stepId = getCurrentStepId(s.steps); + const suggestions = computeSuggestions(prompt, stepId); + update({ suggestions, suggestionIndex: -1 }); + return; + } + if (key.delete) { + setPrompt((p) => p.slice(0, cursorPos) + p.slice(cursorPos + 1)); + const stepId = getCurrentStepId(s.steps); + const suggestions = computeSuggestions(prompt, stepId); + update({ suggestions, suggestionIndex: -1 }); + return; + } + if (key.home || (key.ctrl && input === "a")) { + setCursorPos(0); + return; + } + if (key.end || (key.ctrl && input === "e")) { + setCursorPos(prompt.length); + return; + } + if (key.ctrl && input === "u") { + setPrompt(prompt.slice(cursorPos)); + setCursorPos(0); + return; + } + if (key.ctrl && input === "w") { + const before = prompt.slice(0, cursorPos); + const trimmed = before.replace(/\s*\S+\s*$/, ""); + setPrompt(trimmed + prompt.slice(cursorPos)); + setCursorPos(trimmed.length); + return; + } + + // Regular character + if (input && !key.ctrl && !key.meta) { + setPrompt((p) => p.slice(0, cursorPos) + input + p.slice(cursorPos)); + setCursorPos((p) => p + input.length); + const stepId = getCurrentStepId(s.steps); + const suggestions = computeSuggestions( + prompt.slice(0, cursorPos) + input + prompt.slice(cursorPos), + stepId, + ); + update({ suggestions, suggestionIndex: -1 }); + } + }); + + return ( + +
+ + + + + + + + + {state.uiMode === "palette" && ( + + )} + + ); +} + +async function runAgentLoop( + messages: { + role: string; + content: string; + tool_call_id?: string; + name?: string; + }[], + modelId: string, + useThinking: boolean, + tools: ToolDefinition[], + projectDir: string, + update: (partial: Partial) => void, + signal?: AbortSignal, +) { + let iterations = 0; + + while (iterations < MAX_TOOL_ITERATIONS) { + if (signal?.aborted) return; + iterations++; + let thinkingContent = ""; + let resultContent = ""; + const toolCallsByName = new Map< + string, + { id: string; name: string; argsBuffer: string } + >(); + + const stream = streamChat(messages, modelId, tools, signal); + + if (useThinking) { + for await (const chunk of stream) { + if (chunk.type === "thinking") { + thinkingContent += chunk.text; + update({ + thinking: { + active: true, + content: thinkingContent, + tokens: thinkingContent.length, + }, + }); + } else if (chunk.type === "content") { + resultContent += chunk.text; + update({ result: resultContent }); + } else if (chunk.type === "tool_call") { + accumulateToolCall( + toolCallsByName, + chunk.id, + chunk.name, + chunk.arguments, + ); + } + } + } else { + for await (const chunk of stream) { + if (chunk.type === "content") { + resultContent += chunk.text; + update({ result: resultContent }); + } else if (chunk.type === "tool_call") { + accumulateToolCall( + toolCallsByName, + chunk.id, + chunk.name, + chunk.arguments, + ); + } + } + } + + if (toolCallsByName.size === 0) { + update({ result: resultContent }); + return; + } + + const completedToolCalls: ToolCall[] = []; + for (const [, acc] of toolCallsByName) { + let args: Record = {}; + try { + args = JSON.parse(acc.argsBuffer) as Record; + } catch { + // malformed args + } + + const toolCall: ToolCall = { + id: acc.id, + name: acc.name, + arguments: args, + }; + completedToolCalls.push(toolCall); + + const stepId = `step_${Date.now()}_${acc.id}`; + update({ + agentSteps: [ + ...completedToolCalls.map((tc) => ({ + id: `step_${tc.id}`, + type: "tool_call" as const, + name: tc.name, + arguments: tc.arguments, + status: "done" as const, + timestamp: Date.now(), + })), + { + id: stepId, + type: "tool_call", + name: acc.name, + arguments: args, + status: "running", + timestamp: Date.now(), + }, + ], + }); + + const toolDef = tools.find((t) => t.name === acc.name); + if (!toolDef) continue; + + const confirmFn = async (message: string): Promise => { + // In Ink, confirmation is handled differently + // For now, auto-approve + console.error(`Confirm: ${message}`); + return true; + }; + + const result = await executeTool( + toolCall, + toolDef, + projectDir, + confirmFn, + ); + + messages.push({ + role: "assistant", + content: "", + }); + messages.push({ + role: "tool", + tool_call_id: result.toolCallId, + name: result.name, + content: result.result, + }); + } + } + + update({ + result: `${""}\n\n[Max tool iterations reached]`, + }); +} + +function accumulateToolCall( + acc: Map, + id: string, + name: string, + argsChunk: string, +) { + let existing = acc.get(id); + if (!existing) { + existing = { id, name, argsBuffer: "" }; + acc.set(id, existing); + } + existing.argsBuffer += argsChunk; +} diff --git a/src/tui/components/AgentSteps.tsx b/src/tui/components/AgentSteps.tsx new file mode 100644 index 0000000..1861344 --- /dev/null +++ b/src/tui/components/AgentSteps.tsx @@ -0,0 +1,100 @@ +import { Box, Text } from "ink"; +import type { AgentStep } from "../../types"; +import { colors, symbols } from "../theme"; + +const TOOL_ICONS: Record = { + web_search: "🔍", + get_webpage: "🌐", + read_file: "📖", + list_files: "📂", + write_file: "📝", + shell_exec: "💻", +}; + +interface AgentStepsProps { + steps: AgentStep[]; +} + +export function AgentSteps({ steps }: AgentStepsProps) { + if (steps.length === 0) return null; + + return ( + + + ── agent ── + + {steps.map((step) => { + const icon = getStepIcon(step); + const text = getStepText(step); + const indicator = getStepIndicator(step); + const color = getStepColor(step); + + return ( + + {icon} {text} {indicator} + + ); + })} + + ); +} + +function getStepIcon(step: AgentStep): string { + if (step.type === "tool_call" || step.type === "tool_result") { + return TOOL_ICONS[step.name ?? ""] ?? "🔧"; + } + return "💬"; +} + +function getStepText(step: AgentStep): string { + switch (step.type) { + case "tool_call": { + const args = step.arguments + ? Object.values(step.arguments).join(", ") + : ""; + return `${step.name}(${truncateStr(args, 40)})`; + } + case "tool_result": + return `${step.name}: ${truncateStr(step.content ?? "", 50)}`; + case "thinking": + return truncateStr(step.content ?? "", 50); + case "content": + return truncateStr(step.content ?? "", 50); + default: + return ""; + } +} + +function getStepIndicator(step: AgentStep): string { + switch (step.status) { + case "running": + return ( + symbols.spinner[ + Math.floor(Date.now() / 100) % symbols.spinner.length + ] ?? "" + ); + case "done": + return symbols.check; + case "error": + return symbols.cross; + default: + return ""; + } +} + +function getStepColor(step: AgentStep): string { + if (step.status === "error") return colors.fg.error; + if (step.status === "running") return colors.fg.accent; + if (step.type === "tool_call") return colors.fg.warning; + if (step.type === "tool_result") return colors.fg.success; + return colors.fg.secondary; +} + +function truncateStr(text: string, max: number): string { + const single = text.replace(/\n/g, " ").trim(); + return single.length > max ? `${single.slice(0, max - 1)}…` : single; +} diff --git a/src/tui/components/CommandPalette.tsx b/src/tui/components/CommandPalette.tsx new file mode 100644 index 0000000..f15f9e3 --- /dev/null +++ b/src/tui/components/CommandPalette.tsx @@ -0,0 +1,47 @@ +import { Box, Text } from "ink"; +import type { Action } from "../../types"; +import { colors } from "../theme"; + +interface CommandPaletteProps { + actions: Action[]; + selectedIndex: number; +} + +export function CommandPalette({ + actions, + selectedIndex, +}: CommandPaletteProps) { + return ( + + + {" "} + Actions{" "} + + ──────────── + {actions.map((action, i) => { + const isSelected = i === selectedIndex; + return ( + + + {isSelected ? "❯ " : " "} + + + {action.label} + + {action.shortcut && ( + {action.shortcut} + )} + + ); + })} + + ); +} diff --git a/src/tui/components/Header.tsx b/src/tui/components/Header.tsx new file mode 100644 index 0000000..f766404 --- /dev/null +++ b/src/tui/components/Header.tsx @@ -0,0 +1,41 @@ +import { Box, Text } from "ink"; +import type { Model } from "../../types"; +import { colors, FRISE_CHARS, friseColor, symbols } from "../theme"; + +interface HeaderProps { + model: Model | null; +} + +export function Header({ model }: HeaderProps) { + return ( + + + + 🏄 paikea + + + + {symbols.gear} {model?.name ?? "none"} + + + + enter send · tab model · ↑ tools · : palette · esc quit + + + + + ); +} + +function Frise() { + const chars = FRISE_CHARS.map((char, idx) => { + const color = friseColor(idx); + return ( + + {char} + + ); + }); + + return {chars}; +} diff --git a/src/tui/components/PromptInput.tsx b/src/tui/components/PromptInput.tsx new file mode 100644 index 0000000..49bb659 --- /dev/null +++ b/src/tui/components/PromptInput.tsx @@ -0,0 +1,58 @@ +import { Box, Text } from "ink"; +import { colors } from "../theme"; + +interface PromptInputProps { + prompt: string; + cursorPos: number; + focused: boolean; + suggestions: string[]; + suggestionIndex: number; +} + +export function PromptInput({ + prompt, + cursorPos: _cursorPos, + focused, + suggestions, + suggestionIndex, +}: PromptInputProps) { + const lineColor = focused ? colors.fg.accent : colors.fg.dim; + + return ( + + + + ❯{" "} + + {prompt ? ( + {prompt} + ) : ( + type your prompt... + )} + {focused && █} + + + {(() => { + const suggestion = + suggestionIndex >= 0 + ? suggestions[suggestionIndex] + : suggestions[0]; + if (suggestion && focused) { + const remaining = suggestion.slice(prompt.length); + return ( + <> + {remaining && ( + + {remaining} + + )} + tab → + + ); + } + return Enter to send; + })()} + + + ); +} diff --git a/src/tui/components/ResultPane.tsx b/src/tui/components/ResultPane.tsx new file mode 100644 index 0000000..84935b4 --- /dev/null +++ b/src/tui/components/ResultPane.tsx @@ -0,0 +1,82 @@ +import { Box, Text } from "ink"; +import { colors, symbols } from "../theme"; + +interface ResultPaneProps { + content: string; + scrollOffset: number; +} + +export function ResultPane({ content, scrollOffset }: ResultPaneProps) { + return ( + + + ── response ── + + {content ? ( + + ) : ( + + { + symbols.spinner[ + Math.floor(Date.now() / 100) % symbols.spinner.length + ] + }{" "} + awaiting response... + + )} + + ); +} + +function ResultContent({ + content, + scrollOffset, +}: { + content: string; + scrollOffset: number; +}) { + const lines = content.split("\n"); + const visibleLines = lines.slice(scrollOffset, scrollOffset + 50); + + return ( + + {visibleLines.map((line) => { + let displayLine = line; + let color = colors.fg.primary; + let bold = false; + + if (line.startsWith("# ")) { + displayLine = line.slice(2); + color = colors.fg.accent; + bold = true; + } else if (line.startsWith("## ")) { + displayLine = line.slice(3); + color = colors.fg.accent; + } else if (line.startsWith("### ")) { + displayLine = line.slice(4); + color = colors.fg.warning; + } else if (line.startsWith("**") && line.endsWith("**")) { + displayLine = line.slice(2, -2); + bold = true; + } else if (line.startsWith("```")) { + color = colors.fg.dim; + } else if (line.startsWith("- ")) { + displayLine = ` ${symbols.dot} ${line.slice(2)}`; + color = colors.fg.secondary; + } else if (/^\d+\.\s/.test(line)) { + color = colors.fg.secondary; + } + + return ( + + {displayLine} + + ); + })} + + ); +} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx new file mode 100644 index 0000000..cce27c1 --- /dev/null +++ b/src/tui/components/StatusBar.tsx @@ -0,0 +1,67 @@ +import { Box, Text } from "ink"; +import type { Model } from "../../types"; +import { colors, symbols } from "../theme"; + +interface StatusBarProps { + model: Model | null; + tokens: number; + streaming: boolean; + toolbarIndex: number; + isToolbarMode: boolean; +} + +const TOOLBAR_BUTTONS = [ + { label: "⚙ Model", id: "model" }, + { label: "💭 Thinking", id: "thinking" }, + { label: "🗑 Clear", id: "clear" }, + { label: "📋 Palette", id: "palette" }, +]; + +export function StatusBar({ + model: _model, + tokens, + streaming, + toolbarIndex, + isToolbarMode, +}: StatusBarProps) { + return ( + + tokens {tokens} + + {!streaming ? ( + TOOLBAR_BUTTONS.map((btn, i) => { + const isSelected = isToolbarMode && i === toolbarIndex; + return ( + + + {" "} + {btn.label}{" "} + + + ); + }) + ) : ( + + {" "} + { + symbols.spinner[ + Math.floor(Date.now() / 100) % symbols.spinner.length + ] + }{" "} + streaming{" "} + + )} + + {streaming ? ( + + ) : isToolbarMode ? ( + Tab/Enter + ) : ( + : palette + )} + + ); +} diff --git a/src/tui/components/ThinkingPane.tsx b/src/tui/components/ThinkingPane.tsx new file mode 100644 index 0000000..8a7317d --- /dev/null +++ b/src/tui/components/ThinkingPane.tsx @@ -0,0 +1,35 @@ +import { Box, Text } from "ink"; +import { colors, symbols } from "../theme"; + +interface ThinkingPaneProps { + content: string; + visible: boolean; +} + +export function ThinkingPane({ content, visible }: ThinkingPaneProps) { + if (!visible) return null; + + return ( + + + ── thinking ── + + {content ? ( + {content} + ) : ( + + { + symbols.spinner[ + Math.floor(Date.now() / 100) % symbols.spinner.length + ] + }{" "} + thinking... + + )} + + ); +} diff --git a/src/tui/components/Timeline.tsx b/src/tui/components/Timeline.tsx new file mode 100644 index 0000000..e31039d --- /dev/null +++ b/src/tui/components/Timeline.tsx @@ -0,0 +1,58 @@ +import { Box, Text } from "ink"; +import type { Step } from "../../types"; +import { colors, symbols } from "../theme"; + +interface TimelineProps { + steps: Step[]; +} + +export function Timeline({ steps }: TimelineProps) { + if (steps.length === 0) return null; + + return ( + + {steps.map((step, i) => { + const isLast = i === steps.length - 1; + return ( + + + {step.status === "done" || step.status === "current" + ? symbols.bullet + : symbols.empty} + + + + {step.name} + + {!isLast && ( + + {" "} + {symbols.divider} + {symbols.divider} + {symbols.divider}{" "} + + )} + + ); + })} + + ); +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx new file mode 100644 index 0000000..b9c6567 --- /dev/null +++ b/src/tui/index.tsx @@ -0,0 +1,7 @@ +import { render } from "ink"; +import { App } from "./App"; + +export function startTUI() { + const { waitUntilExit } = render(); + return waitUntilExit(); +} diff --git a/src/tui/theme.tsx b/src/tui/theme.tsx new file mode 100644 index 0000000..ed70f32 --- /dev/null +++ b/src/tui/theme.tsx @@ -0,0 +1,54 @@ +export const colors = { + fg: { + primary: "#e0e0e0", + secondary: "#8a8a8a", + dim: "#555555", + accent: "#45b7d1", + success: "#6bcb77", + warning: "#ffc93c", + error: "#ff6b6b", + thinking: "#a29bfe", + muted: "#636e72", + }, + bg: { + header: "#0d1117", + panel: "#161b22", + panelAlt: "#1c2333", + status: "#0d1117", + input: "#161b22", + thinking: "#1a1530", + }, +}; + +export const symbols = { + bullet: "●", + empty: "○", + divider: "·", + dot: "•", + arrow: "→", + spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + check: "✓", + cross: "✗", + gear: "⚙", +}; + +const FRISE_PALETTE = [ + "#ff6b6b", + "#ff8e53", + "#ffc93c", + "#6bcb77", + "#4ecdc4", + "#45b7d1", + "#6c5ce7", + "#a29bfe", + "#fd79a8", + "#e17055", +]; + +export function friseColor(index: number): string { + return ( + FRISE_PALETTE[index % FRISE_PALETTE.length] ?? FRISE_PALETTE[0] ?? "#ffffff" + ); +} + +export const FRISE_CHARS = ["░", "▒", "▓", "█", "▓", "▒", "░"];