Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pytest-cov==7.1.0
rich==15.0.0
ruff==0.15.11
textual
python-dotenv
python-dotenv
pyyaml
43 changes: 41 additions & 2 deletions src/console/commands/workspaces/workspace_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
SYSTEM_ROLE,
TOOL_RULES,
WORKFLOW_GUIDELINES,
clear_extension_hooks,
generate_extensions_section,
register_extension_hook,
)
from src.core.agent_manager import AgentManager
from src.core.skill_manager import SkillManager
from src.models.agent import AgentConfig
from src.models.commands import Command, CommandContext, CommandResult

Expand All @@ -19,7 +22,7 @@
AGENTS_MD_FENCE_END = "<!-- llm-relevant-end -->"


def _generate_tool_definitions_section(context: CommandContext, agent: AgentConfig) -> str:
def _generate_tool_definitions_section(context: CommandContext, agent: AgentConfig, enable_skill: bool = False) -> str:
"""Generate <tool_definitions> XML block with doc for each registered tool,
filtered by the current agent's tool permissions."""
tools = context.tool_registry.list_tools()
Expand All @@ -29,6 +32,9 @@ def _generate_tool_definitions_section(context: CommandContext, agent: AgentConf

docs: list[str] = ["<tool_definitions>"]
for name in tools["sync"]:
# 与src/workspace/tools/skill_tool.py一致
if (not enable_skill) and name == "skill":
continue
# Filter by agent permissions
if not agent.tool_permissions.is_tool_allowed(name):
continue
Expand Down Expand Up @@ -106,13 +112,41 @@ def _generate_agent_directive_section(agent: AgentConfig) -> str:
return "\n".join(parts)


def _generate_skill_prompt_section() -> str:
"""Generate Skill-related prompt sections: skill instructions and available skills list.

Returns:
XML-formatted string containing skill prompt and available skills
"""
skill_manager = SkillManager()

# 获取启用的 skills(会根据数据库中的禁用状态过滤)
enabled_skills = skill_manager.get_enabled()

if not enabled_skills:
return ""

parts = ["", "<available_skills>"]

# 可用 Skill 列表
for skill in sorted(enabled_skills.values(), key=lambda s: s.name):
parts.append(f""" <skill>
<name>{skill.name}</name>
<description>{skill.description}</description>
</skill>""")
parts.append("</available_skills>")

return "\n".join(parts)


def _assemble_full_prompt(context: CommandContext) -> str:
"""Assemble the complete system prompt from ordered XML sections.

Order: role → constraints → agent_directive → tool_rules → tool_definitions
→ workflow → workspace_context → augmentation → extensions
"""
agent = AgentManager().get_current()
skill_prompt = _generate_skill_prompt_section()

sections = [
"<system_prompt>",
Expand All @@ -139,7 +173,7 @@ def _assemble_full_prompt(context: CommandContext) -> str:
sections.append("")

# ⑤ Tool definitions
sections.append(_generate_tool_definitions_section(context, agent))
sections.append(_generate_tool_definitions_section(context, agent, len(skill_prompt) > 0))
sections.append("")

# ⑥ Workflow guidelines — skip if agent provides its own workflow
Expand All @@ -157,11 +191,16 @@ def _assemble_full_prompt(context: CommandContext) -> str:
sections.append(augmentations)
sections.append("")

if len(skill_prompt) > 0:
register_extension_hook(lambda: skill_prompt)

# ⑨ Extensions (Skills / MCP hooks)
sections.append(generate_extensions_section())
sections.append("")
sections.append("</system_prompt>")

clear_extension_hooks()

return "\n".join(sections)


Expand Down
11 changes: 11 additions & 0 deletions src/console/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from src.console.folder_picker import pick_folder
from src.console.result_manager import ResultManager
from src.console.ui.repl import REPL
from src.core.config_manager import ConfigManager
from src.core.skill_manager import SkillManager
from src.core.tool_registry import ToolRegistry
from src.workspace.workspace import Workspace

Expand Down Expand Up @@ -87,6 +89,15 @@ def init_workspace(start_path: str | None = None) -> Workspace | None:
agent_manager.initialize(workspace.root_path)
agent_manager.write_default(workspace.root_path)

# Initialize ConfigManager and apply environment configs
config_manager = ConfigManager()
config_manager.initialize(workspace.root_path)
config_manager.apply_env_configs()

# Initialize SkillManager and discover skills
skill_manager = SkillManager()
skill_manager.discover(workspace.root_path)

# 在创建新会话之前清理孤立的会话
_cleanup_orphaned_sessions(workspace.db)

Expand Down
9 changes: 9 additions & 0 deletions src/console/ui/repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,21 @@ def on_mount(self) -> None:
audit_committer = AuditCommitter(self.workspace)
tui_console.audit_tab.set_committer(audit_committer)

# 注入 Shell 结果标签页
tui_console.shell_result_tab.set_database(self.workspace.db)

# 注入统计标签页
tui_console.stats_tab.set_database(
self.workspace.db,
getattr(self.tool_registry, "_current_session_id", None),
)

# 注入设置标签页
from src.core.skill_manager import SkillManager

skill_manager = SkillManager()
tui_console.settings_tab.set_managers(self.workspace.root_path, skill_manager)

# 创建 command handler
self.command_handler = CommandHandler(
self.workspace,
Expand Down
23 changes: 22 additions & 1 deletion src/console/ui/tui_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from textual.widgets import Collapsible, RichLog, Static, TabbedContent, TabPane

from src.console.ui.widgets.audit_tab import AuditTab
from src.console.ui.widgets.settings_tab import SettingsTab
from src.console.ui.widgets.shell_result_tab import ShellResultTab
from src.console.ui.widgets.stats_tab import StatsTab


Expand All @@ -19,7 +21,9 @@ class TuiConsole(Vertical):
- Tab 1 (RichLog): 用于普通富文本日志.
- Tab 2 (Tool Calls): 用于显示工具调用情况.
- Tab 3 (Audit): 用于审核待处理的写入/编辑操作.
- Tab 4 (Statistics): 用于查看会话统计与工具使用排名.
- Tab 4 (Shell Results): 用于查看已执行的 Shell 命令输出.
- Tab 5 (Statistics): 用于查看会话统计与工具使用排名.
- Tab 6 (Settings): 用于配置环境变量和 Skill.
"""

DEFAULT_CSS = """
Expand Down Expand Up @@ -60,8 +64,12 @@ def compose(self):
yield Vertical(id="tui-console-tool-calls")
with TabPane("Audit", id="tab-audit"):
yield AuditTab()
with TabPane("Shell Results", id="tab-shell-results"):
yield ShellResultTab()
with TabPane("Statistics", id="tab-stats"):
yield StatsTab()
with TabPane("Settings", id="tab-settings"):
yield SettingsTab()

@property
def main_log(self) -> RichLog:
Expand All @@ -75,16 +83,29 @@ def tool_calls_container(self) -> Vertical:
def audit_tab(self) -> AuditTab:
return self.query_one(AuditTab)

@property
def shell_result_tab(self) -> ShellResultTab:
return self.query_one(ShellResultTab)

@property
def stats_tab(self) -> StatsTab:
return self.query_one(StatsTab)

@property
def settings_tab(self) -> SettingsTab:
return self.query_one(SettingsTab)

async def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
"""切换标签页时刷新内容."""
if event.pane.id == "tab-audit":
await self.audit_tab._refresh()
elif event.pane.id == "tab-shell-results":
await self.shell_result_tab._refresh()
elif event.pane.id == "tab-stats":
await self.stats_tab._refresh()
elif event.pane.id == "tab-settings":
# Settings tab refreshes automatically when managers are set
pass

def print(self, *args) -> None:
"""将内容写入主日志区"""
Expand Down
17 changes: 17 additions & 0 deletions src/console/ui/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""TUI widgets package."""

from src.console.ui.widgets.audit_tab import AuditTab
from src.console.ui.widgets.env_config_tab import EnvConfigTab
from src.console.ui.widgets.settings_tab import SettingsTab
from src.console.ui.widgets.shell_result_tab import ShellResultTab
from src.console.ui.widgets.skill_config_tab import SkillConfigTab
from src.console.ui.widgets.stats_tab import StatsTab

__all__ = [
"AuditTab",
"EnvConfigTab",
"SettingsTab",
"ShellResultTab",
"SkillConfigTab",
"StatsTab",
]
114 changes: 75 additions & 39 deletions src/console/ui/widgets/audit_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ class AuditTab(Vertical):
padding: 0 1;
margin-bottom: 1;
}

.audit-section-label {
height: auto;
padding: 0 0 0 0;
text-style: bold;
color: $text;
}
"""

def __init__(self) -> None:
Expand Down Expand Up @@ -108,55 +115,80 @@ async def _refresh(self) -> None:

await self.remove_children()

pending = self._committer.workspace.db.get_snapshots_by_audit_status("PENDING_AUDIT")
# Result area for showing commit results
result_log = Vertical(id="audit-result-log")
await self.mount(result_log)

pending_files = self._committer.workspace.db.get_snapshots_by_audit_status("PENDING_AUDIT")
pending_shells = self._committer.workspace.db.get_shell_pending_audits()

if not pending:
total_items = len(pending_files) + len(pending_shells)

if total_items == 0:
await self.mount(Label("没有待审核的更改.", id="audit-empty"))
return

# Group by file_path
grouped: defaultdict[str, list[tuple]] = defaultdict(list)
for snap in pending:
grouped[snap[1]].append(snap)

# Result area for showing commit results
result_log = Vertical(id="audit-result-log")
await self.mount(result_log)
# Group file snapshots by file_path
file_grouped: defaultdict[str, list[tuple]] = defaultdict(list)
for snap in pending_files:
file_grouped[snap[1]].append(snap)

header = Label(
f"待审核更改 ({sum(len(snaps) for snaps in grouped.values())} 项)",
f"待审核更改 ({total_items} 项)",
id="audit-header",
)
await self.mount(header)

for file_path in sorted(grouped):
snaps = grouped[file_path]
# Collect all children for all snaps of this file
all_snap_widgets: list[Static | Horizontal] = []
for snap in snaps:
snap_id = snap[0]
diff_content = snap[4] or "(空 diff)"
# Render pending shell commands first
if pending_shells:
shell_children: list[Label | Collapsible] = [Label("Shell 命令:", classes="audit-section-label")]
for shell in pending_shells:
shell_id, command, description, _ts, _sid, _status = shell
preview = f"$ {command}"
if description:
preview += f"\n # {description}"

diff_container = Vertical(Static(diff_content, markup=False), classes="audit-diff")
cmd_display = Static(preview, markup=False, classes="audit-diff")
btn_row = Horizontal(
Button("批准", variant="primary", id=f"approve-{snap_id}", classes="audit-approve"),
Button("拒绝", variant="error", id=f"reject-{snap_id}", classes="audit-reject"),
Button("批准", variant="primary", id=f"shell_approve-{shell_id}", classes="audit-approve"),
Button("拒绝", variant="error", id=f"shell_reject-{shell_id}", classes="audit-reject"),
classes="audit-buttons",
)
all_snap_widgets.append(diff_container)
all_snap_widgets.append(btn_row)

content_widgets = Vertical(*all_snap_widgets)

collapsible = Collapsible(
content_widgets,
title=f"{file_path} ({len(snaps)} 次更改)",
classes="audit-collapsible",
)
# Collapse excess items initially — keep first 3 expanded
if len(self.children) > 6: # header + result_log + 3 expanded
collapsible.collapsed = True
await self.mount(collapsible)
collapsible = Collapsible(
Vertical(cmd_display, btn_row),
title=f"Shell #{shell_id}: {command.strip()[:60]}{'...' if len(command.strip()) > 60 else ''}",
classes="audit-collapsible",
)
shell_children.append(collapsible)
await self.mount(Vertical(*shell_children))

# Render file snapshot changes
if pending_files:
for file_path in sorted(file_grouped):
snaps = file_grouped[file_path]
all_snap_widgets: list[Static | Horizontal] = []
for snap in snaps:
snap_id = snap[0]
diff_content = snap[4] or "(空 diff)"

diff_container = Vertical(Static(diff_content, markup=False), classes="audit-diff")
btn_row = Horizontal(
Button("批准", variant="primary", id=f"approve-{snap_id}", classes="audit-approve"),
Button("拒绝", variant="error", id=f"reject-{snap_id}", classes="audit-reject"),
classes="audit-buttons",
)
all_snap_widgets.append(diff_container)
all_snap_widgets.append(btn_row)

content_widgets = Vertical(*all_snap_widgets)
collapsible = Collapsible(
content_widgets,
title=f"{file_path} ({len(snaps)} 次更改)",
classes="audit-collapsible",
)
if len(self.children) > 6:
collapsible.collapsed = True
await self.mount(collapsible)

async def on_button_pressed(self, event: Button.Pressed) -> None:
"""处理批准/拒绝按钮点击."""
Expand All @@ -169,16 +201,20 @@ async def on_button_pressed(self, event: Button.Pressed) -> None:
if len(parts) != 2:
return

action, snap_id_str = parts
action, id_str = parts
try:
snapshot_id = int(snap_id_str)
item_id = int(id_str)
except ValueError:
return

if action == "approve":
result = self._committer.commit(snapshot_id, approved=True)
result = self._committer.commit(item_id, approved=True)
elif action == "reject":
result = self._committer.commit(snapshot_id, approved=False)
result = self._committer.commit(item_id, approved=False)
elif action == "shell_approve":
result = self._committer.commit_shell(item_id, approved=True)
elif action == "shell_reject":
result = self._committer.commit_shell(item_id, approved=False)
else:
return

Expand Down
Loading