From eba8f112de6bbd08cdee88367f3df700591acda3 Mon Sep 17 00:00:00 2001 From: pushuai Date: Fri, 29 May 2026 17:57:19 +0800 Subject: [PATCH 1/4] fix: prevent infinite recursion in Session.is_active() / mark_disconnected() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_active(): directly set disconnect_at instead of calling mark_disconnected() which would call is_active() again → infinite recursion - mark_disconnected(): check self.disconnect_at directly instead of is_active() - connected(): fix dead string literal (no-op) to actual print --- TMWebDriver.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/TMWebDriver.py b/TMWebDriver.py index 54f316852..f645d6552 100644 --- a/TMWebDriver.py +++ b/TMWebDriver.py @@ -16,7 +16,8 @@ def __init__(self, session_id, info, client=None): @property def url(self): return self.info.get('url', '') def is_active(self): - if self.type == 'http' and time.time() - self.connect_at > 60: self.mark_disconnected() + if self.type == 'http' and self.disconnect_at is None and time.time() - self.connect_at > 60: + self.disconnect_at = time.time() return self.disconnect_at is None def reconnect(self, client, info): self.info = info @@ -29,7 +30,7 @@ def reconnect(self, client, info): self.connect_at = time.time() self.disconnect_at = None def mark_disconnected(self): - if self.is_active(): print(f"Tab disconnected: {self.url} (Session: {self.id})") + if self.disconnect_at is None: print(f"Tab disconnected: {self.url} (Session: {self.id})") self.disconnect_at = time.time() @@ -150,7 +151,7 @@ def handle(self) -> None: except Exception as e: print(f"Error handling message: {e}") if hasattr(self, 'data'): print(self.data) - def connected(self): (f"New connection from {self.address}") + def connected(self): print(f"New WS connection from {self.address}") def handle_close(self): print(f"WS Connection closed: {self.address}") driver._unregister_client(self) From 4635997aeddac0a6101c5c5e50faa9493ea408d1 Mon Sep 17 00:00:00 2001 From: pushuai Date: Fri, 29 May 2026 17:57:24 +0800 Subject: [PATCH 2/4] feat: Firefox CDP Bridge compatibility + split browser manifests - background.js: guard chrome.debugger API (not available in Firefox) with graceful fallback instead of crash - manifest.json: adapted for Firefox (no debugger permission, scripts instead of service_worker, explicit CSP for ws://, added gecko id) - manifest.chrome.json: standalone Chrome manifest with full permissions - manifest.firefox.json: standalone Firefox manifest (gecko-specific) --- assets/tmwd_cdp_bridge/background.js | 4 +- assets/tmwd_cdp_bridge/manifest.chrome.json | 41 +++++++++++++++++++ assets/tmwd_cdp_bridge/manifest.firefox.json | 43 ++++++++++++++++++++ assets/tmwd_cdp_bridge/manifest.json | 26 +++++++----- 4 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 assets/tmwd_cdp_bridge/manifest.chrome.json create mode 100644 assets/tmwd_cdp_bridge/manifest.firefox.json diff --git a/assets/tmwd_cdp_bridge/background.js b/assets/tmwd_cdp_bridge/background.js index 6dbd4b796..bee88ae9d 100644 --- a/assets/tmwd_cdp_bridge/background.js +++ b/assets/tmwd_cdp_bridge/background.js @@ -117,6 +117,7 @@ async function handleBatch(msg, sender) { const tabs = (await chrome.tabs.query({})).filter(t => isScriptable(t.url)); R.push({ ok: true, data: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })) }); } else if (c.cmd === 'cdp') { + if (!chrome.debugger) { R.push({ ok: false, error: 'debugger API not available in this browser (Firefox)' }); continue; } const tabId = c.tabId || msg.tabId || sender.tab?.id; if (attached !== tabId) { if (attached) { await chrome.debugger.detach({ tabId: attached }); attached = null; } @@ -139,6 +140,7 @@ async function handleBatch(msg, sender) { async function handleCDP(msg, sender) { const tabId = msg.tabId || sender.tab?.id; if (!tabId) return { ok: false, error: 'no tabId' }; + if (!chrome.debugger) return { ok: false, error: 'debugger API not available in this browser (Firefox)' }; try { await chrome.debugger.attach({ tabId }, '1.3'); const result = await chrome.debugger.sendCommand({ tabId }, msg.method, msg.params || {}); @@ -292,7 +294,7 @@ async function handleWsExec(data) { res = { ok: false, error: { name: e.name || 'Error', message: e.message || String(e), stack: e.stack || '' }, csp: true }; } // CDP fallback for CSP-restricted pages - if (res && !res.ok && res.csp) { + if (res && !res.ok && res.csp && chrome.debugger) { console.log('[TMWD-WS] CDP fallback for tab', tabId); const wrappedCode = buildCdpScript(data.code); try { diff --git a/assets/tmwd_cdp_bridge/manifest.chrome.json b/assets/tmwd_cdp_bridge/manifest.chrome.json new file mode 100644 index 000000000..da63c44f8 --- /dev/null +++ b/assets/tmwd_cdp_bridge/manifest.chrome.json @@ -0,0 +1,41 @@ +{ + "manifest_version": 3, + "name": "TMWD CDP Bridge", + "version": "2.0", + "description": "Cookie viewer + CDP bridge", + "permissions": [ + "cookies", + "tabs", + "activeTab", + "debugger", + "scripting", + "alarms", + "declarativeNetRequest", + "management", + "contentSettings" + ], + "host_permissions": [""], + "background": { + "service_worker": "background.js", + "scripts": ["background.js"] + }, + "content_scripts": [ + { + "matches": [""], + "js": ["disable_dialogs.js"], + "run_at": "document_start", + "all_frames": true, + "world": "MAIN" + }, + { + "matches": [""], + "js": ["config.js", "content.js"], + "run_at": "document_idle", + "all_frames": true + } + ], + "action": { + "default_popup": "popup.html", + "default_title": "TMWD CDP Bridge" + } +} \ No newline at end of file diff --git a/assets/tmwd_cdp_bridge/manifest.firefox.json b/assets/tmwd_cdp_bridge/manifest.firefox.json new file mode 100644 index 000000000..df28ddf01 --- /dev/null +++ b/assets/tmwd_cdp_bridge/manifest.firefox.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 3, + "name": "TMWD CDP Bridge (Firefox)", + "version": "2.0", + "description": "Cookie viewer + Bridge for Firefox", + "permissions": [ + "cookies", + "tabs", + "activeTab", + "scripting", + "alarms", + "declarativeNetRequest", + "management" + ], + "host_permissions": [""], + "background": { + "scripts": ["background.js"] + }, + "content_scripts": [ + { + "matches": [""], + "js": ["disable_dialogs.js"], + "run_at": "document_start", + "all_frames": true + }, + { + "matches": [""], + "js": ["config.js", "content.js"], + "run_at": "document_idle", + "all_frames": true + } + ], + "browser_specific_settings": { + "gecko": { + "id": "tmwd-bridge@genericagent.local", + "strict_min_version": "128.0" + } + }, + "action": { + "default_popup": "popup.html", + "default_title": "TMWD Bridge" + } +} diff --git a/assets/tmwd_cdp_bridge/manifest.json b/assets/tmwd_cdp_bridge/manifest.json index 0ed2aa7cc..abb1ecb51 100644 --- a/assets/tmwd_cdp_bridge/manifest.json +++ b/assets/tmwd_cdp_bridge/manifest.json @@ -1,30 +1,30 @@ { "manifest_version": 3, - "name": "TMWD CDP Bridge", + "name": "TMWD CDP Bridge (Firefox)", "version": "2.0", - "description": "Cookie viewer + CDP bridge", + "description": "Cookie viewer + Bridge for Firefox", "permissions": [ "cookies", "tabs", "activeTab", - "debugger", "scripting", "alarms", "declarativeNetRequest", - "management", - "contentSettings" + "management" ], "host_permissions": [""], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' ws://127.0.0.1:18765 http://127.0.0.1:18765" + }, "background": { - "service_worker": "background.js" + "scripts": ["background.js"] }, "content_scripts": [ { "matches": [""], "js": ["disable_dialogs.js"], "run_at": "document_start", - "all_frames": true, - "world": "MAIN" + "all_frames": true }, { "matches": [""], @@ -33,8 +33,14 @@ "all_frames": true } ], + "browser_specific_settings": { + "gecko": { + "id": "tmwd-bridge@genericagent.local", + "strict_min_version": "128.0" + } + }, "action": { "default_popup": "popup.html", - "default_title": "TMWD CDP Bridge" + "default_title": "TMWD Bridge" } -} \ No newline at end of file +} From 429fca4eed2ce525b1ce756f5c9378df2a6a8a34 Mon Sep 17 00:00:00 2001 From: pushuai Date: Fri, 29 May 2026 17:57:50 +0800 Subject: [PATCH 3/4] feat: /todo command for TUI v2 & v3 with persistent storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /todo command (frontends/todo_cmd.py) shared by both TUI versions: /todo add → 添加 /todo ls → 列表 /todo run → 选择执行(执行后自动消除) /todo del → 选择删除 - Data persisted to temp/user_todo.json - v2: tuiapp_v2.py with _cmd_todo, _do_todo_*, and COMMANDS entry - v3: tui_v3.py with /todo dispatch and full i18n support --- frontends/todo_cmd.py | 71 ++++++++++++++++++++++++++++++++++++++++++ frontends/tui_v3.py | 39 +++++++++++++++++++++++ frontends/tuiapp_v2.py | 58 ++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 frontends/todo_cmd.py diff --git a/frontends/todo_cmd.py b/frontends/todo_cmd.py new file mode 100644 index 000000000..c01e8448f --- /dev/null +++ b/frontends/todo_cmd.py @@ -0,0 +1,71 @@ +"""Shared /todo command — persistent user TODO list. + +Data file: temp/user_todo.json (list of str, creation time tracked for display) +API: load(), save(), add(text), remove(idx), list_all() +""" + +from __future__ import annotations +import json, os, time + +_TODO_PATH = os.path.join(os.path.dirname(__file__), "..", "temp", "user_todo.json") + + +def _path() -> str: + return os.path.abspath(_TODO_PATH) + + +def load() -> list[dict]: + """Return [{id, text, created}, …] or empty list.""" + p = _path() + if not os.path.isfile(p): + return [] + try: + with open(p, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + except (json.JSONDecodeError, OSError): + pass + return [] + + +def save(items: list[dict]) -> None: + p = _path() + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w", encoding="utf-8") as f: + json.dump(items, f, ensure_ascii=False, indent=2) + + +def add(text: str) -> int: + """Append a new TODO item. Returns its 1-based index.""" + items = load() + item = { + "id": int(time.time() * 1000) % 1000000, + "text": text.strip(), + "created": time.strftime("%m-%d %H:%M"), + } + items.append(item) + save(items) + return len(items) + + +def remove(idx: int) -> str | None: + """Remove by 0-based index. Returns removed text or None.""" + items = load() + if not (0 <= idx < len(items)): + return None + removed = items.pop(idx) + save(items) + return removed["text"] + + +def list_all() -> list[dict]: + """Return items with 'text' and 'created'.""" + return load() + + +def format_list(items: list[dict]) -> list[str]: + """Format items for terminal display, one str per item, 1-based.""" + if not items: + return ["(empty)"] + return [f"{i+1}. {it['text']} ({it['created']})" for i, it in enumerate(items)] diff --git a/frontends/tui_v3.py b/frontends/tui_v3.py index 84a058220..f080fb2f5 100644 --- a/frontends/tui_v3.py +++ b/frontends/tui_v3.py @@ -177,6 +177,7 @@ 'cmd.rename.desc': 'Rename the current session', 'cmd.clear.desc': 'Clear display (LLM history untouched)', 'cmd.cost.desc': 'Token usage for the current session', + 'cmd.todo.desc': 'Personal TODO list (add / ls / run / del)', 'cmd.verbose.desc': 'Tool-call audit', 'cmd.export.desc': 'Export the last reply (clip/file/all)', 'cmd.stop.desc': 'Abort current task', @@ -430,6 +431,7 @@ 'cmd.rename.desc': '重命名当前会话', 'cmd.clear.desc': '清空显示(不动 LLM 历史)', 'cmd.cost.desc': '显示当前会话 token 用量', + 'cmd.todo.desc': '个人 TODO 列表 (add / ls / run / del)', 'cmd.verbose.desc': '工具调用审计', 'cmd.export.desc': '导出最后回复(剪贴板/文件/日志路径)', 'cmd.stop.desc': '中止当前任务', @@ -1821,6 +1823,7 @@ def _cmds() -> list[tuple[str, str, str]]: ('/conductor', _t('cmd.conductor.arg'), _t('cmd.conductor.desc')), ('/scheduler', '', _t('cmd.scheduler.desc')), ('/rewind', _t('cmd.rewind.arg'), _t('cmd.rewind.desc')), + ('/todo', 'add|ls|run|del', _t('cmd.todo.desc')), ('/continue', _t('cmd.continue.arg'), _t('cmd.continue.desc')), ('/new', _t('cmd.new.arg'), _t('cmd.new.desc')), ('/rename', _t('cmd.rename.arg'), _t('cmd.rename.desc')), @@ -4161,6 +4164,42 @@ def _pick_session(idx: int) -> None: _do_restore(sess[idx][0]) self._show_menu(_t('continue.title'), options, _pick_session) + elif name == 'todo': + from frontends import todo_cmd as _tc + sub = (arg.split(None, 1)[0] if arg else '').lower() + if sub == 'add': + rest = (arg[len('add'):] if arg.lower().startswith('add') else '').strip() + if not rest: + self.commit([_t('err.todo_add_usage', fallback='Usage: /todo add ')]); return + n = _tc.add(rest) + self.commit([f'✓ TODO #{n} added']) + elif sub == 'ls': + items = _tc.list_all() + lines = _tc.format_list(items) + self.commit(lines) + elif sub == 'run': + items = _tc.list_all() + if not items: + self.commit([_DIM + '(TODO list is empty)' + _RST]); return + opts = _tc.format_list(items) + def _run_todo(idx): + text = items[idx]['text'] + _tc.remove(idx) + # Submit as normal user message to execute immediately + self._commit_user(text) + self._submit(text, []) + self._show_menu('Select TODO to execute:', opts, _run_todo) + elif sub == 'del': + items = _tc.list_all() + if not items: + self.commit([_DIM + '(TODO list is empty)' + _RST]); return + opts = _tc.format_list(items) + def _del_todo(idx): + removed = _tc.remove(idx) + self.commit([f'🗑 Deleted: {removed}']) + self._show_menu('Select TODO to delete:', opts, _del_todo) + else: + self.commit([_t('err.todo_usage', fallback='Usage: /todo add|ls|run|del')]) elif name == 'clear': self._reset_session(ag) self.commit([_DIM + _t('msg.cleared') + _RST]) diff --git a/frontends/tuiapp_v2.py b/frontends/tuiapp_v2.py index e64462192..2c8473151 100644 --- a/frontends/tuiapp_v2.py +++ b/frontends/tuiapp_v2.py @@ -1316,6 +1316,7 @@ def default_agent_factory() -> Any: ("/conductor", "[task]", "调用 frontends/conductor.py 多 subagent 编排"), ("/scheduler", "", "多选启动/停止 reflect 任务(cron 由 reflect/scheduler.py 驱动)"), ("/continue", "[n|name]", "列出 / 恢复历史会话"), + ("/todo", "add|ls|run|del", "个人 TODO 列表(持久化,run 自动清除)"), ("/resume", "", "列出最近会话并恢复其中一个"), ("/cost", "[all]", "显示当前会话 token 用量(all = 所有会话)"), ("/export", "clip||all", "导出最后回复"), @@ -2677,6 +2678,7 @@ def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None: "stop": self._cmd_stop, "llm": self._cmd_llm, "export": self._cmd_export, "restore": self._cmd_restore, "btw": self._cmd_btw, "review": self._cmd_review, "continue": self._cmd_continue, "cost": self._cmd_cost, + "todo": self._cmd_todo, "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 @@ -4107,6 +4109,62 @@ def _finish(): self.call_after_refresh(_finish) return result.splitlines()[0] if result else "✅ 已恢复" + def _cmd_todo(self, args, raw): + from frontends import todo_cmd as _tc + sub = (args[0] if args else '').lower() + if sub == 'add' and len(args) >= 2: + text = ' '.join(args[1:]) + _tc.add(text) + self._system(f"📝 已添加: {text}") + elif sub == 'ls': + items = _tc.list_all() + if not items: + self._system("📋 TODO 列表为空"); return + lines = [f" {i+1}. {it['text']} ({it['created']})" for i, it in enumerate(items)] + self._system("📋 TODO 列表:\n" + "\n".join(lines)) + elif sub == 'run': + items = _tc.list_all() + if not items: + self._system("📋 TODO 列表为空"); return + choices = [(f"{i+1}. {it['text']} ({it['created']})", i) for i, it in enumerate(items)] + msg = ChatMessage( + role="system", content="选择要执行的 TODO (↑/↓ 移动,→/Enter 确认,Esc 取消):", + kind="choice", choices=choices, + on_select=lambda idx: self._do_todo_run(idx), + ) + self.current.messages.append(msg) + self._refresh_messages() + elif sub == 'del': + items = _tc.list_all() + if not items: + self._system("📋 TODO 列表为空"); return + choices = [(f"{i+1}. {it['text']} ({it['created']})", i) for i, it in enumerate(items)] + msg = ChatMessage( + role="system", content="选择要删除的 TODO (↑/↓ 移动,→/Enter 确认,Esc 取消):", + kind="choice", choices=choices, + on_select=lambda idx: self._do_todo_del(idx), + ) + self.current.messages.append(msg) + self._refresh_messages() + else: + self._system("用法: /todo add <内容> | ls | run | del") + + def _do_todo_del(self, idx: int): + from frontends import todo_cmd as _tc + removed = _tc.remove(idx) + self._system(f"🗑 已删除: {removed}") + + def _do_todo_run(self, idx: int): + from frontends import todo_cmd as _tc + items = _tc.list_all() + if idx >= len(items): + self._system("❌ TODO 索引无效"); return + text = items[idx]["text"] + _tc.remove(idx) + self.current._cmd_text = text + self._system(f"▶ 执行 TODO #{idx+1}: {text}") + self.call_after_refresh(lambda: self.submit_user_message(text)) + def _cmd_cost(self, args, raw): try: import cost_tracker From ea62e79ad69e0ae2b1ace9e6499d3ff05df8cd07 Mon Sep 17 00:00:00 2001 From: pushuai Date: Fri, 29 May 2026 17:58:02 +0800 Subject: [PATCH 4/4] feat: ga sync command with auto-merge + clipboard copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ga sync: safe update workflow - git stash local changes → git pull → pip install -e . → stash pop - Auto-resolve merge conflicts using keep-both strategy - Graceful rollback on unresolvable conflicts - 'ga tui' now defaults to tuiapp_v2.py tuiapp_v2.py UX improvements: - Ctrl+Shift+C copies selected text to clipboard - Middle-click (button=2) also triggers paste (like right-click) - Explicit ctrl+enter/shift+enter newline handling --- frontends/tuiapp_v2.py | 33 +++++++++- ga_cli/cli.py | 138 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 166 insertions(+), 5 deletions(-) diff --git a/frontends/tuiapp_v2.py b/frontends/tuiapp_v2.py index 2c8473151..8ce379ce5 100644 --- a/frontends/tuiapp_v2.py +++ b/frontends/tuiapp_v2.py @@ -1886,6 +1886,7 @@ class InputArea(TextArea): Binding("ctrl+j", "newline", "Newline", show=False), Binding("ctrl+enter", "newline", "Newline", show=False), Binding("shift+enter", "newline", "Newline", show=False), + Binding("ctrl+shift+c","copy_text", "Copy", show=False), Binding("ctrl+v", "paste", "Paste", show=False), # macOS muscle-memory alias: most terminals swallow Cmd+V (forward via bracketed # paste → _on_paste); this only hits if the terminal forwards Cmd as a key. @@ -2058,7 +2059,8 @@ async def _on_mouse_down(self, event: events.MouseDown) -> None: await super()._on_mouse_down(event) async def _on_click(self, event: events.Click) -> None: - if getattr(event, "button", 0) == 3 and not self.read_only: + if getattr(event, "button", 0) in (2, 3) and not self.read_only: + # button=2: middle-click paste; button=3: right-click paste self.action_paste() event.stop(); event.prevent_default() @@ -2240,6 +2242,15 @@ async def _on_key(self, event: events.Key) -> None: event.stop(); event.prevent_default() self.action_newline() return + + # Modified-enter newlines: handle explicitly (in addition to BINDINGS) + # because many terminals send \r for ctrl+enter/shift+enter too, + # making Textual see all three as plain "enter". + if event.key in ("ctrl+enter", "shift+enter", "ctrl+j"): + self._insert_via_keyboard("\n") + event.stop(); event.prevent_default() + return + if event.key == "enter": # bare Enter = submit event.stop(); event.prevent_default() self.post_message(self.Submitted(self, self.text)) return @@ -2613,6 +2624,7 @@ class GenericAgentTUI(App[None]): # macOS muscle-memory aliases — only fire if the terminal forwards Cmd as a key # (Terminal.app / default iTerm2 swallow them; Ghostty / WezTerm / kitty can forward). Binding("cmd+c", "handle_ctrl_c", "Stop/Quit", show=False, priority=True), + Binding("ctrl+shift+c","copy_selection","Copy", show=False), Binding("ctrl+n", "new_session", "New", show=False), Binding("cmd+n", "new_session", "New", show=False), Binding("ctrl+b", "toggle_sidebar","Sidebar", show=False), @@ -3012,6 +3024,25 @@ def _disarm_rewind(self) -> None: try: self._refresh_bottombar() except Exception: pass + def action_copy_selection(self) -> None: + """Copy selected text to clipboard (Ctrl+Shift+C).""" + inp = self.query_one("#input", InputArea) + if self.focused is inp and inp.selected_text: + try: self.copy_to_clipboard(inp.selected_text) + except Exception: pass + self.notify("Copied input selection", timeout=1.5) + return + try: + text = self.screen.get_selected_text() + except Exception: + text = None + if text: + try: self.copy_to_clipboard(text) + except Exception: pass + self.notify("Copied selection", timeout=1.5) + return + self.notify("No selection. Use /export clip", timeout=2, severity="warning") + def on_key(self, event: events.Key) -> None: if self._quit_armed and event.key not in ("ctrl+c", "cmd+c"): self._disarm_quit() diff --git a/ga_cli/cli.py b/ga_cli/cli.py index 9ecc4455c..44205344b 100644 --- a/ga_cli/cli.py +++ b/ga_cli/cli.py @@ -3,7 +3,7 @@ 通过 python -m ga_cli <命令> 或 ga <命令> 调用 """ -import os, sys, subprocess, argparse, textwrap +import os, re, sys, subprocess, argparse, textwrap # Windows GBK 终端兼容 if sys.platform == "win32" and sys.stdout.encoding and sys.stdout.encoding.lower() in ("gbk", "gb2312"): @@ -29,6 +29,10 @@ def launch_frontend(cmd_parts, args=None): part = part.replace("{REFLECT}", _reflect()) full_cmd.append(part) + # [修复] 用当前 Python 解释器路径替换硬编码 'python' + if full_cmd and full_cmd[0] == "python": + full_cmd[0] = sys.executable + # 插入额外参数 if args: full_cmd.extend(args) @@ -61,9 +65,9 @@ def launch_frontend(cmd_parts, args=None): "cmd": ["python", "{PROJECT_DIR}/hub.pyw"], }, "tui": { - "help": "启动终端 TUI (tuiapp)", - "desc": "启动终端图形界面(Textual),适合纯终端环境或 SSH", - "cmd": ["python", "{FRONTENDS}/tuiapp.py"], + "help": "启动终端 TUI (tuiapp_v2)", + "desc": "启动新式终端图形界面(Textual v2),支持多行输入/粘贴/历史浏览", + "cmd": ["python", "{FRONTENDS}/tuiapp_v2.py"], }, "tui2": { "help": "启动终端 TUI v2 (tuiapp_v2)", @@ -98,6 +102,12 @@ def launch_frontend(cmd_parts, args=None): "cmd": None, "internal": True, }, + "sync": { + "help": "安全同步更新(stash + pull + pip install)", + "desc": "先暂存本地修改再拉取,自动恢复。不会因为未提交改动而拒绝更新", + "cmd": None, + "internal": True, + }, } @@ -146,6 +156,120 @@ def cmd_update(): print(r2.stderr[-500:]) +def _auto_resolve_keep_both() -> int: + """自动解决所有未合并文件:保留冲突双方的完整内容(限文本文件)。 + + 策略:对每个冲突区域,依次保留 upstream 版本 + stash 版本, + 最大限度保留双方代码,不丢弃任何改动。 + """ + import pathlib + # 1. 找出所有未合并的文件 + sp = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=U"], + capture_output=True, text=True, cwd=PROJECT_DIR + ) + files = [f.strip() for f in sp.stdout.splitlines() if f.strip()] + if not files: + return 0 + + pattern = re.compile( + r"^<<<<<<< (?:Updated upstream|HEAD|ours?)[^\n]*\n" + r"(.*?)" + r"^=======\n" + r"(.*?)" + r"^>>>>>>> (?:Stashed changes|theirs?)[^\n]*\n?", + flags=re.MULTILINE | re.DOTALL + ) + + resolved = 0 + for fpath in files: + fpath = os.path.join(PROJECT_DIR, fpath) + if not os.path.isfile(fpath): + continue + # 跳过二进制文件 + try: + raw = pathlib.Path(fpath).read_bytes() + if b"\x00" in raw[:8192]: + print(f" ⏭️ 跳过二进制文件: {fpath}") + continue + text = raw.decode("utf-8") + except (UnicodeDecodeError, OSError): + print(f" ⏭️ 跳过不可解码文件: {fpath}") + continue + + new_text, count = pattern.subn(_merge_conflict_block, text) + if count == 0: + continue + + pathlib.Path(fpath).write_text(new_text, encoding="utf-8") + subprocess.run(["git", "add", fpath], capture_output=True, cwd=PROJECT_DIR) + print(f" 📄 {os.path.relpath(fpath, PROJECT_DIR)}: {count} 处冲突已合并") + resolved += 1 + + return resolved + + +def _merge_conflict_block(m: re.Match) -> str: + """将单个冲突块合并为『upstream版 + stash版』。""" + ours = m.group(1).rstrip("\n") + theirs = m.group(2).rstrip("\n") + # 如果两边完全一样,只保留一份 + if ours.strip() == theirs.strip(): + return ours + "\n" + return ours + "\n\n" + theirs + "\n" + + +def cmd_sync(): + """安全同步:stash→pull→stash pop→pip install,不怕本地未提交改动""" + os.chdir(PROJECT_DIR) + + # 1. stash 本地改动 + print("📦 暂存本地修改...") + sp_stash = subprocess.run(["git", "stash"], capture_output=True, text=True) + has_local = sp_stash.returncode == 0 and "No local changes" not in sp_stash.stderr + if has_local: + print(" 已暂存") + else: + print(" 无本地修改") + + # 2. git pull + print("🔄 git pull...") + sp_pull = subprocess.run(["git", "pull"], capture_output=True, text=True) + print(sp_pull.stdout) + if sp_pull.returncode != 0: + print(sp_pull.stderr) + if has_local: + subprocess.run(["git", "stash", "pop"], capture_output=True) + return + + # 3. pip install + print("📦 pip install...") + sp_pip = subprocess.run([sys.executable, "-m", "pip", "install", "-e", "."], + capture_output=True, text=True) + print(sp_pip.stdout[-500:] if sp_pip.stdout else "") + if sp_pip.returncode != 0: + print(sp_pip.stderr[-500:]) + + # 4. pop 恢复 + if has_local: + print("📦 恢复本地修改...") + sp_pop = subprocess.run(["git", "stash", "pop"], capture_output=True, text=True) + if sp_pop.returncode == 0: + print(" 恢复成功 ✅") + else: + print("⚙️ 检测到冲突,自动合并中(最大限度保留本地+上游)...") + resolved = _auto_resolve_keep_both() + if resolved: + print(f" ✅ 已自动解决 {resolved} 个文件冲突") + subprocess.run(["git", "stash", "drop"], capture_output=True) + print(" ✅ stash 已清理") + print(" 恢复成功 ✅") + else: + print(" ❌ 自动合并失败,请手动处理:") + print(" git stash drop # 放弃 stash") + print(" git diff # 查看冲突") + + def main(): parser = argparse.ArgumentParser( prog="ga", @@ -160,6 +284,8 @@ def main(): ga tui2 启动终端 TUI (v2 增强版) ga pet 启动桌面宠物 v2 ga launch 启动 webview 桌面壳 + + ga sync 安全更新(stash+拉取+恢复,不怕本地改动) ga list 列出所有命令 """), ) @@ -193,6 +319,10 @@ def main(): cmd_update() return + if cmd == "sync": + cmd_sync() + return + if cmd not in COMMANDS: print(f"❌ 未知命令: {cmd}") print(f" 使用 'ga list' 查看可用命令")