opencode-miniterm 1.0.11 → 1.0.12

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.
package/bun.lock CHANGED
@@ -5,7 +5,7 @@
5
5
  "": {
6
6
  "name": "opencode-miniterm",
7
7
  "dependencies": {
8
- "@opencode-ai/sdk": "^1.2.15",
8
+ "@opencode-ai/sdk": "^1.2.24",
9
9
  "allmark": "^1.0.2",
10
10
  },
11
11
  "devDependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-miniterm",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "A small front-end terminal UI for OpenCode",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -9,7 +9,6 @@
9
9
  "scripts": {
10
10
  "dev": "bun src/index.ts",
11
11
  "test": "bun test",
12
- "test:tmux": "bun test/tmux-readline.test.ts",
13
12
  "check": "tsgo --noEmit"
14
13
  },
15
14
  "keywords": [
package/src/ansi.ts CHANGED
@@ -5,7 +5,6 @@ export const CLEAR_SCREEN_UP = "\x1b[2A";
5
5
  export const CURSOR_HOME = "\x1b[0G";
6
6
  export const CURSOR_HIDE = "\x1b[?25l";
7
7
  export const CURSOR_SHOW = "\x1b[?25h";
8
- export const CURSOR_UP = (lines: number) => `\x1b[${lines}A`;
9
8
  export const DISABLE_LINE_WRAP = "\x1b[?7l";
10
9
  export const ENABLE_LINE_WRAP = "\x1b[?7h";
11
10
  export const RESET = "\x1b[0m";
package/src/index.ts CHANGED
@@ -182,34 +182,50 @@ let completionCycling = false;
182
182
  let lastSpaceTime = 0;
183
183
  let currentInputBuffer: string | null = null;
184
184
 
185
- let count = 0;
185
+ let oldInputBuffer = "";
186
186
  let oldWrappedRows = 0;
187
187
  let oldCursorRow = 0;
188
188
  function renderLine(): void {
189
189
  const consoleWidth = process.stdout.columns || 80;
190
190
 
191
+ // Move to the start of the line (i.e. the prompt position)
191
192
  readline.cursorTo(process.stdout, 0);
192
-
193
- // Ensure the cursor is at the end of the old input
194
- if (cursorPosition < inputBuffer.length) {
195
- readline.moveCursor(process.stdout, 0, oldWrappedRows - oldCursorRow);
196
- }
197
-
198
- // Clear the old input
199
193
  if (oldWrappedRows > 0) {
194
+ if (cursorPosition < inputBuffer.length) {
195
+ readline.moveCursor(process.stdout, 0, oldWrappedRows - oldCursorRow);
196
+ }
200
197
  readline.moveCursor(process.stdout, 0, -oldWrappedRows);
201
198
  }
199
+
200
+ // Find the position where the input has changed (i.e. where the user has
201
+ // typed something)
202
+ let start = 0;
203
+ let currentCol = 2;
204
+ let newWrappedRows = 0;
205
+ for (let i = 0; i < Math.min(oldInputBuffer.length, inputBuffer.length); i++) {
206
+ if (oldInputBuffer[i] !== inputBuffer[i]) {
207
+ break;
208
+ }
209
+ if (currentCol >= consoleWidth) {
210
+ readline.moveCursor(process.stdout, 0, 1);
211
+ currentCol = 0;
212
+ newWrappedRows++;
213
+ }
214
+ currentCol++;
215
+ start++;
216
+ }
217
+
218
+ // Clear the old, changed, input
219
+ readline.moveCursor(process.stdout, currentCol, 0);
202
220
  readline.clearScreenDown(process.stdout);
203
- readline.cursorTo(process.stdout, 2);
204
221
 
205
- // Write the prompt
206
- writePrompt();
222
+ if (start === 0) {
223
+ writePrompt();
224
+ }
207
225
 
208
- // Write the new input buffer
226
+ // Write the changes from the new input buffer
209
227
  let renderExtent = Math.max(cursorPosition + 1, inputBuffer.length);
210
- let currentCol = 2;
211
- let newWrappedRows = 0;
212
- for (let i = 0; i < renderExtent; i++) {
228
+ for (let i = start; i < renderExtent; i++) {
213
229
  if (currentCol >= consoleWidth) {
214
230
  process.stdout.write("\n");
215
231
  currentCol = 0;
@@ -221,14 +237,15 @@ function renderLine(): void {
221
237
  currentCol++;
222
238
  }
223
239
 
240
+ // Calculate and move to the cursor's position
224
241
  let absolutePos = 2 + cursorPosition;
225
242
  let newCursorRow = Math.floor(absolutePos / consoleWidth);
226
243
  let newCursorCol = absolutePos % consoleWidth;
227
-
228
244
  readline.cursorTo(process.stdout, 0);
229
245
  readline.moveCursor(process.stdout, 0, -1 * (newWrappedRows - newCursorRow));
230
246
  readline.cursorTo(process.stdout, newCursorCol);
231
247
 
248
+ oldInputBuffer = inputBuffer;
232
249
  oldWrappedRows = newWrappedRows;
233
250
  oldCursorRow = newCursorRow;
234
251
  }
package/src/render.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OpencodeClient } from "@opencode-ai/sdk";
2
2
  import { gfm, parse, renderToConsole } from "allmark";
3
+ import readline from "node:readline";
3
4
  import * as ansi from "./ansi";
4
5
  import { config } from "./config";
5
6
  import type { State } from "./index";
@@ -138,9 +139,10 @@ function lastThinkingLines(text: string): string {
138
139
 
139
140
  function clearRenderedLines(state: State, linesToClear: number): void {
140
141
  if (linesToClear > 0) {
141
- state.write(`${ansi.CURSOR_UP(linesToClear)}\x1b[J`);
142
+ readline.moveCursor(process.stdout, 0, -1 * linesToClear);
143
+ readline.clearScreenDown(process.stdout);
142
144
  }
143
- state.write(ansi.CURSOR_HOME);
145
+ readline.cursorTo(process.stdout, 0);
144
146
  }
145
147
 
146
148
  export function wrapText(text: string, width: number): string[] {
@@ -301,7 +303,7 @@ export function startAnimation(startTime?: number): void {
301
303
  const elapsedText = formatDuration(elapsed);
302
304
 
303
305
  process.stdout.write(
304
- `\r${ansi.BOLD_MAGENTA}${ANIMATION_CHARS[index]} ${ansi.RESET}${ansi.BRIGHT_BLACK}Running for ${elapsedText}${ansi.RESET}`,
306
+ `\r${ansi.BOLD_MAGENTA}${ANIMATION_CHARS[index]} ${ansi.RESET}${ansi.BRIGHT_BLACK}Running for ${elapsedText}${ansi.RESET} `,
305
307
  );
306
308
  index = (index + 1) % ANIMATION_CHARS.length;
307
309
  }, 100);
package/test/README.md DELETED
@@ -1,164 +0,0 @@
1
- # Tmux Readline Tests
2
-
3
- This directory contains tmux-based integration tests for the readline functionality of opencode-miniterm.
4
-
5
- ## Overview
6
-
7
- The `tmux-readline.test.ts` script uses tmux to simulate real terminal interactions with the application, testing:
8
-
9
- - **Input Editing**: Character insertion, backspace, delete key
10
- - **Cursor Navigation**: Left/right arrow keys, word boundaries (Meta+left/right)
11
- - **History Navigation**: Up/down arrow through command history
12
- - **Tab Completion**: Slash commands (`/`) and file references (`@`)
13
- - **Smart Features**: Double-space to period conversion
14
- - **Escape Key**: Clearing input and canceling requests
15
-
16
- ## Running the Tests
17
-
18
- ```bash
19
- # Run all tmux readline tests
20
- bun run test:tmux
21
-
22
- # Or run directly
23
- bun test/tmux-readline.test.ts
24
- ```
25
-
26
- ## How It Works
27
-
28
- The test script:
29
-
30
- 1. **Sets up a tmux session** - Creates a detached tmux session for testing
31
- 2. **Starts the application** - Launches opencode-miniterm in the tmux session
32
- 3. **Sends keystrokes** - Simulates user input using `tmux send-keys`
33
- 4. **Captures output** - Uses `tmux capture-pane` to read terminal state
34
- 5. **Verifies behavior** - Checks that the output matches expected results
35
- 6. **Cleans up** - Kills the tmux session after tests complete
36
-
37
- ## Test Cases
38
-
39
- ### Input Editing
40
-
41
- - **Character insertion**: Typing "hello" should insert characters correctly
42
- - **Backspace**: Deleting characters before the cursor
43
- - **Delete key**: Deleting character at the cursor position
44
-
45
- ### Cursor Navigation
46
-
47
- - **Left arrow**: Moving cursor left one character
48
- - **Right arrow**: Moving cursor right one character
49
- - **Meta+Left**: Jumping to previous word boundary
50
- - **Meta+Right**: Jumping to next word boundary
51
-
52
- ### History Navigation
53
-
54
- - **Up arrow**: Navigating backward through command history
55
- - **Down arrow**: Navigating forward through command history
56
-
57
- ### Tab Completion
58
-
59
- - **Slash commands**: Tab after `/` shows available commands
60
- - **File references**: Tab after `@` completes file paths
61
-
62
- ### Smart Features
63
-
64
- - **Double space**: Two spaces within 500ms converts to period + space
65
-
66
- ### Escape Key
67
-
68
- - **Clear input**: Escape clears the current input buffer
69
-
70
- ## Requirements
71
-
72
- - **tmux**: Must be installed on your system (`brew install tmux` on macOS)
73
- - **Bun**: Must have bun installed to run the tests
74
- - **opencode-miniterm**: Must be able to run the application
75
-
76
- ## Troubleshooting
77
-
78
- ### Tests fail with "Expected prompt to contain X"
79
-
80
- The test might be running too fast. Increase `WAIT_MS` at the top of the test file.
81
-
82
- ### Tests hang or timeout
83
-
84
- The application might be stuck in a command menu. Press `Escape` or `C-c` in the tmux session to recover.
85
-
86
- ### tmux session already exists
87
-
88
- The script automatically kills existing test sessions. If you see errors, manually run:
89
-
90
- ```bash
91
- tmux kill-session -t opencode-test
92
- ```
93
-
94
- ### Tests don't start
95
-
96
- Ensure tmux is installed and working:
97
-
98
- ```bash
99
- tmux -V
100
- tmux new-session -d -s test
101
- tmux kill-session -t test
102
- ```
103
-
104
- ## Adding New Tests
105
-
106
- To add a new test:
107
-
108
- 1. Create an async test function that:
109
- - Clears input with `await clearInput()`
110
- - Sends keystrokes with `await sendKeys("key")`
111
- - Waits for processing with `await sleep(ms)`
112
- - Captures output with `await capturePane()`
113
- - Asserts behavior with `assertPromptContains()` or `assertContains()`
114
- - Throws errors with descriptive messages
115
-
116
- 2. Register the test in `runAllTests()`:
117
- ```typescript
118
- await runTest("Your test name", testYourFunction);
119
- ```
120
-
121
- ### Example Test
122
-
123
- ```typescript
124
- async function testYourFeature(): Promise<void> {
125
- await clearInput();
126
- await sendKeys("test");
127
- await sendKeys("Enter");
128
- await sleep(1000);
129
-
130
- const output = await capturePane();
131
- if (!assertContains(output, "expected text")) {
132
- throw new Error(`Expected output to contain 'expected text'`);
133
- }
134
- }
135
- ```
136
-
137
- ## Command Menu Tests
138
-
139
- For examples of testing the interactive command menus (agents, models, sessions), see `tmux-menu-examples.ts`. This file demonstrates:
140
-
141
- - How to enter command menus (`/agents`, `/models`, `/sessions`)
142
- - How to navigate menu options (Up, Down)
143
- - How to filter/search within menus
144
- - How to select items (Enter)
145
- - How to cancel menus (Escape)
146
- - How to verify the results
147
-
148
- ## Key Constants
149
-
150
- - `TMUX_SESSION`: "opencode-test" - The tmux session name
151
- - `TMUX_WINDOW`: "readline-test" - The tmux window name
152
- - `WAIT_MS`: 100 - Default wait time between keystrokes
153
- - `APP_PATH`: Path to the application entry point
154
-
155
- ## Helper Functions
156
-
157
- - `setupTmux()` / `teardownTmux()`: Create/destroy test environment
158
- - `startApp()` / `stopApp()`: Start/stop the application
159
- - `sendKeys(keys)`: Send keystrokes to tmux (e.g., "C-c", "Enter", "M-Left")
160
- - `capturePane()`: Get current terminal output
161
- - `clearInput()`: Clear the current input line
162
- - `assertContains(output, expected)`: Check if output contains text
163
- - `assertPromptContains(text, expected)`: Check if prompt contains text
164
- - `stripAnsi(text)`: Remove ANSI escape codes from text
@@ -1,119 +0,0 @@
1
- // Example: How to add command menu tests
2
- // This file contains examples for testing the interactive command menus
3
- import { $ } from "bun";
4
-
5
- const TMUX_SESSION = "opencode-test-menu";
6
-
7
- async function testAgentMenu(): Promise<void> {
8
- console.log("Testing agent menu navigation");
9
-
10
- // Start with /agents command
11
- await $`tmux send-keys -t ${TMUX_SESSION} "/agents" Enter`;
12
- await sleep(500);
13
-
14
- // Send some filter text
15
- await $`tmux send-keys -t ${TMUX_SESSION} "code"`;
16
- await sleep(100);
17
-
18
- // Navigate up
19
- await $`tmux send-keys -t ${TMUX_SESSION} Up`;
20
- await sleep(100);
21
-
22
- // Navigate down
23
- await $`tmux send-keys -t ${TMUX_SESSION} Down`;
24
- await sleep(100);
25
-
26
- // Clear filter
27
- await $`tmux send-keys -t ${TMUX_SESSION} C-u`;
28
- await sleep(100);
29
-
30
- // Cancel with escape
31
- await $`tmux send-keys -t ${TMUX_SESSION} Escape`;
32
- await sleep(100);
33
-
34
- // Verify we're back at prompt
35
- const output = await capturePane();
36
- if (!assertPromptContains(output, "#")) {
37
- throw new Error("Should be back at prompt");
38
- }
39
- }
40
-
41
- async function testModelMenu(): Promise<void> {
42
- console.log("Testing model menu navigation");
43
-
44
- await $`tmux send-keys -t ${TMUX_SESSION} "/models" Enter`;
45
- await sleep(500);
46
-
47
- // Navigate through options
48
- await $`tmux send-keys -t ${TMUX_SESSION} Down`;
49
- await sleep(100);
50
- await $`tmux send-keys -t ${TMUX_SESSION} Down`;
51
- await sleep(100);
52
-
53
- // Select a model
54
- await $`tmux send-keys -t ${TMUX_SESSION} Enter`;
55
- await sleep(200);
56
-
57
- // Verify selection worked
58
- const output = await capturePane();
59
- if (!assertContains(output, "Model changed")) {
60
- throw new Error("Model should be changed");
61
- }
62
- }
63
-
64
- async function testSessionMenu(): Promise<void> {
65
- console.log("Testing session menu navigation");
66
-
67
- await $`tmux send-keys -t ${TMUX_SESSION} "/sessions" Enter`;
68
- await sleep(500);
69
-
70
- // Test pagination
71
- await $`tmux send-keys -t ${TMUX_SESSION} Down`;
72
- await sleep(100);
73
- await $`tmux send-keys -t ${TMUX_SESSION} Down`;
74
- await sleep(100);
75
-
76
- // Select a session
77
- await $`tmux send-keys -t ${TMUX_SESSION} Enter`;
78
- await sleep(200);
79
-
80
- // Verify we're using the selected session
81
- const output = await capturePane();
82
- if (!assertContains(output, "Session loaded")) {
83
- throw new Error("Session should be loaded");
84
- }
85
- }
86
-
87
- // Note: These are example tests that would need to be integrated
88
- // into the main tmux-readline.test.ts file. They demonstrate:
89
- // 1. How to enter command menus (/agents, /models, /sessions)
90
- // 2. How to navigate menu options (Up, Down)
91
- // 3. How to filter/search within menus
92
- // 4. How to select items (Enter)
93
- // 5. How to cancel menus (Escape)
94
- // 6. How to verify the results
95
-
96
- function sleep(ms: number): Promise<void> {
97
- return new Promise((resolve) => setTimeout(resolve, ms));
98
- }
99
-
100
- async function capturePane(): Promise<string> {
101
- const result = await $`tmux capture-pane -t ${TMUX_SESSION} -p`.quiet();
102
- return result.stdout.toString();
103
- }
104
-
105
- function assertPromptContains(text: string, expected: string): boolean {
106
- const lines = text.split("\n");
107
- for (const line of lines) {
108
- if (line.includes("#")) {
109
- return line.includes(expected);
110
- }
111
- }
112
- return false;
113
- }
114
-
115
- function assertContains(output: string, expected: string): boolean {
116
- return output.includes(expected);
117
- }
118
-
119
- export {};
@@ -1,518 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { $ } from "bun";
3
-
4
- const TMUX_SESSION = "opencode-test";
5
- const TMUX_WINDOW = "readline-test";
6
- const APP_PATH = "/Users/andrewjk/Source/opencode-miniterm/src/index.ts";
7
- const WAIT_MS = 80;
8
-
9
- interface TestResult {
10
- name: string;
11
- passed: boolean;
12
- output: string;
13
- expected: string;
14
- error?: string;
15
- }
16
-
17
- const results: TestResult[] = [];
18
-
19
- async function setupTmux(): Promise<void> {
20
- try {
21
- await $`tmux kill-session -t ${TMUX_SESSION}`.quiet();
22
- } catch {
23
- // Session might not exist
24
- }
25
-
26
- await $`tmux new-session -d -s ${TMUX_SESSION} -n ${TMUX_WINDOW}`;
27
- await sleep(300);
28
- }
29
-
30
- async function teardownTmux(): Promise<void> {
31
- await $`tmux kill-session -t ${TMUX_SESSION}`.quiet();
32
- }
33
-
34
- async function startApp(): Promise<void> {
35
- await $`tmux send-keys -t ${TMUX_SESSION} "cd /Users/andrewjk/Source/opencode-miniterm && bun run ${APP_PATH}" Enter`;
36
- await sleep(3000);
37
- }
38
-
39
- async function stopApp(): Promise<void> {
40
- await $`tmux send-keys -t ${TMUX_SESSION} C-c`;
41
- await sleep(200);
42
- }
43
-
44
- async function sendKeys(keys: string): Promise<void> {
45
- await $`tmux send-keys -t ${TMUX_SESSION} ${keys}`.quiet();
46
- await sleep(WAIT_MS);
47
- }
48
-
49
- async function capturePane(): Promise<string> {
50
- const result = await $`tmux capture-pane -t ${TMUX_SESSION} -p`.quiet();
51
- const text = result.stdout.toString();
52
- return text;
53
- }
54
-
55
- async function clearInput(): Promise<void> {
56
- // Send Enter to execute/clear current input
57
- await sendKeys("Enter");
58
- await sleep(500);
59
- // Send Escape to cancel any active state
60
- await sendKeys("Escape");
61
- await sleep(100);
62
- // Send Ctrl+U to clear from cursor to start
63
- await sendKeys("C-u");
64
- await sleep(100);
65
- // Send Ctrl+K to clear from cursor to end
66
- await sendKeys("C-k");
67
- await sleep(100);
68
- }
69
-
70
- async function sleep(ms: number): Promise<void> {
71
- await new Promise((resolve) => setTimeout(resolve, ms));
72
- }
73
-
74
- function stripAnsi(text: string): string {
75
- return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\[[0-9;]*[ABCDEFGJKLM]/g, "");
76
- }
77
-
78
- function extractPromptLine(text: string): string {
79
- const lines = text.split("\n");
80
- let lastPromptIndex = -1;
81
-
82
- // Find the LAST prompt line (with #)
83
- for (let i = 0; i < lines.length; i++) {
84
- const line = lines[i];
85
- if (line && line.includes("#")) {
86
- lastPromptIndex = i;
87
- }
88
- }
89
-
90
- if (lastPromptIndex === -1) {
91
- return "";
92
- }
93
-
94
- // Collect the prompt line and its wrapped continuations
95
- const promptLine = lines[lastPromptIndex]!;
96
- const afterPrompt = promptLine.substring(promptLine.indexOf("#") + 1).trim();
97
-
98
- const promptLines: string[] = [afterPrompt];
99
- for (let j = lastPromptIndex + 1; j < lines.length; j++) {
100
- const nextLine = lines[j];
101
- if (!nextLine) {
102
- break;
103
- }
104
- const trimmed = nextLine.trim();
105
- // Stop if we hit another prompt or an empty line
106
- if (nextLine.includes("#") || trimmed === "") {
107
- break;
108
- }
109
- promptLines.push(trimmed);
110
- }
111
-
112
- return stripAnsi(promptLines.join(" "));
113
- }
114
-
115
- function assertContains(output: string, expected: string): boolean {
116
- const stripped = stripAnsi(output);
117
- return stripped.includes(expected);
118
- }
119
-
120
- function assertPromptContains(text: string, expected: string): boolean {
121
- const promptLine = extractPromptLine(text);
122
- return promptLine.includes(expected);
123
- }
124
-
125
- async function runTest(name: string, testFn: () => Promise<void>): Promise<void> {
126
- console.log(`Running: ${name}`);
127
- try {
128
- await testFn();
129
- results.push({
130
- name,
131
- passed: true,
132
- output: "",
133
- expected: "",
134
- });
135
- console.log(`✓ ${name}`);
136
- } catch (error) {
137
- results.push({
138
- name,
139
- passed: false,
140
- output: (error as Error).message,
141
- expected: "",
142
- error: (error as Error).stack,
143
- });
144
- console.error(`✗ ${name}`);
145
- console.error(` Error: ${(error as Error).message}`);
146
- }
147
- }
148
-
149
- async function testCharacterInsertion(): Promise<void> {
150
- await clearInput();
151
- await sleep(100);
152
- await sendKeys("h");
153
- await sendKeys("e");
154
- await sendKeys("l");
155
- await sendKeys("l");
156
- await sendKeys("o");
157
-
158
- const output = await capturePane();
159
- if (!assertPromptContains(output, "hello")) {
160
- throw new Error(`Expected prompt to contain 'hello', got: ${extractPromptLine(output)}`);
161
- }
162
- }
163
-
164
- async function testBackspace(): Promise<void> {
165
- await clearInput();
166
- await sleep(100);
167
- await sendKeys("hello");
168
- await sendKeys("Space");
169
- await sendKeys("world");
170
- await sendKeys("C-h");
171
- await sendKeys("C-h");
172
- await sendKeys("C-h");
173
- await sendKeys("C-h");
174
- await sendKeys("C-h");
175
-
176
- const output = await capturePane();
177
- if (!assertPromptContains(output, "hello")) {
178
- throw new Error(`Expected prompt to contain 'hello', got: ${extractPromptLine(output)}`);
179
- }
180
- }
181
-
182
- async function testDelete(): Promise<void> {
183
- await clearInput();
184
- await sleep(100);
185
- await sendKeys("hello");
186
- await sendKeys("Left");
187
- await sendKeys("Left");
188
- await sendKeys("Delete");
189
-
190
- const output = await capturePane();
191
- if (!assertPromptContains(output, "helo")) {
192
- throw new Error(`Expected prompt to contain 'helo', got: ${extractPromptLine(output)}`);
193
- }
194
- }
195
-
196
- async function testLeftArrow(): Promise<void> {
197
- await clearInput();
198
- await sleep(100);
199
- await sendKeys("test");
200
- await sendKeys("Left");
201
- await sendKeys("Left");
202
- await sendKeys("X");
203
-
204
- const output = await capturePane();
205
- if (!assertPromptContains(output, "teXst")) {
206
- throw new Error(`Expected prompt to contain 'teXst', got: ${extractPromptLine(output)}`);
207
- }
208
- }
209
-
210
- async function testRightArrow(): Promise<void> {
211
- await clearInput();
212
- await sleep(200);
213
- await sendKeys("test");
214
- await sleep(150);
215
- await sendKeys("Left");
216
- await sleep(150);
217
- await sendKeys("Left");
218
- await sleep(150);
219
- await sendKeys("Right");
220
- await sleep(150);
221
- await sendKeys("Right");
222
- await sleep(150);
223
- await sendKeys("X");
224
-
225
- const output = await capturePane();
226
- if (!assertPromptContains(output, "tesXt") && !assertPromptContains(output, "testX")) {
227
- throw new Error(
228
- `Expected prompt to contain 'tesXt' or 'testX', got: ${extractPromptLine(output)}`,
229
- );
230
- }
231
- }
232
-
233
- async function testTabCompletionSlashCommands(): Promise<void> {
234
- await clearInput();
235
- await sleep(100);
236
- await sendKeys("/");
237
- await sleep(200);
238
- await sendKeys("Tab");
239
- await sleep(500);
240
-
241
- const output = await capturePane();
242
- const stripped = stripAnsi(output);
243
- if (!stripped.includes("/") && !stripped.includes("/help") && !stripped.includes("commands")) {
244
- throw new Error(
245
- `Expected output to contain command completions, got: ${stripped.substring(0, 200)}`,
246
- );
247
- }
248
- }
249
-
250
- async function testTabCompletionFileRef(): Promise<void> {
251
- await clearInput();
252
- await sleep(100);
253
- await sendKeys("@");
254
- await sleep(100);
255
- await sendKeys("src/");
256
- await sleep(100);
257
- await sendKeys("Tab");
258
- await sleep(300);
259
-
260
- const output = await capturePane();
261
- const stripped = stripAnsi(output);
262
- if (!stripped.includes("src/")) {
263
- throw new Error(`Expected output to contain 'src/', got: ${stripped.substring(0, 200)}`);
264
- }
265
- }
266
-
267
- async function testHistoryUpArrow(): Promise<void> {
268
- await clearInput();
269
- await sleep(100);
270
-
271
- // Add some commands to history
272
- await sendKeys("first command");
273
- await sendKeys("Enter");
274
- await sleep(1000);
275
-
276
- await sendKeys("second command");
277
- await sendKeys("Enter");
278
- await sleep(1000);
279
-
280
- await clearInput();
281
- await sleep(100);
282
- await sendKeys("Up");
283
- await sleep(100);
284
- await sendKeys("Up");
285
- await sleep(100);
286
-
287
- const output = await capturePane();
288
- if (!assertPromptContains(output, "first command")) {
289
- throw new Error(
290
- `Expected prompt to contain 'first command', got: ${extractPromptLine(output)}`,
291
- );
292
- }
293
- }
294
-
295
- async function testHistoryDownArrow(): Promise<void> {
296
- await clearInput();
297
- await sleep(100);
298
-
299
- await sendKeys("test history");
300
- await sendKeys("Enter");
301
- await sleep(1000);
302
-
303
- await clearInput();
304
- await sleep(100);
305
- await sendKeys("Up");
306
- await sleep(100);
307
- await sendKeys("Down");
308
- await sleep(100);
309
-
310
- const output = await capturePane();
311
- const promptLine = extractPromptLine(output);
312
- if (promptLine.includes("test history")) {
313
- throw new Error(`Expected prompt to be empty after down arrow, got: ${promptLine}`);
314
- }
315
- }
316
-
317
- async function testEscapeClearsInput(): Promise<void> {
318
- await clearInput();
319
- await sleep(200);
320
- await sendKeys("test");
321
- await sleep(100);
322
- await sendKeys("Escape");
323
- await sleep(300);
324
-
325
- const output = await capturePane();
326
- const promptLine = extractPromptLine(output);
327
- // Escape should clear input or cancel active request
328
- if (
329
- promptLine &&
330
- promptLine.includes("test") &&
331
- !promptLine.includes("test ") &&
332
- promptLine.trim() !== ""
333
- ) {
334
- // Input still has "test" - escape might not have cleared it, but this is okay if it cancels a request
335
- // Just mark as pass since the key was processed
336
- }
337
- }
338
-
339
- async function testDoubleSpaceToPeriod(): Promise<void> {
340
- await clearInput();
341
- await sleep(100);
342
- await sendKeys("hello");
343
- await sleep(100);
344
- await sendKeys("Space");
345
- await sleep(100);
346
- await sendKeys("Space");
347
- await sleep(700);
348
-
349
- const output = await capturePane();
350
- if (!assertPromptContains(output, "hello.")) {
351
- throw new Error(`Expected prompt to contain 'hello.', got: ${extractPromptLine(output)}`);
352
- }
353
- }
354
-
355
- async function testMetaLeftWordNavigation(): Promise<void> {
356
- await clearInput();
357
- await sleep(200);
358
- await sendKeys("hello world test");
359
- await sleep(200);
360
-
361
- // Try to send Alt+b using different methods
362
- await $`tmux send-keys -t ${TMUX_SESSION} 'M-b'`.quiet();
363
- await sleep(200);
364
- await $`tmux send-keys -t ${TMUX_SESSION} 'M-b'`.quiet();
365
- await sleep(200);
366
- await sendKeys("X");
367
-
368
- const output = await capturePane();
369
- const promptLine = extractPromptLine(output);
370
- // Check if cursor moved by verifying X is not at the end
371
- if (promptLine && promptLine.endsWith("testX")) {
372
- // Cursor didn't move, try to skip this test or mark as partial pass
373
- console.log(" (Meta key not recognized, test skipped)");
374
- } else if (
375
- !assertPromptContains(output, "X world test") &&
376
- !assertPromptContains(output, "hello worldX test") &&
377
- !assertPromptContains(output, "hello world testX")
378
- ) {
379
- throw new Error(`Unexpected prompt: ${promptLine}`);
380
- }
381
- }
382
-
383
- async function testMetaRightWordNavigation(): Promise<void> {
384
- await clearInput();
385
- await sleep(100);
386
- await sendKeys("hello");
387
- await sendKeys("Space");
388
- await sleep(100);
389
-
390
- // Use Alt+f instead of M-Right for better compatibility
391
- await $`tmux send-keys -t ${TMUX_SESSION} M-f`.quiet();
392
- await sleep(100);
393
- await sendKeys("X");
394
-
395
- const output = await capturePane();
396
- if (!assertPromptContains(output, "hello X")) {
397
- throw new Error(`Expected prompt to contain 'hello X', got: ${extractPromptLine(output)}`);
398
- }
399
- }
400
-
401
- async function testMultiLineBackspaceToSingleLine(): Promise<void> {
402
- await clearInput();
403
- await sleep(200);
404
-
405
- // Type a command that will produce output above the prompt
406
- await sendKeys("echo test");
407
- await sendKeys("Enter");
408
- await sleep(1000);
409
-
410
- // Capture state before multi-line input - we should have output above
411
- const outputBefore = await capturePane();
412
- const linesBefore = outputBefore.split("\n");
413
-
414
- // Find the line with our output (should contain "test" or similar)
415
- const outputLineIndex = linesBefore.findIndex(
416
- (line) => line.includes("test") && !line.includes("#"),
417
- );
418
- if (outputLineIndex === -1) {
419
- throw new Error("Could not find output line before typing multi-line input");
420
- }
421
-
422
- const outputLineBefore = linesBefore[outputLineIndex]!;
423
-
424
- // Type enough text to span multiple lines
425
- await sendKeys(
426
- "This is a very long line of text that should wrap to the next line when typed in the terminal",
427
- );
428
- await sleep(200);
429
-
430
- // Press backspace multiple times to reduce to single line
431
- for (let i = 0; i < 40; i++) {
432
- await sendKeys("C-h");
433
- await sleep(30);
434
- }
435
-
436
- await sleep(300);
437
- const outputAfter = await capturePane();
438
- const linesAfter = outputAfter.split("\n");
439
-
440
- // Find the output line after backspace
441
- const outputLineIndexAfter = linesAfter.findIndex(
442
- (line) => line.includes("test") && !line.includes("#"),
443
- );
444
-
445
- if (outputLineIndexAfter === -1) {
446
- throw new Error(`Output line was cleared! Before: '${outputLineBefore}'`);
447
- }
448
-
449
- const outputLineAfter = linesAfter[outputLineIndexAfter]!;
450
-
451
- // Verify the output line is still there and not modified
452
- if (!outputLineAfter.includes("test")) {
453
- throw new Error(
454
- `Output line was modified or cleared! Before: '${outputLineBefore}', After: '${outputLineAfter}'`,
455
- );
456
- }
457
-
458
- // Also verify the output hasn't been corrupted with extra characters from the prompt
459
- const strippedBefore = stripAnsi(outputLineBefore);
460
- const strippedAfter = stripAnsi(outputLineAfter);
461
- if (strippedBefore !== strippedAfter) {
462
- throw new Error(
463
- `Output line content changed! Before: '${strippedBefore}', After: '${strippedAfter}'`,
464
- );
465
- }
466
- }
467
-
468
- async function runAllTests(): Promise<void> {
469
- console.log("Setting up tmux test environment...");
470
- await setupTmux();
471
- await startApp();
472
-
473
- console.log("\nRunning readline tests...\n");
474
-
475
- await runTest("Character insertion", testCharacterInsertion);
476
- await runTest("Backspace deletion", testBackspace);
477
- await runTest("Delete key", testDelete);
478
- await runTest("Left arrow navigation", testLeftArrow);
479
- await runTest("Right arrow navigation", testRightArrow);
480
- await runTest("Tab completion - slash commands", testTabCompletionSlashCommands);
481
- await runTest("Tab completion - file references", testTabCompletionFileRef);
482
- await runTest("History navigation - up arrow", testHistoryUpArrow);
483
- await runTest("History navigation - down arrow", testHistoryDownArrow);
484
- await runTest("Escape clears input", testEscapeClearsInput);
485
- await runTest("Double space to period", testDoubleSpaceToPeriod);
486
- await runTest("Word navigation - Meta+Left", testMetaLeftWordNavigation);
487
- await runTest("Word navigation - Meta+Right", testMetaRightWordNavigation);
488
- await runTest("Multi-line backspace to single line", testMultiLineBackspaceToSingleLine);
489
-
490
- await stopApp();
491
- await teardownTmux();
492
-
493
- console.log("\n" + "=".repeat(60));
494
- console.log("Test Results:");
495
- console.log("=".repeat(60));
496
-
497
- const passed = results.filter((r) => r.passed).length;
498
- const failed = results.filter((r) => !r.passed).length;
499
-
500
- results.forEach((result) => {
501
- const icon = result.passed ? "✓" : "✗";
502
- console.log(`${icon} ${result.name}`);
503
- if (!result.passed) {
504
- console.log(` ${result.output}`);
505
- }
506
- });
507
-
508
- console.log("=".repeat(60));
509
- console.log(`Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
510
- console.log("=".repeat(60));
511
-
512
- process.exit(failed > 0 ? 1 : 0);
513
- }
514
-
515
- runAllTests().catch((error) => {
516
- console.error("Fatal error:", error);
517
- process.exit(1);
518
- });