@callakrsos/my-ollama-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+
4
+ const BASE_DIR = process.cwd();
5
+ export const SKILLS_PATH = path.join(BASE_DIR, 'skills.json');
6
+
7
+ export const DEFAULT_SKILLS = {
8
+ "commit": {
9
+ "description": "Git 커밋 메시지를 자동으로 작성합니다",
10
+ "prompt": "execute_shell_command 도구로 'git diff --cached'와 'git status'를 실행하고, 변경 사항을 분석하여 Conventional Commits 형식(feat/fix/docs/refactor/chore 등)으로 한국어 커밋 메시지를 작성해주세요."
11
+ },
12
+ "review": {
13
+ "description": "코드 리뷰를 수행합니다",
14
+ "prompt": "주어진 파일 또는 최근 변경 사항을 분석하여 코드 리뷰를 수행해주세요. 버그 가능성, 성능 이슈, 보안 문제, 코드 품질, 개선 제안을 항목별로 정리해주세요."
15
+ },
16
+ "explain": {
17
+ "description": "코드나 파일을 쉽게 설명합니다",
18
+ "prompt": "주어진 코드 또는 파일을 읽고, 전체 흐름과 핵심 로직을 개발자가 이해하기 쉽게 단계별로 설명해주세요."
19
+ },
20
+ "test": {
21
+ "description": "테스트 코드를 작성합니다",
22
+ "prompt": "주어진 코드를 분석하고, 핵심 기능에 대한 단위 테스트 코드를 작성해주세요. 경계 조건과 예외 케이스도 포함해주세요."
23
+ },
24
+ "refactor": {
25
+ "description": "코드를 리팩토링합니다",
26
+ "prompt": "주어진 코드를 더 읽기 쉽고 유지보수하기 좋게 리팩토링해주세요. 중복 제거, 명확한 변수명, 함수 분리 등을 고려해주세요."
27
+ },
28
+ "doc": {
29
+ "description": "문서(JSDoc/docstring/README)를 작성합니다",
30
+ "prompt": "주어진 코드를 분석하고, 함수/클래스에 JSDoc 또는 docstring 주석을 추가하고 필요하면 README 섹션도 작성해주세요."
31
+ },
32
+ "analyze": {
33
+ "description": "데이터나 로그를 Python으로 분석합니다",
34
+ "prompt": "주어진 데이터 또는 파일을 execute_python_code 도구를 사용해 Python으로 분석하고, 결과를 요약해주세요. 필요시 통계나 시각화 코드도 작성해주세요."
35
+ },
36
+ "security": {
37
+ "description": "보안 취약점을 검토합니다",
38
+ "prompt": "주어진 코드를 OWASP Top 10 기준으로 보안 취약점을 검토하고, 발견된 문제와 개선 방법을 구체적으로 알려주세요."
39
+ },
40
+ };
41
+
42
+ export async function loadSkills() {
43
+ try {
44
+ const raw = await fs.readFile(SKILLS_PATH, 'utf-8');
45
+ const custom = JSON.parse(raw);
46
+ return { ...DEFAULT_SKILLS, ...custom };
47
+ } catch (e) {
48
+ return DEFAULT_SKILLS;
49
+ }
50
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "_comment": "이 파일을 skills.json으로 복사하면 커스텀 스킬을 추가하거나 기본 스킬을 덮어쓸 수 있습니다.",
3
+ "my-skill": {
4
+ "description": "나만의 커스텀 스킬 예시",
5
+ "prompt": "여기에 AI에게 전달할 프롬프트를 작성하세요."
6
+ },
7
+ "standup": {
8
+ "description": "오늘 작업 요약(스탠드업 미팅용)을 작성합니다",
9
+ "prompt": "execute_shell_command 도구로 'git log --since=yesterday --oneline --author=$(git config user.email)' 를 실행하고, 어제부터 오늘 사이의 커밋 내역을 바탕으로 스탠드업 미팅용 업무 보고를 3줄로 요약해주세요."
10
+ },
11
+ "deps": {
12
+ "description": "package.json 의존성 취약점 및 업데이트 분석",
13
+ "prompt": "execute_shell_command 도구로 'npm outdated' 와 'npm audit --json' 을 실행하고 결과를 분석하여 업데이트가 필요한 패키지와 보안 취약점을 정리해주세요."
14
+ }
15
+ }
@@ -0,0 +1,65 @@
1
+ import { DynamicStructuredTool } from "@langchain/core/tools";
2
+ import { z } from "zod";
3
+ import fs from "fs/promises";
4
+ import { readFileWithBOM } from "../FilesUtils.js";
5
+ import { getSafePath, truncateToolContent } from "./utils.js";
6
+
7
+ export const readFileTool = new DynamicStructuredTool({
8
+ name: "read_file",
9
+ description: "파일의 내용을 읽어옵니다. 코드를 분석하거나 내용을 확인할 때 사용하세요.",
10
+ schema: z.object({
11
+ filePath: z.string().describe("읽을 파일의 경로 (예: ./src/index.js)"),
12
+ }),
13
+ func: async ({ filePath }) => {
14
+ console.log(`read_file tool 호출 ${filePath}`);
15
+ try {
16
+ const safePath = getSafePath(filePath);
17
+ const content = await readFileWithBOM(safePath);
18
+ const full = `[파일 내용 - ${filePath}]:\n${content}`;
19
+ return truncateToolContent(full, '파일');
20
+ } catch (error) {
21
+ return `파일 읽기 실패: ${error.message}`;
22
+ }
23
+ },
24
+ });
25
+
26
+ export const writeFileTool = new DynamicStructuredTool({
27
+ name: "write_file",
28
+ description: "파일을 생성하거나 내용을 덮어씁니다. 코드를 작성하거나 수정할 때 사용하세요.",
29
+ schema: z.object({
30
+ filePath: z.string().describe("저장할 파일 경로"),
31
+ content: z.string().describe("저장할 파일의 전체 내용"),
32
+ }),
33
+ func: async ({ filePath, content }) => {
34
+ console.log(`write_file tool 호출 ${filePath}`);
35
+ try {
36
+ const safePath = getSafePath(filePath);
37
+ await fs.writeFile(safePath, content, "utf-8");
38
+ return `성공: 파일이 저장되었습니다. (${filePath})`;
39
+ } catch (error) {
40
+ return `파일 쓰기 실패: ${error.message}`;
41
+ }
42
+ },
43
+ });
44
+
45
+ export const readPdfTool = new DynamicStructuredTool({
46
+ name: "read_pdf",
47
+ description: "PDF 파일의 텍스트 내용을 읽어옵니다. 문서 내용을 분석하거나 정보를 추출할 때 사용하세요.",
48
+ schema: z.object({
49
+ filePath: z.string().describe("읽을 PDF 파일의 경로 (예: ./docs/manual.pdf)"),
50
+ }),
51
+ func: async ({ filePath }) => {
52
+ try {
53
+ console.log("read_pdf tool 호출");
54
+ const safePath = getSafePath(filePath);
55
+ const dataBuffer = await fs.readFile(safePath);
56
+ // pdf-parse 패키지 필요: npm install pdf-parse
57
+ const pdfParse = (await import('pdf-parse')).default;
58
+ const result = await pdfParse(dataBuffer);
59
+ const full = `[PDF 내용 - ${filePath}]:\n${result.text}`;
60
+ return truncateToolContent(full, 'PDF');
61
+ } catch (error) {
62
+ return `PDF 읽기 실패: ${error.message}`;
63
+ }
64
+ },
65
+ });
package/tools/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export { readFileTool, writeFileTool, readPdfTool } from './fileTools.js';
2
+ export { executeShellTool, executePythonTool } from './shellTools.js';
3
+
4
+ import { readFileTool, writeFileTool, readPdfTool } from './fileTools.js';
5
+ import { executeShellTool, executePythonTool } from './shellTools.js';
6
+
7
+ export const tools = [
8
+ readFileTool,
9
+ writeFileTool,
10
+ executeShellTool,
11
+ executePythonTool,
12
+ readPdfTool,
13
+ ];
@@ -0,0 +1,83 @@
1
+ import { DynamicStructuredTool } from "@langchain/core/tools";
2
+ import { z } from "zod";
3
+ import { spawn } from "child_process";
4
+ import path from "path";
5
+ import os from "os";
6
+ import fs from "fs/promises";
7
+ import chalk from "chalk";
8
+
9
+ export const executeShellTool = new DynamicStructuredTool({
10
+ name: "execute_shell_command",
11
+ description: "터미널(셸) 명령어를 실행하고 결과를 반환합니다. ipconfig, ls, pwd, date 같은 시스템 확인용 명령에 사용하세요.",
12
+ schema: z.object({
13
+ command: z.string().describe("실행할 셸 명령어 (예: ipconfig)"),
14
+ }),
15
+ func: async ({ command }) => {
16
+ const blocklist = ["rm", "del", "sudo", "su", "shutdown", "reboot"];
17
+ const commandBase = command.split(" ")[0];
18
+ if (blocklist.includes(commandBase)) {
19
+ return `에러: 보안상의 이유로 '${commandBase}' 명령어는 실행할 수 없습니다.`;
20
+ }
21
+ console.log(chalk.gray(`[툴 실행] 셸 명령어 실행: ${command}`));
22
+ return new Promise((resolve) => {
23
+ const child = spawn(command, { shell: 'powershell.exe' });
24
+ let stdout = '';
25
+ let stderr = '';
26
+ child.stdout.on('data', (data) => { stdout += data.toString('utf-8'); });
27
+ child.stderr.on('data', (data) => { stderr += data.toString('utf-8'); });
28
+ child.on('close', (code) => {
29
+ let output = `종료 코드: ${code}\n`;
30
+ if (stdout.trim()) output += `STDOUT:\n${stdout.trim()}\n`;
31
+ if (stderr.trim()) output += `STDERR:\n${stderr.trim()}\n`;
32
+ if (code === 0) {
33
+ resolve(`명령어 실행 성공:\n${output}`);
34
+ } else {
35
+ resolve(`명령어 실행 중 에러 발생:\n${output}`);
36
+ }
37
+ });
38
+ child.on('error', (err) => resolve(`명령어 실행 실패: ${err.message}`));
39
+ });
40
+ },
41
+ });
42
+
43
+ export const executePythonTool = new DynamicStructuredTool({
44
+ name: "execute_python_code",
45
+ description: "Python 코드를 작성하고 실행합니다. 데이터 분석, 수치 계산, 파일 처리, 자동화 스크립트 작성 등에 사용하세요.",
46
+ schema: z.object({
47
+ code: z.string().describe("실행할 Python 코드 (전체 내용)"),
48
+ description: z.string().optional().describe("코드 설명 (선택 사항)"),
49
+ }),
50
+ func: async ({ code, description }) => {
51
+ const tmpFile = path.join(os.tmpdir(), `kyj_cli_py_${Date.now()}.py`);
52
+ try {
53
+ if (description) console.log(chalk.gray(`[툴 실행] Python 코드 실행: ${description}`));
54
+ else console.log(chalk.gray(`[툴 실행] Python 코드 실행 중...`));
55
+ await fs.writeFile(tmpFile, code, 'utf-8');
56
+ return new Promise((resolve) => {
57
+ const child = spawn('python', [tmpFile], { stdio: 'pipe' });
58
+ let stdout = '';
59
+ let stderr = '';
60
+ child.stdout.on('data', (data) => { stdout += data.toString('utf-8'); });
61
+ child.stderr.on('data', (data) => { stderr += data.toString('utf-8'); });
62
+ child.on('close', async (exitCode) => {
63
+ try { await fs.unlink(tmpFile); } catch (_) {}
64
+ let output = '';
65
+ if (stdout.trim()) output += `STDOUT:\n${stdout.trim()}\n`;
66
+ if (stderr.trim()) output += `STDERR:\n${stderr.trim()}\n`;
67
+ if (exitCode === 0) {
68
+ resolve(`Python 코드 실행 성공 (종료 코드: ${exitCode}):\n${output || '(출력 없음)'}`);
69
+ } else {
70
+ resolve(`Python 코드 실행 실패 (종료 코드: ${exitCode}):\n${output}`);
71
+ }
72
+ });
73
+ child.on('error', async (err) => {
74
+ try { await fs.unlink(tmpFile); } catch (_) {}
75
+ resolve(`Python 실행 실패: ${err.message}\n(Python이 설치되어 있는지 확인하세요. 명령어: python --version)`);
76
+ });
77
+ });
78
+ } catch (error) {
79
+ try { await fs.unlink(tmpFile); } catch (_) {}
80
+ return `Python 코드 실행 실패: ${error.message}`;
81
+ }
82
+ },
83
+ });
package/tools/utils.js ADDED
@@ -0,0 +1,19 @@
1
+ import path from 'path';
2
+
3
+ export const BASE_DIR = process.cwd();
4
+
5
+ export const TOOL_CONTENT_MAX_CHARS = 1200000;
6
+
7
+ export function getSafePath(targetPath) {
8
+ const resolvedPath = path.resolve(BASE_DIR, targetPath);
9
+ if (!resolvedPath.startsWith(BASE_DIR)) {
10
+ throw new Error("보안 경고: 현재 작업 디렉터리를 벗어난 파일에는 접근할 수 없습니다.");
11
+ }
12
+ return resolvedPath;
13
+ }
14
+
15
+ export function truncateToolContent(text, label = '파일') {
16
+ if (typeof text !== 'string' || text.length <= TOOL_CONTENT_MAX_CHARS) return text;
17
+ return text.slice(0, TOOL_CONTENT_MAX_CHARS) +
18
+ `\n\n... (${label} 내용이 ${text.length}자라 ${TOOL_CONTENT_MAX_CHARS}자까지만 전달했습니다. 필요한 부분을 지정해 다시 요청하세요.)`;
19
+ }
@@ -0,0 +1,44 @@
1
+ import { glob } from 'glob';
2
+ import { checkbox } from '@inquirer/prompts';
3
+ import search from "../search/search/dist/index.js";
4
+ import chalk from 'chalk';
5
+
6
+ const IGNORE = ['node_modules/**', '.git/**', '*.env', '**/node_modules/**', '**/.git/**', '.m2/**', '.idea/**'];
7
+
8
+ export async function selectFile(initialInput = '') {
9
+ const allFiles = await glob('**/*', { ignore: IGNORE, nodir: true });
10
+ const initialFiles = initialInput
11
+ ? allFiles.filter(f => f.toLowerCase().includes(initialInput.toLowerCase()))
12
+ : allFiles;
13
+
14
+ return await search({
15
+ message: '첨부할 파일을 선택하세요:',
16
+ source: async (input) => {
17
+ if (input === undefined) return initialFiles;
18
+ if (!input) return allFiles;
19
+ return allFiles.filter(f => f.toLowerCase().includes(input.toLowerCase()));
20
+ },
21
+ });
22
+ }
23
+
24
+ export async function selectMultipleFiles(initialInput = '') {
25
+ const allFiles = await glob('**/*', { ignore: IGNORE, nodir: true });
26
+ let filtered = initialInput
27
+ ? allFiles.filter(f => f.toLowerCase().includes(initialInput.toLowerCase()))
28
+ : allFiles;
29
+
30
+ if (filtered.length === 0) {
31
+ console.log(chalk.yellow('검색 결과가 없습니다.'));
32
+ return [];
33
+ }
34
+ if (filtered.length > 80) {
35
+ console.log(chalk.gray(` ${filtered.length}개 파일 중 80개만 표시됩니다. '@@검색어'로 범위를 좁히세요.`));
36
+ filtered = filtered.slice(0, 80);
37
+ }
38
+
39
+ return await checkbox({
40
+ message: `첨부할 파일을 선택하세요 ${chalk.gray('(Space: 선택/해제, Enter: 확인, a: 전체선택)')}:`,
41
+ choices: filtered.map(f => ({ name: f, value: f })),
42
+ pageSize: 15,
43
+ });
44
+ }
package/ui/prompt.js ADDED
@@ -0,0 +1,347 @@
1
+ import { glob } from 'glob';
2
+ import chalk from 'chalk';
3
+
4
+ const IGNORE = ['node_modules/**', '.git/**', '*.env', '**/node_modules/**', '**/.git/**', '.m2/**', '.idea/**'];
5
+
6
+ /** 터미널 스피너 (외부 라이브러리 없이 구현) */
7
+ export function createSpinner(text) {
8
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
9
+ let i = 0;
10
+ let currentText = text;
11
+ const interval = setInterval(() => {
12
+ process.stdout.write(`\r${chalk.cyan(frames[i++ % frames.length])} ${currentText} `);
13
+ }, 80);
14
+ return {
15
+ update: (newText) => { currentText = newText; },
16
+ stop: () => {
17
+ clearInterval(interval);
18
+ process.stdout.write('\r' + ' '.repeat(currentText.length + 5) + '\r');
19
+ },
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Claude / Gemini 스타일 멀티라인 입력 컴포넌트 (인라인 @ 파일 피커 포함)
25
+ *
26
+ * Enter : 줄바꿈 (피커 열려 있으면 파일 선택)
27
+ * Ctrl+S : 전송
28
+ * Ctrl+D : 전송
29
+ * ↑↓ : 히스토리 (피커 열려 있으면 피커 탐색)
30
+ * ← → : 커서 이동
31
+ * Backspace: 문자/줄 삭제
32
+ * Esc : 피커 닫기
33
+ * @ : 입력 커서 위치에서 단어가 @ 로 시작하면 파일 피커 팝업
34
+ * @@ : 피커 미적용 (기존 멀티파일 명령어)
35
+ */
36
+ export function cliPrompt(history = []) {
37
+ const P0 = ' ❯ ';
38
+ const PN = ' ';
39
+ const pre = (r) => r === 0 ? P0 : PN;
40
+ const PICKER_SIZE = 10;
41
+
42
+ return new Promise(async (resolve, reject) => {
43
+ let allPickerFiles = [];
44
+ try {
45
+ allPickerFiles = await glob('**/*', { ignore: IGNORE, nodir: true });
46
+ } catch (_) {}
47
+
48
+ let lines = [''];
49
+ let row = 0, col = 0;
50
+ let histIdx = -1, draft = '';
51
+ let screenLines = 1;
52
+
53
+ let pickerActive = false;
54
+ let pickerFiles = [];
55
+ let pickerIdx = 0;
56
+ let pickerOffset = 0;
57
+ let pickerScreenLines = 0;
58
+
59
+ const W = process.stdout.columns || 100;
60
+ process.stdout.write('\n' + chalk.gray('─'.repeat(W)) + '\n');
61
+ process.stdout.write(
62
+ chalk.green.bold(' You') +
63
+ chalk.gray(' (Enter: 줄바꿈 Ctrl+S: 전송 ↑↓: 히스토리 @파일명: 파일첨부)') + '\n'
64
+ );
65
+ process.stdout.write(P0);
66
+
67
+ function strWidth(str) {
68
+ let w = 0;
69
+ for (const ch of str) {
70
+ const code = ch.codePointAt(0);
71
+ if (code >= 0x1100 && (
72
+ code <= 0x115F ||
73
+ code === 0x2329 || code === 0x232A ||
74
+ (code >= 0x2E80 && code <= 0x303E) ||
75
+ (code >= 0x3040 && code <= 0x33BF) ||
76
+ (code >= 0xAC00 && code <= 0xD7AF) ||
77
+ (code >= 0xF900 && code <= 0xFAFF) ||
78
+ (code >= 0xFF01 && code <= 0xFF60) ||
79
+ (code >= 0xFFE0 && code <= 0xFFE6)
80
+ )) {
81
+ w += 2;
82
+ } else {
83
+ w += 1;
84
+ }
85
+ }
86
+ return w;
87
+ }
88
+ const colW = () => strWidth(lines[row].slice(0, col));
89
+
90
+ function getAtWord() {
91
+ const line = lines[row];
92
+ let start = col;
93
+ while (start > 0 && line[start - 1] !== ' ' && line[start - 1] !== '\n') start--;
94
+ const word = line.slice(start, col);
95
+ if (word.startsWith('@') && !word.startsWith('@@')) return { word, start };
96
+ return null;
97
+ }
98
+
99
+ function filterFiles(query) {
100
+ if (!query) return allPickerFiles.slice(0, 300);
101
+ const q = query.toLowerCase();
102
+ return allPickerFiles.filter(f => f.toLowerCase().includes(q)).slice(0, 300);
103
+ }
104
+
105
+ function syncPicker() {
106
+ const atWord = getAtWord();
107
+ if (atWord) {
108
+ pickerFiles = filterFiles(atWord.word.slice(1));
109
+ if (pickerIdx >= pickerFiles.length) pickerIdx = Math.max(0, pickerFiles.length - 1);
110
+ pickerActive = true;
111
+ } else {
112
+ pickerActive = false;
113
+ pickerFiles = [];
114
+ pickerIdx = 0;
115
+ pickerOffset = 0;
116
+ }
117
+ }
118
+
119
+ const fullRedraw = (prevInputLines = screenLines, prevPickerLines = pickerScreenLines) => {
120
+ const newInputLines = lines.length;
121
+ const newPickerLines = (pickerActive && pickerFiles.length > 0)
122
+ ? Math.min(PICKER_SIZE, pickerFiles.length) + 1
123
+ : 0;
124
+
125
+ if (row > 0) process.stdout.moveCursor(0, -row);
126
+ process.stdout.cursorTo(0);
127
+
128
+ const maxTotal = Math.max(newInputLines + newPickerLines, prevInputLines + prevPickerLines);
129
+
130
+ for (let i = 0; i < maxTotal; i++) {
131
+ process.stdout.clearLine(0);
132
+ if (i < newInputLines) {
133
+ process.stdout.write(pre(i) + lines[i]);
134
+ } else if (i < newInputLines + newPickerLines) {
135
+ const pi = i - newInputLines;
136
+ if (pi === 0) {
137
+ const total = pickerFiles.length;
138
+ const more = total > PICKER_SIZE ? chalk.gray(` +${total - PICKER_SIZE}개`) : '';
139
+ process.stdout.write(
140
+ chalk.bgGray.white(' 파일 ') +
141
+ chalk.gray(` ${total}개${more} `) +
142
+ chalk.gray('↑↓이동') + chalk.gray(' ') +
143
+ chalk.cyan('Enter') + chalk.gray(':선택 ') +
144
+ chalk.yellow('Esc') + chalk.gray(':닫기')
145
+ );
146
+ } else {
147
+ const fileIdx = pickerOffset + pi - 1;
148
+ const file = pickerFiles[fileIdx];
149
+ if (file !== undefined) {
150
+ if (fileIdx === pickerIdx) {
151
+ process.stdout.write(chalk.cyan(' ❯ ') + chalk.bold(file));
152
+ } else {
153
+ process.stdout.write(chalk.gray(' ') + file);
154
+ }
155
+ }
156
+ }
157
+ }
158
+ if (i < maxTotal - 1) process.stdout.write('\n');
159
+ }
160
+
161
+ const moveUp = (maxTotal - 1) - row;
162
+ if (moveUp > 0) process.stdout.moveCursor(0, -moveUp);
163
+ process.stdout.cursorTo(pre(row).length + colW());
164
+
165
+ screenLines = newInputLines;
166
+ pickerScreenLines = newPickerLines;
167
+ };
168
+
169
+ const end = () => {
170
+ process.stdin.setRawMode(false);
171
+ process.stdin.removeListener('data', onData);
172
+ };
173
+ const submit = () => {
174
+ if (pickerActive || pickerScreenLines > 0) {
175
+ pickerActive = false;
176
+ fullRedraw(screenLines, pickerScreenLines);
177
+ }
178
+ end();
179
+ process.stdout.write('\n');
180
+ resolve(lines.join('\n').trim());
181
+ };
182
+ const cancel = () => {
183
+ end();
184
+ process.stdout.write('\n');
185
+ reject({ name: 'ExitPromptError' });
186
+ };
187
+
188
+ const onData = (buf) => {
189
+ const s = buf.toString('utf8');
190
+
191
+ // Windows IME 조합 시퀀스: \b + 문자
192
+ if (s.length >= 2 && (s[0] === '\u0008' || s[0] === '\u007f')) {
193
+ const rest = s.slice(1);
194
+ if (rest.length >= 1 && rest.charCodeAt(0) >= 32 && !rest.startsWith('\u001b')) {
195
+ if (col > 0) {
196
+ lines[row] = lines[row].slice(0, col - 1) + rest + lines[row].slice(col);
197
+ const prevPicker = pickerScreenLines;
198
+ syncPicker();
199
+ fullRedraw(screenLines, prevPicker);
200
+ }
201
+ return;
202
+ }
203
+ }
204
+
205
+ if (s === '\u0003') { cancel(); return; }
206
+ if (s === '\u0013' || s === '\u0004') { submit(); return; }
207
+
208
+ if (s === '\u001b' || s === '\u001b\u001b') {
209
+ if (pickerActive) {
210
+ const prevPicker = pickerScreenLines;
211
+ pickerActive = false; pickerFiles = []; pickerIdx = 0; pickerOffset = 0;
212
+ fullRedraw(screenLines, prevPicker);
213
+ }
214
+ return;
215
+ }
216
+
217
+ if (s === '\r' || s === '\n') {
218
+ if (pickerActive && pickerFiles.length > 0) {
219
+ const selected = pickerFiles[pickerIdx];
220
+ const atWord = getAtWord();
221
+ if (atWord && selected) {
222
+ const replacement = '@' + selected + ' ';
223
+ lines[row] = lines[row].slice(0, atWord.start) + replacement + lines[row].slice(col);
224
+ col = atWord.start + replacement.length;
225
+ }
226
+ const prevPicker = pickerScreenLines;
227
+ pickerActive = false; pickerFiles = []; pickerIdx = 0; pickerOffset = 0;
228
+ fullRedraw(screenLines, prevPicker);
229
+ return;
230
+ }
231
+ if (lines.length === 1 && lines[0].trim() === '') return;
232
+ const after = lines[row].slice(col);
233
+ lines[row] = lines[row].slice(0, col);
234
+ lines.splice(row + 1, 0, after);
235
+ row++; col = 0;
236
+ const prevPicker = pickerScreenLines;
237
+ syncPicker();
238
+ fullRedraw(screenLines, prevPicker);
239
+ return;
240
+ }
241
+
242
+ if (s.startsWith('\u001b[')) {
243
+ switch (s) {
244
+ case '\u001b[A': // ↑
245
+ if (pickerActive) {
246
+ if (pickerIdx > 0) { pickerIdx--; if (pickerIdx < pickerOffset) pickerOffset = pickerIdx; fullRedraw(screenLines, pickerScreenLines); }
247
+ } else if (row > 0) {
248
+ process.stdout.moveCursor(0, -1); row--;
249
+ col = Math.min(col, lines[row].length);
250
+ process.stdout.cursorTo(pre(row).length + colW());
251
+ } else if (histIdx < history.length - 1) {
252
+ if (histIdx === -1) draft = lines.join('\n');
253
+ histIdx++;
254
+ lines = history[history.length - 1 - histIdx].split('\n');
255
+ row = 0; col = lines[0].length;
256
+ fullRedraw();
257
+ }
258
+ break;
259
+ case '\u001b[B': // ↓
260
+ if (pickerActive) {
261
+ if (pickerIdx < pickerFiles.length - 1) { pickerIdx++; if (pickerIdx >= pickerOffset + PICKER_SIZE) pickerOffset = pickerIdx - PICKER_SIZE + 1; fullRedraw(screenLines, pickerScreenLines); }
262
+ } else if (row < lines.length - 1) {
263
+ process.stdout.moveCursor(0, 1); row++;
264
+ col = Math.min(col, lines[row].length);
265
+ process.stdout.cursorTo(pre(row).length + colW());
266
+ } else if (histIdx === 0) {
267
+ histIdx = -1; const prev = screenLines;
268
+ lines = draft ? draft.split('\n') : ['']; row = 0; col = lines[0].length;
269
+ fullRedraw(prev);
270
+ } else if (histIdx > 0) {
271
+ histIdx--;
272
+ lines = history[history.length - 1 - histIdx].split('\n');
273
+ row = 0; col = lines[0].length; fullRedraw();
274
+ }
275
+ break;
276
+ case '\u001b[D': // ←
277
+ if (col > 0) { col--; process.stdout.cursorTo(pre(row).length + colW()); }
278
+ else if (row > 0) { row--; col = lines[row].length; process.stdout.moveCursor(0, -1); process.stdout.cursorTo(pre(row).length + colW()); }
279
+ break;
280
+ case '\u001b[C': // →
281
+ if (col < lines[row].length) { col++; process.stdout.cursorTo(pre(row).length + colW()); }
282
+ else if (row < lines.length - 1) { row++; col = 0; process.stdout.moveCursor(0, 1); process.stdout.cursorTo(pre(row).length); }
283
+ break;
284
+ case '\u001b[H': case '\u001b[1~': // Home
285
+ col = 0; process.stdout.cursorTo(pre(row).length); break;
286
+ case '\u001b[F': case '\u001b[4~': // End
287
+ col = lines[row].length; process.stdout.cursorTo(pre(row).length + strWidth(lines[row])); break;
288
+ case '\u001b[3~': { // Delete
289
+ const prevPicker = pickerScreenLines;
290
+ if (col < lines[row].length) {
291
+ lines[row] = lines[row].slice(0, col) + lines[row].slice(col + 1);
292
+ syncPicker(); fullRedraw(screenLines, prevPicker);
293
+ } else if (row < lines.length - 1) {
294
+ lines[row] += lines[row + 1]; lines.splice(row + 1, 1);
295
+ syncPicker(); fullRedraw(screenLines + 1, prevPicker);
296
+ }
297
+ break;
298
+ }
299
+ }
300
+ return;
301
+ }
302
+
303
+ if (s === '\u007f' || s === '\b') {
304
+ const prevPicker = pickerScreenLines;
305
+ if (col > 0) {
306
+ lines[row] = lines[row].slice(0, col - 1) + lines[row].slice(col);
307
+ col--; syncPicker(); fullRedraw(screenLines, prevPicker);
308
+ } else if (row > 0) {
309
+ const prevLen = lines[row - 1].length;
310
+ lines[row - 1] += lines[row]; lines.splice(row, 1);
311
+ row--; col = prevLen; syncPicker(); fullRedraw(screenLines + 1, prevPicker);
312
+ }
313
+ return;
314
+ }
315
+
316
+ if (s.length > 1) {
317
+ const text = s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
318
+ for (const ch of text) {
319
+ if (ch === '\n') {
320
+ const after = lines[row].slice(col);
321
+ lines[row] = lines[row].slice(0, col);
322
+ lines.splice(row + 1, 0, after);
323
+ row++; col = 0;
324
+ } else if (ch.charCodeAt(0) >= 32 || ch === '\t') {
325
+ lines[row] = lines[row].slice(0, col) + ch + lines[row].slice(col);
326
+ col++;
327
+ }
328
+ }
329
+ const prevPicker = pickerScreenLines;
330
+ syncPicker(); fullRedraw(screenLines, prevPicker);
331
+ return;
332
+ }
333
+
334
+ if (s.charCodeAt(0) >= 32) {
335
+ lines[row] = lines[row].slice(0, col) + s + lines[row].slice(col);
336
+ col++;
337
+ const prevPicker = pickerScreenLines;
338
+ syncPicker(); fullRedraw(screenLines, prevPicker);
339
+ }
340
+ };
341
+
342
+ process.stdin.setRawMode(true);
343
+ process.stdin.resume();
344
+ process.stdin.setEncoding('utf8');
345
+ process.stdin.on('data', onData);
346
+ });
347
+ }