Skip to content

Commit 0885352

Browse files
authored
Merge pull request #8691 from chezsmithy/feat-ttyless-cli
feat: ✨ Remove TTY requirement for the cli in headless mode
2 parents 78f904b + ad283ee commit 0885352

File tree

14 files changed

+700
-11
lines changed

14 files changed

+700
-11
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
name: Sub Agent Background Prompt
3+
description: Start a subagent using the continue cli in the background
4+
invokable: true
5+
---
6+
7+
# Continue Sub Agent Background Prompt
8+
9+
Take the prompt provided by the user and using the terminal tool run the following command in the background:
10+
11+
cn -p "{{prompt}}"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
name: Sub Agent Foreground Prompt
3+
description: Start a subagent using the continue cli in the foreground
4+
invokable: true
5+
---
6+
7+
# Continue Sub Agent Foreground Prompt
8+
9+
Take the prompt provided by the user and using the terminal tool run the following command in the foreground:
10+
11+
cn -p "{{prompt}}"

extensions/cli/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,30 @@ cn
1818

1919
### Headless Mode
2020

21+
Headless mode (`-p` flag) runs without an interactive terminal UI, making it perfect for:
22+
23+
- Scripts and automation
24+
- CI/CD pipelines
25+
- Docker containers
26+
- VSCode/IntelliJ extension integration
27+
- Environments without a TTY
28+
2129
```bash
30+
# Basic usage
2231
cn -p "Generate a conventional commit name for the current git changes."
32+
33+
# With piped input
34+
echo "Review this code" | cn -p
35+
36+
# JSON output for scripting
37+
cn -p "Analyze the code" --format json
38+
39+
# Silent mode (strips thinking tags)
40+
cn -p "Write a README" --silent
2341
```
2442

43+
**TTY-less Environments**: Headless mode is designed to work in environments without a terminal (TTY), such as when called from VSCode/IntelliJ extensions using terminal commands. The CLI will not attempt to read stdin or initialize the interactive UI when running in headless mode with a supplied prompt.
44+
2545
### Session Management
2646

2747
The CLI automatically saves your chat history for each terminal session. You can resume where you left off:
@@ -47,6 +67,7 @@ cn ls --json
4767
## Environment Variables
4868

4969
- `CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE`: Disable adding the Continue commit signature to generated commit messages
70+
- `FORCE_NO_TTY`: Force TTY-less mode, prevents stdin reading (useful for testing and automation)
5071

5172
## Commands
5273

@@ -62,3 +83,26 @@ cn ls --json
6283
Shows recent sessions, limited by screen height to ensure it fits on your terminal.
6384

6485
- `--json`: Output in JSON format for scripting (always shows 10 sessions)
86+
87+
## TTY-less Support
88+
89+
The CLI fully supports running in environments without a TTY (terminal):
90+
91+
```bash
92+
# From Docker without TTY allocation
93+
docker run --rm my-image cn -p "Generate docs"
94+
95+
# From CI/CD pipeline
96+
cn -p "Review changes" --format json
97+
98+
# From VSCode/IntelliJ extension terminal tool
99+
cn -p "Analyze code" --silent
100+
```
101+
102+
The CLI automatically detects TTY-less environments and adjusts its behavior:
103+
104+
- Skips stdin reading when a prompt is supplied
105+
- Disables interactive UI components
106+
- Ensures clean stdout/stderr output
107+
108+
For more details, see [`spec/tty-less-support.md`](./spec/tty-less-support.md).
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# TTY-less Environment Support
2+
3+
## Overview
4+
5+
The Continue CLI supports running in TTY-less environments (environments without a terminal/TTY), which is essential for:
6+
7+
- VSCode and IntelliJ extensions using the `run_terminal_command` tool
8+
- Docker containers without TTY allocation
9+
- CI/CD pipelines
10+
- Automated scripts and tools
11+
- Background processes
12+
13+
## Architecture
14+
15+
### Mode Separation
16+
17+
The CLI has two distinct execution modes with complete separation:
18+
19+
1. **Interactive Mode (TUI)**: Requires a TTY, uses Ink for rendering
20+
2. **Headless Mode**: Works in TTY-less environments, outputs to stdout/stderr
21+
22+
```
23+
┌─────────────────────────────────────────────────────────────┐
24+
│ CLI Entry Point │
25+
│ (src/index.ts) │
26+
└────────────────────────┬────────────────────────────────────┘
27+
28+
┌────────────┴────────────┐
29+
│ │
30+
┌───────▼────────┐ ┌───────▼─────────┐
31+
│ Interactive │ │ Headless │
32+
│ Mode (TUI) │ │ Mode (-p) │
33+
│ │ │ │
34+
│ • Requires TTY │ │ • No TTY needed │
35+
│ • Uses Ink │ │ • Stdin/stdout │
36+
│ • Keyboard UI │ │ • One-shot exec │
37+
└────────────────┘ └─────────────────┘
38+
```
39+
40+
### Safeguards Implemented
41+
42+
#### 1. **TTY Detection Utilities** (`src/util/cli.ts`)
43+
44+
```typescript
45+
// Check if running in TTY-less environment
46+
export function isTTYless(): boolean;
47+
48+
// Check if environment supports interactive features
49+
export function supportsInteractive(): boolean;
50+
51+
// Check if prompt was supplied via CLI arguments
52+
export function hasSuppliedPrompt(): boolean;
53+
```
54+
55+
#### 2. **Stdin Reading Protection** (`src/util/stdin.ts`)
56+
57+
Prevents stdin reading when:
58+
59+
- In headless mode with supplied prompt
60+
- `FORCE_NO_TTY` environment variable is set
61+
- In test environments
62+
63+
This avoids blocking/hanging in TTY-less environments where stdin is not available or not readable.
64+
65+
#### 3. **TUI Initialization Guards** (`src/ui/index.ts`)
66+
67+
The `startTUIChat()` function now includes multiple safeguards:
68+
69+
- **Headless mode check**: Throws error if called in headless mode
70+
- **TTY-less check**: Throws error if no TTY is available
71+
- **Raw mode test**: Validates stdin supports raw mode (required by Ink)
72+
- **Explicit stdin/stdout**: Passes streams explicitly to Ink
73+
74+
```typescript
75+
// Critical safeguard: Prevent TUI in headless mode
76+
if (isHeadlessMode()) {
77+
throw new Error("Cannot start TUI in headless mode");
78+
}
79+
80+
// Critical safeguard: Prevent TUI in TTY-less environment
81+
if (isTTYless() && !customStdin) {
82+
throw new Error("Cannot start TUI in TTY-less environment");
83+
}
84+
```
85+
86+
#### 4. **Headless Mode Validation** (`src/commands/chat.ts`)
87+
88+
Ensures headless mode has all required inputs:
89+
90+
```typescript
91+
if (!prompt) {
92+
throw new Error("Headless mode requires a prompt");
93+
}
94+
```
95+
96+
#### 5. **Logger Configuration** (`src/util/logger.ts`)
97+
98+
Configures output handling for TTY-less environments:
99+
100+
- Sets UTF-8 encoding
101+
- Leaves stdout/stderr buffering unchanged in headless mode.
102+
- Disables progress indicators
103+
104+
## Usage Examples
105+
106+
### From VSCode/IntelliJ Extension
107+
108+
```typescript
109+
// Using the run_terminal_command tool
110+
const command = 'cn -p "Analyze the current git diff"';
111+
const result = await runTerminalCommand(command);
112+
```
113+
114+
### From Docker Container
115+
116+
```bash
117+
# Without TTY allocation (-t flag)
118+
docker run --rm my-image cn -p "Generate a README"
119+
```
120+
121+
### From CI/CD Pipeline
122+
123+
```yaml
124+
- name: Run Continue CLI
125+
run: |
126+
cn -p "Review code changes" --format json
127+
```
128+
129+
### From Automated Script
130+
131+
```bash
132+
#!/bin/bash
133+
# Non-interactive script
134+
cn -p "Generate commit message for current changes" --silent
135+
```
136+
137+
## Environment Variables
138+
139+
- `FORCE_NO_TTY`: Forces TTY-less mode, prevents stdin reading
140+
- `CONTINUE_CLI_TEST`: Marks test environment, prevents stdin reading
141+
142+
## Testing
143+
144+
### TTY-less Test
145+
146+
```typescript
147+
const result = await runCLI(context, {
148+
args: ["-p", "Hello, world!"],
149+
env: {
150+
FORCE_NO_TTY: "true",
151+
},
152+
});
153+
```
154+
155+
### Expected Behavior
156+
157+
- ✅ Should not hang on stdin
158+
- ✅ Should not attempt to initialize Ink
159+
- ✅ Should output results to stdout
160+
- ✅ Should exit cleanly
161+
162+
## Error Messages
163+
164+
### Attempting TUI in TTY-less Environment
165+
166+
```
167+
Error: Cannot start TUI in TTY-less environment. No TTY available for interactive mode.
168+
For non-interactive use, run with -p flag:
169+
cn -p "your prompt here"
170+
```
171+
172+
### Missing Prompt in Headless Mode
173+
174+
```
175+
Error: A prompt is required when using the -p/--print flag, unless --prompt or --agent is provided.
176+
177+
Usage examples:
178+
cn -p "please review my current git diff"
179+
echo "hello" | cn -p
180+
cn -p "analyze the code in src/"
181+
cn -p --agent my-org/my-agent
182+
```
183+
184+
## Troubleshooting
185+
186+
### CLI Hangs in Docker/CI
187+
188+
**Cause**: CLI attempting to read stdin in TTY-less environment
189+
190+
**Solution**: Ensure using `-p` flag with a prompt:
191+
192+
```bash
193+
cn -p "your prompt" --config config.yaml
194+
```
195+
196+
### "Cannot start TUI" Error
197+
198+
**Cause**: Attempting interactive mode in TTY-less environment
199+
200+
**Solution**: Use headless mode:
201+
202+
```bash
203+
cn -p "your prompt"
204+
```
205+
206+
### Raw Mode Error
207+
208+
**Cause**: Terminal doesn't support raw mode (required by Ink)
209+
210+
**Solution**: Use headless mode instead of interactive mode
211+
212+
## Design Principles
213+
214+
1. **Fail Fast**: Detect environment early and fail with clear messages
215+
2. **Explicit Separation**: No code path should allow Ink to load in headless mode
216+
3. **No Blocking**: Never block on stdin in TTY-less environments
217+
4. **Clear Errors**: Provide actionable error messages with examples
218+
5. **Testing**: Comprehensive tests for TTY-less scenarios
219+
220+
## Implementation Checklist
221+
222+
- [x] Add TTY detection utilities
223+
- [x] Protect stdin reading in headless mode
224+
- [x] Guard TUI initialization
225+
- [x] Validate headless mode inputs
226+
- [x] Configure logger for TTY-less output
227+
- [x] Update test helpers
228+
- [x] Add TTY-less tests
229+
- [x] Document TTY-less support
230+
231+
## Related Files
232+
233+
- `src/util/cli.ts` - TTY detection utilities
234+
- `src/util/stdin.ts` - Stdin reading protection
235+
- `src/ui/index.ts` - TUI initialization guards
236+
- `src/commands/chat.ts` - Mode routing and validation
237+
- `src/util/logger.ts` - Output configuration
238+
- `src/test-helpers/cli-helpers.ts` - Test support
239+
- `src/e2e/headless-minimal.test.ts` - TTY-less tests

extensions/cli/src/commands/chat.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,27 @@ async function runHeadlessMode(
492492
initialPrompt,
493493
);
494494

495+
// Critical validation: Ensure we have actual prompt text in headless mode
496+
// This prevents the CLI from hanging in TTY-less environments when question() is called
497+
// We check AFTER processing all prompts (including agent files) to ensure we have real content
498+
// EXCEPTION: Allow empty prompts when resuming/forking since they may just want to view history
499+
if (!initialUserInput || !initialUserInput.trim()) {
500+
// If resuming or forking, allow empty prompt - just exit successfully after showing history
501+
if (options.resume || options.fork) {
502+
// For resume/fork with no new input, we've already loaded the history above
503+
// Just exit successfully (the history was already loaded into chatHistory)
504+
await gracefulExit(0);
505+
return;
506+
}
507+
508+
throw new Error(
509+
'Headless mode requires a prompt. Use: cn -p "your prompt"\n' +
510+
'Or pipe input: echo "prompt" | cn -p\n' +
511+
"Or use agent files: cn -p --agent my-org/my-agent\n" +
512+
"Note: Agent files must contain a prompt field.",
513+
);
514+
}
515+
495516
let isFirstMessage = true;
496517
while (true) {
497518
// When in headless mode, don't ask for user input
@@ -544,6 +565,15 @@ export async function chat(prompt?: string, options: ChatOptions = {}) {
544565
// Start active time tracking
545566
telemetryService.startActiveTime();
546567

568+
// Critical routing: Explicit separation of headless and interactive modes
569+
if (options.headless) {
570+
// Headless path - no Ink, no TUI, works in TTY-less environments
571+
logger.debug("Running in headless mode (TTY-less compatible)");
572+
await runHeadlessMode(prompt, options);
573+
return;
574+
}
575+
576+
// Interactive path - requires TTY for Ink rendering
547577
// If not in headless mode, use unified initialization with TUI
548578
if (!options.headless) {
549579
// Process flags for TUI mode

0 commit comments

Comments
 (0)