multacd 1.0.0__tar.gz → 2.0.0b2__tar.gz

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.
Files changed (199) hide show
  1. {multacd-1.0.0 → multacd-2.0.0b2}/.github/workflows/release.yml +14 -3
  2. {multacd-1.0.0 → multacd-2.0.0b2}/CHANGELOG.md +29 -0
  3. {multacd-1.0.0 → multacd-2.0.0b2}/DESIGN.md +23 -0
  4. {multacd-1.0.0 → multacd-2.0.0b2}/PKG-INFO +2 -3
  5. {multacd-1.0.0 → multacd-2.0.0b2}/README.md +1 -1
  6. {multacd-1.0.0 → multacd-2.0.0b2}/core/agent_loop.py +129 -16
  7. multacd-2.0.0b2/core/clipboard.py +74 -0
  8. {multacd-1.0.0 → multacd-2.0.0b2}/core/config.py +12 -12
  9. multacd-2.0.0b2/core/llm_client.py +367 -0
  10. {multacd-1.0.0 → multacd-2.0.0b2}/core/mode_manager.py +13 -13
  11. {multacd-1.0.0 → multacd-2.0.0b2}/core/permissions.py +27 -3
  12. multacd-2.0.0b2/core/providers/__init__.py +137 -0
  13. multacd-2.0.0b2/core/providers/__main__.py +38 -0
  14. multacd-2.0.0b2/core/providers/anthropic.py +329 -0
  15. multacd-2.0.0b2/core/providers/openai_compat.py +301 -0
  16. {multacd-1.0.0 → multacd-2.0.0b2}/core/version.py +1 -1
  17. {multacd-1.0.0 → multacd-2.0.0b2}/daemon/process.py +7 -7
  18. {multacd-1.0.0 → multacd-2.0.0b2}/docs/configuration.md +1 -1
  19. {multacd-1.0.0 → multacd-2.0.0b2}/docs/index.md +1 -1
  20. {multacd-1.0.0 → multacd-2.0.0b2}/main.py +12 -12
  21. {multacd-1.0.0 → multacd-2.0.0b2}/multacd.spec +8 -23
  22. {multacd-1.0.0 → multacd-2.0.0b2}/plan/PLAN-phase2.md +4 -4
  23. {multacd-1.0.0 → multacd-2.0.0b2}/pyproject.toml +3 -2
  24. {multacd-1.0.0 → multacd-2.0.0b2}/requirements.txt +0 -1
  25. {multacd-1.0.0 → multacd-2.0.0b2}/roadmap/ROADMAP.md +8 -1
  26. {multacd-1.0.0 → multacd-2.0.0b2}/scheduler/engine.py +1 -1
  27. {multacd-1.0.0 → multacd-2.0.0b2}/scheduler/jobs/__init__.py +1 -1
  28. {multacd-1.0.0 → multacd-2.0.0b2}/scripts/install.sh +45 -9
  29. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_binary.py +3 -1
  30. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_entrypoint.py +3 -4
  31. {multacd-1.0.0 → multacd-2.0.0b2}/tools/code/run_python.py +17 -20
  32. {multacd-1.0.0 → multacd-2.0.0b2}/tools/code/run_tests.py +12 -10
  33. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_push.py +1 -1
  34. {multacd-1.0.0 → multacd-2.0.0b2}/tools/registry.py +21 -3
  35. {multacd-1.0.0 → multacd-2.0.0b2}/tools/shell/bash.py +12 -17
  36. multacd-2.0.0b2/tools/streaming.py +142 -0
  37. {multacd-1.0.0 → multacd-2.0.0b2}/tui/app.py +1 -1
  38. {multacd-1.0.0 → multacd-2.0.0b2}/tui/icons.py +25 -6
  39. {multacd-1.0.0 → multacd-2.0.0b2}/tui/screens/main_screen.py +249 -17
  40. {multacd-1.0.0 → multacd-2.0.0b2}/tui/screens/setup_wizard.py +156 -29
  41. {multacd-1.0.0 → multacd-2.0.0b2}/tui/themes/__init__.py +91 -14
  42. multacd-2.0.0b2/tui/themes/__main__.py +26 -0
  43. multacd-2.0.0b2/tui/tokens.py +90 -0
  44. multacd-2.0.0b2/tui/widgets/chat_panel.py +182 -0
  45. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/confirm_dialog.py +8 -4
  46. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/diff_viewer.py +15 -3
  47. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/file_tree.py +4 -4
  48. multacd-2.0.0b2/tui/widgets/info_panel.py +87 -0
  49. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/input_bar.py +35 -2
  50. multacd-2.0.0b2/tui/widgets/model_selector.py +335 -0
  51. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/permission_popup.py +83 -16
  52. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/slash_palette.py +29 -4
  53. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/sources_panel.py +5 -2
  54. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/status_bar.py +22 -7
  55. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/thinking_bar.py +16 -1
  56. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/tool_activity.py +11 -6
  57. multacd-1.0.0/core/llm_client.py +0 -347
  58. multacd-1.0.0/plan/PLAN-phase3.md +0 -1103
  59. multacd-1.0.0/plan/PLAN-phase4.md +0 -1326
  60. multacd-1.0.0/plan/PLAN-phase5.md +0 -1334
  61. multacd-1.0.0/tui/themes/__main__.py +0 -17
  62. multacd-1.0.0/tui/widgets/chat_panel.py +0 -83
  63. {multacd-1.0.0 → multacd-2.0.0b2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  64. {multacd-1.0.0 → multacd-2.0.0b2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  65. {multacd-1.0.0 → multacd-2.0.0b2}/.github/pull_request_template.md +0 -0
  66. {multacd-1.0.0 → multacd-2.0.0b2}/.github/workflows/test.yml +0 -0
  67. {multacd-1.0.0 → multacd-2.0.0b2}/.gitignore +0 -0
  68. {multacd-1.0.0 → multacd-2.0.0b2}/CONTRIBUTING.md +0 -0
  69. {multacd-1.0.0 → multacd-2.0.0b2}/LICENSE +0 -0
  70. {multacd-1.0.0 → multacd-2.0.0b2}/briefing/__init__.py +0 -0
  71. {multacd-1.0.0 → multacd-2.0.0b2}/briefing/generator.py +0 -0
  72. {multacd-1.0.0 → multacd-2.0.0b2}/briefing/privacy.py +0 -0
  73. {multacd-1.0.0 → multacd-2.0.0b2}/briefing/sources/__init__.py +0 -0
  74. {multacd-1.0.0 → multacd-2.0.0b2}/briefing/sources/git_source.py +0 -0
  75. {multacd-1.0.0 → multacd-2.0.0b2}/briefing/sources/news_source.py +0 -0
  76. {multacd-1.0.0 → multacd-2.0.0b2}/briefing/sources/todo_source.py +0 -0
  77. {multacd-1.0.0 → multacd-2.0.0b2}/core/__init__.py +0 -0
  78. {multacd-1.0.0 → multacd-2.0.0b2}/core/codebase.py +0 -0
  79. {multacd-1.0.0 → multacd-2.0.0b2}/core/plugin_loader.py +0 -0
  80. {multacd-1.0.0 → multacd-2.0.0b2}/core/prompt_composer.py +0 -0
  81. {multacd-1.0.0 → multacd-2.0.0b2}/core/research/__init__.py +0 -0
  82. {multacd-1.0.0 → multacd-2.0.0b2}/core/research/bus.py +0 -0
  83. {multacd-1.0.0 → multacd-2.0.0b2}/core/research/orchestrator.py +0 -0
  84. {multacd-1.0.0 → multacd-2.0.0b2}/core/research/query_generator.py +0 -0
  85. {multacd-1.0.0 → multacd-2.0.0b2}/core/updater.py +0 -0
  86. {multacd-1.0.0 → multacd-2.0.0b2}/daemon/__init__.py +0 -0
  87. {multacd-1.0.0 → multacd-2.0.0b2}/daemon/ipc.py +0 -0
  88. {multacd-1.0.0 → multacd-2.0.0b2}/docs/install.md +0 -0
  89. {multacd-1.0.0 → multacd-2.0.0b2}/docs/mkdocs.yml +0 -0
  90. {multacd-1.0.0 → multacd-2.0.0b2}/docs/modes/coding.md +0 -0
  91. {multacd-1.0.0 → multacd-2.0.0b2}/docs/modes/personal.md +0 -0
  92. {multacd-1.0.0 → multacd-2.0.0b2}/docs/modes/research.md +0 -0
  93. {multacd-1.0.0 → multacd-2.0.0b2}/docs/plugins.md +0 -0
  94. {multacd-1.0.0 → multacd-2.0.0b2}/docs/quickstart.md +0 -0
  95. {multacd-1.0.0 → multacd-2.0.0b2}/memory/__init__.py +0 -0
  96. {multacd-1.0.0 → multacd-2.0.0b2}/memory/context.py +0 -0
  97. {multacd-1.0.0 → multacd-2.0.0b2}/memory/store.py +0 -0
  98. {multacd-1.0.0 → multacd-2.0.0b2}/plan/PLAN-phase1.md +0 -0
  99. {multacd-1.0.0 → multacd-2.0.0b2}/plugins/README.md +0 -0
  100. {multacd-1.0.0 → multacd-2.0.0b2}/plugins/example_plugin.py +0 -0
  101. {multacd-1.0.0 → multacd-2.0.0b2}/scheduler/__init__.py +0 -0
  102. {multacd-1.0.0 → multacd-2.0.0b2}/scheduler/jobs/briefing_job.py +0 -0
  103. {multacd-1.0.0 → multacd-2.0.0b2}/scheduler/jobs/research_job.py +0 -0
  104. {multacd-1.0.0 → multacd-2.0.0b2}/scripts/install.ps1 +0 -0
  105. {multacd-1.0.0 → multacd-2.0.0b2}/search_providers/__init__.py +0 -0
  106. {multacd-1.0.0 → multacd-2.0.0b2}/search_providers/__main__.py +0 -0
  107. {multacd-1.0.0 → multacd-2.0.0b2}/search_providers/base.py +0 -0
  108. {multacd-1.0.0 → multacd-2.0.0b2}/search_providers/brave.py +0 -0
  109. {multacd-1.0.0 → multacd-2.0.0b2}/search_providers/duckduckgo.py +0 -0
  110. {multacd-1.0.0 → multacd-2.0.0b2}/search_providers/exa.py +0 -0
  111. {multacd-1.0.0 → multacd-2.0.0b2}/search_providers/serpapi.py +0 -0
  112. {multacd-1.0.0 → multacd-2.0.0b2}/search_providers/tavily.py +0 -0
  113. {multacd-1.0.0 → multacd-2.0.0b2}/soul.md +0 -0
  114. {multacd-1.0.0 → multacd-2.0.0b2}/tests/__init__.py +0 -0
  115. {multacd-1.0.0 → multacd-2.0.0b2}/tests/conftest.py +0 -0
  116. {multacd-1.0.0 → multacd-2.0.0b2}/tests/integration/__init__.py +0 -0
  117. {multacd-1.0.0 → multacd-2.0.0b2}/tests/integration/test_agent_loop.py +0 -0
  118. {multacd-1.0.0 → multacd-2.0.0b2}/tests/test_selftests.py +0 -0
  119. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/__init__.py +0 -0
  120. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_config.py +0 -0
  121. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_docs.py +0 -0
  122. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_github.py +0 -0
  123. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_installer.py +0 -0
  124. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_permissions.py +0 -0
  125. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_plugin_loader.py +0 -0
  126. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_privacy_filter.py +0 -0
  127. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_search_providers/__init__.py +0 -0
  128. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_search_providers/test_tavily.py +0 -0
  129. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_tools/__init__.py +0 -0
  130. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_tools/test_bash.py +0 -0
  131. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_tools/test_read_file.py +0 -0
  132. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_tools/test_web_scrape.py +0 -0
  133. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_tools/test_write_file.py +0 -0
  134. {multacd-1.0.0 → multacd-2.0.0b2}/tests/unit/test_updater.py +0 -0
  135. {multacd-1.0.0 → multacd-2.0.0b2}/tg/__init__.py +0 -0
  136. {multacd-1.0.0 → multacd-2.0.0b2}/tg/access_control.py +0 -0
  137. {multacd-1.0.0 → multacd-2.0.0b2}/tg/agent.py +0 -0
  138. {multacd-1.0.0 → multacd-2.0.0b2}/tg/bot.py +0 -0
  139. {multacd-1.0.0 → multacd-2.0.0b2}/tg/file_handler.py +0 -0
  140. {multacd-1.0.0 → multacd-2.0.0b2}/tg/formatter.py +0 -0
  141. {multacd-1.0.0 → multacd-2.0.0b2}/tg/handlers.py +0 -0
  142. {multacd-1.0.0 → multacd-2.0.0b2}/tools/__init__.py +0 -0
  143. {multacd-1.0.0 → multacd-2.0.0b2}/tools/agent/__init__.py +0 -0
  144. {multacd-1.0.0 → multacd-2.0.0b2}/tools/agent/ask.py +0 -0
  145. {multacd-1.0.0 → multacd-2.0.0b2}/tools/agent/skill.py +0 -0
  146. {multacd-1.0.0 → multacd-2.0.0b2}/tools/agent/task.py +0 -0
  147. {multacd-1.0.0 → multacd-2.0.0b2}/tools/agent/todo_write.py +0 -0
  148. {multacd-1.0.0 → multacd-2.0.0b2}/tools/code/__init__.py +0 -0
  149. {multacd-1.0.0 → multacd-2.0.0b2}/tools/code/lint_python.py +0 -0
  150. {multacd-1.0.0 → multacd-2.0.0b2}/tools/codebase/__init__.py +0 -0
  151. {multacd-1.0.0 → multacd-2.0.0b2}/tools/codebase/scan_codebase.py +0 -0
  152. {multacd-1.0.0 → multacd-2.0.0b2}/tools/common.py +0 -0
  153. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/__init__.py +0 -0
  154. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/apply_patch.py +0 -0
  155. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/delete_file.py +0 -0
  156. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/edit_file.py +0 -0
  157. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/glob.py +0 -0
  158. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/grep.py +0 -0
  159. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/list_dir.py +0 -0
  160. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/move_file.py +0 -0
  161. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/multi_edit.py +0 -0
  162. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/read_file.py +0 -0
  163. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/read_many_files.py +0 -0
  164. {multacd-1.0.0 → multacd-2.0.0b2}/tools/filesystem/write_file.py +0 -0
  165. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/__init__.py +0 -0
  166. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/_helper.py +0 -0
  167. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_add.py +0 -0
  168. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_branch.py +0 -0
  169. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_checkout.py +0 -0
  170. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_commit.py +0 -0
  171. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_diff.py +0 -0
  172. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_log.py +0 -0
  173. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_merge.py +0 -0
  174. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_pull.py +0 -0
  175. {multacd-1.0.0 → multacd-2.0.0b2}/tools/git/git_status.py +0 -0
  176. {multacd-1.0.0 → multacd-2.0.0b2}/tools/memory/__init__.py +0 -0
  177. {multacd-1.0.0 → multacd-2.0.0b2}/tools/memory/forget.py +0 -0
  178. {multacd-1.0.0 → multacd-2.0.0b2}/tools/memory/recall.py +0 -0
  179. {multacd-1.0.0 → multacd-2.0.0b2}/tools/memory/remember.py +0 -0
  180. {multacd-1.0.0 → multacd-2.0.0b2}/tools/personal/__init__.py +0 -0
  181. {multacd-1.0.0 → multacd-2.0.0b2}/tools/personal/cancel_job.py +0 -0
  182. {multacd-1.0.0 → multacd-2.0.0b2}/tools/personal/daemon_status.py +0 -0
  183. {multacd-1.0.0 → multacd-2.0.0b2}/tools/personal/generate_briefing.py +0 -0
  184. {multacd-1.0.0 → multacd-2.0.0b2}/tools/personal/get_jobs.py +0 -0
  185. {multacd-1.0.0 → multacd-2.0.0b2}/tools/personal/schedule_job.py +0 -0
  186. {multacd-1.0.0 → multacd-2.0.0b2}/tools/personal/send_telegram.py +0 -0
  187. {multacd-1.0.0 → multacd-2.0.0b2}/tools/personal/user_manager.py +0 -0
  188. {multacd-1.0.0 → multacd-2.0.0b2}/tools/research/__init__.py +0 -0
  189. {multacd-1.0.0 → multacd-2.0.0b2}/tools/research/deep_research.py +0 -0
  190. {multacd-1.0.0 → multacd-2.0.0b2}/tools/research/export_research.py +0 -0
  191. {multacd-1.0.0 → multacd-2.0.0b2}/tools/research/quick_research.py +0 -0
  192. {multacd-1.0.0 → multacd-2.0.0b2}/tools/research/web_scrape.py +0 -0
  193. {multacd-1.0.0 → multacd-2.0.0b2}/tools/research/web_search.py +0 -0
  194. {multacd-1.0.0 → multacd-2.0.0b2}/tools/shell/__init__.py +0 -0
  195. {multacd-1.0.0 → multacd-2.0.0b2}/tools/web/__init__.py +0 -0
  196. {multacd-1.0.0 → multacd-2.0.0b2}/tools/web/web_fetch.py +0 -0
  197. {multacd-1.0.0 → multacd-2.0.0b2}/tui/__init__.py +0 -0
  198. {multacd-1.0.0 → multacd-2.0.0b2}/tui/screens/__init__.py +0 -0
  199. {multacd-1.0.0 → multacd-2.0.0b2}/tui/widgets/__init__.py +0 -0
@@ -76,7 +76,9 @@ jobs:
76
76
  path: dist_bin/
77
77
  - run: pip install build twine
78
78
  - run: python -m build --outdir dist_pkg/
79
- - run: twine upload dist_pkg/*
79
+ # --skip-existing: rerun aman (file yang sudah naik di-skip, bukan error).
80
+ # --verbose: body error PyPI kelihatan di log kalau 4xx lagi.
81
+ - run: twine upload --skip-existing --verbose dist_pkg/*
80
82
  env:
81
83
  TWINE_USERNAME: __token__
82
84
  TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
@@ -84,13 +86,22 @@ jobs:
84
86
  with:
85
87
  files: dist_bin/**/*
86
88
  generate_release_notes: true
89
+ # Tag beta (v2.0.0b1 / vX-rc) → prerelease, bukan final.
90
+ prerelease: ${{ contains(github.ref_name, 'b') || contains(github.ref_name, '-') }}
87
91
  body: |
88
92
  ## Install
89
93
 
90
- Linux / macOS / Termux:
94
+ Linux / macOS (download dulu biar gagalnya kelihatan):
91
95
 
92
96
  ```bash
93
- curl -fsSL https://raw.githubusercontent.com/prototypeall850-creator/multacd/main/scripts/install.sh | bash
97
+ curl -fSL https://raw.githubusercontent.com/prototypeall850-creator/multacd/main/scripts/install.sh -o ./m-install.sh && bash ./m-install.sh && rm ./m-install.sh
98
+ ```
99
+
100
+ Termux (binary rilis tidak kompatibel — pakai pip):
101
+
102
+ ```bash
103
+ pkg install -y python git
104
+ pip install "multacd==2.0.0b2" --pre
94
105
  ```
95
106
 
96
107
  Windows (PowerShell):
@@ -2,6 +2,35 @@
2
2
 
3
3
  Format: Keep a Changelog. Versi: Semantic Versioning.
4
4
 
5
+ ## [2.0.0b2] - 2026-09-12
6
+
7
+ Beta v2 kedua: cabut LiteLLM, ganti provider native (httpx, 2 adapter
8
+ SSE: OpenAI-compatible + Anthropic) — install pip Termux ringan tanpa
9
+ Rust, binary susut 91MB ke 56MB. Cost estimasi lokal ala OpenCode
10
+ tampil di info panel. Interface stream_completion tidak berubah.
11
+
12
+ Install beta: `pip install --pre "multacd==2.0.0b2"` (PyPI pre-release)
13
+ atau binary dari halaman GitHub prerelease.
14
+
15
+ ## [2.0.0b1] - 2026-09-12
16
+
17
+ Beta v2 buat test Termux: redesign TUI (tanpa emoticon, responsif),
18
+ model selector, info panel, live output, token resmi, approve commit,
19
+ wizard back-nav, /copy, installer anti-diam + panduan pip Termux.
20
+
21
+ Install beta: `pip install --pre "multacd==2.0.0b1"` (PyPI pre-release)
22
+ atau binary dari halaman GitHub prerelease.
23
+
24
+ ## [Unreleased] (menuju 2.0.0 — redesign v2 full bebas)
25
+
26
+ ### Changed (breaking visual, config v1 tetap kebaca)
27
+ - Tema default `multacd-dark` + `multacd-light`/`multacd-min` (Termux hemat)
28
+ - Status bar compact di layar sempit, splash v2, palette fuzzy, thinking statis min-mode
29
+ - Wizard numbering /6, AskDialog responsif, marker git peach
30
+ - Wizard back-nav (Esc/tombol, fetch basi dibuang), tree reload tiap turn
31
+ - `/copy [n]` + Ctrl+Y salin jawaban (clipboard chain + fallback file)
32
+ - Live output tool, token resmi provider, approve commit + branch dialog
33
+
5
34
  ## [1.0.0] - 2026-09-11
6
35
 
7
36
  Rilis stabil pertama: coding + research + personal agent dalam satu TUI,
@@ -990,3 +990,26 @@ Section Keputusan Mulai berlaku
990
990
  11 Info panel kanan (Ctrl+I) Phase 2 TUI
991
991
  12 Model selector popup Phase 2 TUI
992
992
  13 Permission bar (inline, 3 brs) Phase 2 TUI (ganti Section 4)
993
+
994
+ ---
995
+
996
+ ## 14. Redesign v2 (full bebas, Termux baseline v0.119)
997
+
998
+ Breaking visual, kompatibel config (config v1 tetap kebaca):
999
+
1000
+ - Tema default = multacd-dark (identitas sendiri, turunan Mocha dengan
1001
+ aksen sapphire + kontras naik buat HP). Catppuccin 4 varian tetap ada.
1002
+ Tambah multacd-light + multacd-min (16 warna sistem, Termux hemat).
1003
+ Token semantik di tui/tokens.py — widget dilarang hardcode hex.
1004
+ - Icon unicode tanpa emoji 2-cell. Auto jadi ascii kalau
1005
+ NO_EMOJI/NO_COLOR/Termux sempit (<70 kolom).
1006
+ - Status bar responsif: <70 kolom tampil 'multacd - mode - status' saja.
1007
+ Bukan repo = segmen git hilang (bukan 'no git').
1008
+ - Thinking statis saat MULTACD_MIN=1/NO_COLOR (tanpa repaint 200ms).
1009
+ - Slash palette fuzzy berperingkat: '/md' ketemu '/model'.
1010
+ - Tool header berwarna (run biru/sukses hijau/gagal merah).
1011
+ - Splash v2: logo + mode-model-versi + 1 tip. Welcome ringkas 2 baris.
1012
+ - Panel kanan min-width 28 ke 20. Git watcher 60 dtk saat min-mode.
1013
+ - AskDialog responsif (80% max 60) + ikon tanpa emoji.
1014
+ - Wizard numbering /6 (sebelumnya campur /5 dan /6).
1015
+ - File tree marker peach #fab387.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: multacd
3
- Version: 1.0.0
3
+ Version: 2.0.0b2
4
4
  Summary: Agentic TUI for coding, research, and personal automation
5
5
  Project-URL: Homepage, https://github.com/prototypeall850-creator/multacd
6
6
  Project-URL: Issues, https://github.com/prototypeall850-creator/multacd/issues
@@ -20,7 +20,6 @@ Requires-Dist: apscheduler>=3.10.0
20
20
  Requires-Dist: beautifulsoup4>=4.12.0
21
21
  Requires-Dist: exa-py>=1.0.0
22
22
  Requires-Dist: httpx>=0.27.0
23
- Requires-Dist: litellm>=1.40.0
24
23
  Requires-Dist: markdownify>=0.12.0
25
24
  Requires-Dist: pydantic>=2.0
26
25
  Requires-Dist: python-telegram-bot>=21.0
@@ -38,7 +37,7 @@ Description-Content-Type: text/markdown
38
37
  # ⚡ multacd
39
38
 
40
39
  Agentic TUI untuk coding, research, dan personal automation — dalam satu terminal.
41
- Satu config BYOK untuk 100+ LLM provider via LiteLLM.
40
+ Satu config BYOK untuk LLM favoritmu via provider native (ringan, Termux aman).
42
41
 
43
42
  ```
44
43
  > /code baca main.py lalu jelaskan cara kerjanya
@@ -1,7 +1,7 @@
1
1
  # ⚡ multacd
2
2
 
3
3
  Agentic TUI untuk coding, research, dan personal automation — dalam satu terminal.
4
- Satu config BYOK untuk 100+ LLM provider via LiteLLM.
4
+ Satu config BYOK untuk LLM favoritmu via provider native (ringan, Termux aman).
5
5
 
6
6
  ```
7
7
  > /code baca main.py lalu jelaskan cara kerjanya
@@ -5,8 +5,8 @@ Async generator — setiap update di-yield real-time ke TUI:
5
5
 
6
6
  async for event in run_agent(user_input, context, config):
7
7
  isinstance(event, AgentText) → tampilkan potongan teks
8
- isinstance(event, AgentToolStart) → tampilkan 🔧 ... ⏳
9
- isinstance(event, AgentToolDone) → update jadi ✅ / ❌
8
+ isinstance(event, AgentToolStart) → tampilkan tool berjalan
9
+ isinstance(event, AgentToolDone) → update jadi sukses / gagal
10
10
  isinstance(event, AgentDone) → agent selesai
11
11
  isinstance(event, AgentError) → tampilkan error, loop berhenti
12
12
 
@@ -49,6 +49,9 @@ SYSTEM_PROMPT = (
49
49
  # confirm: "yes" | "no" | "all" (all = izinkan semua sesi ini)
50
50
  ConfirmCallback = Callable[[str, dict[str, Any]], Awaitable[str]]
51
51
  AskCallback = Callable[[str], Awaitable[str]]
52
+ # Live output tool: (call_id, baris). Dipanggil dari thread worker tool —
53
+ # TUI wajib marshal (call_from_thread); CLI boleh print langsung.
54
+ OutputCallback = Callable[[str, str], None]
52
55
 
53
56
 
54
57
  @dataclass
@@ -76,29 +79,52 @@ class AgentDone:
76
79
  text: str
77
80
 
78
81
 
82
+ @dataclass
83
+ class AgentUsage:
84
+ """Token resmi provider untuk satu panggilan LLM (0 = tak dilapor)."""
85
+
86
+ prompt_tokens: int = 0
87
+ completion_tokens: int = 0
88
+ cost_usd: float = 0.0 # estimasi lokal (tabel harga provider)
89
+
90
+
79
91
  @dataclass
80
92
  class AgentError:
81
93
  message: str
82
94
 
83
95
 
84
- AgentEvent = AgentText | AgentToolStart | AgentToolDone | AgentDone | AgentError
96
+ AgentEvent = AgentText | AgentToolStart | AgentToolDone | AgentDone | AgentUsage | AgentError
85
97
 
86
98
 
87
99
  async def _stdin_confirm(tool_name: str, params: dict[str, Any]) -> str:
88
100
  target = params.get("path") or params.get("command") or params.get("url") or ""
89
- print(f"⚠️ {tool_name} {target} — izinkan? [y/n/a] ", end="", flush=True)
101
+ extra = ""
102
+ if tool_name == "git_commit":
103
+ extra = "/e edit pesan"
104
+ elif tool_name == "git_push":
105
+ extra = "/b branch baru"
106
+ print(f"[izin] {tool_name} {target} — lanjutkan? [y/n/a{extra}] ", end="", flush=True)
90
107
  try:
91
108
  ans = await asyncio.to_thread(input)
92
109
  except (EOFError, KeyboardInterrupt):
93
110
  return "no"
94
- ans = ans.strip().lower()
111
+ ans = ans.strip()
95
112
  if ans in ("a", "all"):
96
113
  return "all"
114
+ if ans in ("e", "edit") and tool_name == "git_commit":
115
+ try:
116
+ new_msg = await asyncio.to_thread(
117
+ input, f"Pesan baru [{params.get('message', '')}]: ")
118
+ except (EOFError, KeyboardInterrupt):
119
+ return "no"
120
+ return f"edit:{new_msg.strip() or params.get('message', '')}"
121
+ if ans in ("b", "branch") and tool_name == "git_push":
122
+ return "branch"
97
123
  return "yes" if ans in ("y", "yes") else "no"
98
124
 
99
125
 
100
126
  async def _stdin_ask(question: str) -> str:
101
- print(f"❓ {question}")
127
+ print(f"? {question}")
102
128
  try:
103
129
  return await asyncio.to_thread(input, "Jawaban: ")
104
130
  except (EOFError, KeyboardInterrupt):
@@ -116,6 +142,7 @@ async def run_agent(
116
142
  mode_manager: ModeManager | None = None,
117
143
  active_tools: list[str] | None = None,
118
144
  composer: PromptComposer | None = None,
145
+ output_cb: OutputCallback | None = None,
119
146
  ) -> AsyncIterator[AgentEvent]:
120
147
  """Jalankan satu turn agent. Yield AgentEvent secara real-time."""
121
148
  def _system_prompt() -> str:
@@ -154,7 +181,7 @@ async def run_agent(
154
181
  iteration += 1
155
182
  if iteration > config.max_tool_iterations:
156
183
  yield AgentError(
157
- f"⚠️ Mencapai batas {config.max_tool_iterations} iterasi tool. "
184
+ f"Mencapai batas {config.max_tool_iterations} iterasi tool. "
158
185
  "Berhenti agar tidak infinite loop — coba pecah tugas jadi langkah kecil."
159
186
  )
160
187
  return
@@ -168,11 +195,12 @@ async def run_agent(
168
195
  yield AgentText(event.content)
169
196
  elif isinstance(event, StreamDone):
170
197
  done = event
198
+ yield AgentUsage(done.prompt_tokens, done.completion_tokens)
171
199
  if done is None:
172
200
  # Provider aneh: stream tutup tanpa StreamDone — jangan
173
201
  # biarkan assert mentah, kasih pesan yang bisa dibaca user.
174
202
  yield AgentError(
175
- "⚠️ Provider menutup stream tanpa hasil lengkap. "
203
+ "Provider menutup stream tanpa hasil lengkap. "
176
204
  "Coba ulangi; kalau berulang, cek status provider / model.")
177
205
  return
178
206
  except LLMError as e:
@@ -211,6 +239,36 @@ async def run_agent(
211
239
  if choice == "all":
212
240
  checker.approve_all_for_session(call.name)
213
241
  choice = "yes"
242
+ # #4: user edit pesan commit → pakai pesan baru, lanjut yes.
243
+ if choice.startswith("edit:"):
244
+ new_msg = choice[len("edit:"):].strip()
245
+ if not new_msg:
246
+ choice = "no" # edit dibatalkan = tolak
247
+ elif "message" in call.arguments:
248
+ call.arguments["message"] = new_msg
249
+ choice = "yes"
250
+ else:
251
+ choice = "yes"
252
+ # #4: user pilih buat branch baru (push protected) → buat
253
+ # branch, arahkan push ke sana, lanjut yes.
254
+ if choice == "branch":
255
+ new_branch = (await ask_cb(
256
+ "Nama branch baru buat push (kosongkan = batal)?"
257
+ ) or "").strip()
258
+ if not new_branch:
259
+ choice = "no"
260
+ else:
261
+ made = await asyncio.to_thread(
262
+ execute_tool, "git_checkout",
263
+ {"workdir": call.arguments.get("workdir", "."),
264
+ "branch": new_branch, "create": True})
265
+ if not made.get("success"):
266
+ context.add_tool_result(call.id, call.name, made)
267
+ yield AgentToolDone(call.id, call.name, False, made)
268
+ continue
269
+ call.arguments["branch"] = new_branch
270
+ call.arguments.pop("allow_protected", None)
271
+ choice = "yes"
214
272
  if choice != "yes":
215
273
  cancel_result = fail(
216
274
  f"Dibatalkan user — jangan coba {call.name} yang sama lagi, "
@@ -222,7 +280,13 @@ async def run_agent(
222
280
  # to_thread: tool sync jalan di thread, event loop tetap hidup.
223
281
  # Tanpa ini, tool research (yang emit event via call_from_thread)
224
282
  # deadlock — loop diblok nunggu thread, thread nunggu loop.
225
- result = await asyncio.to_thread(execute_tool, call.name, call.arguments)
283
+ # output_cb (live stream) jalan dari thread itu juga.
284
+ def _live(line: str, _cid: str = call.id) -> None:
285
+ assert output_cb is not None
286
+ output_cb(_cid, line)
287
+ result = await asyncio.to_thread(
288
+ execute_tool, call.name, call.arguments,
289
+ _live if output_cb is not None else None)
226
290
  context.add_tool_result(call.id, call.name, result)
227
291
  yield AgentToolDone(call.id, call.name, result["success"], result)
228
292
 
@@ -270,9 +334,10 @@ if __name__ == "__main__":
270
334
  events = await _drain(run_agent("ls", ctx, cfg, llm_client=fake,
271
335
  confirm=_no_confirm))
272
336
  kinds = [type(e).__name__ for e in events]
273
- assert kinds == ["AgentText", "AgentText", "AgentToolStart", "AgentToolDone",
274
- "AgentText", "AgentDone"], kinds
275
- assert events[3].success is True
337
+ assert kinds == ["AgentText", "AgentText", "AgentUsage", "AgentToolStart",
338
+ "AgentToolDone", "AgentText", "AgentUsage",
339
+ "AgentDone"], kinds
340
+ assert events[4].success is True
276
341
  tool_msgs = [m for m in ctx.get_messages() if m["role"] == "tool"]
277
342
  assert len(tool_msgs) == 1 and tool_msgs[0]["tool_call_id"] == "c1"
278
343
  # assistant message membawa tool_calls format OpenAI
@@ -289,7 +354,7 @@ if __name__ == "__main__":
289
354
  ])
290
355
  ctx = ConversationContext()
291
356
  events = await _drain(run_agent("tulis", ctx, cfg, llm_client=fake, confirm=_deny))
292
- assert events[0].name == "write_file" and events[1].success is False
357
+ assert events[1].name == "write_file" and events[2].success is False
293
358
  assert "Dibatalkan user" in ctx.get_messages()[-2]["content"]
294
359
 
295
360
  # 4. "all" = sesi ini auto seterusnya
@@ -315,7 +380,7 @@ if __name__ == "__main__":
315
380
  ])
316
381
  ctx = ConversationContext()
317
382
  events = await _drain(run_agent("x", ctx, cfg, llm_client=fake, confirm=_no_confirm))
318
- assert events[1].success is False
383
+ assert events[2].success is False
319
384
  assert "tidak tersedia" in ctx.get_messages()[-2]["content"]
320
385
  assert isinstance(events[-1], AgentDone)
321
386
 
@@ -339,7 +404,7 @@ if __name__ == "__main__":
339
404
  ctx = ConversationContext()
340
405
  events = await _drain(run_agent("tanya", ctx, cfg, llm_client=fake,
341
406
  confirm=_no_confirm, ask_user=_answer))
342
- assert events[1].success is True
407
+ assert events[2].success is True
343
408
  assert "budi" in ctx.get_messages()[-2]["content"]
344
409
 
345
410
  # 8. Slash command dicegat — LLM tidak dipanggil, history bersih
@@ -402,7 +467,55 @@ if __name__ == "__main__":
402
467
  assert ctx.get_messages()[0]["content"].startswith("SOULKU"), \
403
468
  ctx.get_messages()[0]["content"][:100]
404
469
 
405
- print("✅ agent_loop self-test OK (10 skenario)")
470
+ # 11. #4 edit pesan commit: confirm "edit:X" → commit pakai X
471
+ import subprocess as _sp
472
+ import tempfile as _tf
473
+ from pathlib import Path as _Path
474
+ tmp = _Path(_tf.mkdtemp(prefix="multacd-commit-"))
475
+ _sp.run(["git", "-C", str(tmp), "init", "-b", "main"],
476
+ capture_output=True, timeout=30, check=True)
477
+ _sp.run(["git", "-C", str(tmp), "config", "user.email", "t@t"],
478
+ capture_output=True, timeout=30, check=True)
479
+ _sp.run(["git", "-C", str(tmp), "config", "user.name", "t"],
480
+ capture_output=True, timeout=30, check=True)
481
+ (tmp / "a.txt").write_text("x")
482
+ _sp.run(["git", "-C", str(tmp), "add", "."],
483
+ capture_output=True, timeout=30, check=True)
484
+
485
+ async def _edit(name, params):
486
+ assert name == "git_commit", name
487
+ return "edit:pesan edit e2e"
488
+ fake5 = FakeLLM([StreamDone("", [_TCR(
489
+ "c1", "git_commit",
490
+ {"workdir": str(tmp), "message": "pesan awal"})]),
491
+ StreamDone("done", [])])
492
+ ctx = ConversationContext()
493
+ events = await _drain(run_agent("commit", ctx, cfg, llm_client=fake5,
494
+ confirm=_edit))
495
+ assert any(isinstance(e, AgentToolDone) and e.success for e in events)
496
+ log = _sp.run(["git", "-C", str(tmp), "log", "--format=%s"],
497
+ capture_output=True, text=True, timeout=30)
498
+ assert "pesan edit e2e" in log.stdout, log.stdout
499
+
500
+ # 12. #4 branch baru: push main ditolak → "branch" → buat + push ulang
501
+ async def _branch(name, params):
502
+ assert name == "git_push", name
503
+ return "branch"
504
+ fake6 = FakeLLM([StreamDone("", [_TCR(
505
+ "c1", "git_push", {"workdir": str(tmp)})]),
506
+ StreamDone("done", [])])
507
+ ctx = ConversationContext()
508
+ events = await _drain(run_agent(
509
+ "push", ctx, cfg, llm_client=fake6, confirm=_branch,
510
+ ask_user=lambda q: _asyncio.sleep(0, result="fitur-e2e")))
511
+ br = _sp.run(["git", "-C", str(tmp), "branch", "--list"],
512
+ capture_output=True, text=True, timeout=30)
513
+ assert "fitur-e2e" in br.stdout, br.stdout
514
+ push_done = [e for e in events if isinstance(e, AgentToolDone)]
515
+ assert push_done and "fitur-e2e" in str(
516
+ ctx.get_messages()), "push harus diarahkan ke branch baru"
517
+
518
+ print("✅ agent_loop self-test OK (12 skenario)")
406
519
 
407
520
  async def _no_confirm(name, params):
408
521
  raise AssertionError(f"tidak boleh minta konfirmasi untuk {name}")
@@ -0,0 +1,74 @@
1
+ """Clipboard helper — salin teks ke clipboard sistem (buat /copy).
2
+
3
+ Rantai provider (yang pertama berhasil menang):
4
+ Termux (termux-clipboard-set) → wl-copy → xclip → xsel →
5
+ pbcopy (macOS) → clip/powershell (Windows).
6
+
7
+ Tak ada yang bisa (server headless / Termux:API belum install) →
8
+ fallback jujur: tulis ke ~/.multacd/clipboard.txt + kasih tau path-nya.
9
+ Tak pernah raise — return pesan buat ditampilkan ke user.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+
20
+ def _home() -> Path:
21
+ return Path(os.environ.get("MULTACD_HOME", str(Path.home())))
22
+
23
+
24
+ def _run(cmd: list[str], text: str) -> bool:
25
+ try:
26
+ proc = subprocess.run(cmd, input=text, capture_output=True,
27
+ text=True, timeout=10)
28
+ except (OSError, subprocess.SubprocessError):
29
+ return False
30
+ return proc.returncode == 0
31
+
32
+
33
+ def copy_text(text: str) -> str:
34
+ """Salin `text`. Return pesan hasil (Indonesia, siap tampil di chat)."""
35
+ if not text.strip():
36
+ return "(tidak ada teks buat disalin)"
37
+ data = text if len(text) <= 200_000 else text[:200_000] + "\n…(dipotong)"
38
+ providers: list[tuple[str, list[str]]] = []
39
+ if "TERMUX_VERSION" in os.environ:
40
+ providers.append(("termux", ["termux-clipboard-set"]))
41
+ providers += [
42
+ ("wl-copy", ["wl-copy"]),
43
+ ("xclip", ["xclip", "-selection", "clipboard"]),
44
+ ("xsel", ["xsel", "--clipboard", "--input"]),
45
+ ("pbcopy", ["pbcopy"]),
46
+ ]
47
+ if os.name == "nt":
48
+ providers.append(("Windows", ["clip"]))
49
+ for label, cmd in providers:
50
+ if shutil.which(cmd[0]) and _run(cmd, data):
51
+ return f"tersalin ke clipboard ({label})."
52
+ # Fallback: file (buka + salin manual dari sana).
53
+ try:
54
+ p = _home() / ".multacd" / "clipboard.txt"
55
+ p.parent.mkdir(parents=True, exist_ok=True)
56
+ p.write_text(data, encoding="utf-8")
57
+ return (f"clipboard sistem tak terjangkau — teks disimpan ke {p} "
58
+ "(buka file itu lalu salin manual).")
59
+ except OSError as e:
60
+ return f"gagal salin ({e})."
61
+
62
+
63
+ if __name__ == "__main__":
64
+ import tempfile
65
+
66
+ tmp = tempfile.mkdtemp(prefix="multacd-clip-")
67
+ os.environ["MULTACD_HOME"] = tmp
68
+ # Isolasikan PATH (tanpa provider clipboard) → jatuh ke file fallback.
69
+ os.environ["PATH"] = tmp
70
+ msg = copy_text("halo clipboard")
71
+ assert "clipboard.txt" in msg, msg
72
+ assert Path(tmp, ".multacd", "clipboard.txt").read_text() == "halo clipboard"
73
+ assert "tidak ada teks" in copy_text(" ")
74
+ print("✅ clipboard self-test OK (fallback file)")
@@ -63,9 +63,8 @@ ask_before_bash: true # bash → konfirmasi dulu
63
63
  ask_before_web: true # web_fetch → konfirmasi dulu
64
64
 
65
65
  # ── Display Settings ─────────────────────────────────
66
- theme: "dark" # dark | light | catppuccin-mocha | catppuccin-latte
67
- # | catppuccin-frappe | catppuccin-macchiato
68
- # (dark/light = alias mocha/latte)
66
+ theme: "dark" # dark | light (= multacd-dark/light)
67
+ # + multacd-min (Termux hemat) | catppuccin-* (kompat v1)
69
68
  show_tool_calls: true # tampilkan nama tool yang dijalankan
70
69
  show_thinking: false # tampilkan reasoning LLM (verbose mode)
71
70
  icon_style: "auto" # auto | nerdfonts | unicode | ascii
@@ -161,7 +160,8 @@ class Config(BaseModel):
161
160
  ask_before_web: bool = True
162
161
 
163
162
  # ── Display settings ──
164
- theme: Literal["dark", "light", "catppuccin-mocha", "catppuccin-latte",
163
+ theme: Literal["dark", "light", "multacd-dark", "multacd-light",
164
+ "multacd-min", "catppuccin-mocha", "catppuccin-latte",
165
165
  "catppuccin-frappe", "catppuccin-macchiato"] = "dark"
166
166
  show_tool_calls: bool = True
167
167
  show_thinking: bool = False
@@ -235,7 +235,7 @@ def resolve_config_path(explicit: Path | str | None = None) -> Path:
235
235
 
236
236
  def _setup_message(path: Path) -> str:
237
237
  return (
238
- "⚡ multacd — config belum ditemukan.\n"
238
+ "multacd — config belum ditemukan.\n"
239
239
  "\n"
240
240
  f"File yang dicari: {path}\n"
241
241
  "\n"
@@ -259,7 +259,7 @@ def load_config(explicit_path: Path | str | None = None) -> Config:
259
259
  try:
260
260
  path.parent.mkdir(parents=True, exist_ok=True)
261
261
  except OSError as e:
262
- print(f"❌ Gagal membuat folder config {path.parent}: {e}", file=sys.stderr)
262
+ print(f"Gagal membuat folder config {path.parent}: {e}", file=sys.stderr)
263
263
  raise SystemExit(1) from e
264
264
 
265
265
  if not path.is_file():
@@ -269,15 +269,15 @@ def load_config(explicit_path: Path | str | None = None) -> Config:
269
269
  try:
270
270
  raw = yaml.safe_load(path.read_text(encoding="utf-8"))
271
271
  except yaml.YAMLError as e:
272
- print(f"❌ config.yaml bukan YAML valid: {path}\n Detail: {e}", file=sys.stderr)
272
+ print(f"config.yaml bukan YAML valid: {path}\n Detail: {e}", file=sys.stderr)
273
273
  raise SystemExit(1) from e
274
274
  except OSError as e:
275
- print(f"❌ Gagal membaca config {path}: {e}", file=sys.stderr)
275
+ print(f"Gagal membaca config {path}: {e}", file=sys.stderr)
276
276
  raise SystemExit(1) from e
277
277
 
278
278
  if not isinstance(raw, dict) or not raw:
279
279
  print(
280
- f"❌ config.yaml kosong atau tidak berbentuk key: value: {path}\n"
280
+ f"config.yaml kosong atau tidak berbentuk key: value: {path}\n"
281
281
  " Isi dengan contoh config (jalankan tanpa config untuk melihatnya).",
282
282
  file=sys.stderr,
283
283
  )
@@ -286,7 +286,7 @@ def load_config(explicit_path: Path | str | None = None) -> Config:
286
286
  try:
287
287
  cfg = Config(**raw)
288
288
  except ValidationError as e:
289
- print(f"❌ config.yaml tidak valid: {path}", file=sys.stderr)
289
+ print(f"config.yaml tidak valid: {path}", file=sys.stderr)
290
290
  for err in e.errors():
291
291
  field = ".".join(str(p) for p in err["loc"])
292
292
  print(f" • {field}: {err['msg']}", file=sys.stderr)
@@ -294,10 +294,10 @@ def load_config(explicit_path: Path | str | None = None) -> Config:
294
294
  raise SystemExit(1) from e
295
295
 
296
296
  if not cfg.model.strip():
297
- print("❌ Field `model` wajib diisi (contoh: anthropic/claude-sonnet-4-6).", file=sys.stderr)
297
+ print("Field `model` wajib diisi (contoh: anthropic/claude-sonnet-4-6).", file=sys.stderr)
298
298
  raise SystemExit(1)
299
299
  if not cfg.api_key.strip():
300
- print("❌ Field `api_key` wajib diisi (BYOK — pakai key provider kamu).", file=sys.stderr)
300
+ print("Field `api_key` wajib diisi (BYOK — pakai key provider kamu).", file=sys.stderr)
301
301
  raise SystemExit(1)
302
302
 
303
303
  return cfg