@hoangsonw/forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +919 -0
- package/bin/forge.js +30 -0
- package/dist/agents/architect.d.ts +20 -0
- package/dist/agents/architect.d.ts.map +1 -0
- package/dist/agents/architect.js +75 -0
- package/dist/agents/architect.js.map +1 -0
- package/dist/agents/base.d.ts +20 -0
- package/dist/agents/base.d.ts.map +1 -0
- package/dist/agents/base.js +3 -0
- package/dist/agents/base.js.map +1 -0
- package/dist/agents/debugger.d.ts +16 -0
- package/dist/agents/debugger.d.ts.map +1 -0
- package/dist/agents/debugger.js +93 -0
- package/dist/agents/debugger.js.map +1 -0
- package/dist/agents/executor.d.ts +48 -0
- package/dist/agents/executor.d.ts.map +1 -0
- package/dist/agents/executor.js +402 -0
- package/dist/agents/executor.js.map +1 -0
- package/dist/agents/memory.d.ts +8 -0
- package/dist/agents/memory.d.ts.map +1 -0
- package/dist/agents/memory.js +84 -0
- package/dist/agents/memory.js.map +1 -0
- package/dist/agents/planner.d.ts +5 -0
- package/dist/agents/planner.d.ts.map +1 -0
- package/dist/agents/planner.js +185 -0
- package/dist/agents/planner.js.map +1 -0
- package/dist/agents/registry.d.ts +6 -0
- package/dist/agents/registry.d.ts.map +1 -0
- package/dist/agents/registry.js +32 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/agents/reviewer.d.ts +18 -0
- package/dist/agents/reviewer.d.ts.map +1 -0
- package/dist/agents/reviewer.js +87 -0
- package/dist/agents/reviewer.js.map +1 -0
- package/dist/classifier/classifier.d.ts +9 -0
- package/dist/classifier/classifier.d.ts.map +1 -0
- package/dist/classifier/classifier.js +83 -0
- package/dist/classifier/classifier.js.map +1 -0
- package/dist/classifier/heuristics.d.ts +11 -0
- package/dist/classifier/heuristics.d.ts.map +1 -0
- package/dist/classifier/heuristics.js +112 -0
- package/dist/classifier/heuristics.js.map +1 -0
- package/dist/cli/animations.d.ts +27 -0
- package/dist/cli/animations.d.ts.map +1 -0
- package/dist/cli/animations.js +186 -0
- package/dist/cli/animations.js.map +1 -0
- package/dist/cli/banners.d.ts +47 -0
- package/dist/cli/banners.d.ts.map +1 -0
- package/dist/cli/banners.js +211 -0
- package/dist/cli/banners.js.map +1 -0
- package/dist/cli/bootstrap.d.ts +2 -0
- package/dist/cli/bootstrap.d.ts.map +1 -0
- package/dist/cli/bootstrap.js +21 -0
- package/dist/cli/bootstrap.js.map +1 -0
- package/dist/cli/commands/bundle.d.ts +3 -0
- package/dist/cli/commands/bundle.d.ts.map +1 -0
- package/dist/cli/commands/bundle.js +80 -0
- package/dist/cli/commands/bundle.js.map +1 -0
- package/dist/cli/commands/changelog.d.ts +3 -0
- package/dist/cli/commands/changelog.d.ts.map +1 -0
- package/dist/cli/commands/changelog.js +60 -0
- package/dist/cli/commands/changelog.js.map +1 -0
- package/dist/cli/commands/config.d.ts +3 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +91 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/container.d.ts +3 -0
- package/dist/cli/commands/container.d.ts.map +1 -0
- package/dist/cli/commands/container.js +149 -0
- package/dist/cli/commands/container.js.map +1 -0
- package/dist/cli/commands/cost.d.ts +3 -0
- package/dist/cli/commands/cost.d.ts.map +1 -0
- package/dist/cli/commands/cost.js +38 -0
- package/dist/cli/commands/cost.js.map +1 -0
- package/dist/cli/commands/daemon.d.ts +3 -0
- package/dist/cli/commands/daemon.d.ts.map +1 -0
- package/dist/cli/commands/daemon.js +39 -0
- package/dist/cli/commands/daemon.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +3 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/dev.js +73 -0
- package/dist/cli/commands/dev.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +214 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +148 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +3 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/mcp.js +227 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/memory.d.ts +3 -0
- package/dist/cli/commands/memory.d.ts.map +1 -0
- package/dist/cli/commands/memory.js +101 -0
- package/dist/cli/commands/memory.js.map +1 -0
- package/dist/cli/commands/migrate.d.ts +3 -0
- package/dist/cli/commands/migrate.d.ts.map +1 -0
- package/dist/cli/commands/migrate.js +18 -0
- package/dist/cli/commands/migrate.js.map +1 -0
- package/dist/cli/commands/model.d.ts +3 -0
- package/dist/cli/commands/model.d.ts.map +1 -0
- package/dist/cli/commands/model.js +37 -0
- package/dist/cli/commands/model.js.map +1 -0
- package/dist/cli/commands/permissions.d.ts +3 -0
- package/dist/cli/commands/permissions.d.ts.map +1 -0
- package/dist/cli/commands/permissions.js +32 -0
- package/dist/cli/commands/permissions.js.map +1 -0
- package/dist/cli/commands/resume.d.ts +3 -0
- package/dist/cli/commands/resume.d.ts.map +1 -0
- package/dist/cli/commands/resume.js +90 -0
- package/dist/cli/commands/resume.js.map +1 -0
- package/dist/cli/commands/run.d.ts +5 -0
- package/dist/cli/commands/run.d.ts.map +1 -0
- package/dist/cli/commands/run.js +164 -0
- package/dist/cli/commands/run.js.map +1 -0
- package/dist/cli/commands/session.d.ts +3 -0
- package/dist/cli/commands/session.d.ts.map +1 -0
- package/dist/cli/commands/session.js +94 -0
- package/dist/cli/commands/session.js.map +1 -0
- package/dist/cli/commands/skills.d.ts +4 -0
- package/dist/cli/commands/skills.d.ts.map +1 -0
- package/dist/cli/commands/skills.js +176 -0
- package/dist/cli/commands/skills.js.map +1 -0
- package/dist/cli/commands/spec.d.ts +3 -0
- package/dist/cli/commands/spec.d.ts.map +1 -0
- package/dist/cli/commands/spec.js +58 -0
- package/dist/cli/commands/spec.js.map +1 -0
- package/dist/cli/commands/status.d.ts +3 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +65 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/task.d.ts +3 -0
- package/dist/cli/commands/task.d.ts.map +1 -0
- package/dist/cli/commands/task.js +42 -0
- package/dist/cli/commands/task.js.map +1 -0
- package/dist/cli/commands/ui.d.ts +3 -0
- package/dist/cli/commands/ui.d.ts.map +1 -0
- package/dist/cli/commands/ui.js +28 -0
- package/dist/cli/commands/ui.js.map +1 -0
- package/dist/cli/commands/update.d.ts +3 -0
- package/dist/cli/commands/update.d.ts.map +1 -0
- package/dist/cli/commands/update.js +53 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/commands/web.d.ts +3 -0
- package/dist/cli/commands/web.d.ts.map +1 -0
- package/dist/cli/commands/web.js +42 -0
- package/dist/cli/commands/web.js.map +1 -0
- package/dist/cli/help.d.ts +21 -0
- package/dist/cli/help.d.ts.map +1 -0
- package/dist/cli/help.js +216 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +154 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/repl-commands.d.ts +47 -0
- package/dist/cli/repl-commands.d.ts.map +1 -0
- package/dist/cli/repl-commands.js +508 -0
- package/dist/cli/repl-commands.js.map +1 -0
- package/dist/cli/repl-input.d.ts +87 -0
- package/dist/cli/repl-input.d.ts.map +1 -0
- package/dist/cli/repl-input.js +764 -0
- package/dist/cli/repl-input.js.map +1 -0
- package/dist/cli/repl.d.ts +5 -0
- package/dist/cli/repl.d.ts.map +1 -0
- package/dist/cli/repl.js +1046 -0
- package/dist/cli/repl.js.map +1 -0
- package/dist/cli/ui.d.ts +19 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +106 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/config/loader.d.ts +11 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +132 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/paths.d.ts +35 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +114 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/schema.d.ts +372 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +161 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/xdg.d.ts +2 -0
- package/dist/config/xdg.d.ts.map +1 -0
- package/dist/config/xdg.js +55 -0
- package/dist/config/xdg.js.map +1 -0
- package/dist/core/continuity.d.ts +8 -0
- package/dist/core/continuity.d.ts.map +1 -0
- package/dist/core/continuity.js +36 -0
- package/dist/core/continuity.js.map +1 -0
- package/dist/core/conversation.d.ts +152 -0
- package/dist/core/conversation.d.ts.map +1 -0
- package/dist/core/conversation.js +435 -0
- package/dist/core/conversation.js.map +1 -0
- package/dist/core/estimation.d.ts +19 -0
- package/dist/core/estimation.d.ts.map +1 -0
- package/dist/core/estimation.js +53 -0
- package/dist/core/estimation.js.map +1 -0
- package/dist/core/fork.d.ts +7 -0
- package/dist/core/fork.d.ts.map +1 -0
- package/dist/core/fork.js +93 -0
- package/dist/core/fork.js.map +1 -0
- package/dist/core/interactive-host.d.ts +28 -0
- package/dist/core/interactive-host.d.ts.map +1 -0
- package/dist/core/interactive-host.js +19 -0
- package/dist/core/interactive-host.js.map +1 -0
- package/dist/core/loop-detection.d.ts +25 -0
- package/dist/core/loop-detection.d.ts.map +1 -0
- package/dist/core/loop-detection.js +37 -0
- package/dist/core/loop-detection.js.map +1 -0
- package/dist/core/loop.d.ts +15 -0
- package/dist/core/loop.d.ts.map +1 -0
- package/dist/core/loop.js +417 -0
- package/dist/core/loop.js.map +1 -0
- package/dist/core/mode-policy.d.ts +33 -0
- package/dist/core/mode-policy.d.ts.map +1 -0
- package/dist/core/mode-policy.js +62 -0
- package/dist/core/mode-policy.js.map +1 -0
- package/dist/core/orchestrator.d.ts +14 -0
- package/dist/core/orchestrator.d.ts.map +1 -0
- package/dist/core/orchestrator.js +69 -0
- package/dist/core/orchestrator.js.map +1 -0
- package/dist/core/plan-fixer.d.ts +16 -0
- package/dist/core/plan-fixer.d.ts.map +1 -0
- package/dist/core/plan-fixer.js +55 -0
- package/dist/core/plan-fixer.js.map +1 -0
- package/dist/core/signals.d.ts +5 -0
- package/dist/core/signals.d.ts.map +1 -0
- package/dist/core/signals.js +44 -0
- package/dist/core/signals.js.map +1 -0
- package/dist/core/spec.d.ts +8 -0
- package/dist/core/spec.d.ts.map +1 -0
- package/dist/core/spec.js +75 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/validation.d.ts +21 -0
- package/dist/core/validation.d.ts.map +1 -0
- package/dist/core/validation.js +126 -0
- package/dist/core/validation.js.map +1 -0
- package/dist/daemon/control.d.ts +9 -0
- package/dist/daemon/control.d.ts.map +1 -0
- package/dist/daemon/control.js +88 -0
- package/dist/daemon/control.js.map +1 -0
- package/dist/daemon/server.d.ts +8 -0
- package/dist/daemon/server.d.ts.map +1 -0
- package/dist/daemon/server.js +129 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/updater.d.ts +21 -0
- package/dist/daemon/updater.d.ts.map +1 -0
- package/dist/daemon/updater.js +159 -0
- package/dist/daemon/updater.js.map +1 -0
- package/dist/keychain/index.d.ts +8 -0
- package/dist/keychain/index.d.ts.map +1 -0
- package/dist/keychain/index.js +243 -0
- package/dist/keychain/index.js.map +1 -0
- package/dist/keychain/windows.d.ts +5 -0
- package/dist/keychain/windows.d.ts.map +1 -0
- package/dist/keychain/windows.js +65 -0
- package/dist/keychain/windows.js.map +1 -0
- package/dist/logging/logger.d.ts +12 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +127 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/logging/rotation.d.ts +9 -0
- package/dist/logging/rotation.d.ts.map +1 -0
- package/dist/logging/rotation.js +85 -0
- package/dist/logging/rotation.js.map +1 -0
- package/dist/logging/trace.d.ts +7 -0
- package/dist/logging/trace.d.ts.map +1 -0
- package/dist/logging/trace.js +50 -0
- package/dist/logging/trace.js.map +1 -0
- package/dist/mcp/client.d.ts +37 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/client.js +111 -0
- package/dist/mcp/client.js.map +1 -0
- package/dist/mcp/http-transport.d.ts +30 -0
- package/dist/mcp/http-transport.d.ts.map +1 -0
- package/dist/mcp/http-transport.js +109 -0
- package/dist/mcp/http-transport.js.map +1 -0
- package/dist/mcp/oauth.d.ts +23 -0
- package/dist/mcp/oauth.d.ts.map +1 -0
- package/dist/mcp/oauth.js +235 -0
- package/dist/mcp/oauth.js.map +1 -0
- package/dist/mcp/registry.d.ts +5 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +35 -0
- package/dist/mcp/registry.js.map +1 -0
- package/dist/memory/cold.d.ts +16 -0
- package/dist/memory/cold.d.ts.map +1 -0
- package/dist/memory/cold.js +244 -0
- package/dist/memory/cold.js.map +1 -0
- package/dist/memory/graph.d.ts +19 -0
- package/dist/memory/graph.d.ts.map +1 -0
- package/dist/memory/graph.js +102 -0
- package/dist/memory/graph.js.map +1 -0
- package/dist/memory/hot.d.ts +26 -0
- package/dist/memory/hot.d.ts.map +1 -0
- package/dist/memory/hot.js +58 -0
- package/dist/memory/hot.js.map +1 -0
- package/dist/memory/index.d.ts +7 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +26 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/memory/learning.d.ts +18 -0
- package/dist/memory/learning.d.ts.map +1 -0
- package/dist/memory/learning.js +83 -0
- package/dist/memory/learning.js.map +1 -0
- package/dist/memory/retrieval.d.ts +21 -0
- package/dist/memory/retrieval.d.ts.map +1 -0
- package/dist/memory/retrieval.js +114 -0
- package/dist/memory/retrieval.js.map +1 -0
- package/dist/memory/warm.d.ts +9 -0
- package/dist/memory/warm.d.ts.map +1 -0
- package/dist/memory/warm.js +150 -0
- package/dist/memory/warm.js.map +1 -0
- package/dist/migrations/runner.d.ts +18 -0
- package/dist/migrations/runner.d.ts.map +1 -0
- package/dist/migrations/runner.js +62 -0
- package/dist/migrations/runner.js.map +1 -0
- package/dist/models/adapter.d.ts +46 -0
- package/dist/models/adapter.d.ts.map +1 -0
- package/dist/models/adapter.js +85 -0
- package/dist/models/adapter.js.map +1 -0
- package/dist/models/anthropic.d.ts +17 -0
- package/dist/models/anthropic.d.ts.map +1 -0
- package/dist/models/anthropic.js +128 -0
- package/dist/models/anthropic.js.map +1 -0
- package/dist/models/cache.d.ts +5 -0
- package/dist/models/cache.d.ts.map +1 -0
- package/dist/models/cache.js +135 -0
- package/dist/models/cache.js.map +1 -0
- package/dist/models/circuit-breaker.d.ts +18 -0
- package/dist/models/circuit-breaker.d.ts.map +1 -0
- package/dist/models/circuit-breaker.js +63 -0
- package/dist/models/circuit-breaker.js.map +1 -0
- package/dist/models/cost.d.ts +13 -0
- package/dist/models/cost.d.ts.map +1 -0
- package/dist/models/cost.js +92 -0
- package/dist/models/cost.js.map +1 -0
- package/dist/models/llamacpp.d.ts +9 -0
- package/dist/models/llamacpp.d.ts.map +1 -0
- package/dist/models/llamacpp.js +15 -0
- package/dist/models/llamacpp.js.map +1 -0
- package/dist/models/lmstudio.d.ts +11 -0
- package/dist/models/lmstudio.d.ts.map +1 -0
- package/dist/models/lmstudio.js +18 -0
- package/dist/models/lmstudio.js.map +1 -0
- package/dist/models/local-catalog.d.ts +45 -0
- package/dist/models/local-catalog.d.ts.map +1 -0
- package/dist/models/local-catalog.js +314 -0
- package/dist/models/local-catalog.js.map +1 -0
- package/dist/models/ollama.d.ts +10 -0
- package/dist/models/ollama.d.ts.map +1 -0
- package/dist/models/ollama.js +98 -0
- package/dist/models/ollama.js.map +1 -0
- package/dist/models/openai.d.ts +16 -0
- package/dist/models/openai.d.ts.map +1 -0
- package/dist/models/openai.js +139 -0
- package/dist/models/openai.js.map +1 -0
- package/dist/models/provider.d.ts +7 -0
- package/dist/models/provider.d.ts.map +1 -0
- package/dist/models/provider.js +39 -0
- package/dist/models/provider.js.map +1 -0
- package/dist/models/rate-limit.d.ts +13 -0
- package/dist/models/rate-limit.d.ts.map +1 -0
- package/dist/models/rate-limit.js +37 -0
- package/dist/models/rate-limit.js.map +1 -0
- package/dist/models/registry.d.ts +2 -0
- package/dist/models/registry.d.ts.map +1 -0
- package/dist/models/registry.js +69 -0
- package/dist/models/registry.js.map +1 -0
- package/dist/models/router.d.ts +26 -0
- package/dist/models/router.d.ts.map +1 -0
- package/dist/models/router.js +185 -0
- package/dist/models/router.js.map +1 -0
- package/dist/models/vllm.d.ts +13 -0
- package/dist/models/vllm.d.ts.map +1 -0
- package/dist/models/vllm.js +19 -0
- package/dist/models/vllm.js.map +1 -0
- package/dist/notifications/manager.d.ts +5 -0
- package/dist/notifications/manager.d.ts.map +1 -0
- package/dist/notifications/manager.js +65 -0
- package/dist/notifications/manager.js.map +1 -0
- package/dist/permissions/manager.d.ts +15 -0
- package/dist/permissions/manager.d.ts.map +1 -0
- package/dist/permissions/manager.js +159 -0
- package/dist/permissions/manager.js.map +1 -0
- package/dist/permissions/risk.d.ts +13 -0
- package/dist/permissions/risk.d.ts.map +1 -0
- package/dist/permissions/risk.js +43 -0
- package/dist/permissions/risk.js.map +1 -0
- package/dist/persistence/compression.d.ts +9 -0
- package/dist/persistence/compression.d.ts.map +1 -0
- package/dist/persistence/compression.js +126 -0
- package/dist/persistence/compression.js.map +1 -0
- package/dist/persistence/conversation-store.d.ts +67 -0
- package/dist/persistence/conversation-store.d.ts.map +1 -0
- package/dist/persistence/conversation-store.js +370 -0
- package/dist/persistence/conversation-store.js.map +1 -0
- package/dist/persistence/events.d.ts +4 -0
- package/dist/persistence/events.d.ts.map +1 -0
- package/dist/persistence/events.js +50 -0
- package/dist/persistence/events.js.map +1 -0
- package/dist/persistence/index-db.d.ts +65 -0
- package/dist/persistence/index-db.d.ts.map +1 -0
- package/dist/persistence/index-db.js +280 -0
- package/dist/persistence/index-db.js.map +1 -0
- package/dist/persistence/jsonl.d.ts +8 -0
- package/dist/persistence/jsonl.d.ts.map +1 -0
- package/dist/persistence/jsonl.js +90 -0
- package/dist/persistence/jsonl.js.map +1 -0
- package/dist/persistence/sessions.d.ts +5 -0
- package/dist/persistence/sessions.d.ts.map +1 -0
- package/dist/persistence/sessions.js +54 -0
- package/dist/persistence/sessions.js.map +1 -0
- package/dist/persistence/tasks.d.ts +7 -0
- package/dist/persistence/tasks.d.ts.map +1 -0
- package/dist/persistence/tasks.js +162 -0
- package/dist/persistence/tasks.js.map +1 -0
- package/dist/prompts/assembler.d.ts +29 -0
- package/dist/prompts/assembler.d.ts.map +1 -0
- package/dist/prompts/assembler.js +136 -0
- package/dist/prompts/assembler.js.map +1 -0
- package/dist/prompts/layers.d.ts +6 -0
- package/dist/prompts/layers.d.ts.map +1 -0
- package/dist/prompts/layers.js +60 -0
- package/dist/prompts/layers.js.map +1 -0
- package/dist/release/download.d.ts +19 -0
- package/dist/release/download.d.ts.map +1 -0
- package/dist/release/download.js +187 -0
- package/dist/release/download.js.map +1 -0
- package/dist/release/verify.d.ts +34 -0
- package/dist/release/verify.d.ts.map +1 -0
- package/dist/release/verify.js +127 -0
- package/dist/release/verify.js.map +1 -0
- package/dist/sandbox/fs.d.ts +10 -0
- package/dist/sandbox/fs.d.ts.map +1 -0
- package/dist/sandbox/fs.js +114 -0
- package/dist/sandbox/fs.js.map +1 -0
- package/dist/sandbox/shell.d.ts +20 -0
- package/dist/sandbox/shell.d.ts.map +1 -0
- package/dist/sandbox/shell.js +131 -0
- package/dist/sandbox/shell.js.map +1 -0
- package/dist/scheduler/dag.d.ts +7 -0
- package/dist/scheduler/dag.d.ts.map +1 -0
- package/dist/scheduler/dag.js +72 -0
- package/dist/scheduler/dag.js.map +1 -0
- package/dist/scheduler/resource-manager.d.ts +25 -0
- package/dist/scheduler/resource-manager.d.ts.map +1 -0
- package/dist/scheduler/resource-manager.js +101 -0
- package/dist/scheduler/resource-manager.js.map +1 -0
- package/dist/security/injection.d.ts +14 -0
- package/dist/security/injection.d.ts.map +1 -0
- package/dist/security/injection.js +46 -0
- package/dist/security/injection.js.map +1 -0
- package/dist/security/redact.d.ts +10 -0
- package/dist/security/redact.d.ts.map +1 -0
- package/dist/security/redact.js +89 -0
- package/dist/security/redact.js.map +1 -0
- package/dist/skills/loader.d.ts +4 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +142 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/marketplace.d.ts +15 -0
- package/dist/skills/marketplace.d.ts.map +1 -0
- package/dist/skills/marketplace.js +132 -0
- package/dist/skills/marketplace.js.map +1 -0
- package/dist/tools/apply-patch.d.ts +20 -0
- package/dist/tools/apply-patch.d.ts.map +1 -0
- package/dist/tools/apply-patch.js +195 -0
- package/dist/tools/apply-patch.js.map +1 -0
- package/dist/tools/ask-user.d.ts +12 -0
- package/dist/tools/ask-user.d.ts.map +1 -0
- package/dist/tools/ask-user.js +86 -0
- package/dist/tools/ask-user.js.map +1 -0
- package/dist/tools/delete-file.d.ts +10 -0
- package/dist/tools/delete-file.d.ts.map +1 -0
- package/dist/tools/delete-file.js +94 -0
- package/dist/tools/delete-file.js.map +1 -0
- package/dist/tools/edit-file.d.ts +20 -0
- package/dist/tools/edit-file.d.ts.map +1 -0
- package/dist/tools/edit-file.js +128 -0
- package/dist/tools/edit-file.js.map +1 -0
- package/dist/tools/format.d.ts +5 -0
- package/dist/tools/format.d.ts.map +1 -0
- package/dist/tools/format.js +131 -0
- package/dist/tools/format.js.map +1 -0
- package/dist/tools/git.d.ts +24 -0
- package/dist/tools/git.d.ts.map +1 -0
- package/dist/tools/git.js +122 -0
- package/dist/tools/git.js.map +1 -0
- package/dist/tools/glob.d.ts +12 -0
- package/dist/tools/glob.d.ts.map +1 -0
- package/dist/tools/glob.js +55 -0
- package/dist/tools/glob.js.map +1 -0
- package/dist/tools/grep.d.ts +19 -0
- package/dist/tools/grep.d.ts.map +1 -0
- package/dist/tools/grep.js +97 -0
- package/dist/tools/grep.js.map +1 -0
- package/dist/tools/init.d.ts +3 -0
- package/dist/tools/init.d.ts.map +1 -0
- package/dist/tools/init.js +66 -0
- package/dist/tools/init.js.map +1 -0
- package/dist/tools/list-dir.d.ts +16 -0
- package/dist/tools/list-dir.d.ts.map +1 -0
- package/dist/tools/list-dir.js +107 -0
- package/dist/tools/list-dir.js.map +1 -0
- package/dist/tools/move-file.d.ts +13 -0
- package/dist/tools/move-file.d.ts.map +1 -0
- package/dist/tools/move-file.js +100 -0
- package/dist/tools/move-file.js.map +1 -0
- package/dist/tools/read-file.d.ts +14 -0
- package/dist/tools/read-file.d.ts.map +1 -0
- package/dist/tools/read-file.js +99 -0
- package/dist/tools/read-file.js.map +1 -0
- package/dist/tools/registry.d.ts +10 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +30 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/run-command.d.ts +17 -0
- package/dist/tools/run-command.d.ts.map +1 -0
- package/dist/tools/run-command.js +73 -0
- package/dist/tools/run-command.js.map +1 -0
- package/dist/tools/run-tests.d.ts +16 -0
- package/dist/tools/run-tests.d.ts.map +1 -0
- package/dist/tools/run-tests.js +140 -0
- package/dist/tools/run-tests.js.map +1 -0
- package/dist/tools/web-browse.d.ts +10 -0
- package/dist/tools/web-browse.d.ts.map +1 -0
- package/dist/tools/web-browse.js +45 -0
- package/dist/tools/web-browse.js.map +1 -0
- package/dist/tools/web-fetch.d.ts +11 -0
- package/dist/tools/web-fetch.d.ts.map +1 -0
- package/dist/tools/web-fetch.js +43 -0
- package/dist/tools/web-fetch.js.map +1 -0
- package/dist/tools/web-search.d.ts +12 -0
- package/dist/tools/web-search.d.ts.map +1 -0
- package/dist/tools/web-search.js +52 -0
- package/dist/tools/web-search.js.map +1 -0
- package/dist/tools/write-file.d.ts +13 -0
- package/dist/tools/write-file.d.ts.map +1 -0
- package/dist/tools/write-file.js +100 -0
- package/dist/tools/write-file.js.map +1 -0
- package/dist/types/errors.d.ts +14 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/errors.js +55 -0
- package/dist/types/errors.js.map +1 -0
- package/dist/types/index.d.ts +267 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +38 -0
- package/dist/types/index.js.map +1 -0
- package/dist/ui/chat.d.ts +89 -0
- package/dist/ui/chat.d.ts.map +1 -0
- package/dist/ui/chat.js +311 -0
- package/dist/ui/chat.js.map +1 -0
- package/dist/ui/public/app.js +2113 -0
- package/dist/ui/public/index.html +78 -0
- package/dist/ui/public/styles.css +1703 -0
- package/dist/ui/server-errors.d.ts +24 -0
- package/dist/ui/server-errors.d.ts.map +1 -0
- package/dist/ui/server-errors.js +31 -0
- package/dist/ui/server-errors.js.map +1 -0
- package/dist/ui/server.d.ts +10 -0
- package/dist/ui/server.d.ts.map +1 -0
- package/dist/ui/server.js +815 -0
- package/dist/ui/server.js.map +1 -0
- package/dist/ui/task-runner.d.ts +71 -0
- package/dist/ui/task-runner.d.ts.map +1 -0
- package/dist/ui/task-runner.js +334 -0
- package/dist/ui/task-runner.js.map +1 -0
- package/dist/web/browse.d.ts +35 -0
- package/dist/web/browse.d.ts.map +1 -0
- package/dist/web/browse.js +166 -0
- package/dist/web/browse.js.map +1 -0
- package/dist/web/fetch.d.ts +18 -0
- package/dist/web/fetch.d.ts.map +1 -0
- package/dist/web/fetch.js +107 -0
- package/dist/web/fetch.js.map +1 -0
- package/dist/web/sanitize.d.ts +8 -0
- package/dist/web/sanitize.d.ts.map +1 -0
- package/dist/web/sanitize.js +58 -0
- package/dist/web/sanitize.js.map +1 -0
- package/dist/web/search.d.ts +12 -0
- package/dist/web/search.d.ts.map +1 -0
- package/dist/web/search.js +124 -0
- package/dist/web/search.js.map +1 -0
- package/install/install.ps1 +46 -0
- package/install/install.sh +72 -0
- package/package.json +89 -0
- package/scripts/bundle.js +26 -0
- package/scripts/copy-assets.js +33 -0
- package/scripts/link.sh +79 -0
- package/scripts/metrics.sh +33 -0
- package/scripts/postinstall.js +36 -0
|
@@ -0,0 +1,2113 @@
|
|
|
1
|
+
// Forge dashboard — vanilla ES modules. Honest layout, dismissable overlays,
|
|
2
|
+
// real command palette, Monaco-backed config editor.
|
|
3
|
+
|
|
4
|
+
const app = document.getElementById('app');
|
|
5
|
+
const toasts = document.getElementById('toasts');
|
|
6
|
+
const overlayHost = document.getElementById('overlay-host');
|
|
7
|
+
const navEl = document.getElementById('nav');
|
|
8
|
+
const statusDot = document.getElementById('status-dot');
|
|
9
|
+
const statusText = document.getElementById('status-text');
|
|
10
|
+
|
|
11
|
+
let currentProject = null;
|
|
12
|
+
let projectWs = null;
|
|
13
|
+
const taskConnections = new Map();
|
|
14
|
+
|
|
15
|
+
// ---------- Icons ----------
|
|
16
|
+
|
|
17
|
+
const ICON_PATHS = {
|
|
18
|
+
home: '<path d="M3 10l9-7 9 7v10a2 2 0 01-2 2h-4v-7H9v7H5a2 2 0 01-2-2V10z"/>',
|
|
19
|
+
list: '<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>',
|
|
20
|
+
play: '<path d="M5 3l14 9-14 9V3z"/>',
|
|
21
|
+
bolt: '<path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/>',
|
|
22
|
+
brain: '<path d="M12 3a3 3 0 00-3 3v0a3 3 0 00-3 3v1a3 3 0 00-2 2.83V14a3 3 0 003 3h1a3 3 0 003 3v0a3 3 0 003-3h1a3 3 0 003-3v-1.17A3 3 0 0018 10V9a3 3 0 00-3-3a3 3 0 00-3-3z"/>',
|
|
23
|
+
plug: '<path d="M9 2v6M15 2v6M7 8h10v3a5 5 0 01-5 5v6"/>',
|
|
24
|
+
star: '<path d="M12 2l3 7 7 1-5 5 1 7-6-4-6 4 1-7-5-5 7-1 3-7z"/>',
|
|
25
|
+
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>',
|
|
26
|
+
archive: '<rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v12h14V8M10 12h4"/>',
|
|
27
|
+
sparkle: '<path d="M12 3v6M12 15v6M3 12h6M15 12h6M6 6l4 4M14 14l4 4M6 18l4-4M14 10l4-4"/>',
|
|
28
|
+
coin: '<circle cx="12" cy="12" r="9"/><path d="M12 6v12M8 9h6a2 2 0 010 4H10a2 2 0 000 4h6"/>',
|
|
29
|
+
gear: '<circle cx="12" cy="12" r="3"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>',
|
|
30
|
+
heart: '<path d="M20.8 5.6a5.5 5.5 0 00-7.8 0L12 6.6l-1-1a5.5 5.5 0 00-7.8 7.8l1 1L12 22l7.8-7.6 1-1a5.5 5.5 0 000-7.8z"/>',
|
|
31
|
+
arrow: '<path d="M5 12h14M13 5l7 7-7 7"/>',
|
|
32
|
+
close: '<path d="M6 6l12 12M18 6L6 18"/>',
|
|
33
|
+
wrench: '<path d="M14.7 6.3a4 4 0 00-5.6 5.6L3 18l3 3 6.1-6.1a4 4 0 005.6-5.6l-2.5 2.5-2.5-2.5 2.5-2.5a4 4 0 00-2.5-0.5z"/>',
|
|
34
|
+
test: '<path d="M10 2v7l-5 9a2 2 0 002 3h10a2 2 0 002-3l-5-9V2M9 14h6"/>',
|
|
35
|
+
shield: '<path d="M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6l8-4z"/>',
|
|
36
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/>',
|
|
37
|
+
book: '<path d="M4 4h12a4 4 0 014 4v14H8a4 4 0 01-4-4V4z"/><path d="M4 18h16"/>',
|
|
38
|
+
bug: '<path d="M8 10v4a4 4 0 108 0v-4M8 10a4 4 0 018 0M8 10H4M16 10h4M6 14H3M18 14h3M6 18H3M18 18h3M12 4V2M9 4l-2-2M15 4l2-2"/>',
|
|
39
|
+
refresh: '<path d="M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5"/>',
|
|
40
|
+
rocket: '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09zM12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
|
|
41
|
+
check: '<path d="M5 12l5 5L20 7"/>',
|
|
42
|
+
chat: '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z"/>',
|
|
43
|
+
send: '<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>',
|
|
44
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
45
|
+
trash: '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M10 11v6M14 11v6M5 6l1 14a2 2 0 002 2h8a2 2 0 002-2l1-14"/>',
|
|
46
|
+
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21v-1a6 6 0 016-6h4a6 6 0 016 6v1"/>',
|
|
47
|
+
robot: '<rect x="3" y="7" width="18" height="12" rx="2"/><path d="M12 2v5M9 12h.01M15 12h.01M9 16h6"/>',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const icon = (name) =>
|
|
51
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6">${ICON_PATHS[name] || ''}</svg>`;
|
|
52
|
+
|
|
53
|
+
// ---------- Navigation ----------
|
|
54
|
+
|
|
55
|
+
const NAV = [
|
|
56
|
+
{ group: 'overview', items: [
|
|
57
|
+
{ id: 'dashboard', label: 'Dashboard', icon: 'home' },
|
|
58
|
+
{ id: 'tasks', label: 'Tasks', icon: 'list' },
|
|
59
|
+
]},
|
|
60
|
+
{ group: 'run', items: [
|
|
61
|
+
{ id: 'chat', label: 'Chat', icon: 'chat' },
|
|
62
|
+
{ id: 'run', label: 'New task', icon: 'play' },
|
|
63
|
+
{ id: 'active', label: 'Active', icon: 'bolt', live: true },
|
|
64
|
+
]},
|
|
65
|
+
{ group: 'runtime', items: [
|
|
66
|
+
{ id: 'models', label: 'Models', icon: 'brain' },
|
|
67
|
+
{ id: 'mcp', label: 'MCP', icon: 'plug' },
|
|
68
|
+
{ id: 'skills', label: 'Skills', icon: 'star' },
|
|
69
|
+
{ id: 'web', label: 'Web', icon: 'globe' },
|
|
70
|
+
]},
|
|
71
|
+
{ group: 'memory', items: [
|
|
72
|
+
{ id: 'memory', label: 'Cold memory', icon: 'archive' },
|
|
73
|
+
{ id: 'learning', label: 'Learning', icon: 'sparkle' },
|
|
74
|
+
]},
|
|
75
|
+
{ group: 'ops', items: [
|
|
76
|
+
{ id: 'cost', label: 'Cost', icon: 'coin' },
|
|
77
|
+
{ id: 'config', label: 'Config', icon: 'gear' },
|
|
78
|
+
{ id: 'doctor', label: 'Doctor', icon: 'heart' },
|
|
79
|
+
]},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const renderNav = () => {
|
|
83
|
+
navEl.innerHTML = NAV.map((g) => `
|
|
84
|
+
<div class="nav-group">
|
|
85
|
+
<div class="nav-group-title">${g.group}</div>
|
|
86
|
+
${g.items.map((it) => `
|
|
87
|
+
<button class="nav-item" data-view="${it.id}">
|
|
88
|
+
<span class="nav-icon">${icon(it.icon)}</span>
|
|
89
|
+
<span>${esc(it.label)}</span>
|
|
90
|
+
${it.kbd ? `<kbd>${it.kbd}</kbd>` : ''}
|
|
91
|
+
${it.live ? `<span class="badge-live" data-active-count hidden>0</span>` : ''}
|
|
92
|
+
</button>
|
|
93
|
+
`).join('')}
|
|
94
|
+
</div>
|
|
95
|
+
`).join('');
|
|
96
|
+
navEl.querySelectorAll('[data-view]').forEach((b) =>
|
|
97
|
+
b.addEventListener('click', () => setView(b.dataset.view)),
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------- Utilities ----------
|
|
102
|
+
|
|
103
|
+
const esc = (s) =>
|
|
104
|
+
String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
105
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
106
|
+
}[c]));
|
|
107
|
+
|
|
108
|
+
const fmtDate = (iso) => {
|
|
109
|
+
try {
|
|
110
|
+
const d = new Date(iso);
|
|
111
|
+
const diff = (Date.now() - d.getTime()) / 1000;
|
|
112
|
+
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
|
113
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
114
|
+
if (diff < 86_400) return `${Math.floor(diff / 3600)}h ago`;
|
|
115
|
+
return d.toLocaleDateString();
|
|
116
|
+
} catch { return iso; }
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const badge = (status) => `<span class="badge badge-${esc(status)}">${esc(status)}</span>`;
|
|
120
|
+
|
|
121
|
+
const toast = (message, kind = 'info') => {
|
|
122
|
+
const el = document.createElement('div');
|
|
123
|
+
el.className = `toast toast-${kind}`;
|
|
124
|
+
el.textContent = message;
|
|
125
|
+
toasts.appendChild(el);
|
|
126
|
+
setTimeout(() => el.remove(), 4000);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const api = async (path, opts = {}) => {
|
|
130
|
+
const init = { ...opts };
|
|
131
|
+
// Auto-serialize plain-object bodies.
|
|
132
|
+
if (init.body && typeof init.body === 'object' && !(init.body instanceof FormData) && !(init.body instanceof Blob)) {
|
|
133
|
+
init.headers = { 'content-type': 'application/json', ...(init.headers || {}) };
|
|
134
|
+
init.body = JSON.stringify(init.body);
|
|
135
|
+
}
|
|
136
|
+
const r = await fetch(path, init);
|
|
137
|
+
if (!r.ok) {
|
|
138
|
+
const txt = await r.text();
|
|
139
|
+
throw new Error(`${path}: ${r.status} ${txt.slice(0, 200)}`);
|
|
140
|
+
}
|
|
141
|
+
return r.status === 204 ? null : r.json();
|
|
142
|
+
};
|
|
143
|
+
const apiPost = (path, body) =>
|
|
144
|
+
api(path, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'content-type': 'application/json' },
|
|
147
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const syntaxHighlight = (obj) =>
|
|
151
|
+
JSON.stringify(obj, null, 2)
|
|
152
|
+
.replace(/&/g, '&')
|
|
153
|
+
.replace(/</g, '<')
|
|
154
|
+
.replace(/>/g, '>')
|
|
155
|
+
.replace(/("[^"]+")\s*:/g, '<span class="json-key">$1</span>:')
|
|
156
|
+
.replace(/: ("(?:[^"\\]|\\.)*")/g, ': <span class="json-string">$1</span>')
|
|
157
|
+
.replace(/: (-?\d+(?:\.\d+)?)/g, ': <span class="json-number">$1</span>')
|
|
158
|
+
.replace(/: (true|false)/g, ': <span class="json-boolean">$1</span>')
|
|
159
|
+
.replace(/: (null)/g, ': <span class="json-null">$1</span>');
|
|
160
|
+
|
|
161
|
+
const pageHeader = (title, subtitle = '', actions = '') => `
|
|
162
|
+
<header class="page-header">
|
|
163
|
+
<div>
|
|
164
|
+
<h1 class="page-title">${esc(title)}</h1>
|
|
165
|
+
${subtitle ? `<div class="page-subtitle">${esc(subtitle)}</div>` : ''}
|
|
166
|
+
</div>
|
|
167
|
+
<div class="page-actions">${actions}</div>
|
|
168
|
+
</header>`;
|
|
169
|
+
|
|
170
|
+
const sectionShell = (title, meta, body) => `
|
|
171
|
+
<section class="section">
|
|
172
|
+
${title || meta ? `<div class="section-head">
|
|
173
|
+
<h2>${esc(title || '')}</h2>
|
|
174
|
+
${meta ? `<span class="section-meta">${meta}</span>` : ''}
|
|
175
|
+
</div>` : ''}
|
|
176
|
+
<div class="section-body">${body}</div>
|
|
177
|
+
</section>`;
|
|
178
|
+
|
|
179
|
+
const skeletonRows = (n = 3) =>
|
|
180
|
+
`<div class="skeleton-list">${
|
|
181
|
+
Array.from({ length: n }).map(() => `<div class="skeleton w-90"></div><div class="skeleton w-50"></div>`).join('')
|
|
182
|
+
}</div>`;
|
|
183
|
+
|
|
184
|
+
const greeting = () => {
|
|
185
|
+
const h = new Date().getHours();
|
|
186
|
+
if (h < 5) return 'You’re up late.';
|
|
187
|
+
if (h < 12) return 'Good morning.';
|
|
188
|
+
if (h < 17) return 'Good afternoon.';
|
|
189
|
+
if (h < 22) return 'Good evening.';
|
|
190
|
+
return 'You’re up late.';
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Wrap any view body in `.content-inner` so the content area's generous
|
|
194
|
+
// padding doesn't feel empty on wide monitors.
|
|
195
|
+
const page = (html) => `<div class="content-inner">${html}</div>`;
|
|
196
|
+
|
|
197
|
+
// ---------- Prompt history (arrow-up recall, UI-wide) ----------
|
|
198
|
+
//
|
|
199
|
+
// Mirrors the REPL's up/down recall. Shared pool across the hero, run, and
|
|
200
|
+
// chat inputs so a prompt you typed on one surface can be pulled back on
|
|
201
|
+
// another. Persists to localStorage; capped at 500 entries.
|
|
202
|
+
|
|
203
|
+
const PROMPT_HIST_KEY = 'forge:prompt-history';
|
|
204
|
+
const PROMPT_HIST_MAX = 500;
|
|
205
|
+
|
|
206
|
+
const loadPromptHistory = () => {
|
|
207
|
+
try {
|
|
208
|
+
const raw = localStorage.getItem(PROMPT_HIST_KEY);
|
|
209
|
+
if (!raw) return [];
|
|
210
|
+
const arr = JSON.parse(raw);
|
|
211
|
+
return Array.isArray(arr) ? arr.filter((s) => typeof s === 'string' && s) : [];
|
|
212
|
+
} catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const savePromptHistory = (arr) => {
|
|
218
|
+
try {
|
|
219
|
+
localStorage.setItem(PROMPT_HIST_KEY, JSON.stringify(arr.slice(-PROMPT_HIST_MAX)));
|
|
220
|
+
} catch { /* quota — ignore */ }
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const pushPromptHistory = (text) => {
|
|
224
|
+
const s = (text || '').trim();
|
|
225
|
+
if (!s) return;
|
|
226
|
+
const hist = loadPromptHistory();
|
|
227
|
+
// Drop an immediately-repeated entry so a double submit doesn't duplicate.
|
|
228
|
+
if (hist[hist.length - 1] === s) return;
|
|
229
|
+
hist.push(s);
|
|
230
|
+
savePromptHistory(hist);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Wire ArrowUp/ArrowDown history recall on an `<input>` or `<textarea>`.
|
|
235
|
+
*
|
|
236
|
+
* UX rules (mirrors bash/zsh + the REPL line editor):
|
|
237
|
+
* - ArrowUp when the field is empty OR the cursor is on the first line
|
|
238
|
+
* → replace value with the previous history entry
|
|
239
|
+
* - ArrowDown on the last line → step newer, eventually restoring the
|
|
240
|
+
* live draft that was in progress when navigation began
|
|
241
|
+
* - Esc while navigating → restore the live draft and stop navigating
|
|
242
|
+
* - Typing at any point cancels navigation so the next up-arrow picks up
|
|
243
|
+
* from the newest entry again
|
|
244
|
+
*/
|
|
245
|
+
const attachPromptHistory = (el) => {
|
|
246
|
+
if (!el || el.dataset.historyAttached === '1') return;
|
|
247
|
+
el.dataset.historyAttached = '1';
|
|
248
|
+
|
|
249
|
+
let hist = loadPromptHistory();
|
|
250
|
+
// -1 = live draft, 0..hist.length-1 = navigating from newest → oldest
|
|
251
|
+
let idx = -1;
|
|
252
|
+
let stash = '';
|
|
253
|
+
|
|
254
|
+
const isTextarea = el.tagName === 'TEXTAREA';
|
|
255
|
+
const onFirstLine = () => {
|
|
256
|
+
if (!isTextarea) return true;
|
|
257
|
+
const before = el.value.slice(0, el.selectionStart ?? 0);
|
|
258
|
+
return !before.includes('\n');
|
|
259
|
+
};
|
|
260
|
+
const onLastLine = () => {
|
|
261
|
+
if (!isTextarea) return true;
|
|
262
|
+
const after = el.value.slice(el.selectionEnd ?? el.value.length);
|
|
263
|
+
return !after.includes('\n');
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const show = (value) => {
|
|
267
|
+
el.value = value;
|
|
268
|
+
// Place caret at the end so the next ArrowUp keeps navigating.
|
|
269
|
+
const end = value.length;
|
|
270
|
+
try { el.setSelectionRange(end, end); } catch { /* not all inputs support it */ }
|
|
271
|
+
if (isTextarea && typeof autosize === 'function') {
|
|
272
|
+
try { autosize(el); } catch { /* autosize is optional */ }
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
el.addEventListener('keydown', (e) => {
|
|
277
|
+
if (e.key === 'ArrowUp') {
|
|
278
|
+
if (!onFirstLine()) return;
|
|
279
|
+
hist = loadPromptHistory();
|
|
280
|
+
if (!hist.length) return;
|
|
281
|
+
if (idx === -1) stash = el.value;
|
|
282
|
+
idx = Math.min(idx + 1, hist.length - 1);
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
show(hist[hist.length - 1 - idx]);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (e.key === 'ArrowDown') {
|
|
288
|
+
if (idx === -1 || !onLastLine()) return;
|
|
289
|
+
idx--;
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
show(idx === -1 ? stash : hist[hist.length - 1 - idx]);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (e.key === 'Escape' && idx !== -1) {
|
|
295
|
+
idx = -1;
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
show(stash);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Any ordinary typing cancels navigation.
|
|
301
|
+
if (idx !== -1 && e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
302
|
+
idx = -1;
|
|
303
|
+
stash = '';
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// ---------- View router ----------
|
|
309
|
+
|
|
310
|
+
const views = {};
|
|
311
|
+
let currentView = 'dashboard';
|
|
312
|
+
|
|
313
|
+
const setView = (name) => {
|
|
314
|
+
currentView = name;
|
|
315
|
+
document.querySelectorAll('.nav-item').forEach((b) =>
|
|
316
|
+
b.classList.toggle('active', b.dataset.view === name),
|
|
317
|
+
);
|
|
318
|
+
if (views[name]) return views[name]();
|
|
319
|
+
app.innerHTML = page(`<div class="empty"><div class="empty-title">Unknown view</div><div>${esc(name)}</div></div>`);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// ---------- Dashboard ----------
|
|
323
|
+
|
|
324
|
+
const QUICK_CHIPS = [
|
|
325
|
+
{ label: 'Add tests', prompt: 'Write unit tests for the most important module in this project. Auto-detect the test framework.', icon: 'test' },
|
|
326
|
+
{ label: 'Audit security', prompt: 'Audit this codebase for common security issues. Report findings by severity, do not modify files.', icon: 'shield' },
|
|
327
|
+
{ label: 'Refactor', prompt: 'Identify the module most in need of refactoring and produce a plan with low-risk small steps.', icon: 'wrench' },
|
|
328
|
+
{ label: 'Generate docs', prompt: 'Generate README and architecture documentation for this project.', icon: 'book' },
|
|
329
|
+
{ label: 'Fix a bug', prompt: 'Investigate any failing tests or obvious bugs and fix the root cause.', icon: 'bug' },
|
|
330
|
+
{ label: 'Ship it', prompt: 'Prepare this repo for a release: changelog, version bump, build check.', icon: 'rocket' },
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
views.dashboard = async () => {
|
|
334
|
+
app.innerHTML = page(skeletonRows(4));
|
|
335
|
+
try {
|
|
336
|
+
const [status, projects, tasks, activeRes, cost] = await Promise.all([
|
|
337
|
+
api('/api/status'),
|
|
338
|
+
api('/api/projects'),
|
|
339
|
+
api('/api/tasks?limit=8'),
|
|
340
|
+
api('/api/tasks/active'),
|
|
341
|
+
api('/api/cost').catch(() => ({ totals: { calls: 0, tokens: 0, usd: 0 } })),
|
|
342
|
+
]);
|
|
343
|
+
currentProject = currentProject || projects[0]?.path || status.cwd;
|
|
344
|
+
updateActiveBadge(activeRes.active);
|
|
345
|
+
|
|
346
|
+
const live = (activeRes.active || []).filter((t) => t.status === 'running' || t.status === 'awaiting');
|
|
347
|
+
|
|
348
|
+
// Recent prompts extracted from task titles (most recent first, de-duped)
|
|
349
|
+
const seenPrompts = new Set();
|
|
350
|
+
const recentPrompts = [];
|
|
351
|
+
for (const t of tasks) {
|
|
352
|
+
const title = (t.title || '').trim();
|
|
353
|
+
if (!title || seenPrompts.has(title.toLowerCase())) continue;
|
|
354
|
+
seenPrompts.add(title.toLowerCase());
|
|
355
|
+
recentPrompts.push({ id: t.id, title, status: t.status, updated: t.updated_at });
|
|
356
|
+
if (recentPrompts.length >= 5) break;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const chipsHtml = QUICK_CHIPS.map((c) => `
|
|
360
|
+
<button class="chip" data-chip="${esc(c.prompt)}">
|
|
361
|
+
<span class="chip-icon">${icon(c.icon)}</span>
|
|
362
|
+
${esc(c.label)}
|
|
363
|
+
</button>
|
|
364
|
+
`).join('');
|
|
365
|
+
|
|
366
|
+
const liveSection = live.length
|
|
367
|
+
? sectionShell(`Active · ${live.length}`, '', live.map((t) => `
|
|
368
|
+
<div class="row">
|
|
369
|
+
${badge(t.status)}
|
|
370
|
+
<div class="row-main">
|
|
371
|
+
<div class="title">${esc(t.prompt.slice(0, 140))}</div>
|
|
372
|
+
<div class="sub"><code>${esc(t.taskId)}</code><span>${esc(t.mode)}</span></div>
|
|
373
|
+
</div>
|
|
374
|
+
<button class="btn btn-ghost btn-sm" data-open-task="${esc(t.taskId)}">Open</button>
|
|
375
|
+
</div>`).join(''))
|
|
376
|
+
: '';
|
|
377
|
+
|
|
378
|
+
const recentSection = sectionShell('Recent tasks', tasks.length ? `${tasks.length} shown` : '',
|
|
379
|
+
tasks.length
|
|
380
|
+
? `<div class="table-wrap"><table class="table sortable" data-default-sort="4">
|
|
381
|
+
<thead><tr>
|
|
382
|
+
<th data-sort="text">id</th>
|
|
383
|
+
<th data-sort="text">status</th>
|
|
384
|
+
<th data-sort="text">mode</th>
|
|
385
|
+
<th data-sort="text">intent</th>
|
|
386
|
+
<th data-sort="text" class="col-wrap">title</th>
|
|
387
|
+
<th data-sort="date" data-default-dir="desc">updated</th>
|
|
388
|
+
</tr></thead>
|
|
389
|
+
<tbody>${tasks.map((t) => `<tr>
|
|
390
|
+
<td><code>${esc(t.id)}</code></td>
|
|
391
|
+
<td>${badge(t.status)}</td>
|
|
392
|
+
<td>${esc(t.mode)}</td>
|
|
393
|
+
<td>${esc(t.intent ?? '—')}</td>
|
|
394
|
+
<td class="col-wrap">${esc((t.title ?? '').slice(0, 80) || '—')}</td>
|
|
395
|
+
<td data-raw="${esc(t.updated_at ?? '')}">${esc(fmtDate(t.updated_at))}</td>
|
|
396
|
+
</tr>`).join('')}</tbody>
|
|
397
|
+
</table></div>`
|
|
398
|
+
: '<div class="empty"><div class="empty-title">No tasks yet</div><div>Type a prompt above or press <kbd>⌘ K</kbd>.</div></div>');
|
|
399
|
+
|
|
400
|
+
const providerSection = sectionShell('Runtime',
|
|
401
|
+
status.daemon.running ? `daemon pid ${status.daemon.pid}` : 'daemon stopped',
|
|
402
|
+
`<div class="section-body-padded" style="display:flex;flex-direction:column;gap:12px">
|
|
403
|
+
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
|
404
|
+
${(status.providers || []).map((p) => `
|
|
405
|
+
<span class="pill ${p.available ? 'ok' : 'down'}"><span class="dot"></span>${esc(p.name)}</span>
|
|
406
|
+
`).join('')}
|
|
407
|
+
</div>
|
|
408
|
+
<div style="font-size:12px;color:var(--muted)">provider · <strong style="color:var(--fg-2)">${esc(status.provider)}</strong></div>
|
|
409
|
+
<div style="font-size:12px;color:var(--muted)">mode · <strong style="color:var(--fg-2)">${esc(status.defaultMode)}</strong></div>
|
|
410
|
+
<div style="font-size:12px;color:var(--muted)">channel · <strong style="color:var(--fg-2)">${esc(status.channel)}</strong>${status.version ? ' · v' + esc(status.version) : ''}</div>
|
|
411
|
+
</div>`);
|
|
412
|
+
|
|
413
|
+
const recentPromptsSection = recentPrompts.length
|
|
414
|
+
? sectionShell('Recent prompts', 'click to rerun', recentPrompts.map((r) => `
|
|
415
|
+
<div class="row" data-rerun="${esc(r.title)}" style="cursor:pointer">
|
|
416
|
+
${badge(r.status)}
|
|
417
|
+
<div class="row-main">
|
|
418
|
+
<div class="title">${esc(r.title.slice(0, 140))}</div>
|
|
419
|
+
<div class="sub"><code>${esc(r.id)}</code><span>${esc(fmtDate(r.updated))}</span></div>
|
|
420
|
+
</div>
|
|
421
|
+
<span class="nav-icon" style="color:var(--muted)">${icon('refresh')}</span>
|
|
422
|
+
</div>`).join(''))
|
|
423
|
+
: '';
|
|
424
|
+
|
|
425
|
+
app.innerHTML = page(`
|
|
426
|
+
<section class="hero">
|
|
427
|
+
<h1>${esc(greeting())} What should Forge build?</h1>
|
|
428
|
+
<div class="hero-sub">Type a prompt. Or press <kbd>⌘ K</kbd> for the command palette.</div>
|
|
429
|
+
<div class="hero-input-wrap">
|
|
430
|
+
<input class="hero-input" id="hero-input" placeholder="e.g. add a /health endpoint to the Express server" autocomplete="off" />
|
|
431
|
+
<button class="hero-submit" id="hero-go" aria-label="Run">${icon('arrow')}</button>
|
|
432
|
+
</div>
|
|
433
|
+
<div class="hero-chips">${chipsHtml}</div>
|
|
434
|
+
<div class="hero-hint"><kbd>⏎</kbd> run · <kbd>⇧ ⏎</kbd> open advanced form</div>
|
|
435
|
+
</section>
|
|
436
|
+
|
|
437
|
+
<div class="stats">
|
|
438
|
+
<div class="stat">
|
|
439
|
+
<div class="label">Active</div>
|
|
440
|
+
<div class="value">${live.length}</div>
|
|
441
|
+
<div class="sub">${live.length ? 'running now' : 'idle'}</div>
|
|
442
|
+
</div>
|
|
443
|
+
<div class="stat">
|
|
444
|
+
<div class="label">Tasks</div>
|
|
445
|
+
<div class="value">${tasks.length}</div>
|
|
446
|
+
<div class="sub">shown · ${projects.length} projects</div>
|
|
447
|
+
</div>
|
|
448
|
+
<div class="stat">
|
|
449
|
+
<div class="label">Spend</div>
|
|
450
|
+
<div class="value">$${Number(cost.totals?.usd ?? 0).toFixed(3)}</div>
|
|
451
|
+
<div class="sub">${Number(cost.totals?.tokens ?? 0).toLocaleString()} tokens</div>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="stat">
|
|
454
|
+
<div class="label">Provider</div>
|
|
455
|
+
<div class="value" style="font-size:18px">${esc(status.provider)}</div>
|
|
456
|
+
<div class="sub">mode · ${esc(status.defaultMode)}</div>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
${liveSection}
|
|
461
|
+
|
|
462
|
+
<div class="grid-2">
|
|
463
|
+
${recentSection}
|
|
464
|
+
<div style="display:flex;flex-direction:column;gap:16px">
|
|
465
|
+
${providerSection}
|
|
466
|
+
${recentPromptsSection}
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
`);
|
|
470
|
+
|
|
471
|
+
const input = document.getElementById('hero-input');
|
|
472
|
+
const go = async (prompt = null) => {
|
|
473
|
+
const p = (prompt ?? input.value).trim();
|
|
474
|
+
if (!p) return;
|
|
475
|
+
pushPromptHistory(p);
|
|
476
|
+
try {
|
|
477
|
+
const { taskId } = await apiPost('/api/tasks/run', { prompt: p, autoApprove: false });
|
|
478
|
+
toast('Task started', 'ok');
|
|
479
|
+
openTask(taskId);
|
|
480
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
481
|
+
};
|
|
482
|
+
document.getElementById('hero-go').addEventListener('click', () => go());
|
|
483
|
+
attachPromptHistory(input);
|
|
484
|
+
input.addEventListener('keydown', (e) => {
|
|
485
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); go(); }
|
|
486
|
+
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); setView('run'); }
|
|
487
|
+
});
|
|
488
|
+
app.querySelectorAll('[data-chip]').forEach((b) =>
|
|
489
|
+
b.addEventListener('click', () => { input.value = b.dataset.chip; input.focus(); }),
|
|
490
|
+
);
|
|
491
|
+
app.querySelectorAll('[data-open-task]').forEach((b) =>
|
|
492
|
+
b.addEventListener('click', () => openTask(b.dataset.openTask)),
|
|
493
|
+
);
|
|
494
|
+
app.querySelectorAll('[data-rerun]').forEach((el) =>
|
|
495
|
+
el.addEventListener('click', () => { input.value = el.dataset.rerun; input.focus(); }),
|
|
496
|
+
);
|
|
497
|
+
setTimeout(() => input.focus(), 30);
|
|
498
|
+
} catch (e) {
|
|
499
|
+
app.innerHTML = page(`<div class="empty"><div class="empty-title">Failed to load</div><div>${esc(e.message)}</div></div>`);
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// ---------- Chat (multi-turn conversations) ----------
|
|
504
|
+
|
|
505
|
+
const chatState = {
|
|
506
|
+
sessionId: null,
|
|
507
|
+
poll: null,
|
|
508
|
+
ws: null,
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const closeChatWs = () => {
|
|
512
|
+
if (chatState.ws) {
|
|
513
|
+
try { chatState.ws.close(); } catch {}
|
|
514
|
+
chatState.ws = null;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const CHAT_MODES = ['fast', 'balanced', 'heavy', 'plan', 'audit', 'debug', 'architect'];
|
|
519
|
+
|
|
520
|
+
views.chat = async () => {
|
|
521
|
+
app.innerHTML = page(`
|
|
522
|
+
<div class="chat-shell">
|
|
523
|
+
<aside class="chat-sidebar">
|
|
524
|
+
<div class="chat-sidebar-head">
|
|
525
|
+
<button id="chat-new" class="chat-new-btn">${icon('plus')}<span>New chat</span></button>
|
|
526
|
+
</div>
|
|
527
|
+
<div id="chat-list" class="chat-list"></div>
|
|
528
|
+
</aside>
|
|
529
|
+
<section class="chat-main" id="chat-main">
|
|
530
|
+
<div class="chat-empty">
|
|
531
|
+
<div class="chat-empty-icon">${icon('chat')}</div>
|
|
532
|
+
<div class="chat-empty-title">Start a conversation</div>
|
|
533
|
+
<div class="chat-empty-sub">Multi-turn follow-ups: Forge threads prior turns into each new plan.</div>
|
|
534
|
+
<button id="chat-empty-new" class="chat-empty-cta">${icon('plus')}<span>New chat</span></button>
|
|
535
|
+
</div>
|
|
536
|
+
</section>
|
|
537
|
+
</div>
|
|
538
|
+
`);
|
|
539
|
+
|
|
540
|
+
const projectPath = encodeURIComponent(currentProject || '');
|
|
541
|
+
|
|
542
|
+
const refreshList = async () => {
|
|
543
|
+
const sessions = await api(`/api/chat/sessions?projectPath=${projectPath}`).catch(() => []);
|
|
544
|
+
const host = document.getElementById('chat-list');
|
|
545
|
+
if (!host) return;
|
|
546
|
+
if (!sessions.length) {
|
|
547
|
+
host.innerHTML = `<div class="chat-list-empty">No conversations yet.</div>`;
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
host.innerHTML = sessions.map((s) => {
|
|
551
|
+
const badge = s.source === 'repl'
|
|
552
|
+
? `<span class="chat-src chat-src-repl">REPL</span>`
|
|
553
|
+
: `<span class="chat-src chat-src-chat">CHAT</span>`;
|
|
554
|
+
return `
|
|
555
|
+
<button class="chat-list-item${s.id === chatState.sessionId ? ' active' : ''}" data-id="${esc(s.id)}">
|
|
556
|
+
<div class="chat-list-title">${badge}${esc(s.title || 'Untitled')}</div>
|
|
557
|
+
<div class="chat-list-meta">
|
|
558
|
+
<span>${s.turns} turn${s.turns === 1 ? '' : 's'}</span>
|
|
559
|
+
<span>·</span>
|
|
560
|
+
<span>${esc(s.mode)}</span>
|
|
561
|
+
<span>·</span>
|
|
562
|
+
<span>${timeago(s.lastAt)}</span>
|
|
563
|
+
</div>
|
|
564
|
+
</button>
|
|
565
|
+
`;
|
|
566
|
+
}).join('');
|
|
567
|
+
host.querySelectorAll('.chat-list-item').forEach((el) =>
|
|
568
|
+
el.addEventListener('click', () => openSession(el.dataset.id)),
|
|
569
|
+
);
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const openSession = async (id) => {
|
|
573
|
+
chatState.sessionId = id;
|
|
574
|
+
stopPolling();
|
|
575
|
+
closeChatWs();
|
|
576
|
+
await refreshList();
|
|
577
|
+
await renderConversation();
|
|
578
|
+
startPolling();
|
|
579
|
+
openChatWs(id);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const openChatWs = (id) => {
|
|
583
|
+
try {
|
|
584
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
585
|
+
const qs = new URLSearchParams({ projectPath: currentProject || '' });
|
|
586
|
+
const ws = new WebSocket(`${proto}//${location.host}/ws/conversations/${id}?${qs}`);
|
|
587
|
+
ws.onmessage = async (ev) => {
|
|
588
|
+
try {
|
|
589
|
+
const msg = JSON.parse(ev.data);
|
|
590
|
+
if (msg && msg.kind === 'conversation.update' && chatState.sessionId === id) {
|
|
591
|
+
// Refresh conversation + sidebar on remote-authored updates so
|
|
592
|
+
// turns/status/files appear without waiting for the poller.
|
|
593
|
+
await renderConversation();
|
|
594
|
+
await refreshList();
|
|
595
|
+
}
|
|
596
|
+
} catch {}
|
|
597
|
+
};
|
|
598
|
+
ws.onclose = () => { if (chatState.ws === ws) chatState.ws = null; };
|
|
599
|
+
chatState.ws = ws;
|
|
600
|
+
} catch (err) {
|
|
601
|
+
// WebSocket unavailable is non-fatal — the poll loop covers updates.
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const newSession = async () => {
|
|
606
|
+
const body = { projectPath: currentProject, source: 'chat' };
|
|
607
|
+
const s = await api('/api/chat/sessions', { method: 'POST', body });
|
|
608
|
+
chatState.sessionId = s.meta ? s.meta.id : s.id;
|
|
609
|
+
await refreshList();
|
|
610
|
+
await renderConversation();
|
|
611
|
+
openChatWs(chatState.sessionId);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const renderConversation = async () => {
|
|
615
|
+
const host = document.getElementById('chat-main');
|
|
616
|
+
if (!host) return;
|
|
617
|
+
if (!chatState.sessionId) {
|
|
618
|
+
host.innerHTML = `
|
|
619
|
+
<div class="chat-empty">
|
|
620
|
+
<div class="chat-empty-icon">${icon('chat')}</div>
|
|
621
|
+
<div class="chat-empty-title">Start a conversation</div>
|
|
622
|
+
<div class="chat-empty-sub">Multi-turn follow-ups: Forge threads prior turns into each new plan.</div>
|
|
623
|
+
<button id="chat-empty-new" class="chat-empty-cta">${icon('plus')}<span>New chat</span></button>
|
|
624
|
+
</div>`;
|
|
625
|
+
host.querySelector('#chat-empty-new')?.addEventListener('click', newSession);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const sess = await api(`/api/chat/sessions/${chatState.sessionId}?projectPath=${projectPath}`).catch(() => null);
|
|
629
|
+
if (!sess) {
|
|
630
|
+
host.innerHTML = `<div class="chat-empty"><div class="chat-empty-title">Session not found.</div></div>`;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// Unified Conversation response: { meta, turns }. Fall back to the old
|
|
634
|
+
// flat shape for backward compat with any cached tabs.
|
|
635
|
+
const meta = sess.meta || sess;
|
|
636
|
+
const turns = sess.turns || [];
|
|
637
|
+
const isRunning = turns.some((t) => t.status === 'running' || t.status === 'pending');
|
|
638
|
+
const sourceBadge = meta.source === 'repl'
|
|
639
|
+
? `<span class="chat-src chat-src-repl" title="Created from the CLI REPL">REPL</span>`
|
|
640
|
+
: `<span class="chat-src chat-src-chat" title="Created from the Web UI">CHAT</span>`;
|
|
641
|
+
host.innerHTML = `
|
|
642
|
+
<header class="chat-header">
|
|
643
|
+
<div class="chat-header-title" title="${esc(meta.title || '')}">${sourceBadge}${esc(meta.title || '')}</div>
|
|
644
|
+
<div class="chat-header-meta">
|
|
645
|
+
<span class="chip chip-neutral">${esc(meta.mode || '')}</span>
|
|
646
|
+
<span class="chip chip-neutral">${turns.length} turn${turns.length === 1 ? '' : 's'}</span>
|
|
647
|
+
<button class="chat-header-del" id="chat-delete" title="Delete this chat">${icon('trash')}</button>
|
|
648
|
+
</div>
|
|
649
|
+
</header>
|
|
650
|
+
<div class="chat-turns" id="chat-turns">
|
|
651
|
+
${turns.map(turnHtml).join('')}
|
|
652
|
+
${isRunning ? `<div class="chat-running">${icon('bolt')}<span>Forge is working…</span></div>` : ''}
|
|
653
|
+
</div>
|
|
654
|
+
<form class="chat-composer" id="chat-composer">
|
|
655
|
+
<div class="chat-composer-inner">
|
|
656
|
+
<textarea id="chat-input" placeholder="Reply… (Shift+Enter for newline)" rows="1" ${isRunning ? 'disabled' : ''}></textarea>
|
|
657
|
+
<div class="chat-composer-bar">
|
|
658
|
+
<select id="chat-mode" class="chat-mode-select" ${isRunning ? 'disabled' : ''}>
|
|
659
|
+
${CHAT_MODES.map((m) => `<option value="${m}" ${m === meta.mode ? 'selected' : ''}>${m}</option>`).join('')}
|
|
660
|
+
</select>
|
|
661
|
+
<label class="chat-auto"><input type="checkbox" id="chat-auto"> auto-approve</label>
|
|
662
|
+
<button type="submit" class="chat-send" ${isRunning ? 'disabled' : ''}>${icon('send')}<span>Send</span></button>
|
|
663
|
+
</div>
|
|
664
|
+
</div>
|
|
665
|
+
</form>
|
|
666
|
+
`;
|
|
667
|
+
|
|
668
|
+
// Preserve scroll position on re-render: only auto-scroll if the user
|
|
669
|
+
// was already near the bottom (within 80px) — keeps them pinned as new
|
|
670
|
+
// content arrives, but doesn't rip them away mid-scroll if they're
|
|
671
|
+
// reading older turns.
|
|
672
|
+
const turnsEl = document.getElementById('chat-turns');
|
|
673
|
+
if (turnsEl) {
|
|
674
|
+
const prev = chatState.lastScroll;
|
|
675
|
+
const wasAtBottom = prev == null
|
|
676
|
+
? true
|
|
677
|
+
: prev.scrollHeight - (prev.scrollTop + prev.clientHeight) < 80;
|
|
678
|
+
turnsEl.scrollTop = wasAtBottom ? turnsEl.scrollHeight : prev.scrollTop;
|
|
679
|
+
turnsEl.addEventListener('scroll', () => {
|
|
680
|
+
chatState.lastScroll = {
|
|
681
|
+
scrollTop: turnsEl.scrollTop,
|
|
682
|
+
scrollHeight: turnsEl.scrollHeight,
|
|
683
|
+
clientHeight: turnsEl.clientHeight,
|
|
684
|
+
};
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Wire retry buttons on failed turns.
|
|
689
|
+
document.querySelectorAll('.chat-retry').forEach((btn) => {
|
|
690
|
+
btn.addEventListener('click', async () => {
|
|
691
|
+
const text = btn.dataset.input;
|
|
692
|
+
if (!text) return;
|
|
693
|
+
try {
|
|
694
|
+
await api(`/api/chat/sessions/${chatState.sessionId}/turns`, {
|
|
695
|
+
method: 'POST',
|
|
696
|
+
body: { input: text, mode: document.getElementById('chat-mode')?.value, projectPath: currentProject },
|
|
697
|
+
});
|
|
698
|
+
await renderConversation();
|
|
699
|
+
await refreshList();
|
|
700
|
+
startPolling();
|
|
701
|
+
} catch (err) {
|
|
702
|
+
toast('retry failed: ' + err.message, 'err');
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const input = document.getElementById('chat-input');
|
|
708
|
+
if (input && !isRunning) {
|
|
709
|
+
input.focus();
|
|
710
|
+
attachPromptHistory(input);
|
|
711
|
+
input.addEventListener('keydown', (e) => {
|
|
712
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
713
|
+
e.preventDefault();
|
|
714
|
+
document.getElementById('chat-composer').dispatchEvent(new Event('submit', { cancelable: true }));
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
input.addEventListener('input', () => autosize(input));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const form = document.getElementById('chat-composer');
|
|
721
|
+
if (form) {
|
|
722
|
+
form.addEventListener('submit', async (e) => {
|
|
723
|
+
e.preventDefault();
|
|
724
|
+
const text = (input.value || '').trim();
|
|
725
|
+
if (!text) return;
|
|
726
|
+
pushPromptHistory(text);
|
|
727
|
+
const mode = document.getElementById('chat-mode').value;
|
|
728
|
+
const auto = document.getElementById('chat-auto').checked;
|
|
729
|
+
input.disabled = true;
|
|
730
|
+
try {
|
|
731
|
+
await api(`/api/chat/sessions/${chatState.sessionId}/turns`, {
|
|
732
|
+
method: 'POST',
|
|
733
|
+
body: { input: text, mode, autoApprove: auto, projectPath: currentProject },
|
|
734
|
+
});
|
|
735
|
+
input.value = '';
|
|
736
|
+
} catch (err) {
|
|
737
|
+
toast('send failed: ' + err.message, 'err');
|
|
738
|
+
}
|
|
739
|
+
await renderConversation();
|
|
740
|
+
await refreshList();
|
|
741
|
+
startPolling();
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
document.getElementById('chat-delete')?.addEventListener('click', async () => {
|
|
746
|
+
if (!confirm('Delete this conversation? This cannot be undone.')) return;
|
|
747
|
+
await api(`/api/chat/sessions/${chatState.sessionId}?projectPath=${projectPath}`, { method: 'DELETE' });
|
|
748
|
+
chatState.sessionId = null;
|
|
749
|
+
closeChatWs();
|
|
750
|
+
await refreshList();
|
|
751
|
+
await renderConversation();
|
|
752
|
+
});
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const turnHtml = (t) => {
|
|
756
|
+
const r = t.result || {};
|
|
757
|
+
const status = t.status || 'pending';
|
|
758
|
+
const badge = status === 'done' ? 'chip-ok' : status === 'failed' ? 'chip-err' : status === 'running' ? 'chip-warn' : 'chip-neutral';
|
|
759
|
+
const cost = r.costUsd ? `$${r.costUsd.toFixed(4)}` : '';
|
|
760
|
+
const dur = r.durationMs ? `${(r.durationMs / 1000).toFixed(1)}s` : '';
|
|
761
|
+
const files = (r.filesChanged || []).slice(0, 8);
|
|
762
|
+
let body;
|
|
763
|
+
if (status === 'running' || status === 'pending') {
|
|
764
|
+
body = `<div class="chat-thinking">Planning and executing…</div>`;
|
|
765
|
+
} else if (status === 'failed' || status === 'cancelled') {
|
|
766
|
+
body = `
|
|
767
|
+
<div class="chat-failure">
|
|
768
|
+
<div class="chat-failure-title">${status === 'cancelled' ? 'Cancelled' : 'Failed'}</div>
|
|
769
|
+
<div class="chat-failure-body">${esc(r.summary || '(no error text recorded)')}</div>
|
|
770
|
+
<button class="chat-retry" data-input="${esc(t.input)}">${icon('refresh')}<span>Retry</span></button>
|
|
771
|
+
</div>`;
|
|
772
|
+
} else {
|
|
773
|
+
body = `<div class="chat-summary">${esc(r.summary || '(no summary)')}</div>`;
|
|
774
|
+
}
|
|
775
|
+
return `
|
|
776
|
+
<div class="chat-turn" data-turn="${esc(t.id)}">
|
|
777
|
+
<div class="chat-msg chat-msg-user">
|
|
778
|
+
<div class="chat-avatar">${icon('user')}</div>
|
|
779
|
+
<div class="chat-bubble">${esc(t.input)}</div>
|
|
780
|
+
</div>
|
|
781
|
+
<div class="chat-msg chat-msg-agent">
|
|
782
|
+
<div class="chat-avatar agent">${icon('robot')}</div>
|
|
783
|
+
<div class="chat-bubble">
|
|
784
|
+
<div class="chat-bubble-head">
|
|
785
|
+
<span class="chip ${badge}">${esc(status)}</span>
|
|
786
|
+
${dur ? `<span class="chat-meta">${dur}</span>` : ''}
|
|
787
|
+
${cost ? `<span class="chat-meta">${cost}</span>` : ''}
|
|
788
|
+
<span class="chat-meta">${esc(t.mode)}</span>
|
|
789
|
+
</div>
|
|
790
|
+
${body}
|
|
791
|
+
${files.length ? `
|
|
792
|
+
<div class="chat-files">
|
|
793
|
+
${files.map((f) => `<span class="chat-file">${esc(f)}</span>`).join('')}
|
|
794
|
+
${(r.filesChanged || []).length > 8 ? `<span class="chat-file-more">+${(r.filesChanged || []).length - 8} more</span>` : ''}
|
|
795
|
+
</div>
|
|
796
|
+
` : ''}
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
`;
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const autosize = (el) => {
|
|
804
|
+
el.style.height = 'auto';
|
|
805
|
+
el.style.height = Math.min(240, el.scrollHeight) + 'px';
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const startPolling = () => {
|
|
809
|
+
stopPolling();
|
|
810
|
+
if (!chatState.sessionId) return;
|
|
811
|
+
chatState.poll = setInterval(async () => {
|
|
812
|
+
if (currentView !== 'chat') { stopPolling(); return; }
|
|
813
|
+
const sess = await api(`/api/chat/sessions/${chatState.sessionId}?projectPath=${projectPath}`).catch(() => null);
|
|
814
|
+
if (!sess) return;
|
|
815
|
+
const running = sess.turns.some((t) => t.status === 'running' || t.status === 'pending');
|
|
816
|
+
if (!running) {
|
|
817
|
+
stopPolling();
|
|
818
|
+
await renderConversation();
|
|
819
|
+
await refreshList();
|
|
820
|
+
} else {
|
|
821
|
+
await renderConversation();
|
|
822
|
+
}
|
|
823
|
+
}, 1500);
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const stopPolling = () => {
|
|
827
|
+
if (chatState.poll) { clearInterval(chatState.poll); chatState.poll = null; }
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// wire buttons
|
|
831
|
+
document.getElementById('chat-new')?.addEventListener('click', newSession);
|
|
832
|
+
document.getElementById('chat-empty-new')?.addEventListener('click', newSession);
|
|
833
|
+
|
|
834
|
+
await refreshList();
|
|
835
|
+
// auto-open the most recent session, if any
|
|
836
|
+
const sessions = await api(`/api/chat/sessions?projectPath=${projectPath}`).catch(() => []);
|
|
837
|
+
if (sessions.length) {
|
|
838
|
+
await openSession(sessions[0].id);
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// helpers for chat
|
|
843
|
+
const timeago = (iso) => {
|
|
844
|
+
try {
|
|
845
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
846
|
+
if (ms < 60000) return 'just now';
|
|
847
|
+
if (ms < 3600000) return Math.floor(ms / 60000) + 'm ago';
|
|
848
|
+
if (ms < 86400000) return Math.floor(ms / 3600000) + 'h ago';
|
|
849
|
+
return Math.floor(ms / 86400000) + 'd ago';
|
|
850
|
+
} catch { return ''; }
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// ---------- Run (full form) ----------
|
|
854
|
+
|
|
855
|
+
views.run = async () => {
|
|
856
|
+
const status = await api('/api/status').catch(() => ({ defaultMode: 'balanced', cwd: '' }));
|
|
857
|
+
app.innerHTML = page(`
|
|
858
|
+
${pageHeader('New task', 'Describe what Forge should do, then launch.')}
|
|
859
|
+
<section class="section"><div class="section-body"><div class="section-body-padded">
|
|
860
|
+
<div class="form-row"><label>Prompt</label>
|
|
861
|
+
<textarea id="run-prompt" placeholder="e.g. add a /health endpoint to the Express server"></textarea>
|
|
862
|
+
</div>
|
|
863
|
+
<div class="form-row"><label>Mode</label>
|
|
864
|
+
<select id="run-mode">
|
|
865
|
+
${['fast','balanced','heavy','plan','audit','debug','architect','offline-safe']
|
|
866
|
+
.map((m) => `<option value="${m}" ${m === status.defaultMode ? 'selected' : ''}>${m}</option>`).join('')}
|
|
867
|
+
</select>
|
|
868
|
+
</div>
|
|
869
|
+
<div class="form-row"><label>Project path</label>
|
|
870
|
+
<input type="text" id="run-cwd" value="${esc(status.cwd ?? '')}" placeholder="/absolute/path (blank = current)">
|
|
871
|
+
</div>
|
|
872
|
+
<div class="form-row"><label>Permissions</label>
|
|
873
|
+
<div class="grid-checkboxes">
|
|
874
|
+
${[
|
|
875
|
+
['autoApprove','Auto-approve plan'],
|
|
876
|
+
['skipRoutine','Skip routine prompts'],
|
|
877
|
+
['allowFiles','Allow file writes'],
|
|
878
|
+
['allowShell','Allow shell'],
|
|
879
|
+
['allowNetwork','Allow network'],
|
|
880
|
+
['allowWeb','Allow web tools'],
|
|
881
|
+
['allowMcp','Allow MCP'],
|
|
882
|
+
['strict','Strict (confirm each)'],
|
|
883
|
+
].map(([k, l]) => `<label class="checkbox-row"><input type="checkbox" data-flag="${k}"><span>${l}</span></label>`).join('')}
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
<div style="display:flex;gap:8px">
|
|
887
|
+
<button class="btn btn-primary" id="run-go">Launch task</button>
|
|
888
|
+
<button class="btn btn-ghost" id="run-reset">Reset</button>
|
|
889
|
+
</div>
|
|
890
|
+
</div></div></section>
|
|
891
|
+
`);
|
|
892
|
+
|
|
893
|
+
const go = async () => {
|
|
894
|
+
const prompt = document.getElementById('run-prompt').value.trim();
|
|
895
|
+
if (!prompt) return toast('prompt required', 'warn');
|
|
896
|
+
pushPromptHistory(prompt);
|
|
897
|
+
const mode = document.getElementById('run-mode').value;
|
|
898
|
+
const cwd = document.getElementById('run-cwd').value.trim() || undefined;
|
|
899
|
+
const flags = {};
|
|
900
|
+
let autoApprove = false;
|
|
901
|
+
app.querySelectorAll('[data-flag]').forEach((i) => {
|
|
902
|
+
if (i.checked) {
|
|
903
|
+
if (i.dataset.flag === 'autoApprove') autoApprove = true;
|
|
904
|
+
else flags[i.dataset.flag] = true;
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
try {
|
|
908
|
+
const { taskId } = await apiPost('/api/tasks/run', { prompt, mode, cwd, autoApprove, flags });
|
|
909
|
+
toast('Task started', 'ok');
|
|
910
|
+
openTask(taskId);
|
|
911
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
document.getElementById('run-go').addEventListener('click', go);
|
|
915
|
+
attachPromptHistory(document.getElementById('run-prompt'));
|
|
916
|
+
document.getElementById('run-prompt').addEventListener('keydown', (e) => {
|
|
917
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') go();
|
|
918
|
+
});
|
|
919
|
+
document.getElementById('run-reset').addEventListener('click', () => setView('run'));
|
|
920
|
+
document.getElementById('run-prompt').focus();
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
// ---------- Task detail ----------
|
|
924
|
+
|
|
925
|
+
const openTask = (taskId) => {
|
|
926
|
+
currentView = 'task';
|
|
927
|
+
document.querySelectorAll('.nav-item').forEach((b) => b.classList.remove('active'));
|
|
928
|
+
app.innerHTML = page(`
|
|
929
|
+
${pageHeader('Task · ' + taskId, 'Live stream from the interactive host.', `
|
|
930
|
+
<button class="btn btn-ghost" data-action="back">← Active</button>
|
|
931
|
+
<button class="btn btn-danger" data-action="cancel">Cancel</button>
|
|
932
|
+
`)}
|
|
933
|
+
<section class="section" id="task-plan" hidden></section>
|
|
934
|
+
<section class="section">
|
|
935
|
+
<div class="section-head">
|
|
936
|
+
<h2>Stream</h2>
|
|
937
|
+
<span class="section-meta" id="task-meta">connecting…</span>
|
|
938
|
+
</div>
|
|
939
|
+
<div class="section-body"><div style="padding:14px 18px">
|
|
940
|
+
<div id="task-stream" class="log-stream"></div>
|
|
941
|
+
</div></div>
|
|
942
|
+
</section>
|
|
943
|
+
`);
|
|
944
|
+
|
|
945
|
+
const stream = document.getElementById('task-stream');
|
|
946
|
+
const planSec = document.getElementById('task-plan');
|
|
947
|
+
let currentPlanPromptId = null;
|
|
948
|
+
|
|
949
|
+
const push = (line) => {
|
|
950
|
+
const el = document.createElement('div');
|
|
951
|
+
el.className = line.cls;
|
|
952
|
+
el.innerHTML = line.html;
|
|
953
|
+
stream.insertBefore(el, stream.firstChild);
|
|
954
|
+
while (stream.childElementCount > 300) stream.lastChild?.remove();
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
const renderPlan = (plan) => {
|
|
958
|
+
const steps = (plan.steps || []).map((s, i) => `
|
|
959
|
+
<div class="plan-step">
|
|
960
|
+
<div class="plan-step-num">${i + 1}</div>
|
|
961
|
+
<div class="plan-step-body">
|
|
962
|
+
<div class="plan-step-head">
|
|
963
|
+
<span class="plan-step-type">${esc(s.type)}</span>
|
|
964
|
+
${s.risk ? `<span class="badge badge-${s.risk === 'critical' ? 'failed' : 'awaiting'}">${esc(s.risk)}</span>` : ''}
|
|
965
|
+
${s.id ? `<code>${esc(s.id)}</code>` : ''}
|
|
966
|
+
</div>
|
|
967
|
+
<div class="plan-step-desc">${esc(s.description)}</div>
|
|
968
|
+
${s.target ? `<div class="plan-step-meta"><span>target · <code>${esc(s.target)}</code></span></div>` : ''}
|
|
969
|
+
</div>
|
|
970
|
+
</div>`).join('');
|
|
971
|
+
planSec.hidden = false;
|
|
972
|
+
planSec.innerHTML = `
|
|
973
|
+
<div class="section-head">
|
|
974
|
+
<h2>Proposed plan</h2>
|
|
975
|
+
<span class="section-meta">${(plan.steps || []).length} steps · ${esc((plan.goal || '').slice(0, 80))}</span>
|
|
976
|
+
</div>
|
|
977
|
+
<div class="section-body">
|
|
978
|
+
<div class="plan-viewer">${steps}</div>
|
|
979
|
+
<div style="padding:12px 18px;display:flex;gap:8px;justify-content:flex-end;border-top:1px solid var(--border)">
|
|
980
|
+
<button class="btn btn-ghost" data-plan-action="cancel">Reject</button>
|
|
981
|
+
<button class="btn btn-primary" data-plan-action="approve">Approve & run</button>
|
|
982
|
+
</div>
|
|
983
|
+
</div>`;
|
|
984
|
+
planSec.querySelectorAll('[data-plan-action]').forEach((b) =>
|
|
985
|
+
b.addEventListener('click', async () => {
|
|
986
|
+
if (!currentPlanPromptId) return;
|
|
987
|
+
await apiPost('/api/prompts/respond', { promptId: currentPlanPromptId, value: b.dataset.planAction });
|
|
988
|
+
planSec.hidden = true;
|
|
989
|
+
planSec.innerHTML = '';
|
|
990
|
+
currentPlanPromptId = null;
|
|
991
|
+
}),
|
|
992
|
+
);
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
app.querySelector('[data-action="back"]').addEventListener('click', () => setView('active'));
|
|
996
|
+
app.querySelector('[data-action="cancel"]').addEventListener('click', async () => {
|
|
997
|
+
try { await apiPost(`/api/tasks/${taskId}/cancel`); toast('cancel requested', 'warn'); }
|
|
998
|
+
catch (e) { toast(String(e), 'err'); }
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
if (taskConnections.has(taskId)) { try { taskConnections.get(taskId).close(); } catch {} }
|
|
1002
|
+
const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/tasks/${taskId}`;
|
|
1003
|
+
const ws = new WebSocket(url);
|
|
1004
|
+
taskConnections.set(taskId, ws);
|
|
1005
|
+
|
|
1006
|
+
const meta = document.getElementById('task-meta');
|
|
1007
|
+
ws.onopen = () => { meta.textContent = 'live'; };
|
|
1008
|
+
ws.onclose = () => { meta.textContent = 'disconnected'; };
|
|
1009
|
+
ws.onmessage = (e) => {
|
|
1010
|
+
let msg;
|
|
1011
|
+
try { msg = JSON.parse(e.data); } catch { return; }
|
|
1012
|
+
if (msg.kind === 'event') {
|
|
1013
|
+
const ev = msg.event;
|
|
1014
|
+
push({
|
|
1015
|
+
cls: `log-line ${ev.severity ?? 'info'}`,
|
|
1016
|
+
html: `<time>${esc(fmtDate(ev.timestamp))}</time><span class="log-type">${esc(ev.type)}</span> · ${esc(ev.message)}`,
|
|
1017
|
+
});
|
|
1018
|
+
} else if (msg.kind === 'prompt') {
|
|
1019
|
+
if (msg.promptType === 'plan_approval') {
|
|
1020
|
+
currentPlanPromptId = msg.promptId;
|
|
1021
|
+
renderPlan(msg.plan);
|
|
1022
|
+
} else if (msg.promptType === 'permission') {
|
|
1023
|
+
openPermissionModal(msg);
|
|
1024
|
+
} else if (msg.promptType === 'user_input') {
|
|
1025
|
+
openUserInputModal(msg);
|
|
1026
|
+
}
|
|
1027
|
+
} else if (msg.kind === 'task.started') {
|
|
1028
|
+
push({ cls: 'log-line', html: `<span class="log-type">STARTED</span> · ${esc(msg.prompt.slice(0, 120))}` });
|
|
1029
|
+
} else if (msg.kind === 'task.result') {
|
|
1030
|
+
const ok = msg.result?.success;
|
|
1031
|
+
push({ cls: `log-line ${ok ? '' : 'error'}`, html: `<span class="log-type">${ok ? 'DONE' : 'FAILED'}</span> · ${esc(msg.result?.summary ?? '')}` });
|
|
1032
|
+
toast(ok ? 'Task complete' : 'Task failed', ok ? 'ok' : 'err');
|
|
1033
|
+
} else if (msg.kind === 'task.error') {
|
|
1034
|
+
push({ cls: 'log-line error', html: `<span class="log-type">ERROR</span> · ${esc(msg.error)}` });
|
|
1035
|
+
} else if (msg.kind === 'task.cancel_requested') {
|
|
1036
|
+
push({ cls: 'log-line warning', html: `<span class="log-type">CANCEL</span> · requested` });
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
// ---------- Active / tasks ----------
|
|
1042
|
+
|
|
1043
|
+
views.active = async () => {
|
|
1044
|
+
app.innerHTML = page(`${pageHeader('Active tasks', 'Running or awaiting approval.')}
|
|
1045
|
+
<div id="active-body">${skeletonRows(3)}</div>`);
|
|
1046
|
+
const render = async () => {
|
|
1047
|
+
const { active } = await api('/api/tasks/active');
|
|
1048
|
+
updateActiveBadge(active);
|
|
1049
|
+
const body = document.getElementById('active-body');
|
|
1050
|
+
body.innerHTML = active.length
|
|
1051
|
+
? sectionShell('', `${active.length} total`, active.map((t) => `
|
|
1052
|
+
<div class="row">
|
|
1053
|
+
${badge(t.status)}
|
|
1054
|
+
<div class="row-main">
|
|
1055
|
+
<div class="title">${esc(t.prompt.slice(0, 160))}</div>
|
|
1056
|
+
<div class="sub">
|
|
1057
|
+
<code>${esc(t.taskId)}</code>
|
|
1058
|
+
<span>${esc(t.mode)}</span>
|
|
1059
|
+
<span>started ${esc(fmtDate(new Date(t.startedAt).toISOString()))}</span>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
<button class="btn btn-ghost btn-sm" data-open="${esc(t.taskId)}">Open</button>
|
|
1063
|
+
<button class="btn btn-danger btn-sm" data-cancel="${esc(t.taskId)}">Cancel</button>
|
|
1064
|
+
</div>`).join(''))
|
|
1065
|
+
: `<div class="empty"><div class="empty-title">Nothing running</div><div>Start something from <strong>New task</strong> or press <kbd>⌘ K</kbd>.</div></div>`;
|
|
1066
|
+
body.querySelectorAll('[data-open]').forEach((b) => b.addEventListener('click', () => openTask(b.dataset.open)));
|
|
1067
|
+
body.querySelectorAll('[data-cancel]').forEach((b) => b.addEventListener('click', async () => {
|
|
1068
|
+
await apiPost(`/api/tasks/${b.dataset.cancel}/cancel`);
|
|
1069
|
+
toast('cancel requested', 'warn');
|
|
1070
|
+
render();
|
|
1071
|
+
}));
|
|
1072
|
+
};
|
|
1073
|
+
render();
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
views.tasks = async () => {
|
|
1077
|
+
app.innerHTML = page(`${pageHeader('Tasks', 'Full history, searchable.')}
|
|
1078
|
+
<div class="section"><div class="section-body-padded">
|
|
1079
|
+
<input type="search" id="q" placeholder="Search by title or intent…" autocomplete="off" />
|
|
1080
|
+
</div></div>
|
|
1081
|
+
<div id="tasks-body">${skeletonRows(4)}</div>`);
|
|
1082
|
+
const body = document.getElementById('tasks-body');
|
|
1083
|
+
const render = async (q) => {
|
|
1084
|
+
body.innerHTML = skeletonRows(3);
|
|
1085
|
+
const params = new URLSearchParams({ limit: '100' });
|
|
1086
|
+
if (q) params.set('q', q);
|
|
1087
|
+
const rows = await api(`/api/tasks?${params}`);
|
|
1088
|
+
body.innerHTML = rows.length
|
|
1089
|
+
? `<section class="section"><div class="section-body"><div class="table-wrap"><table class="table sortable" data-default-sort="6" data-default-dir="desc">
|
|
1090
|
+
<thead><tr>
|
|
1091
|
+
<th data-sort="text">id</th>
|
|
1092
|
+
<th data-sort="text">status</th>
|
|
1093
|
+
<th data-sort="text">mode</th>
|
|
1094
|
+
<th data-sort="text">intent</th>
|
|
1095
|
+
<th data-sort="text">risk</th>
|
|
1096
|
+
<th data-sort="text" class="col-wrap">title</th>
|
|
1097
|
+
<th data-sort="date">updated</th>
|
|
1098
|
+
</tr></thead>
|
|
1099
|
+
<tbody>${rows.map((t) => `<tr>
|
|
1100
|
+
<td><code>${esc(t.id)}</code></td>
|
|
1101
|
+
<td>${badge(t.status)}</td>
|
|
1102
|
+
<td>${esc(t.mode)}</td>
|
|
1103
|
+
<td>${esc(t.intent ?? '—')}</td>
|
|
1104
|
+
<td>${esc(t.risk ?? '—')}</td>
|
|
1105
|
+
<td class="col-wrap">${esc((t.title ?? '').slice(0, 100) || '—')}</td>
|
|
1106
|
+
<td data-raw="${esc(t.updated_at ?? '')}">${esc(fmtDate(t.updated_at))}</td>
|
|
1107
|
+
</tr>`).join('')}</tbody>
|
|
1108
|
+
</table></div></div></section>`
|
|
1109
|
+
: `<div class="empty"><div class="empty-title">No matching tasks</div></div>`;
|
|
1110
|
+
};
|
|
1111
|
+
const input = document.getElementById('q');
|
|
1112
|
+
let h;
|
|
1113
|
+
input.addEventListener('input', () => {
|
|
1114
|
+
clearTimeout(h);
|
|
1115
|
+
h = setTimeout(() => render(input.value.trim()), 200);
|
|
1116
|
+
});
|
|
1117
|
+
input.focus();
|
|
1118
|
+
render('');
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
// ---------- Models / MCP / Skills / Web / Memory / Learning / Cost / Doctor ----------
|
|
1122
|
+
// (unchanged from the previous pass, just wrapped in page())
|
|
1123
|
+
|
|
1124
|
+
views.models = async () => {
|
|
1125
|
+
app.innerHTML = page(`${pageHeader('Models', 'Registered providers and catalogs.')}
|
|
1126
|
+
<div id="models-body">${skeletonRows(3)}</div>`);
|
|
1127
|
+
const data = await api('/api/models');
|
|
1128
|
+
document.getElementById('models-body').innerHTML = data.map((p) => `
|
|
1129
|
+
<section class="section">
|
|
1130
|
+
<div class="section-head">
|
|
1131
|
+
<h2>${esc(p.provider)}</h2>
|
|
1132
|
+
<span class="pill ${p.available ? 'ok' : 'down'}"><span class="dot"></span>${p.available ? 'available' : 'unavailable'}</span>
|
|
1133
|
+
</div>
|
|
1134
|
+
<div class="section-body">
|
|
1135
|
+
${p.models.length ? `<div class="table-wrap"><table class="table sortable">
|
|
1136
|
+
<thead><tr>
|
|
1137
|
+
<th data-sort="text">model</th>
|
|
1138
|
+
<th data-sort="text">class</th>
|
|
1139
|
+
<th data-sort="number">context</th>
|
|
1140
|
+
<th data-sort="text" class="col-wrap">roles</th>
|
|
1141
|
+
</tr></thead>
|
|
1142
|
+
<tbody>${p.models.map((m) => `<tr>
|
|
1143
|
+
<td><code>${esc(m.id)}</code></td>
|
|
1144
|
+
<td>${esc(m.class)}</td>
|
|
1145
|
+
<td data-raw="${esc(m.contextTokens)}">${esc(m.contextTokens)}</td>
|
|
1146
|
+
<td class="col-wrap">${esc((m.roles ?? []).join(', '))}</td>
|
|
1147
|
+
</tr>`).join('')}</tbody>
|
|
1148
|
+
</table></div>` : '<div class="empty">no models</div>'}
|
|
1149
|
+
</div>
|
|
1150
|
+
</section>`).join('');
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
views.mcp = async () => {
|
|
1154
|
+
app.innerHTML = page(`${pageHeader('MCP connections', 'Stdio & HTTP Model Context Protocol servers.')}
|
|
1155
|
+
<div id="mcp-body">${skeletonRows(3)}</div>`);
|
|
1156
|
+
const render = async () => {
|
|
1157
|
+
const rows = await api('/api/mcp');
|
|
1158
|
+
const body = document.getElementById('mcp-body');
|
|
1159
|
+
const form = `<section class="section"><div class="section-head"><h2>Add connection</h2></div>
|
|
1160
|
+
<div class="section-body"><div class="section-body-padded">
|
|
1161
|
+
<div class="form-row"><label>Name</label><input type="text" id="mcp-name" placeholder="github" /></div>
|
|
1162
|
+
<div class="form-row"><label>Transport</label>
|
|
1163
|
+
<select id="mcp-transport"><option value="stdio">stdio</option><option value="http_stream">http_stream</option></select>
|
|
1164
|
+
</div>
|
|
1165
|
+
<div class="form-row"><label>Command (stdio)</label><input type="text" id="mcp-command" placeholder="/usr/local/bin/mcp-server" /></div>
|
|
1166
|
+
<div class="form-row"><label>Args</label><input type="text" id="mcp-args" placeholder="space-separated" /></div>
|
|
1167
|
+
<div class="form-row"><label>Endpoint (http_stream)</label><input type="url" id="mcp-endpoint" placeholder="https://…/mcp" /></div>
|
|
1168
|
+
<div class="form-row"><label>Auth</label>
|
|
1169
|
+
<select id="mcp-auth"><option value="none">none</option><option value="api_key">api_key</option><option value="oauth">oauth</option><option value="basic">basic</option></select>
|
|
1170
|
+
</div>
|
|
1171
|
+
<button class="btn btn-primary" id="mcp-add">Add</button>
|
|
1172
|
+
</div></div></section>`;
|
|
1173
|
+
const list = rows.length
|
|
1174
|
+
? sectionShell('Connections', `${rows.length} total`, rows.map((c) => `
|
|
1175
|
+
<div class="row">
|
|
1176
|
+
<div class="row-main">
|
|
1177
|
+
<div class="title">${esc(c.name)} <code>${esc(c.id)}</code></div>
|
|
1178
|
+
<div class="sub">
|
|
1179
|
+
<span>${esc(c.transport)}</span>
|
|
1180
|
+
<code>${esc(c.endpoint || c.command || '')}</code>
|
|
1181
|
+
<span>auth: ${esc(c.auth)}</span>
|
|
1182
|
+
</div>
|
|
1183
|
+
</div>
|
|
1184
|
+
${badge(c.status === 'connected' ? 'completed' : 'cancelled')}
|
|
1185
|
+
<button class="btn btn-danger btn-sm" data-del="${esc(c.id)}">Remove</button>
|
|
1186
|
+
</div>`).join(''))
|
|
1187
|
+
: '';
|
|
1188
|
+
body.innerHTML = form + list;
|
|
1189
|
+
document.getElementById('mcp-add').addEventListener('click', async () => {
|
|
1190
|
+
const name = document.getElementById('mcp-name').value.trim();
|
|
1191
|
+
if (!name) return toast('name required', 'warn');
|
|
1192
|
+
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
1193
|
+
const transport = document.getElementById('mcp-transport').value;
|
|
1194
|
+
const endpoint = document.getElementById('mcp-endpoint').value || undefined;
|
|
1195
|
+
const command = document.getElementById('mcp-command').value || undefined;
|
|
1196
|
+
const argsRaw = document.getElementById('mcp-args').value.trim();
|
|
1197
|
+
const args = argsRaw ? argsRaw.split(/\s+/).filter(Boolean) : undefined;
|
|
1198
|
+
const auth = document.getElementById('mcp-auth').value;
|
|
1199
|
+
try {
|
|
1200
|
+
await apiPost('/api/mcp', { id, name, transport, endpoint, command, args, auth, status: 'disconnected' });
|
|
1201
|
+
toast('added', 'ok');
|
|
1202
|
+
render();
|
|
1203
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
1204
|
+
});
|
|
1205
|
+
body.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', async () => {
|
|
1206
|
+
await api(`/api/mcp/${b.dataset.del}`, { method: 'DELETE' });
|
|
1207
|
+
render();
|
|
1208
|
+
}));
|
|
1209
|
+
};
|
|
1210
|
+
render();
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
views.skills = async () => {
|
|
1214
|
+
app.innerHTML = page(`${pageHeader('Skills', 'Markdown skills loaded from ~/.forge/skills and .forge/skills.')}
|
|
1215
|
+
<div id="skills-body">${skeletonRows(3)}</div>`);
|
|
1216
|
+
const installed = await api('/api/skills');
|
|
1217
|
+
const body = document.getElementById('skills-body');
|
|
1218
|
+
const installedSection = installed.length
|
|
1219
|
+
? sectionShell('Installed', `${installed.length}`, installed.map((s) => `
|
|
1220
|
+
<div class="row">
|
|
1221
|
+
<div class="row-main">
|
|
1222
|
+
<div class="title">${esc(s.name)}</div>
|
|
1223
|
+
<div class="sub">
|
|
1224
|
+
${s.tags.length ? `<span>${esc(s.tags.join(', '))}</span>` : ''}
|
|
1225
|
+
<span>${esc(s.description || '(no description)')}</span>
|
|
1226
|
+
</div>
|
|
1227
|
+
</div>
|
|
1228
|
+
</div>`).join(''))
|
|
1229
|
+
: `<div class="empty"><div class="empty-title">No skills installed</div><div>Drop a Markdown file into ~/.forge/skills/ or search the registry below.</div></div>`;
|
|
1230
|
+
body.innerHTML = installedSection + `
|
|
1231
|
+
<section class="section">
|
|
1232
|
+
<div class="section-head"><h2>Install from registry</h2></div>
|
|
1233
|
+
<div class="section-body"><div class="section-body-padded">
|
|
1234
|
+
<div class="form-row"><input type="search" id="skill-search" placeholder="e.g. react, test, refactor" /></div>
|
|
1235
|
+
<div id="skill-results"></div>
|
|
1236
|
+
</div></div>
|
|
1237
|
+
</section>`;
|
|
1238
|
+
const searchInput = document.getElementById('skill-search');
|
|
1239
|
+
const resultsEl = document.getElementById('skill-results');
|
|
1240
|
+
let h;
|
|
1241
|
+
searchInput.addEventListener('input', () => {
|
|
1242
|
+
clearTimeout(h);
|
|
1243
|
+
h = setTimeout(async () => {
|
|
1244
|
+
const q = searchInput.value.trim();
|
|
1245
|
+
if (!q) { resultsEl.innerHTML = ''; return; }
|
|
1246
|
+
resultsEl.innerHTML = skeletonRows(2);
|
|
1247
|
+
try {
|
|
1248
|
+
const hits = await api(`/api/skills/search?q=${encodeURIComponent(q)}`);
|
|
1249
|
+
resultsEl.innerHTML = hits.length
|
|
1250
|
+
? hits.map((hit) => `
|
|
1251
|
+
<div class="row">
|
|
1252
|
+
<div class="row-main">
|
|
1253
|
+
<div class="title">${esc(hit.name)}</div>
|
|
1254
|
+
<div class="sub"><span>${esc(hit.description)}</span><code>${esc(hit.url)}</code></div>
|
|
1255
|
+
</div>
|
|
1256
|
+
<button class="btn btn-primary btn-sm" data-install='${esc(JSON.stringify(hit))}'>Install</button>
|
|
1257
|
+
</div>`).join('')
|
|
1258
|
+
: '<div class="empty">No matches.</div>';
|
|
1259
|
+
resultsEl.querySelectorAll('[data-install]').forEach((b) => b.addEventListener('click', async () => {
|
|
1260
|
+
const { name, url } = JSON.parse(b.getAttribute('data-install'));
|
|
1261
|
+
try {
|
|
1262
|
+
await apiPost('/api/skills/install', { name, url });
|
|
1263
|
+
toast(`installed ${name}`, 'ok');
|
|
1264
|
+
views.skills();
|
|
1265
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
1266
|
+
}));
|
|
1267
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
1268
|
+
}, 250);
|
|
1269
|
+
});
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
views.memory = async () => {
|
|
1273
|
+
app.innerHTML = page(`${pageHeader('Cold memory', 'Project FTS5 index — the planner retrieves context from here.')}
|
|
1274
|
+
<section class="section"><div class="section-body"><div class="section-body-padded">
|
|
1275
|
+
<div style="display:flex;gap:8px;margin-bottom:14px">
|
|
1276
|
+
<button class="btn btn-primary" id="mem-index">Re-index project</button>
|
|
1277
|
+
</div>
|
|
1278
|
+
<div class="form-row"><label>Search</label><input type="search" id="mem-q" placeholder="try: 'scheduler' or 'permission'" /></div>
|
|
1279
|
+
<div id="mem-results"></div>
|
|
1280
|
+
</div></div></section>`);
|
|
1281
|
+
document.getElementById('mem-index').addEventListener('click', async () => {
|
|
1282
|
+
try {
|
|
1283
|
+
const stats = await apiPost('/api/memory/index', {});
|
|
1284
|
+
toast(`indexed ${stats.scanned} files (${stats.durationMs}ms)`, 'ok');
|
|
1285
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
1286
|
+
});
|
|
1287
|
+
const qEl = document.getElementById('mem-q');
|
|
1288
|
+
const resEl = document.getElementById('mem-results');
|
|
1289
|
+
let h;
|
|
1290
|
+
qEl.addEventListener('input', () => {
|
|
1291
|
+
clearTimeout(h);
|
|
1292
|
+
h = setTimeout(async () => {
|
|
1293
|
+
const q = qEl.value.trim();
|
|
1294
|
+
if (!q) { resEl.innerHTML = ''; return; }
|
|
1295
|
+
resEl.innerHTML = skeletonRows(2);
|
|
1296
|
+
try {
|
|
1297
|
+
const hits = await api(`/api/memory/search?q=${encodeURIComponent(q)}`);
|
|
1298
|
+
resEl.innerHTML = hits.length
|
|
1299
|
+
? `<div class="table-wrap"><table class="table sortable" data-default-sort="1" data-default-dir="desc">
|
|
1300
|
+
<thead><tr>
|
|
1301
|
+
<th data-sort="text" class="col-wrap">path</th>
|
|
1302
|
+
<th data-sort="number">score</th>
|
|
1303
|
+
<th data-sort="text" class="col-wrap">snippet</th>
|
|
1304
|
+
</tr></thead>
|
|
1305
|
+
<tbody>${hits.map((r) => `<tr>
|
|
1306
|
+
<td class="col-wrap"><code>${esc(r.path)}</code></td>
|
|
1307
|
+
<td data-raw="${Number(r.score)}">${Number(r.score).toFixed(2)}</td>
|
|
1308
|
+
<td class="col-wrap">${esc(r.snippet.replace(/\s+/g, ' ').slice(0, 180))}</td>
|
|
1309
|
+
</tr>`).join('')}</tbody>
|
|
1310
|
+
</table></div>`
|
|
1311
|
+
: '<div class="empty">No matches.</div>';
|
|
1312
|
+
} catch (e) { resEl.innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
|
1313
|
+
}, 200);
|
|
1314
|
+
});
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
views.web = async () => {
|
|
1318
|
+
app.innerHTML = page(`${pageHeader('Web tools', 'Search and fetch. SSRF-guarded and injection-filtered.')}
|
|
1319
|
+
<section class="section"><div class="section-head"><h2>Search</h2></div><div class="section-body"><div class="section-body-padded">
|
|
1320
|
+
<div class="form-row"><input type="search" id="web-q" placeholder="search the web…" /></div>
|
|
1321
|
+
<div id="web-results"></div>
|
|
1322
|
+
</div></div></section>
|
|
1323
|
+
<section class="section"><div class="section-head"><h2>Fetch</h2></div><div class="section-body"><div class="section-body-padded">
|
|
1324
|
+
<div class="form-row"><input type="url" id="web-url" placeholder="https://…" /></div>
|
|
1325
|
+
<button class="btn btn-primary" id="web-fetch">Fetch</button>
|
|
1326
|
+
<div id="web-page" style="margin-top:14px"></div>
|
|
1327
|
+
</div></div></section>`);
|
|
1328
|
+
const qEl = document.getElementById('web-q');
|
|
1329
|
+
const resEl = document.getElementById('web-results');
|
|
1330
|
+
let h;
|
|
1331
|
+
qEl.addEventListener('input', () => {
|
|
1332
|
+
clearTimeout(h);
|
|
1333
|
+
h = setTimeout(async () => {
|
|
1334
|
+
const q = qEl.value.trim();
|
|
1335
|
+
if (!q) { resEl.innerHTML = ''; return; }
|
|
1336
|
+
resEl.innerHTML = skeletonRows(3);
|
|
1337
|
+
try {
|
|
1338
|
+
const hits = await api(`/api/web/search?q=${encodeURIComponent(q)}`);
|
|
1339
|
+
resEl.innerHTML = hits.length
|
|
1340
|
+
? hits.map((r) => `
|
|
1341
|
+
<div class="row">
|
|
1342
|
+
<div class="row-main">
|
|
1343
|
+
<div class="title">${esc(r.title)}</div>
|
|
1344
|
+
<div class="sub"><span>${esc(r.snippet.slice(0, 220))}</span></div>
|
|
1345
|
+
<div class="sub"><a href="${esc(r.url)}" target="_blank" rel="noopener" style="color:var(--accent)">${esc(r.url)}</a></div>
|
|
1346
|
+
</div>
|
|
1347
|
+
</div>`).join('')
|
|
1348
|
+
: '<div class="empty">No results.</div>';
|
|
1349
|
+
} catch (e) { resEl.innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
|
1350
|
+
}, 300);
|
|
1351
|
+
});
|
|
1352
|
+
document.getElementById('web-fetch').addEventListener('click', async () => {
|
|
1353
|
+
const url = document.getElementById('web-url').value.trim();
|
|
1354
|
+
if (!url) return;
|
|
1355
|
+
const pageEl = document.getElementById('web-page');
|
|
1356
|
+
pageEl.innerHTML = skeletonRows(3);
|
|
1357
|
+
try {
|
|
1358
|
+
const r = await apiPost('/api/web/fetch', { url });
|
|
1359
|
+
pageEl.innerHTML = `
|
|
1360
|
+
<div style="color:var(--muted);font-size:12px;margin-bottom:10px">
|
|
1361
|
+
${esc(r.status)} · ${esc(r.contentType)} · ${r.bytesReceived}B
|
|
1362
|
+
${r.flaggedInjection ? ' · <span class="pill warn"><span class="dot"></span>injection filtered</span>' : ''}
|
|
1363
|
+
</div>
|
|
1364
|
+
<h3 style="margin:0 0 8px 0;font-size:14px">${esc(r.title ?? '(no title)')}</h3>
|
|
1365
|
+
<pre class="json-view" style="white-space:pre-wrap">${esc(r.text.slice(0, 6000))}</pre>`;
|
|
1366
|
+
} catch (e) { pageEl.innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
|
1367
|
+
});
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
views.learning = async () => {
|
|
1371
|
+
app.innerHTML = page(`${pageHeader('Learning memory', 'Patterns Forge has reinforced from past tasks.')}
|
|
1372
|
+
<div id="learn-body">${skeletonRows(3)}</div>`);
|
|
1373
|
+
const rows = await api('/api/learning');
|
|
1374
|
+
document.getElementById('learn-body').innerHTML = rows.length
|
|
1375
|
+
? `<section class="section"><div class="section-body"><div class="table-wrap"><table class="table sortable" data-default-sort="3" data-default-dir="desc">
|
|
1376
|
+
<thead><tr>
|
|
1377
|
+
<th data-sort="text" class="col-wrap">pattern</th>
|
|
1378
|
+
<th data-sort="text" class="col-wrap">context</th>
|
|
1379
|
+
<th data-sort="text" class="col-wrap">fix</th>
|
|
1380
|
+
<th data-sort="number">confidence</th>
|
|
1381
|
+
<th data-sort="number">✓</th>
|
|
1382
|
+
<th data-sort="number">✗</th>
|
|
1383
|
+
<th data-sort="date">updated</th>
|
|
1384
|
+
</tr></thead>
|
|
1385
|
+
<tbody>${rows.map((r) => `<tr>
|
|
1386
|
+
<td class="col-wrap">${esc(r.pattern)}</td>
|
|
1387
|
+
<td class="col-wrap">${esc(r.context ?? '')}</td>
|
|
1388
|
+
<td class="col-wrap">${esc(r.fix ?? '')}</td>
|
|
1389
|
+
<td data-raw="${Number(r.confidence)}"><span class="pill ${r.confidence > 0.6 ? 'ok' : 'warn'}"><span class="dot"></span>${Number(r.confidence).toFixed(2)}</span></td>
|
|
1390
|
+
<td>${r.success_count}</td>
|
|
1391
|
+
<td>${r.failure_count}</td>
|
|
1392
|
+
<td data-raw="${esc(r.updated_at ?? '')}">${esc(fmtDate(r.updated_at))}</td>
|
|
1393
|
+
</tr>`).join('')}</tbody>
|
|
1394
|
+
</table></div></div></section>`
|
|
1395
|
+
: `<div class="empty"><div class="empty-title">No patterns yet</div><div>Forge learns as you run tasks.</div></div>`;
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
views.cost = async () => {
|
|
1399
|
+
app.innerHTML = page(`${pageHeader('Cost', 'USD and token usage per model call.')}
|
|
1400
|
+
<div id="cost-body">${skeletonRows(3)}</div>`);
|
|
1401
|
+
const data = await api('/api/cost');
|
|
1402
|
+
const t = data.totals;
|
|
1403
|
+
const rows = data.recent || [];
|
|
1404
|
+
document.getElementById('cost-body').innerHTML = `
|
|
1405
|
+
<div class="stats">
|
|
1406
|
+
<div class="stat"><div class="label">Calls</div><div class="value">${esc(t.calls)}</div><div class="sub">all providers</div></div>
|
|
1407
|
+
<div class="stat"><div class="label">Tokens</div><div class="value">${Number(t.tokens).toLocaleString()}</div><div class="sub">input + output</div></div>
|
|
1408
|
+
<div class="stat"><div class="label">Spend</div><div class="value">$${Number(t.usd).toFixed(4)}</div><div class="sub">estimated USD</div></div>
|
|
1409
|
+
</div>
|
|
1410
|
+
<section class="section"><div class="section-head"><h2>Recent calls</h2></div>
|
|
1411
|
+
<div class="section-body">${rows.length
|
|
1412
|
+
? `<div class="table-wrap"><table class="table sortable" data-default-sort="6" data-default-dir="desc">
|
|
1413
|
+
<thead><tr>
|
|
1414
|
+
<th data-sort="text">provider</th>
|
|
1415
|
+
<th data-sort="text" class="col-wrap">model</th>
|
|
1416
|
+
<th data-sort="number">in</th>
|
|
1417
|
+
<th data-sort="number">out</th>
|
|
1418
|
+
<th data-sort="number">ms</th>
|
|
1419
|
+
<th data-sort="number">usd</th>
|
|
1420
|
+
<th data-sort="date">when</th>
|
|
1421
|
+
</tr></thead>
|
|
1422
|
+
<tbody>${rows.map((r) => `<tr>
|
|
1423
|
+
<td>${esc(r.provider)}</td>
|
|
1424
|
+
<td class="col-wrap"><code>${esc(r.model)}</code></td>
|
|
1425
|
+
<td>${r.input_tokens}</td>
|
|
1426
|
+
<td>${r.output_tokens}</td>
|
|
1427
|
+
<td>${r.duration_ms}</td>
|
|
1428
|
+
<td data-raw="${Number(r.cost_usd)}">$${Number(r.cost_usd).toFixed(4)}</td>
|
|
1429
|
+
<td data-raw="${esc(r.created_at ?? '')}">${esc(fmtDate(r.created_at))}</td>
|
|
1430
|
+
</tr>`).join('')}</tbody>
|
|
1431
|
+
</table></div>`
|
|
1432
|
+
: '<div class="empty">No model calls yet.</div>'}</div></section>`;
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
// ---------- Config (Monaco editor) ----------
|
|
1436
|
+
|
|
1437
|
+
let monacoLoading = null;
|
|
1438
|
+
const loadMonaco = () => {
|
|
1439
|
+
if (window.monaco) return Promise.resolve(window.monaco);
|
|
1440
|
+
if (monacoLoading) return monacoLoading;
|
|
1441
|
+
monacoLoading = new Promise((resolve, reject) => {
|
|
1442
|
+
const loader = document.createElement('script');
|
|
1443
|
+
loader.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
|
|
1444
|
+
loader.onerror = () => reject(new Error('monaco loader failed'));
|
|
1445
|
+
loader.onload = () => {
|
|
1446
|
+
try {
|
|
1447
|
+
window.require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
|
|
1448
|
+
window.require(['vs/editor/editor.main'], () => {
|
|
1449
|
+
window.monaco.editor.defineTheme('forge-dark', {
|
|
1450
|
+
base: 'vs-dark',
|
|
1451
|
+
inherit: true,
|
|
1452
|
+
rules: [],
|
|
1453
|
+
colors: {
|
|
1454
|
+
'editor.background': '#0f161d',
|
|
1455
|
+
'editor.foreground': '#e7eaee',
|
|
1456
|
+
'editor.lineHighlightBackground': '#13171c',
|
|
1457
|
+
'editorLineNumber.foreground': '#4b5460',
|
|
1458
|
+
'editorLineNumber.activeForeground': '#7d8692',
|
|
1459
|
+
'editorCursor.foreground': '#14b8a6',
|
|
1460
|
+
'editor.selectionBackground': '#14b8a632',
|
|
1461
|
+
'editorIndentGuide.background': '#1d232a',
|
|
1462
|
+
'editorIndentGuide.activeBackground': '#27333f',
|
|
1463
|
+
},
|
|
1464
|
+
});
|
|
1465
|
+
resolve(window.monaco);
|
|
1466
|
+
});
|
|
1467
|
+
} catch (e) { reject(e); }
|
|
1468
|
+
};
|
|
1469
|
+
document.head.appendChild(loader);
|
|
1470
|
+
});
|
|
1471
|
+
return monacoLoading;
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
views.config = async () => {
|
|
1475
|
+
const cfg = await api('/api/config');
|
|
1476
|
+
app.innerHTML = page(`${pageHeader('Config', 'Global ~/.forge/config.json — edit inline or via key-path.', `
|
|
1477
|
+
<button class="btn btn-ghost" id="cfg-reload">Reload</button>
|
|
1478
|
+
<button class="btn btn-primary" id="cfg-save-all">Save editor</button>
|
|
1479
|
+
`)}
|
|
1480
|
+
<section class="section">
|
|
1481
|
+
<div class="section-head">
|
|
1482
|
+
<h2>Editor</h2>
|
|
1483
|
+
<span class="section-meta" id="cfg-editor-status">loading editor…</span>
|
|
1484
|
+
</div>
|
|
1485
|
+
<div class="section-body"><div class="section-body-padded">
|
|
1486
|
+
<div id="cfg-editor" class="monaco-host"></div>
|
|
1487
|
+
</div></div>
|
|
1488
|
+
</section>
|
|
1489
|
+
<section class="section"><div class="section-head"><h2>Update a single key</h2></div>
|
|
1490
|
+
<div class="section-body"><div class="section-body-padded">
|
|
1491
|
+
<div class="form-row"><label>Key (dot-path)</label><input type="text" id="cfg-key" placeholder="update.channel" /></div>
|
|
1492
|
+
<div class="form-row"><label>Value (JSON)</label><input type="text" id="cfg-val" placeholder='"beta" · true · 30' /></div>
|
|
1493
|
+
<button class="btn btn-secondary" id="cfg-save-key">Save key</button>
|
|
1494
|
+
</div></div></section>`);
|
|
1495
|
+
|
|
1496
|
+
const statusEl = document.getElementById('cfg-editor-status');
|
|
1497
|
+
const editorHost = document.getElementById('cfg-editor');
|
|
1498
|
+
let editor = null;
|
|
1499
|
+
let currentRaw = JSON.stringify(cfg, null, 2);
|
|
1500
|
+
|
|
1501
|
+
// Load Monaco. Fall back to a styled textarea if CDN fails.
|
|
1502
|
+
try {
|
|
1503
|
+
const monaco = await loadMonaco();
|
|
1504
|
+
editor = monaco.editor.create(editorHost, {
|
|
1505
|
+
value: currentRaw,
|
|
1506
|
+
language: 'json',
|
|
1507
|
+
theme: 'forge-dark',
|
|
1508
|
+
automaticLayout: true,
|
|
1509
|
+
minimap: { enabled: false },
|
|
1510
|
+
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
|
1511
|
+
fontSize: 13,
|
|
1512
|
+
tabSize: 2,
|
|
1513
|
+
scrollBeyondLastLine: false,
|
|
1514
|
+
renderLineHighlight: 'line',
|
|
1515
|
+
smoothScrolling: true,
|
|
1516
|
+
});
|
|
1517
|
+
statusEl.textContent = 'Monaco · ⌘S to save';
|
|
1518
|
+
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveEditor());
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
statusEl.textContent = 'textarea fallback (Monaco CDN unreachable)';
|
|
1521
|
+
editorHost.innerHTML = `<textarea id="cfg-fallback" style="width:100%;height:100%;min-height:520px;background:#0f161d;color:#e7eaee;border:none;padding:14px;font-family:'JetBrains Mono',monospace;font-size:13px"></textarea>`;
|
|
1522
|
+
const t = document.getElementById('cfg-fallback');
|
|
1523
|
+
t.value = currentRaw;
|
|
1524
|
+
editor = {
|
|
1525
|
+
getValue: () => t.value,
|
|
1526
|
+
setValue: (v) => { t.value = v; },
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const saveEditor = async () => {
|
|
1531
|
+
const raw = editor.getValue();
|
|
1532
|
+
let parsed;
|
|
1533
|
+
try { parsed = JSON.parse(raw); }
|
|
1534
|
+
catch (e) { toast(`invalid JSON: ${e.message}`, 'err'); return; }
|
|
1535
|
+
try {
|
|
1536
|
+
// Backend supports per-key PATCH; emulate a full replace by sending every top-level key.
|
|
1537
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1538
|
+
await apiPost('/api/config', { key, value });
|
|
1539
|
+
}
|
|
1540
|
+
toast('Config saved', 'ok');
|
|
1541
|
+
const refreshed = await api('/api/config');
|
|
1542
|
+
currentRaw = JSON.stringify(refreshed, null, 2);
|
|
1543
|
+
editor.setValue(currentRaw);
|
|
1544
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
document.getElementById('cfg-save-all').addEventListener('click', saveEditor);
|
|
1548
|
+
document.getElementById('cfg-reload').addEventListener('click', async () => {
|
|
1549
|
+
const refreshed = await api('/api/config');
|
|
1550
|
+
currentRaw = JSON.stringify(refreshed, null, 2);
|
|
1551
|
+
editor.setValue(currentRaw);
|
|
1552
|
+
toast('Reloaded', 'ok');
|
|
1553
|
+
});
|
|
1554
|
+
document.getElementById('cfg-save-key').addEventListener('click', async () => {
|
|
1555
|
+
const key = document.getElementById('cfg-key').value.trim();
|
|
1556
|
+
const rawVal = document.getElementById('cfg-val').value.trim();
|
|
1557
|
+
if (!key) return;
|
|
1558
|
+
let value;
|
|
1559
|
+
try { value = JSON.parse(rawVal); } catch { value = rawVal; }
|
|
1560
|
+
try {
|
|
1561
|
+
await apiPost('/api/config', { key, value });
|
|
1562
|
+
toast('saved', 'ok');
|
|
1563
|
+
const refreshed = await api('/api/config');
|
|
1564
|
+
currentRaw = JSON.stringify(refreshed, null, 2);
|
|
1565
|
+
editor.setValue(currentRaw);
|
|
1566
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
1567
|
+
});
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
views.doctor = async () => {
|
|
1571
|
+
app.innerHTML = page(`${pageHeader('Doctor', 'Health diagnostics.')}
|
|
1572
|
+
<div id="doc-body">${skeletonRows(3)}</div>`);
|
|
1573
|
+
const checks = await api('/api/doctor');
|
|
1574
|
+
document.getElementById('doc-body').innerHTML = sectionShell('', `${checks.length} checks`, checks.map((c) => `
|
|
1575
|
+
<div class="row">
|
|
1576
|
+
<span class="pill ${c.ok ? 'ok' : 'down'}"><span class="dot"></span>${c.ok ? 'ok' : 'fail'}</span>
|
|
1577
|
+
<div class="row-main">
|
|
1578
|
+
<div class="title">${esc(c.name)}</div>
|
|
1579
|
+
<div class="sub"><code>${esc(c.detail)}</code></div>
|
|
1580
|
+
</div>
|
|
1581
|
+
</div>`).join(''));
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
// ---------- Overlays ----------
|
|
1585
|
+
|
|
1586
|
+
const mountOverlay = (innerHTML, { closeOnClickOutside = true, onClose, onKey } = {}) => {
|
|
1587
|
+
const overlay = document.createElement('div');
|
|
1588
|
+
overlay.className = 'overlay';
|
|
1589
|
+
overlay.innerHTML = innerHTML;
|
|
1590
|
+
overlayHost.appendChild(overlay);
|
|
1591
|
+
const close = () => {
|
|
1592
|
+
overlay.remove();
|
|
1593
|
+
window.removeEventListener('keydown', onKeyInternal);
|
|
1594
|
+
onClose?.();
|
|
1595
|
+
};
|
|
1596
|
+
if (closeOnClickOutside) {
|
|
1597
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
1598
|
+
}
|
|
1599
|
+
overlay.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', close));
|
|
1600
|
+
const onKeyInternal = (e) => {
|
|
1601
|
+
if (e.key === 'Escape') { close(); return; }
|
|
1602
|
+
onKey?.(e);
|
|
1603
|
+
};
|
|
1604
|
+
window.addEventListener('keydown', onKeyInternal);
|
|
1605
|
+
return { overlay, close };
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
const openPermissionModal = (msg) => {
|
|
1609
|
+
const { overlay, close } = mountOverlay(`
|
|
1610
|
+
<div class="modal">
|
|
1611
|
+
<div class="modal-head">
|
|
1612
|
+
<div class="modal-title">Permission required</div>
|
|
1613
|
+
<button class="modal-close" data-close aria-label="Close">${icon('close')}</button>
|
|
1614
|
+
</div>
|
|
1615
|
+
<div class="modal-body">
|
|
1616
|
+
<div style="font-size:13px;color:var(--fg-2);margin-bottom:14px">${esc(msg.request.action)}</div>
|
|
1617
|
+
<div style="display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;font-size:12px">
|
|
1618
|
+
<span style="color:var(--muted)">tool</span><code>${esc(msg.request.tool)}</code>
|
|
1619
|
+
<span style="color:var(--muted)">risk</span>${badge(msg.request.risk === 'critical' ? 'failed' : 'awaiting')}
|
|
1620
|
+
<span style="color:var(--muted)">side-effect</span><span>${esc(msg.request.sideEffect)}</span>
|
|
1621
|
+
${msg.request.target ? `<span style="color:var(--muted)">target</span><code>${esc(msg.request.target)}</code>` : ''}
|
|
1622
|
+
</div>
|
|
1623
|
+
</div>
|
|
1624
|
+
<div class="modal-foot">
|
|
1625
|
+
<button class="btn btn-ghost" data-perm="deny">Deny</button>
|
|
1626
|
+
<button class="btn btn-secondary" data-perm="allow">Allow once</button>
|
|
1627
|
+
<button class="btn btn-primary" data-perm="allow_session">Allow for session</button>
|
|
1628
|
+
</div>
|
|
1629
|
+
</div>`, { closeOnClickOutside: false });
|
|
1630
|
+
overlay.querySelectorAll('[data-perm]').forEach((b) => b.addEventListener('click', async () => {
|
|
1631
|
+
await apiPost('/api/prompts/respond', { promptId: msg.promptId, value: b.dataset.perm });
|
|
1632
|
+
close();
|
|
1633
|
+
}));
|
|
1634
|
+
};
|
|
1635
|
+
|
|
1636
|
+
const openUserInputModal = (msg) => {
|
|
1637
|
+
const { overlay, close } = mountOverlay(`
|
|
1638
|
+
<div class="modal">
|
|
1639
|
+
<div class="modal-head">
|
|
1640
|
+
<div class="modal-title">Forge needs input</div>
|
|
1641
|
+
<button class="modal-close" data-close aria-label="Close">${icon('close')}</button>
|
|
1642
|
+
</div>
|
|
1643
|
+
<div class="modal-body">
|
|
1644
|
+
<div style="margin-bottom:12px;color:var(--fg-2)">${esc(msg.question)}</div>
|
|
1645
|
+
${msg.choices?.length
|
|
1646
|
+
? `<div style="display:flex;flex-direction:column;gap:6px">${msg.choices.map((c) => `<button class="btn btn-secondary" data-choice="${esc(c)}">${esc(c)}</button>`).join('')}</div>`
|
|
1647
|
+
: `<input type="text" id="ui-input" value="${esc(msg.defaultValue ?? '')}" />`}
|
|
1648
|
+
</div>
|
|
1649
|
+
${msg.choices?.length ? '' : `<div class="modal-foot">
|
|
1650
|
+
<button class="btn btn-ghost" data-ui="">Skip</button>
|
|
1651
|
+
<button class="btn btn-primary" data-ui="__submit__">Submit</button>
|
|
1652
|
+
</div>`}
|
|
1653
|
+
</div>`);
|
|
1654
|
+
const send = async (v) => {
|
|
1655
|
+
await apiPost('/api/prompts/respond', { promptId: msg.promptId, value: v });
|
|
1656
|
+
close();
|
|
1657
|
+
};
|
|
1658
|
+
overlay.querySelectorAll('[data-choice]').forEach((b) => b.addEventListener('click', () => send(b.dataset.choice)));
|
|
1659
|
+
overlay.querySelectorAll('[data-ui]').forEach((b) => b.addEventListener('click', () => {
|
|
1660
|
+
const v = b.dataset.ui === '__submit__' ? overlay.querySelector('#ui-input').value : '';
|
|
1661
|
+
send(v);
|
|
1662
|
+
}));
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
// ---------- Command palette ----------
|
|
1666
|
+
//
|
|
1667
|
+
// Navigation + quick actions + fallback "Run task: <text>".
|
|
1668
|
+
// Arrow keys pick items, Enter executes, Esc dismisses.
|
|
1669
|
+
|
|
1670
|
+
const buildCommands = () => [
|
|
1671
|
+
{ group: 'go', id: 'nav.dashboard', label: 'Go to Dashboard', keywords: 'home overview', icon: 'home', run: () => setView('dashboard') },
|
|
1672
|
+
{ group: 'go', id: 'nav.tasks', label: 'Go to Tasks', keywords: 'history list', icon: 'list', run: () => setView('tasks') },
|
|
1673
|
+
{ group: 'go', id: 'nav.active', label: 'Go to Active', keywords: 'running live', icon: 'bolt', run: () => setView('active') },
|
|
1674
|
+
{ group: 'go', id: 'nav.run', label: 'Open New-Task form', keywords: 'launch create', icon: 'play', run: () => setView('run') },
|
|
1675
|
+
{ group: 'go', id: 'nav.models', label: 'Go to Models', keywords: 'providers', icon: 'brain', run: () => setView('models') },
|
|
1676
|
+
{ group: 'go', id: 'nav.mcp', label: 'Go to MCP connections', keywords: 'servers', icon: 'plug', run: () => setView('mcp') },
|
|
1677
|
+
{ group: 'go', id: 'nav.skills', label: 'Go to Skills', keywords: 'plugins', icon: 'star', run: () => setView('skills') },
|
|
1678
|
+
{ group: 'go', id: 'nav.web', label: 'Go to Web tools', keywords: 'search fetch', icon: 'globe', run: () => setView('web') },
|
|
1679
|
+
{ group: 'go', id: 'nav.memory', label: 'Go to Cold memory', keywords: 'index search', icon: 'archive', run: () => setView('memory') },
|
|
1680
|
+
{ group: 'go', id: 'nav.learning', label: 'Go to Learning memory', keywords: 'patterns', icon: 'sparkle', run: () => setView('learning') },
|
|
1681
|
+
{ group: 'go', id: 'nav.cost', label: 'Go to Cost', keywords: 'tokens usd', icon: 'coin', run: () => setView('cost') },
|
|
1682
|
+
{ group: 'go', id: 'nav.config', label: 'Go to Config', keywords: 'settings json', icon: 'gear', run: () => setView('config') },
|
|
1683
|
+
{ group: 'go', id: 'nav.doctor', label: 'Go to Doctor', keywords: 'health diag', icon: 'heart', run: () => setView('doctor') },
|
|
1684
|
+
{ group: 'action', id: 'act.index', label: 'Re-index cold memory', keywords: 'fts5 search', icon: 'archive', run: async () => { try { const s = await apiPost('/api/memory/index', {}); toast(`indexed ${s.scanned} files`, 'ok'); } catch (e) { toast(String(e), 'err'); } } },
|
|
1685
|
+
{ group: 'action', id: 'act.doctor', label: 'Run health check', keywords: 'diagnose', icon: 'heart', run: () => setView('doctor') },
|
|
1686
|
+
{ group: 'action', id: 'act.cancel', label: 'Cancel all active tasks',keywords: 'stop', icon: 'close', run: async () => {
|
|
1687
|
+
const { active } = await api('/api/tasks/active');
|
|
1688
|
+
for (const t of active) await apiPost(`/api/tasks/${t.taskId}/cancel`);
|
|
1689
|
+
toast(`cancelled ${active.length}`, 'warn');
|
|
1690
|
+
} },
|
|
1691
|
+
];
|
|
1692
|
+
|
|
1693
|
+
const scoreCommand = (cmd, q) => {
|
|
1694
|
+
if (!q) return 1;
|
|
1695
|
+
const needle = q.toLowerCase();
|
|
1696
|
+
const hay = `${cmd.label} ${cmd.keywords || ''}`.toLowerCase();
|
|
1697
|
+
if (hay.includes(needle)) {
|
|
1698
|
+
// Prefer label prefix > label substring > keyword match
|
|
1699
|
+
if (cmd.label.toLowerCase().startsWith(needle)) return 3;
|
|
1700
|
+
if (cmd.label.toLowerCase().includes(needle)) return 2;
|
|
1701
|
+
return 1;
|
|
1702
|
+
}
|
|
1703
|
+
return 0;
|
|
1704
|
+
};
|
|
1705
|
+
|
|
1706
|
+
const openPalette = () => {
|
|
1707
|
+
let selected = 0;
|
|
1708
|
+
const commands = buildCommands();
|
|
1709
|
+
let filtered = commands;
|
|
1710
|
+
|
|
1711
|
+
const render = () => {
|
|
1712
|
+
const list = overlay.querySelector('#pal-list');
|
|
1713
|
+
list.innerHTML = filtered.length
|
|
1714
|
+
? filtered.map((c, i) => `
|
|
1715
|
+
<div class="palette-item ${i === selected ? 'active' : ''}" data-idx="${i}">
|
|
1716
|
+
<span class="palette-icon">${icon(c.icon || 'arrow')}</span>
|
|
1717
|
+
<span class="palette-label">${esc(c.label)}</span>
|
|
1718
|
+
<span class="palette-group">${esc(c.group)}</span>
|
|
1719
|
+
</div>`).join('')
|
|
1720
|
+
: `<div class="palette-empty">No commands match. Press <kbd>⏎</kbd> to run it as a task.</div>`;
|
|
1721
|
+
list.querySelectorAll('.palette-item').forEach((el) => {
|
|
1722
|
+
el.addEventListener('mouseenter', () => { selected = Number(el.dataset.idx); render(); });
|
|
1723
|
+
el.addEventListener('click', () => execute());
|
|
1724
|
+
});
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
const execute = async () => {
|
|
1728
|
+
const q = input.value.trim();
|
|
1729
|
+
if (filtered.length) {
|
|
1730
|
+
const cmd = filtered[selected];
|
|
1731
|
+
close();
|
|
1732
|
+
await cmd.run();
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
// Fallback: run the typed text as a task.
|
|
1736
|
+
if (q) {
|
|
1737
|
+
close();
|
|
1738
|
+
try {
|
|
1739
|
+
const { taskId } = await apiPost('/api/tasks/run', { prompt: q, autoApprove: false });
|
|
1740
|
+
toast('Task started', 'ok');
|
|
1741
|
+
openTask(taskId);
|
|
1742
|
+
} catch (e) { toast(String(e), 'err'); }
|
|
1743
|
+
} else {
|
|
1744
|
+
close();
|
|
1745
|
+
}
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
const { overlay, close } = mountOverlay(`
|
|
1749
|
+
<div class="modal" style="max-width:640px">
|
|
1750
|
+
<input class="palette-input" id="pal-input" placeholder="Jump to a view, run an action, or type a task…" autocomplete="off" />
|
|
1751
|
+
<div class="palette-list" id="pal-list"></div>
|
|
1752
|
+
<div class="palette-hint">
|
|
1753
|
+
<span><kbd>↑ ↓</kbd> navigate</span>
|
|
1754
|
+
<span><kbd>⏎</kbd> run</span>
|
|
1755
|
+
<span><kbd>esc</kbd> close</span>
|
|
1756
|
+
</div>
|
|
1757
|
+
</div>`, {
|
|
1758
|
+
onKey: (e) => {
|
|
1759
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); selected = Math.min(filtered.length - 1, selected + 1); render(); }
|
|
1760
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); selected = Math.max(0, selected - 1); render(); }
|
|
1761
|
+
if (e.key === 'Enter') { e.preventDefault(); execute(); }
|
|
1762
|
+
},
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
const input = overlay.querySelector('#pal-input');
|
|
1766
|
+
setTimeout(() => input.focus(), 30);
|
|
1767
|
+
input.addEventListener('input', () => {
|
|
1768
|
+
const q = input.value.trim();
|
|
1769
|
+
const ranked = commands
|
|
1770
|
+
.map((c) => ({ c, score: scoreCommand(c, q) }))
|
|
1771
|
+
.filter((x) => x.score > 0)
|
|
1772
|
+
.sort((a, b) => b.score - a.score)
|
|
1773
|
+
.map((x) => x.c);
|
|
1774
|
+
filtered = ranked;
|
|
1775
|
+
selected = 0;
|
|
1776
|
+
render();
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
render();
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
// ---------- Project event WS (for live log on dashboard, if used) ----------
|
|
1783
|
+
|
|
1784
|
+
const connectProjectWs = (projectPath) => {
|
|
1785
|
+
if (projectWs) { try { projectWs.close(); } catch {} }
|
|
1786
|
+
const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws?projectPath=${encodeURIComponent(projectPath)}`;
|
|
1787
|
+
try {
|
|
1788
|
+
projectWs = new WebSocket(url);
|
|
1789
|
+
projectWs.onopen = () => setStatus(true);
|
|
1790
|
+
projectWs.onclose = () => setStatus(false);
|
|
1791
|
+
} catch {}
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
const setStatus = (online) => {
|
|
1795
|
+
statusDot.classList.toggle('off', !online);
|
|
1796
|
+
statusText.textContent = online ? 'online' : 'offline';
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
const updateActiveBadge = (list) => {
|
|
1800
|
+
const n = (list ?? []).filter((t) => t.status === 'running' || t.status === 'awaiting').length;
|
|
1801
|
+
document.querySelectorAll('[data-active-count]').forEach((el) => {
|
|
1802
|
+
if (n > 0) { el.hidden = false; el.textContent = String(n); }
|
|
1803
|
+
else el.hidden = true;
|
|
1804
|
+
});
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1807
|
+
const pollActive = async () => {
|
|
1808
|
+
try { const { active } = await api('/api/tasks/active'); updateActiveBadge(active); }
|
|
1809
|
+
catch {}
|
|
1810
|
+
};
|
|
1811
|
+
|
|
1812
|
+
// ---------- Keyboard ----------
|
|
1813
|
+
//
|
|
1814
|
+
// Global shortcuts:
|
|
1815
|
+
// ⌘/Ctrl + K command palette (already existed)
|
|
1816
|
+
// ⌘/Ctrl + N new chat (jumps to chat view & creates session)
|
|
1817
|
+
// ⌘/Ctrl + Enter send chat message (inside composer) · submit task (inside run form)
|
|
1818
|
+
// ⌘/Ctrl + ↑ / ↓ previous / next chat session
|
|
1819
|
+
// / focus chat input (when on chat view)
|
|
1820
|
+
// ? open shortcut reference overlay
|
|
1821
|
+
// Esc close overlay · blur input
|
|
1822
|
+
// g then h/t/r/c/m/d vim-style go-to (dashboard/tasks/run/chat/models/doctor)
|
|
1823
|
+
// 1..9 jump to nav item N
|
|
1824
|
+
|
|
1825
|
+
const SHORTCUT_DOC = [
|
|
1826
|
+
['⌘/Ctrl + K', 'command palette'],
|
|
1827
|
+
['⌘/Ctrl + N', 'new chat'],
|
|
1828
|
+
['⌘/Ctrl + Enter', 'send from composer · submit from run form'],
|
|
1829
|
+
['⌘/Ctrl + ↑ / ↓', 'previous / next chat session'],
|
|
1830
|
+
['/', 'focus chat input'],
|
|
1831
|
+
['?', 'this help'],
|
|
1832
|
+
['Esc', 'close overlay · blur input'],
|
|
1833
|
+
['g h / g t / g r / g c', 'go to Dashboard / Tasks / Run / Chat'],
|
|
1834
|
+
['g m / g d', 'go to Models / Doctor'],
|
|
1835
|
+
['1 … 9', 'jump to nav item N'],
|
|
1836
|
+
];
|
|
1837
|
+
|
|
1838
|
+
const openShortcuts = () => {
|
|
1839
|
+
const existing = document.querySelector('.shortcut-overlay');
|
|
1840
|
+
if (existing) { existing.remove(); return; }
|
|
1841
|
+
const host = document.getElementById('overlay-host');
|
|
1842
|
+
const el = document.createElement('div');
|
|
1843
|
+
el.className = 'shortcut-overlay';
|
|
1844
|
+
el.innerHTML = `
|
|
1845
|
+
<div class="shortcut-card">
|
|
1846
|
+
<div class="shortcut-head">
|
|
1847
|
+
<span>Keyboard shortcuts</span>
|
|
1848
|
+
<button class="shortcut-close">${icon('close')}</button>
|
|
1849
|
+
</div>
|
|
1850
|
+
<table class="shortcut-table">
|
|
1851
|
+
${SHORTCUT_DOC.map(([k, d]) => `<tr><td><kbd>${esc(k)}</kbd></td><td>${esc(d)}</td></tr>`).join('')}
|
|
1852
|
+
</table>
|
|
1853
|
+
</div>
|
|
1854
|
+
`;
|
|
1855
|
+
host.appendChild(el);
|
|
1856
|
+
const close = () => el.remove();
|
|
1857
|
+
el.querySelector('.shortcut-close').addEventListener('click', close);
|
|
1858
|
+
el.addEventListener('click', (e) => { if (e.target === el) close(); });
|
|
1859
|
+
const onEsc = (e) => { if (e.key === 'Escape') { close(); window.removeEventListener('keydown', onEsc); } };
|
|
1860
|
+
window.addEventListener('keydown', onEsc);
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
// Simple two-key "g X" sequence handler: press g, then within 1s the next key
|
|
1864
|
+
// selects a destination. Works when no input is focused.
|
|
1865
|
+
let pendingG = 0;
|
|
1866
|
+
const goToMap = {
|
|
1867
|
+
h: 'dashboard',
|
|
1868
|
+
t: 'tasks',
|
|
1869
|
+
r: 'run',
|
|
1870
|
+
c: 'chat',
|
|
1871
|
+
m: 'models',
|
|
1872
|
+
d: 'doctor',
|
|
1873
|
+
s: 'skills',
|
|
1874
|
+
u: 'mcp',
|
|
1875
|
+
w: 'web',
|
|
1876
|
+
o: 'cost', // "o" for money
|
|
1877
|
+
f: 'config', // "f" for config
|
|
1878
|
+
l: 'learning',
|
|
1879
|
+
b: 'memory', // b for "brain"
|
|
1880
|
+
a: 'active',
|
|
1881
|
+
};
|
|
1882
|
+
|
|
1883
|
+
const createNewChat = async () => {
|
|
1884
|
+
if (currentView !== 'chat') setView('chat');
|
|
1885
|
+
// After the view mounts, hit the New Chat button.
|
|
1886
|
+
setTimeout(() => {
|
|
1887
|
+
const btn = document.getElementById('chat-new');
|
|
1888
|
+
if (btn) btn.click();
|
|
1889
|
+
else {
|
|
1890
|
+
// fallback: call directly if chatState exists
|
|
1891
|
+
if (typeof currentProject === 'string') {
|
|
1892
|
+
fetch('/api/chat/sessions', {
|
|
1893
|
+
method: 'POST',
|
|
1894
|
+
headers: { 'content-type': 'application/json' },
|
|
1895
|
+
body: JSON.stringify({ projectPath: currentProject }),
|
|
1896
|
+
}).then(() => setView('chat'));
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}, 120);
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1902
|
+
const cycleChatSession = async (direction) => {
|
|
1903
|
+
if (currentView !== 'chat') return;
|
|
1904
|
+
const list = [...document.querySelectorAll('.chat-list-item')];
|
|
1905
|
+
if (!list.length) return;
|
|
1906
|
+
const activeIdx = list.findIndex((el) => el.classList.contains('active'));
|
|
1907
|
+
const nextIdx = Math.max(0, Math.min(list.length - 1,
|
|
1908
|
+
(activeIdx < 0 ? 0 : activeIdx) + direction));
|
|
1909
|
+
list[nextIdx]?.click();
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1912
|
+
window.addEventListener('keydown', (e) => {
|
|
1913
|
+
const tag = e.target?.tagName;
|
|
1914
|
+
const inEditable = ['INPUT', 'TEXTAREA'].includes(tag) || e.target?.isContentEditable;
|
|
1915
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
1916
|
+
|
|
1917
|
+
// Command palette (works everywhere)
|
|
1918
|
+
if (meta && (e.key === 'k' || e.key === 'K')) {
|
|
1919
|
+
e.preventDefault();
|
|
1920
|
+
openPalette();
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// New chat (works everywhere)
|
|
1925
|
+
if (meta && (e.key === 'n' || e.key === 'N')) {
|
|
1926
|
+
e.preventDefault();
|
|
1927
|
+
createNewChat();
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// Cmd+Enter submit — routed per-context
|
|
1932
|
+
if (meta && e.key === 'Enter') {
|
|
1933
|
+
const composer = document.getElementById('chat-composer');
|
|
1934
|
+
if (composer) { e.preventDefault(); composer.dispatchEvent(new Event('submit', { cancelable: true })); return; }
|
|
1935
|
+
const runGo = document.getElementById('run-go');
|
|
1936
|
+
if (runGo) { e.preventDefault(); runGo.click(); return; }
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// Cmd+↑/↓ switches chat sessions
|
|
1940
|
+
if (meta && e.key === 'ArrowUp') { if (currentView === 'chat') { e.preventDefault(); cycleChatSession(-1); } return; }
|
|
1941
|
+
if (meta && e.key === 'ArrowDown') { if (currentView === 'chat') { e.preventDefault(); cycleChatSession(+1); } return; }
|
|
1942
|
+
|
|
1943
|
+
// Escape: close overlays, blur input
|
|
1944
|
+
if (e.key === 'Escape') {
|
|
1945
|
+
const overlay = document.querySelector('.shortcut-overlay, .palette-overlay:not([hidden])');
|
|
1946
|
+
if (overlay) { overlay.remove(); return; }
|
|
1947
|
+
if (inEditable && typeof e.target.blur === 'function') { e.target.blur(); return; }
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
if (inEditable) return;
|
|
1952
|
+
|
|
1953
|
+
// "/" focuses the chat composer
|
|
1954
|
+
if (e.key === '/' && currentView === 'chat') {
|
|
1955
|
+
const input = document.getElementById('chat-input');
|
|
1956
|
+
if (input) { e.preventDefault(); input.focus(); return; }
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// "?" opens the shortcut card
|
|
1960
|
+
if (e.key === '?') { e.preventDefault(); openShortcuts(); return; }
|
|
1961
|
+
|
|
1962
|
+
// 1..9: jump to nav item by index
|
|
1963
|
+
if (/^[1-9]$/.test(e.key)) {
|
|
1964
|
+
const items = [...document.querySelectorAll('.nav-item')];
|
|
1965
|
+
const target = items[parseInt(e.key, 10) - 1];
|
|
1966
|
+
if (target) { e.preventDefault(); target.click(); }
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// g then X: go to view
|
|
1971
|
+
if (e.key === 'g') {
|
|
1972
|
+
pendingG = Date.now();
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
if (Date.now() - pendingG < 1000 && goToMap[e.key]) {
|
|
1976
|
+
pendingG = 0;
|
|
1977
|
+
e.preventDefault();
|
|
1978
|
+
setView(goToMap[e.key]);
|
|
1979
|
+
} else if (pendingG) {
|
|
1980
|
+
pendingG = 0;
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
// ---------- Mobile navigation ----------
|
|
1985
|
+
//
|
|
1986
|
+
// The sidebar slides in from the left on mobile; the hamburger button and
|
|
1987
|
+
// backdrop come into the DOM in index.html but are hidden above the CSS
|
|
1988
|
+
// breakpoint. Wire toggle + close-on-nav + Escape.
|
|
1989
|
+
|
|
1990
|
+
const navToggleEl = document.getElementById('nav-toggle');
|
|
1991
|
+
const navBackdropEl = document.getElementById('nav-backdrop');
|
|
1992
|
+
const sidebarEl = document.querySelector('.sidebar');
|
|
1993
|
+
|
|
1994
|
+
const setNavOpen = (open) => {
|
|
1995
|
+
if (!sidebarEl || !navToggleEl || !navBackdropEl) return;
|
|
1996
|
+
sidebarEl.classList.toggle('open', open);
|
|
1997
|
+
// Visibility is managed via .visible class, NOT the hidden attribute —
|
|
1998
|
+
// the global `[hidden]{display:none!important}` rule would win against
|
|
1999
|
+
// our backdrop styles otherwise.
|
|
2000
|
+
navBackdropEl.classList.toggle('visible', open);
|
|
2001
|
+
navToggleEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
2002
|
+
document.body.style.overflow = open ? 'hidden' : '';
|
|
2003
|
+
};
|
|
2004
|
+
navToggleEl?.addEventListener('click', () => {
|
|
2005
|
+
const open = sidebarEl?.classList.contains('open');
|
|
2006
|
+
setNavOpen(!open);
|
|
2007
|
+
});
|
|
2008
|
+
navBackdropEl?.addEventListener('click', () => setNavOpen(false));
|
|
2009
|
+
// Close the sidebar when a nav item is tapped so the user sees the page.
|
|
2010
|
+
navEl.addEventListener('click', (e) => {
|
|
2011
|
+
const target = e.target;
|
|
2012
|
+
if (target && target.closest('[data-view]')) setNavOpen(false);
|
|
2013
|
+
});
|
|
2014
|
+
// Escape closes on any screen size.
|
|
2015
|
+
window.addEventListener('keydown', (e) => {
|
|
2016
|
+
if (e.key === 'Escape' && sidebarEl?.classList.contains('open')) setNavOpen(false);
|
|
2017
|
+
});
|
|
2018
|
+
// If the user resizes above the breakpoint, force-close so layout is clean.
|
|
2019
|
+
window.addEventListener('resize', () => {
|
|
2020
|
+
if (window.innerWidth > 920) setNavOpen(false);
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
// ---------- Sortable tables ----------
|
|
2024
|
+
//
|
|
2025
|
+
// Any table with class `sortable` gets click-to-sort on each <th> that has
|
|
2026
|
+
// a `data-sort="text|number|date"` attribute. Cells may override their
|
|
2027
|
+
// sort key via `data-raw="..."` (useful for columns that display a
|
|
2028
|
+
// formatted value — e.g. "3m ago" — but need the raw timestamp for sort).
|
|
2029
|
+
//
|
|
2030
|
+
// Default sort can be set via `data-default-sort="<col-index>"` (zero-based)
|
|
2031
|
+
// and `data-default-dir="asc|desc"` on the <table>.
|
|
2032
|
+
|
|
2033
|
+
const readCellKey = (td, kind) => {
|
|
2034
|
+
const raw = td.getAttribute('data-raw');
|
|
2035
|
+
const text = (raw ?? td.textContent ?? '').trim();
|
|
2036
|
+
if (kind === 'number') {
|
|
2037
|
+
const n = parseFloat(text.replace(/[^\d.\-eE]/g, ''));
|
|
2038
|
+
return Number.isFinite(n) ? n : Number.NEGATIVE_INFINITY;
|
|
2039
|
+
}
|
|
2040
|
+
if (kind === 'date') {
|
|
2041
|
+
const t = Date.parse(text);
|
|
2042
|
+
return Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY;
|
|
2043
|
+
}
|
|
2044
|
+
return text.toLowerCase();
|
|
2045
|
+
};
|
|
2046
|
+
|
|
2047
|
+
const sortTable = (table, colIdx, dir) => {
|
|
2048
|
+
const ths = [...table.querySelectorAll('thead th')];
|
|
2049
|
+
const kind = ths[colIdx]?.dataset.sort || 'text';
|
|
2050
|
+
const tbody = table.querySelector('tbody');
|
|
2051
|
+
if (!tbody) return;
|
|
2052
|
+
const rows = [...tbody.querySelectorAll('tr')];
|
|
2053
|
+
rows.sort((a, b) => {
|
|
2054
|
+
const ka = readCellKey(a.children[colIdx], kind);
|
|
2055
|
+
const kb = readCellKey(b.children[colIdx], kind);
|
|
2056
|
+
if (ka < kb) return dir === 'asc' ? -1 : 1;
|
|
2057
|
+
if (ka > kb) return dir === 'asc' ? 1 : -1;
|
|
2058
|
+
return 0;
|
|
2059
|
+
});
|
|
2060
|
+
for (const row of rows) tbody.appendChild(row);
|
|
2061
|
+
// Visual indicator on headers.
|
|
2062
|
+
ths.forEach((th, i) => {
|
|
2063
|
+
th.classList.remove('sorted-asc', 'sorted-desc');
|
|
2064
|
+
if (i === colIdx) th.classList.add(dir === 'asc' ? 'sorted-asc' : 'sorted-desc');
|
|
2065
|
+
});
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2068
|
+
const initSortableTables = (rootEl) => {
|
|
2069
|
+
const root = rootEl ?? document;
|
|
2070
|
+
root.querySelectorAll('table.sortable').forEach((table) => {
|
|
2071
|
+
if (table.dataset.sortWired) return;
|
|
2072
|
+
table.dataset.sortWired = '1';
|
|
2073
|
+
const ths = [...table.querySelectorAll('thead th')];
|
|
2074
|
+
ths.forEach((th, i) => {
|
|
2075
|
+
if (!th.dataset.sort) return;
|
|
2076
|
+
th.classList.add('sort-head');
|
|
2077
|
+
th.setAttribute('tabindex', '0');
|
|
2078
|
+
th.setAttribute('role', 'button');
|
|
2079
|
+
th.setAttribute('aria-label', `Sort by ${th.textContent.trim()}`);
|
|
2080
|
+
const trigger = () => {
|
|
2081
|
+
const current =
|
|
2082
|
+
th.classList.contains('sorted-asc') ? 'asc' :
|
|
2083
|
+
th.classList.contains('sorted-desc') ? 'desc' : null;
|
|
2084
|
+
const next = current === 'asc' ? 'desc' : 'asc';
|
|
2085
|
+
sortTable(table, i, next);
|
|
2086
|
+
};
|
|
2087
|
+
th.addEventListener('click', trigger);
|
|
2088
|
+
th.addEventListener('keydown', (e) => {
|
|
2089
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); trigger(); }
|
|
2090
|
+
});
|
|
2091
|
+
});
|
|
2092
|
+
const defaultCol = parseInt(table.dataset.defaultSort ?? '', 10);
|
|
2093
|
+
const defaultDir = table.dataset.defaultDir === 'desc' ? 'desc' : 'asc';
|
|
2094
|
+
if (Number.isFinite(defaultCol)) sortTable(table, defaultCol, defaultDir);
|
|
2095
|
+
});
|
|
2096
|
+
};
|
|
2097
|
+
|
|
2098
|
+
// Auto-wire sortable tables whenever the app root is mutated.
|
|
2099
|
+
const _sortObserver = new MutationObserver(() => initSortableTables());
|
|
2100
|
+
_sortObserver.observe(document.getElementById('app'), { childList: true, subtree: true });
|
|
2101
|
+
|
|
2102
|
+
// ---------- Bootstrap ----------
|
|
2103
|
+
|
|
2104
|
+
renderNav();
|
|
2105
|
+
setStatus(true);
|
|
2106
|
+
setView('dashboard');
|
|
2107
|
+
setInterval(pollActive, 4000);
|
|
2108
|
+
pollActive();
|
|
2109
|
+
|
|
2110
|
+
api('/api/projects').then((ps) => {
|
|
2111
|
+
currentProject = ps[0]?.path || currentProject;
|
|
2112
|
+
if (currentProject) connectProjectWs(currentProject);
|
|
2113
|
+
}).catch(() => {});
|