Compare commits
513 Commits
b0c3b79e11
...
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 |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -801,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())
|
||||
|
|
@ -880,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.
|
||||
|
|
@ -992,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();
|
||||
|
|
@ -1014,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 => {
|
||||
|
|
@ -1029,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
|
||||
|
|
@ -1068,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() {
|
||||
|
|
@ -1531,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());
|
||||
|
|
@ -1558,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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,6 +249,10 @@ impl MultiPodApp {
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -325,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)
|
||||
|
|
@ -692,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);
|
||||
|
|
@ -873,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));
|
||||
|
|
@ -1199,6 +1207,76 @@ mod tests {
|
|||
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,
|
||||
|
|
@ -1238,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,
|
||||
|
|
@ -1291,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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**:
|
||||
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`
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
Loading…
Reference in New Issue
Block a user