tuiからセッションを復帰する経路の実装
This commit is contained in:
@@ -12,6 +12,7 @@ session-store = { version = "0.1.0", path = "../session-store" }
|
||||
manifest = { version = "0.1.0", path = "../manifest" }
|
||||
protocol = { version = "0.1.0", path = "../protocol" }
|
||||
provider = { version = "0.1.0", path = "../provider" }
|
||||
scope-lock = { version = "0.1.0", path = "../scope-lock" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
thiserror = "2.0"
|
||||
|
||||
@@ -44,9 +44,9 @@ struct Cli {
|
||||
#[arg(long, value_name = "PATH", requires = "adopt")]
|
||||
callback: Option<PathBuf>,
|
||||
|
||||
/// Restore a Pod from an existing session. The source session log
|
||||
/// is forked at its head into a new session id, so the original
|
||||
/// jsonl is left untouched and double-write races are impossible.
|
||||
/// Restore a Pod from an existing session. The Pod re-uses the
|
||||
/// given session id and appends new turns to the same jsonl;
|
||||
/// concurrent writers are prevented by the `scope.lock` registry.
|
||||
/// Mutually exclusive with `--adopt` (spawned children always start
|
||||
/// fresh).
|
||||
#[arg(long, value_name = "UUID", conflicts_with = "adopt")]
|
||||
|
||||
+27
-23
@@ -100,10 +100,11 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||
/// PodInterceptor installed in `ensure_interceptor_installed`.
|
||||
pending_notifies: NotifyBuffer,
|
||||
/// Scope allocation in the machine-wide lock file. `Some` for
|
||||
/// Pods built via `from_manifest` (production path); `None` for
|
||||
/// lower-level constructors (`Pod::new`, `Pod::restore`) that
|
||||
/// bypass the registry. Kept purely for its `Drop` impl, which
|
||||
/// releases the allocation when the Pod is dropped.
|
||||
/// Pods built via `from_manifest` / `from_manifest_spawned` /
|
||||
/// `restore_from_manifest` (production paths); `None` for the
|
||||
/// low-level `Pod::new` constructor used in tests, which bypasses
|
||||
/// the registry. Kept purely for its `Drop` impl, which releases
|
||||
/// the allocation when the Pod is dropped.
|
||||
#[allow(dead_code)]
|
||||
scope_allocation: Option<ScopeAllocationGuard>,
|
||||
/// Socket path of the spawning Pod. `Some` only for Pods built via
|
||||
@@ -717,6 +718,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
self.head_hash = Some(hash);
|
||||
return Ok(());
|
||||
}
|
||||
let prev_session_id = self.session_id;
|
||||
session_store::ensure_head_or_fork(
|
||||
&self.store,
|
||||
&mut self.session_id,
|
||||
@@ -724,6 +726,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
// ensure_head_or_fork mints a fresh session_id when it auto-
|
||||
// forks. Sync that to scope.lock so a concurrent
|
||||
// restore_from_manifest can't see "no live writer" for the new
|
||||
// session and grab it.
|
||||
if self.session_id != prev_session_id && self.scope_allocation.is_some() {
|
||||
scope_lock::update_session(&self.manifest.pod.name, self.session_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1155,6 +1164,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
// until its first LLM call.
|
||||
self.session_id = new_session_id;
|
||||
self.head_hash = Some(new_head_hash);
|
||||
// Keep scope.lock pointing at the live session_id. Without this
|
||||
// a concurrent `restore_from_manifest(new_session_id)` would
|
||||
// see no live writer and grab the session this Pod just moved
|
||||
// into, causing two writers to race on the same jsonl. Skipped
|
||||
// when no allocation is installed (e.g. compact under
|
||||
// `Pod::new` in tests).
|
||||
if self.scope_allocation.is_some() {
|
||||
scope_lock::update_session(&self.manifest.pod.name, new_session_id)?;
|
||||
}
|
||||
let worker = self.worker.as_mut().unwrap();
|
||||
worker.set_history(new_history);
|
||||
// Anchor the prompt cache at the summary item so that Anthropic
|
||||
@@ -1602,15 +1620,6 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
store: St,
|
||||
loader: PromptLoader,
|
||||
) -> Result<Self, PodError> {
|
||||
// Refuse to resume into a session that's already being written.
|
||||
if let Some(info) = scope_lock::lookup_session(session_id)? {
|
||||
return Err(PodError::SessionInUse {
|
||||
session_id,
|
||||
pod_name: info.pod_name,
|
||||
socket: info.socket,
|
||||
});
|
||||
}
|
||||
|
||||
let state = session_store::restore(&store, session_id).await?;
|
||||
if state.head_hash.is_none() {
|
||||
return Err(PodError::SessionEmpty { session_id });
|
||||
@@ -1618,6 +1627,11 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
|
||||
let common = prepare_pod_common(&manifest, &loader, /* parse_template */ false)?;
|
||||
|
||||
// Atomic: register_pod inside install_top_level rejects when
|
||||
// another live allocation already holds `session_id`. Wrapping
|
||||
// the lookup + install inside a single `LockFileGuard` is what
|
||||
// makes "no two live Pods write to the same session log"
|
||||
// actually structural rather than a hopeful pre-check.
|
||||
let socket_path = dir::default_base()
|
||||
.map_err(ScopeLockError::from)?
|
||||
.join(&manifest.pod.name)
|
||||
@@ -1883,16 +1897,6 @@ pub enum PodError {
|
||||
#[error("memory Phase 1 staging write failed: {0}")]
|
||||
ExtractStaging(#[source] memory::extract::StagingError),
|
||||
|
||||
#[error(
|
||||
"session {session_id} is currently in use by pod `{pod_name}` at {}",
|
||||
.socket.display()
|
||||
)]
|
||||
SessionInUse {
|
||||
session_id: SessionId,
|
||||
pod_name: String,
|
||||
socket: PathBuf,
|
||||
},
|
||||
|
||||
#[error("session {session_id} has no entries to restore")]
|
||||
SessionEmpty { session_id: SessionId },
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod dir;
|
||||
pub mod scope_lock;
|
||||
pub use ::scope_lock;
|
||||
|
||||
@@ -441,7 +441,8 @@ fn scope_lock_err_to_tool(e: ScopeLockError) -> ToolError {
|
||||
ScopeLockError::NotSubset { .. }
|
||||
| ScopeLockError::WriteConflict { .. }
|
||||
| ScopeLockError::DuplicatePodName(_)
|
||||
| ScopeLockError::UnknownPod(_) => ToolError::InvalidArgument(e.to_string()),
|
||||
| ScopeLockError::UnknownPod(_)
|
||||
| ScopeLockError::SessionConflict { .. } => ToolError::InvalidArgument(e.to_string()),
|
||||
ScopeLockError::Io(_) => ToolError::ExecutionFailed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Integration tests for `Pod::restore_from_manifest`'s pre-build
|
||||
//! validation paths.
|
||||
//!
|
||||
//! These cases all return before `prepare_pod_common` runs, so they
|
||||
//! do not need a real LLM client or scope-lock environment — only the
|
||||
//! session store needs to be present.
|
||||
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use pod::{Pod, PodError};
|
||||
use session_store::{FsStore, SessionId, StoreError};
|
||||
|
||||
const MINIMAL_MANIFEST_TOML: &str = r#"
|
||||
[pod]
|
||||
name = "restore-test"
|
||||
pwd = "./"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[worker]
|
||||
max_tokens = 100
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
|
||||
/// Serialises tests that mutate runtime-dir env vars, mirroring the
|
||||
/// pattern used by other integration tests in this crate.
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_unknown_session() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(store_tmp.path()).await.unwrap();
|
||||
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
|
||||
|
||||
// A freshly-minted id with no jsonl file at all → store returns
|
||||
// NotFound, which `Pod::restore_from_manifest` surfaces verbatim
|
||||
// as `PodError::Store`.
|
||||
let unknown = session_store::new_session_id();
|
||||
let result = Pod::restore_from_manifest(
|
||||
unknown,
|
||||
manifest,
|
||||
store,
|
||||
pod::PromptLoader::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(PodError::Store(StoreError::NotFound(id))) => assert_eq!(id, unknown),
|
||||
Err(other) => panic!("expected Store(NotFound), got {other:?}"),
|
||||
Ok(_) => panic!("expected unknown session to fail"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_empty_session_log() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(store_tmp.path()).await.unwrap();
|
||||
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
|
||||
|
||||
// Pre-create an empty `<id>.jsonl` so `read_all` succeeds with no
|
||||
// entries. `collect_state` returns `head_hash = None`, which
|
||||
// `restore_from_manifest` rejects with `SessionEmpty` *before* it
|
||||
// gets as far as building the LLM client — so the test does not
|
||||
// need credentials or a runtime sandbox.
|
||||
let id: SessionId = session_store::new_session_id();
|
||||
let path = store_tmp.path().join(format!("{id}.jsonl"));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
|
||||
let result =
|
||||
Pod::restore_from_manifest(id, manifest, store, pod::PromptLoader::builtins_only()).await;
|
||||
|
||||
match result {
|
||||
Err(PodError::SessionEmpty { session_id }) => assert_eq!(session_id, id),
|
||||
Err(other) => panic!("expected SessionEmpty, got {other:?}"),
|
||||
Ok(_) => panic!("expected empty session log to fail"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "scope-lock"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fs4 = { version = "0.13.1", features = ["sync"] }
|
||||
libc = "0.2.185"
|
||||
manifest = { version = "0.1.0", path = "../manifest" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
session-store = { version = "0.1.0", path = "../session-store" }
|
||||
thiserror = "2.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
@@ -303,6 +303,10 @@ fn find_conflict_in_subtree(
|
||||
/// Register a top-level Pod (started directly by a human, no
|
||||
/// delegation parent). Reclaims stale entries before checking
|
||||
/// conflicts so a crashed Pod's allocation doesn't block the new one.
|
||||
///
|
||||
/// Rejects when another live allocation is already writing to
|
||||
/// `session_id`, so two `restore_from_manifest` calls under different
|
||||
/// `pod_name`s cannot both grab the same session log.
|
||||
pub fn register_pod(
|
||||
guard: &mut LockFileGuard,
|
||||
pod_name: String,
|
||||
@@ -315,6 +319,13 @@ pub fn register_pod(
|
||||
if guard.data().find(&pod_name).is_some() {
|
||||
return Err(ScopeLockError::DuplicatePodName(pod_name));
|
||||
}
|
||||
if let Some(existing) = guard.data().find_by_session(session_id) {
|
||||
return Err(ScopeLockError::SessionConflict {
|
||||
session_id,
|
||||
pod_name: existing.pod_name.clone(),
|
||||
socket: existing.socket.clone(),
|
||||
});
|
||||
}
|
||||
for rule in scope_allow
|
||||
.iter()
|
||||
.filter(|r| r.permission == Permission::Write)
|
||||
@@ -550,6 +561,46 @@ pub fn adopt_allocation(
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewrite the `session_id` recorded for `pod_name` to
|
||||
/// `new_session_id`.
|
||||
///
|
||||
/// The Pod's in-memory `session_id` can change underneath the
|
||||
/// allocation in two normal places:
|
||||
///
|
||||
/// - `Pod::compact` mints a fresh session and swaps it in.
|
||||
/// - `session_store::ensure_head_or_fork` auto-forks when another
|
||||
/// writer has advanced the store head behind our back.
|
||||
///
|
||||
/// Both paths must call this so subsequent `lookup_session` queries
|
||||
/// find the live session id, not the old one. Without this update a
|
||||
/// concurrent `restore_from_manifest(new_id)` would see "no live
|
||||
/// writer" and proceed to register a competing allocation on the
|
||||
/// session this Pod just moved into.
|
||||
///
|
||||
/// The lock is opened once and the allocation is rewritten inside the
|
||||
/// guard, so the session_id collision check is atomic with the
|
||||
/// rewrite.
|
||||
pub fn update_session(pod_name: &str, new_session_id: SessionId) -> Result<(), ScopeLockError> {
|
||||
let lock_path = default_lock_path()?;
|
||||
let mut guard = LockFileGuard::open(&lock_path)?;
|
||||
if let Some(other) = guard.data().find_by_session(new_session_id) {
|
||||
if other.pod_name != pod_name {
|
||||
return Err(ScopeLockError::SessionConflict {
|
||||
session_id: new_session_id,
|
||||
pod_name: other.pod_name.clone(),
|
||||
socket: other.socket.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let alloc = guard
|
||||
.data_mut()
|
||||
.find_mut(pod_name)
|
||||
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.into()))?;
|
||||
alloc.session_id = Some(new_session_id);
|
||||
guard.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Information about a Pod that currently holds an allocation for a
|
||||
/// given session.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -593,6 +644,15 @@ pub enum ScopeLockError {
|
||||
NotSubset { spawner: String, rule: ScopeRule },
|
||||
#[error("pod `{0}` is not registered")]
|
||||
UnknownPod(String),
|
||||
#[error(
|
||||
"session {session_id} is already held by pod `{pod_name}` at {}",
|
||||
.socket.display()
|
||||
)]
|
||||
SessionConflict {
|
||||
session_id: SessionId,
|
||||
pod_name: String,
|
||||
socket: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1200,4 +1260,164 @@ mod tests {
|
||||
other => panic!("expected WriteConflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_by_session_skips_none_placeholders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("scope.lock");
|
||||
let mut g = open_empty(&path);
|
||||
// Pre-reservation: delegate_scope leaves session_id = None
|
||||
// until adopt_allocation rewrites it. find_by_session must not
|
||||
// match those placeholders, otherwise a freshly-spawning child
|
||||
// would shadow itself before it has even chosen a session.
|
||||
register_pod(
|
||||
&mut g,
|
||||
"parent".into(),
|
||||
std::process::id(),
|
||||
sock("parent"),
|
||||
vec![write_rule("/p", true)],
|
||||
sid(),
|
||||
)
|
||||
.unwrap();
|
||||
delegate_scope(
|
||||
&mut g,
|
||||
"parent",
|
||||
"child".into(),
|
||||
std::process::id(),
|
||||
sock("child"),
|
||||
vec![write_rule("/p/sub", true)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let target_session = sid();
|
||||
// The placeholder allocation has session_id = None and must
|
||||
// not be returned for any lookup.
|
||||
assert!(g.data().find_by_session(target_session).is_none());
|
||||
|
||||
// After adopt-style rewrite, the same allocation is now found.
|
||||
g.data_mut()
|
||||
.find_mut("child")
|
||||
.unwrap()
|
||||
.session_id = Some(target_session);
|
||||
let found = g.data().find_by_session(target_session).unwrap();
|
||||
assert_eq!(found.pod_name, "child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_pod_rejects_session_id_collision() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("scope.lock");
|
||||
let mut g = open_empty(&path);
|
||||
let shared_session = sid();
|
||||
register_pod(
|
||||
&mut g,
|
||||
"first".into(),
|
||||
std::process::id(),
|
||||
sock("first"),
|
||||
vec![write_rule("/work/a", true)],
|
||||
shared_session,
|
||||
)
|
||||
.unwrap();
|
||||
// Second registration tries to grab the same session_id under
|
||||
// a different pod_name. Without the SessionConflict check both
|
||||
// would succeed and race on the same jsonl.
|
||||
let err = register_pod(
|
||||
&mut g,
|
||||
"second".into(),
|
||||
std::process::id(),
|
||||
sock("second"),
|
||||
vec![write_rule("/work/b", true)],
|
||||
shared_session,
|
||||
)
|
||||
.unwrap_err();
|
||||
match err {
|
||||
ScopeLockError::SessionConflict {
|
||||
session_id,
|
||||
pod_name,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(session_id, shared_session);
|
||||
assert_eq!(pod_name, "first");
|
||||
}
|
||||
other => panic!("expected SessionConflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_session_returns_live_writer_info() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let s = sid();
|
||||
let guard = install_top_level(
|
||||
"live".into(),
|
||||
std::process::id(),
|
||||
sock("live"),
|
||||
vec![write_rule("/work", true)],
|
||||
s,
|
||||
)
|
||||
.unwrap();
|
||||
let info = lookup_session(s).unwrap().expect("expected live writer");
|
||||
assert_eq!(info.pod_name, "live");
|
||||
assert_eq!(info.socket, sock("live"));
|
||||
drop(guard);
|
||||
// After the guard's release, the lookup goes back to None.
|
||||
assert!(lookup_session(s).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_session_rewrites_allocation_session_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let original = sid();
|
||||
let updated = sid();
|
||||
let _guard = install_top_level(
|
||||
"p".into(),
|
||||
std::process::id(),
|
||||
sock("p"),
|
||||
vec![write_rule("/work", true)],
|
||||
original,
|
||||
)
|
||||
.unwrap();
|
||||
update_session("p", updated).unwrap();
|
||||
// lookup against the original is now empty, the updated id wins.
|
||||
assert!(lookup_session(original).unwrap().is_none());
|
||||
assert_eq!(lookup_session(updated).unwrap().unwrap().pod_name, "p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_session_rejects_when_target_already_held() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _sandbox = RuntimeDirSandbox::new(dir.path());
|
||||
let s_a = sid();
|
||||
let s_b = sid();
|
||||
let _g_a = install_top_level(
|
||||
"a".into(),
|
||||
std::process::id(),
|
||||
sock("a"),
|
||||
vec![write_rule("/work/a", true)],
|
||||
s_a,
|
||||
)
|
||||
.unwrap();
|
||||
let _g_b = install_top_level(
|
||||
"b".into(),
|
||||
std::process::id(),
|
||||
sock("b"),
|
||||
vec![write_rule("/work/b", true)],
|
||||
s_b,
|
||||
)
|
||||
.unwrap();
|
||||
// `a` cannot adopt b's live session id.
|
||||
let err = update_session("a", s_b).unwrap_err();
|
||||
match err {
|
||||
ScopeLockError::SessionConflict {
|
||||
pod_name,
|
||||
session_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(pod_name, "b");
|
||||
assert_eq!(session_id, s_b);
|
||||
}
|
||||
other => panic!("expected SessionConflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,4 @@ uuid = "1.23"
|
||||
toml = "1.1.2"
|
||||
manifest = { version = "0.1.0", path = "../manifest" }
|
||||
session-store = { version = "0.1.0", path = "../session-store" }
|
||||
scope-lock = { version = "0.1.0", path = "../scope-lock" }
|
||||
|
||||
@@ -19,6 +19,7 @@ use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, TerminalOptions, Viewport};
|
||||
use scope_lock::lookup_session;
|
||||
use session_store::{
|
||||
ContentPart, FsStore, HashedEntry, Item, LogEntry, SessionId, Store,
|
||||
};
|
||||
@@ -71,6 +72,11 @@ struct Row {
|
||||
id: SessionId,
|
||||
/// Last user / assistant snippet, or a `[corrupt]` placeholder.
|
||||
preview: String,
|
||||
/// `Some(pod_name)` when a live Pod currently holds an allocation
|
||||
/// for this session in `scope.lock`. Picking such a row launches
|
||||
/// `pod --session <UUID>` which will fail with `SessionConflict` —
|
||||
/// the badge warns the user up-front.
|
||||
live_pod: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<PickerOutcome, PickerError> {
|
||||
@@ -82,7 +88,15 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
|
||||
let mut rows: Vec<Row> = Vec::with_capacity(MAX_ROWS);
|
||||
for id in ids.into_iter().take(MAX_ROWS) {
|
||||
let preview = build_preview(&store, id).await;
|
||||
rows.push(Row { id, preview });
|
||||
// Best-effort live check. A scope.lock I/O hiccup downgrades
|
||||
// the row to "no badge" rather than killing the picker — the
|
||||
// user still gets to see the listing.
|
||||
let live_pod = lookup_session(id).ok().flatten().map(|info| info.pod_name);
|
||||
rows.push(Row {
|
||||
id,
|
||||
preview,
|
||||
live_pod,
|
||||
});
|
||||
}
|
||||
|
||||
let mut selected = 0usize;
|
||||
@@ -291,12 +305,19 @@ fn row_line(row: &Row, selected: bool) -> Line<'_> {
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
Line::from(vec![
|
||||
let mut spans = vec![
|
||||
Span::raw(marker),
|
||||
Span::styled(short_session(row.id), id_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(row.preview.clone(), preview_style),
|
||||
])
|
||||
];
|
||||
if let Some(ref pod_name) = row.live_pod {
|
||||
spans.push(Span::styled(
|
||||
format!("[live: {pod_name}] "),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
spans.push(Span::styled(row.preview.clone(), preview_style));
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn short_session(id: SessionId) -> String {
|
||||
|
||||
@@ -447,8 +447,8 @@ struct Form {
|
||||
editing: bool,
|
||||
/// `Some(id)` flips the dialog into "Resume Pod" mode: the title
|
||||
/// switches, the source session is shown to the user, and the
|
||||
/// child pod is launched with `--session <id>` so it forks and
|
||||
/// restores `id`.
|
||||
/// child pod is launched with `--session <id>` so it restores
|
||||
/// from `id` and appends to the same session log.
|
||||
resume_from: Option<SessionId>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user