├── .prettierignore ├── .gitignore ├── src ├── cli.tsx ├── components │ └── Fullscreen.tsx └── App.tsx ├── README ├── .vscode └── launch.json ├── tsconfig.json └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /src/cli.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import React from 'react' 3 | 4 | import { App } from './App.js' 5 | import { withFullscreen } from './components/Fullscreen.js' 6 | 7 | await withFullscreen() 8 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | # termite 2 | 3 | > This readme is automatically generated by [create-ink-app](https://github.com/vadimdemedes/create-ink-app) 4 | 5 | ## Install 6 | 7 | ```bash 8 | $ npm install --global termite 9 | ``` 10 | 11 | ## CLI 12 | 13 | ``` 14 | $ termite --help 15 | 16 | Usage 17 | $ termite 18 | 19 | Options 20 | --name Your name 21 | 22 | Examples 23 | $ termite --name=Jane 24 | Hello, Jane 25 | ``` 26 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/dist/cli.js", 13 | "preLaunchTask": "tsc: build - tsconfig.json", 14 | "outFiles": ["${workspaceFolder}/dist/**/*.js"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | 5 | "module": "node16", 6 | "moduleResolution": "node16", 7 | "moduleDetection": "force", 8 | 9 | "target": "ES2020", 10 | "lib": ["DOM", "DOM.Iterable", "ES2020"], 11 | "allowSyntheticDefaultImports": true, 12 | "resolveJsonModule": false, 13 | "jsx": "react", 14 | "declaration": true, 15 | "pretty": true, 16 | 17 | "stripInternal": true, 18 | "strict": true, 19 | "noImplicitReturns": true, 20 | "noImplicitOverride": true, 21 | 22 | "noEmitOnError": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "skipLibCheck": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "termite", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "bin": "dist/cli.js", 6 | "type": "module", 7 | "engines": { 8 | "node": ">=16" 9 | }, 10 | "scripts": { 11 | "build": "tsc", 12 | "dev": "tsc --watch" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "dependencies": { 18 | "ink": "^4.1.0", 19 | "ink-text-input": "^5.0.1", 20 | "meow": "^11.0.0", 21 | "react": "^18.2.0" 22 | }, 23 | "devDependencies": { 24 | "@types/react": "^18.0.32", 25 | "prettier": "^2.8.7", 26 | "ts-node": "^10.9.1", 27 | "typescript": "^5.0.3" 28 | }, 29 | "prettier": { 30 | "semi": false, 31 | "singleQuote": true, 32 | "printWidth": 120 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Fullscreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useState, useEffect } from 'react' 2 | import { Box, useStdout, render } from 'ink' 3 | 4 | type FullscreenProps = { 5 | children: ReactNode 6 | } 7 | 8 | export function useDimentions() { 9 | const { stdout } = useStdout() 10 | const [dimensions, setDimensions] = useState([stdout.columns, stdout.rows]) 11 | 12 | useEffect(() => { 13 | const handler = () => setDimensions([stdout.columns, stdout.rows]) 14 | 15 | stdout.on('resize', handler) 16 | 17 | return () => { 18 | stdout.off('resize', handler) 19 | } 20 | }, [stdout]) 21 | 22 | return dimensions 23 | } 24 | 25 | export function Fullscreen({ children }: FullscreenProps) { 26 | const [columns, rows] = useDimentions() 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | ) 33 | } 34 | 35 | async function write(content: string) { 36 | return new Promise((resolve, reject) => { 37 | process.stdout.write(content, (error) => { 38 | if (error) reject(error) 39 | else resolve() 40 | }) 41 | }) 42 | } 43 | 44 | export async function withFullscreen(app: ReactNode) { 45 | await write('\x1b[?1049h') 46 | await render({app}).waitUntilExit() 47 | await write('\x1b[?1049l') 48 | } 49 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' 2 | import { Box, Spacer, Text, useFocusManager, useInput } from 'ink' 3 | import TextInput from 'ink-text-input' 4 | 5 | import { writeFile, readFile } from 'fs/promises' 6 | import { createWriteStream } from 'fs' 7 | import { pipeline } from 'stream/promises' 8 | import { Readable } from 'stream' 9 | 10 | import { useDimentions } from './components/Fullscreen.js' 11 | 12 | enum FocusTarget { 13 | commandBuffer = 'commandBuffer', 14 | screenBuffer = 'screenBuffer', 15 | } 16 | 17 | enum EditMode { 18 | replace, 19 | append, 20 | writing, 21 | } 22 | 23 | async function handleCommand(input: string, screenBuffer: string[]): Promise { 24 | const [cmd, ...params] = input.trim().slice(1).split(' ') 25 | 26 | switch (cmd) { 27 | case 'save': { 28 | const [file] = params 29 | 30 | await writeFile(`./${file}`, screenBuffer.join('\n')) 31 | 32 | return screenBuffer 33 | } 34 | case 'load': { 35 | const [file] = params 36 | 37 | const content = await readFile(`./${file}`, 'utf-8') 38 | 39 | return content.split('\n') 40 | } 41 | case 'write': { 42 | const handle = params.join(' ') 43 | 44 | await pipeline(Readable.from([screenBuffer.join('\n')], { encoding: 'utf-8' }), createWriteStream(handle)) 45 | 46 | return screenBuffer 47 | } 48 | default: 49 | return false 50 | } 51 | } 52 | 53 | export function App() { 54 | const [columns, rows] = useDimentions() 55 | 56 | const [focus, setFocus] = useState(FocusTarget.commandBuffer) 57 | 58 | const [commandBuffer, setCommandBuffer] = useState('') 59 | const [screenBuffer, setScreenBuffer] = useState([]) 60 | 61 | const [index, setIndex] = useState(screenBuffer.length - 1) 62 | const [bounds, setBounds] = useState<[number, number]>([0, Math.min(rows - 3, Math.max(0, screenBuffer.length - 1))]) 63 | 64 | const [editMode, setEditMode] = useState(EditMode.writing) 65 | 66 | const handleCommandSubmit = useCallback( 67 | async (value: string) => { 68 | if (value.trim().startsWith('/')) { 69 | // this is a non outputing command 70 | const result = await handleCommand(value, screenBuffer) 71 | 72 | if (result !== false) { 73 | setScreenBuffer(result) 74 | setCommandBuffer('') 75 | } 76 | 77 | return 78 | } 79 | 80 | setCommandBuffer('') 81 | 82 | if (editMode === EditMode.replace) { 83 | setScreenBuffer((buffer) => [...buffer.slice(0, index), value, ...buffer.slice(index + 1)]) 84 | setIndex(index) 85 | setEditMode(EditMode.append) 86 | setFocus(FocusTarget.screenBuffer) 87 | } else if (editMode === EditMode.append) { 88 | setScreenBuffer((buffer) => [...buffer.slice(0, index + 1), value, ...buffer.slice(index + 1)]) 89 | setIndex(index + 1) 90 | setEditMode(EditMode.append) 91 | setFocus(FocusTarget.screenBuffer) 92 | } else if (editMode === EditMode.writing) { 93 | setScreenBuffer((buffer) => [...buffer.slice(0, index + 1), value, ...buffer.slice(index + 1)]) 94 | setIndex(index + 1) 95 | } 96 | }, 97 | [screenBuffer, index, editMode] 98 | ) 99 | 100 | useLayoutEffect(() => { 101 | if (index < 0) { 102 | setIndex(0) 103 | } 104 | 105 | if (screenBuffer.length >= 1 && index >= screenBuffer.length) { 106 | setIndex(screenBuffer.length - 1) 107 | } 108 | }, [index, screenBuffer]) 109 | 110 | useLayoutEffect(() => { 111 | let maxSize = rows - 3 112 | const [prevMin, prevMax] = bounds 113 | let [currMin, currMax] = bounds 114 | 115 | const currSize = currMax - currMin 116 | 117 | if (index < currMin) { 118 | currMin = index 119 | currMax = Math.min(screenBuffer.length - 1, currMin + Math.min(maxSize, screenBuffer.length)) 120 | } else if (index > currMax) { 121 | currMax = index 122 | currMin = Math.max(0, currMax - Math.min(maxSize, screenBuffer.length)) 123 | } else if (currSize < Math.min(maxSize, screenBuffer.length)) { 124 | currMax += 2 125 | } 126 | 127 | if (currMin !== prevMin || currMax !== prevMax) { 128 | setBounds([currMin, currMax]) 129 | } 130 | }, [index, screenBuffer, bounds, rows]) 131 | 132 | const screenOutput = useMemo(() => { 133 | return screenBuffer.slice(bounds[0], bounds[1] + 1).map((content, i) => { 134 | return { lineNumber: i + bounds[0], active: bounds[0] + i === index, content: content } 135 | }) 136 | }, [bounds, index, screenBuffer]) 137 | 138 | useInput((input, key) => { 139 | if (key.escape) { 140 | setFocus(FocusTarget.screenBuffer) 141 | } 142 | 143 | if (focus === FocusTarget.commandBuffer) { 144 | if (key.upArrow) { 145 | setIndex(screenBuffer.length - 1) 146 | setFocus(FocusTarget.screenBuffer) 147 | } 148 | 149 | if (key.downArrow) { 150 | setIndex(0) 151 | setFocus(FocusTarget.screenBuffer) 152 | } 153 | } 154 | 155 | if (focus === FocusTarget.screenBuffer) { 156 | if (key.upArrow) { 157 | setIndex((index) => Math.min(Math.max(0, index - 1), Math.max(0, screenBuffer.length - 1))) 158 | } 159 | 160 | if (key.downArrow) { 161 | setIndex((index) => Math.min(Math.max(0, index + 1), Math.max(0, screenBuffer.length - 1))) 162 | } 163 | 164 | if (key.return) { 165 | setEditMode(EditMode.writing) 166 | setFocus(FocusTarget.commandBuffer) 167 | } 168 | 169 | if (input === 'r') { 170 | setEditMode(EditMode.replace) 171 | setFocus(FocusTarget.commandBuffer) 172 | } 173 | 174 | if (input === 'a') { 175 | setEditMode(EditMode.append) 176 | setFocus(FocusTarget.commandBuffer) 177 | } 178 | 179 | if (input === 'x') { 180 | setScreenBuffer((buffer) => [...buffer.slice(0, index), ...buffer.slice(index + 1)]) 181 | } 182 | 183 | if (input === 'n') { 184 | setScreenBuffer((buffer) => [...buffer.slice(0, index + 1), '', ...buffer.slice(index + 1)]) 185 | } 186 | } 187 | }) 188 | 189 | return ( 190 | 191 | 200 | 201 | {screenOutput.map((output) => ( 202 | 206 | {String(output.lineNumber + 1).padStart(6, ' ') + ' '} 207 | 208 | ))} 209 | 210 | 219 | 220 | {(commandBuffer.trim().startsWith('/') 221 | ? 'CMD' 222 | : editMode === EditMode.replace 223 | ? `${index + 1}` 224 | : `` 225 | ).padStart(6, ' ') + ' '} 226 | 227 | 228 | 229 | 230 | 231 | 232 | {screenOutput.map((output) => ( 233 | {output.content.length > 0 ? output.content : ' '} 234 | ))} 235 | 236 | 237 | 238 | 244 | 245 | 246 | 247 | ) 248 | } 249 | 250 | /* 251 | 252 | 253 | 254 | 255 | {screenBuffer} 256 | 257 | 258 | 259 | 260 | 261 | COMMAND 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | */ 270 | --------------------------------------------------------------------------------