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 = ["░", "▒", "▓", "█", "▓", "▒", "░"];