├── .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 |
--------------------------------------------------------------------------------