Add local image thumbnails to the TUI composer (#58261)

## What changed

- Show draft attachment thumbnails in reference order in inline and fullscreen views on supported terminals with local workspaces. Display a `+N` count for attachments that do not fit, and preserve editable image references when previews are unavailable or space is limited.
- Refresh previews when attached files change, apply image orientation metadata, and clear frame graphics before terminal handoffs, screen switches, and shutdown.
- Finish CLI subcommand dispatch before polling interactive TUI events.

## Testing

Add a regression test and snapshot covering thumbnail order, overflow, resizing, attachment deletion, file replacement, and fallback behavior in both inline and fullscreen views.

GitOrigin-RevId: 1ea7c89e089cecc390e145c1790b6ede8bad8d5e
This commit is contained in:
Eric Traut
2026-10-30 06:37:32 +00:00
committed by copyberry
parent d5cbb90b72
commit 8b5ce2fd61
17 changed files with 496 additions and 76 deletions
+35 -37
View File
@@ -1036,16 +1036,18 @@ fn main() -> anyhow::Result<()> {
codex_build_info::initialize!();
let remote_control_disabled = codex_app_server::take_remote_control_disabled_env();
arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move {
// Keep the CLI dispatcher off the runtime's stack while the TUI rebuilds a thread.
Box::pin(cli_main(arg0_paths, remote_control_disabled)).await?;
// Finish polling the large subcommand dispatcher before polling interactive TUI events.
if let Some(tui) = Box::pin(dispatch_cli(arg0_paths, remote_control_disabled)).await? {
tui.await?;
}
Ok(())
})
}
async fn cli_main(
async fn dispatch_cli(
arg0_paths: Arg0DispatchPaths,
remote_control_disabled: bool,
) -> anyhow::Result<()> {
) -> anyhow::Result<Option<impl Future<Output = anyhow::Result<()>>>> {
let MultitoolCli {
config_overrides: mut root_config_overrides,
feature_toggles,
@@ -1097,6 +1099,11 @@ async fn cli_main(
profile_v2_for_subcommand(&interactive, subcommand)?;
}
let launch_tui = move |interactive, remote, auth_token_env, arg0_paths| async move {
let exit_info =
run_interactive_tui(interactive, remote, auth_token_env, arg0_paths).await?;
handle_app_exit(exit_info, daemon_cli_executable.as_deref())
};
let open_agents_overview = matches!(&subcommand, Some(Subcommand::Agents(_)));
match subcommand {
None | Some(Subcommand::Agents(_)) => {
@@ -1129,17 +1136,15 @@ async fn cli_main(
}
interactive.agents_overview = true;
}
let exit_info = run_interactive_tui(
return Ok(Some(launch_tui(
interactive,
root_remote.clone(),
root_remote_auth_token_env.clone(),
arg0_paths.clone(),
)
.await?;
handle_app_exit(exit_info, daemon_cli_executable.as_deref())?;
root_remote,
root_remote_auth_token_env,
arg0_paths,
)));
}
Some(Subcommand::TcpTunnel(args)) => {
return codex_tcp_tunnel::run(args).await;
codex_tcp_tunnel::run(args).await?;
}
Some(Subcommand::Exec(mut exec_cli)) => {
reject_remote_mode_for_subcommand(
@@ -1252,15 +1257,15 @@ async fn cli_main(
subcommand.is_none(),
"--print-startup-config cannot run a subcommand"
);
return codex_app_server::print_startup_config(
codex_app_server::print_startup_config(
arg0_paths,
root_config_overrides,
LoaderOverrides::default(),
strict_config,
startup_config,
)
.await
.map_err(Into::into);
.await?;
return Ok(None);
}
match subcommand {
None => {
@@ -1405,7 +1410,7 @@ async fn cli_main(
AppServerDaemonSubcommand::PidUpdateLoop {
check_package_ownership: true,
..
} => return Ok(()),
} => return Ok(None),
AppServerDaemonSubcommand::Update {
from_cli: false, ..
}
@@ -1528,16 +1533,12 @@ async fn cli_main(
include_non_interactive,
config_overrides,
);
let exit_info = run_interactive_tui(
return Ok(Some(launch_tui(
interactive,
remote.remote.or(root_remote.clone()),
remote
.remote_auth_token_env
.or(root_remote_auth_token_env.clone()),
arg0_paths.clone(),
)
.await?;
handle_app_exit(exit_info, daemon_cli_executable.as_deref())?;
remote.remote.or(root_remote),
remote.remote_auth_token_env.or(root_remote_auth_token_env),
arg0_paths,
)));
}
Some(Subcommand::Archive(cmd)) => {
let output = run_session_archive_cli_command(
@@ -1628,16 +1629,12 @@ async fn cli_main(
all,
config_overrides,
);
let exit_info = run_interactive_tui(
return Ok(Some(launch_tui(
interactive,
remote.remote.or(root_remote.clone()),
remote
.remote_auth_token_env
.or(root_remote_auth_token_env.clone()),
arg0_paths.clone(),
)
.await?;
handle_app_exit(exit_info, daemon_cli_executable.as_deref())?;
remote.remote.or(root_remote),
remote.remote_auth_token_env.or(root_remote_auth_token_env),
arg0_paths,
)));
}
Some(Subcommand::Login(mut login_cli)) => {
reject_remote_mode_for_subcommand(
@@ -1762,7 +1759,7 @@ async fn cli_main(
.parse_overrides()
.map_err(anyhow::Error::msg)?;
sandbox_setup::run(setup_cli, config_profile.cloned(), cli_overrides).await?;
return Ok(());
return Ok(None);
}
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
@@ -1770,7 +1767,8 @@ async fn cli_main(
"sandbox",
)?;
if let Some(SandboxSubcommand::Uninstall(command)) = subcommand {
return command.run();
command.run()?;
return Ok(None);
}
let loader_overrides = loader_overrides_for_profile(config_profile)?;
#[cfg(target_os = "macos")]
@@ -1950,7 +1948,7 @@ async fn cli_main(
},
}
Ok(())
Ok(None)
}
fn profile_v2_for_subcommand<'a>(