Skip to content

Commit 1265568

Browse files
feat(worldline): checkpoint-tree rewind for tui_v2 (#625)
* fix(continue): preserve session workspace on in-place restore In-place /continue retargets agent.log_path to the restored file, so the reset bind `_bind_workspace(None)` ran during restore was persisting session_ws_set(path, "") — erasing that session's own workspace mapping to "" (read back as "explicitly off") before we read it, which also short- circuited the log-scan fallback. The continued session never re-entered its workspace. Add a `persist` flag to `_bind_workspace`; the restore-time reset passes persist=False so it only refreshes in-memory state. The session→workspace map is now written solely by explicit /workspace, /workspace off, and a successful restore. * fix(tui_v3): support Home/End keys for line start/end PTK delivers Home/End as raw VT sequences in KeyPress.data, which _esc_repl swallowed (only arrows were decoded). Map \x1b[H/[F/[1~/[4~ and SS3 \x1bOH/\x1bOF to internal bytes 0x07/0x14, extend _ESC_RE to match SS3 as whole sequences, and add jump-to-line-start/end handlers in _keys. Home no longer collides with Ctrl+A select-all. * feat(worldline): integrate checkpoint-tree rewind into tui_v2 - tuiapp_v2.py: /rewind durable inline picker + /worldline three-pane checkpoint tree (tree UI inlined; colors follow the v2 theme) with a per-node diff viewport; conv-only /rewind restores via the persistent store. - worldline.py (new): UI-agnostic backend — RewindStore (persistent checkpoint tree + content-addressed blobs), reconcile, restore_plan, node_diff, native-log projection. - continue_cmd.py: gated worldline support — list_sessions(rewind_root=), continue_inplace/copy(allow_empty=); default-off, other UIs byte-identical. - tui_v3.py: status line shows concrete model id (unrelated minor tweak). * feat(worldline): /rewind reuses the restore-mode picker (conv/code/both) After picking a turn, /rewind now opens the same RestoreModeScreen as /worldline to choose conversation / code / both, then restores via the persistent store — making /rewind a true lite view of /worldline (linear list, no tree/diff) over the identical restore backend.
1 parent f8ecfdb commit 1265568

4 files changed

Lines changed: 2367 additions & 35 deletions

File tree

frontends/continue_cmd.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def _rounds_for_file(path, st):
294294
return n, key
295295

296296

297-
def list_sessions(exclude_pid=None, exclude_log=None):
297+
def list_sessions(exclude_pid=None, exclude_log=None, rewind_root=None):
298298
"""Newest-first list of (path, mtime, preview_text, n_rounds). Preview uses head/tail window only.
299299
300300
`exclude_log` (basename, e.g. 'model_responses_123456.txt') drops the caller's
@@ -324,6 +324,43 @@ def list_sessions(exclude_pid=None, exclude_log=None):
324324
valid_keys.append(key)
325325
out.append((f, mtime, preview, rounds))
326326
_save_rounds_cache(valid_keys)
327+
# 【门控·worldline】树感知发现:日志空/缺失但有非空世界线树的会话(回退到起点后日志被
328+
# 清空 → 上面 sz<32 跳过了)。仅当调用方显式传 rewind_root 时启用 → 其他 UI 不传,
329+
# 行为逐字节不变。只读 tree.json 的 nodes/head(不依赖 worldline 模块)。
330+
if rewind_root and os.path.isdir(rewind_root):
331+
have = {os.path.basename(p) for p, *_ in out}
332+
try:
333+
keys = os.listdir(rewind_root)
334+
except OSError:
335+
keys = []
336+
for key in keys:
337+
if not key.startswith('model_responses_'):
338+
continue
339+
log_name = key + '.txt'
340+
if log_name in have or log_name == exclude_log:
341+
continue
342+
log_path = os.path.join(_LOG_DIR, log_name)
343+
try: # 仅收"日志确实空/缺失"的(非空日志已被主循环收录)
344+
if os.path.getsize(log_path) >= 32:
345+
continue
346+
except OSError:
347+
pass # 缺失也算
348+
try:
349+
with open(os.path.join(rewind_root, key, 'tree.json'), encoding='utf-8') as fh:
350+
d = json.load(fh)
351+
except Exception:
352+
continue
353+
nodes = d.get('nodes') or {}
354+
real = [v for v in nodes.values() if v.get('kind') != 'origin']
355+
if not real: # 只有 origin 的空树 → 无内容,跳过
356+
continue
357+
try:
358+
mtime = os.path.getmtime(os.path.join(rewind_root, key, 'tree.json'))
359+
except OSError:
360+
mtime = 0
361+
head = d.get('head')
362+
title = (nodes.get(head, {}).get('title') if head else '') or '(已回退至会话起点)'
363+
out.append((log_path, mtime, f'[世界线] {title}', len(real)))
327364
out.sort(key=lambda x: x[1], reverse=True)
328365
return out
329366
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
@@ -1014,10 +1051,19 @@ def _load_history_into(agent, path):
10141051
return f'⚠️ 非 native 格式,降级恢复 {n} 轮摘要({name})', False
10151052

10161053

1017-
def continue_inplace(agent, path, agent_id=None):
1054+
def _is_empty_log(path):
1055+
"""日志空(<32 字节)或缺失。用于 allow_empty:回退到会话起点后日志被清空的会话。"""
1056+
try:
1057+
return os.path.getsize(path) < 32
1058+
except OSError:
1059+
return True
1060+
1061+
1062+
def continue_inplace(agent, path, agent_id=None, allow_empty=False):
10181063
"""原地续:把 agent 的日志指回 `path` 本身,之后轮次追加到 X,延续同一会话。
10191064
调用方应已确认空闲(session_occupant 为 None);抢锁失败(被占)返回错误。
1020-
返回 (msg, ok)。"""
1065+
`allow_empty`(仅 worldline UI 传):日志为空时不报错,按【空会话】恢复(清空对话,
1066+
由调用方按 `.ga_rewind` 树重连),用于"回退至会话起点"的会话。返回 (msg, ok)。"""
10211067
try: agent.abort()
10221068
except Exception: pass
10231069
if not acquire_lock(path, agent_id): # 先抢到目标锁;失败则保持现状,不丢自己的锁
@@ -1026,10 +1072,14 @@ def continue_inplace(agent, path, agent_id=None):
10261072
if cur and os.path.basename(cur) != os.path.basename(path):
10271073
release_lock(cur) # 目标到手,旧会话释放为空闲(同一文件则不放)
10281074
_retarget_log(agent, path)
1029-
return _load_history_into(agent, path)
1075+
msg, ok = _load_history_into(agent, path)
1076+
if not ok and allow_empty and _is_empty_log(path):
1077+
_replace_backend_history(agent, []) # 空会话:清空对话(载入失败时它没被清)
1078+
return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
1079+
return msg, ok
10301080

10311081

1032-
def continue_copy(agent, path, agent_id=None):
1082+
def continue_copy(agent, path, agent_id=None, allow_empty=False):
10331083
"""拷贝续:铸新 logid、把 `path` 内容拷进去,在副本上续;`path` 原件不动。
10341084
用于"被占用→用户选拷贝"以及快照源。返回 (msg, ok)。"""
10351085
try: agent.abort()
@@ -1042,7 +1092,11 @@ def continue_copy(agent, path, agent_id=None):
10421092
pass
10431093
acquire_lock(newp, agent_id)
10441094
_retarget_log(agent, newp)
1045-
return _load_history_into(agent, newp)
1095+
msg, ok = _load_history_into(agent, newp)
1096+
if not ok and allow_empty and _is_empty_log(newp):
1097+
_replace_backend_history(agent, [])
1098+
return '✅ 已恢复空会话(回退至会话起点;世界线树已重连)', True
1099+
return msg, ok
10461100

10471101

10481102
def install(cls):

frontends/tui_v3.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -877,10 +877,12 @@ def has(*needles: str) -> bool:
877877
return b'\x02'
878878
if has('right'):
879879
return b'\x06'
880+
# Home/End get dedicated bytes — 0x01 is already Ctrl+A (select-all) and
881+
# 0x05 has no editor handler, so reusing them would break/no-op the keys.
880882
if has('home'):
881-
return b'\x01'
883+
return b'\x07'
882884
if has('end'):
883-
return b'\x05'
885+
return b'\x14'
884886
if has('delete'):
885887
return b'\x7f'
886888
if has('backspace') or data == '\x7f' or data == '\x08':
@@ -1496,6 +1498,15 @@ def llm_name(self) -> str:
14961498
except Exception:
14971499
return '?'
14981500

1501+
@property
1502+
def llm_model(self) -> str:
1503+
"""The concrete model id in use (e.g. claude-opus-4-8), not the
1504+
channel group `llm_name` returns. Empty string when unavailable."""
1505+
try:
1506+
return self.agent.get_llm_name(model=True) or ''
1507+
except Exception:
1508+
return ''
1509+
14991510
def list_llms(self) -> list[tuple[int, str, bool]]:
15001511
return self.agent.list_llms()
15011512

@@ -1632,7 +1643,9 @@ def repl(m: re.Match) -> str:
16321643
out.append(t); i += 1
16331644
return '\x1b[' + ';'.join(out) + 'm' if out else '\x1b[0m'
16341645
return _SGR_RE.sub(repl, s)
1635-
_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b.')
1646+
# CSI (`\x1b[…`) or SS3 (`\x1bO…`, application-cursor mode for Home/End/arrows)
1647+
# as whole sequences, else any 2-byte `\x1b.` — order matters so SS3 wins over `\x1b.`.
1648+
_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1bO[@-~]|\x1b.')
16361649
_FILE_REF_RE = re.compile(r'@([\w./\-~]+)')
16371650
_PASTE_PH_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]')
16381651
_FILE_PH_RE = re.compile(r'\[File #(\d+)\]')
@@ -2107,6 +2120,13 @@ def _esc_repl(m: re.Match) -> bytes:
21072120
return b'\x1d' # Shift+↓ → extend selection down
21082121
if s in (b'\x1b[27;2;13~', b'\x1b[13;2u'):
21092122
return b'\n' # Shift+Enter (modifyOtherKeys / kitty) → newline
2123+
# Home/End arrive as raw VT sequences via PTK's KeyPress.data (special keys
2124+
# carry their original escape bytes), so _ptk_keypress_to_bytes returns them
2125+
# verbatim and they must be decoded here like the arrows above.
2126+
if s in (b'\x1b[H', b'\x1b[1~', b'\x1b[7~', b'\x1bOH'):
2127+
return b'\x07' # Home → internal jump-to-line-start
2128+
if s in (b'\x1b[F', b'\x1b[4~', b'\x1b[8~', b'\x1bOF'):
2129+
return b'\x14' # End → internal jump-to-line-end
21102130
return b'' # swallow every other escape sequence
21112131

21122132

@@ -2496,7 +2516,9 @@ def __init__(self) -> None:
24962516
# in callers are harmless legacy resets (they zero state PTK ignores).
24972517

24982518
def _status_line(self, w: int) -> str:
2499-
name = self._bridge.llm_name if self._bridge else '?'
2519+
# Show the concrete model id; fall back to the channel group only when
2520+
# the model is unavailable (e.g. a mixin without a single .model).
2521+
name = (self._bridge.llm_model or self._bridge.llm_name) if self._bridge else '?'
25002522
if self._asking:
25012523
state = _t('status.asking')
25022524
elif self._running:
@@ -5677,6 +5699,14 @@ def _keys(self, data: bytes) -> None:
56775699
elif o == 0x01: # Ctrl+A — select all
56785700
if self.buf:
56795701
self._sel = 0; self.pos = len(self.buf)
5702+
elif o == 0x07: # Home — jump to line start
5703+
self._sel = None
5704+
_, _, ls, _ = self._line_region()
5705+
self.pos = ls
5706+
elif o == 0x14: # End — jump to line end
5707+
self._sel = None
5708+
_, _, _, le = self._line_region()
5709+
self.pos = le
56805710
elif o == 0x18: # Ctrl+X — cut selection
56815711
r = self._sel_range()
56825712
if r:

0 commit comments

Comments
 (0)