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:
1 parent
1d2f3597eb
commit
84254ad762
6 files changed
+1516
-55
No files matched your search
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 # 未激活,普通模式,什么都不做
|
||||
|
||||
|
||||
Reference in new issue
Block a user