feat(tui/editor): open external editor with Ctrl+G

fixes #531
This commit is contained in:
James Peter
2026-01-24 00:23:05 +10:00
parent f198c33f09
commit d3a315c963
38 changed files with 241 additions and 46 deletions
+32
View File
@@ -21,6 +21,7 @@ use crate::bottom_pane::SettingsSection;
use crate::chatwidget::ChatWidget;
use crate::cloud_tasks_service;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::external_editor;
use crate::get_git_diff::get_git_diff;
use crate::history_cell;
use crate::slash_command::SlashCommand;
@@ -575,6 +576,37 @@ impl App<'_> {
AppEvent::Paste(text) => {
self.dispatch_paste_event(text);
}
AppEvent::OpenExternalEditor { initial } => {
let was_alt_screen = self.alt_screen_active;
self.input_suspended.store(true, Ordering::Release);
let editor_result = match tui::restore() {
Ok(()) => external_editor::run_editor(&initial),
Err(err) => Err(external_editor::ExternalEditorError::LaunchFailed(format!(
"Failed to reset terminal: {err}",
))),
};
let (new_terminal, new_terminal_info) = tui::init(&self.config)?;
*terminal = new_terminal;
self.terminal_info = new_terminal_info;
terminal.clear()?;
if was_alt_screen {
self.alt_screen_active = true;
} else {
let _ = tui::leave_alt_screen_only();
self.alt_screen_active = false;
}
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_standard_terminal_mode(!self.alt_screen_active);
}
self.input_suspended.store(false, Ordering::Release);
if let AppState::Chat { widget } = &mut self.app_state {
match editor_result {
Ok(text) => widget.set_composer_text(text),
Err(err) => widget.debug_notice(err.to_string()),
}
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RegisterPastedImage { placeholder, path } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.register_pasted_image(placeholder, path);
+8 -1
View File
@@ -106,8 +106,9 @@ impl App<'_> {
// Spawn a dedicated thread for reading the crossterm event loop and
// re-publishing the events as AppEvents, as appropriate.
// Create the input thread stop flag up front so we can store it on `Self`.
// Create the input thread flags up front so we can store them on `Self`.
let input_running = Arc::new(AtomicBool::new(true));
let input_suspended = Arc::new(AtomicBool::new(false));
#[cfg(unix)]
let mut sigterm_guard = None;
#[cfg(unix)]
@@ -115,6 +116,7 @@ impl App<'_> {
{
let app_event_tx = app_event_tx.clone();
let input_running_thread = input_running.clone();
let input_suspended_thread = input_suspended.clone();
let drop_release_events = enhanced_keys_supported;
if let Err(err) = std::thread::Builder::new()
.name("tui-input-loop".to_string())
@@ -123,6 +125,10 @@ impl App<'_> {
let mut last_key_time = Instant::now();
loop {
if !input_running_thread.load(Ordering::Relaxed) { break; }
if input_suspended_thread.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(10));
continue;
}
// This timeout is necessary to avoid holding the event lock
// that crossterm::event::read() acquires. In particular,
// reading the cursor position (crossterm::cursor::position())
@@ -315,6 +321,7 @@ impl App<'_> {
stdout_backpressure_skips: 0,
frame_timer,
input_running,
input_suspended,
enhanced_keys_supported,
non_enhanced_pressed_keys: HashSet::new(),
_debug: debug,
+2
View File
@@ -220,6 +220,8 @@ pub(crate) struct App<'a> {
pub(super) frame_timer: Arc<FrameTimer>,
/// Controls the input reader thread spawned at startup.
pub(super) input_running: Arc<AtomicBool>,
/// Temporarily pause input reader while external editor owns the terminal.
pub(super) input_suspended: Arc<AtomicBool>,
pub(super) enhanced_keys_supported: bool,
/// Tracks keys seen as pressed when keyboard enhancements are unavailable
+3
View File
@@ -169,6 +169,9 @@ pub(crate) enum AppEvent {
/// Text pasted from the terminal clipboard.
Paste(String),
/// Open the external editor with the current composer text.
OpenExternalEditor { initial: String },
/// Request to exit the application gracefully.
ExitRequest,
+14 -12
View File
@@ -934,6 +934,8 @@ impl ChatComposer {
if !text.is_empty() {
self.typed_anything = true;
}
self.sync_command_popup();
self.sync_file_search_popup();
}
pub(crate) fn insert_str(&mut self, text: &str) {
@@ -2447,18 +2449,18 @@ impl ChatComposer {
let mut include_tokens = !token_spans_full.is_empty() || !token_spans_compact.is_empty();
let mut token_use_compact = false;
// Guide hint (priority 4) — only when not auto-drive and not showing quit hint
let guide_spans: Vec<Span<'static>> = if !self.auto_drive_active && !self.ctrl_c_quit_hint {
// Editor hint (priority 4) — only when not auto-drive and not showing quit hint
let editor_spans: Vec<Span<'static>> = if !self.auto_drive_active && !self.ctrl_c_quit_hint {
vec![
Span::from("Ctrl+G").style(key_hint_style),
Span::from(" guide").style(label_style),
Span::from(" editor").style(label_style),
]
} else {
Vec::new()
};
let guide_present = !guide_spans.is_empty();
if guide_present {
right_sections.push((4, guide_spans, true));
let editor_present = !editor_spans.is_empty();
if editor_present {
right_sections.push((4, editor_spans, true));
}
// Tokens placeholder (actual spans chosen later)
@@ -2489,7 +2491,7 @@ impl ChatComposer {
right_sections.iter().any(|(p, _, inc)| *p == 3 && *inc);
let mut include_left_misc = true;
let mut include_ctrl_c = ctrl_c_present;
let mut include_guide = guide_present;
let mut include_editor = editor_present;
let mut include_right_other = true; // covers priority 7 sections
// helper closures to rebuild assembled spans based on current flags
@@ -2559,7 +2561,7 @@ impl ChatComposer {
include_tokens: bool,
use_compact_tokens: bool,
include_auto_review_agent_hint: bool,
include_guide: bool,
include_editor: bool,
include_right_other: bool,
| -> (Vec<Span<'static>>, usize) {
let mut assembled: Vec<Span<'static>> = Vec::new();
@@ -2569,7 +2571,7 @@ impl ChatComposer {
let include = match *priority {
1 => include_tokens && *included,
3 => include_auto_review_agent_hint && *included,
4 => include_guide && *included,
4 => include_editor && *included,
7 => include_right_other && *included,
_ => *included,
};
@@ -2618,7 +2620,7 @@ impl ChatComposer {
include_tokens,
token_use_compact,
include_auto_review_agent_hint,
include_guide,
include_editor,
include_right_other,
);
@@ -2656,7 +2658,7 @@ impl ChatComposer {
}
// Removal order: 7 (right other) -> 6 (left misc) -> 5 (Ctrl+A hint)
// -> 4 (guide) -> 3 (auto review status) -> 2 (Ctrl+C) -> 1 (tokens)
// -> 4 (editor) -> 3 (auto review status) -> 2 (Ctrl+C) -> 1 (tokens)
match removal_stage {
0 => {
include_right_other = false;
@@ -2668,7 +2670,7 @@ impl ChatComposer {
include_auto_review_agent_hint = false;
}
3 => {
include_guide = false;
include_editor = false;
}
4 => {
include_auto_review_status = false;
+5
View File
@@ -517,6 +517,11 @@ impl BottomPane<'_> {
self.request_redraw();
}
pub(crate) fn set_composer_text(&mut self, text: String) {
self.composer.set_text_content(text);
self.request_redraw();
}
/// Clear the composer text and reset transient composer state.
pub(crate) fn clear_composer(&mut self) {
self.composer.clear_text();
+42 -1
View File
@@ -5933,6 +5933,16 @@ impl ChatWidget<'_> {
self.bottom_pane.insert_str(s);
}
pub(crate) fn set_composer_text(&mut self, text: String) {
if self.auto_state.should_show_goal_entry()
&& matches!(self.auto_goal_escape_state, AutoGoalEscState::Inactive)
&& !text.trim().is_empty()
{
self.auto_goal_escape_state = AutoGoalEscState::NeedsEnableEditing;
}
self.bottom_pane.set_composer_text(text);
}
// Removed: pending insert sequencing is not used under strict ordering.
pub(crate) fn register_pasted_image(&mut self, placeholder: String, path: std::path::PathBuf) {
@@ -8027,6 +8037,21 @@ impl ChatWidget<'_> {
}
}
if let KeyEvent {
code: crossterm::event::KeyCode::Char('g'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} = key_event
{
if !self.bottom_pane.has_active_modal_view() {
let initial = self.bottom_pane.composer_text();
self.app_event_tx
.send(AppEvent::OpenExternalEditor { initial });
}
return;
}
// Fast-path PageUp/PageDown to scroll the transcript by a viewport at a time.
if let crossterm::event::KeyEvent {
code: crossterm::event::KeyCode::PageUp,
@@ -21833,7 +21858,8 @@ Have we met every part of this goal and is there no further work to do?"#
));
// Global
lines.push(kv("Ctrl+G", "Guide overlay"));
lines.push(kv("F1", "Help overlay"));
lines.push(kv("Ctrl+G", "Open external editor"));
lines.push(kv("Ctrl+R", "Toggle reasoning"));
lines.push(kv("Ctrl+T", "Toggle screen"));
lines.push(kv("Ctrl+D", "Diff viewer"));
@@ -29470,6 +29496,7 @@ impl Drop for AutoReviewStubGuard {
CAPTURE_AUTO_TURN_COMMIT_STUB,
GIT_DIFF_NAME_ONLY_BETWEEN_STUB,
};
use crate::app_event::AppEvent;
use crate::bottom_pane::AutoCoordinatorViewModel;
use crate::chatwidget::message::UserMessage;
use crate::chatwidget::smoke_helpers::{enter_test_runtime_guard, ChatWidgetHarness};
@@ -31247,6 +31274,20 @@ use code_core::protocol::OrderMeta;
assert_eq!(chat.bottom_pane.composer_text(), "x");
}
#[test]
fn ctrl_g_dispatches_external_editor_event() {
let mut harness = ChatWidgetHarness::new();
let key_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL);
harness.chat().handle_key_event(key_event);
let events = harness.drain_events();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::OpenExternalEditor { .. })),
"expected external editor request on Ctrl+G",
);
}
#[test]
fn goal_entry_esc_exits_immediately_without_suggestion() {
let mut harness = ChatWidgetHarness::new();
+4 -4
View File
@@ -5,9 +5,9 @@ use crossterm::event::{KeyCode, KeyEvent};
// Returns true if the key was handled by the guide overlay (or toggled it closed).
pub(super) fn handle_help_key(chat: &mut ChatWidget<'_>, key_event: KeyEvent) -> bool {
// If no guide overlay, only intercept Ctrl+G to open it.
// If no guide overlay, only intercept F1 to open it.
if chat.help.overlay.is_none() {
if let KeyEvent { code: KeyCode::Char('g'), modifiers: crossterm::event::KeyModifiers::CONTROL, .. } = key_event {
if let KeyEvent { code: KeyCode::F(1), .. } = key_event {
chat.toggle_help_popup();
return true;
}
@@ -56,8 +56,8 @@ pub(super) fn handle_help_key(chat: &mut ChatWidget<'_>, key_event: KeyEvent) ->
chat.request_redraw();
true
}
KeyCode::Esc | KeyCode::Char('g') => {
// Close on Esc or Ctrl+G
KeyCode::Esc | KeyCode::F(1) => {
// Close on Esc or F1
chat.help.overlay = None;
chat.request_redraw();
true
+102
View File
@@ -0,0 +1,102 @@
use std::env;
use std::fs;
use std::io::Write;
use std::process::Command;
use tempfile::Builder;
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum ExternalEditorError {
#[error("Set $VISUAL or $EDITOR to use the external editor.")]
MissingEditor,
#[error("Failed to parse editor command.")]
ParseFailed,
#[error("Editor command is empty.")]
EmptyCommand,
#[error("Failed to launch editor: {0}")]
LaunchFailed(String),
#[error("Editor exited with status {0}.")]
NonZeroExit(String),
#[error("Failed to read edited content: {0}")]
ReadFailed(String),
}
pub(crate) fn run_editor(initial: &str) -> Result<String, ExternalEditorError> {
let command = resolve_editor_command()?;
let (program, args) = command
.split_first()
.ok_or(ExternalEditorError::EmptyCommand)?;
let mut temp = Builder::new()
.prefix("code-edit-")
.suffix(".md")
.tempfile()
.map_err(|e| ExternalEditorError::ReadFailed(e.to_string()))?;
if !initial.is_empty() {
std::io::Write::write_all(&mut temp, initial.as_bytes())
.map_err(|e| ExternalEditorError::ReadFailed(e.to_string()))?;
}
temp.flush()
.map_err(|e| ExternalEditorError::ReadFailed(e.to_string()))?;
let temp_path = temp.into_temp_path();
let path = temp_path.to_path_buf();
let path_str = path.to_string_lossy().to_string();
let mut args = args.to_vec();
let mut replaced = false;
for arg in &mut args {
if arg.contains("{file}") {
*arg = arg.replace("{file}", &path_str);
replaced = true;
} else if arg == "{}" {
*arg = path_str.clone();
replaced = true;
}
}
if !replaced {
args.push(path_str.clone());
}
let status = Command::new(program)
.args(&args)
.status()
.map_err(|e| ExternalEditorError::LaunchFailed(e.to_string()))?;
if !status.success() {
return Err(ExternalEditorError::NonZeroExit(format!("{status:?}")));
}
fs::read_to_string(&path)
.map_err(|e| ExternalEditorError::ReadFailed(e.to_string()))
}
fn resolve_editor_command() -> Result<Vec<String>, ExternalEditorError> {
let visual = env::var("VISUAL").ok().filter(|val| !val.trim().is_empty());
let editor = env::var("EDITOR").ok().filter(|val| !val.trim().is_empty());
let raw = visual.or(editor).ok_or(ExternalEditorError::MissingEditor)?;
parse_editor_command(&raw)
}
fn parse_editor_command(raw: &str) -> Result<Vec<String>, ExternalEditorError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ExternalEditorError::EmptyCommand);
}
let parsed = shlex::split(trimmed).ok_or(ExternalEditorError::ParseFailed)?;
if parsed.is_empty() {
return Err(ExternalEditorError::EmptyCommand);
}
Ok(parsed)
}
#[cfg(test)]
mod tests {
use super::parse_editor_command;
#[test]
fn parse_editor_command_splits_args() {
let parsed = parse_editor_command("code --wait --reuse-window").expect("parse");
assert_eq!(parsed, vec!["code", "--wait", "--reuse-window"]);
}
}
+1
View File
@@ -64,6 +64,7 @@ mod colors;
pub mod card_theme;
mod diff_render;
mod exec_command;
mod external_editor;
mod file_search;
pub mod gradient_background;
mod get_git_diff;
@@ -189,7 +189,7 @@ fn assert_viewport_invariants(output: &str, expected_rows: u16) {
lines.len()
);
assert!(
output.contains("Ctrl+G guide"),
output.contains("Ctrl+G editor"),
"composer footer is expected to remain visible"
);
if let Some(last_line) = lines.last() {
@@ -23,4 +23,4 @@ expression: collapsed
| What can I code for you today? |
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -23,4 +23,4 @@ expression: expanded
| What can I code for you today? |
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -29,4 +29,4 @@ expression: output
| What can I code for you today? |
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -23,4 +23,4 @@ expression: output
| What can I code for you today? |
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -32,4 +32,4 @@ expression: output
+----------------------------- ✶ Responding... -----------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -20,4 +20,4 @@ expression: output
+----------------------------- ✶ Using tools... -----------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -32,4 +32,4 @@ expression: output
+----------------------------- ✶ Browsing... -----------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -32,4 +32,4 @@ expression: output
+----------------------------- ✶ Browsing... -----------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -24,4 +24,4 @@ expression: output
+----------------------------- ✶ Browsing... -----------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -20,4 +20,4 @@ expression: frame
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -18,4 +18,4 @@ expression: output
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -18,4 +18,4 @@ expression: output
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -18,4 +18,4 @@ expression: output
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -18,4 +18,4 @@ expression: output
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -24,4 +24,4 @@ expression: output
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -24,4 +24,4 @@ expression: output
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -24,4 +24,4 @@ expression: scrolled
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -28,4 +28,4 @@ expression: closed
+------------------------------------------------------------------------------------------------+
| What can I code for you today? |
+------------------------------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -28,4 +28,4 @@ expression: open
+------------------------------------------------------------------------------------------------+
| What can I code for you today? |
+------------------------------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -28,4 +28,4 @@ expression: section_closed
+------------------------------------------------------------------------------------------------+
| What can I code for you today? |
+------------------------------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -28,4 +28,4 @@ expression: section_open
+------------------------------------------------------------------------------------------------+
| What can I code for you today? |
+------------------------------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -28,4 +28,4 @@ expression: output
+------------------------------------------------------------------------------------------------+
| What can I code for you today? |
+------------------------------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -20,4 +20,4 @@ expression: output
+--------------------------------------------------------+
| What can I code for you today? |
+--------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -22,4 +22,4 @@ expression: output
+------------------------------------------------------------------------------------------------+
| What can I code for you today? |
+------------------------------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -24,4 +24,4 @@ expression: output
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -12,4 +12,4 @@ expression: output
+----------------------------------------------------------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key
@@ -40,4 +40,4 @@ expression: output
+----------------------------- ✶ Agents... -----------------------------+
| What can I code for you today? |
+----------------------------------------------------------------------------+
Read Only (Shift+Tab change) Ctrl+G guide • API key
Read Only (Shift+Tab change) Ctrl+G editor • API key