tui: remove panel direct pod send
This commit is contained in:
parent
9717d64458
commit
393cde9259
|
|
@ -11,7 +11,7 @@ use client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
|
||||||
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
||||||
use pod_store::FsPodStore;
|
use pod_store::FsPodStore;
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
use protocol::{ErrorCode, Event, InvokeKind, Method, PodStatus, Segment};
|
use protocol::{ErrorCode, Event, Method, PodStatus, Segment};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
|
@ -142,14 +142,6 @@ pub(crate) async fn run(
|
||||||
app.notice = Some("Refresh already in progress.".to_string());
|
app.notice = Some("Refresh already in progress.".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MultiPodAction::Send(request) => {
|
|
||||||
pending_reload.abort();
|
|
||||||
terminal.draw(|f| draw(f, app))?;
|
|
||||||
let result = send_run_and_confirm(&request.socket_path, request.segments).await;
|
|
||||||
app.finish_send(result);
|
|
||||||
app.reload_or_notice().await;
|
|
||||||
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
|
||||||
}
|
|
||||||
MultiPodAction::DispatchTicketAction(request) => {
|
MultiPodAction::DispatchTicketAction(request) => {
|
||||||
pending_reload.abort();
|
pending_reload.abort();
|
||||||
terminal.draw(|f| draw(f, app))?;
|
terminal.draw(|f| draw(f, app))?;
|
||||||
|
|
@ -256,17 +248,11 @@ fn default_pod_store_dir() -> Result<PathBuf, MultiPodError> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum SendEligibility {
|
pub(crate) enum OpenEligibility {
|
||||||
SendNow,
|
OpenNow,
|
||||||
Disabled,
|
Disabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub(crate) struct DirectSendRequest {
|
|
||||||
socket_path: PathBuf,
|
|
||||||
segments: Vec<Segment>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct IntakeLaunchRequest {
|
pub(crate) struct IntakeLaunchRequest {
|
||||||
context: TicketRoleLaunchContext,
|
context: TicketRoleLaunchContext,
|
||||||
|
|
@ -459,14 +445,14 @@ impl MultiPodApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn selected_send_eligibility(&self) -> SendEligibility {
|
pub(crate) fn selected_open_eligibility(&self) -> OpenEligibility {
|
||||||
match self.selected_pod_entry() {
|
match self.selected_pod_entry() {
|
||||||
Some(entry) if entry.actions.can_send_now => SendEligibility::SendNow,
|
Some(entry) if entry.actions.can_open => OpenEligibility::OpenNow,
|
||||||
_ => SendEligibility::Disabled,
|
_ => OpenEligibility::Disabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn selected_send_disabled_reason(&self) -> Option<String> {
|
pub(crate) fn selected_open_disabled_reason(&self) -> Option<String> {
|
||||||
if let Some(row) = self
|
if let Some(row) = self
|
||||||
.selected_panel_row()
|
.selected_panel_row()
|
||||||
.filter(|row| row.is_ticket_action())
|
.filter(|row| row.is_ticket_action())
|
||||||
|
|
@ -476,16 +462,16 @@ impl MultiPodApp {
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| row.key_hint.clone())
|
.or_else(|| row.key_hint.clone())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
"Press Enter to dispatch this Ticket action; stale Tickets are re-checked before any mutation."
|
"Empty Enter dispatches this Ticket action; stale Tickets are re-checked before any mutation."
|
||||||
.to_string()
|
.to_string()
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let entry = self.selected_pod_entry()?;
|
let entry = self.selected_pod_entry()?;
|
||||||
if entry.actions.can_send_now {
|
if entry.actions.can_open {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(send_disabled_reason(entry))
|
Some(open_disabled_reason(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn select_next(&mut self) {
|
pub(crate) fn select_next(&mut self) {
|
||||||
|
|
@ -644,53 +630,16 @@ impl MultiPodApp {
|
||||||
segments_are_blank(&self.input.submit_segments())
|
segments_are_blank(&self.input.submit_segments())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prepare_send(&mut self) -> Option<DirectSendRequest> {
|
pub(crate) fn reject_companion_submit(&mut self) {
|
||||||
let (target_name, socket_path) = {
|
|
||||||
let entry = match self.selected_pod_entry() {
|
|
||||||
Some(entry) => entry,
|
|
||||||
None => {
|
|
||||||
self.notice = Some(selected_ticket_notice(self.selected_panel_row()));
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !entry.actions.can_send_now {
|
|
||||||
self.notice = Some(send_disabled_reason(entry));
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let Some(socket_path) = entry.attach_socket_path().map(PathBuf::from) else {
|
|
||||||
self.notice = Some("Selected Pod has no reachable socket.".to_string());
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
(entry.name.clone(), socket_path)
|
|
||||||
};
|
|
||||||
let segments = self.input.submit_segments();
|
let segments = self.input.submit_segments();
|
||||||
if segments_are_blank(&segments) {
|
if segments_are_blank(&segments) {
|
||||||
self.notice = Some("Composer is empty.".to_string());
|
self.notice = Some("Composer is empty.".to_string());
|
||||||
return None;
|
return;
|
||||||
}
|
}
|
||||||
self.sending = true;
|
|
||||||
self.notice = Some(format!("Sending to {target_name}…"));
|
|
||||||
Some(DirectSendRequest {
|
|
||||||
socket_path,
|
|
||||||
segments,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finish_send(&mut self, result: Result<(), DirectSendError>) {
|
|
||||||
self.sending = false;
|
self.sending = false;
|
||||||
match result {
|
self.notice = Some(bounded_panel_diagnostic(
|
||||||
Ok(()) => {
|
"Companion composer is not wired to a Companion Pod yet; draft kept. Press o or empty Enter to open/attach the selected Pod.",
|
||||||
let target = self
|
));
|
||||||
.selected_pod_entry()
|
|
||||||
.map(|entry| entry.name.clone())
|
|
||||||
.unwrap_or_else(|| "selected Pod".to_string());
|
|
||||||
self.input.clear();
|
|
||||||
self.notice = Some(format!("Delivered to {target}."));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.notice = Some(format!("Delivery failed; composer kept: {e}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prepare_ticket_action_dispatch(&mut self) -> Option<TicketActionRequest> {
|
pub(crate) fn prepare_ticket_action_dispatch(&mut self) -> Option<TicketActionRequest> {
|
||||||
|
|
@ -867,10 +816,10 @@ impl MultiPodApp {
|
||||||
.map(MultiPodAction::LaunchIntake)
|
.map(MultiPodAction::LaunchIntake)
|
||||||
.unwrap_or(MultiPodAction::None),
|
.unwrap_or(MultiPodAction::None),
|
||||||
KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open,
|
KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open,
|
||||||
KeyCode::Enter => self
|
KeyCode::Enter => {
|
||||||
.prepare_send()
|
self.reject_companion_submit();
|
||||||
.map(MultiPodAction::Send)
|
MultiPodAction::None
|
||||||
.unwrap_or(MultiPodAction::None),
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
self.input.delete_before();
|
self.input.delete_before();
|
||||||
MultiPodAction::None
|
MultiPodAction::None
|
||||||
|
|
@ -909,7 +858,6 @@ enum MultiPodAction {
|
||||||
Quit,
|
Quit,
|
||||||
Open,
|
Open,
|
||||||
Refresh,
|
Refresh,
|
||||||
Send(DirectSendRequest),
|
|
||||||
DispatchTicketAction(TicketActionRequest),
|
DispatchTicketAction(TicketActionRequest),
|
||||||
LaunchIntake(IntakeLaunchRequest),
|
LaunchIntake(IntakeLaunchRequest),
|
||||||
}
|
}
|
||||||
|
|
@ -1361,7 +1309,6 @@ async fn dispatch_ticket_action(
|
||||||
NextUserAction::Clarify
|
NextUserAction::Clarify
|
||||||
| NextUserAction::Edit
|
| NextUserAction::Edit
|
||||||
| NextUserAction::OpenPod
|
| NextUserAction::OpenPod
|
||||||
| NextUserAction::SendToPod
|
|
||||||
| NextUserAction::Wait => Ok(TicketActionOutcome {
|
| NextUserAction::Wait => Ok(TicketActionOutcome {
|
||||||
notice: format!(
|
notice: format!(
|
||||||
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
|
"{} for Ticket {} has no safe inline workspace-panel dispatch; use the Ticket workflow.",
|
||||||
|
|
@ -1417,11 +1364,11 @@ async fn notify_workspace_orchestrator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_notify_only(socket: &Path, message: String) -> Result<(), DirectSendError> {
|
async fn send_notify_only(socket: &Path, message: String) -> Result<(), NotifySendError> {
|
||||||
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket))
|
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| DirectSendError::Io("connect timed out".into()))?
|
.map_err(|_| NotifySendError::Io("connect timed out".into()))?
|
||||||
.map_err(|e| DirectSendError::Io(format!("connect: {e}")))?;
|
.map_err(|e| NotifySendError::Io(format!("connect: {e}")))?;
|
||||||
let (reader, writer) = stream.into_split();
|
let (reader, writer) = stream.into_split();
|
||||||
let mut reader = JsonLineReader::new(reader);
|
let mut reader = JsonLineReader::new(reader);
|
||||||
let mut writer = JsonLineWriter::new(writer);
|
let mut writer = JsonLineWriter::new(writer);
|
||||||
|
|
@ -1429,17 +1376,17 @@ async fn send_notify_only(socket: &Path, message: String) -> Result<(), DirectSe
|
||||||
loop {
|
loop {
|
||||||
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| DirectSendError::Io("read initial Snapshot timed out".into()))?
|
.map_err(|_| NotifySendError::Io("read initial Snapshot timed out".into()))?
|
||||||
.map_err(|e| DirectSendError::Io(format!("read initial Snapshot: {e}")))?;
|
.map_err(|e| NotifySendError::Io(format!("read initial Snapshot: {e}")))?;
|
||||||
match event {
|
match event {
|
||||||
Some(Event::Snapshot { .. }) => break,
|
Some(Event::Snapshot { .. }) => break,
|
||||||
Some(Event::Alert(_)) => continue,
|
Some(Event::Alert(_)) => continue,
|
||||||
Some(Event::Error { code, message }) => {
|
Some(Event::Error { code, message }) => {
|
||||||
return Err(DirectSendError::Rejected { code, message });
|
return Err(NotifySendError::Rejected { code, message });
|
||||||
}
|
}
|
||||||
Some(_) => continue,
|
Some(_) => continue,
|
||||||
None => {
|
None => {
|
||||||
return Err(DirectSendError::Io(
|
return Err(NotifySendError::Io(
|
||||||
"connection closed before initial Snapshot".into(),
|
"connection closed before initial Snapshot".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -1448,96 +1395,28 @@ async fn send_notify_only(socket: &Path, message: String) -> Result<(), DirectSe
|
||||||
|
|
||||||
tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(&Method::Notify { message }))
|
tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(&Method::Notify { message }))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| DirectSendError::Io("write timed out".into()))?
|
.map_err(|_| NotifySendError::Io("write timed out".into()))?
|
||||||
.map_err(|e| DirectSendError::Io(format!("write: {e}")))
|
.map_err(|e| NotifySendError::Io(format!("write: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) enum DirectSendError {
|
pub(crate) enum NotifySendError {
|
||||||
AlreadyRunning,
|
|
||||||
Rejected { code: ErrorCode, message: String },
|
Rejected { code: ErrorCode, message: String },
|
||||||
Io(String),
|
Io(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for DirectSendError {
|
impl std::fmt::Display for NotifySendError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::AlreadyRunning => write!(f, "target Pod is already running"),
|
|
||||||
Self::Rejected { code, message } => {
|
Self::Rejected { code, message } => {
|
||||||
write!(f, "target rejected run ({code:?}): {message}")
|
write!(f, "target rejected method ({code:?}): {message}")
|
||||||
}
|
}
|
||||||
Self::Io(message) => write!(f, "{message}"),
|
Self::Io(message) => write!(f, "{message}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for DirectSendError {}
|
impl std::error::Error for NotifySendError {}
|
||||||
|
|
||||||
async fn send_run_and_confirm(socket: &Path, input: Vec<Segment>) -> Result<(), DirectSendError> {
|
|
||||||
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(socket))
|
|
||||||
.await
|
|
||||||
.map_err(|_| DirectSendError::Io("connect timed out".into()))?
|
|
||||||
.map_err(|e| DirectSendError::Io(format!("connect: {e}")))?;
|
|
||||||
let (reader, writer) = stream.into_split();
|
|
||||||
let mut reader = JsonLineReader::new(reader);
|
|
||||||
let mut writer = JsonLineWriter::new(writer);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
|
||||||
.await
|
|
||||||
.map_err(|_| DirectSendError::Io("read initial Snapshot timed out".into()))?
|
|
||||||
.map_err(|e| DirectSendError::Io(format!("read initial Snapshot: {e}")))?;
|
|
||||||
match event {
|
|
||||||
Some(Event::Snapshot { .. }) => break,
|
|
||||||
Some(Event::Alert(_)) => continue,
|
|
||||||
Some(Event::Error {
|
|
||||||
code: ErrorCode::AlreadyRunning,
|
|
||||||
..
|
|
||||||
}) => return Err(DirectSendError::AlreadyRunning),
|
|
||||||
Some(Event::Error { code, message }) => {
|
|
||||||
return Err(DirectSendError::Rejected { code, message });
|
|
||||||
}
|
|
||||||
Some(_) => continue,
|
|
||||||
None => {
|
|
||||||
return Err(DirectSendError::Io(
|
|
||||||
"connection closed before initial Snapshot".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(&Method::Run { input }))
|
|
||||||
.await
|
|
||||||
.map_err(|_| DirectSendError::Io("write timed out".into()))?
|
|
||||||
.map_err(|e| DirectSendError::Io(format!("write: {e}")))?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
|
||||||
.await
|
|
||||||
.map_err(|_| DirectSendError::Io("read response timed out".into()))?
|
|
||||||
.map_err(|e| DirectSendError::Io(format!("read response: {e}")))?;
|
|
||||||
match event {
|
|
||||||
Some(Event::Error {
|
|
||||||
code: ErrorCode::AlreadyRunning,
|
|
||||||
..
|
|
||||||
}) => return Err(DirectSendError::AlreadyRunning),
|
|
||||||
Some(Event::Error { code, message }) => {
|
|
||||||
return Err(DirectSendError::Rejected { code, message });
|
|
||||||
}
|
|
||||||
Some(Event::InvokeStart {
|
|
||||||
kind: InvokeKind::UserSend,
|
|
||||||
})
|
|
||||||
| Some(Event::UserMessage { .. })
|
|
||||||
| Some(Event::TurnStart { .. }) => return Ok(()),
|
|
||||||
Some(_) => continue,
|
|
||||||
None => {
|
|
||||||
return Err(DirectSendError::Io(
|
|
||||||
"connection closed before response".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn segments_are_blank(segments: &[Segment]) -> bool {
|
fn segments_are_blank(segments: &[Segment]) -> bool {
|
||||||
segments.iter().all(|segment| match segment {
|
segments.iter().all(|segment| match segment {
|
||||||
|
|
@ -1546,31 +1425,31 @@ fn segments_are_blank(segments: &[Segment]) -> bool {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_disabled_reason(entry: &PodListEntry) -> String {
|
fn open_disabled_reason(entry: &PodListEntry) -> String {
|
||||||
if let Some(live) = entry.live.as_ref() {
|
if let Some(live) = entry.live.as_ref() {
|
||||||
if !live.reachable {
|
if !live.reachable {
|
||||||
return "Selected live Pod is unreachable.".to_string();
|
return "Selected live Pod is unreachable.".to_string();
|
||||||
}
|
}
|
||||||
return match live.status {
|
return match live.status {
|
||||||
Some(PodStatus::Running) => {
|
Some(PodStatus::Running) => {
|
||||||
"Selected Pod is running; direct send is disabled in multi-Pod view.".to_string()
|
"Selected Pod is running; press o or empty Enter to open/attach.".to_string()
|
||||||
}
|
}
|
||||||
Some(PodStatus::Paused) => {
|
Some(PodStatus::Paused) => {
|
||||||
"Selected Pod is paused; open it explicitly to resume or start a new turn."
|
"Selected Pod is paused; open it explicitly to resume or start a new turn."
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
Some(PodStatus::Idle) => "Selected Pod is not send-eligible.".to_string(),
|
Some(PodStatus::Idle) => "Selected Pod can be opened/attached.".to_string(),
|
||||||
None => "Selected Pod did not report a live status.".to_string(),
|
None => "Selected Pod did not report a live status.".to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if entry.stored.is_some() {
|
if entry.stored.is_some() {
|
||||||
return "Selected Pod is stopped; open/restore it before sending.".to_string();
|
return "Selected Pod is stopped; press o or empty Enter to restore/open.".to_string();
|
||||||
}
|
}
|
||||||
entry
|
entry
|
||||||
.actions
|
.actions
|
||||||
.disabled_reason
|
.disabled_reason
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "Selected Pod is not send-eligible.".to_string())
|
.unwrap_or_else(|| "Selected Pod cannot be opened from this row.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
|
fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
|
||||||
|
|
@ -1813,11 +1692,11 @@ fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
.composer
|
.composer
|
||||||
.is_available(ComposerTarget::TicketIntake)
|
.is_available(ComposerTarget::TicketIntake)
|
||||||
{
|
{
|
||||||
" Enter dispatches selected Ticket action · Ctrl+T target · o open/attach · r refresh"
|
" Empty Enter dispatches selected Ticket action · Ctrl+T target · o/empty Enter open/attach · r refresh"
|
||||||
} else if app.panel.header.ticket_configured {
|
} else if app.panel.header.ticket_configured {
|
||||||
" Enter dispatches selected Ticket action · Enter sends to selected idle Pod when no Ticket action is selected · o open/attach · r refresh"
|
" Empty Enter dispatches selected Ticket action · o/empty Enter open/attach Pod · r refresh"
|
||||||
} else {
|
} else {
|
||||||
" Pod-centric view · Enter sends to selected idle Pod · o open/attach · r refresh"
|
" Pod-centric view · o/empty Enter open/attach selected Pod · r refresh"
|
||||||
};
|
};
|
||||||
let mut spans = vec![
|
let mut spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
|
|
@ -2199,15 +2078,16 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
let left = if app.sending && app.composer_target() == ComposerTarget::TicketIntake {
|
let left = if app.sending && app.composer_target() == ComposerTarget::TicketIntake {
|
||||||
"launching Ticket Intake…".to_string()
|
"launching Ticket Intake…".to_string()
|
||||||
} else if app.sending {
|
} else if app.sending {
|
||||||
"sending…".to_string()
|
"working…".to_string()
|
||||||
} else if let Some(notice) = app.notice.as_deref() {
|
} else if let Some(notice) = app.notice.as_deref() {
|
||||||
notice.to_string()
|
notice.to_string()
|
||||||
} else if let Some(reason) = app.selected_send_disabled_reason() {
|
} else if let Some(reason) = app.selected_open_disabled_reason() {
|
||||||
reason
|
reason
|
||||||
} else {
|
} else {
|
||||||
match app.composer_target() {
|
match app.composer_target() {
|
||||||
ComposerTarget::Companion => {
|
ComposerTarget::Companion => {
|
||||||
"idle live target: Enter sends directly without opening conversation".to_string()
|
"Companion target pending; non-empty Enter keeps draft and reports a diagnostic"
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
ComposerTarget::TicketIntake => {
|
ComposerTarget::TicketIntake => {
|
||||||
"Ticket Intake target: Enter launches Intake with composer text".to_string()
|
"Ticket Intake target: Enter launches Intake with composer text".to_string()
|
||||||
|
|
@ -2219,9 +2099,9 @@ fn draw_actionbar(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
||||||
.composer
|
.composer
|
||||||
.is_available(ComposerTarget::TicketIntake)
|
.is_available(ComposerTarget::TicketIntake)
|
||||||
{
|
{
|
||||||
"↑/↓ select Enter use target Ctrl+T target o open r refresh Esc quit"
|
"↑/↓ select Empty Enter target/open Ctrl+T target o open r refresh Esc quit"
|
||||||
} else {
|
} else {
|
||||||
"↑/↓ select Enter send o open r refresh Esc quit"
|
"↑/↓ select Empty Enter open non-empty Enter diagnose o open r refresh Esc quit"
|
||||||
};
|
};
|
||||||
let left_width = area
|
let left_width = area
|
||||||
.width
|
.width
|
||||||
|
|
@ -2561,7 +2441,7 @@ mod tests {
|
||||||
let mut app = app_with_panel(list, panel);
|
let mut app = app_with_panel(list, panel);
|
||||||
|
|
||||||
assert_eq!(app.selected_panel_row().unwrap().title, "Needs Human Reply");
|
assert_eq!(app.selected_panel_row().unwrap().title, "Needs Human Reply");
|
||||||
assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
|
||||||
let lines = list_lines(&app, 100, 6)
|
let lines = list_lines(&app, 100, 6)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|line| plain_line(&line))
|
.map(|line| plain_line(&line))
|
||||||
|
|
@ -2575,20 +2455,23 @@ mod tests {
|
||||||
|
|
||||||
app.select_next();
|
app.select_next();
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "idle");
|
assert_eq!(app.list.selected_entry().unwrap().name, "idle");
|
||||||
assert_eq!(app.selected_send_eligibility(), SendEligibility::SendNow);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
||||||
let open = app.prepare_open().unwrap();
|
let open = app.prepare_open().unwrap();
|
||||||
assert_eq!(open.pod_name, "idle");
|
assert_eq!(open.pod_name, "idle");
|
||||||
assert_eq!(open.socket_override, Some(PathBuf::from("/tmp/idle.sock")));
|
assert_eq!(open.socket_override, Some(PathBuf::from("/tmp/idle.sock")));
|
||||||
|
|
||||||
app.input.insert_str("send after ticket row");
|
app.input.insert_str("draft after ticket row");
|
||||||
let request = match app.handle_key(key(KeyCode::Enter)) {
|
assert!(matches!(
|
||||||
MultiPodAction::Send(request) => request,
|
app.handle_key(key(KeyCode::Enter)),
|
||||||
_ => panic!("Pod row should preserve direct send behavior"),
|
MultiPodAction::None
|
||||||
};
|
));
|
||||||
assert_eq!(request.socket_path, PathBuf::from("/tmp/idle.sock"));
|
assert!(!app.sending);
|
||||||
assert_eq!(
|
assert_eq!(input_text(&app), "draft after ticket row");
|
||||||
Segment::flatten_to_text(&request.segments),
|
assert!(
|
||||||
"send after ticket row"
|
app.notice
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Companion composer is not wired")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2714,11 +2597,11 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_idle_live_selected_target_is_send_eligible() {
|
fn multi_idle_live_selected_target_is_open_eligible() {
|
||||||
let app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
let app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
|
||||||
assert_eq!(app.selected_send_eligibility(), SendEligibility::SendNow);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
||||||
assert!(app.selected_send_disabled_reason().is_none());
|
assert!(app.selected_open_disabled_reason().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2860,7 +2743,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_running_paused_and_stopped_targets_are_direct_send_disabled() {
|
fn multi_running_paused_and_stopped_targets_are_open_eligible() {
|
||||||
let mut app = test_app(vec![
|
let mut app = test_app(vec![
|
||||||
live_info("running", PodStatus::Running),
|
live_info("running", PodStatus::Running),
|
||||||
live_info("paused", PodStatus::Paused),
|
live_info("paused", PodStatus::Paused),
|
||||||
|
|
@ -2879,28 +2762,16 @@ mod tests {
|
||||||
app.selected_row = None;
|
app.selected_row = None;
|
||||||
app.ensure_selection_visible();
|
app.ensure_selection_visible();
|
||||||
|
|
||||||
assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
||||||
assert!(
|
assert!(app.selected_open_disabled_reason().is_none());
|
||||||
app.selected_send_disabled_reason()
|
|
||||||
.unwrap()
|
|
||||||
.contains("running")
|
|
||||||
);
|
|
||||||
app.select_next();
|
app.select_next();
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "paused");
|
assert_eq!(app.list.selected_entry().unwrap().name, "paused");
|
||||||
assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
||||||
assert!(
|
assert!(app.selected_open_disabled_reason().is_none());
|
||||||
app.selected_send_disabled_reason()
|
|
||||||
.unwrap()
|
|
||||||
.contains("paused")
|
|
||||||
);
|
|
||||||
app.select_next();
|
app.select_next();
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "stopped");
|
assert_eq!(app.list.selected_entry().unwrap().name, "stopped");
|
||||||
assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
||||||
assert!(
|
assert!(app.selected_open_disabled_reason().is_none());
|
||||||
app.selected_send_disabled_reason()
|
|
||||||
.unwrap()
|
|
||||||
.contains("stopped")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -3004,7 +2875,7 @@ mod tests {
|
||||||
|
|
||||||
assert!(app.selected_row.is_none());
|
assert!(app.selected_row.is_none());
|
||||||
assert!(app.list.selected_name.is_none());
|
assert!(app.list.selected_name.is_none());
|
||||||
assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled);
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -3097,26 +2968,32 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_delivery_failure_keeps_composer_contents() {
|
fn multi_companion_submit_keeps_composer_contents() {
|
||||||
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
app.input.insert_str("keep me");
|
app.input.insert_str("keep me");
|
||||||
let before = input_text(&app);
|
let before = input_text(&app);
|
||||||
|
|
||||||
app.finish_send(Err(DirectSendError::Io("boom".to_string())));
|
app.reject_companion_submit();
|
||||||
|
|
||||||
assert_eq!(input_text(&app), before);
|
assert_eq!(input_text(&app), before);
|
||||||
assert!(app.notice.as_deref().unwrap().contains("composer kept"));
|
assert!(!app.sending);
|
||||||
|
assert!(
|
||||||
|
app.notice
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Companion composer is not wired")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_delivery_success_clears_composer_contents() {
|
fn multi_companion_submit_empty_reports_empty_composer() {
|
||||||
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
app.input.insert_str("send me");
|
|
||||||
|
|
||||||
app.finish_send(Ok(()));
|
app.reject_companion_submit();
|
||||||
|
|
||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
assert!(app.notice.as_deref().unwrap().contains("Delivered"));
|
assert!(!app.sending);
|
||||||
|
assert_eq!(app.notice.as_deref(), Some("Composer is empty."));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -3201,19 +3078,23 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_non_empty_enter_uses_direct_send_action() {
|
fn multi_non_empty_enter_reports_companion_unavailable() {
|
||||||
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
app.input.insert_str("send me");
|
app.input.insert_str("keep this draft");
|
||||||
|
|
||||||
let request = match app.handle_key(key(KeyCode::Enter)) {
|
assert!(matches!(
|
||||||
MultiPodAction::Send(request) => request,
|
app.handle_key(key(KeyCode::Enter)),
|
||||||
_ => panic!("non-empty Enter should direct-send"),
|
MultiPodAction::None
|
||||||
};
|
));
|
||||||
|
|
||||||
assert_eq!(request.socket_path, PathBuf::from("/tmp/idle.sock"));
|
assert_eq!(input_text(&app), "keep this draft");
|
||||||
assert_eq!(Segment::flatten_to_text(&request.segments), "send me");
|
assert!(!app.sending);
|
||||||
assert!(app.sending);
|
assert!(
|
||||||
assert!(app.notice.as_deref().unwrap().contains("Sending to idle"));
|
app.notice
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Companion composer is not wired")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -3279,7 +3160,6 @@ mod tests {
|
||||||
|
|
||||||
let request = match app.handle_key(key(KeyCode::Enter)) {
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
||||||
MultiPodAction::LaunchIntake(request) => request,
|
MultiPodAction::LaunchIntake(request) => request,
|
||||||
MultiPodAction::Send(_) => panic!("Ticket Intake target must not direct-send"),
|
|
||||||
_ => panic!("Ticket Intake target should launch Intake"),
|
_ => panic!("Ticket Intake target should launch Intake"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,6 @@ pub(crate) enum NextUserAction {
|
||||||
Edit,
|
Edit,
|
||||||
Wait,
|
Wait,
|
||||||
OpenPod,
|
OpenPod,
|
||||||
SendToPod,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NextUserAction {
|
impl NextUserAction {
|
||||||
|
|
@ -179,7 +178,6 @@ impl NextUserAction {
|
||||||
Self::Edit => "Edit",
|
Self::Edit => "Edit",
|
||||||
Self::Wait => "Wait",
|
Self::Wait => "Wait",
|
||||||
Self::OpenPod => "Open",
|
Self::OpenPod => "Open",
|
||||||
Self::SendToPod => "Send",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -652,9 +650,7 @@ fn pod_rows(pods: &PodList) -> Vec<PanelRow> {
|
||||||
|
|
||||||
fn pod_row(entry: &PodListEntry) -> PanelRow {
|
fn pod_row(entry: &PodListEntry) -> PanelRow {
|
||||||
let status = pod_status_label(entry).to_string();
|
let status = pod_status_label(entry).to_string();
|
||||||
let next_action = if entry.actions.can_send_now {
|
let next_action = if entry.actions.can_open {
|
||||||
Some(NextUserAction::SendToPod)
|
|
||||||
} else if entry.actions.can_open {
|
|
||||||
Some(NextUserAction::OpenPod)
|
Some(NextUserAction::OpenPod)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -680,7 +676,7 @@ fn pod_row(entry: &PodListEntry) -> PanelRow {
|
||||||
ticket: None,
|
ticket: None,
|
||||||
related_pods: Vec::new(),
|
related_pods: Vec::new(),
|
||||||
disabled_reason: entry.actions.disabled_reason.clone(),
|
disabled_reason: entry.actions.disabled_reason.clone(),
|
||||||
key_hint: Some("Pod rows preserve existing open/direct-send behavior".to_string()),
|
key_hint: Some("Press o or empty Enter to open/attach this Pod".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user