feat(tui): workspace 项目模式 + @ 文件引用补全(v2/v3) (#607)

* feat(tui): workspace 项目模式 + @ 文件引用补全(v2/v3)

- workspace: /workspace 设/off/picker、junction 复用 project_mode 记忆、
  per-session(v2)/进程级(v3)、/continue 恢复、session map 持久化绑定状态
- @ 补全(completion-only): 子序列模糊 + path-like 目录补全(~/ / ./ C:\)、
  提交期 @路径作为普通文本交 agent
- v3 filterable 焦点链 picker(输入框作焦点环对象、free_input)
- 新增共享模块 at_complete.py / workspace_cmd.py;核心零改动

* feat(tui): @ 路径绝对化 + 默认根=temp + 未绑显示完整路径

- 默认根 os.getcwd() → agent 工作目录 <GA根>/temp(与 file_read/code_run 一致、不随启动 cwd 飘)
- 提交期 @相对 → @绝对(display 保留相对短路径),让 agent file_read(相对自身 cwd)找得到
- 未绑 workspace 时候选显示完整路径(根不直观);索引忽略 model_responses 会话日志噪音
- 启动预热 temp 索引 + candidates_for 惰性兜底(任何根首次访问自动建)
This commit is contained in:
nianyucatfish authored and GitHub committed 2026-06-14 09:10:04 +08:00
1 parent 1d2f3597eb
commit 84254ad762
6 files changed
+1516 -55

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
"""@ file completion — shared UI-less logic for tui_v2 / tui_v3.
File index (os.scandir, cached per root) + fuzzy match + @token detection +
insert text. No UI deps; each front-end renders candidates its own way and
calls candidates_for(query, root). Index root is the front-end's choice
(session workspace, else CWD). Submit-time: completion-only does NOT read
content, but absolutize_mentions() rewrites @relative → @absolute so the
agent's file_read (relative to its own cwd) can locate the file. The
content-injecting auto-read variant lives in
temp/plan_v2_at_mention/autoread_version.py.
"""
import os
import re
import threading
# ---------------------------------------------------------------- index
_IGNORE_DIRS = {
".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build",
".next", ".idea", ".vscode", "target", ".cache", ".eggs",
"model_responses", # GA 会话日志(上千个 .txt),未绑时根=temp 会淹没 @ 候选
}
_IGNORE_EXT = {".pyc", ".pyo", ".so", ".o", ".class", ".lock", ".dll", ".exe"}
_MAX_FILES = 50_000 # 超大目录宁缺毋卡:到上限就停
def scan_files(root: str, max_files: int = _MAX_FILES) -> list[str]:
"""Collect relative file paths under root, '/'-normalized.
os.scandir over os.walk: one syscall yields is_dir without an extra
stat per entry. Dotted dirs are skipped wholesale (.git, .venv...).
"""
out: list[str] = []
stack = [root]
while stack and len(out) < max_files:
d = stack.pop()
try:
with os.scandir(d) as it:
for e in it:
try:
if e.is_dir(follow_symlinks=False):
if e.name not in _IGNORE_DIRS and not e.name.startswith("."):
stack.append(e.path)
elif e.is_file(follow_symlinks=False):
if os.path.splitext(e.name)[1].lower() not in _IGNORE_EXT:
rel = os.path.relpath(e.path, root).replace("\\", "/")
out.append(rel)
if len(out) >= max_files:
return out
except OSError:
continue
except OSError:
continue
return out
class FileIndexCache:
"""Per-root background file index. warm() is idempotent-cheap: a
rebuild is only started when none is in flight."""
def __init__(self, root: str):
self.root = root
self._files: list[str] = []
self._lock = threading.Lock()
self._building = False
self.ready = threading.Event()
def warm(self) -> None:
with self._lock:
if self._building:
return
self._building = True
def _build():
try:
files = scan_files(self.root)
with self._lock:
self._files = files
self.ready.set()
finally:
with self._lock:
self._building = False
threading.Thread(target=_build, name="ga-at-index", daemon=True).start()
def snapshot(self) -> list[str]:
with self._lock:
return self._files
_indexes: dict[str, FileIndexCache] = {}
_indexes_lk = threading.Lock()
def get_index(root: str) -> FileIndexCache:
key = os.path.normcase(os.path.realpath(root or os.getcwd()))
with _indexes_lk:
idx = _indexes.get(key)
if idx is None:
idx = _indexes[key] = FileIndexCache(root)
return idx
# ---------------------------------------------------------------- fuzzy
def _subseq_score(q: str, path: str):
"""Subsequence match score (higher = better), None when q doesn't
fully appear in order. Contiguous runs dominate (fzf-style): scattered
one-char hits across a long path must not beat a tight cluster.
Word-boundary hits and basename substring add on top; ties broken by
caller on shorter path."""
if not q:
return 0
score, qi, prev_hit = 0, 0, -2
for pi, ch in enumerate(path):
if qi < len(q) and ch == q[qi]:
score += 1
if pi == prev_hit + 1:
score += 2 # contiguous run: the dominant signal
if pi == 0 or path[pi - 1] in "/\\_-. ":
score += 3
prev_hit = pi
qi += 1
if qi < len(q):
return None
base = path.rsplit("/", 1)[-1]
if q in base:
score += 8
elif q in path:
score += 4
return score
def fuzzy_rank(query: str, files: list[str], limit: int = 10) -> list[str]:
q = query.lower()
if not q:
# bare `@`: surface shallow paths first for discoverability
return sorted(files, key=lambda f: (f.count("/"), f))[:limit]
scored = []
for f in files:
s = _subseq_score(q, f.lower())
if s is not None:
scored.append((s, f))
scored.sort(key=lambda x: (-x[0], len(x[1]), x[1]))
return [f for _, f in scored[:limit]]
# ------------------------------------------------------- edit-time token
# `(?:^|\s)@` 前置:@ 前必须是行首或空白 → 邮箱/代码里的 a@b 不触发。
# 字符集含路径分隔符与 ~ :,\w 在 unicode 下覆盖中文文件名。
_AT_TOKEN_RE = re.compile(r"(?:^|\s)(@[\w\-./\\~:]*)$", re.UNICODE)
def find_at_token(line_before_cursor: str):
"""Return (query, at_pos) when the cursor sits in an @token being
typed on this line, else None. at_pos is the index of '@'."""
m = _AT_TOKEN_RE.search(line_before_cursor)
if not m:
return None
tok = m.group(1)
return tok[1:], m.start(1)
def format_pick(path: str) -> str:
"""`@path` insert text; dirs get no trailing space (keep completing next
level), files get one (close token). Spaces → quoted."""
trailing = '' if path.endswith(('/', '\\')) else ' '
return f'@"{path}"{trailing}' if ' ' in path else f'@{path}{trailing}'
# --- path-like completion: an explicit-path @token (~/ / ./ ../ or C:\) goes
# to live directory completion instead of index fuzzy — this is how absolute
# paths outside the index root get completed level by level (claude-code parity).
def is_path_like(token: str) -> bool:
if token in ('~', '.', '..'):
return True
if token.startswith(('~/', '~\\', './', '.\\', '../', '..\\', '/', '\\')):
return True
return len(token) >= 3 and token[0].isalpha() and token[1] == ':' and token[2] in '/\\'
def path_completions(token: str, root: str, limit: int = 15) -> list[str]:
"""readdir the real dir of a path-like token, prefix-match, dirs first.
`~` expanded, relative → root, absolute as-is; candidates keep the token's
spelling, dirs carry a trailing '/'."""
sep = max(token.rfind('/'), token.rfind('\\'))
if sep >= 0:
dir_part, prefix = token[:sep + 1], token[sep + 1:]
elif token in ('~', '.', '..'):
dir_part, prefix = token.rstrip('/\\') + '/', ''
else:
return []
exp = os.path.expanduser(dir_part)
real_dir = exp if os.path.isabs(exp) else os.path.join(root, exp)
try:
with os.scandir(real_dir) as it:
entries = list(it)
except OSError:
return []
pl = prefix.lower()
rows = []
for e in entries:
nm = e.name
if pl and not nm.lower().startswith(pl):
continue
if nm.startswith('.') and not prefix.startswith('.'): # 隐藏项需显式 . 才出
continue
try:
is_dir = e.is_dir()
except OSError:
is_dir = False
rows.append((not is_dir, nm.lower(), dir_part + nm + ('/' if is_dir else '')))
rows.sort(key=lambda r: (r[0], r[1])) # 目录优先 + 字母序
return [d for _, _, d in rows[:limit]]
def candidates_for(query: str, root: str, limit: int = 15, absolute: bool = False) -> list[str]:
"""@token candidates: path-like → directory completion, else index fuzzy.
Single dispatch point shared by both front-ends. `absolute=True` returns
fuzzy hits as absolute paths (front-end shows full path when no workspace
is bound, since the relative root isn't obvious to the user)."""
if is_path_like(query):
return path_completions(query, root, limit)
idx = get_index(root)
files = idx.snapshot()
if not files:
idx.warm() # 惰性兜底:该根还没建索引 → 后台建(本次可能空,下次有)
res = fuzzy_rank(query, files, limit) if files else []
if absolute:
res = [os.path.normpath(os.path.join(root, c)) for c in res]
return res
# ------------------------------------------------------ submit-time absolutize
# A fuzzy candidate inserts a path relative to the @ root (workspace/CWD), but
# the agent's file_read resolves relative to its own ./temp cwd — so a bare
# `@frontends/x.py` won't be found. At submit we rewrite each @mention naming a
# real file to an absolute path; display keeps the short form. Still no content
# read — this only completes the path so the agent can locate it.
_AT_ABS_RE = re.compile(r'(^|\s)@("([^"]+)"|([\w\-./\\~:#]+))', re.UNICODE)
_LINE_SUFFIX_RE = re.compile(r'(#L\d+(?:-\d+)?)$')
def absolutize_mentions(text: str, root: str) -> str:
"""@relative → @absolute (root-resolved, ~ expanded, quoted if it gains a
space), `#Lx-y` suffix kept. Only existing paths are rewritten; decorative
@words / typos pass through unchanged."""
def repl(m):
lead, quoted, bare = m.group(1), m.group(3), m.group(4)
raw = quoted if quoted is not None else bare
trail = ''
if quoted is None: # strip trailing prose punctuation
stripped = raw.rstrip(',。,;;))]》>')
trail, raw = raw[len(stripped):], stripped
sm = _LINE_SUFFIX_RE.search(raw)
suffix = sm.group(1) if sm else ''
path = raw[:len(raw) - len(suffix)] if suffix else raw
if not path:
return m.group(0)
exp = os.path.expanduser(path)
absp = os.path.normpath(exp if os.path.isabs(exp) else os.path.join(root, exp))
if not os.path.exists(absp): # decorative / typo → leave as-is
return m.group(0)
full = absp + suffix
token = f'@"{full}"' if ' ' in full else f'@{full}'
return lead + token + trail
return _AT_ABS_RE.sub(repl, text)
+13 -2
View File
@@ -36,7 +36,7 @@ def _first_user(pairs):
if not isinstance(msg, dict): continue
for blk in msg.get('content', []) or []:
if isinstance(blk, dict) and blk.get('type') == 'text':
t = (blk.get('text') or '').strip()
t = strip_project_mode(blk.get('text') or '').strip()
if t and '<history>' not in t and not t.startswith('### [WORKING MEMORY]'):
return t
for p, _ in pairs[:1]:
@@ -440,6 +440,17 @@ def handle(agent, query, display_queue):
_INJECT_MARKERS = ('### [WORKING MEMORY]', '[SYSTEM TIPS]', '[SYSTEM]', '[System]',
'[DANGER]', '### [总结提炼经验]')
# project_mode 插件把 `\n\n---\n[PROJECT MODE: <name>]\n…\n---` 追加在用户消息末尾
# (见 plugins/project_mode._build_injection)。它会进日志,所以 /continue 重建 UI 时
# 必须从显示文本里剔除,只留用户原话。不能加进 _INJECT_MARKERS——那会把整块(连用户
# 原话)一起丢弃;这里只剜掉注入这一段后缀。
_PM_BLOCK_RE = re.compile(r"\n*-{3,}\n\[PROJECT MODE:.*?\n-{3,}\s*$", re.DOTALL)
def strip_project_mode(text: str) -> str:
"""剔除用户文本尾部的 project-mode 注入块。"""
return _PM_BLOCK_RE.sub("", text or "")
def _user_text(prompt_body):
"""User-typed text from a prompt JSON; '' if this is an agent auto-continuation.
@@ -458,7 +469,7 @@ def _user_text(prompt_body):
return ''
for blk in blocks:
if isinstance(blk, dict) and blk.get('type') == 'text':
t = (blk.get('text') or '').strip()
t = strip_project_mode(blk.get('text') or '').strip()
if t and not any(mk in t for mk in _INJECT_MARKERS): return t
return ''
+418 -39
View File
@@ -32,6 +32,7 @@ from rich.markdown import Markdown
from rich.text import Text
from rich.theme import Theme
from typing import Callable
from frontends import at_complete, workspace_cmd # @ 补全 + /workspace(与 v2 共用)
# ════════════════════════════════════════════════════════════════════════════
# i18n — minimal dict-based zh/en translation layer (inlined; was tui_v3_i18n.py)
@@ -295,6 +296,10 @@ _I18N: dict[str, dict[str, str]] = {
# menu / picker / palette
'menu.default_title': 'Pick',
'menu.hint': '↑↓ pick · Enter confirm · Esc cancel',
'menu.hint.filter': 'type to filter · ↑↓ pick · Enter confirm · Esc cancel',
'menu.search': 'filter workspaces, or type an abs path + Enter to create one',
'menu.no_match': 'no match',
'menu.free.hint': 'Enter to create/enter this path',
'ask.default_q': 'answer:',
'ask.title': '◉ answer',
'ask.pending': ' +{n} pending',
@@ -304,6 +309,16 @@ _I18N: dict[str, dict[str, str]] = {
# continue picker
'continue.title': 'Restore historical session',
# /workspace (parity with v2; backed by workspace_cmd.py)
'cmd.workspace.arg': '[path|off]',
'cmd.workspace.desc': 'set working dir (abs path) and enter project mode',
'ws.entered': '✅ entered workspace「{n}」',
'ws.fail': '❌ workspace failed: {e}',
'ws.exited': 'left workspace (project mode off; junction & files kept)',
'ws.inactive': 'not in a workspace right now',
'ws.none': 'no registered workspace yet; /workspace <abs path> to create/enter',
'ws.pick.title': 'Pick a workspace (↑↓ choose · Enter confirm · Esc cancel)',
'ws.restored': 'restored working dir: {t}',
'continue.row.fmt': '{rel:>4} {rounds:>3}r {preview}',
'continue.unit.round': 'r',
@@ -545,6 +560,10 @@ _I18N: dict[str, dict[str, str]] = {
# menu / picker / palette
'menu.default_title': '选择',
'menu.hint': '↑↓ 选 · Enter 确认 · Esc 取消',
'menu.hint.filter': '输入过滤 · ↑↓ 选 · Enter 确认 · Esc 取消',
'menu.search': '输入筛选工作区或输入绝对路径回车新建工作区',
'menu.no_match': '无匹配',
'menu.free.hint': '回车以该路径新建/进入',
'ask.default_q': '请回答:',
'ask.title': '◉ 请回答',
'ask.pending': ' +{n} 待答',
@@ -554,6 +573,16 @@ _I18N: dict[str, dict[str, str]] = {
# continue picker
'continue.title': '恢复历史会话',
# /workspace(与 v2 一致;后端 workspace_cmd.py)
'cmd.workspace.arg': '[path|off]',
'cmd.workspace.desc': '设定工作目录(绝对路径)并进入项目模式',
'ws.entered': '✅ 已进入 workspace「{n}」',
'ws.fail': '❌ workspace 设定失败: {e}',
'ws.exited': '已退出 workspace(项目模式关闭;junction 与文件保留)',
'ws.inactive': '当前未处于 workspace 模式',
'ws.none': '暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入',
'ws.pick.title': '选择 workspace(↑↓ 选择,Enter 确认,Esc 取消)',
'ws.restored': '已恢复工作目录: {t}',
'continue.row.fmt': '{rel:>4} {rounds:>3}轮 {preview}',
'continue.unit.round': '轮',
@@ -1336,6 +1365,10 @@ class AgentBridge:
self.agent.llmclient = self.agent.llmclients[llm_no % len(self.agent.llmclients)]
self.agent.inc_out = True
self.agent.verbose = True
# 默认普通模式:设 None 让 project_mode 插件不读 pid 文件锚(与 v2 一致)。
# /workspace 绑定时改为项目名 + 真实路径。
self.agent._ga_project_mode_name = None
self.agent._ga_project_mode_workspace_path = ''
# task_dir path enables ga's `_keyinfo` / `_intervene` consume paths.
# PID-scoped so concurrent v3 processes don't share signal files.
# Only the *path* is set here; the dir is created lazily by the writer
@@ -1841,6 +1874,8 @@ def _cmds() -> list[tuple[str, str, str]]:
('/scheduler', '', _t('cmd.scheduler.desc')),
('/rewind', _t('cmd.rewind.arg'), _t('cmd.rewind.desc')),
('/continue', _t('cmd.continue.arg'), _t('cmd.continue.desc')),
('/workspace', _t('cmd.workspace.arg', default='[path|off]'),
_t('cmd.workspace.desc', default='设定工作目录(绝对路径)并进入项目模式')),
('/new', _t('cmd.new.arg'), _t('cmd.new.desc')),
('/rename', _t('cmd.rename.arg'), _t('cmd.rename.desc')),
('/clear', '', _t('cmd.clear.desc')),
@@ -2205,6 +2240,46 @@ def _clip_cells(s: str, width: int) -> str:
return out
def _cell_head(s: str, n: int) -> str:
"""Keep the head within n cells, suffix … if truncated (省略末尾 — names)."""
if cell_len(s) <= n:
return s
if n <= 1:
return '…'
out, w = '', 0
for ch in s:
c = cell_len(ch)
if w + c > n - 1:
break
out += ch; w += c
return out + '…'
def _cell_mid(s: str, n: int) -> str:
"""Keep head+tail within n cells, … in the middle (省略中间 — paths: project
root and leaf both stay visible). CJK counts as 2 via cell_len."""
if cell_len(s) <= n:
return s
if n <= 1:
return '…'
avail = n - 1
head_budget = avail - avail // 2
tail_budget = avail // 2
head, w = '', 0
for ch in s:
c = cell_len(ch)
if w + c > head_budget:
break
head += ch; w += c
tail_rev, w = '', 0
for ch in reversed(s):
c = cell_len(ch)
if w + c > tail_budget:
break
tail_rev += ch; w += c
return head + '…' + tail_rev[::-1]
def _term_safe_text(s: str) -> str:
"""Normalize control chars whose terminal geometry is stateful.
@@ -2380,11 +2455,25 @@ class SB:
# on_submit(_menu_sel) and ignores _menu_checked.
self._menu_multi: bool = False
self._menu_checked: set[int] = set()
# opt-in 可过滤菜单(/workspace 用):输入过滤 + free_input 提交原文。
self._menu_filterable: bool = False
self._menu_free: bool = False
self._menu_on_free = None
self._menu_query: str = ''
self._menu_all_labels: list[str] = [] # 未过滤的全部显示行
self._menu_filter_keys: list[str] = [] # 与 all_labels 等长的可搜索文本(完整,不省略)
self._menu_map: list[int] = [] # 可见行 idx → all_labels 原始 idx
# Interactive command palette: index into _cmd_matches output when
# buf starts with `/`. ↑↓ steer the highlight, Tab completes the
# highlighted command, Enter still executes whatever is in buf.
self._palette_sel: int = 0
self._palette_scroll: int = 0 # viewport offset into the matches list
# workspace 绑定(单会话 → 进程级一个)。空 = 普通模式。
self._ws_name: str = ''
self._ws_path: str = ''
self._ws_link: str = ''
# @ 候选缓存:(buf, pos) → list[path],避免每次按键重算 fuzzy_rank。
self._at_cache: tuple | None = None
# ── live region ──
#
@@ -2409,7 +2498,12 @@ class SB:
else:
state = _t('status.ready')
cost = _cost_str(self._bridge.agent) if self._bridge else ''
return f'[main] {name} │ {state}{cost}'
ws = ''
if self._ws_name:
disp = (os.path.basename((self._ws_path or '').rstrip('/\\'))
or re.sub(r'-[0-9a-f]{8}$', '', self._ws_name))
ws = f' │ ⌂ {_cell_head(disp, 18)}'
return f'[main] {name} │ {state}{cost}{ws}'
# v2-style plan card budget: 5 rows max — header(1) + optional step(1) +
# tasks(rest) + optional overflow(1). Grace periods avoid flicker when the
@@ -2658,7 +2752,9 @@ class SB:
def _show_menu(self, title: str, options: list[str], on_submit,
hint: str | None = None, on_cancel=None,
multi_select: bool = False,
pre_checked: set[int] | None = None) -> None:
pre_checked: set[int] | None = None,
filterable: bool = False, free_input: bool = False,
on_free=None, filter_keys: list[str] | None = None) -> None:
"""Open a modal arrow-key menu in place of the input box.
The menu takes over the live region; ↑↓ move the highlight, Enter
@@ -2676,11 +2772,23 @@ class SB:
worked logically but left a one-frame window where PTK could render
the menu with the wrong state — observed as /scheduler picker
rendering all unchecked even though `reflect/scheduler.py` was alive.
Passing the set up-front makes the open atomic."""
Passing the set up-front makes the open atomic.
`filterable=True` adds a search box: printable chars / backspace edit a
live query that filters rows by `filter_keys` (defaults to the visible
labels; pass the full untruncated text so an elided middle still
matches). `free_input=True` + `on_free` lets Enter on a query with NO
match commit the raw query (e.g. an abs path → new workspace)."""
if not options:
return
if not (filterable and free_input):
return # 空列表但允许 free_input → 仍打开(可输路径新建)
self._menu_active = True
self._menu_options = list(options)
self._menu_filterable = bool(filterable)
self._menu_free = bool(free_input)
self._menu_on_free = on_free
self._menu_query = ''
self._menu_all_labels = list(options)
self._menu_filter_keys = list(filter_keys) if filter_keys else list(options)
self._menu_title = title
# In multi-select mode show a Space-aware hint by default so the user
# discovers the toggle key without reading the docstring.
@@ -2688,16 +2796,38 @@ class SB:
self._menu_hint = hint
elif multi_select:
self._menu_hint = _t('menu.hint.multi', default='Space toggle · ↑↓ move · Enter submit · Esc cancel')
elif filterable:
self._menu_hint = _t('menu.hint.filter', default='输入过滤 · ↑↓ 选择 · Enter 确认 · Esc 取消')
else:
self._menu_hint = _t('menu.hint')
self._menu_sel = 0
self._menu_scroll = 0
self._menu_on_submit = on_submit
self._menu_on_cancel = on_cancel
self._menu_multi = bool(multi_select)
self._menu_checked = set(pre_checked) if pre_checked else set()
self._menu_apply_filter() # 设 _menu_options + _menu_map(初始 query 为空 → 全量)
self._render_live()
def _menu_apply_filter(self) -> None:
"""Recompute visible rows (`_menu_options`) + map-to-original
(`_menu_map`) from the live query. No-op shape when not filterable."""
q = self._menu_query.strip().lower()
if not self._menu_filterable or not q:
self._menu_options = list(self._menu_all_labels)
self._menu_map = list(range(len(self._menu_all_labels)))
else:
terms = q.split()
self._menu_options, self._menu_map = [], []
for i, key in enumerate(self._menu_filter_keys):
kl = key.lower()
if all(t in kl for t in terms):
self._menu_options.append(self._menu_all_labels[i])
self._menu_map.append(i)
# Focus model (filterable): -1 = the input row itself (a selectable
# object in the ↑↓ ring, v2-continue style); 0..n-1 = a candidate.
# Typing keeps focus on the input row. Non-filterable menus start at 0.
self._menu_sel = -1 if self._menu_filterable else 0
self._menu_scroll = 0
def _close_menu(self) -> None:
self._menu_active = False
self._menu_options = []
@@ -2709,6 +2839,13 @@ class SB:
self._menu_on_cancel = None
self._menu_multi = False
self._menu_checked = set()
self._menu_filterable = False
self._menu_free = False
self._menu_on_free = None
self._menu_query = ''
self._menu_all_labels = []
self._menu_filter_keys = []
self._menu_map = []
@staticmethod
def _scroll_window(sel: int, total: int, visible: int, scroll: int) -> int:
@@ -2729,6 +2866,36 @@ class SB:
checked = sorted(self._menu_checked) if multi else None
if not self._menu_active:
return
# Filterable: map the visible selection back to the original index; or,
# when the query matches nothing, commit it verbatim via on_free
# (free_input — e.g. a typed abs path → new workspace).
if self._menu_filterable:
mp = self._menu_map
on_free = self._menu_on_free
q = self._menu_query.strip()
# 焦点在某个候选 → 选它(映射回原始 idx)。
if sel >= 0 and self._menu_options:
orig = mp[sel] if 0 <= sel < len(mp) else mp[0]
self._close_menu()
if cb is not None:
try: cb(orig)
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
return
# 焦点在输入框(sel<0):有 query → free 提交(当作路径新建/进入)。
if self._menu_free and q and on_free is not None:
self._close_menu()
try: on_free(q)
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
return
# 输入框空 + 有候选 → 选第一个(便捷)。
if self._menu_options:
orig = mp[0]
self._close_menu()
if cb is not None:
try: cb(orig)
except Exception as e: self.commit([_t('err.menu_cb', err=str(e))])
return
return # 无 query 无候选 → 维持菜单
self._close_menu()
if cb is not None:
try:
@@ -2767,14 +2934,50 @@ class SB:
return ae.candidates[self._picker_sel]
return None
def _at_root(self) -> str:
# @ 索引根 = workspace(绑定时真实路径),否则 agent 实际工作目录
# _ROOT/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。
return self._ws_path or os.path.join(_ROOT, "temp")
def _at_active(self):
"""@ 补全:返回 (query, at_pos_in_buf) 或 None。基于光标前、当前逻辑行的
@token(@ 可在任意行任意位置;菜单 / ask 态不触发)。"""
if self._asking is not None or self._menu_active:
return None
ls = self.buf.rfind('\n', 0, self.pos) + 1
tok = at_complete.find_at_token(self.buf[ls:self.pos])
if tok is None:
return None
query, at_in_line = tok
return query, ls + at_in_line
def _at_candidates(self) -> list:
"""当前 @token 的候选(带 (buf,pos) 缓存,避免每键重算 fuzzy)。"""
act = self._at_active()
if act is None:
self._at_cache = None
return []
key = (self.buf, self.pos)
if self._at_cache is not None and self._at_cache[0] == key:
return self._at_cache[1]
# 未绑 workspace → 索引根是 temp,相对路径不直观,候选用绝对路径(_hint_lines
# 本就整条显示);绑了用相对(短)。
items = at_complete.candidates_for(act[0], self._at_root(), absolute=not self._ws_path)
self._at_cache = (key, items)
return items
def _palette_total(self) -> int:
# 当前 palette 候选数(↓ 键越界判断用):slash 命令 or @ 文件。
if self._slash_visible():
return len(self._cmd_matches(self.buf))
return len(self._at_candidates())
def _cmd_matches(self, prefix: str) -> list[tuple[str, str, str]]:
p = prefix.strip().lower()
return [c for c in _cmds() if c[0].startswith(p)]
def _palette_visible(self) -> bool:
"""True when the live `/`-command palette should appear and own ↑↓/Tab."""
if self._asking is not None or self._menu_active:
return False
def _slash_visible(self) -> bool:
"""True when the buffer is a live `/`-command prefix with matches."""
if '\n' in self.buf or not self.buf.startswith('/'):
return False
ms = self._cmd_matches(self.buf)
@@ -2784,19 +2987,29 @@ class SB:
return False
return True
def _palette_visible(self) -> bool:
"""True when the live palette should appear and own ↑↓/Tab — either the
`/`-command palette (buf starts with /) or the `@` file palette (cursor
sits in an @token). Same machinery, two candidate sources."""
if self._asking is not None or self._menu_active:
return False
return self._slash_visible() or bool(self._at_candidates())
def _hint_lines(self, w: int) -> list[str]:
"""Live `/`-command palette: scrollback-style list with an arrow-key
highlight + scrolling viewport. ↑↓ move the highlight (handled in
`_keys`), Tab completes the highlighted command, Enter completes into
the input box. Long match lists scroll the visible window as sel walks
past the edges (no wrap-around, no "N more" chrome)."""
"""Live palette: scrollback-style list with an arrow-key highlight +
scrolling viewport. ↑↓ move the highlight (handled in `_keys`), Tab /
Enter complete the highlighted entry into the input box. Renders the
`/`-command set when buf starts with `/`, else the `@` file candidates."""
if not self._palette_visible():
return []
ms = self._cmd_matches(self.buf)
total = len(ms)
if self._slash_visible():
rows = [f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}'
for n, a, d in self._cmd_matches(self.buf)]
else:
rows = [' @ ' + p for p in self._at_candidates()]
total = len(rows)
# Cap palette viewport to 6 rows so it doesn't squeeze the input box.
visible = min(total, 6)
# Clamp sel + slide scroll so sel is in-window.
if self._palette_sel < 0 or self._palette_sel >= total:
self._palette_sel = 0
self._palette_scroll = self._scroll_window(self._palette_sel, total, visible, self._palette_scroll)
@@ -2804,21 +3017,27 @@ class SB:
end = min(total, start + visible)
out: list[str] = []
for i in range(start, end):
n, a, d = ms[i]
# `<11` + literal space guarantees a visible gap between the
# 10-char names (/conductor, /morphling, /scheduler) and their
# `[arg]` placeholder — without it they render as `/conductor[task]`.
row = f' {n:<11} {a:<8} {d}' if a else f' {n:<11} {d}'
if i == self._palette_sel:
out.append(_ACCENT + _BOLD + _clip_cells(row, w) + _RST)
else:
out.append(_DIM + _clip_cells(row, w) + _RST)
styled = (_ACCENT + _BOLD) if i == self._palette_sel else _DIM
out.append(styled + _clip_cells(rows[i], w) + _RST)
return out
def _tab(self) -> None:
if self._asking is not None or self._menu_active:
return
if '\n' in self.buf or not self.buf.startswith('/'):
# @ 文件补全:用选中候选替换光标处的 @token(format_pick 加引号/尾空格)。
if not self._slash_visible():
items = self._at_candidates()
act = self._at_active()
if not items or act is None:
return
idx = self._palette_sel if 0 <= self._palette_sel < len(items) else 0
rep = at_complete.format_pick(items[idx])
at_pos = act[1]
self.buf = self.buf[:at_pos] + rep + self.buf[self.pos:]
self.pos = at_pos + len(rep)
self._palette_sel = 0
self._palette_scroll = 0
self._at_cache = None
return
ms = self._cmd_matches(self.buf)
if not ms:
@@ -3008,9 +3227,14 @@ class SB:
# Use PTK's current viewport height (set on SB._h by the render loop).
h = max(8, getattr(self, '_h', 24))
visible = min(total, self._menu_visible_count(h))
# Clamp sel and recompute scroll so sel is in-window.
self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0
self._menu_scroll = self._scroll_window(self._menu_sel, total, visible, self._menu_scroll)
# Clamp sel and recompute scroll so sel is in-window. Filterable menus
# allow sel == -1 (focus on the input row); clamp candidates to range
# but leave -1 intact. _scroll_window treats -1 as "top".
if self._menu_filterable:
self._menu_sel = -1 if self._menu_sel < 0 else (min(total - 1, self._menu_sel) if total else -1)
else:
self._menu_sel = max(0, min(total - 1, self._menu_sel)) if total else 0
self._menu_scroll = self._scroll_window(max(0, self._menu_sel), total, visible, self._menu_scroll)
start = self._menu_scroll
end = min(total, start + visible)
@@ -3051,6 +3275,22 @@ class SB:
return r
rows = [top]
# Filterable menu: a search line above the rows, showing the live query
# with a caret. Empty match set renders a hint instead of blank.
if self._menu_filterable:
q = self._menu_query
focused = (self._menu_sel < 0) # 焦点在输入框(可被 ↑↓ 选中的对象)
# 整行单一 style(纯文本交给 row,颜色走 style 参)——内嵌 ANSI 会让
# row() 的 pad=content_w-cell_len(ch) 把转义算进宽度,右边框就错位。
# focused → 整行 accent(箭头+占位/输入都高亮,焦点明显);否则 dim。
if q:
text = '› ' + q + ('▏' if focused else '')
else:
text = '› ' + _t('menu.search')
rows.extend(row(text, _ACCENT if focused else _DIM))
if not self._menu_options:
hint = (_t('menu.free.hint') if (self._menu_free and q.strip()) else _t('menu.no_match'))
rows.extend(row(hint, _DIM))
# Viewport scrolls as sel walks past the edges; the title's N/total tag
# signals position, so no "N more" indicator rows.
for i in range(start, end):
@@ -4018,17 +4258,20 @@ class SB:
imgs = [self._imgs[i] for i in
(int(m.group(1)) for m in _IMG_PH_RE.finditer(raw)) if i in self._imgs]
expanded = self._expand(raw)
# @ mentions: agent 收绝对路径(file_read 相对自身 cwd,否则找不到),
# scrollback 显示相对(简洁)。仅改路径根、不读内容。
agent_text = at_complete.absolutize_mentions(expanded, self._at_root()) if "@" in expanded else expanded
if self._running:
wrapped = _t('pending.inject_wrap', text=expanded)
wrapped = _t('pending.inject_wrap', text=agent_text)
if self._bridge and self._bridge.inject_intervene(wrapped, track=True):
self._pending.append(expanded)
self._pending.append(agent_text)
self._commit_user(_t('pending.queued_marker', text=expanded))
self._pstore.clear(); self._fstore.clear(); self._imgs.clear()
self._render_live()
return
# Agent went idle in the race — fall through to put_task.
self._commit_user(expanded) # scrollback shows exactly what
self._submit(expanded, imgs) # the agent receives, not the
self._commit_user(expanded) # scrollback 显示相对
self._submit(agent_text, imgs) # agent 收绝对
self._pstore.clear(); self._fstore.clear(); self._imgs.clear() # drop placeholders
def _cost_section(self, tname: str, t, be) -> list[str]:
@@ -4073,6 +4316,87 @@ class SB:
self._blocks = []; self._streaming_block = None; self._sent = 0
self._tool_base = 0; self._tools = {}
# ── workspace(与 v2 共用 workspace_cmd;单会话 → 进程级一个绑定)─────────
def _bind_workspace(self, info) -> None:
"""绑定 / 解绑 workspace。info=prepare() 的结果 dict → 绑定;None → 解绑。
同步 agent 的 project_mode 属性(插件据此注入项目上下文),刷新 @ 索引根。"""
ag = self._bridge.agent if self._bridge else None
if info:
self._ws_name = info.get('name') or ''
self._ws_path = info.get('target') or info.get('path') or ''
self._ws_link = info.get('link') or ''
pm_name, pm_path = (self._ws_name or None), self._ws_path
else:
self._ws_name = self._ws_path = self._ws_link = ''
pm_name, pm_path = None, ''
self._at_cache = None # 索引根可能变了,@ 候选缓存失效
if ag is not None:
try:
ag._ga_project_mode_name = pm_name
ag._ga_project_mode_workspace_path = pm_path
except Exception:
pass
# 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。
workspace_cmd.session_ws_set(getattr(ag, "log_path", "") or "", pm_path or "")
at_complete.get_index(self._at_root()).warm() # 预热新根(或 CWD)
def _do_workspace_activate(self, path: str) -> str:
r = workspace_cmd.prepare(path)
if not r.get('ok'):
return _t('ws.fail', default='❌ workspace 设定失败: {e}').format(e=r.get('error'))
self._bind_workspace(r)
# 显示名去 hash:真实目录 basename,退回剥 name 尾 hash。
disp = (os.path.basename((r.get('target') or '').rstrip('/\\'))
or re.sub(r'-[0-9a-f]{8}$', '', r.get('name') or ''))
out = _t('ws.entered', default='✅ 已进入 workspace「{n}」').format(n=disp)
if r.get('warning'):
out += ' ⚠ ' + r['warning']
return out
def _cmd_workspace(self, arg: str) -> None:
arg = (arg or '').strip()
if arg.lower() == 'off':
if self._ws_name:
self._bind_workspace(None)
self.commit([_DIM + _t('ws.exited',
default='已退出 workspace(项目模式关闭;junction 与文件保留)') + _RST])
else:
self.commit([_DIM + _t('ws.inactive', default='当前未处于 workspace 模式') + _RST])
return
if arg: # 直接路径:设定/进入
self.commit([self._do_workspace_activate(arg)])
return
# 无参 → 菜单选已登记 workspace(去 hash / 名称末尾省略 / 路径中间省略)。
items = workspace_cmd.registry_list()
if not items:
self.commit([_DIM + _t('ws.none',
default='暂无已登记 workspace;用 /workspace <绝对路径> 新建/进入') + _RST])
return
options: list[str] = []
paths: list[str] = []
fkeys: list[str] = []
for it in items:
disp = (os.path.basename((it['path'] or '').rstrip('/\\'))
or re.sub(r'-[0-9a-f]{8}$', '', it['name']))
age = _rel(it['last_used']) if it['last_used'] else '—'
mem = (f"{it['mem_lines']}行" if it['mem_lines'] else '空')
flag = ' ⚠' if it['dangling'] else ''
options.append(f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}")
paths.append(it['path'])
# 搜索键含完整路径(显示中间省略,但搜索看完整 — 与 v2 _filter_choices 一致)。
fkeys.append(f"{disp} {it['path']}")
def _pick(idx: int) -> None:
self.commit([self._do_workspace_activate(paths[idx])])
def _free(q: str) -> None: # 输入框内回车一个绝对路径 → 新建/进入
self.commit([self._do_workspace_activate(q)])
self._show_menu(_t('ws.pick.title',
default='选择 workspace(输入过滤 / 绝对路径回车新建 · ↑↓ 选 · Esc 取消)'),
options, _pick, filterable=True, free_input=True,
on_free=_free, filter_keys=fkeys)
def _cmd(self, raw: str) -> None:
assert self._bridge is not None
parts = raw[1:].split(None, 1)
@@ -4170,6 +4494,24 @@ class SB:
else:
self._commit_assistant(c)
self.commit([_DIM + '┄┄ ' + _t('msg.continue_ready', msg=msg) + ' ┄┄' + _RST])
# 自动恢复 workspace:续接的会话若在某个已登记 workspace 里工作过,
# 重新绑定(必要时重建 junction),不触碰 project_mode 的进程锚。
self._bind_workspace(None)
try:
rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录)
if rec is not None:
ws_path = rec or None # "" → 该会话已 off,明确不恢复
else:
info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志
ws_path = info['path'] if info else None
if ws_path:
r = workspace_cmd.prepare(ws_path)
if r.get('ok'):
self._bind_workspace(r)
self.commit([_DIM + '⌂ ' + _t('ws.restored',
default='已恢复工作目录: {t}').format(t=r['target']) + _RST])
except Exception:
pass
if arg:
# Direct numeric argument still supported for power users / scripts.
@@ -4207,6 +4549,8 @@ class SB:
_do_restore(sess[idx][0])
self._show_menu(_t('continue.title'), options, _pick_session)
elif name == 'workspace':
self._cmd_workspace(arg)
elif name == 'clear':
self._reset_session(ag)
self.commit([_DIM + _t('msg.cleared') + _RST])
@@ -5129,11 +5473,28 @@ class SB:
if self._menu_active:
n = len(self._menu_options)
if o == 0x10: # ↑
if n:
if self._menu_filterable:
# ring [input(-1), 0..n-1]: ↑ from input → last cand;
# ↑ from first cand → input; else step up.
if self._menu_sel < 0:
self._menu_sel = n - 1 if n else -1
elif self._menu_sel == 0:
self._menu_sel = -1
else:
self._menu_sel -= 1
elif n:
self._menu_sel = max(0, self._menu_sel - 1)
self._render_live(); continue
if o == 0x0e: # ↓
if n:
if self._menu_filterable:
# ↓ from input → first cand; ↓ from last cand → input.
if self._menu_sel < 0:
self._menu_sel = 0 if n else -1
elif self._menu_sel >= n - 1:
self._menu_sel = -1
else:
self._menu_sel += 1
elif n:
self._menu_sel = min(n - 1, self._menu_sel + 1)
self._render_live(); continue
if self._menu_multi and ch == ' ': # Space toggles
@@ -5156,6 +5517,19 @@ class SB:
# a one-handed rollback without reaching for Esc.
# Menus with no on_cancel just dismiss.
self._menu_cancel(); continue
# Filterable menu (e.g. /workspace): printable chars + Backspace
# edit a live query that filters rows; everything else (arrows
# 0x10/0x0e/0x02/0x06 are < 0x20) still falls through to swallow.
if self._menu_filterable:
if o in (0x08, 0x7f): # Backspace / Del
if self._menu_query:
self._menu_query = self._menu_query[:-1]
self._menu_apply_filter()
self._render_live(); continue
if o >= 0x20 and o != 0x7f: # printable → append to query
self._menu_query += ch
self._menu_apply_filter()
self._render_live(); continue
# swallow everything else while the menu is up
continue
# ── ask_user picker key intercept ───────────────────────────────
@@ -5228,7 +5602,7 @@ class SB:
self._stash_draft()
elif o == 0x0e: # ↓ visual-row down (history at bottom)
if self._palette_visible():
n = len(self._cmd_matches(self.buf))
n = self._palette_total()
self._palette_sel = min(n - 1, self._palette_sel + 1) if n else 0
else:
self._sel = None
@@ -5589,6 +5963,11 @@ def main(argv: list[str] | None = None) -> int:
print(_t('err.no_tty'))
return 1
_sweep_stale_task_dirs() # clear empty signal dirs left by prior runs
try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions
except Exception: pass
try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone
except Exception: pass
at_complete.get_index(os.path.join(_ROOT, "temp")).warm() # @ 补全:预热未绑时的默认根(temp)
SB().run()
return 0
+321 -11
View File
@@ -368,6 +368,49 @@ def _cell_tail(s: str, n: int) -> str:
return "…" + "".join(reversed(out))
def _cell_head(s: str, n: int) -> str:
"""Keep the head of `s` within n cells, suffixing … if truncated.
(省略末尾 — names: keep the start, drop the tail.)"""
from rich.cells import cell_len
if cell_len(s) <= n:
return s
if n <= 1:
return "…"
out, w = [], 0
for ch in s:
c = cell_len(ch)
if w + c > n - 1:
break
out.append(ch); w += c
return "".join(out) + "…"
def _cell_mid(s: str, n: int) -> str:
"""Keep head+tail of `s` within n cells, eliding the middle with ….
(省略中间 — paths: the project root and the leaf both stay visible.)"""
from rich.cells import cell_len
if cell_len(s) <= n:
return s
if n <= 1:
return "…"
avail = n - 1 # reserve one cell for …
head_budget = avail - avail // 2 # head gets the rounding bias
tail_budget = avail // 2
head, w = [], 0
for ch in s:
c = cell_len(ch)
if w + c > head_budget:
break
head.append(ch); w += c
tail, w = [], 0
for ch in reversed(s):
c = cell_len(ch)
if w + c > tail_budget:
break
tail.append(ch); w += c
return "".join(head) + "…" + "".join(reversed(tail))
class _CardWriter:
"""Accumulates a tool card's two parallel streams: `ansi` (narrow, colored,
later margined) and `plain` (wide, the copy source — no margin). `row()`
@@ -1361,6 +1404,16 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
return "".join(source_parts).rstrip("\n"), line_starts, line_indents, line_lengths
# ---------------------------------------------------------------------------
# @ 文件引用(at-mention)— 补全版(completion-only)
# 编辑期:光标处 @token → 后台文件索引 + 模糊匹配 → 复用 #palette 下拉,
# 选中把 @路径 补进输入框(索引根 = 会话 workspace,未绑定退化为 CWD)。
# 提交期:不处理,@路径 作为普通文本发给 agent,由其自行决定是否 file_read。
# 纯逻辑(索引/模糊/token)抽到 frontends/at_complete.py,与 tui_v3 共用;
# 自动预读那一版见 temp/plan_v2_at_mention/autoread_version.py。
from at_complete import get_index, fuzzy_rank, find_at_token, format_pick, candidates_for, absolutize_mentions
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
@@ -1397,6 +1450,7 @@ from chatapp_common import format_restore
from btw_cmd import handle_frontend_command as btw_handle
from review_cmd import handle as review_handle
from continue_cmd import list_sessions as continue_list, extract_ui_messages as continue_extract
import workspace_cmd
from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard
# Cross-platform clipboard copy for /export clip. Mirrors tui_v3's native-tool
@@ -1945,6 +1999,10 @@ class AgentSession:
input_pastes: dict[int, str] = field(default_factory=dict)
input_paste_counter: int = 0
buffer: str = ""
# Per-session workspace/project-mode binding. Empty means ordinary mode.
workspace_name: str = ""
workspace_path: str = ""
workspace_link: str = ""
# Drives topbar heat-color ramp + elapsed label; set on first running tick.
_busy_since: Optional[float] = None
# Stamps running→idle; topbar dot flashes green for ~5s after.
@@ -2011,6 +2069,7 @@ COMMANDS = [
("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"),
("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"),
("/continue", "[n|name]", "列出 / 恢复历史会话"),
("/workspace","[path|off]", "设定工作目录(绝对路径)并进入项目模式"),
("/resume", "", "列出最近会话并恢复其中一个"),
("/cost", "[all]", "显示当前会话 token 用量(all = 所有会话)"),
("/export", "clip|<file>|all", "导出最后回复"),
@@ -2042,9 +2101,25 @@ class ChoiceList(OptionList):
Binding("escape", "cancel", "Cancel", show=False)]
def __init__(self, msg: "ChatMessage", *options, **kwargs):
super().__init__(*options, **kwargs)
super().__init__(*[self._single_line(o) for o in options], **kwargs)
self.msg = msg
@staticmethod
def _single_line(item):
# A str prompt → a no-wrap, ellipsis-on-overflow Option so a long
# candidate (e.g. a workspace `name · /very/long/path · …`) stays on
# exactly one row instead of soft-wrapping into several. Already-built
# Option / None pass through untouched.
if isinstance(item, str):
return Option(Text(item, no_wrap=True, overflow="ellipsis"))
return item
def add_option(self, option=None):
return super().add_option(self._single_line(option))
def add_options(self, items):
return super().add_options([self._single_line(i) for i in items])
def action_cancel(self) -> None:
try:
self.app._cancel_choice(self.msg)
@@ -2110,7 +2185,7 @@ class LazyChoiceList(ChoiceList):
take = (len(self._lazy_labels) - self._lazy_loaded) if count is None else max(1, int(count))
end = min(len(self._lazy_labels), self._lazy_loaded + take)
try:
self.add_options([Option(self._lazy_labels[i]) for i in range(self._lazy_loaded, end)])
self.add_options([self._lazy_labels[i] for i in range(self._lazy_loaded, end)])
except Exception:
# If the list isn't mounted yet (very early call), fall back to
# buffering via _options if available; otherwise silently bail so
@@ -2172,7 +2247,10 @@ def _filter_choices(all_choices: list, query: str) -> list:
`all_choices` is `[(label, value), ...]`. Each whitespace-separated token
in `query` must hit somewhere in either:
* the label text (cheap, always tried first), or
* the basename of `value` when it looks like a path, or
* the **full** `value` when it's a string (e.g. a workspace's complete
real path — so a mid-path directory still matches even though the
displayed label elides the middle; display-layer truncation must not
shrink the searchable data), or
* the **content** of the session file at `value` (first ~1MB), so users
who remember a phrase from inside a session ("Conductor", "subB diff",
a file path they pasted) can find it back.
@@ -2206,7 +2284,10 @@ def _filter_choices(all_choices: list, query: str) -> list:
continue
meta = str(label).lower()
if isinstance(value, str) and value:
meta = meta + "\n" + os.path.basename(value).lower()
# Full value, not just basename: workspace pickers put the complete
# real path here, and the displayed label elides the middle — search
# must see the whole path so a mid-path term still matches.
meta = meta + "\n" + value.lower()
if all(t in meta for t in terms):
out.append(item)
continue
@@ -2778,8 +2859,25 @@ class InputArea(TextArea):
text = f"[Pasted text #{sid} +{line_count} lines]"
self._insert_via_keyboard(text)
def _paste_gesture_echo(self, source: str) -> bool:
"""One VSCode right-click can emit BOTH a forwarded mouse-click
(→ action_paste, source='manual') and a native bracketed paste
(→ _on_paste, source='bracketed'), pasting the clipboard twice. Treat the
second arrival from the *other* mechanism within a short window as an echo
and report it so the caller can skip. Same-mechanism repeats (a deliberate
double Ctrl+V) and lone gestures are never suppressed."""
now = time.monotonic()
prev = self._last_paste_gesture
if prev and prev[0] != source and now - prev[1] < 0.5:
self._last_paste_gesture = None # pair consumed; next gesture starts clean
return True
self._last_paste_gesture = (source, now)
return False
def action_paste(self) -> None:
if self.read_only or self._paste_file_from_clipboard():
if self.read_only or self._paste_gesture_echo("manual"):
return
if self._paste_file_from_clipboard():
return
text = _read_clipboard_text() or getattr(self.app, "clipboard", "")
if text:
@@ -2850,6 +2948,7 @@ class InputArea(TextArea):
super().__init__(*args, **kwargs)
self._pastes: dict[int, str] = {}
self._paste_counter = 0
self._last_paste_gesture: Optional[tuple[str, float]] = None # (source, monotonic) — VSCode right-click double-paste guard
self._input_history: list[str] = []
self._history_index: int = -1 # -1 means not browsing
self._history_stash: str = ""
@@ -2948,16 +3047,20 @@ class InputArea(TextArea):
# Terminal Ctrl+V in bracketed-paste mode lands here, bypassing action_paste.
if self.read_only:
return
event.stop(); event.prevent_default()
# VSCode right-click fires this Paste AND a forwarded mouse-click
# (→ _on_click → action_paste); collapse the duplicate. See _paste_gesture_echo.
if self._paste_gesture_echo("bracketed"):
return
if self._paste_file_from_clipboard():
event.stop(); event.prevent_default(); return
return
# Git-bash / mintty fallback: PIL.ImageGrab can't return Image objects
# in that TTY env, but the OS clipboard does hold the file path the
# screenshot tool wrote. Treat a single-line, on-disk path as if the
# file grab had succeeded — same placeholder + `_pastes` entry.
if self._paste_file_from_text(event.text):
event.stop(); event.prevent_default(); return
return
self._insert_paste_text(event.text)
event.stop(); event.prevent_default()
def _paste_file_from_text(self, raw: str) -> bool:
if not raw: return False
@@ -3101,7 +3204,8 @@ def render_status_chip(busy: bool, elapsed: int = 0) -> Text:
def render_topbar(session_name: str, status: str, model: str, tasks_running: int,
fold_mode: bool = True, busy_elapsed: int = 0,
effort: str = "", sess_elapsed: int = 0,
just_done: bool = False, term_width: int = 0) -> Table:
just_done: bool = False, term_width: int = 0,
workspace: str = "") -> Table:
# Layout: identity-chip + session + status + fold packed LEFT; model + effort
# + tasks CENTERED; clock RIGHT. The 2:2:1 ratio keeps the centered model
# chip visually anchored even when the left column has the long status pill.
@@ -3154,6 +3258,13 @@ def render_topbar(session_name: str, status: str, model: str, tasks_running: int
# narrow `▾ fold` glyph from being eaten by the left's ellipsis when the
# running status pill fills the column budget.
right = Text()
# workspace chip (top-right) — only when active. Clean real-dir basename,
# never the hashed junction name, so the hash never reaches the user.
if workspace:
short_ws = workspace if len(workspace) <= 18 else workspace[:17] + "…"
right.append("workspace: ", style=C_MUTED)
right.append(short_ws, style=f"bold {C_GREEN}")
right.append(" · ", style=C_DIM)
if fold_mode:
right.append("▾ fold", style=C_DIM)
right.append(" · ", style=C_DIM)
@@ -3458,6 +3569,7 @@ class GenericAgentTUI(App[None]):
"export": self._cmd_export,
"restore": self._cmd_restore, "btw": self._cmd_btw, "review": self._cmd_review,
"continue": self._cmd_continue, "cost": self._cmd_cost,
"workspace": self._cmd_workspace,
"reload-keys": self._cmd_reload_keys,
# slash_cmds bundle — see frontends/slash_cmds.py for the prompt
# bodies + reflect/scheduler discovery. All but /scheduler are
@@ -3550,6 +3662,11 @@ class GenericAgentTUI(App[None]):
def on_mount(self) -> None:
_sweep_stale_task_dirs() # clear empty signal dirs left by prior runs
try: workspace_cmd.cleanup() # remove dangling/unregistered workspace junctions
except Exception: pass
try: workspace_cmd.session_map_prune() # drop session→ws entries whose log is gone
except Exception: pass
get_index(os.path.join(ROOT_DIR, "temp")).warm() # @ 补全:预热未绑时的默认根(temp)
self.add_session("main")
self._system(f"Welcome to GenericAgent TUI. 按 / 唤起命令面板,{fmt_key('ctrl+n')} 新建会话。")
@@ -3660,6 +3777,15 @@ class GenericAgentTUI(App[None]):
f'_tui_v2_{os.getpid()}_{agent_id}')
except Exception:
pass
try:
# Opt TUI v2 agents into per-agent project-mode selection. The
# plugin falls back to the legacy pid anchor only when this
# private attribute is absent, so None here means "ordinary mode"
# for this session rather than "use the process-global workspace".
agent._ga_project_mode_name = None
agent._ga_project_mode_workspace_path = ""
except Exception:
pass
sess = AgentSession(agent_id=agent_id, name=name or f"agent-{agent_id}", agent=agent)
thread = threading.Thread(target=agent.run, name=f"ga-tui-agent-{agent_id}", daemon=True)
thread.start()
@@ -3672,6 +3798,36 @@ class GenericAgentTUI(App[None]):
self._refresh_all()
return sess
def _bind_workspace(self, sess: AgentSession, info: Optional[dict]) -> None:
if info:
sess.workspace_name = info.get("name") or ""
sess.workspace_path = info.get("target") or info.get("path") or ""
sess.workspace_link = info.get("link") or ""
project_name = sess.workspace_name or None
project_path = sess.workspace_path
else:
sess.workspace_name = ""
sess.workspace_path = ""
sess.workspace_link = ""
project_name = None
project_path = ""
try:
sess.agent._ga_project_mode_name = project_name
sess.agent._ga_project_mode_workspace_path = project_path
except Exception:
pass
# 持久化绑定/off → /continue 即时恢复,不必先聊一轮留 PROJECT MODE 块。
workspace_cmd.session_ws_set(getattr(sess.agent, "log_path", "") or "", project_path or "")
if project_path:
get_index(project_path).warm() # @ 候选跟随 workspace
def _at_root(self, sess: Optional["AgentSession"] = None) -> str:
# @ 索引根:绑了 workspace 用真实 target;否则用 agent 的实际工作目录
# ROOT_DIR/temp(file_read/code_run 都相对它),而非飘忽的 os.getcwd()。
# 一律真实路径,绝不暴露哈希 junction 名。
s = sess or (self.sessions.get(self.current_id) if self.current_id is not None else None)
return (s.workspace_path if s and s.workspace_path else os.path.join(ROOT_DIR, "temp"))
_write_snapshot_hook_installed = False
def _install_write_snapshot_hook(self) -> None:
@@ -4263,9 +4419,45 @@ class GenericAgentTUI(App[None]):
if first_line.startswith("/") and " " not in first_line and "\n" not in val:
self._populate_palette(first_line)
self._show_palette()
return
# @ file mention: reuse the same palette for path candidates when the
# cursor sits inside an `@token` (claude-code parity, workspace-rooted).
try:
row, col = inp.cursor_location
line = inp.document.get_line(row)[:col]
except Exception:
line = ""
tok = find_at_token(line)
if tok is not None:
self._populate_at_palette(tok[0])
else:
self._hide_palette()
def _populate_at_palette(self, query: str) -> None:
sess = self.sessions.get(self.current_id)
unbound = not (sess and sess.workspace_path) # 未绑 workspace → 根是 temp,显示完整路径
matches = candidates_for(query, self._at_root(), absolute=unbound)
palette = self.query_one("#palette", OptionList)
palette.clear_options()
if not matches:
self._hide_palette()
return
for path in matches:
t = Text()
if unbound: # 未绑:整条完整路径(根不直观)
t.append(path)
else: # 绑 workspace:base + 父目录(短)
# 目录候选末尾带 '/',先剥掉再拆 base,否则 rsplit 得到空串 → 空白行。
is_dir = path.endswith("/")
core = path.rstrip("/")
parent, name = core.rsplit("/", 1) if "/" in core else ("", core)
base = name + ("/" if is_dir else "")
t.append(base, style="bold")
if parent:
t.append(f" {parent}", style=C_MUTED)
palette.add_option(Option(t, id=f"at:{path}"))
self._show_palette()
def _resize_input(self, inp: TextArea) -> None:
# wrapped_document.height counts soft-wrapped lines; document.line_count only logical.
try:
@@ -4335,6 +4527,15 @@ class GenericAgentTUI(App[None]):
# forward the literal so the agent recovers context.
self.submit_user_message(text)
return
# @ mentions (completion-only): rewrite @relative → @absolute so the
# agent's file_read can locate it; scrollback keeps the short form via
# display_text. No content is read here. (Content-injecting auto-read
# variant: temp/plan_v2_at_mention/autoread_version.py.)
if "@" in text:
abs_text = absolutize_mentions(text, self._at_root())
if abs_text != text:
self.submit_user_message(abs_text, images=images, display_text=text)
return
self.submit_user_message(text, images=images)
def _show_palette(self) -> None:
@@ -4363,6 +4564,24 @@ class GenericAgentTUI(App[None]):
ol = event.option_list
if ol.id == "palette":
cmd_id = event.option.id
if cmd_id and cmd_id.startswith("at:"):
# @ candidate accepted: replace the in-progress @token with the
# picked path (quoted when it contains spaces), cursor to end.
inp = self.query_one("#input", InputArea)
try:
row, col = inp.cursor_location
line = inp.document.get_line(row)[:col]
tok = find_at_token(line)
if tok is not None:
rep = format_pick(cmd_id[3:])
self._suppress_palette_open = True
inp.replace(rep, (row, tok[1]), (row, col))
inp.move_cursor((row, tok[1] + len(rep)))
except Exception:
pass
self._hide_palette()
inp.focus()
return
if cmd_id:
inp = self.query_one("#input", InputArea)
needs_args = any(c[1] for c in COMMANDS if c[0] == cmd_id)
@@ -4633,6 +4852,12 @@ class GenericAgentTUI(App[None]):
new.agent.llmclient.backend.history = copy.deepcopy(old.agent.llmclient.backend.history)
except Exception as e:
self._system(f"Branch warning: {e}"); return
if old.workspace_name:
self._bind_workspace(new, {
"name": old.workspace_name,
"target": old.workspace_path,
"link": old.workspace_link,
})
# deepcopy(old.messages) trips on mounted Textual widget refs; shallow-copy each
# ChatMessage and null out widget/cache fields so the new session re-mounts cleanly.
new.messages = []
@@ -5118,11 +5343,93 @@ class GenericAgentTUI(App[None]):
session_names.migrate(path, current_log)
except Exception:
pass
# Auto-restore workspace: if the continued session worked in a
# registered workspace, bind it to this session (recreating the
# junction if needed) without touching the legacy process anchor.
self._bind_workspace(self.current, None)
try:
rec = workspace_cmd.session_ws_get(path) # 路径 / "" (off) / None(无记录)
if rec is not None:
ws_path = rec or None # "" → 该会话已 off,明确不恢复
else:
info = workspace_cmd.workspace_from_log(path) # 老会话:回退扫日志
ws_path = info["path"] if info else None
if ws_path:
r = workspace_cmd.prepare(ws_path)
if r.get("ok"):
self._bind_workspace(self.current, r)
self._system(f"⌂ 已恢复工作目录: {r['target']}")
else:
self._system(f"⚠ workspace 恢复失败: {r.get('error')}")
except Exception:
pass
self._remount_current_session()
self._refresh_all()
self.call_after_refresh(_finish)
return result.splitlines()[0] if result else "✅ 已恢复"
def _cmd_workspace(self, args, raw):
# /workspace <abs path> | /workspace off | /workspace (picker).
# Path may contain spaces (Windows) → capture the whole tail.
m = re.match(r"/workspace\s+(\S.*?)\s*$", (raw or "").strip())
if m:
token = m.group(1)
if token.lower() == "off":
sess = self.current
if sess.workspace_name:
self._bind_workspace(sess, None)
self._system("已退出 workspace(项目模式关闭;junction 与文件保留)")
else:
self._system("当前未处于 workspace 模式")
self._refresh_topbar()
return
# 直接路径无 picker 面包屑,自己显示一条。
self._system(self._do_workspace_activate(token))
return
# No arg → searchable picker: free-text input (type an abs path to
# create/enter) over a candidate list of registered workspaces.
sess = self.current
choices = []
for it in workspace_cmd.registry_list():
age = _short_age(it["last_used"]) if it["last_used"] else "—"
mem = f"{it['mem_lines']}行记忆" if it["mem_lines"] else "空"
flag = " ⚠失效" if it["dangling"] else ""
# 显示名取真实目录 basename(天然不含 junction 的 -hash8 后缀);
# dangling 无 path 时退回剥掉 name 尾部 hash。名称省略末尾、路径
# 省略中间,整行经 ChoiceList 单行渲染不会折行。
disp = os.path.basename((it["path"] or "").rstrip("/\\")) \
or re.sub(r"-[0-9a-f]{8}$", "", it["name"])
label = f"{_cell_head(disp, 22)} · {_cell_mid(it['path'], 46)} · {age} · {mem}{flag}"
choices.append((label, it["path"]))
head = ("指定工作目录(输入绝对路径回车新建/进入,或从下方选择已有 · "
"↑/↓ 移动,→/Enter 确认,Esc 取消)")
msg = ChatMessage(
role="system", content=head, kind="choice", choices=choices,
on_select=lambda v: self._do_workspace_activate(v),
)
msg.searchable = True
msg.free_input = True # Enter on a typed abs path commits it as a new workspace
msg.all_choices = list(choices)
sess.messages.append(msg)
self._refresh_messages()
def _do_workspace_activate(self, path: str) -> str:
# 唯一展示文本 = 返回值:picker 路径由 _collapse_choice 渲染成 `✓ …`
# 面包屑;直接 `/workspace <path>` 路径由 _cmd_workspace 用 _system 显示。
# 两条路径各出一条,故此处不再自行 _system(否则与面包屑重复)。
r = workspace_cmd.prepare(path)
if not r.get("ok"):
return f"❌ workspace 设定失败: {r.get('error')}"
self._bind_workspace(self.current, r)
self._refresh_topbar()
# 显示名去 hash(与 picker 一致):真实目录 basename,退回剥 name 尾 hash。
disp = os.path.basename((r.get("target") or "").rstrip("/\\")) \
or re.sub(r"-[0-9a-f]{8}$", "", r.get("name") or "")
out = f"✅ 已进入 workspace「{disp}」"
if r.get("warning"):
out += f" ⚠ {r['warning']}"
return out
def _cmd_cost(self, args, raw):
try:
import cost_tracker
@@ -6268,11 +6575,14 @@ class GenericAgentTUI(App[None]):
self._chip_timer = None
try: term_w = self.size.width
except Exception: term_w = 0
# Workspace label is per-session, not the legacy process-global anchor.
p = (s.workspace_path or "").rstrip("/\\")
ws_name = os.path.basename(p) if p else s.workspace_name
self.query_one("#topbar", Static).update(
render_topbar(s.name, s.status, model, tasks_running,
fold_mode=self.fold_mode, busy_elapsed=elapsed, effort=effort,
sess_elapsed=sess_elapsed, just_done=just_done,
term_width=term_w))
term_width=term_w, workspace=ws_name))
self._ensure_title_timer()
self._update_terminal_title()
@@ -6848,7 +7158,7 @@ class GenericAgentTUI(App[None]):
else:
widget = ChoiceList(m, classes="picker")
for cl, _ in m.choices:
widget.add_option(Option(cl))
widget.add_option(cl)
# `searchable` wraps the freshly-built picker in a Vertical
# container with an Input filter on top. The original picker
# is preserved as `.picker` so `_active_choice`, key routing
+478
View File
@@ -0,0 +1,478 @@
"""Workspace 命令的共享逻辑(tuiapp_v2 / tui_v3 复用)。
设计要点(详见对话设计稿):
* **兼容旧入口** `plugins/project_mode.py` 与 `memory/project_mode_sop.md` 的 pid 锚。
前端在
`<repo>/temp/projects/<name>` 建一个指向用户真实绝对路径的目录联接(junction),
并可按需写激活锚 `<repo>/temp/.active_project.<pid>`。project_mode 插件
照常每轮注入 L1,并把 project_memory.md / 产物经 junction 写进真实仓库根
(与 Claude Code 在仓库根放 CLAUDE.md 同理,已接受)。
* **路径基准必须与插件一致**:插件的 `_TEMP` 是基于其 `__file__` 的 `<repo>/temp`
绝对路径(非 cwd)。本模块也从自身 `__file__` 推 `<repo>/temp`(frontends/ 的上一级
即 repo 根),两边独立计算但结果一致,互不 import。
* **pid 语义**:插件读 `os.getpid()`(GA 进程)。前端就跑在 GA 进程里,写锚同样用
`os.getpid()`(不是 SOP 里 code_run 子进程用的 getppid)。
* **命名** `name = f"{basename}-{hash8}"`,hash8 = blake2b(规范化绝对路径)[:8]。
同一 workspace 恒定同名(幂等复用);hash 后缀又让 junction 名不与其它 UI 人工命名的
普通项目目录相撞。
* **junction 安全**:检测用 reparse 属性(`os.path.islink` 对 junction 返回 False!);
删除用 `os.rmdir`,**绝不 rmtree**(会击穿删真实文件)。cleanup 只动确认是 junction
且悬空/未注册的条目,真实目录(其它 UI 的普通项目)一律不碰。
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import stat
import subprocess
import sys
import time
from typing import Optional
# --------------------------------------------------------------------------- #
# 路径基准(与 plugins/project_mode.py 的 _TEMP 保持一致)
# --------------------------------------------------------------------------- #
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _temp_root() -> str:
return os.path.join(_REPO_ROOT, "temp")
def _projects_root() -> str:
return os.path.join(_temp_root(), "projects")
def _anchor_path() -> str:
"""激活锚,pid 键控,与插件 `_ANCHOR` 同。"""
return os.path.join(_temp_root(), f".active_project.{os.getpid()}")
def _registry_path() -> str:
return os.path.join(_temp_root(), "workspaces.json")
_REGISTRY_VERSION = 1
# --------------------------------------------------------------------------- #
# 命名
# --------------------------------------------------------------------------- #
def _norm_abspath(p: str) -> str:
"""规范化绝对路径用于 hash:abspath + normcase(Windows 大小写不敏感 ->
同一目录恒定同名)。不走 realpath,避免解析 junction/symlink 带来的意外。"""
return os.path.normcase(os.path.abspath(p))
def _ws_name(abs_path: str) -> str:
base = os.path.basename(abs_path.rstrip("/\\")) or "ws"
digest = hashlib.blake2b(_norm_abspath(abs_path).encode("utf-8")).hexdigest()[:8]
return f"{base}-{digest}"
def _link_path(name: str) -> str:
return os.path.join(_projects_root(), name)
# --------------------------------------------------------------------------- #
# junction / symlink 跨平台封装(reparse 安全)
# --------------------------------------------------------------------------- #
def make_dir_link(target_abs: str, link_path: str) -> bool:
"""建目录联接。Windows 用 `mklink /J`(免管理员);POSIX 用 symlink。
成功返回 True;失败打印到 stderr 并返回 False。"""
target_abs = os.path.abspath(target_abs)
parent = os.path.dirname(link_path)
try:
os.makedirs(parent, exist_ok=True)
except OSError as e:
sys.stderr.write(f"[workspace] mkdir {parent} failed: {e}\n")
return False
if os.name == "nt":
# mklink 是 cmd 内建,必须经 cmd 调用。列表传参由 subprocess 负责加引号,
# 兼容含空格/中文的路径。
try:
r = subprocess.run(
["cmd", "/c", "mklink", "/J", link_path, target_abs],
capture_output=True, text=True,
)
except OSError as e:
sys.stderr.write(f"[workspace] mklink invoke failed: {e}\n")
return False
if r.returncode != 0 or not os.path.exists(link_path):
sys.stderr.write(f"[workspace] mklink /J failed: "
f"{(r.stderr or r.stdout or '').strip()}\n")
return False
return True
# POSIX
try:
os.symlink(target_abs, link_path, target_is_directory=True)
return True
except OSError as e:
sys.stderr.write(f"[workspace] symlink failed: {e}\n")
return False
def is_dir_link(path: str) -> bool:
"""是否目录联接/符号链接。**不能只用 os.path.islink**——它对 Windows junction
返回 False。改看 reparse point 属性 + reparse tag。"""
try:
if os.path.islink(path): # POSIX symlink、Windows 符号链接
return True
except OSError:
return False
if os.name != "nt":
return False
try:
st = os.lstat(path)
except OSError:
return False
attrs = getattr(st, "st_file_attributes", 0)
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
if not (attrs & reparse):
return False
# 进一步认 tag:挂载点(junction)或符号链接
tag = getattr(st, "st_reparse_tag", 0)
mount = getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003)
syml = getattr(stat, "IO_REPARSE_TAG_SYMLINK", 0xA000000C)
if tag:
return tag in (mount, syml)
return True # 有 reparse 属性但拿不到 tag,保守视作链接(我们只在此目录建链)
def link_target(path: str) -> Optional[str]:
"""读链接目标;清洗 Windows 的 \\??\\ / \\\\?\\ 前缀。失败返回 None。"""
try:
t = os.readlink(path)
except OSError:
return None
for pre in ("\\??\\", "\\\\?\\"):
if t.startswith(pre):
t = t[len(pre):]
break
return t
def remove_dir_link(path: str) -> bool:
"""只摘掉链接本身,绝不递归删目标。Windows junction / 符号链接目录用 os.rmdir,
POSIX symlink 用 os.unlink。**调用前务必 is_dir_link 确认。**"""
try:
if os.name == "nt":
os.rmdir(path)
else:
os.unlink(path)
return True
except OSError as e:
sys.stderr.write(f"[workspace] remove link {path} failed: {e}\n")
return False
# --------------------------------------------------------------------------- #
# 注册表 temp/workspaces.json(本功能私有;v2/v3 可能并发 -> 原子写)
# --------------------------------------------------------------------------- #
def registry_load() -> dict:
try:
with open(_registry_path(), encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION:
items = data.get("items")
if isinstance(items, dict):
return items
except (OSError, ValueError):
pass
return {}
def _registry_save(items: dict) -> None:
path = _registry_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({"version": _REGISTRY_VERSION, "items": items},
fh, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
except OSError as e:
sys.stderr.write(f"[workspace] registry save failed: {e}\n")
# --------------------------------------------------------------------------- #
# 会话→工作区映射 temp/session_workspaces.json — 让 /continue 即时恢复,不必先
# 聊一轮在日志留 PROJECT MODE 块。key=会话日志名, value=workspace 真实路径,
# ""=已 off(区别于缺 key=无记录→回退扫日志)。手动操作触发、极低频,照搬注册
# 表原子写、不加锁。
# --------------------------------------------------------------------------- #
def _session_map_path() -> str:
return os.path.join(_temp_root(), "session_workspaces.json")
def _session_map_load() -> dict:
try:
with open(_session_map_path(), encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict) and data.get("version") == _REGISTRY_VERSION:
items = data.get("items")
if isinstance(items, dict):
return items
except (OSError, ValueError):
pass
return {}
def _session_map_save(items: dict) -> None:
path = _session_map_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({"version": _REGISTRY_VERSION, "items": items},
fh, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
except OSError as e:
sys.stderr.write(f"[workspace] session map save failed: {e}\n")
def session_ws_set(log_path: str, target: str) -> None:
"""记录会话绑定的 workspace 路径;target="" 表示该会话已 off。"""
key = os.path.basename(log_path or "")
if not key:
return
items = _session_map_load()
items[key] = target or ""
_session_map_save(items)
def session_ws_get(log_path: str):
"""路径=绑定 / ""=已 off / None=无记录(调用方回退扫日志)。"""
key = os.path.basename(log_path or "")
return _session_map_load().get(key) if key else None
def session_map_prune() -> None:
"""删掉日志文件已不存在的孤儿条目(启动时调一次)。"""
items = _session_map_load()
logdir = os.path.join(_temp_root(), "model_responses")
alive = {k: v for k, v in items.items() if os.path.isfile(os.path.join(logdir, k))}
if len(alive) != len(items):
_session_map_save(alive)
def registry_upsert(name: str, abs_path: str) -> None:
items = registry_load()
items[name] = {"path": os.path.abspath(abs_path), "last_used": int(time.time())}
_registry_save(items)
def registry_remove(name: str) -> None:
items = registry_load()
if items.pop(name, None) is not None:
_registry_save(items)
def _mem_lines(link: str) -> int:
"""project_memory.md 行数(经 junction 读真实文件);读不到返回 0。"""
mp = os.path.join(link, "project_memory.md")
try:
with open(mp, encoding="utf-8", errors="replace") as fh:
return sum(1 for _ in fh)
except OSError:
return 0
def registry_list() -> list[dict]:
"""供 picker 候选列表:[{name, path, last_used, mem_lines, dangling}],按最近使用倒序。"""
out = []
for name, ent in registry_load().items():
path = (ent or {}).get("path") or ""
out.append({
"name": name,
"path": path,
"last_used": int((ent or {}).get("last_used") or 0),
"mem_lines": _mem_lines(_link_path(name)) if path else 0,
"dangling": not (path and os.path.isdir(path)),
})
out.sort(key=lambda x: x["last_used"], reverse=True)
return out
# --------------------------------------------------------------------------- #
# 校验
# --------------------------------------------------------------------------- #
def validate_path(abs_path: str) -> tuple[bool, str]:
if not abs_path or not abs_path.strip():
return False, "路径为空"
p = abs_path.strip().strip('"').strip("'")
if not os.path.isabs(p):
return False, "需要绝对路径"
if os.name == "nt" and p.startswith("\\\\"):
return False, "不支持网络路径(UNC):junction 无法指向网络位置"
if not os.path.exists(p):
return False, f"路径不存在: {p}"
if not os.path.isdir(p):
return False, "不是目录"
if _norm_abspath(p).startswith(_norm_abspath(_temp_root())):
return False, "该路径已在 temp 内,无需 workspace"
return True, ""
# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #
def prepare(abs_path: str) -> dict:
"""准备 workspace,但不写进程级激活锚。返回:
{ok, name, link, target, mem_text, warning, error}。
流程:校验 -> name -> 幂等建链 -> 确保 project_memory.md 存在 -> 注册 -> 回读记忆。
TUI 多会话隔离使用本函数,避免多个 session 争用同一个 pid 锚。"""
p = abs_path.strip().strip('"').strip("'") if abs_path else ""
ok, msg = validate_path(p)
if not ok:
return {"ok": False, "error": msg}
target = os.path.abspath(p)
name = _ws_name(target)
link = _link_path(name)
warning = ""
# 幂等建链
if os.path.lexists(link):
if is_dir_link(link):
cur = link_target(link)
if cur and _norm_abspath(cur) == _norm_abspath(target):
pass # 已指向同一目标 -> 复用
else:
remove_dir_link(link)
if not make_dir_link(target, link):
return {"ok": False, "error": "重建 junction 失败(见 stderr)"}
else:
# 极罕见:同名真实目录占位(其它 UI 的普通项目)。绝不覆盖。
return {"ok": False,
"error": f"{link} 已是真实目录(可能是其它项目),拒绝覆盖"}
else:
if not make_dir_link(target, link):
return {"ok": False, "error": "创建 junction 失败(见 stderr)"}
# 确保 project_memory.md 存在(经 junction 落到真实仓库根)
mem_path = os.path.join(link, "project_memory.md")
if not os.path.isfile(mem_path):
try:
open(mem_path, "a", encoding="utf-8").close()
except OSError as e:
warning = f"无法创建 project_memory.md: {e}"
mem_text = ""
try:
with open(mem_path, encoding="utf-8", errors="replace") as fh:
mem_text = fh.read()
except OSError:
pass
registry_upsert(name, target)
return {"ok": True, "name": name, "link": link, "target": target,
"mem_text": mem_text, "warning": warning, "error": ""}
def activate(abs_path: str) -> dict:
"""设定并激活进程级 workspace。保留给旧 SOP / 非多会话 UI 使用。"""
r = prepare(abs_path)
if not r.get("ok"):
return r
try:
with open(_anchor_path(), "w", encoding="utf-8") as fh:
fh.write(r["name"])
except OSError as e:
r = dict(r)
r.update({"ok": False, "error": f"写激活锚失败: {e}"})
return r
def deactivate() -> bool:
"""仅删激活锚;junction 与文件保留。返回是否原本处于激活态。"""
anchor = _anchor_path()
if os.path.isfile(anchor):
try:
os.remove(anchor)
return True
except OSError as e:
sys.stderr.write(f"[workspace] deactivate failed: {e}\n")
return False
def current() -> Optional[dict]:
"""当前激活的 workspace:{name, path};未激活返回 None。"""
anchor = _anchor_path()
try:
name = open(anchor, encoding="utf-8").read().strip()
except OSError:
return None
if not name:
return None
ent = registry_load().get(name) or {}
return {"name": name, "path": ent.get("path") or ""}
def is_dangling(name: str) -> bool:
"""junction 指向的真实目标是否已失效(被删/盘断开)。"""
link = _link_path(name)
if not is_dir_link(link):
return True
t = link_target(link)
return not (t and os.path.isdir(t))
def remove(name: str) -> None:
"""显式注销:删 junction(不动真实文件)+ 删注册表条目。若正激活该项目则一并删锚。"""
link = _link_path(name)
if is_dir_link(link):
remove_dir_link(link)
registry_remove(name)
cur = current()
if cur and cur["name"] == name:
deactivate()
# project_mode 插件注入的标记:`[PROJECT MODE: <name>]`(见 _build_injection)。
# 它随用户消息写进 model_responses 日志,故可据此判断被 /continue 的会话当时
# 在哪个 workspace。
_PM_RE = re.compile(r"\[PROJECT MODE:\s*([^\]\n]+?)\s*\]")
def workspace_from_log(log_path: str) -> Optional[dict]:
"""扫一份 model_responses 日志,返回它最后激活的 workspace {name, path};
仅当该 name 是**已注册的 workspace**(在 workspaces.json 里)才返回——
普通 SOP 项目(无 hash、不在注册表)一律忽略。"""
try:
with open(log_path, encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError:
return None
names = _PM_RE.findall(content)
if not names:
return None
name = names[-1].strip() # 最后一次激活胜出
ent = registry_load().get(name)
if not ent or not ent.get("path"):
return None
return {"name": name, "path": ent["path"]}
def cleanup() -> None:
"""v2/v3 启动时调一次:清理 temp/projects/ 下**悬空或未注册**的 junction。
安全纪律:只处理 is_dir_link 确认的链接;真实目录(其它 UI 的普通项目)一律跳过。"""
proot = _projects_root()
if not os.path.isdir(proot):
return
registered = set(registry_load().keys())
try:
entries = os.listdir(proot)
except OSError:
return
for nm in entries:
link = os.path.join(proot, nm)
if not is_dir_link(link):
continue # 真实目录 -> 不碰
t = link_target(link)
dangling = not (t and os.path.isdir(t))
if dangling or nm not in registered:
remove_dir_link(link)
+14 -3
View File
@@ -41,9 +41,20 @@ def _cleanup_stale_anchors():
_cleanup_stale_anchors()
def _active_project():
"""读文件锚,返回当前激活的项目名;未激活返回 None。
def _active_project(ctx=None):
"""返回当前激活的项目名;未激活返回 None。
兼容策略:
- 新 TUI 多会话可在当前 GenericAgent 实例上设置 _ga_project_mode_name。
只要该属性存在,就以它为准;值为 None/空串表示该 agent 普通模式。
- 其它 UI / 旧 SOP 不设置该属性,继续读取 pid 键控文件锚。
异常不在此捕获——hooks.trigger 统一捕获并打印,保持可观测。"""
parent = None
if isinstance(ctx, dict):
handler = ctx.get('handler')
parent = getattr(handler, 'parent', None)
if parent is not None and hasattr(parent, '_ga_project_mode_name'):
return getattr(parent, '_ga_project_mode_name', None) or None
if not os.path.isfile(_ANCHOR):
return None
return open(_ANCHOR, encoding='utf-8').read().strip() or None
@@ -101,7 +112,7 @@ def _build_injection(name):
@hooks.register('agent_before')
def inject_project_context(ctx):
"""每个用户轮起始时,若项目模式激活,把项目上下文追加到 user message。"""
name = _active_project()
name = _active_project(ctx)
if not name:
return # 未激活,普通模式,什么都不做