Compare commits
513 Commits
7880672737
...
36c24a4c7e
| Author | SHA1 | Date | |
|---|---|---|---|
| 36c24a4c7e | |||
| 48b569667a | |||
| 89393e4623 | |||
| 3c57b1690b | |||
| 0f56ca1fc0 | |||
| 4065c0a0f3 | |||
| cbb59a47d0 | |||
| f8881f7289 | |||
| 85ffbaf10a | |||
| e43e6620b5 | |||
| b904f56b4f | |||
| 3e7b81aa9c | |||
| 003d2b584c | |||
| 67c5c4a864 | |||
| f6a3d2c6e5 | |||
| 208143f01b | |||
| 97f3df651a | |||
| 1da2498295 | |||
| 6bb7882c3e | |||
| 06e0bfc359 | |||
| 0b23aa8191 | |||
| ee4ccba591 | |||
| 0d7244d0cc | |||
| eea40f5095 | |||
| cc5510bc60 | |||
| 0ecc7f487d | |||
| 79da9aa102 | |||
| be54cb07ea | |||
| eb249dae0c | |||
| 66408d87e0 | |||
| be4b175bc5 | |||
| d3c54a2407 | |||
| 258ac2b9a6 | |||
| e58d820c90 | |||
| 401d0912b7 | |||
| d54d49531f | |||
| b5e608c597 | |||
| f21642a5a6 | |||
| b63ff7a11e | |||
| 6fb7b57054 | |||
| 98c931563e | |||
| 7aa48ee4d2 | |||
| f1504c40fd | |||
| 601ce9f5ac | |||
| 7d1db97754 | |||
| 0777bcf299 | |||
| e4810a7411 | |||
| 142322ef95 | |||
| 58981775ec | |||
| 18ba6aee38 | |||
| ccb4cd30cd | |||
| 06ebecb329 | |||
| 4998813e8a | |||
| d6fd4d1b9c | |||
| 8703f14a15 | |||
| 09c2041a88 | |||
| 2eab301bf8 | |||
| 35d35ef471 | |||
| 1e938b548c | |||
| fcca67a9ad | |||
| a85ca369ae | |||
| 11d1dcffb6 | |||
| 12dd35cfb2 | |||
| 9a92443269 | |||
| c274e4a891 | |||
| 7034d02455 | |||
| 65bbff663f | |||
| 311d74c25d | |||
| df55af3545 | |||
| 7cb1804504 | |||
| 47cc6234be | |||
| dbfdf6aa6c | |||
| 60b9cb169a | |||
| 36f544da18 | |||
| 1f7bc518cb | |||
| 56aa241d7b | |||
| 1e956c7dff | |||
| 3345acafab | |||
| 23fc2cf9f4 | |||
| 1c82058c6f | |||
| 2bb0605650 | |||
| a0394a01a6 | |||
| c1a724aedf | |||
| 21e48bd2c0 | |||
| a7f1b348de | |||
| 11f644fddc | |||
| 23d3b9e070 | |||
| 9cd776eaec | |||
| 647223eb32 | |||
| 40f4e801dc | |||
| dac5fc516f | |||
| 876d75a747 | |||
| 00596d3f9a | |||
| 8c6a4acf5f | |||
| ef71bb57d3 | |||
| 93373066e0 | |||
| 8416533695 | |||
| 372a99bc0b | |||
| 5ccfdea7c8 | |||
| ded02e4c08 | |||
| a5b1f15632 | |||
| 11e86d3e6e | |||
| 670b4b876f | |||
| 405339fb04 | |||
| 9435f44d53 | |||
| fea274cfe3 | |||
| 7c42c2b110 | |||
| b1a3b06db7 | |||
| 9ec77a2a2b | |||
| 962652832d | |||
| 4a4ff0f6c9 | |||
| 88619f36cf | |||
| 3f260d7d4e | |||
| bdffa5120d | |||
| bb917246ec | |||
| 770173a4ef | |||
| 66540172de | |||
| be753099ae | |||
| 41402c0951 | |||
| fc81555129 | |||
| d9191c393f | |||
| 01796f9316 | |||
| ab73051ddc | |||
| 9fb11e25a4 | |||
| f73055550b | |||
| 7003c00d45 | |||
| 10d3556792 | |||
| 5b3c579324 | |||
| 08710d808d | |||
| e5fda7efdf | |||
| 9224951000 | |||
| 0172414d9a | |||
| 891b6d91fd | |||
| cabc556b2c | |||
| 95d05628e7 | |||
| 50422ee555 | |||
| 14381b8ba5 | |||
| f439de6cdc | |||
| 83cab17f1f | |||
| b7340eab4b | |||
| f1c886e451 | |||
| a9a2b1e034 | |||
| 65c399e6d9 | |||
| 3ae145269c | |||
| b64e098b5b | |||
| fed3997eb8 | |||
| 45bc2265f4 | |||
| 88f755a38f | |||
| e65b62affa | |||
| 2061cea5dd | |||
| df8f91bda7 | |||
| 565f152e13 | |||
| 7181910806 | |||
| 9fc3653502 | |||
| 03e7795130 | |||
| 8813d966bb | |||
| 74ccdd6726 | |||
| 044032ef2b | |||
| fd1b06198e | |||
| aea33efaeb | |||
| cf4ecf8d70 | |||
| d5da95499d | |||
| ba92581d51 | |||
| c4183d5ba6 | |||
| a8b311bd1f | |||
| cfb5fa89f1 | |||
| c2bcaac03d | |||
| a26f18c466 | |||
| 0cba8e9f5c | |||
| ccc6efc0e6 | |||
| 942ab0e15b | |||
| 0ff39f33bb | |||
| 7480a5732f | |||
| c1d4d0b65c | |||
| aa57253e39 | |||
| a267ad8114 | |||
| 637694893a | |||
| be8b10e759 | |||
| 60daf64808 | |||
| 01ccac7cf1 | |||
| 369a5931bc | |||
| 9ee7f04805 | |||
| 0d30b6139c | |||
| f4627de3ee | |||
| 767400d5c2 | |||
| 5c4b1e1ec8 | |||
| 79842b212a | |||
| 4072d35f81 | |||
| 4795b6cb4a | |||
| 12d33c265c | |||
| d9ca5e8c41 | |||
| d0cac8ab89 | |||
| 96fda589ac | |||
| 1e0dc6566c | |||
| 73e1c8332d | |||
| 292b923cae | |||
| 0e562dd4d9 | |||
| ca2e2352f4 | |||
| e0e58ebbf7 | |||
| 58608c4f57 | |||
| 78209d5126 | |||
| 93c91e7c06 | |||
| 458fdbc9e0 | |||
| 8a062b1a19 | |||
| 27be927afe | |||
| 8e1c5d3bdc | |||
| 3057fe6c24 | |||
| 8747cc802f | |||
| ca7c5b82d7 | |||
| a9340a8817 | |||
| c47a539278 | |||
| 7542605ec9 | |||
| 0dfdd11921 | |||
| 5edc4d3b03 | |||
| d2b3c2f53d | |||
| a084324830 | |||
| c2b55a498b | |||
| 2d23673393 | |||
| 22f5d02385 | |||
| de549812ab | |||
| 5362e5858c | |||
| 55c5ac4942 | |||
| f41c60c3ae | |||
| 90e83bf2ae | |||
| 3d091acacd | |||
| 72c1d04cf2 | |||
| 801d7d9abb | |||
| 133402dcdb | |||
| 39a803d7e5 | |||
| fcc6b67f40 | |||
| f90ec5ee62 | |||
| 35988f3249 | |||
| d840add130 | |||
| 79b8336a14 | |||
| fd8526799b | |||
| dcfffbcbde | |||
| 7b79743ea4 | |||
| 9f9e42ab59 | |||
| a761372a9e | |||
| c4f81da828 | |||
| beef5e5710 | |||
| d5a4f77420 | |||
| d5a7cf2aab | |||
| 35fec78519 | |||
| 988495cfea | |||
| 34c89f8739 | |||
| f9def2d5bb | |||
| e4b66345aa | |||
| 6358affd76 | |||
| f73e648929 | |||
| e7064878c2 | |||
| dfa466c980 | |||
| d04d2fc704 | |||
| 3c2f5eb337 | |||
| 38c309535d | |||
| 4a8dba276a | |||
| 28443d2e04 | |||
| a44288c258 | |||
| f3b99aca0c | |||
| e001f4c3f9 | |||
| ba3655522b | |||
| 5a3e3f5994 | |||
| b7e329a1a1 | |||
| 524e3dc551 | |||
| b67023aafc | |||
| aa27f62409 | |||
| 1803b0cf67 | |||
| 93145afc3c | |||
| d236521c77 | |||
| fc4786628c | |||
| 9cd76515d0 | |||
| cf822dbc5c | |||
| bedaf62cb0 | |||
| ea3014164e | |||
| 8d2ca5d530 | |||
| 53b508abae | |||
| 27b84abccc | |||
| abbf7f8273 | |||
| 2f84bd32ba | |||
| df6ec428ca | |||
| 0c34923ad1 | |||
| 0af1c16009 | |||
| 64506b643b | |||
| b78018b62e | |||
| 8026e8e319 | |||
| 7ac948afa0 | |||
| 96821556c6 | |||
| f70975789e | |||
| 520895f1c9 | |||
| 072c1bfbc7 | |||
| fdb181e825 | |||
| cfb7f09e38 | |||
| 1676e525e8 | |||
| 01bdf04f2e | |||
| 96d5be4337 | |||
| 6c93ec38df | |||
| 1d8db0aadd | |||
| 69ac3799d6 | |||
| eb9a67decc | |||
| 0e906b72b7 | |||
| a2aecbf029 | |||
| a8a6e049bc | |||
| dbc96ee075 | |||
| 2c8aec5385 | |||
| eb9af32b49 | |||
| 919602b496 | |||
| 507b164822 | |||
| 8100a5dfd1 | |||
| 8f3c1942fb | |||
| d6f27f7c45 | |||
| b7953b3d28 | |||
| 20c82a4bf6 | |||
| 7d9b9682bd | |||
| 75ade5750e | |||
| 3def5edbdf | |||
| df0be1cd6b | |||
| 175343c612 | |||
| 4d6b548611 | |||
| f479aa5206 | |||
| 94c2f3a106 | |||
| 45b9912c8f | |||
| 7c5c1609cb | |||
| 60144c550a | |||
| 2df9de73c7 | |||
| 41bce21339 | |||
| 584cbe406a | |||
| 0900e05f9e | |||
| d036d53096 | |||
| 69edd29a46 | |||
| 902b8d759b | |||
| 22d87f8ade | |||
| 6c95b2da56 | |||
| fc5cfefb62 | |||
| ba2c8ae687 | |||
| 9d709c6470 | |||
| dec17c9909 | |||
| 37065144da | |||
| 5acb0d4d85 | |||
| d0270288de | |||
| 0d5ec4ff63 | |||
| 75c573fed1 | |||
| 594671edc3 | |||
| e31cbcb150 | |||
| 9f14a43a59 | |||
| f33eba3fe6 | |||
| cb7da11de7 | |||
| 006897790c | |||
| cb72533ab0 | |||
| 954cf200e2 | |||
| 5b38aa6a87 | |||
| ef7d7bd6a1 | |||
| a61b498564 | |||
| dcd22d4399 | |||
| f2dda9097e | |||
| 560c23bc75 | |||
| 79e85ccda6 | |||
| 73a9efdc9a | |||
| 19df6340cd | |||
| a0771608b1 | |||
| b4ca718c24 | |||
| a0e1583916 | |||
| 0e2521d7c1 | |||
| 798b95a887 | |||
| 59b953c139 | |||
| e6b6df09b8 | |||
| 9a06494bc6 | |||
| 58b12d859b | |||
| aec7a071ac | |||
| a18f74ca24 | |||
| 8d9910fd20 | |||
| 698175e60c | |||
| 9ffe91b5bc | |||
| 4dae614a55 | |||
| b22b226fea | |||
| 25d07976f2 | |||
| 6021f27469 | |||
| 2e8dd759d8 | |||
| 9679661313 | |||
| 9e58149dda | |||
| 3cbd759397 | |||
| b8c459549f | |||
| 7ddfdb09b5 | |||
| 613bf07610 | |||
| b90291d5a0 | |||
| 364f936ed1 | |||
| 3fe4f169b7 | |||
| 4e48f35e55 | |||
| aca5bda1f2 | |||
| 70c4f1930e | |||
| 702ed79517 | |||
| b35bb2154f | |||
| 56e94911c1 | |||
| 201bb6c82e | |||
| 4466a35d6c | |||
| 2c86e64f19 | |||
| 261c682e5e | |||
| 3bb8af824e | |||
| 689a988e83 | |||
| 9d0b9e9d90 | |||
| 81ff4c6073 | |||
| cb5cd1e3d1 | |||
| 08da79822d | |||
| 1b8a9efdd5 | |||
| 76e5e66326 | |||
| b8aab6725b | |||
| e9b426b825 | |||
| d33b1c111e | |||
| 5479b14411 | |||
| e2d1fa120f | |||
| 8e24b3c607 | |||
| 573501e37c | |||
| 9ac127d607 | |||
| 7899ab4386 | |||
| 99dc94416b | |||
| 469c5ead99 | |||
| bccd60d9be | |||
| 201e68f17e | |||
| b907715dd4 | |||
| 06f4dc0428 | |||
| d8a7200ea4 | |||
| f1b7af6249 | |||
| 57fab557d3 | |||
| 8773e751ec | |||
| 5b27fb9e19 | |||
| d7bc7ab3dd | |||
| bc81dc1513 | |||
| 623b54cefc | |||
| f914ae235a | |||
| 0df59462ea | |||
| f31c58dccd | |||
| 3fc65e6f6b | |||
| b0393d2fe9 | |||
| fa68957277 | |||
| e3b36371e9 | |||
| 8a9e3b4fe3 | |||
| 913ee4764a | |||
| 7736baaec9 | |||
| b0db02da6d | |||
| b79747bd0c | |||
| 99dbb1c6c0 | |||
| dfb458cff2 | |||
| 81ac7ccb7b | |||
| 0b322a645a | |||
| 274b7df32d | |||
| 8a8fd225bf | |||
| 1aa06dba60 | |||
| deab5c5b50 | |||
| 59bbfb9621 | |||
| fd96a517bb | |||
| 87768c2e2d | |||
| 2b89bb6d2e | |||
| 023ed09adc | |||
| ce4c0930c3 | |||
| 3d1b8a4761 | |||
| f385f06abc | |||
| cf4c454a03 | |||
| 513653ce55 | |||
| 1e65287bf1 | |||
| 6fe19b84ce | |||
| e49fb3f1a0 | |||
| e437028849 | |||
| 3d04f793de | |||
| 141b77b7e4 | |||
| 37d35df6be | |||
| 8cf1d6c9cf | |||
| dfa6213c18 | |||
| 4273d2a463 | |||
| bdf2a08459 | |||
| 7a0ed7d744 | |||
| bcaa4645f7 | |||
| 84c6c2f17f | |||
| 0435ec5cbd | |||
| c75efb50b9 | |||
| 8cf0bd6374 | |||
| 8658845b02 | |||
| 306b1cf942 | |||
| 695cfa05a7 | |||
| e8559d4bee | |||
| 12c1d55127 | |||
| bc9eb3aa1c | |||
| 978d542855 | |||
| c9d1d4fa5c | |||
| c8fe95901c | |||
| 9f1443b027 | |||
| c4ca9e1d89 | |||
| cd8b620e6a | |||
| f2e47629d0 | |||
| c7a873bcf9 | |||
| c3278bb8da | |||
| 0a3af686f7 | |||
| c9a7d652dc | |||
| 2ee536ed71 | |||
| 82f08b966b | |||
| d3b729c671 | |||
| 0a97886005 | |||
| 4763173f36 | |||
| 30f9abacb8 | |||
| 7ecb1e6fc1 | |||
| ccf1f2b6bf | |||
| e21d2041ef | |||
| 26ce346a81 | |||
| b8d368f5e5 | |||
| 0c1276b730 | |||
| 270d7923ab | |||
| ef294eeb68 | |||
| d3ba0a299a | |||
| 72128aab9f | |||
| 388079759c | |||
| ce59c5320e | |||
| de3272fdfd | |||
| ca5a3d1152 | |||
| 47da4a03cb | |||
| 2914800673 |
|
|
@ -1,4 +1,4 @@
|
|||
//! pod バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ
|
||||
//! `insomnia-pod` バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ
|
||||
//! ハンドシェイク。
|
||||
//!
|
||||
//! - 親プロセス (TUI / GUI / E2E) は overlay TOML を組み立ててこの関数に
|
||||
|
|
@ -258,7 +258,7 @@ async fn drain_stderr_into_tail(stderr_path: &Path, tail: &mut StderrTail, offse
|
|||
}
|
||||
|
||||
/// Resolves the binary used to launch a child Pod. Must point at a
|
||||
/// `pod`-compatible executable — the parent reads the child's stderr
|
||||
/// `insomnia-pod`-compatible executable — the parent reads the child's stderr
|
||||
/// directly looking for `INSOMNIA-READY`, so any wrapper that emits
|
||||
/// extra lines on stderr will pollute that handshake.
|
||||
///
|
||||
|
|
@ -271,7 +271,7 @@ fn resolve_pod_command() -> PathBuf {
|
|||
{
|
||||
return PathBuf::from(cmd);
|
||||
}
|
||||
PathBuf::from("pod")
|
||||
PathBuf::from("insomnia-pod")
|
||||
}
|
||||
|
||||
struct StderrTail {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ version = "0.1.0"
|
|||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "insomnia-pod"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
clap = { version = "4.6.0", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ use crate::spawn::comm_tools::{
|
|||
use crate::spawn::registry::SpawnedPodRegistry;
|
||||
use crate::spawn::tool::spawn_pod_tool;
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, ErrorCode, Event, Method, PodStatus, RunResult, Segment, TurnResult,
|
||||
AlertLevel, AlertSource, ErrorCode, Event, Method, PodStatus, RewindTargetId, RunResult,
|
||||
Segment, TurnResult,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -781,6 +782,45 @@ async fn controller_loop<C, St>(
|
|||
}
|
||||
},
|
||||
|
||||
Method::ListRewindTargets => match shared_state.get_status() {
|
||||
PodStatus::Idle | PodStatus::Paused => emit_rewind_targets(&pod, &event_tx),
|
||||
PodStatus::Running => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Pod is already executing a turn; rewind can only run while idle or paused"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Method::RewindTo {
|
||||
target,
|
||||
expected_head_entries,
|
||||
} => match shared_state.get_status() {
|
||||
PodStatus::Idle => {
|
||||
if apply_rewind(&mut pod, &event_tx, target, expected_head_entries) {
|
||||
shared_state.set_status(PodStatus::Idle);
|
||||
let _ = event_tx.send(Event::Status {
|
||||
status: PodStatus::Idle,
|
||||
});
|
||||
}
|
||||
}
|
||||
PodStatus::Paused => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
message: "Cannot apply rewind while the Pod is paused; resume or wait for idle first"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
PodStatus::Running => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Pod is already executing a turn; rewind can only run while idle or paused"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Method::Shutdown => {
|
||||
let _ = event_tx.send(Event::Shutdown);
|
||||
break;
|
||||
|
|
@ -1014,10 +1054,10 @@ where
|
|||
message: "Pod is already executing a turn".into(),
|
||||
});
|
||||
}
|
||||
Some(Method::Compact) => {
|
||||
Some(Method::Compact | Method::ListRewindTargets | Method::RewindTo { .. }) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::AlreadyRunning,
|
||||
message: "Pod is already executing a turn; compact can only run while idle"
|
||||
message: "Pod is already executing a turn; rewind/compact can only run while idle or paused"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
|
|
@ -1069,6 +1109,70 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
fn emit_rewind_targets<C, St>(pod: &Pod<C, St>, event_tx: &broadcast::Sender<Event>)
|
||||
where
|
||||
C: LlmClient,
|
||||
St: Store,
|
||||
{
|
||||
match pod.list_rewind_targets() {
|
||||
Ok((head_entries, targets)) => {
|
||||
let _ = event_tx.send(Event::RewindTargets {
|
||||
head_entries,
|
||||
targets,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::Internal,
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_rewind<C, St>(
|
||||
pod: &mut Pod<C, St>,
|
||||
event_tx: &broadcast::Sender<Event>,
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
) -> bool
|
||||
where
|
||||
C: LlmClient,
|
||||
St: Store,
|
||||
{
|
||||
match pod.rewind_to(target, expected_head_entries) {
|
||||
Ok(applied) => match applied
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
{
|
||||
Ok(entries) => {
|
||||
let _ = event_tx.send(Event::RewindApplied {
|
||||
entries,
|
||||
input: applied.input,
|
||||
summary: applied.summary,
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::Internal,
|
||||
message: format!("failed to encode rewind snapshot: {error}"),
|
||||
});
|
||||
false
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
message: err.to_string(),
|
||||
});
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_greeting<C, St>(pod: &Pod<C, St>) -> protocol::Greeting
|
||||
where
|
||||
C: LlmClient,
|
||||
|
|
|
|||
|
|
@ -596,7 +596,7 @@ fn resolve_pod_command() -> PathBuf {
|
|||
{
|
||||
return PathBuf::from(cmd);
|
||||
}
|
||||
PathBuf::from("pod")
|
||||
PathBuf::from("insomnia-pod")
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
|
|||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "pod",
|
||||
name = "insomnia-pod",
|
||||
about = "Spawn a Pod process from manifest layers or a single manifest file"
|
||||
)]
|
||||
struct Cli {
|
||||
|
|
@ -364,19 +364,25 @@ permission = "write"
|
|||
|
||||
#[test]
|
||||
fn user_manifest_flag_is_not_accepted() {
|
||||
let err = Cli::try_parse_from(["pod", "--user-manifest", "manifest.toml"]).unwrap_err();
|
||||
let err =
|
||||
Cli::try_parse_from(["insomnia-pod", "--user-manifest", "manifest.toml"]).unwrap_err();
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_conflicts_with_project_and_overlay() {
|
||||
let project_err =
|
||||
Cli::try_parse_from(["pod", "--manifest", "manifest.toml", "--project", "."])
|
||||
.unwrap_err();
|
||||
let project_err = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--manifest",
|
||||
"manifest.toml",
|
||||
"--project",
|
||||
".",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(project_err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
||||
|
||||
let overlay_err = Cli::try_parse_from([
|
||||
"pod",
|
||||
"insomnia-pod",
|
||||
"--manifest",
|
||||
"manifest.toml",
|
||||
"--overlay",
|
||||
|
|
@ -391,7 +397,8 @@ permission = "write"
|
|||
let tmp = TempDir::new().unwrap();
|
||||
let manifest = tmp.path().join("manifest.toml");
|
||||
write(&manifest, &manifest_toml("single", tmp.path()));
|
||||
let cli = Cli::try_parse_from(["pod", "--manifest", manifest.to_str().unwrap()]).unwrap();
|
||||
let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()])
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml")))
|
||||
.unwrap_err();
|
||||
|
|
@ -405,7 +412,8 @@ permission = "write"
|
|||
let tmp = TempDir::new().unwrap();
|
||||
let manifest = tmp.path().join("manifest.toml");
|
||||
write(&manifest, &manifest_toml("single", tmp.path()));
|
||||
let cli = Cli::try_parse_from(["pod", "--manifest", manifest.to_str().unwrap()]).unwrap();
|
||||
let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, loader) =
|
||||
resolve_manifest_with_user_manifest_env(&cli, Some(OsString::new())).unwrap();
|
||||
|
|
@ -422,8 +430,12 @@ permission = "write"
|
|||
write(&user_manifest, &manifest_toml("from-env", tmp.path()));
|
||||
let no_project_root = tmp.path().join("no-project");
|
||||
std::fs::create_dir_all(&no_project_root).unwrap();
|
||||
let cli =
|
||||
Cli::try_parse_from(["pod", "--project", no_project_root.to_str().unwrap()]).unwrap();
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--project",
|
||||
no_project_root.to_str().unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(
|
||||
&cli,
|
||||
|
|
@ -438,8 +450,8 @@ permission = "write"
|
|||
fn pod_flag_conflicts_with_session() {
|
||||
let segment_id = session_store::new_segment_id();
|
||||
let segment_id = segment_id.to_string();
|
||||
let err =
|
||||
Cli::try_parse_from(["pod", "--pod", "agent", "--session", &segment_id]).unwrap_err();
|
||||
let err = Cli::try_parse_from(["insomnia-pod", "--pod", "agent", "--session", &segment_id])
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
|
||||
}
|
||||
|
||||
|
|
@ -449,7 +461,7 @@ permission = "write"
|
|||
let manifest = tmp.path().join("manifest.toml");
|
||||
write(&manifest, &manifest_toml("from-file", tmp.path()));
|
||||
let cli = Cli::try_parse_from([
|
||||
"pod",
|
||||
"insomnia-pod",
|
||||
"--manifest",
|
||||
manifest.to_str().unwrap(),
|
||||
"--pod",
|
||||
|
|
@ -471,7 +483,7 @@ permission = "write"
|
|||
&manifest_toml("unused", tmp.path()).replace("name = \"unused\"\n", ""),
|
||||
);
|
||||
let cli = Cli::try_parse_from([
|
||||
"pod",
|
||||
"insomnia-pod",
|
||||
"--manifest",
|
||||
manifest.to_str().unwrap(),
|
||||
"--pod",
|
||||
|
|
@ -491,8 +503,12 @@ permission = "write"
|
|||
write(&single_manifest, &manifest_toml("single-file", tmp.path()));
|
||||
std::fs::create_dir_all(tmp.path().join("prompts")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".insomnia").join("prompts")).unwrap();
|
||||
let cli =
|
||||
Cli::try_parse_from(["pod", "--manifest", single_manifest.to_str().unwrap()]).unwrap();
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--manifest",
|
||||
single_manifest.to_str().unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let (manifest, loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,9 @@ use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
|
|||
use crate::workflow::WorkflowResolveError;
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::interceptor::PreRequestAction;
|
||||
use protocol::{AlertLevel, AlertSource, Event, Segment};
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, Event, RewindSummary, RewindTarget, RewindTargetId, Segment,
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
|
|
@ -830,6 +832,85 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
&self.store
|
||||
}
|
||||
|
||||
/// List user-submitted turns in newest-first order for the manual rewind picker.
|
||||
pub fn list_rewind_targets(&self) -> Result<(usize, Vec<RewindTarget>), RewindError> {
|
||||
let loc = self.segment_state.location();
|
||||
let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
|
||||
Ok((
|
||||
entries.len(),
|
||||
build_rewind_targets(loc.segment_id, &entries),
|
||||
))
|
||||
}
|
||||
|
||||
/// Truncate the current segment to just before a previously listed user input.
|
||||
pub fn rewind_to(
|
||||
&mut self,
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
) -> Result<RewindAppliedState, RewindError> {
|
||||
let loc = self.segment_state.location();
|
||||
if target.segment_id != loc.segment_id {
|
||||
return Err(RewindError::Invalid(
|
||||
"rewind target belongs to a different segment".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
|
||||
if entries.len() != expected_head_entries {
|
||||
return Err(RewindError::Invalid(format!(
|
||||
"session head changed since picker opened (expected {expected_head_entries}, current {})",
|
||||
entries.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let Some(LogEntry::UserInput { segments, .. }) = entries.get(target.user_input_entry_index)
|
||||
else {
|
||||
return Err(RewindError::Invalid(
|
||||
"rewind target is no longer a user message".into(),
|
||||
));
|
||||
};
|
||||
let input = segments.clone();
|
||||
let truncate_entries = rewind_truncate_entries(&entries, target.user_input_entry_index);
|
||||
let retained = entries[..truncate_entries].to_vec();
|
||||
let tool_side_effect_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
|
||||
let state = segment_log::collect_state(&retained);
|
||||
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
||||
let task_store = tools::TaskStore::from_history(&state.history);
|
||||
let summary = RewindSummary {
|
||||
truncated_to_entries: truncate_entries,
|
||||
discarded_entries: entries.len().saturating_sub(truncate_entries),
|
||||
tool_side_effect_warning,
|
||||
};
|
||||
|
||||
self.store
|
||||
.truncate(loc.session_id, loc.segment_id, truncate_entries)?;
|
||||
self.segment_state.set_entries_written(truncate_entries);
|
||||
self.sink.truncate_silent(truncate_entries);
|
||||
|
||||
self.worker_mut().set_history(state.history);
|
||||
self.worker_mut().set_request_config(state.config);
|
||||
self.worker_mut().set_turn_count(state.turn_count);
|
||||
self.worker_mut()
|
||||
.set_last_run_interrupted(state.last_run_interrupted);
|
||||
self.user_segments = state.user_segments;
|
||||
*self.usage_history.lock().expect("usage_history poisoned") = state.usage_history;
|
||||
*self
|
||||
.pending_attachments
|
||||
.lock()
|
||||
.expect("pending_attachments poisoned") = Vec::new();
|
||||
*self
|
||||
.extract_pointer
|
||||
.lock()
|
||||
.expect("extract_pointer poisoned") = extract_pointer;
|
||||
self.task_store = task_store;
|
||||
|
||||
Ok(RewindAppliedState {
|
||||
entries: retained,
|
||||
input,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_pod_metadata_pending(&self) -> Result<(), StoreError> {
|
||||
let Some(writer) = &self.pod_metadata_writer else {
|
||||
return Ok(());
|
||||
|
|
@ -4328,6 +4409,110 @@ fn token_budget_bytes(tokens: u64) -> usize {
|
|||
}
|
||||
|
||||
/// Pod errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RewindError {
|
||||
#[error(transparent)]
|
||||
Store(#[from] StoreError),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RewindAppliedState {
|
||||
pub entries: Vec<LogEntry>,
|
||||
pub input: Vec<Segment>,
|
||||
pub summary: RewindSummary,
|
||||
}
|
||||
|
||||
fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<RewindTarget> {
|
||||
let head_entries = entries.len();
|
||||
let mut turn_index = 0usize;
|
||||
let mut targets = Vec::new();
|
||||
for (entry_index, entry) in entries.iter().enumerate() {
|
||||
if let LogEntry::UserInput { segments, ts } = entry {
|
||||
turn_index += 1;
|
||||
let truncate_entries = rewind_truncate_entries(entries, entry_index);
|
||||
let tool_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
|
||||
targets.push(RewindTarget {
|
||||
id: RewindTargetId {
|
||||
segment_id,
|
||||
user_input_entry_index: entry_index,
|
||||
},
|
||||
expected_head_entries: head_entries,
|
||||
truncate_entries,
|
||||
turn_index,
|
||||
timestamp_ms: Some(*ts),
|
||||
preview: preview_segments(segments),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: tool_warning.then(|| {
|
||||
"history suffix will be discarded; tool side effects are not undone".into()
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
targets.reverse();
|
||||
targets
|
||||
}
|
||||
|
||||
fn rewind_truncate_entries(entries: &[LogEntry], user_input_entry_index: usize) -> usize {
|
||||
if user_input_entry_index > 0
|
||||
&& matches!(
|
||||
entries.get(user_input_entry_index - 1),
|
||||
Some(LogEntry::Invoke { .. })
|
||||
)
|
||||
{
|
||||
user_input_entry_index - 1
|
||||
} else {
|
||||
user_input_entry_index
|
||||
}
|
||||
}
|
||||
|
||||
fn suffix_has_tool_side_effects(entries: &[LogEntry]) -> bool {
|
||||
entries.iter().any(|entry| match entry {
|
||||
LogEntry::ToolResult { .. } => true,
|
||||
LogEntry::AssistantItem { item, .. } => logged_item_is_tool_call(item),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn logged_item_is_tool_call(item: &session_store::LoggedItem) -> bool {
|
||||
matches!(item, session_store::LoggedItem::ToolCall { .. })
|
||||
}
|
||||
|
||||
fn preview_segments(segments: &[Segment]) -> String {
|
||||
let mut preview = String::new();
|
||||
for segment in segments {
|
||||
if !preview.is_empty() {
|
||||
preview.push(' ');
|
||||
}
|
||||
match segment {
|
||||
Segment::Text { content } => preview.push_str(content.trim()),
|
||||
Segment::Paste { content, .. } => preview.push_str(content.trim()),
|
||||
Segment::FileRef { path } => {
|
||||
preview.push('@');
|
||||
preview.push_str(path);
|
||||
}
|
||||
Segment::KnowledgeRef { slug } => {
|
||||
preview.push('#');
|
||||
preview.push_str(slug);
|
||||
}
|
||||
Segment::WorkflowInvoke { slug } => {
|
||||
preview.push('/');
|
||||
preview.push_str(slug);
|
||||
}
|
||||
Segment::Unknown => preview.push_str("[unknown input segment]"),
|
||||
}
|
||||
}
|
||||
let preview = preview.replace(['\n', '\r'], " ");
|
||||
let mut chars = preview.chars();
|
||||
let mut out: String = chars.by_ref().take(120).collect();
|
||||
if chars.next().is_some() {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PodError {
|
||||
#[error(transparent)]
|
||||
|
|
@ -4809,6 +4994,156 @@ mod build_summary_prompt_tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn text_segment(text: &str) -> Segment {
|
||||
Segment::Text {
|
||||
content: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rewind_test_pod() -> (tempfile::TempDir, Pod<NoopClient, session_store::FsStore>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest = minimal_manifest_with_skills(vec![]);
|
||||
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
|
||||
let pwd = dir.path().join("workspace");
|
||||
std::fs::create_dir_all(&pwd).unwrap();
|
||||
let scope = Scope::writable(&pwd).unwrap();
|
||||
let mut pod = Pod::new(manifest, Worker::new(NoopClient), store, pwd, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
pod.ensure_segment_head().unwrap();
|
||||
(dir, pod)
|
||||
}
|
||||
|
||||
fn append_test_entry(pod: &Pod<NoopClient, session_store::FsStore>, entry: LogEntry) {
|
||||
let loc = pod.segment_state.location();
|
||||
pod.store
|
||||
.append(loc.session_id, loc.segment_id, &entry)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn append_user_turn(pod: &Pod<NoopClient, session_store::FsStore>, ts: u64, text: &str) {
|
||||
append_test_entry(
|
||||
pod,
|
||||
LogEntry::Invoke {
|
||||
ts,
|
||||
trigger: protocol::InvokeKind::UserSend,
|
||||
},
|
||||
);
|
||||
append_test_entry(
|
||||
pod,
|
||||
LogEntry::UserInput {
|
||||
ts: ts + 1,
|
||||
segments: vec![text_segment(text)],
|
||||
},
|
||||
);
|
||||
append_test_entry(
|
||||
pod,
|
||||
LogEntry::TurnEnd {
|
||||
ts: ts + 2,
|
||||
turn_count: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewind_target_listing_is_newest_first_and_warns_on_tool_suffix() {
|
||||
let (_dir, pod) = rewind_test_pod().await;
|
||||
append_user_turn(&pod, 10, "first message");
|
||||
append_user_turn(&pod, 20, "second message");
|
||||
append_test_entry(
|
||||
&pod,
|
||||
LogEntry::ToolResult {
|
||||
ts: 30,
|
||||
item: session_store::LoggedItem::ToolResult {
|
||||
call_id: "call-1".into(),
|
||||
summary: "wrote a file".into(),
|
||||
content: None,
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let (head_entries, targets) = pod.list_rewind_targets().unwrap();
|
||||
let loc = pod.segment_state.location();
|
||||
|
||||
assert_eq!(
|
||||
head_entries,
|
||||
pod.store
|
||||
.read_all(loc.session_id, loc.segment_id)
|
||||
.unwrap()
|
||||
.len()
|
||||
);
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0].preview, "second message");
|
||||
assert_eq!(targets[1].preview, "first message");
|
||||
assert!(
|
||||
targets[0]
|
||||
.warning
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("tool side effects")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewind_apply_truncates_log_and_restores_selected_input() {
|
||||
let (_dir, mut pod) = rewind_test_pod().await;
|
||||
append_user_turn(&pod, 10, "first message");
|
||||
append_user_turn(&pod, 20, "second message");
|
||||
append_test_entry(
|
||||
&pod,
|
||||
LogEntry::ToolResult {
|
||||
ts: 30,
|
||||
item: session_store::LoggedItem::ToolResult {
|
||||
call_id: "call-1".into(),
|
||||
summary: "wrote a file".into(),
|
||||
content: None,
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
let (head_entries, targets) = pod.list_rewind_targets().unwrap();
|
||||
let expected_truncate_entries = targets[0].truncate_entries;
|
||||
let target = targets[0].id.clone();
|
||||
|
||||
let applied = pod.rewind_to(target, head_entries).unwrap();
|
||||
|
||||
assert_eq!(preview_segments(&applied.input), "second message");
|
||||
assert_eq!(
|
||||
applied.summary.truncated_to_entries,
|
||||
expected_truncate_entries
|
||||
);
|
||||
assert!(applied.summary.tool_side_effect_warning);
|
||||
let loc = pod.segment_state.location();
|
||||
assert_eq!(
|
||||
pod.store
|
||||
.read_all(loc.session_id, loc.segment_id)
|
||||
.unwrap()
|
||||
.len(),
|
||||
expected_truncate_entries
|
||||
);
|
||||
assert_eq!(pod.worker().history().len(), 1);
|
||||
assert_eq!(
|
||||
pod.worker().history()[0].as_text().unwrap(),
|
||||
"first message"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewind_apply_rejects_stale_head() {
|
||||
let (_dir, mut pod) = rewind_test_pod().await;
|
||||
append_user_turn(&pod, 10, "first message");
|
||||
let (head_entries, targets) = pod.list_rewind_targets().unwrap();
|
||||
append_user_turn(&pod, 20, "newer message");
|
||||
|
||||
let err = pod
|
||||
.rewind_to(targets[0].id.clone(), head_entries)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("session head changed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_interrupt_prep_appends_via_callback_and_logs_independent_entries() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -507,6 +507,18 @@ mod tests {
|
|||
assert!(rendered.contains("mark_read_required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_worker_prompts_do_not_include_default_memory_guidance() {
|
||||
let cat = PromptCatalog::builtins_only().unwrap();
|
||||
let compact = cat.compact_system().unwrap();
|
||||
let extract = cat.memory_extract_system("Japanese").unwrap();
|
||||
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
|
||||
for rendered in [compact, extract, consolidate] {
|
||||
assert!(!rendered.contains("### Memory and knowledge"));
|
||||
assert!(!rendered.contains("Do not query memory every turn"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_worker_prompts_include_language() {
|
||||
let cat = PromptCatalog::builtins_only().unwrap();
|
||||
|
|
|
|||
|
|
@ -440,7 +440,11 @@ mod tests {
|
|||
let rendered = tmpl
|
||||
.render(&ctx(dir.path(), &scope, vec!["Read".into()], None))
|
||||
.unwrap();
|
||||
// Builtin default body must expose the language policy.
|
||||
// Builtin default body must expose the tool and language policies.
|
||||
assert!(rendered.contains("### Memory and knowledge"));
|
||||
assert!(rendered.contains("MemoryQuery"));
|
||||
assert!(rendered.contains("MemoryRead(kind=summary)"));
|
||||
assert!(rendered.contains("Do not query memory every turn"));
|
||||
assert!(rendered.contains("## Language"));
|
||||
assert!(rendered.contains("`language`: `match the user's language"));
|
||||
// Trailing section must be present.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//!
|
||||
//! Wires pod-registry delegation, overlay-TOML construction, subprocess
|
||||
//! launch, and socket handoff into a single `Tool` implementation. When
|
||||
//! the LLM calls `SpawnPod`, a fresh `pod` binary is exec'd in its own
|
||||
//! the LLM calls `SpawnPod`, a fresh `insomnia-pod` binary is exec'd in its own
|
||||
//! process group, the pod-registry is updated atomically, and the child's
|
||||
//! first turn is kicked off by handing its socket a `Method::Run`.
|
||||
|
||||
|
|
@ -303,7 +303,8 @@ impl SpawnPodTool {
|
|||
overlay_toml: &str,
|
||||
predicted_socket: &Path,
|
||||
) -> Result<(), ToolError> {
|
||||
let pod_command = std::env::var("INSOMNIA_POD_COMMAND").unwrap_or_else(|_| "pod".into());
|
||||
let pod_command =
|
||||
std::env::var("INSOMNIA_POD_COMMAND").unwrap_or_else(|_| "insomnia-pod".into());
|
||||
|
||||
// Pre-create the child's runtime dir so we have a stable place to
|
||||
// capture its stderr before it has had a chance to bind anything.
|
||||
|
|
@ -381,7 +382,7 @@ fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Serialise the overlay TOML that gets handed to the child `pod`
|
||||
/// Serialise the overlay TOML that gets handed to the child `insomnia-pod`
|
||||
/// binary via `--overlay`. `PodManifestConfig`'s `Serialize` impl is
|
||||
/// the single source of truth for the on-disk manifest format.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//!
|
||||
//! These tests exercise the tool's pod-registry delegation, subprocess
|
||||
//! launch, socket handoff, and `spawned_pods.json` write without relying
|
||||
//! on the real `pod` binary. `INSOMNIA_POD_COMMAND` is pointed at
|
||||
//! on the real `insomnia-pod` binary. `INSOMNIA_POD_COMMAND` is pointed at
|
||||
//! `/bin/true` (which exits immediately) while a test-owned Unix
|
||||
//! listener pre-binds the predicted socket path, so the tool sees the
|
||||
//! "child" as live.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ pub enum Method {
|
|||
/// This is a typed control method: clients must not send `compact` as a
|
||||
/// `Method::Run` user message.
|
||||
Compact,
|
||||
/// Ask the Pod to list valid rewind targets from its authoritative session log.
|
||||
ListRewindTargets,
|
||||
/// Truncate the current session back to the selected rewind target and
|
||||
/// return the selected user input to the client composer.
|
||||
RewindTo {
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
},
|
||||
Shutdown,
|
||||
/// Request a list of completion candidates from the Pod.
|
||||
///
|
||||
|
|
@ -125,7 +133,7 @@ pub enum PodEvent {
|
|||
/// variants — emits an alert and inserts a `[unknown input segment]`
|
||||
/// placeholder into the LLM context so neither user nor LLM is blind to
|
||||
/// the dropped intent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Segment {
|
||||
/// Free-form text. The fallback every client can produce.
|
||||
|
|
@ -433,6 +441,19 @@ pub enum Event {
|
|||
kind: CompletionKind,
|
||||
entries: Vec<CompletionEntry>,
|
||||
},
|
||||
/// Reply to `Method::ListRewindTargets`. Clients should only open a picker
|
||||
/// in response to their own pending request; the event may be broadcast.
|
||||
RewindTargets {
|
||||
head_entries: usize,
|
||||
targets: Vec<RewindTarget>,
|
||||
},
|
||||
/// A rewind has truncated the authoritative session. `entries` is the
|
||||
/// retained session-log prefix clients should use to reseed display state.
|
||||
RewindApplied {
|
||||
entries: Vec<serde_json::Value>,
|
||||
input: Vec<Segment>,
|
||||
summary: RewindSummary,
|
||||
},
|
||||
/// Reply to `Method::ListVisiblePods`. Payload is a stable JSON value so
|
||||
/// the Pod crate can evolve discovery fields without introducing a protocol
|
||||
/// dependency on session-store.
|
||||
|
|
@ -545,6 +566,34 @@ pub struct CompletionEntry {
|
|||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RewindTargetId {
|
||||
pub segment_id: uuid::Uuid,
|
||||
pub user_input_entry_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RewindTarget {
|
||||
pub id: RewindTargetId,
|
||||
pub expected_head_entries: usize,
|
||||
pub truncate_entries: usize,
|
||||
pub turn_index: usize,
|
||||
pub timestamp_ms: Option<u64>,
|
||||
pub preview: String,
|
||||
pub eligible: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RewindSummary {
|
||||
pub truncated_to_entries: usize,
|
||||
pub discarded_entries: usize,
|
||||
pub tool_side_effect_warning: bool,
|
||||
}
|
||||
|
||||
/// Pod self-description rendered by the TUI when a session starts empty.
|
||||
///
|
||||
/// Built once in the Pod controller from the resolved manifest and
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ version = "0.1.0"
|
|||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "insomnia"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
client = { workspace = true }
|
||||
protocol = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -2,20 +2,29 @@ use std::collections::VecDeque;
|
|||
use std::time::Instant;
|
||||
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus, RunResult,
|
||||
Segment,
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus,
|
||||
RewindTarget, RunResult, Segment,
|
||||
};
|
||||
|
||||
use crate::block::{
|
||||
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
|
||||
};
|
||||
use crate::cache::FileCache;
|
||||
use crate::command::{CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry};
|
||||
use crate::command::{
|
||||
CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry,
|
||||
};
|
||||
use crate::input::InputBuffer;
|
||||
use crate::scroll::Scroll;
|
||||
use crate::task::TaskStore;
|
||||
use crate::ui::Mode;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandCompletionApply {
|
||||
Applied,
|
||||
Ambiguous,
|
||||
NoCandidates,
|
||||
}
|
||||
|
||||
/// In-flight completion popup state. Lives on `App` while the user is
|
||||
/// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger
|
||||
/// is invalidated (cursor moved out, whitespace landed inside the
|
||||
|
|
@ -42,6 +51,38 @@ impl CompletionState {
|
|||
pub const MAX_VISIBLE: usize = 6;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RewindPickerScroll {
|
||||
pub top_offset: usize,
|
||||
pub total_lines: usize,
|
||||
pub area_height: u16,
|
||||
pub tail_top_offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewindPickerState {
|
||||
pub head_entries: usize,
|
||||
pub targets: Vec<RewindTarget>,
|
||||
pub selected: usize,
|
||||
pub scroll: RewindPickerScroll,
|
||||
}
|
||||
|
||||
impl RewindPickerState {
|
||||
pub fn new(head_entries: usize, targets: Vec<RewindTarget>) -> Self {
|
||||
let selected = targets.iter().position(|t| t.eligible).unwrap_or(0);
|
||||
Self {
|
||||
head_entries,
|
||||
targets,
|
||||
selected,
|
||||
scroll: RewindPickerScroll::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_target(&self) -> Option<&RewindTarget> {
|
||||
self.targets.get(self.selected)
|
||||
}
|
||||
}
|
||||
|
||||
struct RollbackSubmitState {
|
||||
text: String,
|
||||
segments: Vec<Segment>,
|
||||
|
|
@ -99,6 +140,7 @@ pub struct App {
|
|||
pub command_input: InputBuffer,
|
||||
pub input_mode: CommandInputMode,
|
||||
pub command_registry: CommandRegistry,
|
||||
command_completion_selected: Option<usize>,
|
||||
pub quit: bool,
|
||||
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
|
||||
/// records the instant; a second press within the timeout exits the
|
||||
|
|
@ -116,6 +158,10 @@ pub struct App {
|
|||
/// Completion popup state, when an `@` / `#` / `/` token is in
|
||||
/// flight. `None` whenever the trigger conditions don't hold.
|
||||
pub completion: Option<CompletionState>,
|
||||
/// Dedicated main-view rewind picker state.
|
||||
pub rewind_picker: Option<RewindPickerState>,
|
||||
rewind_request_pending: bool,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
/// In-TUI mirror of the Pod's session task store, reconstructed
|
||||
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
|
||||
/// `[Session TaskStore snapshot]` system messages — no protocol
|
||||
|
|
@ -158,6 +204,7 @@ impl App {
|
|||
command_input: InputBuffer::new(),
|
||||
input_mode: CommandInputMode::Composer,
|
||||
command_registry: CommandRegistry::default(),
|
||||
command_completion_selected: None,
|
||||
quit: false,
|
||||
quit_confirm: None,
|
||||
blocks: Vec::new(),
|
||||
|
|
@ -166,6 +213,9 @@ impl App {
|
|||
cache: FileCache::new(),
|
||||
assistant_streaming: false,
|
||||
completion: None,
|
||||
rewind_picker: None,
|
||||
rewind_request_pending: false,
|
||||
greeting: None,
|
||||
task_store: TaskStore::new(),
|
||||
task_pane_open: false,
|
||||
task_pane_scroll: 0,
|
||||
|
|
@ -910,6 +960,54 @@ impl App {
|
|||
state.selected = 0;
|
||||
}
|
||||
}
|
||||
Event::RewindTargets {
|
||||
head_entries,
|
||||
targets,
|
||||
} => {
|
||||
if self.rewind_request_pending {
|
||||
self.rewind_request_pending = false;
|
||||
self.rewind_picker = Some(RewindPickerState::new(head_entries, targets));
|
||||
}
|
||||
}
|
||||
Event::RewindApplied {
|
||||
entries,
|
||||
input,
|
||||
summary,
|
||||
} => {
|
||||
if let Some(greeting) = self.greeting.clone() {
|
||||
self.restore_snapshot(&entries, greeting);
|
||||
}
|
||||
let restored_composer = if self.input.is_empty() {
|
||||
self.input.replace_with_segments(&input);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
self.completion = None;
|
||||
self.close_rewind_picker();
|
||||
self.reset_run_state(self.pod_status);
|
||||
let mut message = if restored_composer {
|
||||
format!(
|
||||
"Rewound session: discarded {} log entries; restored selected input to composer.",
|
||||
summary.discarded_entries
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Rewound session: discarded {} log entries. Rewind applied; composer not overwritten because it was not empty.",
|
||||
summary.discarded_entries
|
||||
)
|
||||
};
|
||||
if summary.tool_side_effect_warning {
|
||||
message.push_str(
|
||||
" History suffix was discarded; tool side effects were not undone.",
|
||||
);
|
||||
}
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Warn,
|
||||
source: AlertSource::Pod,
|
||||
message,
|
||||
});
|
||||
}
|
||||
Event::VisiblePods { .. }
|
||||
| Event::PodInspection { .. }
|
||||
| Event::PodAttachRestore { .. } => {}
|
||||
|
|
@ -1078,26 +1176,213 @@ impl App {
|
|||
pub fn enter_command_mode(&mut self) {
|
||||
self.input_mode = CommandInputMode::Command;
|
||||
self.completion = None;
|
||||
self.command_completion_selected = None;
|
||||
self.quit_confirm = None;
|
||||
}
|
||||
|
||||
pub fn exit_command_mode(&mut self) {
|
||||
self.input_mode = CommandInputMode::Composer;
|
||||
self.command_input.clear();
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
|
||||
pub fn clear_command_input(&mut self) {
|
||||
self.command_input.clear();
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
|
||||
pub fn command_text(&self) -> String {
|
||||
self.command_input.plain_text()
|
||||
}
|
||||
|
||||
pub fn command_suggestions(&self) -> Vec<crate::command::CommandCandidate> {
|
||||
pub fn command_suggestions(&self) -> Vec<CommandCandidate> {
|
||||
self.command_registry.suggest(&self.command_text())
|
||||
}
|
||||
|
||||
pub fn command_completion_selected(&self) -> Option<usize> {
|
||||
let selected = self.command_completion_selected?;
|
||||
(selected < self.command_suggestions().len()).then_some(selected)
|
||||
}
|
||||
|
||||
pub fn command_completion_active(&self) -> bool {
|
||||
!self.command_suggestions().is_empty()
|
||||
}
|
||||
|
||||
pub fn move_command_completion_up(&mut self) {
|
||||
let len = self.command_suggestions().len();
|
||||
if len == 0 {
|
||||
self.command_completion_selected = None;
|
||||
return;
|
||||
}
|
||||
self.command_completion_selected = Some(match self.command_completion_selected() {
|
||||
Some(0) | None => len - 1,
|
||||
Some(selected) => selected - 1,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn move_command_completion_down(&mut self) {
|
||||
let len = self.command_suggestions().len();
|
||||
if len == 0 {
|
||||
self.command_completion_selected = None;
|
||||
return;
|
||||
}
|
||||
self.command_completion_selected = Some(match self.command_completion_selected() {
|
||||
Some(selected) => (selected + 1) % len,
|
||||
None => 0,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn apply_command_completion(&mut self) -> CommandCompletionApply {
|
||||
let suggestions = self.command_suggestions();
|
||||
let candidate = match self.command_completion_selected() {
|
||||
Some(selected) => suggestions.get(selected),
|
||||
None if suggestions.len() == 1 => suggestions.first(),
|
||||
None if suggestions.is_empty() => return CommandCompletionApply::NoCandidates,
|
||||
None => return self.ambiguous_command_completion(),
|
||||
};
|
||||
|
||||
let Some(candidate) = candidate else {
|
||||
self.command_completion_selected = None;
|
||||
return CommandCompletionApply::NoCandidates;
|
||||
};
|
||||
self.replace_command_name(candidate.name);
|
||||
self.command_completion_selected = None;
|
||||
CommandCompletionApply::Applied
|
||||
}
|
||||
|
||||
pub fn submit_command_with_completion(&mut self) -> Option<Method> {
|
||||
let selected = self.command_completion_selected().is_some();
|
||||
let command_text = self.command_text();
|
||||
if command_text.trim().is_empty() && !selected {
|
||||
return self.submit_command();
|
||||
}
|
||||
if !selected && self.command_name_is_complete(&command_text) {
|
||||
return self.submit_command();
|
||||
}
|
||||
|
||||
match self.apply_command_completion() {
|
||||
CommandCompletionApply::Applied | CommandCompletionApply::NoCandidates => {
|
||||
self.submit_command()
|
||||
}
|
||||
CommandCompletionApply::Ambiguous => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ambiguous_command_completion(&mut self) -> CommandCompletionApply {
|
||||
self.push_command_diagnostic(
|
||||
"Ambiguous command completion; select a candidate with Up/Down or keep typing.",
|
||||
);
|
||||
CommandCompletionApply::Ambiguous
|
||||
}
|
||||
|
||||
fn command_name_is_complete(&self, command_line: &str) -> bool {
|
||||
let trimmed = command_line.trim_start();
|
||||
let name = trimmed
|
||||
.find(char::is_whitespace)
|
||||
.map(|idx| &trimmed[..idx])
|
||||
.unwrap_or(trimmed);
|
||||
!name.is_empty() && self.command_registry.find(name).is_some()
|
||||
}
|
||||
|
||||
fn replace_command_name(&mut self, canonical_name: &str) {
|
||||
let command_line = self.command_text();
|
||||
let leading_len = command_line.len() - command_line.trim_start().len();
|
||||
let after_leading = &command_line[leading_len..];
|
||||
let name_end = after_leading
|
||||
.find(char::is_whitespace)
|
||||
.map(|idx| leading_len + idx)
|
||||
.unwrap_or(command_line.len());
|
||||
let rest = &command_line[name_end..];
|
||||
|
||||
let mut completed = String::with_capacity(command_line.len().max(canonical_name.len() + 1));
|
||||
completed.push_str(&command_line[..leading_len]);
|
||||
completed.push_str(canonical_name);
|
||||
if rest.is_empty() {
|
||||
completed.push(' ');
|
||||
} else {
|
||||
completed.push_str(rest);
|
||||
}
|
||||
|
||||
self.command_input.clear();
|
||||
self.command_input.insert_str(&completed);
|
||||
}
|
||||
|
||||
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
||||
if !self.connected {
|
||||
self.push_command_diagnostic("cannot rewind before the Pod is connected");
|
||||
return None;
|
||||
}
|
||||
if self.running {
|
||||
self.push_command_diagnostic("cannot rewind while the Pod is running");
|
||||
return None;
|
||||
}
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
Some(Method::ListRewindTargets)
|
||||
}
|
||||
|
||||
pub fn close_rewind_picker(&mut self) {
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = false;
|
||||
}
|
||||
|
||||
pub fn rewind_picker_up(&mut self) {
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
if picker.targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
picker.selected = if picker.selected == 0 {
|
||||
picker.targets.len() - 1
|
||||
} else {
|
||||
picker.selected - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rewind_picker_down(&mut self) {
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
if !picker.targets.is_empty() {
|
||||
picker.selected = (picker.selected + 1) % picker.targets.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit_rewind_picker(&mut self) -> Option<Method> {
|
||||
if self.paused {
|
||||
self.push_command_diagnostic(
|
||||
"cannot apply rewind while the Pod is paused; resume or wait for idle first",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if !self.input.is_empty() {
|
||||
self.push_command_diagnostic(
|
||||
"cannot apply rewind while composer is not empty; clear it before restoring rewind input",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let Some(picker) = self.rewind_picker.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let Some(target) = picker.selected_target() else {
|
||||
self.push_command_diagnostic("no rewind target is available");
|
||||
return None;
|
||||
};
|
||||
if !target.eligible {
|
||||
self.push_command_diagnostic(
|
||||
target
|
||||
.disabled_reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| "rewind target is disabled".into()),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(Method::RewindTo {
|
||||
target: target.id.clone(),
|
||||
expected_head_entries: target.expected_head_entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn command_environment(&self) -> CommandEnvironment {
|
||||
CommandEnvironment {
|
||||
connected: self.connected,
|
||||
|
|
@ -1119,9 +1404,16 @@ impl App {
|
|||
}
|
||||
if result.clear_input {
|
||||
self.command_input.clear();
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
if result.exit_command_mode {
|
||||
self.input_mode = CommandInputMode::Composer;
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
if let Some(Method::ListRewindTargets) = result.method.as_ref() {
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
}
|
||||
result.method
|
||||
}
|
||||
|
|
@ -1146,23 +1438,40 @@ impl App {
|
|||
// stay readable. In command mode these operate on the command line,
|
||||
// keeping the normal composer buffer intact.
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
let command_mode = self.is_command_mode();
|
||||
self.active_input_mut().insert_char(c);
|
||||
if command_mode {
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
}
|
||||
pub fn insert_newline(&mut self) {
|
||||
let command_mode = self.is_command_mode();
|
||||
self.active_input_mut().insert_newline();
|
||||
if command_mode {
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
}
|
||||
pub fn insert_paste(&mut self, content: String) {
|
||||
if self.is_command_mode() {
|
||||
self.command_input.insert_str(&content);
|
||||
self.command_completion_selected = None;
|
||||
} else {
|
||||
self.input.insert_paste(content);
|
||||
}
|
||||
}
|
||||
pub fn delete_char_before(&mut self) {
|
||||
let command_mode = self.is_command_mode();
|
||||
self.active_input_mut().delete_before();
|
||||
if command_mode {
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
}
|
||||
pub fn delete_char_after(&mut self) {
|
||||
let command_mode = self.is_command_mode();
|
||||
self.active_input_mut().delete_after();
|
||||
if command_mode {
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
}
|
||||
pub fn move_cursor_left(&mut self) {
|
||||
self.active_input_mut().move_left();
|
||||
|
|
@ -1193,6 +1502,7 @@ impl App {
|
|||
/// produced. Followed by `Event::Entry` updates for anything
|
||||
/// committed after the snapshot.
|
||||
fn restore_snapshot(&mut self, entries: &[serde_json::Value], greeting: protocol::Greeting) {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
self.turn_index = 0;
|
||||
|
|
|
|||
|
|
@ -147,6 +147,15 @@ impl CommandRegistry {
|
|||
can_execute: compact_available,
|
||||
executor: compact_command,
|
||||
});
|
||||
registry.register(CommandSpec {
|
||||
name: "rewind",
|
||||
aliases: &["rollback"],
|
||||
usage: "rewind",
|
||||
description: "Open the rewind target picker.",
|
||||
argument_parser: rewind_args,
|
||||
can_execute: rewind_available,
|
||||
executor: rewind_command,
|
||||
});
|
||||
registry
|
||||
}
|
||||
|
||||
|
|
@ -284,6 +293,15 @@ fn compact_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
|||
}
|
||||
}
|
||||
|
||||
fn rewind_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||
let args = CommandArgs::parse_whitespace(raw);
|
||||
if args.argv().is_empty() {
|
||||
Ok(args)
|
||||
} else {
|
||||
Err(CommandDiagnostic::new("Invalid arguments. Usage: rewind"))
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||
if !environment.connected {
|
||||
return Err(CommandDiagnostic::new(
|
||||
|
|
@ -303,6 +321,20 @@ fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiag
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||
if !environment.connected {
|
||||
return Err(CommandDiagnostic::new(
|
||||
"Cannot rewind before the Pod is connected.",
|
||||
));
|
||||
}
|
||||
if environment.running {
|
||||
return Err(CommandDiagnostic::new(
|
||||
"Cannot rewind while the Pod is running.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn help_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
if let Some(name) = invocation.args.argv().first() {
|
||||
let Some(command) = invocation.registry.find(name) else {
|
||||
|
|
@ -350,6 +382,18 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
|||
}
|
||||
}
|
||||
|
||||
fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.command;
|
||||
let _ = invocation.environment;
|
||||
let _ = invocation.args.raw();
|
||||
CommandExecution {
|
||||
method: Some(Method::ListRewindTargets),
|
||||
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
|
||||
exit_command_mode: true,
|
||||
clear_input: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -421,4 +465,40 @@ mod tests {
|
|||
assert!(result.method.is_none());
|
||||
assert!(result.diagnostics[0].message.contains("paused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_command_and_alias_return_list_method() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
for command in ["rewind", "rollback"] {
|
||||
let result = registry.dispatch(command, &env());
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
assert!(result.exit_command_mode);
|
||||
assert!(result.clear_input);
|
||||
assert!(result.diagnostics[0].message.contains("rewind picker"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_invalid_arguments_are_local_diagnostic() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
let result = registry.dispatch("rewind now", &env());
|
||||
assert!(result.method.is_none());
|
||||
assert!(!result.exit_command_mode);
|
||||
assert!(result.diagnostics[0].message.contains("Invalid arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_rejects_running_but_allows_paused() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
let mut running = env();
|
||||
running.running = true;
|
||||
let result = registry.dispatch("rewind", &running);
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.diagnostics[0].message.contains("running"));
|
||||
|
||||
let mut paused = env();
|
||||
paused.paused = true;
|
||||
let result = registry.dispatch("rewind", &paused);
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,8 @@ pub struct InputBuffer {
|
|||
atoms: Vec<Atom>,
|
||||
/// Insertion point in `0..=atoms.len()`.
|
||||
cursor: usize,
|
||||
/// Top wrapped row of the visible composer viewport.
|
||||
scroll_offset: usize,
|
||||
/// Monotonic counter reused across the TUI process lifetime.
|
||||
next_paste_id: u32,
|
||||
}
|
||||
|
|
@ -153,6 +155,7 @@ impl Default for InputBuffer {
|
|||
Self {
|
||||
atoms: Vec::new(),
|
||||
cursor: 0,
|
||||
scroll_offset: 0,
|
||||
next_paste_id: 1,
|
||||
}
|
||||
}
|
||||
|
|
@ -166,6 +169,7 @@ impl InputBuffer {
|
|||
pub fn clear(&mut self) {
|
||||
self.atoms.clear();
|
||||
self.cursor = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
|
|
@ -697,8 +701,27 @@ impl InputBuffer {
|
|||
lines,
|
||||
cursor_row,
|
||||
cursor_col,
|
||||
viewport_start_row: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip a full render to `visible_height` rows, updating the stored
|
||||
/// vertical scroll offset just enough to keep the cursor row visible.
|
||||
pub fn apply_cursor_viewport(&mut self, render: &mut InputRender, visible_height: u16) {
|
||||
let height = visible_height.max(1) as usize;
|
||||
let total_rows = render.lines.len().max(1);
|
||||
let max_offset = total_rows.saturating_sub(height);
|
||||
self.scroll_offset = self.scroll_offset.min(max_offset);
|
||||
|
||||
let cursor_row = render.cursor_row as usize;
|
||||
if cursor_row < self.scroll_offset {
|
||||
self.scroll_offset = cursor_row;
|
||||
} else if cursor_row >= self.scroll_offset.saturating_add(height) {
|
||||
self.scroll_offset = cursor_row.saturating_add(1).saturating_sub(height);
|
||||
}
|
||||
self.scroll_offset = self.scroll_offset.min(max_offset);
|
||||
render.apply_viewport(self.scroll_offset, height);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a single char, wrapping to a new row first when it would
|
||||
|
|
@ -746,6 +769,119 @@ pub struct InputRender {
|
|||
pub lines: Vec<Line<'static>>,
|
||||
pub cursor_row: u16,
|
||||
pub cursor_col: u16,
|
||||
/// First wrapped row included in `lines` after viewport clipping.
|
||||
pub viewport_start_row: u16,
|
||||
}
|
||||
|
||||
impl InputRender {
|
||||
fn apply_viewport(&mut self, offset: usize, height: usize) {
|
||||
let offset = offset.min(self.lines.len().saturating_sub(1));
|
||||
self.viewport_start_row = offset as u16;
|
||||
self.cursor_row = self.cursor_row.saturating_sub(self.viewport_start_row);
|
||||
let lines = std::mem::take(&mut self.lines);
|
||||
self.lines = lines.into_iter().skip(offset).take(height).collect();
|
||||
if self.lines.is_empty() {
|
||||
self.lines.push(Line::raw(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_viewport_tests {
|
||||
use super::*;
|
||||
|
||||
fn buf_from(text: &str) -> InputBuffer {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in text.chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn render_lines(buf: &mut InputBuffer, width: u16, height: u16) -> Vec<String> {
|
||||
let mut render = buf.render(width);
|
||||
buf.apply_cursor_viewport(&mut render, height);
|
||||
render
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_input_rendering_stays_unscrolled() {
|
||||
let mut buf = buf_from("one\ntwo");
|
||||
let mut render = buf.render(20);
|
||||
buf.apply_cursor_viewport(&mut render, 5);
|
||||
|
||||
assert_eq!(buf.scroll_offset, 0);
|
||||
assert_eq!(render.viewport_start_row, 0);
|
||||
assert_eq!(render.cursor_row, 1);
|
||||
assert_eq!(render.cursor_col, 3);
|
||||
assert_eq!(render_lines(&mut buf, 20, 5), ["one", "two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_viewport_follows_cursor_at_bottom() {
|
||||
let mut buf = buf_from("0\n1\n2\n3\n4");
|
||||
let mut render = buf.render(20);
|
||||
buf.apply_cursor_viewport(&mut render, 3);
|
||||
|
||||
assert_eq!(buf.scroll_offset, 2);
|
||||
assert_eq!(render.viewport_start_row, 2);
|
||||
assert_eq!(render.cursor_row, 2);
|
||||
assert_eq!(render.cursor_col, 1);
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_viewport_scrolls_when_cursor_moves_above_or_below() {
|
||||
let mut buf = buf_from("0\n1\n2\n3\n4");
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]);
|
||||
assert_eq!(buf.scroll_offset, 2);
|
||||
|
||||
buf.move_up();
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]);
|
||||
assert_eq!(buf.scroll_offset, 2);
|
||||
|
||||
buf.move_up();
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]);
|
||||
assert_eq!(buf.scroll_offset, 2);
|
||||
|
||||
buf.move_up();
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["1", "2", "3"]);
|
||||
assert_eq!(buf.scroll_offset, 1);
|
||||
|
||||
buf.move_down();
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["1", "2", "3"]);
|
||||
assert_eq!(buf.scroll_offset, 1);
|
||||
|
||||
buf.move_down();
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["1", "2", "3"]);
|
||||
assert_eq!(buf.scroll_offset, 1);
|
||||
|
||||
buf.move_down();
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["2", "3", "4"]);
|
||||
assert_eq!(buf.scroll_offset, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_viewport_clamps_after_line_deletion() {
|
||||
let mut buf = buf_from("0\n1\n2\n3\n4\n5");
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["3", "4", "5"]);
|
||||
assert_eq!(buf.scroll_offset, 3);
|
||||
|
||||
for _ in 0..6 {
|
||||
buf.delete_before();
|
||||
}
|
||||
assert_eq!(render_lines(&mut buf, 20, 3), ["0", "1", "2"]);
|
||||
assert_eq!(buf.scroll_offset, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ use crate::app::App;
|
|||
use crate::picker::PickerOutcome;
|
||||
use crate::spawn::{SpawnOutcome, SpawnReady};
|
||||
|
||||
type FullscreenTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
||||
|
||||
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||
if let Some(p) = override_path {
|
||||
return p;
|
||||
|
|
@ -59,20 +61,20 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
|||
#[derive(Debug)]
|
||||
enum Mode {
|
||||
Spawn,
|
||||
/// `tui <name>` / `tui --pod <name>`: attach to a live Pod by name if
|
||||
/// possible; otherwise launch `pod --pod <name>` so the pod process
|
||||
/// `insomnia <name>` / `insomnia --pod <name>`: attach to a live Pod by name if
|
||||
/// possible; otherwise launch `insomnia-pod --pod <name>` so the pod process
|
||||
/// resumes from name-keyed state or creates a fresh same-name Pod.
|
||||
PodName {
|
||||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
/// `tui -r` / `tui --resume`: open the Pod picker, then attach to the
|
||||
/// `insomnia -r` / `insomnia --resume`: open the Pod picker, then attach to the
|
||||
/// selected live Pod or restore the selected stopped Pod by name.
|
||||
Resume,
|
||||
/// `tui --session <UUID>`: skip the picker, go straight to the
|
||||
/// `insomnia --session <UUID>`: skip the picker, go straight to the
|
||||
/// resume name dialog with `id` baked in.
|
||||
ResumeWithSession(SegmentId),
|
||||
/// `tui --multi`: open the multi-Pod dashboard. This is intentionally
|
||||
/// `insomnia --multi`: open the multi-Pod dashboard. This is intentionally
|
||||
/// separate from `-r`/`--resume`, which keeps its single-Pod picker
|
||||
/// meaning.
|
||||
Multi,
|
||||
|
|
@ -230,18 +232,18 @@ async fn main() -> ExitCode {
|
|||
let mode = match parse_args() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("tui: {e}");
|
||||
eprintln!("insomnia: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = enable_raw_mode() {
|
||||
eprintln!("tui: failed to enter raw mode: {e}");
|
||||
eprintln!("insomnia: failed to enter raw mode: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
if let Err(e) = execute!(io::stdout(), EnableBracketedPaste) {
|
||||
let _ = disable_raw_mode();
|
||||
eprintln!("tui: {e}");
|
||||
eprintln!("insomnia: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
|
|
@ -278,7 +280,7 @@ async fn main() -> ExitCode {
|
|||
// duplicate. Other errors (pod-name failures, terminal setup
|
||||
// hiccups, etc.) need surfacing here.
|
||||
if e.downcast_ref::<spawn::SpawnError>().is_none() {
|
||||
eprintln!("tui: {e}");
|
||||
eprintln!("insomnia: {e}");
|
||||
}
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
|
@ -289,33 +291,95 @@ async fn run_pod_name(
|
|||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let preferred_socket = resolve_socket(&pod_name, socket_override.clone());
|
||||
if let Some((_socket_path, client)) =
|
||||
connect_live_pod(&pod_name, preferred_socket, socket_override.is_none()).await
|
||||
{
|
||||
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await {
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
let mut app = App::new(pod_name);
|
||||
app.connected = true;
|
||||
return run_loop(&mut terminal, &mut app, client).await;
|
||||
run_connected_pod(&mut terminal, pod_name, client).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ready = match spawn::run_pod_name(pod_name).await? {
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
SpawnOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
terminal.clear()?;
|
||||
let result = run_ready_pod(&mut terminal, ready).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_connected_pod(
|
||||
terminal: &mut FullscreenTerminal,
|
||||
pod_name: String,
|
||||
client: PodClient,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut app = App::new(pod_name);
|
||||
app.connected = true;
|
||||
run_loop(terminal, &mut app, client).await
|
||||
}
|
||||
|
||||
async fn run_pod_name_nested(
|
||||
terminal: &mut FullscreenTerminal,
|
||||
request: multi_pod::OpenPodRequest,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let multi_pod::OpenPodRequest {
|
||||
pod_name,
|
||||
socket_override,
|
||||
} = request;
|
||||
|
||||
if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await {
|
||||
return run_connected_pod(terminal, pod_name, client).await;
|
||||
}
|
||||
|
||||
let ready = spawn_pod_name_from_fullscreen(terminal, &pod_name).await?;
|
||||
run_ready_pod(terminal, ready).await
|
||||
}
|
||||
|
||||
async fn spawn_pod_name_from_fullscreen(
|
||||
terminal: &mut FullscreenTerminal,
|
||||
pod_name: &str,
|
||||
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
|
||||
leave_fullscreen(terminal)?;
|
||||
let outcome = spawn::run_pod_name(pod_name.to_string()).await;
|
||||
enter_fullscreen_existing(terminal)?;
|
||||
terminal.clear()?;
|
||||
|
||||
match outcome? {
|
||||
SpawnOutcome::Ready(ready) => Ok(ready),
|
||||
SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_connect_live_pod(
|
||||
pod_name: &str,
|
||||
socket_override: Option<PathBuf>,
|
||||
) -> Option<PodClient> {
|
||||
let preferred_socket = resolve_socket(pod_name, socket_override.clone());
|
||||
connect_live_pod(pod_name, preferred_socket, socket_override.is_none())
|
||||
.await
|
||||
.map(|(_, client)| client)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NestedOpenCancelled;
|
||||
|
||||
impl std::fmt::Display for NestedOpenCancelled {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Pod open was cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NestedOpenCancelled {}
|
||||
|
||||
async fn run_ready_pod(
|
||||
terminal: &mut FullscreenTerminal,
|
||||
ready: SpawnReady,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let SpawnReady {
|
||||
pod_name,
|
||||
socket_path,
|
||||
} = ready;
|
||||
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
let result = run(&mut terminal, pod_name, &socket_path).await;
|
||||
let _ = execute!(
|
||||
terminal.backend_mut(),
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
result
|
||||
run(terminal, pod_name, &socket_path).await
|
||||
}
|
||||
|
||||
async fn connect_live_pod(
|
||||
|
|
@ -354,24 +418,37 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
|
|||
}
|
||||
|
||||
async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut app = multi_pod::load_app().await?;
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
let outcome = multi_pod::run(&mut terminal).await;
|
||||
|
||||
let _ = execute!(
|
||||
terminal.backend_mut(),
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen
|
||||
);
|
||||
|
||||
match outcome? {
|
||||
multi_pod::MultiPodOutcome::Quit => Ok(()),
|
||||
multi_pod::MultiPodOutcome::Open {
|
||||
pod_name,
|
||||
socket_override,
|
||||
} => run_pod_name(pod_name, socket_override).await,
|
||||
loop {
|
||||
match multi_pod::run(&mut terminal, &mut app).await? {
|
||||
multi_pod::MultiPodOutcome::Quit => {
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
return Ok(());
|
||||
}
|
||||
multi_pod::MultiPodOutcome::Open(request) => {
|
||||
let pod_name = request.pod_name.clone();
|
||||
match run_pod_name_nested(&mut terminal, request).await {
|
||||
Ok(()) => app.finish_open(&pod_name, Ok(())),
|
||||
Err(error) if is_recoverable_multi_open_error(error.as_ref()) => {
|
||||
app.finish_open(&pod_name, Err(error.as_ref()));
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
app.reload().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_recoverable_multi_open_error(error: &(dyn std::error::Error + 'static)) -> bool {
|
||||
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
|
||||
}
|
||||
|
||||
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ready = match spawn::run(resume_from).await? {
|
||||
SpawnOutcome::Ready(r) => r,
|
||||
|
|
@ -396,16 +473,34 @@ async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::er
|
|||
result
|
||||
}
|
||||
|
||||
fn enter_fullscreen() -> Result<Terminal<CrosstermBackend<io::Stdout>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
fn enter_fullscreen() -> Result<FullscreenTerminal, Box<dyn std::error::Error>> {
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
Ok(Terminal::new(backend)?)
|
||||
}
|
||||
|
||||
fn enter_fullscreen_existing(
|
||||
terminal: &mut FullscreenTerminal,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leave_fullscreen(terminal: &mut FullscreenTerminal) -> io::Result<()> {
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen
|
||||
)
|
||||
}
|
||||
|
||||
async fn run(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
terminal: &mut FullscreenTerminal,
|
||||
pod_name: String,
|
||||
socket_path: &std::path::Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
|
@ -438,7 +533,7 @@ const POD_EVENT_DRAIN_LIMIT: usize = 32;
|
|||
|
||||
struct TerminalEventReader {
|
||||
stop: Arc<AtomicBool>,
|
||||
_thread: thread::JoinHandle<()>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl TerminalEventReader {
|
||||
|
|
@ -453,7 +548,7 @@ impl TerminalEventReader {
|
|||
Ok((
|
||||
Self {
|
||||
stop,
|
||||
_thread: thread,
|
||||
thread: Some(thread),
|
||||
},
|
||||
rx,
|
||||
))
|
||||
|
|
@ -463,6 +558,9 @@ impl TerminalEventReader {
|
|||
impl Drop for TerminalEventReader {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -703,6 +801,9 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.toggle_task_pane();
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'r') && ctrl => {
|
||||
Some(app.request_rewind_picker())
|
||||
}
|
||||
KeyCode::Char('a') if ctrl => {
|
||||
app.move_cursor_start();
|
||||
Some(app.refresh_completion())
|
||||
|
|
@ -782,6 +883,25 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
return handle_command_key(app, key);
|
||||
}
|
||||
|
||||
if app.rewind_picker.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.close_rewind_picker();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Enter => return app.submit_rewind_picker(),
|
||||
KeyCode::Up => {
|
||||
app.rewind_picker_up();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.rewind_picker_down();
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Completion popup overrides — only when there's something to
|
||||
// navigate / commit. An empty popup (request in flight) falls
|
||||
// through to the default behaviour.
|
||||
|
|
@ -894,7 +1014,7 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.exit_command_mode();
|
||||
None
|
||||
}
|
||||
KeyCode::Enter => app.submit_command(),
|
||||
KeyCode::Enter => app.submit_command_with_completion(),
|
||||
KeyCode::Backspace => {
|
||||
if app.command_text().is_empty() {
|
||||
app.exit_command_mode();
|
||||
|
|
@ -916,11 +1036,19 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
None
|
||||
}
|
||||
KeyCode::Up => {
|
||||
app.move_cursor_up();
|
||||
if app.command_completion_active() {
|
||||
app.move_command_completion_up();
|
||||
} else {
|
||||
app.move_cursor_up();
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.move_cursor_down();
|
||||
if app.command_completion_active() {
|
||||
app.move_command_completion_down();
|
||||
} else {
|
||||
app.move_cursor_down();
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Home => {
|
||||
|
|
@ -931,7 +1059,10 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.move_cursor_end();
|
||||
None
|
||||
}
|
||||
KeyCode::Tab => None,
|
||||
KeyCode::Tab => {
|
||||
app.apply_command_completion();
|
||||
None
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if key
|
||||
.modifiers
|
||||
|
|
@ -970,6 +1101,7 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
|
||||
|
||||
#[test]
|
||||
fn parse_pod_name_mode() {
|
||||
|
|
@ -1433,6 +1565,191 @@ mod tests {
|
|||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_r_requests_rewind_picker_when_idle_or_paused() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.connected = true;
|
||||
let idle = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(idle, Some(Method::ListRewindTargets)));
|
||||
|
||||
app.set_pod_status(PodStatus::Paused);
|
||||
let paused = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(paused, Some(Method::ListRewindTargets)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_r_is_rejected_while_running() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.connected = true;
|
||||
app.set_pod_status(PodStatus::Running);
|
||||
|
||||
let method = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
|
||||
assert!(method.is_none());
|
||||
assert!(has_alert(&app, "cannot rewind while the Pod is running"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_picker_close_returns_to_history_view() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.connected = true;
|
||||
app.handle_pod_event(Event::RewindTargets {
|
||||
head_entries: 1,
|
||||
targets: vec![],
|
||||
});
|
||||
assert!(app.rewind_picker.is_none());
|
||||
|
||||
let method = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(method, Some(Method::ListRewindTargets)));
|
||||
app.handle_pod_event(Event::RewindTargets {
|
||||
head_entries: 1,
|
||||
targets: vec![],
|
||||
});
|
||||
assert!(app.rewind_picker.is_some());
|
||||
|
||||
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(method.is_none());
|
||||
assert!(app.rewind_picker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_applied_reseeds_display_and_restores_composer() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_pod_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![],
|
||||
status: PodStatus::Idle,
|
||||
});
|
||||
app.handle_pod_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
summary: protocol::RewindSummary {
|
||||
truncated_to_entries: 0,
|
||||
discarded_entries: 2,
|
||||
tool_side_effect_warning: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(input_text(&app), "retry this");
|
||||
assert!(app.rewind_picker.is_none());
|
||||
assert!(has_alert(&app, "tool side effects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_applied_keeps_non_empty_composer() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_pod_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![],
|
||||
status: PodStatus::Idle,
|
||||
});
|
||||
type_keys(&mut app, "draft");
|
||||
|
||||
app.handle_pod_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
summary: protocol::RewindSummary {
|
||||
truncated_to_entries: 0,
|
||||
discarded_entries: 2,
|
||||
tool_side_effect_warning: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(input_text(&app), "draft");
|
||||
assert!(has_alert(
|
||||
&app,
|
||||
"composer not overwritten because it was not empty"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_apply_rejects_non_empty_composer_and_paused_status() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
type_keys(&mut app, "draft");
|
||||
assert!(app.submit_rewind_picker().is_none());
|
||||
assert!(has_alert(&app, "composer is not empty"));
|
||||
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
app.set_pod_status(PodStatus::Paused);
|
||||
assert!(app.submit_rewind_picker().is_none());
|
||||
assert!(has_alert(
|
||||
&app,
|
||||
"cannot apply rewind while the Pod is paused"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_picker_draw_does_not_overwrite_history_scroll_state() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.scroll.top_offset = 3;
|
||||
app.scroll.turn_starts = vec![0, 5, 9];
|
||||
app.scroll.total_lines = 42;
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
let original_top_offset = app.scroll.top_offset;
|
||||
let original_turn_starts = app.scroll.turn_starts.clone();
|
||||
let original_total_lines = app.scroll.total_lines;
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| crate::ui::draw(frame, &mut app))
|
||||
.unwrap();
|
||||
app.close_rewind_picker();
|
||||
|
||||
assert_eq!(app.scroll.top_offset, original_top_offset);
|
||||
assert_eq!(app.scroll.turn_starts, original_turn_starts);
|
||||
assert_eq!(app.scroll.total_lines, original_total_lines);
|
||||
}
|
||||
|
||||
fn rewind_target() -> RewindTarget {
|
||||
RewindTarget {
|
||||
id: RewindTargetId {
|
||||
segment_id: uuid::Uuid::nil(),
|
||||
user_input_entry_index: 0,
|
||||
},
|
||||
expected_head_entries: 1,
|
||||
truncate_entries: 0,
|
||||
turn_index: 1,
|
||||
timestamp_ms: Some(1),
|
||||
preview: "retry this".into(),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_greeting() -> protocol::Greeting {
|
||||
protocol::Greeting {
|
||||
pod_name: "agent".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
scope_summary: "".into(),
|
||||
tools: vec![],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_registry_suggestions_are_available() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
|
|
@ -1460,6 +1777,204 @@ mod tests {
|
|||
assert_eq!(suggestions[0].name, "noop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_tab_applies_unambiguous_candidate() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
enter_command_mode(&mut app);
|
||||
type_keys(&mut app, "no");
|
||||
|
||||
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
|
||||
|
||||
assert!(app.is_command_mode());
|
||||
assert_eq!(app.command_text(), "noop ");
|
||||
assert_eq!(input_text(&app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_enter_applies_and_executes_unambiguous_candidate() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
enter_command_mode(&mut app);
|
||||
type_keys(&mut app, "no");
|
||||
|
||||
let method = handle_key(&mut app, key(KeyCode::Enter));
|
||||
|
||||
assert!(method.is_none());
|
||||
assert!(!app.is_command_mode());
|
||||
assert_eq!(input_text(&app), "");
|
||||
assert!(has_alert(&app, "noop: no action"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_ambiguous_candidate_requires_selection_or_more_input() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
register_test_command(&mut app, "open", "open", parse_no_args, "open executed");
|
||||
register_test_command(
|
||||
&mut app,
|
||||
"options",
|
||||
"options",
|
||||
parse_no_args,
|
||||
"options executed",
|
||||
);
|
||||
enter_command_mode(&mut app);
|
||||
type_keys(&mut app, "o");
|
||||
|
||||
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
|
||||
assert_eq!(app.command_text(), "o");
|
||||
assert!(app.is_command_mode());
|
||||
assert!(has_alert(&app, "Ambiguous command completion"));
|
||||
|
||||
let before = app.blocks.len();
|
||||
let method = handle_key(&mut app, key(KeyCode::Enter));
|
||||
assert!(method.is_none());
|
||||
assert_eq!(app.command_text(), "o");
|
||||
assert!(app.is_command_mode());
|
||||
assert!(app.blocks.len() > before);
|
||||
assert!(!has_alert(&app, "open executed"));
|
||||
assert!(!has_alert(&app, "options executed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_selected_candidate_applies_on_enter() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
register_test_command(&mut app, "open", "open", parse_no_args, "open executed");
|
||||
register_test_command(
|
||||
&mut app,
|
||||
"options",
|
||||
"options",
|
||||
parse_no_args,
|
||||
"options executed",
|
||||
);
|
||||
enter_command_mode(&mut app);
|
||||
type_keys(&mut app, "o");
|
||||
|
||||
assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
|
||||
let method = handle_key(&mut app, key(KeyCode::Enter));
|
||||
|
||||
assert!(method.is_none());
|
||||
assert!(!app.is_command_mode());
|
||||
assert!(has_alert(&app, "open executed"));
|
||||
assert!(!has_alert(&app, "options executed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_argument_required_keeps_command_mode_after_name_completion() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
register_test_command(
|
||||
&mut app,
|
||||
"open",
|
||||
"open <path>",
|
||||
parse_required_arg,
|
||||
"open executed",
|
||||
);
|
||||
enter_command_mode(&mut app);
|
||||
type_keys(&mut app, "op");
|
||||
|
||||
let method = handle_key(&mut app, key(KeyCode::Enter));
|
||||
|
||||
assert!(method.is_none());
|
||||
assert!(app.is_command_mode());
|
||||
assert_eq!(app.command_text(), "open ");
|
||||
assert!(has_alert(&app, "Invalid arguments. Usage: open <path>"));
|
||||
assert!(!has_alert(&app, "open executed"));
|
||||
assert_eq!(input_text(&app), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_completion_does_not_affect_normal_composer_without_popup() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
type_keys(&mut app, "hello");
|
||||
|
||||
assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none());
|
||||
|
||||
assert!(!app.is_command_mode());
|
||||
assert_eq!(input_text(&app), "hello");
|
||||
}
|
||||
|
||||
fn enter_command_mode(app: &mut App) {
|
||||
assert!(handle_key(app, key(KeyCode::Char(':'))).is_none());
|
||||
assert!(app.is_command_mode());
|
||||
}
|
||||
|
||||
fn type_keys(app: &mut App, text: &str) {
|
||||
for c in text.chars() {
|
||||
assert!(handle_key(app, key(KeyCode::Char(c))).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
fn has_alert(app: &App, needle: &str) -> bool {
|
||||
app.blocks.iter().any(|block| match block {
|
||||
crate::block::Block::Alert { message, .. } => message.contains(needle),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn register_test_command(
|
||||
app: &mut App,
|
||||
name: &'static str,
|
||||
usage: &'static str,
|
||||
argument_parser: crate::command::ArgumentParser,
|
||||
message: &'static str,
|
||||
) {
|
||||
app.command_registry.register(crate::command::CommandSpec {
|
||||
name,
|
||||
aliases: &[],
|
||||
usage,
|
||||
description: "test command",
|
||||
argument_parser,
|
||||
can_execute: test_command_available,
|
||||
executor: test_command_executor,
|
||||
});
|
||||
TEST_COMMAND_MESSAGES.with(|messages| messages.borrow_mut().push((name, message)));
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static TEST_COMMAND_MESSAGES: std::cell::RefCell<Vec<(&'static str, &'static str)>> =
|
||||
const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn parse_no_args(
|
||||
raw: &str,
|
||||
) -> Result<crate::command::CommandArgs, crate::command::CommandDiagnostic> {
|
||||
Ok(crate::command::CommandArgs::parse_whitespace(raw))
|
||||
}
|
||||
|
||||
fn parse_required_arg(
|
||||
raw: &str,
|
||||
) -> Result<crate::command::CommandArgs, crate::command::CommandDiagnostic> {
|
||||
let args = crate::command::CommandArgs::parse_whitespace(raw);
|
||||
if args.argv().is_empty() {
|
||||
return Err(crate::command::CommandDiagnostic::new(
|
||||
"Invalid arguments. Usage: open <path>",
|
||||
));
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn test_command_available(
|
||||
_environment: &crate::command::CommandEnvironment,
|
||||
) -> Result<(), crate::command::CommandDiagnostic> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn test_command_executor(
|
||||
invocation: crate::command::CommandInvocation<'_>,
|
||||
) -> crate::command::CommandExecution {
|
||||
let message = TEST_COMMAND_MESSAGES
|
||||
.with(|messages| {
|
||||
messages
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|(name, _)| *name == invocation.command.name)
|
||||
.map(|(_, message)| *message)
|
||||
})
|
||||
.unwrap_or("test command executed");
|
||||
crate::command::CommandExecution::notice(message)
|
||||
}
|
||||
|
||||
fn input_text(app: &App) -> String {
|
||||
protocol::Segment::flatten_to_text(&app.input.submit_segments())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ impl std::fmt::Display for MultiPodError {
|
|||
Self::Store(e) => write!(f, "session store error: {e}"),
|
||||
Self::NoPods => write!(
|
||||
f,
|
||||
"no pods found — start a fresh pod with `tui` or restore one with `tui -r`"
|
||||
"no pods found — start a fresh pod with `insomnia` or restore one with `insomnia -r`"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -62,37 +62,41 @@ impl From<session_store::StoreError> for MultiPodError {
|
|||
|
||||
pub(crate) enum MultiPodOutcome {
|
||||
Quit,
|
||||
Open {
|
||||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
Open(OpenPodRequest),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct OpenPodRequest {
|
||||
pub(crate) pod_name: String,
|
||||
pub(crate) socket_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_app() -> Result<MultiPodApp, MultiPodError> {
|
||||
MultiPodApp::load(None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut MultiPodApp,
|
||||
) -> Result<MultiPodOutcome, MultiPodError> {
|
||||
let mut app = MultiPodApp::load(None).await?;
|
||||
if app.list.entries.is_empty() {
|
||||
return Err(MultiPodError::NoPods);
|
||||
}
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, &mut app))?;
|
||||
terminal.draw(|f| draw(f, app))?;
|
||||
match read()? {
|
||||
TermEvent::Key(key) => match app.handle_key(key) {
|
||||
MultiPodAction::None => {}
|
||||
MultiPodAction::Quit => return Ok(MultiPodOutcome::Quit),
|
||||
MultiPodAction::Open => {
|
||||
if let Some(entry) = app.list.selected_entry() {
|
||||
return Ok(MultiPodOutcome::Open {
|
||||
pod_name: entry.name.clone(),
|
||||
socket_override: entry.attach_socket_path().map(PathBuf::from),
|
||||
});
|
||||
if let Some(request) = app.prepare_open() {
|
||||
return Ok(MultiPodOutcome::Open(request));
|
||||
}
|
||||
}
|
||||
MultiPodAction::Refresh => app.reload().await?,
|
||||
MultiPodAction::Send(request) => {
|
||||
terminal.draw(|f| draw(f, &mut app))?;
|
||||
terminal.draw(|f| draw(f, app))?;
|
||||
let result = send_run_and_confirm(&request.socket_path, request.segments).await;
|
||||
app.finish_send(result);
|
||||
let _ = app.reload().await;
|
||||
|
|
@ -147,7 +151,7 @@ impl MultiPodApp {
|
|||
Ok(app)
|
||||
}
|
||||
|
||||
async fn reload(&mut self) -> Result<(), MultiPodError> {
|
||||
pub(crate) async fn reload(&mut self) -> Result<(), MultiPodError> {
|
||||
self.list = load_pod_list(self.list.selected_name.clone()).await?;
|
||||
self.ensure_selection_visible();
|
||||
Ok(())
|
||||
|
|
@ -211,6 +215,44 @@ impl MultiPodApp {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_open(&mut self) -> Option<OpenPodRequest> {
|
||||
let entry = match self.list.selected_entry() {
|
||||
Some(entry) => entry,
|
||||
None => {
|
||||
self.notice = Some("No Pod is selected.".to_string());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !entry.actions.can_open {
|
||||
self.notice = Some("Selected Pod cannot be opened from this view.".to_string());
|
||||
return None;
|
||||
}
|
||||
self.notice = Some(format!("Opening {}…", entry.name));
|
||||
Some(OpenPodRequest {
|
||||
pod_name: entry.name.clone(),
|
||||
socket_override: entry.attach_socket_path().map(PathBuf::from),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn finish_open(
|
||||
&mut self,
|
||||
pod_name: &str,
|
||||
result: Result<(), &dyn std::fmt::Display>,
|
||||
) {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.notice = Some(format!("Returned from {pod_name}."));
|
||||
}
|
||||
Err(error) => {
|
||||
self.notice = Some(format!("Open failed for {pod_name}: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn composer_is_blank(&self) -> bool {
|
||||
segments_are_blank(&self.input.submit_segments())
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_send(&mut self) -> Option<DirectSendRequest> {
|
||||
let entry = match self.list.selected_entry() {
|
||||
Some(entry) => entry,
|
||||
|
|
@ -287,6 +329,7 @@ impl MultiPodApp {
|
|||
self.input.insert_newline();
|
||||
MultiPodAction::None
|
||||
}
|
||||
KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open,
|
||||
KeyCode::Enter => self
|
||||
.prepare_send()
|
||||
.map(MultiPodAction::Send)
|
||||
|
|
@ -654,8 +697,10 @@ fn multi_pod_layout(area: Rect, input_height: u16) -> MultiPodLayoutState {
|
|||
fn draw(frame: &mut Frame<'_>, app: &mut MultiPodApp) {
|
||||
let area = frame.area();
|
||||
let input_content_width = area.width.saturating_sub(2).max(1);
|
||||
let input_render = app.input.render(input_content_width);
|
||||
let mut input_render = app.input.render(input_content_width);
|
||||
let input_height = input_area_height(&input_render, area.height);
|
||||
app.input
|
||||
.apply_cursor_viewport(&mut input_render, input_height);
|
||||
let layout = multi_pod_layout(area, input_height);
|
||||
|
||||
draw_title(frame, layout.title);
|
||||
|
|
@ -835,7 +880,8 @@ fn draw_target_status(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
|||
fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) {
|
||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len());
|
||||
for (i, src) in render.lines.iter().enumerate() {
|
||||
let prefix = if i == 0 { "> " } else { " " };
|
||||
let absolute_row = render.viewport_start_row as usize + i;
|
||||
let prefix = if absolute_row == 0 { "> " } else { " " };
|
||||
let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))];
|
||||
spans.extend(src.spans.iter().cloned());
|
||||
lines.push(Line::from(spans));
|
||||
|
|
@ -1114,6 +1160,123 @@ mod tests {
|
|||
assert!(app.notice.as_deref().unwrap().contains("Delivered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_open_request_keeps_dashboard_state_for_nested_single_pod() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
app.input.insert_str("draft survives open");
|
||||
|
||||
let request = app.prepare_open().unwrap();
|
||||
|
||||
assert_eq!(request.pod_name, "alpha");
|
||||
assert_eq!(
|
||||
request.socket_override,
|
||||
Some(PathBuf::from("/tmp/alpha.sock"))
|
||||
);
|
||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||
assert_eq!(input_text(&app), "draft survives open");
|
||||
assert!(app.notice.as_deref().unwrap().contains("Opening alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_open_failure_keeps_composer_and_sets_notice() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
app.input.insert_str("keep this draft");
|
||||
let before = input_text(&app);
|
||||
let error = io::Error::other("boom");
|
||||
|
||||
app.finish_open("alpha", Err(&error));
|
||||
|
||||
assert_eq!(input_text(&app), before);
|
||||
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||
assert!(
|
||||
app.notice
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("Open failed for alpha")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_open_disabled_target_stays_in_dashboard() {
|
||||
let mut live = live_info("unreachable", PodStatus::Idle);
|
||||
live.reachable = false;
|
||||
live.status = None;
|
||||
let mut app = test_app(vec![live]);
|
||||
|
||||
assert!(app.prepare_open().is_none());
|
||||
assert!(app.notice.as_deref().unwrap().contains("cannot be opened"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_empty_enter_uses_open_action() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
|
||||
assert!(matches!(
|
||||
app.handle_key(key(KeyCode::Enter)),
|
||||
MultiPodAction::Open
|
||||
));
|
||||
let request = app.prepare_open().unwrap();
|
||||
|
||||
assert_eq!(request.pod_name, "alpha");
|
||||
assert_eq!(
|
||||
request.socket_override,
|
||||
Some(PathBuf::from("/tmp/alpha.sock"))
|
||||
);
|
||||
assert_eq!(input_text(&app), "");
|
||||
assert!(app.notice.as_deref().unwrap().contains("Opening alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_whitespace_only_enter_uses_open_action() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
app.input.insert_str(" \n\t");
|
||||
|
||||
assert!(matches!(
|
||||
app.handle_key(key(KeyCode::Enter)),
|
||||
MultiPodAction::Open
|
||||
));
|
||||
let request = app.prepare_open().unwrap();
|
||||
|
||||
assert_eq!(request.pod_name, "alpha");
|
||||
assert_eq!(input_text(&app), " \n\t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_non_empty_enter_uses_direct_send_action() {
|
||||
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||
app.input.insert_str("send me");
|
||||
|
||||
let request = match app.handle_key(key(KeyCode::Enter)) {
|
||||
MultiPodAction::Send(request) => request,
|
||||
_ => panic!("non-empty Enter should direct-send"),
|
||||
};
|
||||
|
||||
assert_eq!(request.socket_path, PathBuf::from("/tmp/idle.sock"));
|
||||
assert_eq!(Segment::flatten_to_text(&request.segments), "send me");
|
||||
assert!(app.sending);
|
||||
assert!(app.notice.as_deref().unwrap().contains("Sending to idle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_empty_enter_on_non_openable_row_matches_o_diagnostic() {
|
||||
let mut enter_app = test_app(vec![unreachable_live_info("unreachable")]);
|
||||
assert!(matches!(
|
||||
enter_app.handle_key(key(KeyCode::Enter)),
|
||||
MultiPodAction::Open
|
||||
));
|
||||
assert!(enter_app.prepare_open().is_none());
|
||||
let enter_notice = enter_app.notice.clone();
|
||||
|
||||
let mut open_app = test_app(vec![unreachable_live_info("unreachable")]);
|
||||
assert!(matches!(
|
||||
open_app.handle_key(key(KeyCode::Char('o'))),
|
||||
MultiPodAction::Open
|
||||
));
|
||||
assert!(open_app.prepare_open().is_none());
|
||||
|
||||
assert_eq!(enter_notice, open_app.notice);
|
||||
}
|
||||
|
||||
fn test_app(live: Vec<LivePodInfo>) -> MultiPodApp {
|
||||
app_with_list(PodList::from_sources(
|
||||
PodVisibilitySource::ResumePicker,
|
||||
|
|
@ -1153,6 +1316,13 @@ mod tests {
|
|||
live_info_with_updated_at(pod_name, status, 0)
|
||||
}
|
||||
|
||||
fn unreachable_live_info(pod_name: &str) -> LivePodInfo {
|
||||
let mut live = live_info(pod_name, PodStatus::Idle);
|
||||
live.reachable = false;
|
||||
live.status = None;
|
||||
live
|
||||
}
|
||||
|
||||
fn live_info_with_updated_at(
|
||||
pod_name: &str,
|
||||
status: PodStatus,
|
||||
|
|
@ -1206,4 +1376,8 @@ mod tests {
|
|||
fn input_text(app: &MultiPodApp) -> String {
|
||||
Segment::flatten_to_text(&app.input.submit_segments())
|
||||
}
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//!
|
||||
//! Reads live Pod allocations from the runtime registry and stopped Pod state
|
||||
//! from the session store's name-keyed metadata. Picking a live row attaches to
|
||||
//! its socket; picking a stopped row restores via `pod --pod <name>`.
|
||||
//! its socket; picking a stopped row restores via `insomnia-pod --pod <name>`.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -41,7 +41,7 @@ impl std::fmt::Display for PickerError {
|
|||
Self::Store(e) => write!(f, "session store error: {e}"),
|
||||
Self::NoPods => write!(
|
||||
f,
|
||||
"no pods found — start a fresh pod with `tui` and try again"
|
||||
"no pods found — start a fresh pod with `insomnia` and try again"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -64,7 +64,7 @@ impl From<session_store::StoreError> for PickerError {
|
|||
pub enum PickerOutcome {
|
||||
/// User picked a Pod. `socket_override` is set for live rows when the
|
||||
/// runtime registry knows the exact socket path; stopped rows leave it
|
||||
/// empty so the caller restores with `pod --pod <name>`.
|
||||
/// empty so the caller restores with `insomnia-pod --pod <name>`.
|
||||
Picked {
|
||||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! Inline-viewport "spawn Pod and attach" UX.
|
||||
//!
|
||||
//! Rendered at the user's current cursor position when `tui` is invoked
|
||||
//! Rendered at the user's current cursor position when `insomnia` is invoked
|
||||
//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml`
|
||||
//! to seed defaults, prompts for the Pod's name, and on confirmation
|
||||
//! launches the `pod` binary as an independent process with a freshly built
|
||||
//! launches the `insomnia-pod` binary as an independent process with a freshly built
|
||||
//! overlay (name + cwd scope when no project manifest exists). Once
|
||||
//! the process reports its socket via the `INSOMNIA-READY` stderr line,
|
||||
//! the dialog hands control back so main can switch the terminal to
|
||||
|
|
@ -90,7 +90,7 @@ type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
|
|||
|
||||
/// Source session for a resume run. `None` = fresh spawn (current
|
||||
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
|
||||
/// passes `--session <id>` to the spawned `pod` child.
|
||||
/// passes `--session <id>` to the spawned `insomnia-pod` child.
|
||||
pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnError> {
|
||||
let defaults = load_spawn_defaults()?;
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
|
|||
}
|
||||
}
|
||||
|
||||
/// Launch `pod --pod <name>` without opening the name dialog. The child Pod
|
||||
/// Launch `insomnia-pod --pod <name>` without opening the name dialog. The child Pod
|
||||
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
|
||||
/// with the usual TUI cwd-scope fallback.
|
||||
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> {
|
||||
|
|
|
|||
|
|
@ -65,12 +65,19 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
|||
// Input content starts after the prompt (`> ` or `: `), so the width
|
||||
// available for wrapping is two columns narrower than the frame.
|
||||
let input_content_width = area.width.saturating_sub(2).max(1);
|
||||
let input_render = if app.is_command_mode() {
|
||||
let mut input_render = if app.is_command_mode() {
|
||||
app.command_input.render(input_content_width)
|
||||
} else {
|
||||
app.input.render(input_content_width)
|
||||
};
|
||||
let input_height = input_area_height(&input_render, area.height);
|
||||
if app.is_command_mode() {
|
||||
app.command_input
|
||||
.apply_cursor_viewport(&mut input_render, input_height);
|
||||
} else {
|
||||
app.input
|
||||
.apply_cursor_viewport(&mut input_render, input_height);
|
||||
}
|
||||
let mini_view_h = task_mini_view_height(&app.task_store);
|
||||
// One blank row separates the history tail from the mini-view so
|
||||
// the latest message doesn't visually crash into the task summary.
|
||||
|
|
@ -284,11 +291,29 @@ fn draw_command_popup(frame: &mut Frame, app: &App, input_area: Rect) {
|
|||
.add_modifier(Modifier::BOLD);
|
||||
let description_style = Style::default().fg(Color::DarkGray);
|
||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(popup_h as usize);
|
||||
for candidate in visible_suggestions.iter().take(popup_h as usize) {
|
||||
let selected = app.command_completion_selected();
|
||||
for (idx, candidate) in visible_suggestions
|
||||
.iter()
|
||||
.take(popup_h as usize)
|
||||
.enumerate()
|
||||
{
|
||||
let selected_style = if Some(idx) == selected {
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(candidate.name.to_owned(), command_style),
|
||||
Span::styled(" — ", description_style),
|
||||
Span::styled(candidate.description.to_owned(), description_style),
|
||||
Span::styled(
|
||||
candidate.name.to_owned(),
|
||||
command_style.patch(selected_style),
|
||||
),
|
||||
Span::styled(" — ", description_style.patch(selected_style)),
|
||||
Span::styled(
|
||||
candidate.description.to_owned(),
|
||||
description_style.patch(selected_style),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +419,11 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
|
|||
return;
|
||||
}
|
||||
|
||||
if let Some(picker) = app.rewind_picker.as_mut() {
|
||||
draw_rewind_picker(frame, history_area, inner, outer_block, picker);
|
||||
return;
|
||||
}
|
||||
|
||||
let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width);
|
||||
|
||||
// `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll
|
||||
|
|
@ -422,6 +452,99 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
|
|||
.render(history_area, frame.buffer_mut());
|
||||
}
|
||||
|
||||
fn draw_rewind_picker(
|
||||
frame: &mut Frame,
|
||||
history_area: Rect,
|
||||
inner: Rect,
|
||||
outer_block: UiBlock<'_>,
|
||||
picker: &mut crate::app::RewindPickerState,
|
||||
) {
|
||||
let mut logical: Vec<Line<'static>> = Vec::new();
|
||||
logical.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Rewind targets",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" head={} ", picker.head_entries)),
|
||||
Span::styled("Enter", Style::default().fg(Color::Green)),
|
||||
Span::raw(" apply "),
|
||||
Span::styled("Esc", Style::default().fg(Color::Green)),
|
||||
Span::raw(" cancel"),
|
||||
]));
|
||||
logical.push(Line::from(Span::styled(
|
||||
"Selecting a target discards the later history suffix; tool side effects are not undone.",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
logical.push(Line::from(""));
|
||||
|
||||
if picker.targets.is_empty() {
|
||||
logical.push(Line::from(Span::styled(
|
||||
"No previous user messages are available to rewind.",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
} else {
|
||||
for (idx, target) in picker.targets.iter().enumerate() {
|
||||
let selected = idx == picker.selected;
|
||||
let marker = if selected { "▶" } else { " " };
|
||||
let base_style = if selected {
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if target.eligible {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let ts = target
|
||||
.timestamp_ms
|
||||
.map(|ts| format!("{}", ts))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
logical.push(Line::from(vec![
|
||||
Span::styled(marker.to_owned(), base_style),
|
||||
Span::styled(
|
||||
format!(
|
||||
" turn {} idx {} ts {} ",
|
||||
target.turn_index, target.id.user_input_entry_index, ts
|
||||
),
|
||||
base_style,
|
||||
),
|
||||
Span::styled(target.preview.clone(), base_style),
|
||||
]));
|
||||
if let Some(warning) = target.warning.as_ref() {
|
||||
logical.push(Line::from(Span::styled(
|
||||
format!(" warning: {warning}"),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)));
|
||||
}
|
||||
if let Some(reason) = target.disabled_reason.as_ref() {
|
||||
logical.push(Line::from(Span::styled(
|
||||
format!(" disabled: {reason}"),
|
||||
Style::default().fg(Color::Red),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for line in logical {
|
||||
wrap_line_into(line, inner.width, &mut lines);
|
||||
}
|
||||
|
||||
let tail_top = lines.len().saturating_sub(inner.height as usize);
|
||||
picker.scroll.area_height = inner.height;
|
||||
picker.scroll.total_lines = lines.len();
|
||||
picker.scroll.tail_top_offset = tail_top;
|
||||
picker.scroll.top_offset = picker.scroll.top_offset.min(tail_top);
|
||||
|
||||
let end = (picker.scroll.top_offset + inner.height as usize).min(lines.len());
|
||||
let visible = lines[picker.scroll.top_offset..end].to_vec();
|
||||
Paragraph::new(visible)
|
||||
.block(outer_block)
|
||||
.render(history_area, frame.buffer_mut());
|
||||
}
|
||||
|
||||
/// Width to reserve for the task side pane within the history rect.
|
||||
/// Returns 0 when the pane is closed or the rect is too narrow to host
|
||||
/// it without crushing the history view.
|
||||
|
|
@ -1284,7 +1407,12 @@ fn draw_input(frame: &mut Frame, app: &App, render: &crate::input::InputRender,
|
|||
};
|
||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len());
|
||||
for (i, src) in render.lines.iter().enumerate() {
|
||||
let prefix = if i == 0 { prompt } else { continuation };
|
||||
let absolute_row = render.viewport_start_row as usize + i;
|
||||
let prefix = if absolute_row == 0 {
|
||||
prompt
|
||||
} else {
|
||||
continuation
|
||||
};
|
||||
let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)];
|
||||
spans.extend(src.spans.iter().cloned());
|
||||
lines.push(Line::from(spans));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{ pkgs }:
|
||||
let
|
||||
# Dev-only wrapper. tui の spawn 経路は `pod` バイナリを直に exec し、
|
||||
# Dev-only wrapper. tui の spawn 経路は `insomnia-pod` バイナリを直に exec し、
|
||||
# stderr の `INSOMNIA-READY` 行で握手するので、cargo の進捗や rustc の
|
||||
# warning が混ざると tail に余計な行が積もり本当のエラーが押し出される。
|
||||
# ここで一度ビルドを切り離し、成功時はビルド出力を一切捨てて素のバイナリ
|
||||
# を exec、失敗時のみ build log を stderr に流して exit する。
|
||||
pod-dev = pkgs.writeShellScriptBin "pod" ''
|
||||
pod-dev = pkgs.writeShellScriptBin "insomnia-pod" ''
|
||||
set -u
|
||||
buildlog=$(mktemp)
|
||||
trap 'rm -f "$buildlog"' EXIT
|
||||
|
|
@ -15,7 +15,7 @@ let
|
|||
fi
|
||||
manifest=$(cargo locate-project --workspace --message-format plain 2>/dev/null)
|
||||
target_dir=''${CARGO_TARGET_DIR:-$(dirname "$manifest")/target}
|
||||
exec "$target_dir/debug/pod" "$@"
|
||||
exec "$target_dir/debug/insomnia-pod" "$@"
|
||||
'';
|
||||
in
|
||||
pkgs.mkShell {
|
||||
|
|
|
|||
18
docs/nix.md
18
docs/nix.md
|
|
@ -10,12 +10,12 @@ From the repository root:
|
|||
nix build .#
|
||||
```
|
||||
|
||||
The default package is implemented by `package.nix` and builds the Cargo workspace binaries `pod` and `tui`. The derivation uses the checked-in `Cargo.lock`, so Cargo dependencies are fetched by the normal Nix Rust packaging path instead of by network access during the build.
|
||||
The default package is implemented by `package.nix` and builds the Cargo packages `pod` and `tui` as installed binaries `insomnia-pod` and `insomnia`. The derivation uses the checked-in `Cargo.lock`, so Cargo dependencies are fetched by the normal Nix Rust packaging path instead of by network access during the build.
|
||||
|
||||
The package output contains:
|
||||
|
||||
- `bin/pod` — Pod CLI / runtime process.
|
||||
- `bin/tui` — terminal UI.
|
||||
- `bin/insomnia-pod` — Pod CLI / runtime process.
|
||||
- `bin/insomnia` — terminal UI.
|
||||
- `share/insomnia/resources/` — bundled runtime resources, including `resources/prompts/`.
|
||||
- `share/doc/insomnia/nix.md` — this document.
|
||||
|
||||
|
|
@ -24,15 +24,15 @@ The package output contains:
|
|||
After `nix build`:
|
||||
|
||||
```sh
|
||||
./result/bin/pod --help
|
||||
./result/bin/tui
|
||||
./result/bin/insomnia-pod --help
|
||||
./result/bin/insomnia
|
||||
```
|
||||
|
||||
With flakes:
|
||||
|
||||
```sh
|
||||
nix run .#tui
|
||||
nix run .#pod -- --help
|
||||
nix run .#insomnia
|
||||
nix run .#insomnia-pod -- --help
|
||||
```
|
||||
|
||||
`nix run .#` defaults to the TUI.
|
||||
|
|
@ -53,8 +53,8 @@ The Nix package does not put user configuration, sessions, sockets, or other mut
|
|||
|
||||
The package derivation has a credential-free install check that verifies:
|
||||
|
||||
- `pod --help` starts successfully.
|
||||
- `tui` is installed and reaches argument parsing.
|
||||
- `insomnia-pod --help` starts successfully.
|
||||
- `insomnia` is installed and reaches argument parsing.
|
||||
- bundled prompt resources and this Nix usage document are present in the output.
|
||||
|
||||
For full validation before handing changes to review, run:
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ unique であれば workspace を指定しなくて済む。
|
|||
## Daemon-less リモート Pod 生成(SSH-only モデル)
|
||||
|
||||
リモートホスト上の Pod 生成は **daemon 無しで SSH だけで成立する**。
|
||||
remote 側に必要なのは `pod` バイナリと SSH アクセスのみ。
|
||||
remote 側に必要なのは `insomnia-pod` バイナリと SSH アクセスのみ。
|
||||
|
||||
### 前提
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ remote 側に必要なのは `pod` バイナリと SSH アクセスのみ。
|
|||
- insomnia が転送するのは**セッション(会話履歴)と manifest overlay**
|
||||
だけ。コードベースの同期は外部に委ねる
|
||||
- コンテナ内で動かすか bare metal で動かすかも insomnia は問わない。
|
||||
`pod` バイナリが動くホストの fs 上で活動する主体がある、
|
||||
`insomnia-pod` バイナリが動くホストの fs 上で活動する主体がある、
|
||||
それだけが前提
|
||||
|
||||
### フロー
|
||||
|
|
@ -193,7 +193,7 @@ host_a (spawner) host_b (remote)
|
|||
│
|
||||
├── ssh: session データを転送 ────────→ ファイル書き込み
|
||||
├── ssh: overlay TOML を転送 ─────────→ ファイル書き込み
|
||||
├── ssh: `pod --overlay ... &` ───────→ Pod プロセス起動、socket 作成
|
||||
├── ssh: `insomnia-pod --overlay ... &` ───────→ Pod プロセス起動、socket 作成
|
||||
├── ssh -L: socket を tunnel ─────────→ Pod B の unix socket
|
||||
│
|
||||
└── localhost:tunnel に接続 ──────────→ Method::Run / Event stream
|
||||
|
|
@ -209,7 +209,7 @@ tar cz session/ | ssh insomnia@host-b "tar xz -C ~/workspaces/task-123/store"
|
|||
echo "$OVERLAY" | ssh insomnia@host-b "cat > ~/workspaces/task-123/overlay.toml"
|
||||
|
||||
# 2. Pod を起動(detach)
|
||||
ssh insomnia@host-b "pod --store ~/workspaces/task-123/store \
|
||||
ssh insomnia@host-b "insomnia-pod --store ~/workspaces/task-123/store \
|
||||
--overlay ~/workspaces/task-123/overlay.toml &"
|
||||
|
||||
# 3. socket を tunnel で引っ張る
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ manifest 中のパス(`model.auth.file` / `scope.*.target` /
|
|||
|
||||
Pod の作業ディレクトリは manifest に含まれない。プロセス起動時の
|
||||
`std::env::current_dir()` がそのまま Pod の pwd となるため、別の作業
|
||||
ディレクトリで Pod を走らせたい場合は `cd` してから `pod` を起動する
|
||||
ディレクトリで Pod を走らせたい場合は `cd` してから `insomnia-pod` を起動する
|
||||
(または `SpawnPod` が子に対して行っているように、親プロセス側で
|
||||
`Command::current_dir` を明示する)。
|
||||
|
||||
|
|
@ -297,12 +297,12 @@ import-map 形式のプレフィックスで指定する:
|
|||
|
||||
---
|
||||
|
||||
## `pod` CLI
|
||||
## `insomnia-pod` CLI
|
||||
|
||||
`pod` は通常、builtin default → user manifest → project manifest → overlay の cascade で manifest を解決して起動する。
|
||||
`insomnia-pod` は通常、builtin default → user manifest → project manifest → overlay の cascade で manifest を解決して起動する。
|
||||
|
||||
```
|
||||
pod [--project <path>] [--overlay <toml>] [-s/--store <path>] [--session <uuid>]
|
||||
insomnia-pod [--project <path>] [--overlay <toml>] [-s/--store <path>] [--session <uuid>]
|
||||
```
|
||||
|
||||
| フラグ | 説明 |
|
||||
|
|
@ -323,15 +323,15 @@ user manifest は CLI フラグではなく、以下の規則で解決する。
|
|||
単一ファイルだけで起動したい場合は cascade を使わず、`--manifest` を指定する。
|
||||
|
||||
```
|
||||
pod --manifest <path> [-s/--store <path>] [--session <uuid>]
|
||||
insomnia-pod --manifest <path> [-s/--store <path>] [--session <uuid>]
|
||||
```
|
||||
|
||||
`--manifest` は指定 TOML 1 枚だけを `PodManifest::from_toml` で読み、user / project / overlay layer は一切読まない。したがって `--project`、`--overlay`、非空の `INSOMNIA_USER_MANIFEST` とは併用不可。
|
||||
|
||||
spawn 子 Pod 用の内部フラグとして `--adopt` と `--callback <path>` がある。これらは `SpawnPod` が scope allocation と親 callback socket を引き継がせるために使うもので、通常の手動起動では使わない。
|
||||
|
||||
Pod の作業ディレクトリは `pod` 起動時の cwd が直接使われる。別ディレクトリで
|
||||
動かしたい場合は `cd <path> && pod ...` のように外側で `cd` してから起動する。
|
||||
Pod の作業ディレクトリは `insomnia-pod` 起動時の cwd が直接使われる。別ディレクトリで
|
||||
動かしたい場合は `cd <path> && insomnia-pod ...` のように外側で `cd` してから起動する。
|
||||
|
||||
引数無しで起動すると、cwd + `manifest::paths` の自動解決だけで動く最小構成になる
|
||||
(overlay 無し、プロジェクトに `.insomnia/manifest.toml` があればそれを使う)。
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ Running 中に割り込みたい場合、ほとんどのケースで `Ctrl-C`(
|
|||
|
||||
`Ctrl-X` は Running 中だけ Cancel、Idle / Paused では Shutdown。`Ctrl-C` は Running 中だけ Pod に `Method::Pause` を送り、それ以外では Pod は落とさず TUI プロセスだけ抜ける。`Ctrl-D` は常に Pod へ制御メソッドを送らず TUI プロセスだけ抜ける。
|
||||
|
||||
TUI のダイアログから Pod を起動する経路では、起動した Pod は TUI の子プロセスとして管理・終了されず、独立したプロセスとして残る。TUI 終了後は `tui <pod-name>` で再接続できる。
|
||||
TUI のダイアログから Pod を起動する経路では、起動した Pod は TUI の子プロセスとして管理・終了されず、独立したプロセスとして残る。TUI 終了後は `insomnia <pod-name>` で再接続できる。
|
||||
|
||||
## 履歴メモ
|
||||
|
||||
|
|
|
|||
|
|
@ -1,217 +0,0 @@
|
|||
# Nia 構想
|
||||
|
||||
[llm-worker](https://docs.rs/llm-worker/0.2.1/llm_worker/)をベースとして、カスタマイズ可能なToolや、pluginによってワークフローを設計可能にする。
|
||||
|
||||
目標は、エージェントを動かすためのエンジンとなり、Dockerのようなエコシステムを構築すること。
|
||||
|
||||
- エージェントの活動単位
|
||||
- プロセス:(MORE SPEC REQUIRED)1プロセス1セッション?サブエージェントの扱い方
|
||||
- ディレクトリ:基本は下位ディレクトリ全体をワーキングディレクトリとして扱う。
|
||||
サブエージェントに移譲する際は、ディレクトリのexclude又は権限の移譲を必要とし、同時に書き込むことを防ぐ。
|
||||
- これは単に、ディレクトリツリーの管轄を切り分け、ツールの制限として弾き、また、他のpodが存在するディレクトリは削除・移動出来ないということ
|
||||
- ネットワーク越し:通常のメッセージパッシングだが、`niad`(デーモンプロセス)を用いる必要がある。
|
||||
後述の`workspace`単位で公開鍵認証あたりが必要だと考えている
|
||||
|
||||
- `workspace`として、複数エージェント間のコミュニケーションや知識を取りまとめる区分を作成する
|
||||
- 複数のワーカーが存在する以上、可視範囲を作らなければならない
|
||||
- が、これをローカルマシンに閉じる必要がないという考え
|
||||
- モジュール・マイクロサービスを総括するためのもの
|
||||
- (PLAN)スケーラビリティの考え方、大規模にスケールできる必要性(将来的には)
|
||||
|
||||
- 永続化
|
||||
- セッションの履歴を完全に保持する必要はないと考えている。
|
||||
- ドキュメントとして残し、参照可能にする。
|
||||
- 直近のセッションは復元可能である必要性がある
|
||||
- Gitとの相性:ドキュメントでのある時点での参照は後々壊れる可能性がある。
|
||||
- そもそもworkspaceは複数gitレポジトリに跨ることがあるし、workspace自体をgitで管理するべきとするか否か。
|
||||
- コミットハッシュ+ファイル名みたいな挙動が必要かも。
|
||||
- [ビルトイン・モジュール](#ビルトインモジュール)で詳細化している。
|
||||
|
||||
## エコシステム設計
|
||||
|
||||
### 前提
|
||||
|
||||
以下の既存の仕組みについて考察する。
|
||||
|
||||
- [MCP](https://modelcontextprotocol.io/docs/getting-started/intro)
|
||||
- [ACP](https://agentclientprotocol.com/overview/introduction)
|
||||
- [Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview)
|
||||
- [AGENTS.md](https://agents.md)
|
||||
|
||||
### 既存の仕組みとの折り合い
|
||||
|
||||
AIのツールを拡張する仕組みに、MCPがある。ツール、データソースの供給、プロンプトのテンプレートを標準化している。
|
||||
|
||||
MCPツールをniaのツール定義につなぐ仕組みは既存資源の活用の観点から必須であると考えている。
|
||||
リソースアクセスは解釈の余地があるが、結局get_resourceみたいなツールとしてAIに渡されるので、Niaが持つResource管理の仕組みに組み込めたら良いかもしれない。
|
||||
|
||||
テンプレートはSkillsと合わせて一つの仕組みにするべきだと考えている。
|
||||
Skillsは任意のタイミングで読める、知らない知識や壊れやすい手順を補助する説明書で、AIにはそれを読むことでタスクの行い方を理解することが期待される。
|
||||
|
||||
よって、MCPのPromptsはシステムプロンプトを含めたコンテキストの初期状態から組み立てる必要のあるSkillsと解釈できる。
|
||||
しかしながら、システムプロンプト含めて構築するのは、サブエージェントでは有効だが、長期間のタスクでは適さないと考えている。
|
||||
|
||||
ACPは、エージェントとエディタの通信を定義する仕組み。ACPはセッションの開始要求や操作・能力の通知などを行う仕組みであるから、このシステムには適さない。
|
||||
また、権限や処理等をエディタに任せる仕組みである為、立場としてはacp対応エージェントをpodでwrapし、niaシステムで利用できるようにするような組み込み方が適していると考えている。
|
||||
|
||||
AGENTS.mdは、プロジェクトの全容や知っておくべき内容を記載するドキュメントだが、これはAIに更新させるには原始的すぎで、人が更新するには自由すぎる仕組みだと考えている。
|
||||
ユーザーの指示はAIが理解して記憶するべきだし、プロジェクトの知識やユーザーの指示を蓄積するよりよい仕組みに代替されるだろうという考え。
|
||||
|
||||
### 拡張性に求められる仕組み
|
||||
|
||||
llm-workerで目指した、抽象化はするが、より深い制御も可能にする設計は、niaでも必要だと考えている。
|
||||
MCPからToolsやResourcesを作る仕組みは必要だが、このデフォルト機能をオプトアウトし、ユーザーが自身で仕組みを作れなければ、エコシステムとしては十分でない。
|
||||
|
||||
llm-workerとの境界がまさにこれで、エンジンとしてのllm-workerの自由度を利用しつつ、ビルトインの仕組みにプラグインできると良い。
|
||||
CURDやShellツール、subagentを呼び出すツール等のビルトインツールは提供しつつ、ユーザーが自身で定義するフォーマットも受け入れられる必要がある。
|
||||
|
||||
### ワーカーの設計を行う仕組み
|
||||
|
||||
なにが出来るかを定義する仕組みがSkillsで、説明と追加のリソースとツールをセットで提供する。
|
||||
どう動く必要があるかを定義する仕組みがWorkflowsで、出来ること(Skills)に加え、Hooksとロジックを合わせて定義できる。
|
||||
|
||||
Workflowはセッション固有で、Skillsは幾つでも定義可能。
|
||||
|
||||
Toolsは、KVキャッシュ・コンテキストエンジニアリングの考えに基づき、一度コンテキストに載せたら削除は出来ない。
|
||||
なぜなら、実行したことのあるツールが存在しない状況はLLMにとって混乱を招く為。
|
||||
|
||||
- Tools: ツールを定義する、((比較的)低レイヤな)llm-workerの仕組み
|
||||
- Hooks: イベントに基づいてWorkerを制御する、(同上)llm-workerの仕組み
|
||||
- Resources: ReadOnlyな情報源を定義する仕組みで、Toolsを生成する。
|
||||
- (TODO) 結局はツールなので、そのツールの設計は考える必要がある。
|
||||
- Skills: LLMに対する動的な指示文を定義する仕組み。ClaudeのSkillsと類似する。
|
||||
- Workflows:
|
||||
LLMが行うべき作業の流れを定義する仕組み。Tools/Hooks/Skillsを持てる。
|
||||
- システムプロンプトを定義し、セッション全体の流れを制約するものであるため、動的にロードできない。ある地点から、今から作業完了モードに入るというよりは、
|
||||
全体の流れを定義するものにしたい。
|
||||
- Skillsを内包するが、システマチックに行動を制約する仕組みを提供する。
|
||||
- `コーディング->lintを通す->レビューをパスする->記録を書く->完了`
|
||||
みたいな流れを定義可能にしたい。任意の言語でロジックを書けるようにするか、あるいはDSL的に定義するか。
|
||||
- Profiles:
|
||||
利用可能なツール、登録されたHooks、最初から与えられるSkills/Workflow、利用可能なSkillsなどをまとめて定義する。
|
||||
- 利用するプロバイダとモデルや、ビルトインのシステムに対するコンフィグ、サードパーティのプラグインに対するコンフィグなどを纏めて良いかも。
|
||||
|
||||
LSP/Toolchainについて:言語機能やツールチェーンを呼び出す仕組みは、Hookを用いて編集検知->自動lintフィードバックと、Workflowで編集したらbashでcheckを実行する指示を出せば良い。
|
||||
|
||||
また、他のエコシステムとの統合を目指して、LLM/Workerの抽象化層を提供する仕組みを考える必要があるが、
|
||||
現在のllm-workerでは、llm_clientとして抽象化しているが、拡張機能としてプロバイダをロードする仕組みを提供する必要がある。
|
||||
|
||||
### as Plugin/Extension
|
||||
|
||||
Workflowを書くことができればそれでハーネスになると考えている。
|
||||
|
||||
具体的には、Tool/Hookからデータを収集し、Workflowを進める仕組みなど。
|
||||
|
||||
Rhai等のスクリプトを用いて書けると良いと思うが、RustのWasmコンパイルを用いて安全で深い制御ができれば、より良い選択肢になるだろう。
|
||||
|
||||
## ビルトイン・モジュール
|
||||
|
||||
エコシステムの上に成り立つ、組み込みのモジュールで構成されるシステム。
|
||||
|
||||
デフォルトの挙動を形成し、最も重要と言える。
|
||||
|
||||
### File Tools
|
||||
|
||||
CURDと権限をセットで提供する。AIが何を読み、何を編集したのかを記録できる。
|
||||
|
||||
また、先述の管轄ディレクトリ内に権限を制限したり、差分を取得するAPIを提供したりする。
|
||||
|
||||
### Companion
|
||||
|
||||
ユーザーが主に対話するプロファイルで、直接的にタスクには取り組まず、秘書のように振る舞う。
|
||||
|
||||
この役割自体は、Profileとして定義されており、ユーザーの指示を広く受け取るため、Workflowは使われない。
|
||||
|
||||
ドキュメントやメモリーを参照したり、ファイルを検索したりして、ユーザーの質問に答えたり、タスクを設計したりする。
|
||||
|
||||
常にプロジェクトを把握している役割として振舞うことが期待される。
|
||||
|
||||
### Document Resources
|
||||
|
||||
ドキュメントを記録する仕組み。
|
||||
|
||||
- specドキュメント
|
||||
- 作業記録
|
||||
|
||||
[Readable-Index](#readable-index) を組み込みたい。
|
||||
|
||||
### workspace
|
||||
|
||||
同じプロジェクトに属するpodをまとめる仕組みで、プロジェクトに応じてpodを分離するための論理的な空間である。
|
||||
|
||||
ファイルシステム的な制約は無く、ディレクトリを持つ必要はない。
|
||||
|
||||
先述のドキュメントシステムをworkspace単位で構築する仕組みを提供する。
|
||||
|
||||
## システムの必要なコンポーネント
|
||||
|
||||
- `pod`:実際にファイルを読み書きするプロセス
|
||||
- `niad`:デーモンプロセス:niaのコミュニケーションや監視・リストを行う
|
||||
- `nia-cli`:コマンドラインインターフェース:niaの操作を行う
|
||||
- `console`:フロントエンド:niaの操作を行う
|
||||
|
||||
ワークスペースの作成、ダッシュボード、ログ監視、Inboxシステムなど。
|
||||
|
||||
当面はWebフロントエンドで作成する。
|
||||
将来的にはネイティブアプリケーションの方がいいと思う。
|
||||
|
||||
---
|
||||
|
||||
# 離散的なアイデア
|
||||
|
||||
## Podスポーンとサブエージェント
|
||||
|
||||
Podが別のPodをスポーンさせるしくみを持つ。複数のエージェントに非同期的にタスクを行わせる為の仕組みである。
|
||||
|
||||
同じworkspaceを共有するpodとしてスポーンし、親podは子podのターン完了Hookでループの合間に通知として受け、子podのレスポンスをpollingし、指示を出せる。
|
||||
|
||||
この際、編集がコンフリクトしないように、最低限ローカルの書き込み権限を移譲する仕組みが求められる。
|
||||
|
||||
これは、1 pod = 1 sessionの仕組みを用いるべきで、
|
||||
親ディレクトリに居るpodが、子ディレクトリで権限を渡してpodをスポーンさせることで解決できるのではないか。
|
||||
|
||||
サブエージェントは、コンテキストを引き継がず、親セッションが必要な情報のみを渡し、より良い指示追従性能を求めるもので、Skillsの、コンテキストを持ったまま新しい技能を仕入れる仕組みよりもより明確に役割を分けることになる。
|
||||
|
||||
レビューの結果を再度返して修正し、またサブエージェントに投げるなどで、コンテキストを引き継いでほしいという用途も考えられる。
|
||||
しかし、互いのアウトプットをプロンプトとする関係上、相互に悪い方向に向かってしまい、元々の要件である指示追従性を損なう可能性がある。
|
||||
設計思想としては、サブエージェントの単位で相互コミュニケーションはさせるべきではないと考えている。
|
||||
|
||||
(TODO)
|
||||
Skillsとサブエージェントの関係性を考える・サブエージェントの要件を詳細化する
|
||||
|
||||
## readable index
|
||||
|
||||
ドキュメントや作業記録、プロジェクトの知識などを、**AIが読める形で**インデックス化する仕組み。
|
||||
|
||||
すべてのドキュメントのname-descriptionを列挙する仕組みでは、コンテキストを浪費してしまう。
|
||||
なので、作業記録を書いた際に、サブエージェントをHookから呼び、過去作業との連続性を把握させ、既存のRIをアップデートさせる。
|
||||
|
||||
加えて、機械的なlinterを導入してRIの構成や内容をチェックし、品質を保証しないと破綻しそう。
|
||||
|
||||
これを作り、AGENTS.mdとして配置する仕組みがあるべきだよなと過去に考えていた。
|
||||
niaにネイティブに組み込むべきだと思う。
|
||||
|
||||
(TODO)ドキュメント化してリンクを張る
|
||||
|
||||
## ランタイムAPI
|
||||
|
||||
RustそのままでExtensionやPluginを作るのは難しそう。
|
||||
|
||||
Rustで書いた拡張をWasmとしてコンパイルしてもらい、それをロードできる仕組みが良い(Zedの拡張機能の仕組みを参考にする)
|
||||
|
||||
アーカイブをロード可能にし、スクリプトをロードするので全然良いかも。
|
||||
|
||||
## コンテキスト
|
||||
|
||||
コンテキスト圧縮のタイミングで、Skillsはアンロード・Workflowは維持し、ここから利用可能なToolsを再構築する仕組みにする。
|
||||
|
||||
---
|
||||
|
||||
# プロジェクトの存在意義
|
||||
|
||||
## 既存のプロジェクトとの比較
|
||||
|
||||
ClaudeCode/CodexのようなCLIエージェントと比べて、"エージェントの統括"にフォーカスする。
|
||||
正直、細部のコーディング能力などはそれらの製品の焼き直しのようになると思うが、オープンソースなエンジンとして、より大きな価値を提供できるものと考えている。
|
||||
|
||||
OpenHandsは、ソフトウェアエンジニアリングのタスクを自動化するツールとしての価値を提供する一方、niaは対話的にプロジェクトを統括するツールとしての価値を提供する。
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
# MVP実装目標
|
||||
|
||||
## 最小構成
|
||||
|
||||
- [ ] Pod: llm-workerをラップし、プロファイル設定で起動
|
||||
- [ ] niad: Podのライフサイクル管理(起動・停止・一覧)
|
||||
- [ ] nia-cli: `nia run <profile>` でPod起動、stdin/stdoutで対話
|
||||
|
||||
PodにWorkerを組み込み、daemonからPodを起動する仕組み。を作成する。
|
||||
|
||||
ネットワークは考慮せず、一旦は完全にローカルファイルシステムで動作する。
|
||||
|
||||
ビルトインツールは最低限作る。(CURD/Shell)
|
||||
|
||||
LLMバックエンドを構成可能にする。
|
||||
|
||||
## ビルトインツール
|
||||
|
||||
- [ ] read_file / write_file / list_dir
|
||||
- [ ] execute_shell
|
||||
|
||||
## Profile形式
|
||||
|
||||
- TOML or YAML で定義
|
||||
- プロバイダ・モデル・システムプロンプト
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# nia
|
||||
|
||||
`nia` is an agent engine for agentic workflow and automation using LLMs.
|
||||
|
||||
# Parts
|
||||
|
||||
- **Pod**:
|
||||
|
|
@ -27,9 +27,9 @@
|
|||
packages.default = insomnia;
|
||||
packages.insomnia = insomnia;
|
||||
|
||||
apps.default = mkApp "tui" "Run the INSOMNIA terminal UI";
|
||||
apps.tui = mkApp "tui" "Run the INSOMNIA terminal UI";
|
||||
apps.pod = mkApp "pod" "Run the INSOMNIA Pod CLI";
|
||||
apps.default = mkApp "insomnia" "Run the INSOMNIA terminal UI";
|
||||
apps.insomnia = mkApp "insomnia" "Run the INSOMNIA terminal UI";
|
||||
apps.insomnia-pod = mkApp "insomnia-pod" "Run the INSOMNIA Pod CLI";
|
||||
|
||||
checks.default = insomnia;
|
||||
|
||||
|
|
|
|||
37
package.nix
37
package.nix
|
|
@ -40,7 +40,30 @@ rustPlatform.buildRustPackage rec {
|
|||
filter = sourceFilter;
|
||||
};
|
||||
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
cargoHash = "sha256-8ZT5moKFxj/5vbp5rsUG7UkPLY1fvQKhYTyjRWQ58xk=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# nixpkgs 25.11's fetchCargoVendor still uses crates.io's API
|
||||
# download endpoint in this environment, which returns 403 while the
|
||||
# immutable static CDN endpoint works. Keep this local package build on
|
||||
# static.crates.io until the upstream fetcher is fixed in our nixpkgs pin.
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
if [ -n "''${cargoRoot-}" ]; then
|
||||
cd "$cargoRoot"
|
||||
fi
|
||||
|
||||
vendor_util="$(command -v fetch-cargo-vendor-util-v2 || command -v fetch-cargo-vendor-util)"
|
||||
cp "$vendor_util" ./fetch-cargo-vendor-util-static
|
||||
substituteInPlace ./fetch-cargo-vendor-util-static \
|
||||
--replace-fail 'https://crates.io/api/v1/crates/{pkg["name"]}/{pkg["version"]}/download' \
|
||||
'https://static.crates.io/crates/{pkg["name"]}/{pkg["version"]}/download'
|
||||
./fetch-cargo-vendor-util-static create-vendor-staging ./Cargo.lock "$out"
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
};
|
||||
|
||||
strictDeps = true;
|
||||
|
||||
|
|
@ -80,13 +103,13 @@ rustPlatform.buildRustPackage rec {
|
|||
installCheckPhase = ''
|
||||
runHook preInstallCheck
|
||||
|
||||
"$out/bin/pod" --help >/dev/null
|
||||
test -x "$out/bin/tui"
|
||||
if "$out/bin/tui" --session not-a-uuid 2>tui.err; then
|
||||
echo "tui unexpectedly accepted an invalid --session value" >&2
|
||||
"$out/bin/insomnia-pod" --help >/dev/null
|
||||
test -x "$out/bin/insomnia"
|
||||
if "$out/bin/insomnia" --session not-a-uuid 2>insomnia.err; then
|
||||
echo "insomnia unexpectedly accepted an invalid --session value" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "invalid --session UUID" tui.err
|
||||
grep -q "invalid --session UUID" insomnia.err
|
||||
|
||||
test -d "$out/share/insomnia/resources/prompts"
|
||||
test -f "$out/share/doc/insomnia/nix.md"
|
||||
|
|
@ -97,7 +120,7 @@ rustPlatform.buildRustPackage rec {
|
|||
meta = {
|
||||
description = "Agentic coding Pod runtime and terminal UI";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "tui";
|
||||
mainProgram = "insomnia";
|
||||
platforms = lib.platforms.unix;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,10 @@ When searching, use grep/glob primitives rather than shell pipelines.
|
|||
|
||||
You can run multiple tools simultaneously by calling them within a single response.
|
||||
It is recommended to run tools that handle asynchronous processing, such as queries and readings, in batches.
|
||||
|
||||
### Memory and knowledge
|
||||
|
||||
For past decisions, prior requests, durable preferences, project history, or why something was done, use targeted lookup instead of guessing from vague recollection.
|
||||
Use `MemoryQuery` for durable memory records (summary, decisions, requests), `KnowledgeQuery` for project knowledge, `MemoryRead(kind=summary)` for the full memory summary, and `MemoryRead` on returned slugs when excerpts are insufficient.
|
||||
Resident memory and knowledge are helpful context but may be stale; current user instructions, repository files, tickets, git history, and session logs are authoritative for exact current state.
|
||||
Do not query memory every turn, and normally prefer read/query tools; use `MemoryWrite`, `MemoryEdit`, or `MemoryDelete` only when explicitly asked or in a memory maintenance worker.
|
||||
|
|
|
|||
155
work-items/closed/20260527-000004-manual-turn-rollback/item.md
Normal file
155
work-items/closed/20260527-000004-manual-turn-rollback/item.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
id: 20260527-000004-manual-turn-rollback
|
||||
slug: manual-turn-rollback
|
||||
title: Pod/TUI: 手動 rewind 導線
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-27T00:00:04Z
|
||||
updated_at: 2026-05-29T03:09:22Z
|
||||
assignee: null
|
||||
legacy_ticket: tickets/manual-turn-rollback.md
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`pod-empty-turn-rollback` / `tui-empty-turn-restore` により、AI 側出力が 0 の interrupted turn については Pod 側で自動 rollback し、TUI 側で入力を復元できるようになった。
|
||||
|
||||
次に欲しいのは、直前 turn だけの rollback command ではなく、TUI から過去の user message を選び、その地点まで会話を戻してその入力を composer に復元する **manual rewind** 導線である。
|
||||
|
||||
誤送信、モデル選択ミス、途中で方針を変えた場合などに、ユーザーは過去の入力を選び直し、必要なら編集してから Enter で retry できる。選択した瞬間に再実行はしない。
|
||||
|
||||
## UX
|
||||
|
||||
- `:rewind` command を追加する。
|
||||
- `:rollback` は `:rewind` の alias として扱ってよい。
|
||||
- `Ctrl+R` は rewind/rollback を表す shortcut として、同じ picker を開く。
|
||||
- `:rewind` / `Ctrl+R` は引数を取らず、TUI 内の picker を開く。
|
||||
- Rewind picker は popup/overlay ではなく、通常の conversation/history view area を一時的に置き換える dedicated view として表示する。
|
||||
- composer/input area と actionbar/status area は通常通り残す。
|
||||
- main view area だけが message history から rewind target list に切り替わる。
|
||||
- Esc 等で picker を閉じると、通常の conversation/history view に戻る。
|
||||
- `:rewind` command は `Idle` / `Paused` の時だけ picker を開く。`Running` 中は visible diagnostic を出して何もしない。
|
||||
- `Ctrl+R` shortcut も Pod が停止中 (`Idle` または `Paused`) の時だけ有効にする。`Running` 中は無視または visible diagnostic にする。
|
||||
- picker は過去の user message を新しい順に表示する。
|
||||
- turn number / index
|
||||
- timestamp または relative time
|
||||
- message preview
|
||||
- eligible / disabled reason
|
||||
- picker で user message を選択すると、Pod はその user message の直前まで history/session log を rewind し、選択された message を TUI composer に復元する。
|
||||
- 選択後は、composer に該当 message が入っている状態になる。
|
||||
- Enter を押すとその message で retry できる。
|
||||
- ユーザーは送信前に編集できる。
|
||||
- 選択しただけで自動実行しない。
|
||||
- Esc 等で picker を閉じると何も変更しない。
|
||||
|
||||
## Semantics
|
||||
|
||||
Manual rewind は destructive operation として扱う。選択地点より後の履歴 suffix は捨てる。fork は優先度低めの別機能であり、この ticket の実装では fork を作らない。
|
||||
|
||||
- Rewind は current active segment/session に対して行う。
|
||||
- Rewind 成功時、選択された `UserInput` entry 自体も履歴から取り除かれ、composer に戻る。
|
||||
- Rewind 後、選択地点より後の assistant output / later user messages / usage entries / display blocks は現 branch から消える。
|
||||
- 元 suffix を保持したい場合は将来の `pod-session-fork` で扱う。この ticket では保持しない。
|
||||
- Tool side effect の undo はしない。
|
||||
|
||||
Initial safety policy:
|
||||
|
||||
- Pod が `Idle` または `Paused` の時だけ許可する。
|
||||
- `Running` 中は拒否する。
|
||||
- picker 表示時から head が変わった場合は apply 時に再検証して拒否する。
|
||||
- segment rotation / compaction を跨ぐ rewind は初期実装では対象外でよい。
|
||||
- suffix に tool call / tool result / other side-effect-looking entries が含まれる場合でも、初期方針としては destructive rewind を許可してよい。ただし UI には「以降の履歴は破棄され、tool side effects は undo されない」ことが分かる notice/diagnostic を出す。
|
||||
- 実装上どうしても安全に整合性を保てない suffix 種別がある場合は、具体的な disabled reason を表示して拒否する。
|
||||
|
||||
## Protocol / ownership
|
||||
|
||||
TUI がローカルに履歴を削るのではなく、Pod が authoritative に rewind を検証・適用する。
|
||||
|
||||
Suggested protocol shape:
|
||||
|
||||
```rust
|
||||
Method::ListRewindTargets { limit: Option<usize> }
|
||||
Method::RewindTo {
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
}
|
||||
|
||||
Event::RewindTargets { targets: Vec<RewindTarget> }
|
||||
Event::RewindApplied {
|
||||
entries: Vec<serde_json::Value>,
|
||||
input: Vec<Segment>,
|
||||
summary: RewindSummary,
|
||||
}
|
||||
```
|
||||
|
||||
Exact names may differ, but the behavior should stay:
|
||||
|
||||
- listing targets and applying a target are separate operations.
|
||||
- apply revalidates target identity and current head.
|
||||
- success returns enough entries for clients to reseed their view.
|
||||
- success returns the selected user input segments so TUI can restore the composer.
|
||||
- failure uses visible diagnostics, e.g. `Event::Error { code: InvalidRequest, message }`.
|
||||
|
||||
`RunResult::RolledBack` should not be reused for this idle control operation. It remains the run-lifecycle signal for submit-time empty-turn rollback.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Target identity can initially be current segment + entry index:
|
||||
|
||||
```rust
|
||||
RewindTargetId {
|
||||
segment_id: SegmentId,
|
||||
user_input_entry_index: usize,
|
||||
}
|
||||
```
|
||||
|
||||
- Include `expected_head_entries` to reject stale picker selections.
|
||||
- Each target should include:
|
||||
- preview
|
||||
- original `Vec<Segment>`
|
||||
- turn/index metadata if available
|
||||
- whether the target is eligible
|
||||
- disabled/warning reason if relevant
|
||||
- the entry count to truncate to, which is before the selected user message.
|
||||
- Rewind apply must keep these in sync:
|
||||
- worker history
|
||||
- `user_segments`
|
||||
- session store segment log
|
||||
- `SegmentLogSink` mirror
|
||||
- usage history / trackers
|
||||
- TUI view reconstructed from returned entries
|
||||
- If a complete current-state reconstruction from log is simpler and safer than maintaining many historical snapshots, prefer that over fragile partial truncation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `:rewind` opens a picker of past user messages by replacing the normal conversation/history view area, not by drawing a small popup.
|
||||
- `Ctrl+R` opens the same picker only while Pod status is `Idle` or `Paused`; it is disabled/rejected while `Running`.
|
||||
- Selecting a message rewinds the Pod state to before that message and restores the message into the TUI composer.
|
||||
- Rewind does not auto-run; pressing Enter after selection retries the restored message.
|
||||
- Rewind success updates Pod session log, SegmentLogSink mirror, worker state, and TUI display consistently.
|
||||
- Esc returns from the rewind picker to the normal conversation/history view without changing Pod state.
|
||||
- Rewind failure leaves state unchanged and shows a clear reason.
|
||||
- Picker selections are revalidated at apply time to avoid stale-head corruption.
|
||||
- Rewound suffix is intentionally discarded; no fork is created.
|
||||
- Tool side effects are not undone; UI/diagnostics make this clear when relevant.
|
||||
- Tests cover target listing, apply success, stale-head rejection, composer restore, TUI display reseed, and at least one suffix-with-tool case.
|
||||
- `cargo fmt --check`
|
||||
- `cargo check -p protocol -p pod -p tui`
|
||||
- Relevant focused tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Creating a fork when rewinding.
|
||||
- Fork tree visualization.
|
||||
- Merging branches.
|
||||
- Undoing tool side effects.
|
||||
- Rollback history stack / redo.
|
||||
- Rewind across compacted segments unless it falls out naturally from implementation.
|
||||
|
||||
## Related
|
||||
|
||||
- `20260527-000009-pod-session-fork` remains a lower-priority future feature for preserving alternate histories.
|
||||
- Completed: `pod-empty-turn-rollback`
|
||||
- Completed: `tui-empty-turn-restore`
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
---
|
||||
id: 20260527-000004-manual-turn-rollback
|
||||
slug: manual-turn-rollback
|
||||
title: Pod/TUI: 手動 rewind 導線
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-27T00:00:04Z
|
||||
updated_at: 2026-05-29T03:09:22Z
|
||||
assignee: null
|
||||
legacy_ticket: tickets/manual-turn-rollback.md
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`pod-empty-turn-rollback` / `tui-empty-turn-restore` により、AI 側出力が 0 の interrupted turn については Pod 側で自動 rollback し、TUI 側で入力を復元できるようになった。
|
||||
|
||||
次に欲しいのは、直前 turn だけの rollback command ではなく、TUI から過去の user message を選び、その地点まで会話を戻してその入力を composer に復元する **manual rewind** 導線である。
|
||||
|
||||
誤送信、モデル選択ミス、途中で方針を変えた場合などに、ユーザーは過去の入力を選び直し、必要なら編集してから Enter で retry できる。選択した瞬間に再実行はしない。
|
||||
|
||||
## UX
|
||||
|
||||
- `:rewind` command を追加する。
|
||||
- `:rollback` は `:rewind` の alias として扱ってよい。
|
||||
- `Ctrl+R` は rewind/rollback を表す shortcut として、同じ picker を開く。
|
||||
- `:rewind` / `Ctrl+R` は引数を取らず、TUI 内の picker を開く。
|
||||
- Rewind picker は popup/overlay ではなく、通常の conversation/history view area を一時的に置き換える dedicated view として表示する。
|
||||
- composer/input area と actionbar/status area は通常通り残す。
|
||||
- main view area だけが message history から rewind target list に切り替わる。
|
||||
- Esc 等で picker を閉じると、通常の conversation/history view に戻る。
|
||||
- `:rewind` command は `Idle` / `Paused` の時だけ picker を開く。`Running` 中は visible diagnostic を出して何もしない。
|
||||
- `Ctrl+R` shortcut も Pod が停止中 (`Idle` または `Paused`) の時だけ有効にする。`Running` 中は無視または visible diagnostic にする。
|
||||
- picker は過去の user message を新しい順に表示する。
|
||||
- turn number / index
|
||||
- timestamp または relative time
|
||||
- message preview
|
||||
- eligible / disabled reason
|
||||
- picker で user message を選択すると、Pod はその user message の直前まで history/session log を rewind し、選択された message を TUI composer に復元する。
|
||||
- 選択後は、composer に該当 message が入っている状態になる。
|
||||
- Enter を押すとその message で retry できる。
|
||||
- ユーザーは送信前に編集できる。
|
||||
- 選択しただけで自動実行しない。
|
||||
- Esc 等で picker を閉じると何も変更しない。
|
||||
|
||||
## Semantics
|
||||
|
||||
Manual rewind は destructive operation として扱う。選択地点より後の履歴 suffix は捨てる。fork は優先度低めの別機能であり、この ticket の実装では fork を作らない。
|
||||
|
||||
- Rewind は current active segment/session に対して行う。
|
||||
- Rewind 成功時、選択された `UserInput` entry 自体も履歴から取り除かれ、composer に戻る。
|
||||
- Rewind 後、選択地点より後の assistant output / later user messages / usage entries / display blocks は現 branch から消える。
|
||||
- 元 suffix を保持したい場合は将来の `pod-session-fork` で扱う。この ticket では保持しない。
|
||||
- Tool side effect の undo はしない。
|
||||
|
||||
Initial safety policy:
|
||||
|
||||
- Pod が `Idle` または `Paused` の時だけ許可する。
|
||||
- `Running` 中は拒否する。
|
||||
- picker 表示時から head が変わった場合は apply 時に再検証して拒否する。
|
||||
- segment rotation / compaction を跨ぐ rewind は初期実装では対象外でよい。
|
||||
- suffix に tool call / tool result / other side-effect-looking entries が含まれる場合でも、初期方針としては destructive rewind を許可してよい。ただし UI には「以降の履歴は破棄され、tool side effects は undo されない」ことが分かる notice/diagnostic を出す。
|
||||
- 実装上どうしても安全に整合性を保てない suffix 種別がある場合は、具体的な disabled reason を表示して拒否する。
|
||||
|
||||
## Protocol / ownership
|
||||
|
||||
TUI がローカルに履歴を削るのではなく、Pod が authoritative に rewind を検証・適用する。
|
||||
|
||||
Suggested protocol shape:
|
||||
|
||||
```rust
|
||||
Method::ListRewindTargets { limit: Option<usize> }
|
||||
Method::RewindTo {
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
}
|
||||
|
||||
Event::RewindTargets { targets: Vec<RewindTarget> }
|
||||
Event::RewindApplied {
|
||||
entries: Vec<serde_json::Value>,
|
||||
input: Vec<Segment>,
|
||||
summary: RewindSummary,
|
||||
}
|
||||
```
|
||||
|
||||
Exact names may differ, but the behavior should stay:
|
||||
|
||||
- listing targets and applying a target are separate operations.
|
||||
- apply revalidates target identity and current head.
|
||||
- success returns enough entries for clients to reseed their view.
|
||||
- success returns the selected user input segments so TUI can restore the composer.
|
||||
- failure uses visible diagnostics, e.g. `Event::Error { code: InvalidRequest, message }`.
|
||||
|
||||
`RunResult::RolledBack` should not be reused for this idle control operation. It remains the run-lifecycle signal for submit-time empty-turn rollback.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Target identity can initially be current segment + entry index:
|
||||
|
||||
```rust
|
||||
RewindTargetId {
|
||||
segment_id: SegmentId,
|
||||
user_input_entry_index: usize,
|
||||
}
|
||||
```
|
||||
|
||||
- Include `expected_head_entries` to reject stale picker selections.
|
||||
- Each target should include:
|
||||
- preview
|
||||
- original `Vec<Segment>`
|
||||
- turn/index metadata if available
|
||||
- whether the target is eligible
|
||||
- disabled/warning reason if relevant
|
||||
- the entry count to truncate to, which is before the selected user message.
|
||||
- Rewind apply must keep these in sync:
|
||||
- worker history
|
||||
- `user_segments`
|
||||
- session store segment log
|
||||
- `SegmentLogSink` mirror
|
||||
- usage history / trackers
|
||||
- TUI view reconstructed from returned entries
|
||||
- If a complete current-state reconstruction from log is simpler and safer than maintaining many historical snapshots, prefer that over fragile partial truncation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `:rewind` opens a picker of past user messages by replacing the normal conversation/history view area, not by drawing a small popup.
|
||||
- `Ctrl+R` opens the same picker only while Pod status is `Idle` or `Paused`; it is disabled/rejected while `Running`.
|
||||
- Selecting a message rewinds the Pod state to before that message and restores the message into the TUI composer.
|
||||
- Rewind does not auto-run; pressing Enter after selection retries the restored message.
|
||||
- Rewind success updates Pod session log, SegmentLogSink mirror, worker state, and TUI display consistently.
|
||||
- Esc returns from the rewind picker to the normal conversation/history view without changing Pod state.
|
||||
- Rewind failure leaves state unchanged and shows a clear reason.
|
||||
- Picker selections are revalidated at apply time to avoid stale-head corruption.
|
||||
- Rewound suffix is intentionally discarded; no fork is created.
|
||||
- Tool side effects are not undone; UI/diagnostics make this clear when relevant.
|
||||
- Tests cover target listing, apply success, stale-head rejection, composer restore, TUI display reseed, and at least one suffix-with-tool case.
|
||||
- `cargo fmt --check`
|
||||
- `cargo check -p protocol -p pod -p tui`
|
||||
- Relevant focused tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Creating a fork when rewinding.
|
||||
- Fork tree visualization.
|
||||
- Merging branches.
|
||||
- Undoing tool side effects.
|
||||
- Rollback history stack / redo.
|
||||
- Rewind across compacted segments unless it falls out naturally from implementation.
|
||||
|
||||
## Related
|
||||
|
||||
- `20260527-000009-pod-session-fork` remains a lower-priority future feature for preserving alternate histories.
|
||||
- Completed: `pod-empty-turn-rollback`
|
||||
- Completed: `tui-empty-turn-restore`
|
||||
170
work-items/closed/20260527-000004-manual-turn-rollback/thread.md
Normal file
170
work-items/closed/20260527-000004-manual-turn-rollback/thread.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:04Z -->
|
||||
|
||||
## Migrated
|
||||
|
||||
Migrated from tickets/manual-turn-rollback.md. No legacy review file was present at migration time.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T03:09:22Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260527-000004-manual-turn-rollback
|
||||
slug: manual-turn-rollback
|
||||
title: Pod/TUI: 手動 rewind 導線
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-27T00:00:04Z
|
||||
updated_at: 2026-05-29T03:09:22Z
|
||||
assignee: null
|
||||
legacy_ticket: tickets/manual-turn-rollback.md
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`pod-empty-turn-rollback` / `tui-empty-turn-restore` により、AI 側出力が 0 の interrupted turn については Pod 側で自動 rollback し、TUI 側で入力を復元できるようになった。
|
||||
|
||||
次に欲しいのは、直前 turn だけの rollback command ではなく、TUI から過去の user message を選び、その地点まで会話を戻してその入力を composer に復元する **manual rewind** 導線である。
|
||||
|
||||
誤送信、モデル選択ミス、途中で方針を変えた場合などに、ユーザーは過去の入力を選び直し、必要なら編集してから Enter で retry できる。選択した瞬間に再実行はしない。
|
||||
|
||||
## UX
|
||||
|
||||
- `:rewind` command を追加する。
|
||||
- `:rollback` は `:rewind` の alias として扱ってよい。
|
||||
- `Ctrl+R` は rewind/rollback を表す shortcut として、同じ picker を開く。
|
||||
- `:rewind` / `Ctrl+R` は引数を取らず、TUI 内の picker を開く。
|
||||
- Rewind picker は popup/overlay ではなく、通常の conversation/history view area を一時的に置き換える dedicated view として表示する。
|
||||
- composer/input area と actionbar/status area は通常通り残す。
|
||||
- main view area だけが message history から rewind target list に切り替わる。
|
||||
- Esc 等で picker を閉じると、通常の conversation/history view に戻る。
|
||||
- `:rewind` command は `Idle` / `Paused` の時だけ picker を開く。`Running` 中は visible diagnostic を出して何もしない。
|
||||
- `Ctrl+R` shortcut も Pod が停止中 (`Idle` または `Paused`) の時だけ有効にする。`Running` 中は無視または visible diagnostic にする。
|
||||
- picker は過去の user message を新しい順に表示する。
|
||||
- turn number / index
|
||||
- timestamp または relative time
|
||||
- message preview
|
||||
- eligible / disabled reason
|
||||
- picker で user message を選択すると、Pod はその user message の直前まで history/session log を rewind し、選択された message を TUI composer に復元する。
|
||||
- 選択後は、composer に該当 message が入っている状態になる。
|
||||
- Enter を押すとその message で retry できる。
|
||||
- ユーザーは送信前に編集できる。
|
||||
- 選択しただけで自動実行しない。
|
||||
- Esc 等で picker を閉じると何も変更しない。
|
||||
|
||||
## Semantics
|
||||
|
||||
Manual rewind は destructive operation として扱う。選択地点より後の履歴 suffix は捨てる。fork は優先度低めの別機能であり、この ticket の実装では fork を作らない。
|
||||
|
||||
- Rewind は current active segment/session に対して行う。
|
||||
- Rewind 成功時、選択された `UserInput` entry 自体も履歴から取り除かれ、composer に戻る。
|
||||
- Rewind 後、選択地点より後の assistant output / later user messages / usage entries / display blocks は現 branch から消える。
|
||||
- 元 suffix を保持したい場合は将来の `pod-session-fork` で扱う。この ticket では保持しない。
|
||||
- Tool side effect の undo はしない。
|
||||
|
||||
Initial safety policy:
|
||||
|
||||
- Pod が `Idle` または `Paused` の時だけ許可する。
|
||||
- `Running` 中は拒否する。
|
||||
- picker 表示時から head が変わった場合は apply 時に再検証して拒否する。
|
||||
- segment rotation / compaction を跨ぐ rewind は初期実装では対象外でよい。
|
||||
- suffix に tool call / tool result / other side-effect-looking entries が含まれる場合でも、初期方針としては destructive rewind を許可してよい。ただし UI には「以降の履歴は破棄され、tool side effects は undo されない」ことが分かる notice/diagnostic を出す。
|
||||
- 実装上どうしても安全に整合性を保てない suffix 種別がある場合は、具体的な disabled reason を表示して拒否する。
|
||||
|
||||
## Protocol / ownership
|
||||
|
||||
TUI がローカルに履歴を削るのではなく、Pod が authoritative に rewind を検証・適用する。
|
||||
|
||||
Suggested protocol shape:
|
||||
|
||||
```rust
|
||||
Method::ListRewindTargets { limit: Option<usize> }
|
||||
Method::RewindTo {
|
||||
target: RewindTargetId,
|
||||
expected_head_entries: usize,
|
||||
}
|
||||
|
||||
Event::RewindTargets { targets: Vec<RewindTarget> }
|
||||
Event::RewindApplied {
|
||||
entries: Vec<serde_json::Value>,
|
||||
input: Vec<Segment>,
|
||||
summary: RewindSummary,
|
||||
}
|
||||
```
|
||||
|
||||
Exact names may differ, but the behavior should stay:
|
||||
|
||||
- listing targets and applying a target are separate operations.
|
||||
- apply revalidates target identity and current head.
|
||||
- success returns enough entries for clients to reseed their view.
|
||||
- success returns the selected user input segments so TUI can restore the composer.
|
||||
- failure uses visible diagnostics, e.g. `Event::Error { code: InvalidRequest, message }`.
|
||||
|
||||
`RunResult::RolledBack` should not be reused for this idle control operation. It remains the run-lifecycle signal for submit-time empty-turn rollback.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Target identity can initially be current segment + entry index:
|
||||
|
||||
```rust
|
||||
RewindTargetId {
|
||||
segment_id: SegmentId,
|
||||
user_input_entry_index: usize,
|
||||
}
|
||||
```
|
||||
|
||||
- Include `expected_head_entries` to reject stale picker selections.
|
||||
- Each target should include:
|
||||
- preview
|
||||
- original `Vec<Segment>`
|
||||
- turn/index metadata if available
|
||||
- whether the target is eligible
|
||||
- disabled/warning reason if relevant
|
||||
- the entry count to truncate to, which is before the selected user message.
|
||||
- Rewind apply must keep these in sync:
|
||||
- worker history
|
||||
- `user_segments`
|
||||
- session store segment log
|
||||
- `SegmentLogSink` mirror
|
||||
- usage history / trackers
|
||||
- TUI view reconstructed from returned entries
|
||||
- If a complete current-state reconstruction from log is simpler and safer than maintaining many historical snapshots, prefer that over fragile partial truncation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `:rewind` opens a picker of past user messages by replacing the normal conversation/history view area, not by drawing a small popup.
|
||||
- `Ctrl+R` opens the same picker only while Pod status is `Idle` or `Paused`; it is disabled/rejected while `Running`.
|
||||
- Selecting a message rewinds the Pod state to before that message and restores the message into the TUI composer.
|
||||
- Rewind does not auto-run; pressing Enter after selection retries the restored message.
|
||||
- Rewind success updates Pod session log, SegmentLogSink mirror, worker state, and TUI display consistently.
|
||||
- Esc returns from the rewind picker to the normal conversation/history view without changing Pod state.
|
||||
- Rewind failure leaves state unchanged and shows a clear reason.
|
||||
- Picker selections are revalidated at apply time to avoid stale-head corruption.
|
||||
- Rewound suffix is intentionally discarded; no fork is created.
|
||||
- Tool side effects are not undone; UI/diagnostics make this clear when relevant.
|
||||
- Tests cover target listing, apply success, stale-head rejection, composer restore, TUI display reseed, and at least one suffix-with-tool case.
|
||||
- `cargo fmt --check`
|
||||
- `cargo check -p protocol -p pod -p tui`
|
||||
- Relevant focused tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Creating a fork when rewinding.
|
||||
- Fork tree visualization.
|
||||
- Merging branches.
|
||||
- Undoing tool side effects.
|
||||
- Rollback history stack / redo.
|
||||
- Rewind across compacted segments unless it falls out naturally from implementation.
|
||||
|
||||
## Related
|
||||
|
||||
- `20260527-000009-pod-session-fork` remains a lower-priority future feature for preserving alternate histories.
|
||||
- Completed: `pod-empty-turn-rollback`
|
||||
- Completed: `tui-empty-turn-restore`
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -2,12 +2,12 @@
|
|||
id: 20260527-000005-memory-tool-guidance-prompt
|
||||
slug: memory-tool-guidance-prompt
|
||||
title: プロンプト: memory / knowledge tool 利用タイミングのガイダンス
|
||||
status: open
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [migrated]
|
||||
created_at: 2026-05-27T00:00:05Z
|
||||
updated_at: 2026-05-27T00:00:05Z
|
||||
updated_at: 2026-05-28T23:59:06Z
|
||||
assignee: null
|
||||
legacy_ticket: tickets/memory-tool-guidance-prompt.md
|
||||
---
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
id: 20260527-000005-memory-tool-guidance-prompt
|
||||
slug: memory-tool-guidance-prompt
|
||||
title: プロンプト: memory / knowledge tool 利用タイミングのガイダンス
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [migrated]
|
||||
created_at: 2026-05-27T00:00:05Z
|
||||
updated_at: 2026-05-28T23:59:06Z
|
||||
assignee: null
|
||||
legacy_ticket: tickets/memory-tool-guidance-prompt.md
|
||||
---
|
||||
|
||||
## Migration reference
|
||||
|
||||
- legacy_ticket: tickets/memory-tool-guidance-prompt.md
|
||||
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||
|
||||
# プロンプト: memory / knowledge tool 利用タイミングのガイダンス
|
||||
|
||||
## 背景
|
||||
|
||||
通常 Pod には `MemoryQuery` / `MemoryRead` / `KnowledgeQuery` / `MemoryWrite` 等の memory / knowledge tools が提供されているが、現状の通常 system prompt はそれらを「いつ使うべきか」をほとんど説明していない。
|
||||
|
||||
現在の `resources/prompts/common/tool-usage.md` は、既知パスなら Read、検索なら Grep/Glob、並列可能ならまとめる、という汎用 tool 方針に留まる。memory / knowledge tools の description には操作方法はあるが、モデルが自発的に memory lookup すべき状況は明示されていない。
|
||||
|
||||
このため、過去の決定・ユーザー嗜好・以前の経緯を問われても、モデルが `MemoryQuery` / `MemoryRead` を自発的に使わない可能性が高い。`summary.md` resident injection により短い durable context は常時見えるようになるが、詳細な過去判断や request を探すには query guidance が必要である。
|
||||
|
||||
## 方針
|
||||
|
||||
通常 Pod の system prompt に、memory / knowledge tools の利用タイミングを短く追加する。
|
||||
|
||||
目的は「必要な時に過去情報を探す」ことであり、毎 turn memory query を強制することではない。memory / knowledge は helpful context だが stale になり得るため、現在の user instruction / files / tickets / git state / session log を上書きする権威として扱わせない。
|
||||
|
||||
## 推奨する追加文言
|
||||
|
||||
`resources/prompts/common/tool-usage.md` に新しい小節を足すか、`resources/prompts/common/memory.md` を作って `default.md` から include する。
|
||||
|
||||
例:
|
||||
|
||||
```md
|
||||
## Memory and knowledge
|
||||
|
||||
Use memory and knowledge tools when the user asks about past decisions, prior requests, durable preferences, project history, or why something was done. Do not guess from vague recollection when a targeted memory lookup would answer the question.
|
||||
|
||||
- Use `MemoryQuery` for durable memory records: summary, decisions, and requests.
|
||||
- Use `KnowledgeQuery` for project knowledge records.
|
||||
- Use `MemoryRead(kind=summary)` when you need the full workspace memory summary.
|
||||
- Use `MemoryRead` on returned slugs when query excerpts are insufficient.
|
||||
|
||||
Resident memory and knowledge are helpful context but may be stale. Current user instructions, repository files, tickets, git history, and session logs are more authoritative for exact current state.
|
||||
|
||||
Do not query memory on every turn. Prefer it when past context, user preferences, or prior rationale materially affects the answer or implementation.
|
||||
```
|
||||
|
||||
文言は実装時に自然に調整してよいが、以下の意味は維持する。
|
||||
|
||||
- 過去判断 / 過去依頼 / ユーザー嗜好 / project history / why 系では memory lookup を促す。
|
||||
- `MemoryQuery`, `KnowledgeQuery`, `MemoryRead(kind=summary)`, slug read の役割を明示する。
|
||||
- resident context は stale になり得ると明示する。
|
||||
- current user instruction / files / tickets / git / session logs の方が exact current state では強いと明示する。
|
||||
- 毎 turn query しないと明示する。
|
||||
|
||||
## 要件
|
||||
|
||||
- 通常 Pod の default prompt に memory / knowledge tool 利用タイミングの guidance が入る。
|
||||
- internal prompts (`memory_extract_system`, `memory_consolidation_system`, `compact_system`) の挙動を変えない。
|
||||
- guidance は短く、通常 turn の token overhead を過度に増やさない。
|
||||
- guidance は memory / knowledge を current authority より上に置かない。
|
||||
- guidance は毎 turn memory query を促さない。
|
||||
- `MemoryWrite` / `MemoryEdit` / `MemoryDelete` の自発的利用を安易に促さない。
|
||||
- 通常作業では read/query を促し、write/edit/delete は明示的な依頼または memory maintenance worker に寄せる。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `resources/prompts/default.md` から memory guidance が render される。
|
||||
- prompt render / catalog 関連 test があれば更新されている。
|
||||
- internal worker prompt には不要な memory guidance が混ざらない。
|
||||
- `cargo fmt --check` と関連 test が通る。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- `summary.md` resident injection の実装。これは `memory-summary-resident-injection.md` で扱う。
|
||||
- memory tool descriptions の大幅変更。
|
||||
- memory usage metrics の設計変更。
|
||||
- global memory / project local memory の store 分離。
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:05Z -->
|
||||
|
||||
## Migrated
|
||||
|
||||
Migrated from tickets/memory-tool-guidance-prompt.md. No legacy review file was present at migration time.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-28T23:59:06Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260527-000005-memory-tool-guidance-prompt
|
||||
slug: memory-tool-guidance-prompt
|
||||
title: プロンプト: memory / knowledge tool 利用タイミングのガイダンス
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [migrated]
|
||||
created_at: 2026-05-27T00:00:05Z
|
||||
updated_at: 2026-05-28T23:59:06Z
|
||||
assignee: null
|
||||
legacy_ticket: tickets/memory-tool-guidance-prompt.md
|
||||
---
|
||||
|
||||
## Migration reference
|
||||
|
||||
- legacy_ticket: tickets/memory-tool-guidance-prompt.md
|
||||
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||
|
||||
# プロンプト: memory / knowledge tool 利用タイミングのガイダンス
|
||||
|
||||
## 背景
|
||||
|
||||
通常 Pod には `MemoryQuery` / `MemoryRead` / `KnowledgeQuery` / `MemoryWrite` 等の memory / knowledge tools が提供されているが、現状の通常 system prompt はそれらを「いつ使うべきか」をほとんど説明していない。
|
||||
|
||||
現在の `resources/prompts/common/tool-usage.md` は、既知パスなら Read、検索なら Grep/Glob、並列可能ならまとめる、という汎用 tool 方針に留まる。memory / knowledge tools の description には操作方法はあるが、モデルが自発的に memory lookup すべき状況は明示されていない。
|
||||
|
||||
このため、過去の決定・ユーザー嗜好・以前の経緯を問われても、モデルが `MemoryQuery` / `MemoryRead` を自発的に使わない可能性が高い。`summary.md` resident injection により短い durable context は常時見えるようになるが、詳細な過去判断や request を探すには query guidance が必要である。
|
||||
|
||||
## 方針
|
||||
|
||||
通常 Pod の system prompt に、memory / knowledge tools の利用タイミングを短く追加する。
|
||||
|
||||
目的は「必要な時に過去情報を探す」ことであり、毎 turn memory query を強制することではない。memory / knowledge は helpful context だが stale になり得るため、現在の user instruction / files / tickets / git state / session log を上書きする権威として扱わせない。
|
||||
|
||||
## 推奨する追加文言
|
||||
|
||||
`resources/prompts/common/tool-usage.md` に新しい小節を足すか、`resources/prompts/common/memory.md` を作って `default.md` から include する。
|
||||
|
||||
例:
|
||||
|
||||
```md
|
||||
## Memory and knowledge
|
||||
|
||||
Use memory and knowledge tools when the user asks about past decisions, prior requests, durable preferences, project history, or why something was done. Do not guess from vague recollection when a targeted memory lookup would answer the question.
|
||||
|
||||
- Use `MemoryQuery` for durable memory records: summary, decisions, and requests.
|
||||
- Use `KnowledgeQuery` for project knowledge records.
|
||||
- Use `MemoryRead(kind=summary)` when you need the full workspace memory summary.
|
||||
- Use `MemoryRead` on returned slugs when query excerpts are insufficient.
|
||||
|
||||
Resident memory and knowledge are helpful context but may be stale. Current user instructions, repository files, tickets, git history, and session logs are more authoritative for exact current state.
|
||||
|
||||
Do not query memory on every turn. Prefer it when past context, user preferences, or prior rationale materially affects the answer or implementation.
|
||||
```
|
||||
|
||||
文言は実装時に自然に調整してよいが、以下の意味は維持する。
|
||||
|
||||
- 過去判断 / 過去依頼 / ユーザー嗜好 / project history / why 系では memory lookup を促す。
|
||||
- `MemoryQuery`, `KnowledgeQuery`, `MemoryRead(kind=summary)`, slug read の役割を明示する。
|
||||
- resident context は stale になり得ると明示する。
|
||||
- current user instruction / files / tickets / git / session logs の方が exact current state では強いと明示する。
|
||||
- 毎 turn query しないと明示する。
|
||||
|
||||
## 要件
|
||||
|
||||
- 通常 Pod の default prompt に memory / knowledge tool 利用タイミングの guidance が入る。
|
||||
- internal prompts (`memory_extract_system`, `memory_consolidation_system`, `compact_system`) の挙動を変えない。
|
||||
- guidance は短く、通常 turn の token overhead を過度に増やさない。
|
||||
- guidance は memory / knowledge を current authority より上に置かない。
|
||||
- guidance は毎 turn memory query を促さない。
|
||||
- `MemoryWrite` / `MemoryEdit` / `MemoryDelete` の自発的利用を安易に促さない。
|
||||
- 通常作業では read/query を促し、write/edit/delete は明示的な依頼または memory maintenance worker に寄せる。
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `resources/prompts/default.md` から memory guidance が render される。
|
||||
- prompt render / catalog 関連 test があれば更新されている。
|
||||
- internal worker prompt には不要な memory guidance が混ざらない。
|
||||
- `cargo fmt --check` と関連 test が通る。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- `summary.md` resident injection の実装。これは `memory-summary-resident-injection.md` で扱う。
|
||||
- memory tool descriptions の大幅変更。
|
||||
- memory usage metrics の設計変更。
|
||||
- global memory / project local memory の store 分離。
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
id: 20260528-233524-multi-pod-open-return
|
||||
slug: multi-pod-open-return
|
||||
title: Return to multi-Pod view after opening a Pod
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-28T23:35:24Z
|
||||
updated_at: 2026-05-28T23:57:49Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`tui --multi` can open the selected Pod with `o`. The current implementation treats this as leaving the multi-Pod dashboard and tail-calling the normal single-Pod TUI. Once the single-Pod screen is detached with `Ctrl+D` / `Ctrl+C`, the process exits instead of returning to the multi-Pod view.
|
||||
|
||||
For now, no special "back mode" or dedicated back key is needed. The desired behavior is simpler: when the user opens a Pod from the multi-Pod dashboard, the normal single-Pod attach screen runs as a nested attach session. When that session exits normally by detach/quit (`Ctrl+D`, `Ctrl+C`, or equivalent normal exit), control returns to the multi-Pod dashboard.
|
||||
|
||||
This should be implemented by abstracting the TUI launch/control flow, not by adding protocol features or making the single-Pod App deeply aware of multi-Pod mode.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `tui --multi` remains the entrypoint for the multi-Pod dashboard.
|
||||
- In `tui --multi`, pressing `o` on a selected openable Pod opens the normal single-Pod conversation screen.
|
||||
- When that single-Pod screen exits normally, TUI returns to the multi-Pod dashboard instead of exiting the process.
|
||||
- `Ctrl+D` / `Ctrl+C` detach/quit from the opened single-Pod screen should return to multi view.
|
||||
- Normal single-Pod launches such as `tui <pod>` / `tui --pod <name>` should continue to exit the process on `Ctrl+D` / `Ctrl+C`.
|
||||
- Avoid introducing a dedicated back key or back mode for this ticket.
|
||||
- The caller loop determines whether a normal single-Pod exit returns to multi view or exits the process.
|
||||
- Preserve multi-Pod dashboard state where practical.
|
||||
- The selected Pod name should remain selected after returning, if still visible.
|
||||
- The multi-Pod composer contents should be preserved across open/return.
|
||||
- The Pod list should refresh after returning so status changes are visible.
|
||||
- Keep terminal handling clean.
|
||||
- Do not unnecessarily leave/re-enter alternate screen between multi view and the nested single-Pod screen if the existing terminal can be reused safely.
|
||||
- If reusing the same `Terminal`, ensure cursor/mouse/raw-mode cleanup remains correct on final exit and on errors.
|
||||
- Error handling should be explicit.
|
||||
- If opening the selected Pod fails before the single-Pod screen starts, show a multi-view notice and keep the dashboard active.
|
||||
- If the single-Pod session exits with a fatal error, return that error or show a clear diagnostic according to the existing TUI error behavior; do not silently swallow fatal failures.
|
||||
- Existing `tui --multi` direct send behavior, section layout, and separator fix must continue to work.
|
||||
|
||||
## Suggested implementation direction
|
||||
|
||||
- Split the current single-Pod attach runner into a reusable function that accepts an existing `Terminal` and returns when the attached screen exits.
|
||||
- Change `run_multi()` from one-shot `multi_pod::run(...).await -> Open -> run_pod_name(...)` into a controller loop:
|
||||
|
||||
```text
|
||||
loop:
|
||||
run multi dashboard with previous app state / selected Pod
|
||||
Quit => exit process
|
||||
Open(pod) => run single-Pod attach screen using the same terminal
|
||||
on normal exit, refresh dashboard and continue loop
|
||||
```
|
||||
|
||||
- Avoid adding a new protocol method. This is local TUI orchestration.
|
||||
- Avoid making `App` carry a generic `BackToMulti` mode unless it is strictly necessary; prefer caller-owned control flow.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- From `tui --multi`, pressing `o` opens the selected Pod's normal conversation screen.
|
||||
- Pressing `Ctrl+D` / `Ctrl+C` in that opened screen returns to the multi-Pod dashboard.
|
||||
- Starting a single-Pod TUI directly still exits on `Ctrl+D` / `Ctrl+C`.
|
||||
- Returning to multi view preserves multi composer contents and selected Pod name when possible.
|
||||
- Returning to multi view refreshes Pod status/list.
|
||||
- Opening failure from multi view leaves the user in multi view with a visible notice.
|
||||
- Existing multi-Pod tests still pass.
|
||||
- Focused tests cover the controller/runner behavior where possible, especially distinguishing direct single-Pod launch from multi-owned nested launch.
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||
- `cargo check -p tui -p client -p pod`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Dedicated back key.
|
||||
- Per-Pod detail panes inside the multi-Pod dashboard.
|
||||
- Protocol changes.
|
||||
- Changing direct-send semantics.
|
||||
- Changing Pod visibility/discovery rules.
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
id: 20260528-233524-multi-pod-open-return
|
||||
slug: multi-pod-open-return
|
||||
title: Return to multi-Pod view after opening a Pod
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-28T23:35:24Z
|
||||
updated_at: 2026-05-28T23:57:49Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`tui --multi` can open the selected Pod with `o`. The current implementation treats this as leaving the multi-Pod dashboard and tail-calling the normal single-Pod TUI. Once the single-Pod screen is detached with `Ctrl+D` / `Ctrl+C`, the process exits instead of returning to the multi-Pod view.
|
||||
|
||||
For now, no special "back mode" or dedicated back key is needed. The desired behavior is simpler: when the user opens a Pod from the multi-Pod dashboard, the normal single-Pod attach screen runs as a nested attach session. When that session exits normally by detach/quit (`Ctrl+D`, `Ctrl+C`, or equivalent normal exit), control returns to the multi-Pod dashboard.
|
||||
|
||||
This should be implemented by abstracting the TUI launch/control flow, not by adding protocol features or making the single-Pod App deeply aware of multi-Pod mode.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `tui --multi` remains the entrypoint for the multi-Pod dashboard.
|
||||
- In `tui --multi`, pressing `o` on a selected openable Pod opens the normal single-Pod conversation screen.
|
||||
- When that single-Pod screen exits normally, TUI returns to the multi-Pod dashboard instead of exiting the process.
|
||||
- `Ctrl+D` / `Ctrl+C` detach/quit from the opened single-Pod screen should return to multi view.
|
||||
- Normal single-Pod launches such as `tui <pod>` / `tui --pod <name>` should continue to exit the process on `Ctrl+D` / `Ctrl+C`.
|
||||
- Avoid introducing a dedicated back key or back mode for this ticket.
|
||||
- The caller loop determines whether a normal single-Pod exit returns to multi view or exits the process.
|
||||
- Preserve multi-Pod dashboard state where practical.
|
||||
- The selected Pod name should remain selected after returning, if still visible.
|
||||
- The multi-Pod composer contents should be preserved across open/return.
|
||||
- The Pod list should refresh after returning so status changes are visible.
|
||||
- Keep terminal handling clean.
|
||||
- Do not unnecessarily leave/re-enter alternate screen between multi view and the nested single-Pod screen if the existing terminal can be reused safely.
|
||||
- If reusing the same `Terminal`, ensure cursor/mouse/raw-mode cleanup remains correct on final exit and on errors.
|
||||
- Error handling should be explicit.
|
||||
- If opening the selected Pod fails before the single-Pod screen starts, show a multi-view notice and keep the dashboard active.
|
||||
- If the single-Pod session exits with a fatal error, return that error or show a clear diagnostic according to the existing TUI error behavior; do not silently swallow fatal failures.
|
||||
- Existing `tui --multi` direct send behavior, section layout, and separator fix must continue to work.
|
||||
|
||||
## Suggested implementation direction
|
||||
|
||||
- Split the current single-Pod attach runner into a reusable function that accepts an existing `Terminal` and returns when the attached screen exits.
|
||||
- Change `run_multi()` from one-shot `multi_pod::run(...).await -> Open -> run_pod_name(...)` into a controller loop:
|
||||
|
||||
```text
|
||||
loop:
|
||||
run multi dashboard with previous app state / selected Pod
|
||||
Quit => exit process
|
||||
Open(pod) => run single-Pod attach screen using the same terminal
|
||||
on normal exit, refresh dashboard and continue loop
|
||||
```
|
||||
|
||||
- Avoid adding a new protocol method. This is local TUI orchestration.
|
||||
- Avoid making `App` carry a generic `BackToMulti` mode unless it is strictly necessary; prefer caller-owned control flow.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- From `tui --multi`, pressing `o` opens the selected Pod's normal conversation screen.
|
||||
- Pressing `Ctrl+D` / `Ctrl+C` in that opened screen returns to the multi-Pod dashboard.
|
||||
- Starting a single-Pod TUI directly still exits on `Ctrl+D` / `Ctrl+C`.
|
||||
- Returning to multi view preserves multi composer contents and selected Pod name when possible.
|
||||
- Returning to multi view refreshes Pod status/list.
|
||||
- Opening failure from multi view leaves the user in multi view with a visible notice.
|
||||
- Existing multi-Pod tests still pass.
|
||||
- Focused tests cover the controller/runner behavior where possible, especially distinguishing direct single-Pod launch from multi-owned nested launch.
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||
- `cargo check -p tui -p client -p pod`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Dedicated back key.
|
||||
- Per-Pod detail panes inside the multi-Pod dashboard.
|
||||
- Protocol changes.
|
||||
- Changing direct-send semantics.
|
||||
- Changing Pod visibility/discovery rules.
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-28T23:35:24Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-28T23:57:49Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260528-233524-multi-pod-open-return
|
||||
slug: multi-pod-open-return
|
||||
title: Return to multi-Pod view after opening a Pod
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-28T23:35:24Z
|
||||
updated_at: 2026-05-28T23:57:49Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`tui --multi` can open the selected Pod with `o`. The current implementation treats this as leaving the multi-Pod dashboard and tail-calling the normal single-Pod TUI. Once the single-Pod screen is detached with `Ctrl+D` / `Ctrl+C`, the process exits instead of returning to the multi-Pod view.
|
||||
|
||||
For now, no special "back mode" or dedicated back key is needed. The desired behavior is simpler: when the user opens a Pod from the multi-Pod dashboard, the normal single-Pod attach screen runs as a nested attach session. When that session exits normally by detach/quit (`Ctrl+D`, `Ctrl+C`, or equivalent normal exit), control returns to the multi-Pod dashboard.
|
||||
|
||||
This should be implemented by abstracting the TUI launch/control flow, not by adding protocol features or making the single-Pod App deeply aware of multi-Pod mode.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `tui --multi` remains the entrypoint for the multi-Pod dashboard.
|
||||
- In `tui --multi`, pressing `o` on a selected openable Pod opens the normal single-Pod conversation screen.
|
||||
- When that single-Pod screen exits normally, TUI returns to the multi-Pod dashboard instead of exiting the process.
|
||||
- `Ctrl+D` / `Ctrl+C` detach/quit from the opened single-Pod screen should return to multi view.
|
||||
- Normal single-Pod launches such as `tui <pod>` / `tui --pod <name>` should continue to exit the process on `Ctrl+D` / `Ctrl+C`.
|
||||
- Avoid introducing a dedicated back key or back mode for this ticket.
|
||||
- The caller loop determines whether a normal single-Pod exit returns to multi view or exits the process.
|
||||
- Preserve multi-Pod dashboard state where practical.
|
||||
- The selected Pod name should remain selected after returning, if still visible.
|
||||
- The multi-Pod composer contents should be preserved across open/return.
|
||||
- The Pod list should refresh after returning so status changes are visible.
|
||||
- Keep terminal handling clean.
|
||||
- Do not unnecessarily leave/re-enter alternate screen between multi view and the nested single-Pod screen if the existing terminal can be reused safely.
|
||||
- If reusing the same `Terminal`, ensure cursor/mouse/raw-mode cleanup remains correct on final exit and on errors.
|
||||
- Error handling should be explicit.
|
||||
- If opening the selected Pod fails before the single-Pod screen starts, show a multi-view notice and keep the dashboard active.
|
||||
- If the single-Pod session exits with a fatal error, return that error or show a clear diagnostic according to the existing TUI error behavior; do not silently swallow fatal failures.
|
||||
- Existing `tui --multi` direct send behavior, section layout, and separator fix must continue to work.
|
||||
|
||||
## Suggested implementation direction
|
||||
|
||||
- Split the current single-Pod attach runner into a reusable function that accepts an existing `Terminal` and returns when the attached screen exits.
|
||||
- Change `run_multi()` from one-shot `multi_pod::run(...).await -> Open -> run_pod_name(...)` into a controller loop:
|
||||
|
||||
```text
|
||||
loop:
|
||||
run multi dashboard with previous app state / selected Pod
|
||||
Quit => exit process
|
||||
Open(pod) => run single-Pod attach screen using the same terminal
|
||||
on normal exit, refresh dashboard and continue loop
|
||||
```
|
||||
|
||||
- Avoid adding a new protocol method. This is local TUI orchestration.
|
||||
- Avoid making `App` carry a generic `BackToMulti` mode unless it is strictly necessary; prefer caller-owned control flow.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- From `tui --multi`, pressing `o` opens the selected Pod's normal conversation screen.
|
||||
- Pressing `Ctrl+D` / `Ctrl+C` in that opened screen returns to the multi-Pod dashboard.
|
||||
- Starting a single-Pod TUI directly still exits on `Ctrl+D` / `Ctrl+C`.
|
||||
- Returning to multi view preserves multi composer contents and selected Pod name when possible.
|
||||
- Returning to multi view refreshes Pod status/list.
|
||||
- Opening failure from multi view leaves the user in multi view with a visible notice.
|
||||
- Existing multi-Pod tests still pass.
|
||||
- Focused tests cover the controller/runner behavior where possible, especially distinguishing direct single-Pod launch from multi-owned nested launch.
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||
- `cargo check -p tui -p client -p pod`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Dedicated back key.
|
||||
- Per-Pod detail panes inside the multi-Pod dashboard.
|
||||
- Protocol changes.
|
||||
- Changing direct-send semantics.
|
||||
- Changing Pod visibility/discovery rules.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
id: 20260529-001326-rename-installed-binaries
|
||||
slug: rename-installed-binaries
|
||||
title: Rename installed binaries
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [cli, packaging, tui, pod]
|
||||
created_at: 2026-05-29T00:13:26Z
|
||||
updated_at: 2026-05-29T00:39:08Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The workspace crate names `tui` and `pod` are useful internally, but the installed command names are too generic for user environments. `tui` does not identify the application, and `pod` collides with common terminology and other tooling.
|
||||
|
||||
Use application-specific binary names for installed commands:
|
||||
|
||||
- `insomnia`: the main terminal UI / user entrypoint, currently built from the `tui` crate.
|
||||
- `insomnia-pod`: the Pod CLI/runtime command, currently built from the `pod` crate.
|
||||
|
||||
This is a command name change, not a crate rename. Keep the Rust crate/package names `tui` and `pod` unless there is a separate design decision to rename crates.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rename Cargo binary outputs:
|
||||
- `crates/tui` binary name becomes `insomnia`.
|
||||
- `crates/pod` binary name becomes `insomnia-pod`.
|
||||
- Do not add legacy `tui` / `pod` installed aliases unless a concrete internal dependency requires it and is documented.
|
||||
- Update Nix packaging to install and check the new binary names.
|
||||
- `$out/bin/insomnia`
|
||||
- `$out/bin/insomnia-pod`
|
||||
- Update flake apps to use the new command names.
|
||||
- default app should run `insomnia`.
|
||||
- expose app entries for `insomnia` and `insomnia-pod`.
|
||||
- Update docs that instruct users to run `tui` / `pod` as installed commands.
|
||||
- Keep references to crate/package names where they are explicitly Cargo package names, e.g. `cargo check -p tui`.
|
||||
- Prefer `cargo run -p tui -- ...` in development docs if referring to crate-based development invocation, but installed usage should use `insomnia`.
|
||||
- Audit code/tests/scripts for assumptions that installed binary names are `tui` or `pod`.
|
||||
- Internal runtime process spawning must still work.
|
||||
- If code intentionally uses Cargo package names, leave them unchanged.
|
||||
- Keep CLI semantics unchanged except for command names.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `cargo build -p tui -p pod` produces runnable binaries named `insomnia` and `insomnia-pod`.
|
||||
- `cargo run -p tui -- --help` and `cargo run -p pod -- --help` still work as development package invocations.
|
||||
- Installed/Nix package smoke checks look for `insomnia` and `insomnia-pod`, not `tui` and `pod`.
|
||||
- `flake.nix` app outputs use the new binary names.
|
||||
- User-facing docs no longer tell users to run installed commands as `tui` / `pod`.
|
||||
- No legacy aliases are installed unless explicitly justified.
|
||||
- `cargo fmt --check`
|
||||
- Focused cargo checks/tests for affected crates, at least `cargo check -p tui -p pod`.
|
||||
- Nix validation that does not require network where possible, e.g. `nix flake check --no-build`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Renaming crates/packages from `tui` / `pod`.
|
||||
- Changing CLI argument semantics.
|
||||
- Changing Pod protocol or socket behavior.
|
||||
- Publishing or Home Manager module changes.
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
id: 20260529-001326-rename-installed-binaries
|
||||
slug: rename-installed-binaries
|
||||
title: Rename installed binaries
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [cli, packaging, tui, pod]
|
||||
created_at: 2026-05-29T00:13:26Z
|
||||
updated_at: 2026-05-29T00:39:08Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The workspace crate names `tui` and `pod` are useful internally, but the installed command names are too generic for user environments. `tui` does not identify the application, and `pod` collides with common terminology and other tooling.
|
||||
|
||||
Use application-specific binary names for installed commands:
|
||||
|
||||
- `insomnia`: the main terminal UI / user entrypoint, currently built from the `tui` crate.
|
||||
- `insomnia-pod`: the Pod CLI/runtime command, currently built from the `pod` crate.
|
||||
|
||||
This is a command name change, not a crate rename. Keep the Rust crate/package names `tui` and `pod` unless there is a separate design decision to rename crates.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rename Cargo binary outputs:
|
||||
- `crates/tui` binary name becomes `insomnia`.
|
||||
- `crates/pod` binary name becomes `insomnia-pod`.
|
||||
- Do not add legacy `tui` / `pod` installed aliases unless a concrete internal dependency requires it and is documented.
|
||||
- Update Nix packaging to install and check the new binary names.
|
||||
- `$out/bin/insomnia`
|
||||
- `$out/bin/insomnia-pod`
|
||||
- Update flake apps to use the new command names.
|
||||
- default app should run `insomnia`.
|
||||
- expose app entries for `insomnia` and `insomnia-pod`.
|
||||
- Update docs that instruct users to run `tui` / `pod` as installed commands.
|
||||
- Keep references to crate/package names where they are explicitly Cargo package names, e.g. `cargo check -p tui`.
|
||||
- Prefer `cargo run -p tui -- ...` in development docs if referring to crate-based development invocation, but installed usage should use `insomnia`.
|
||||
- Audit code/tests/scripts for assumptions that installed binary names are `tui` or `pod`.
|
||||
- Internal runtime process spawning must still work.
|
||||
- If code intentionally uses Cargo package names, leave them unchanged.
|
||||
- Keep CLI semantics unchanged except for command names.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `cargo build -p tui -p pod` produces runnable binaries named `insomnia` and `insomnia-pod`.
|
||||
- `cargo run -p tui -- --help` and `cargo run -p pod -- --help` still work as development package invocations.
|
||||
- Installed/Nix package smoke checks look for `insomnia` and `insomnia-pod`, not `tui` and `pod`.
|
||||
- `flake.nix` app outputs use the new binary names.
|
||||
- User-facing docs no longer tell users to run installed commands as `tui` / `pod`.
|
||||
- No legacy aliases are installed unless explicitly justified.
|
||||
- `cargo fmt --check`
|
||||
- Focused cargo checks/tests for affected crates, at least `cargo check -p tui -p pod`.
|
||||
- Nix validation that does not require network where possible, e.g. `nix flake check --no-build`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Renaming crates/packages from `tui` / `pod`.
|
||||
- Changing CLI argument semantics.
|
||||
- Changing Pod protocol or socket behavior.
|
||||
- Publishing or Home Manager module changes.
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T00:13:26Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T00:39:08Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260529-001326-rename-installed-binaries
|
||||
slug: rename-installed-binaries
|
||||
title: Rename installed binaries
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [cli, packaging, tui, pod]
|
||||
created_at: 2026-05-29T00:13:26Z
|
||||
updated_at: 2026-05-29T00:39:08Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The workspace crate names `tui` and `pod` are useful internally, but the installed command names are too generic for user environments. `tui` does not identify the application, and `pod` collides with common terminology and other tooling.
|
||||
|
||||
Use application-specific binary names for installed commands:
|
||||
|
||||
- `insomnia`: the main terminal UI / user entrypoint, currently built from the `tui` crate.
|
||||
- `insomnia-pod`: the Pod CLI/runtime command, currently built from the `pod` crate.
|
||||
|
||||
This is a command name change, not a crate rename. Keep the Rust crate/package names `tui` and `pod` unless there is a separate design decision to rename crates.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rename Cargo binary outputs:
|
||||
- `crates/tui` binary name becomes `insomnia`.
|
||||
- `crates/pod` binary name becomes `insomnia-pod`.
|
||||
- Do not add legacy `tui` / `pod` installed aliases unless a concrete internal dependency requires it and is documented.
|
||||
- Update Nix packaging to install and check the new binary names.
|
||||
- `$out/bin/insomnia`
|
||||
- `$out/bin/insomnia-pod`
|
||||
- Update flake apps to use the new command names.
|
||||
- default app should run `insomnia`.
|
||||
- expose app entries for `insomnia` and `insomnia-pod`.
|
||||
- Update docs that instruct users to run `tui` / `pod` as installed commands.
|
||||
- Keep references to crate/package names where they are explicitly Cargo package names, e.g. `cargo check -p tui`.
|
||||
- Prefer `cargo run -p tui -- ...` in development docs if referring to crate-based development invocation, but installed usage should use `insomnia`.
|
||||
- Audit code/tests/scripts for assumptions that installed binary names are `tui` or `pod`.
|
||||
- Internal runtime process spawning must still work.
|
||||
- If code intentionally uses Cargo package names, leave them unchanged.
|
||||
- Keep CLI semantics unchanged except for command names.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `cargo build -p tui -p pod` produces runnable binaries named `insomnia` and `insomnia-pod`.
|
||||
- `cargo run -p tui -- --help` and `cargo run -p pod -- --help` still work as development package invocations.
|
||||
- Installed/Nix package smoke checks look for `insomnia` and `insomnia-pod`, not `tui` and `pod`.
|
||||
- `flake.nix` app outputs use the new binary names.
|
||||
- User-facing docs no longer tell users to run installed commands as `tui` / `pod`.
|
||||
- No legacy aliases are installed unless explicitly justified.
|
||||
- `cargo fmt --check`
|
||||
- Focused cargo checks/tests for affected crates, at least `cargo check -p tui -p pod`.
|
||||
- Nix validation that does not require network where possible, e.g. `nix flake check --no-build`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Renaming crates/packages from `tui` / `pod`.
|
||||
- Changing CLI argument semantics.
|
||||
- Changing Pod protocol or socket behavior.
|
||||
- Publishing or Home Manager module changes.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
id: 20260529-010200-tui-command-completion-apply
|
||||
slug: tui-command-completion-apply
|
||||
title: Apply command completions from keyboard
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, commands, ux]
|
||||
created_at: 2026-05-29T01:02:00Z
|
||||
updated_at: 2026-05-29T02:08:56Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The TUI command mode (`:`) can show completion candidates, but the candidates cannot currently be applied with keyboard completion keys such as Tab. Also, when there is an unambiguous or selected completion candidate, pressing Enter should be able to complete the command and execute it in one action.
|
||||
|
||||
This should make command mode behave like a small command palette rather than a read-only suggestion list.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add keyboard application for command completions in command mode.
|
||||
- Tab should apply the currently selected completion candidate when a candidate exists.
|
||||
- If there is no explicit selection but exactly one candidate exists, Tab should apply that candidate.
|
||||
- Applying a command completion should replace the command name prefix with the canonical command name and preserve/position trailing argument editing sensibly.
|
||||
- Enter behavior should use completion when appropriate.
|
||||
- If the command input has completion candidates and the current command name is incomplete, Enter should apply the selected/unambiguous candidate and execute the completed command in one action when doing so yields a complete executable command.
|
||||
- If applying a completion only fills the command name and arguments are still required, Enter should complete the command name and keep command mode active with a helpful state/notice rather than executing an invalid command.
|
||||
- If no candidate applies, existing command execution/error behavior should remain.
|
||||
- Completion selection/navigation should be keyboard-accessible.
|
||||
- Existing up/down behavior should not regress.
|
||||
- If Tab cycles candidates today for another completion surface, command mode should still have a clear apply path.
|
||||
- Keep normal composer completion behavior unchanged.
|
||||
- This ticket is for `:` command mode completion, not file-ref/chip completion in normal input.
|
||||
- Keep command execution local.
|
||||
- Commands must not be submitted as user messages.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- In command mode, typing a command prefix and pressing Tab fills the selected/unambiguous command completion.
|
||||
- In command mode, typing a command prefix with a selected/unambiguous executable completion and pressing Enter completes and executes it in one action.
|
||||
- Ambiguous completions do not execute the wrong command silently; they require selection or further typing.
|
||||
- Commands requiring arguments are not executed with missing arguments just because Enter applied the command name.
|
||||
- Existing command execution behavior for fully typed commands is unchanged.
|
||||
- Normal composer/file-ref completion behavior is unchanged.
|
||||
- Focused tests cover Tab apply, Enter complete-and-execute, ambiguous candidate handling, and argument-required behavior.
|
||||
- `cargo fmt --check`
|
||||
- Relevant TUI command tests, e.g. `cargo test -p tui command --no-default-features` or equivalent.
|
||||
- `cargo check -p tui`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New commands.
|
||||
- Fuzzy matching beyond current prefix/alias suggestions.
|
||||
- Mouse selection in the completion popup.
|
||||
- Normal input/file reference completion changes.
|
||||
- Changing command registry semantics outside completion application.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
id: 20260529-010200-tui-command-completion-apply
|
||||
slug: tui-command-completion-apply
|
||||
title: Apply command completions from keyboard
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, commands, ux]
|
||||
created_at: 2026-05-29T01:02:00Z
|
||||
updated_at: 2026-05-29T02:08:56Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The TUI command mode (`:`) can show completion candidates, but the candidates cannot currently be applied with keyboard completion keys such as Tab. Also, when there is an unambiguous or selected completion candidate, pressing Enter should be able to complete the command and execute it in one action.
|
||||
|
||||
This should make command mode behave like a small command palette rather than a read-only suggestion list.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add keyboard application for command completions in command mode.
|
||||
- Tab should apply the currently selected completion candidate when a candidate exists.
|
||||
- If there is no explicit selection but exactly one candidate exists, Tab should apply that candidate.
|
||||
- Applying a command completion should replace the command name prefix with the canonical command name and preserve/position trailing argument editing sensibly.
|
||||
- Enter behavior should use completion when appropriate.
|
||||
- If the command input has completion candidates and the current command name is incomplete, Enter should apply the selected/unambiguous candidate and execute the completed command in one action when doing so yields a complete executable command.
|
||||
- If applying a completion only fills the command name and arguments are still required, Enter should complete the command name and keep command mode active with a helpful state/notice rather than executing an invalid command.
|
||||
- If no candidate applies, existing command execution/error behavior should remain.
|
||||
- Completion selection/navigation should be keyboard-accessible.
|
||||
- Existing up/down behavior should not regress.
|
||||
- If Tab cycles candidates today for another completion surface, command mode should still have a clear apply path.
|
||||
- Keep normal composer completion behavior unchanged.
|
||||
- This ticket is for `:` command mode completion, not file-ref/chip completion in normal input.
|
||||
- Keep command execution local.
|
||||
- Commands must not be submitted as user messages.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- In command mode, typing a command prefix and pressing Tab fills the selected/unambiguous command completion.
|
||||
- In command mode, typing a command prefix with a selected/unambiguous executable completion and pressing Enter completes and executes it in one action.
|
||||
- Ambiguous completions do not execute the wrong command silently; they require selection or further typing.
|
||||
- Commands requiring arguments are not executed with missing arguments just because Enter applied the command name.
|
||||
- Existing command execution behavior for fully typed commands is unchanged.
|
||||
- Normal composer/file-ref completion behavior is unchanged.
|
||||
- Focused tests cover Tab apply, Enter complete-and-execute, ambiguous candidate handling, and argument-required behavior.
|
||||
- `cargo fmt --check`
|
||||
- Relevant TUI command tests, e.g. `cargo test -p tui command --no-default-features` or equivalent.
|
||||
- `cargo check -p tui`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New commands.
|
||||
- Fuzzy matching beyond current prefix/alias suggestions.
|
||||
- Mouse selection in the completion popup.
|
||||
- Normal input/file reference completion changes.
|
||||
- Changing command registry semantics outside completion application.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T01:02:00Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T02:08:56Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260529-010200-tui-command-completion-apply
|
||||
slug: tui-command-completion-apply
|
||||
title: Apply command completions from keyboard
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, commands, ux]
|
||||
created_at: 2026-05-29T01:02:00Z
|
||||
updated_at: 2026-05-29T02:08:56Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The TUI command mode (`:`) can show completion candidates, but the candidates cannot currently be applied with keyboard completion keys such as Tab. Also, when there is an unambiguous or selected completion candidate, pressing Enter should be able to complete the command and execute it in one action.
|
||||
|
||||
This should make command mode behave like a small command palette rather than a read-only suggestion list.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add keyboard application for command completions in command mode.
|
||||
- Tab should apply the currently selected completion candidate when a candidate exists.
|
||||
- If there is no explicit selection but exactly one candidate exists, Tab should apply that candidate.
|
||||
- Applying a command completion should replace the command name prefix with the canonical command name and preserve/position trailing argument editing sensibly.
|
||||
- Enter behavior should use completion when appropriate.
|
||||
- If the command input has completion candidates and the current command name is incomplete, Enter should apply the selected/unambiguous candidate and execute the completed command in one action when doing so yields a complete executable command.
|
||||
- If applying a completion only fills the command name and arguments are still required, Enter should complete the command name and keep command mode active with a helpful state/notice rather than executing an invalid command.
|
||||
- If no candidate applies, existing command execution/error behavior should remain.
|
||||
- Completion selection/navigation should be keyboard-accessible.
|
||||
- Existing up/down behavior should not regress.
|
||||
- If Tab cycles candidates today for another completion surface, command mode should still have a clear apply path.
|
||||
- Keep normal composer completion behavior unchanged.
|
||||
- This ticket is for `:` command mode completion, not file-ref/chip completion in normal input.
|
||||
- Keep command execution local.
|
||||
- Commands must not be submitted as user messages.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- In command mode, typing a command prefix and pressing Tab fills the selected/unambiguous command completion.
|
||||
- In command mode, typing a command prefix with a selected/unambiguous executable completion and pressing Enter completes and executes it in one action.
|
||||
- Ambiguous completions do not execute the wrong command silently; they require selection or further typing.
|
||||
- Commands requiring arguments are not executed with missing arguments just because Enter applied the command name.
|
||||
- Existing command execution behavior for fully typed commands is unchanged.
|
||||
- Normal composer/file-ref completion behavior is unchanged.
|
||||
- Focused tests cover Tab apply, Enter complete-and-execute, ambiguous candidate handling, and argument-required behavior.
|
||||
- `cargo fmt --check`
|
||||
- Relevant TUI command tests, e.g. `cargo test -p tui command --no-default-features` or equivalent.
|
||||
- `cargo check -p tui`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New commands.
|
||||
- Fuzzy matching beyond current prefix/alias suggestions.
|
||||
- Mouse selection in the completion popup.
|
||||
- Normal input/file reference completion changes.
|
||||
- Changing command registry semantics outside completion application.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
id: 20260529-010200-tui-composer-cursor-scroll
|
||||
slug: tui-composer-cursor-scroll
|
||||
title: Scroll TUI composer around cursor
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, input, ux]
|
||||
created_at: 2026-05-29T01:02:00Z
|
||||
updated_at: 2026-05-29T02:08:04Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The TUI composer/input area has a fixed visible height. When the input buffer grows beyond the visible area (for example 10+ lines), the rendered text is clipped instead of scrolling to keep the cursor visible.
|
||||
|
||||
This makes editing long messages unreliable: the user can continue typing or moving the cursor, but the relevant lines may be outside the visible area.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Implement cursor-based vertical scrolling for the normal composer input area.
|
||||
- The visible viewport should follow the cursor line when the input has more lines than the allocated input height.
|
||||
- Moving the cursor above the viewport scrolls up.
|
||||
- Moving the cursor below the viewport scrolls down.
|
||||
- Typing new lines at the bottom keeps the cursor visible.
|
||||
- Deleting lines clamps the scroll offset to valid bounds.
|
||||
- Preserve existing input behavior:
|
||||
- editing operations.
|
||||
- cursor movement.
|
||||
- selection/completion behavior for file refs if applicable.
|
||||
- queued input behavior.
|
||||
- command mode behavior unless command input shares the same rendering path and needs the same fix.
|
||||
- The cursor's terminal position should correspond to the visible cursor location after scrolling.
|
||||
- The implementation should not simply increase composer height or hide conversation content indefinitely.
|
||||
- Keep visual separators/borders consistent with the existing TUI layout.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A composer buffer longer than the visible input area renders a window around the cursor instead of clipping from a fixed origin.
|
||||
- Cursor up/down/page movement updates the composer viewport correctly.
|
||||
- Inserting/deleting lines keeps viewport bounds valid.
|
||||
- Existing short single-line and small multi-line input rendering remains unchanged.
|
||||
- Focused tests cover viewport calculation around cursor position and clamping.
|
||||
- `cargo fmt --check`
|
||||
- Relevant TUI focused tests, e.g. `cargo test -p tui input --no-default-features` or equivalent.
|
||||
- `cargo check -p tui`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Resizable composer UX redesign.
|
||||
- Mouse scrolling inside composer.
|
||||
- Horizontal scrolling/wrapping redesign beyond what is needed to keep current behavior correct.
|
||||
- Changing command completion behavior; see `20260529-010200-tui-command-completion-apply`.
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
id: 20260529-010200-tui-composer-cursor-scroll
|
||||
slug: tui-composer-cursor-scroll
|
||||
title: Scroll TUI composer around cursor
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, input, ux]
|
||||
created_at: 2026-05-29T01:02:00Z
|
||||
updated_at: 2026-05-29T02:08:03Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The TUI composer/input area has a fixed visible height. When the input buffer grows beyond the visible area (for example 10+ lines), the rendered text is clipped instead of scrolling to keep the cursor visible.
|
||||
|
||||
This makes editing long messages unreliable: the user can continue typing or moving the cursor, but the relevant lines may be outside the visible area.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Implement cursor-based vertical scrolling for the normal composer input area.
|
||||
- The visible viewport should follow the cursor line when the input has more lines than the allocated input height.
|
||||
- Moving the cursor above the viewport scrolls up.
|
||||
- Moving the cursor below the viewport scrolls down.
|
||||
- Typing new lines at the bottom keeps the cursor visible.
|
||||
- Deleting lines clamps the scroll offset to valid bounds.
|
||||
- Preserve existing input behavior:
|
||||
- editing operations.
|
||||
- cursor movement.
|
||||
- selection/completion behavior for file refs if applicable.
|
||||
- queued input behavior.
|
||||
- command mode behavior unless command input shares the same rendering path and needs the same fix.
|
||||
- The cursor's terminal position should correspond to the visible cursor location after scrolling.
|
||||
- The implementation should not simply increase composer height or hide conversation content indefinitely.
|
||||
- Keep visual separators/borders consistent with the existing TUI layout.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A composer buffer longer than the visible input area renders a window around the cursor instead of clipping from a fixed origin.
|
||||
- Cursor up/down/page movement updates the composer viewport correctly.
|
||||
- Inserting/deleting lines keeps viewport bounds valid.
|
||||
- Existing short single-line and small multi-line input rendering remains unchanged.
|
||||
- Focused tests cover viewport calculation around cursor position and clamping.
|
||||
- `cargo fmt --check`
|
||||
- Relevant TUI focused tests, e.g. `cargo test -p tui input --no-default-features` or equivalent.
|
||||
- `cargo check -p tui`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Resizable composer UX redesign.
|
||||
- Mouse scrolling inside composer.
|
||||
- Horizontal scrolling/wrapping redesign beyond what is needed to keep current behavior correct.
|
||||
- Changing command completion behavior; see `20260529-010200-tui-command-completion-apply`.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T01:02:00Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T02:08:04Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260529-010200-tui-composer-cursor-scroll
|
||||
slug: tui-composer-cursor-scroll
|
||||
title: Scroll TUI composer around cursor
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, input, ux]
|
||||
created_at: 2026-05-29T01:02:00Z
|
||||
updated_at: 2026-05-29T02:08:03Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The TUI composer/input area has a fixed visible height. When the input buffer grows beyond the visible area (for example 10+ lines), the rendered text is clipped instead of scrolling to keep the cursor visible.
|
||||
|
||||
This makes editing long messages unreliable: the user can continue typing or moving the cursor, but the relevant lines may be outside the visible area.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Implement cursor-based vertical scrolling for the normal composer input area.
|
||||
- The visible viewport should follow the cursor line when the input has more lines than the allocated input height.
|
||||
- Moving the cursor above the viewport scrolls up.
|
||||
- Moving the cursor below the viewport scrolls down.
|
||||
- Typing new lines at the bottom keeps the cursor visible.
|
||||
- Deleting lines clamps the scroll offset to valid bounds.
|
||||
- Preserve existing input behavior:
|
||||
- editing operations.
|
||||
- cursor movement.
|
||||
- selection/completion behavior for file refs if applicable.
|
||||
- queued input behavior.
|
||||
- command mode behavior unless command input shares the same rendering path and needs the same fix.
|
||||
- The cursor's terminal position should correspond to the visible cursor location after scrolling.
|
||||
- The implementation should not simply increase composer height or hide conversation content indefinitely.
|
||||
- Keep visual separators/borders consistent with the existing TUI layout.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A composer buffer longer than the visible input area renders a window around the cursor instead of clipping from a fixed origin.
|
||||
- Cursor up/down/page movement updates the composer viewport correctly.
|
||||
- Inserting/deleting lines keeps viewport bounds valid.
|
||||
- Existing short single-line and small multi-line input rendering remains unchanged.
|
||||
- Focused tests cover viewport calculation around cursor position and clamping.
|
||||
- `cargo fmt --check`
|
||||
- Relevant TUI focused tests, e.g. `cargo test -p tui input --no-default-features` or equivalent.
|
||||
- `cargo check -p tui`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Resizable composer UX redesign.
|
||||
- Mouse scrolling inside composer.
|
||||
- Horizontal scrolling/wrapping redesign beyond what is needed to keep current behavior correct.
|
||||
- Changing command completion behavior; see `20260529-010200-tui-command-completion-apply`.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
id: 20260529-031832-multi-pod-empty-enter-open
|
||||
slug: multi-pod-empty-enter-open
|
||||
title: Open selected multi-Pod entry on empty Enter
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-29T03:18:32Z
|
||||
updated_at: 2026-05-29T03:27:13Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`tui --multi` currently uses `o` to open/attach the selected Pod entry. Enter is used to send the composer contents to the selected idle live Pod.
|
||||
|
||||
When the composer is empty, pressing Enter has no message to send. Treat that input as the same action as `o`: open the selected Pod entry in the single-Pod conversation screen.
|
||||
|
||||
This should make the multi-Pod dashboard feel more picker-like while preserving direct-send behavior when text is present.
|
||||
|
||||
## Requirements
|
||||
|
||||
- In `tui --multi`, pressing Enter with an empty composer opens the selected Pod entry, equivalent to pressing `o`.
|
||||
- Pressing Enter with non-empty composer keeps the current behavior: send the composer contents to the selected eligible idle live Pod.
|
||||
- Whitespace-only composer should be treated consistently with existing send behavior.
|
||||
- If current send trims/rejects whitespace-only input as empty, Enter should open.
|
||||
- If current send treats whitespace as input, preserve that existing behavior.
|
||||
- Opening must use the existing open path.
|
||||
- Do not duplicate attach/open logic.
|
||||
- Existing return-to-multi behavior after detaching from the opened Pod must continue to work.
|
||||
- Non-openable selected rows should behave like `o` currently behaves.
|
||||
- Show the same diagnostic/notice and remain in multi view.
|
||||
- Do not change `o` key behavior.
|
||||
- Do not change direct-send delivery semantics.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `tui --multi`: empty composer + Enter returns the same outcome/action as `o` for an openable selected Pod.
|
||||
- `tui --multi`: non-empty composer + Enter still direct-sends to the selected eligible idle live Pod.
|
||||
- Empty Enter on a non-openable row shows the same diagnostic as `o`.
|
||||
- Existing `o` behavior and return-to-multi behavior remain unchanged.
|
||||
- Focused tests cover empty Enter open, non-empty Enter send, and non-openable empty Enter diagnostic.
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||
- `cargo check -p tui`
|
||||
- `git diff --check`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing direct-send eligibility.
|
||||
- Adding a new keybinding.
|
||||
- Changing single-Pod attach behavior.
|
||||
- Changing multi-Pod row layout.
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
id: 20260529-031832-multi-pod-empty-enter-open
|
||||
slug: multi-pod-empty-enter-open
|
||||
title: Open selected multi-Pod entry on empty Enter
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-29T03:18:32Z
|
||||
updated_at: 2026-05-29T03:27:13Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`tui --multi` currently uses `o` to open/attach the selected Pod entry. Enter is used to send the composer contents to the selected idle live Pod.
|
||||
|
||||
When the composer is empty, pressing Enter has no message to send. Treat that input as the same action as `o`: open the selected Pod entry in the single-Pod conversation screen.
|
||||
|
||||
This should make the multi-Pod dashboard feel more picker-like while preserving direct-send behavior when text is present.
|
||||
|
||||
## Requirements
|
||||
|
||||
- In `tui --multi`, pressing Enter with an empty composer opens the selected Pod entry, equivalent to pressing `o`.
|
||||
- Pressing Enter with non-empty composer keeps the current behavior: send the composer contents to the selected eligible idle live Pod.
|
||||
- Whitespace-only composer should be treated consistently with existing send behavior.
|
||||
- If current send trims/rejects whitespace-only input as empty, Enter should open.
|
||||
- If current send treats whitespace as input, preserve that existing behavior.
|
||||
- Opening must use the existing open path.
|
||||
- Do not duplicate attach/open logic.
|
||||
- Existing return-to-multi behavior after detaching from the opened Pod must continue to work.
|
||||
- Non-openable selected rows should behave like `o` currently behaves.
|
||||
- Show the same diagnostic/notice and remain in multi view.
|
||||
- Do not change `o` key behavior.
|
||||
- Do not change direct-send delivery semantics.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `tui --multi`: empty composer + Enter returns the same outcome/action as `o` for an openable selected Pod.
|
||||
- `tui --multi`: non-empty composer + Enter still direct-sends to the selected eligible idle live Pod.
|
||||
- Empty Enter on a non-openable row shows the same diagnostic as `o`.
|
||||
- Existing `o` behavior and return-to-multi behavior remain unchanged.
|
||||
- Focused tests cover empty Enter open, non-empty Enter send, and non-openable empty Enter diagnostic.
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||
- `cargo check -p tui`
|
||||
- `git diff --check`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing direct-send eligibility.
|
||||
- Adding a new keybinding.
|
||||
- Changing single-Pod attach behavior.
|
||||
- Changing multi-Pod row layout.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<!-- event: create author: tickets.sh at: 2026-05-29T03:18:32Z -->
|
||||
|
||||
## Created
|
||||
|
||||
Created by tickets.sh create.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: close author: hare at: 2026-05-29T03:27:13Z status: closed -->
|
||||
|
||||
## Closed
|
||||
|
||||
---
|
||||
id: 20260529-031832-multi-pod-empty-enter-open
|
||||
slug: multi-pod-empty-enter-open
|
||||
title: Open selected multi-Pod entry on empty Enter
|
||||
status: closed
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [tui, pod, ux]
|
||||
created_at: 2026-05-29T03:18:32Z
|
||||
updated_at: 2026-05-29T03:27:13Z
|
||||
assignee: null
|
||||
legacy_ticket: null
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`tui --multi` currently uses `o` to open/attach the selected Pod entry. Enter is used to send the composer contents to the selected idle live Pod.
|
||||
|
||||
When the composer is empty, pressing Enter has no message to send. Treat that input as the same action as `o`: open the selected Pod entry in the single-Pod conversation screen.
|
||||
|
||||
This should make the multi-Pod dashboard feel more picker-like while preserving direct-send behavior when text is present.
|
||||
|
||||
## Requirements
|
||||
|
||||
- In `tui --multi`, pressing Enter with an empty composer opens the selected Pod entry, equivalent to pressing `o`.
|
||||
- Pressing Enter with non-empty composer keeps the current behavior: send the composer contents to the selected eligible idle live Pod.
|
||||
- Whitespace-only composer should be treated consistently with existing send behavior.
|
||||
- If current send trims/rejects whitespace-only input as empty, Enter should open.
|
||||
- If current send treats whitespace as input, preserve that existing behavior.
|
||||
- Opening must use the existing open path.
|
||||
- Do not duplicate attach/open logic.
|
||||
- Existing return-to-multi behavior after detaching from the opened Pod must continue to work.
|
||||
- Non-openable selected rows should behave like `o` currently behaves.
|
||||
- Show the same diagnostic/notice and remain in multi view.
|
||||
- Do not change `o` key behavior.
|
||||
- Do not change direct-send delivery semantics.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `tui --multi`: empty composer + Enter returns the same outcome/action as `o` for an openable selected Pod.
|
||||
- `tui --multi`: non-empty composer + Enter still direct-sends to the selected eligible idle live Pod.
|
||||
- Empty Enter on a non-openable row shows the same diagnostic as `o`.
|
||||
- Existing `o` behavior and return-to-multi behavior remain unchanged.
|
||||
- Focused tests cover empty Enter open, non-empty Enter send, and non-openable empty Enter diagnostic.
|
||||
- `cargo fmt --check`
|
||||
- `cargo test -p tui multi --no-default-features` or equivalent focused tests.
|
||||
- `cargo check -p tui`
|
||||
- `git diff --check`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing direct-send eligibility.
|
||||
- Adding a new keybinding.
|
||||
- Changing single-Pod attach behavior.
|
||||
- Changing multi-Pod row layout.
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
id: 20260527-000004-manual-turn-rollback
|
||||
slug: manual-turn-rollback
|
||||
title: Pod/TUI: 手動 rollback 導線
|
||||
status: open
|
||||
kind: task
|
||||
priority: P2
|
||||
labels: [migrated]
|
||||
created_at: 2026-05-27T00:00:04Z
|
||||
updated_at: 2026-05-27T00:00:04Z
|
||||
assignee: null
|
||||
legacy_ticket: tickets/manual-turn-rollback.md
|
||||
---
|
||||
|
||||
## Migration reference
|
||||
|
||||
- legacy_ticket: tickets/manual-turn-rollback.md
|
||||
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||
|
||||
# Pod/TUI: 手動 rollback 導線
|
||||
|
||||
## 背景
|
||||
|
||||
`pod-empty-turn-rollback` / `tui-empty-turn-restore` により、AI 側出力が 0 の interrupted turn については Pod 側で自動 rollback し、TUI 側で入力を復元できるようになった。
|
||||
|
||||
一方で、rollback substrate は直前 Run の状態復元に使える形で入り始めているが、ユーザーが明示的に rollback を要求する導線はまだない。誤送信、モデル選択ミス、途中で方針を変えた場合などに、ユーザーが手動で直前状態へ戻す手段が必要になる可能性がある。
|
||||
|
||||
詳細な UX / rollback 対象範囲 / safety policy は未決定のため、本チケットでは要求を保持し、実装方針は着手時に確定する。
|
||||
|
||||
## 要件メモ
|
||||
|
||||
- ユーザーが明示的に rollback を要求できる導線を用意する。
|
||||
- TUI system command / keybinding / tool / protocol Method のどこに置くかは未決定。
|
||||
- 最初は TUI から直前 turn を rollback する導線が候補。
|
||||
- rollback 対象範囲を決める。
|
||||
- 直前 submit のみか。
|
||||
- assistant output がある turn を許可するか。
|
||||
- tool call / tool result が含まれる turn を許可するか。
|
||||
- 複数 turn rollback は `pod-session-fork` との関係を確認する。
|
||||
- safety policy を決める。
|
||||
- user-visible assistant output を消す場合は確認を要求するか。
|
||||
- tool side effect が既に発生した turn を rollback できるのか、履歴から消すのではなく fork に誘導するのか。
|
||||
- rollback が history/context 永続化原則を壊さないようにする。
|
||||
- TUI 側の表示を決める。
|
||||
- rollback 成功 / 失敗の通知。
|
||||
- 消された blocks の扱い。
|
||||
- rollback された input を composer に戻すか、history/backup に置くか。
|
||||
- protocol signal を整理する。
|
||||
- 既存 `RunResult::RolledBack` を再利用できるか。
|
||||
- 手動 rollback は RunEnd ではなく専用 Event / Method が必要か。
|
||||
|
||||
## 完了条件(詳細未確定)
|
||||
|
||||
- 手動操作で rollback を要求できる。
|
||||
- rollback 成功時、Pod の session log / SegmentLogSink mirror / TUI 表示が整合する。
|
||||
- rollback 失敗時、理由がユーザーに見える。
|
||||
- tool side effect や assistant output を含む turn の扱いが仕様として明示されている。
|
||||
- tests がある。
|
||||
- `cargo fmt --check`
|
||||
- `cargo check --workspace`
|
||||
- 関連 crate の tests。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- 複数ターン rollback / 過去地点からの本格的なやり直し(`pod-session-fork` と調整)
|
||||
- rollback 履歴スタック
|
||||
- tool side effect の undo
|
||||
- fork tree 可視化
|
||||
|
||||
## 関連
|
||||
|
||||
- `tickets/pod-session-fork.md`
|
||||
- 完了済み: `pod-empty-turn-rollback`
|
||||
- 完了済み: `tui-empty-turn-restore`
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:04Z -->
|
||||
|
||||
## Migrated
|
||||
|
||||
Migrated from tickets/manual-turn-rollback.md. No legacy review file was present at migration time.
|
||||
|
||||
---
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<!-- event: migration author: tickets.sh-migration at: 2026-05-27T00:00:05Z -->
|
||||
|
||||
## Migrated
|
||||
|
||||
Migrated from tickets/memory-tool-guidance-prompt.md. No legacy review file was present at migration time.
|
||||
|
||||
---
|
||||
Loading…
Reference in New Issue
Block a user