bonecode 1.1.0 → 1.2.1
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/README.md +145 -9
- package/bin/bonecode +47 -42
- package/compat/opencode_adapter.ts +188 -17
- package/dist/bone/output/agent/src/algorithms.d.ts +1 -0
- package/dist/bone/output/agent/src/algorithms.js +3 -0
- package/dist/bone/output/agent/src/algorithms.js.map +1 -0
- package/dist/bone/output/agent/src/audit.d.ts +3 -0
- package/dist/bone/output/agent/src/audit.js +40 -0
- package/dist/bone/output/agent/src/audit.js.map +1 -0
- package/dist/bone/output/agent/src/auth.d.ts +8 -0
- package/dist/bone/output/agent/src/auth.js +56 -0
- package/dist/bone/output/agent/src/auth.js.map +1 -0
- package/dist/bone/output/agent/src/db.d.ts +6 -0
- package/dist/bone/output/agent/src/db.js +63 -0
- package/dist/bone/output/agent/src/db.js.map +1 -0
- package/dist/bone/output/agent/src/events.d.ts +25 -0
- package/dist/bone/output/agent/src/events.js +184 -0
- package/dist/bone/output/agent/src/events.js.map +1 -0
- package/dist/bone/output/agent/src/logger.d.ts +28 -0
- package/dist/bone/output/agent/src/logger.js +45 -0
- package/dist/bone/output/agent/src/logger.js.map +1 -0
- package/dist/bone/output/agent/src/metrics.d.ts +5 -0
- package/dist/bone/output/agent/src/metrics.js +60 -0
- package/dist/bone/output/agent/src/metrics.js.map +1 -0
- package/dist/bone/output/agent/src/routes/agent_instance.d.ts +1 -0
- package/dist/bone/output/agent/src/routes/agent_instance.js +253 -0
- package/dist/bone/output/agent/src/routes/agent_instance.js.map +1 -0
- package/dist/bone/output/agent/src/routes/build_step.d.ts +1 -0
- package/dist/bone/output/agent/src/routes/build_step.js +133 -0
- package/dist/bone/output/agent/src/routes/build_step.js.map +1 -0
- package/dist/bone/output/agent/src/routes/plan.d.ts +1 -0
- package/dist/bone/output/agent/src/routes/plan.js +119 -0
- package/dist/bone/output/agent/src/routes/plan.js.map +1 -0
- package/dist/bone/output/agent/src/routes/task.d.ts +1 -0
- package/dist/bone/output/agent/src/routes/task.js +133 -0
- package/dist/bone/output/agent/src/routes/task.js.map +1 -0
- package/dist/bone/output/agent/src/routes/tool_call.d.ts +1 -0
- package/dist/bone/output/agent/src/routes/tool_call.js +190 -0
- package/dist/bone/output/agent/src/routes/tool_call.js.map +1 -0
- package/dist/bone/output/agent/src/state_machines/agent_instance.d.ts +9 -0
- package/dist/bone/output/agent/src/state_machines/agent_instance.js +22 -0
- package/dist/bone/output/agent/src/state_machines/agent_instance.js.map +1 -0
- package/dist/bone/output/agent/src/state_machines/build_step.d.ts +9 -0
- package/dist/bone/output/agent/src/state_machines/build_step.js +20 -0
- package/dist/bone/output/agent/src/state_machines/build_step.js.map +1 -0
- package/dist/bone/output/agent/src/state_machines/plan.d.ts +9 -0
- package/dist/bone/output/agent/src/state_machines/plan.js +20 -0
- package/dist/bone/output/agent/src/state_machines/plan.js.map +1 -0
- package/dist/bone/output/agent/src/state_machines/task.d.ts +9 -0
- package/dist/bone/output/agent/src/state_machines/task.js +20 -0
- package/dist/bone/output/agent/src/state_machines/task.js.map +1 -0
- package/dist/bone/output/agent/src/state_machines/tool_call.d.ts +9 -0
- package/dist/bone/output/agent/src/state_machines/tool_call.js +20 -0
- package/dist/bone/output/agent/src/state_machines/tool_call.js.map +1 -0
- package/dist/bone/output/rag/src/algorithms.d.ts +1 -0
- package/dist/bone/output/rag/src/algorithms.js +3 -0
- package/dist/bone/output/rag/src/algorithms.js.map +1 -0
- package/dist/bone/output/rag/src/auth.d.ts +8 -0
- package/dist/bone/output/rag/src/auth.js +56 -0
- package/dist/bone/output/rag/src/auth.js.map +1 -0
- package/dist/bone/output/rag/src/db.d.ts +6 -0
- package/dist/bone/output/rag/src/db.js +63 -0
- package/dist/bone/output/rag/src/db.js.map +1 -0
- package/dist/bone/output/rag/src/events.d.ts +25 -0
- package/dist/bone/output/rag/src/events.js +184 -0
- package/dist/bone/output/rag/src/events.js.map +1 -0
- package/dist/bone/output/rag/src/extensions.d.ts +83 -0
- package/dist/bone/output/rag/src/extensions.js +329 -0
- package/dist/bone/output/rag/src/extensions.js.map +1 -0
- package/dist/bone/output/rag/src/flows.d.ts +24 -0
- package/dist/bone/output/rag/src/flows.js +236 -0
- package/dist/bone/output/rag/src/flows.js.map +1 -0
- package/dist/bone/output/rag/src/logger.d.ts +28 -0
- package/dist/bone/output/rag/src/logger.js +45 -0
- package/dist/bone/output/rag/src/logger.js.map +1 -0
- package/dist/bone/output/rag/src/metrics.d.ts +5 -0
- package/dist/bone/output/rag/src/metrics.js +60 -0
- package/dist/bone/output/rag/src/metrics.js.map +1 -0
- package/dist/bone/output/rag/src/routes/code_chunk.d.ts +1 -0
- package/dist/bone/output/rag/src/routes/code_chunk.js +100 -0
- package/dist/bone/output/rag/src/routes/code_chunk.js.map +1 -0
- package/dist/bone/output/rag/src/routes/code_file.d.ts +1 -0
- package/dist/bone/output/rag/src/routes/code_file.js +127 -0
- package/dist/bone/output/rag/src/routes/code_file.js.map +1 -0
- package/dist/bone/output/rag/src/routes/indexing_job.d.ts +1 -0
- package/dist/bone/output/rag/src/routes/indexing_job.js +113 -0
- package/dist/bone/output/rag/src/routes/indexing_job.js.map +1 -0
- package/dist/bone/output/rag/src/routes/knowledge_base.d.ts +1 -0
- package/dist/bone/output/rag/src/routes/knowledge_base.js +242 -0
- package/dist/bone/output/rag/src/routes/knowledge_base.js.map +1 -0
- package/dist/bone/output/rag/src/routes/memory_entry.d.ts +1 -0
- package/dist/bone/output/rag/src/routes/memory_entry.js +113 -0
- package/dist/bone/output/rag/src/routes/memory_entry.js.map +1 -0
- package/dist/bone/output/rag/src/state_machines/code_file.d.ts +9 -0
- package/dist/bone/output/rag/src/state_machines/code_file.js +21 -0
- package/dist/bone/output/rag/src/state_machines/code_file.js.map +1 -0
- package/dist/bone/output/rag/src/state_machines/indexing_job.d.ts +9 -0
- package/dist/bone/output/rag/src/state_machines/indexing_job.js +20 -0
- package/dist/bone/output/rag/src/state_machines/indexing_job.js.map +1 -0
- package/dist/bone/output/rag/src/state_machines/knowledge_base.d.ts +9 -0
- package/dist/bone/output/rag/src/state_machines/knowledge_base.js +21 -0
- package/dist/bone/output/rag/src/state_machines/knowledge_base.js.map +1 -0
- package/dist/bone/output/rag/src/state_machines/memory_entry.d.ts +9 -0
- package/dist/bone/output/rag/src/state_machines/memory_entry.js +18 -0
- package/dist/bone/output/rag/src/state_machines/memory_entry.js.map +1 -0
- package/dist/bone/output/session/src/algorithms.d.ts +1 -0
- package/dist/bone/output/session/src/algorithms.js +3 -0
- package/dist/bone/output/session/src/algorithms.js.map +1 -0
- package/dist/bone/output/session/src/audit.d.ts +3 -0
- package/dist/bone/output/session/src/audit.js +40 -0
- package/dist/bone/output/session/src/audit.js.map +1 -0
- package/dist/bone/output/session/src/auth.d.ts +8 -0
- package/dist/bone/output/session/src/auth.js +56 -0
- package/dist/bone/output/session/src/auth.js.map +1 -0
- package/dist/bone/output/session/src/db.d.ts +6 -0
- package/dist/bone/output/session/src/db.js +63 -0
- package/dist/bone/output/session/src/db.js.map +1 -0
- package/dist/bone/output/session/src/events.d.ts +26 -0
- package/dist/bone/output/session/src/events.js +212 -0
- package/dist/bone/output/session/src/events.js.map +1 -0
- package/dist/bone/output/session/src/extensions.d.ts +41 -0
- package/dist/bone/output/session/src/extensions.js +217 -0
- package/dist/bone/output/session/src/extensions.js.map +1 -0
- package/dist/bone/output/session/src/logger.d.ts +28 -0
- package/dist/bone/output/session/src/logger.js +44 -0
- package/dist/bone/output/session/src/logger.js.map +1 -0
- package/dist/bone/output/session/src/metrics.d.ts +5 -0
- package/dist/bone/output/session/src/metrics.js +60 -0
- package/dist/bone/output/session/src/metrics.js.map +1 -0
- package/dist/bone/output/session/src/routes/message.d.ts +1 -0
- package/dist/bone/output/session/src/routes/message.js +120 -0
- package/dist/bone/output/session/src/routes/message.js.map +1 -0
- package/dist/bone/output/session/src/routes/part.d.ts +1 -0
- package/dist/bone/output/session/src/routes/part.js +106 -0
- package/dist/bone/output/session/src/routes/part.js.map +1 -0
- package/dist/bone/output/session/src/routes/permission.d.ts +1 -0
- package/dist/bone/output/session/src/routes/permission.js +106 -0
- package/dist/bone/output/session/src/routes/permission.js.map +1 -0
- package/dist/bone/output/session/src/routes/project.d.ts +1 -0
- package/dist/bone/output/session/src/routes/project.js +106 -0
- package/dist/bone/output/session/src/routes/project.js.map +1 -0
- package/dist/bone/output/session/src/routes/session.d.ts +1 -0
- package/dist/bone/output/session/src/routes/session.js +308 -0
- package/dist/bone/output/session/src/routes/session.js.map +1 -0
- package/dist/bone/output/session/src/state_machines/session.d.ts +9 -0
- package/dist/bone/output/session/src/state_machines/session.js +21 -0
- package/dist/bone/output/session/src/state_machines/session.js.map +1 -0
- package/dist/bone/output/session/src/websocket.d.ts +15 -0
- package/dist/bone/output/session/src/websocket.js +215 -0
- package/dist/bone/output/session/src/websocket.js.map +1 -0
- package/dist/bone/output/workspace/src/algorithms.d.ts +1 -0
- package/dist/bone/output/workspace/src/algorithms.js +3 -0
- package/dist/bone/output/workspace/src/algorithms.js.map +1 -0
- package/dist/bone/output/workspace/src/auth.d.ts +8 -0
- package/dist/bone/output/workspace/src/auth.js +56 -0
- package/dist/bone/output/workspace/src/auth.js.map +1 -0
- package/dist/bone/output/workspace/src/db.d.ts +6 -0
- package/dist/bone/output/workspace/src/db.js +63 -0
- package/dist/bone/output/workspace/src/db.js.map +1 -0
- package/dist/bone/output/workspace/src/events.d.ts +25 -0
- package/dist/bone/output/workspace/src/events.js +184 -0
- package/dist/bone/output/workspace/src/events.js.map +1 -0
- package/dist/bone/output/workspace/src/logger.d.ts +28 -0
- package/dist/bone/output/workspace/src/logger.js +45 -0
- package/dist/bone/output/workspace/src/logger.js.map +1 -0
- package/dist/bone/output/workspace/src/metrics.d.ts +5 -0
- package/dist/bone/output/workspace/src/metrics.js +60 -0
- package/dist/bone/output/workspace/src/metrics.js.map +1 -0
- package/dist/bone/output/workspace/src/routes/codebase.d.ts +1 -0
- package/dist/bone/output/workspace/src/routes/codebase.js +113 -0
- package/dist/bone/output/workspace/src/routes/codebase.js.map +1 -0
- package/dist/bone/output/workspace/src/routes/snapshot.d.ts +1 -0
- package/dist/bone/output/workspace/src/routes/snapshot.js +151 -0
- package/dist/bone/output/workspace/src/routes/snapshot.js.map +1 -0
- package/dist/bone/output/workspace/src/routes/workspace.d.ts +1 -0
- package/dist/bone/output/workspace/src/routes/workspace.js +209 -0
- package/dist/bone/output/workspace/src/routes/workspace.js.map +1 -0
- package/dist/bone/output/workspace/src/state_machines/codebase.d.ts +9 -0
- package/dist/bone/output/workspace/src/state_machines/codebase.js +19 -0
- package/dist/bone/output/workspace/src/state_machines/codebase.js.map +1 -0
- package/dist/bone/output/workspace/src/state_machines/snapshot.d.ts +9 -0
- package/dist/bone/output/workspace/src/state_machines/snapshot.js +18 -0
- package/dist/bone/output/workspace/src/state_machines/snapshot.js.map +1 -0
- package/dist/bone/output/workspace/src/state_machines/workspace.d.ts +9 -0
- package/dist/bone/output/workspace/src/state_machines/workspace.js +19 -0
- package/dist/bone/output/workspace/src/state_machines/workspace.js.map +1 -0
- package/dist/compat/opencode_adapter.d.ts +25 -0
- package/dist/compat/opencode_adapter.js +599 -0
- package/dist/compat/opencode_adapter.js.map +1 -0
- package/dist/extensions/chunker.d.ts +24 -0
- package/dist/extensions/chunker.js +360 -0
- package/dist/extensions/chunker.js.map +1 -0
- package/dist/extensions/embedding_provider.d.ts +18 -0
- package/dist/extensions/embedding_provider.js +150 -0
- package/dist/extensions/embedding_provider.js.map +1 -0
- package/dist/extensions/llm_provider.d.ts +33 -0
- package/dist/extensions/llm_provider.js +338 -0
- package/dist/extensions/llm_provider.js.map +1 -0
- package/dist/extensions/mcp_bridge.d.ts +44 -0
- package/dist/extensions/mcp_bridge.js +151 -0
- package/dist/extensions/mcp_bridge.js.map +1 -0
- package/dist/extensions/rag_search.d.ts +38 -0
- package/dist/extensions/rag_search.js +242 -0
- package/dist/extensions/rag_search.js.map +1 -0
- package/dist/extensions/snapshot.d.ts +14 -0
- package/dist/extensions/snapshot.js +158 -0
- package/dist/extensions/snapshot.js.map +1 -0
- package/dist/extensions/tool_executor.d.ts +28 -0
- package/dist/extensions/tool_executor.js +268 -0
- package/dist/extensions/tool_executor.js.map +1 -0
- package/dist/src/cli.d.ts +15 -0
- package/dist/src/cli.js +687 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +44 -0
- package/dist/src/config.js +165 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/context_builder.d.ts +51 -0
- package/dist/src/context_builder.js +558 -0
- package/dist/src/context_builder.js.map +1 -0
- package/dist/src/db_adapter.d.ts +24 -0
- package/dist/src/db_adapter.js +341 -0
- package/dist/src/db_adapter.js.map +1 -0
- package/dist/src/engine/session/compaction_logic.d.ts +11 -0
- package/dist/src/engine/session/compaction_logic.js +113 -0
- package/dist/src/engine/session/compaction_logic.js.map +1 -0
- package/dist/src/engine/session/instruction_loader.d.ts +5 -0
- package/dist/src/engine/session/instruction_loader.js +78 -0
- package/dist/src/engine/session/instruction_loader.js.map +1 -0
- package/dist/src/engine/session/overflow_check.d.ts +14 -0
- package/dist/src/engine/session/overflow_check.js +45 -0
- package/dist/src/engine/session/overflow_check.js.map +1 -0
- package/dist/src/engine/session/prompt.d.ts +45 -0
- package/dist/src/engine/session/prompt.js +584 -0
- package/dist/src/engine/session/prompt.js.map +1 -0
- package/dist/src/engine/session/provider_transform.d.ts +59 -0
- package/dist/src/engine/session/provider_transform.js +193 -0
- package/dist/src/engine/session/provider_transform.js.map +1 -0
- package/dist/src/engine/session/retry_logic.d.ts +12 -0
- package/dist/src/engine/session/retry_logic.js +72 -0
- package/dist/src/engine/session/retry_logic.js.map +1 -0
- package/dist/src/engine/session/system_prompt.d.ts +9 -0
- package/dist/src/engine/session/system_prompt.js +96 -0
- package/dist/src/engine/session/system_prompt.js.map +1 -0
- package/dist/src/engine/session/tool_registry.d.ts +5 -0
- package/dist/src/engine/session/tool_registry.js +117 -0
- package/dist/src/engine/session/tool_registry.js.map +1 -0
- package/dist/src/export.d.ts +13 -0
- package/dist/src/export.js +103 -0
- package/dist/src/export.js.map +1 -0
- package/dist/src/mdns.d.ts +7 -0
- package/dist/src/mdns.js +60 -0
- package/dist/src/mdns.js.map +1 -0
- package/dist/src/rag_worker.d.ts +38 -0
- package/dist/src/rag_worker.js +435 -0
- package/dist/src/rag_worker.js.map +1 -0
- package/dist/src/server.d.ts +11 -0
- package/dist/src/server.js +214 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/stats.d.ts +45 -0
- package/dist/src/stats.js +233 -0
- package/dist/src/stats.js.map +1 -0
- package/dist/src/tui.d.ts +29 -0
- package/dist/src/tui.js +1053 -0
- package/dist/src/tui.js.map +1 -0
- package/package.json +7 -4
- package/src/cli.ts +247 -5
- package/src/export.ts +122 -0
- package/src/mdns.ts +53 -0
- package/src/server.ts +32 -0
- package/src/stats.ts +290 -0
- package/src/tui.ts +749 -248
package/src/tui.ts
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BoneCode TUI —
|
|
1
|
+
/**
|
|
2
|
+
* BoneCode TUI — terminal interface modeled after OpenCode
|
|
3
|
+
*
|
|
4
|
+
* Three core behaviours:
|
|
5
|
+
* 1. Tool activity is displayed as concise status lines
|
|
6
|
+
* (← Edit src/foo.ts, → Read package.json, $ npm test) — never raw code dumps.
|
|
7
|
+
* Assistant text is shown inline; tool calls and tool outputs are summarized.
|
|
8
|
+
*
|
|
9
|
+
* 2. Typing "/" shows an inline command menu above the prompt with
|
|
10
|
+
* arrow-key selection and tab/enter to insert.
|
|
3
11
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* │ [message history — scrolls up] │
|
|
7
|
-
* │ ┃ User message (left border, panel bg) │
|
|
8
|
-
* │ ← tool $ shell → read ✱ glob │
|
|
9
|
-
* │ response text │
|
|
10
|
-
* │ ▣ Build · model · 2.1s │
|
|
11
|
-
* ├─────────────────────────────────────────────────┤
|
|
12
|
-
* │ ┃ [input textarea] │
|
|
13
|
-
* │ ┃ Build · model │
|
|
14
|
-
* │ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │
|
|
15
|
-
* │ ~/project/dir esc interrupt │
|
|
16
|
-
* └─────────────────────────────────────────────────┘
|
|
12
|
+
* 3. Ctrl+C aborts the in-flight LLM stream by aborting the fetch
|
|
13
|
+
* AND notifying the server to cancel the agent loop.
|
|
17
14
|
*/
|
|
18
15
|
|
|
19
16
|
import * as path from "path";
|
|
@@ -24,37 +21,36 @@ import * as http from "http";
|
|
|
24
21
|
// ─── ANSI ─────────────────────────────────────────────────────────────────────
|
|
25
22
|
|
|
26
23
|
const ESC = "\x1b";
|
|
27
|
-
const R
|
|
28
|
-
const BOLD
|
|
29
|
-
const DIM
|
|
30
|
-
const CYAN
|
|
31
|
-
const GREEN= `${ESC}[92m`;
|
|
32
|
-
const YELLOW
|
|
33
|
-
const RED
|
|
34
|
-
const GRAY
|
|
35
|
-
const WHITE= `${ESC}[97m`;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
function cols() { return process.stdout.columns || 80; }
|
|
24
|
+
const R = `${ESC}[0m`;
|
|
25
|
+
const BOLD = `${ESC}[1m`;
|
|
26
|
+
const DIM = `${ESC}[2m`;
|
|
27
|
+
const CYAN = `${ESC}[96m`;
|
|
28
|
+
const GREEN = `${ESC}[92m`;
|
|
29
|
+
const YELLOW= `${ESC}[93m`;
|
|
30
|
+
const RED = `${ESC}[91m`;
|
|
31
|
+
const GRAY = `${ESC}[90m`;
|
|
32
|
+
const WHITE = `${ESC}[97m`;
|
|
33
|
+
const BLUE = `${ESC}[94m`;
|
|
34
|
+
|
|
35
|
+
// Box drawing
|
|
36
|
+
const VERT = "┃";
|
|
37
|
+
const CORN = "╹";
|
|
38
|
+
const SHADE = "▀";
|
|
39
|
+
const BLOCK = "▣";
|
|
40
|
+
const ARROW_R = "→";
|
|
41
|
+
const ARROW_L = "←";
|
|
42
|
+
const DOLLAR = "$";
|
|
43
|
+
const STAR = "✱";
|
|
44
|
+
const PCT = "%";
|
|
45
|
+
const GEAR = "⚙";
|
|
46
|
+
const HASH = "#";
|
|
47
|
+
|
|
48
|
+
function cols(): number { return process.stdout.columns || 80; }
|
|
53
49
|
function out(s: string) { process.stdout.write(s); }
|
|
54
50
|
function nl(s = "") { process.stdout.write(s + "\n"); }
|
|
51
|
+
function clearLine() { out(`\r${ESC}[2K`); }
|
|
55
52
|
|
|
56
53
|
// ─── Logo ─────────────────────────────────────────────────────────────────────
|
|
57
|
-
// "BONECODE" in block characters
|
|
58
54
|
|
|
59
55
|
function printLogo() {
|
|
60
56
|
nl();
|
|
@@ -64,16 +60,42 @@ function printLogo() {
|
|
|
64
60
|
nl();
|
|
65
61
|
}
|
|
66
62
|
|
|
67
|
-
// ───
|
|
63
|
+
// ─── Package version ──────────────────────────────────────────────────────────
|
|
68
64
|
|
|
69
|
-
const PKG_ROOT =
|
|
65
|
+
const PKG_ROOT = (() => {
|
|
66
|
+
const fromSrc = path.resolve(__dirname, "..");
|
|
67
|
+
const fromDist = path.resolve(__dirname, "..", "..");
|
|
68
|
+
if (fs.existsSync(path.join(fromDist, "package.json"))) return fromDist;
|
|
69
|
+
return fromSrc;
|
|
70
|
+
})();
|
|
70
71
|
|
|
71
72
|
function getVersion(): string {
|
|
72
73
|
try { return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8")).version; }
|
|
73
74
|
catch { return "0.0.0"; }
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
// ───
|
|
77
|
+
// ─── Slash command catalog ────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
interface Command {
|
|
80
|
+
name: string;
|
|
81
|
+
description: string;
|
|
82
|
+
args?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const COMMANDS: Command[] = [
|
|
86
|
+
{ name: "/new", description: "Start a new session" },
|
|
87
|
+
{ name: "/session", description: "Show current session ID" },
|
|
88
|
+
{ name: "/sessions", description: "List recent sessions" },
|
|
89
|
+
{ name: "/model", description: "Switch model", args: "<provider/model>" },
|
|
90
|
+
{ name: "/provider", description: "Switch provider", args: "<id>" },
|
|
91
|
+
{ name: "/providers", description: "List all providers" },
|
|
92
|
+
{ name: "/clear", description: "Clear screen" },
|
|
93
|
+
{ name: "/history", description: "Show last 10 prompts" },
|
|
94
|
+
{ name: "/help", description: "Show this help" },
|
|
95
|
+
{ name: "/exit", description: "Exit BoneCode" },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// ─── @file autocomplete helpers ───────────────────────────────────────────────
|
|
77
99
|
|
|
78
100
|
const IGNORED_DIRS = new Set([
|
|
79
101
|
"node_modules", ".git", "dist", "build", ".next", "__pycache__",
|
|
@@ -109,29 +131,19 @@ function listFiles(worktree: string, prefix: string): string[] {
|
|
|
109
131
|
return results.sort();
|
|
110
132
|
}
|
|
111
133
|
|
|
112
|
-
// ─── Completers ───────────────────────────────────────────────────────────────
|
|
113
|
-
|
|
114
|
-
const COMMANDS = [
|
|
115
|
-
"/new", "/session", "/model", "/provider", "/providers",
|
|
116
|
-
"/clear", "/history", "/help", "/exit", "/quit",
|
|
117
|
-
];
|
|
118
|
-
|
|
119
134
|
function buildCompleter(worktree: string) {
|
|
120
135
|
return (line: string): [string[], string] => {
|
|
121
|
-
// /command autocomplete
|
|
122
136
|
if (line.startsWith("/")) {
|
|
123
|
-
|
|
137
|
+
// Tab-complete commands too (in addition to the inline menu)
|
|
138
|
+
const matches = COMMANDS.map(c => c.name).filter(c => c.startsWith(line));
|
|
124
139
|
return [matches, line];
|
|
125
140
|
}
|
|
126
|
-
|
|
127
|
-
// @file autocomplete
|
|
128
141
|
const atIdx = line.lastIndexOf("@");
|
|
129
142
|
if (atIdx !== -1) {
|
|
130
143
|
const prefix = line.slice(atIdx + 1);
|
|
131
144
|
const completions = listFiles(worktree, prefix).map(f => line.slice(0, atIdx + 1) + f);
|
|
132
145
|
return [completions, line];
|
|
133
146
|
}
|
|
134
|
-
|
|
135
147
|
return [[], line];
|
|
136
148
|
};
|
|
137
149
|
}
|
|
@@ -156,6 +168,12 @@ async function waitForServer(port: number, maxMs = 30_000): Promise<boolean> {
|
|
|
156
168
|
|
|
157
169
|
// ─── API ──────────────────────────────────────────────────────────────────────
|
|
158
170
|
|
|
171
|
+
async function apiGet<T>(url: string, token: string): Promise<T> {
|
|
172
|
+
const r = await fetch(url, { headers: { "Authorization": `Bearer ${token}` } });
|
|
173
|
+
if (!r.ok) throw new Error(`API ${r.status}`);
|
|
174
|
+
return r.json() as Promise<T>;
|
|
175
|
+
}
|
|
176
|
+
|
|
159
177
|
async function apiPost(url: string, body: object, token: string): Promise<Response> {
|
|
160
178
|
return fetch(url, {
|
|
161
179
|
method: "POST",
|
|
@@ -164,10 +182,8 @@ async function apiPost(url: string, body: object, token: string): Promise<Respon
|
|
|
164
182
|
});
|
|
165
183
|
}
|
|
166
184
|
|
|
167
|
-
async function
|
|
168
|
-
|
|
169
|
-
if (!r.ok) throw new Error(`API ${r.status}`);
|
|
170
|
-
return r.json() as Promise<T>;
|
|
185
|
+
async function apiDelete(url: string, token: string): Promise<void> {
|
|
186
|
+
await fetch(url, { method: "DELETE", headers: { "Authorization": `Bearer ${token}` } });
|
|
171
187
|
}
|
|
172
188
|
|
|
173
189
|
async function createSession(port: number, token: string, worktree: string, title: string): Promise<string> {
|
|
@@ -178,6 +194,172 @@ async function createSession(port: number, token: string, worktree: string, titl
|
|
|
178
194
|
return sess.id;
|
|
179
195
|
}
|
|
180
196
|
|
|
197
|
+
// ─── Tool display rules — modeled after opencode's tool.ts ────────────────────
|
|
198
|
+
|
|
199
|
+
interface ToolDisplay {
|
|
200
|
+
icon: string;
|
|
201
|
+
color: string;
|
|
202
|
+
title: string;
|
|
203
|
+
detail?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function relPath(p: string | undefined, worktree: string): string {
|
|
207
|
+
if (!p) return "";
|
|
208
|
+
try {
|
|
209
|
+
if (path.isAbsolute(p)) {
|
|
210
|
+
const rel = path.relative(worktree, p);
|
|
211
|
+
if (!rel.startsWith("..")) return rel.replace(/\\/g, "/");
|
|
212
|
+
}
|
|
213
|
+
return p.replace(/\\/g, "/");
|
|
214
|
+
} catch { return p; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function describeTool(toolName: string, input: any, worktree: string): ToolDisplay {
|
|
218
|
+
const n = (toolName || "").toLowerCase();
|
|
219
|
+
const inp = input || {};
|
|
220
|
+
|
|
221
|
+
// Map BoneCode's tool names AND opencode's names
|
|
222
|
+
const isWrite = n === "write" || n === "write_file";
|
|
223
|
+
const isEdit = n === "edit" || n === "edit_file";
|
|
224
|
+
const isRead = n === "read" || n === "read_file";
|
|
225
|
+
const isShell = n === "bash" || n === "shell" || n === "run_command";
|
|
226
|
+
const isGlob = n === "glob";
|
|
227
|
+
const isGrep = n === "grep" || n === "search_files";
|
|
228
|
+
const isList = n === "list_directory" || n === "list" || n === "ls";
|
|
229
|
+
const isWebfetch = n === "webfetch";
|
|
230
|
+
const isWebsearch = n === "websearch";
|
|
231
|
+
const isPatch = n === "apply_patch" || n === "patch";
|
|
232
|
+
const isTodo = n === "todo_write" || n === "todowrite" || n === "todo";
|
|
233
|
+
const isTask = n === "task" || n === "subagent";
|
|
234
|
+
|
|
235
|
+
if (isWrite) {
|
|
236
|
+
return { icon: ARROW_L, color: CYAN, title: "Write", detail: relPath(inp.path || inp.filePath, worktree) };
|
|
237
|
+
}
|
|
238
|
+
if (isEdit) {
|
|
239
|
+
return { icon: ARROW_L, color: CYAN, title: "Edit", detail: relPath(inp.path || inp.filePath, worktree) };
|
|
240
|
+
}
|
|
241
|
+
if (isPatch) {
|
|
242
|
+
return { icon: PCT, color: CYAN, title: "Patch", detail: inp.patch ? `${String(inp.patch).split("\n").length} lines` : "" };
|
|
243
|
+
}
|
|
244
|
+
if (isRead) {
|
|
245
|
+
let detail = relPath(inp.path || inp.filePath, worktree);
|
|
246
|
+
if (inp.start_line || inp.end_line) {
|
|
247
|
+
detail += ` ${GRAY}[${inp.start_line || 1}-${inp.end_line || ""}]${R}`;
|
|
248
|
+
}
|
|
249
|
+
return { icon: ARROW_R, color: GRAY, title: "Read", detail };
|
|
250
|
+
}
|
|
251
|
+
if (isShell) {
|
|
252
|
+
const cmd = String(inp.command || "").trim();
|
|
253
|
+
const short = cmd.length > 60 ? cmd.slice(0, 57) + "..." : cmd;
|
|
254
|
+
return { icon: DOLLAR, color: YELLOW, title: short || "Shell" };
|
|
255
|
+
}
|
|
256
|
+
if (isGlob) {
|
|
257
|
+
return { icon: STAR, color: GRAY, title: "Glob", detail: inp.pattern || "" };
|
|
258
|
+
}
|
|
259
|
+
if (isGrep) {
|
|
260
|
+
const pattern = inp.pattern || "";
|
|
261
|
+
const glob = inp.glob ? ` in ${inp.glob}` : "";
|
|
262
|
+
return { icon: STAR, color: GRAY, title: "Grep", detail: `"${pattern}"${glob}` };
|
|
263
|
+
}
|
|
264
|
+
if (isList) {
|
|
265
|
+
return { icon: ARROW_R, color: GRAY, title: "List", detail: relPath(inp.path, worktree) };
|
|
266
|
+
}
|
|
267
|
+
if (isWebfetch) {
|
|
268
|
+
return { icon: PCT, color: BLUE, title: "WebFetch", detail: inp.url || "" };
|
|
269
|
+
}
|
|
270
|
+
if (isWebsearch) {
|
|
271
|
+
return { icon: PCT, color: BLUE, title: "WebSearch", detail: inp.query ? `"${inp.query}"` : "" };
|
|
272
|
+
}
|
|
273
|
+
if (isTodo) {
|
|
274
|
+
const todos = Array.isArray(inp.todos) ? inp.todos : [];
|
|
275
|
+
const done = todos.filter((t: any) => t.status === "completed").length;
|
|
276
|
+
return { icon: HASH, color: GRAY, title: "Todos", detail: `${done}/${todos.length} done` };
|
|
277
|
+
}
|
|
278
|
+
if (isTask) {
|
|
279
|
+
return { icon: HASH, color: CYAN, title: "Task", detail: inp.description || "" };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Fallback
|
|
283
|
+
return { icon: GEAR, color: GRAY, title: toolName };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function renderToolStart(d: ToolDisplay) {
|
|
287
|
+
const detail = d.detail ? ` ${GRAY}${d.detail}${R}` : "";
|
|
288
|
+
nl(` ${d.color}${d.icon}${R} ${WHITE}${d.title}${R}${detail}`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderToolDone(d: ToolDisplay, ms?: number) {
|
|
292
|
+
const detail = d.detail ? ` ${GRAY}${d.detail}${R}` : "";
|
|
293
|
+
const time = ms !== undefined ? ` ${GRAY}${(ms / 1000).toFixed(1)}s${R}` : "";
|
|
294
|
+
// Replace the "..." with a checkmark (or just write a new line if not interactive)
|
|
295
|
+
nl(` ${GREEN}✓${R} ${GRAY}${d.title}${R}${detail}${time}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderToolError(d: ToolDisplay, err: string) {
|
|
299
|
+
const detail = d.detail ? ` ${GRAY}${d.detail}${R}` : "";
|
|
300
|
+
nl(` ${RED}✗${R} ${GRAY}${d.title}${R}${detail} ${RED}${err}${R}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── User message rendering ───────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function renderUserMessage(text: string) {
|
|
306
|
+
nl();
|
|
307
|
+
for (const line of text.split("\n")) {
|
|
308
|
+
nl(`${GRAY}${VERT}${R} ${WHITE}${line}${R}`);
|
|
309
|
+
}
|
|
310
|
+
nl();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderTurnEnd(model: string, elapsedMs: number, interrupted: boolean) {
|
|
314
|
+
const elapsed = (elapsedMs / 1000).toFixed(1);
|
|
315
|
+
const dur = interrupted ? `${YELLOW}interrupted${R}` : `${GRAY}${elapsed}s${R}`;
|
|
316
|
+
nl();
|
|
317
|
+
nl(` ${CYAN}${BLOCK}${R} ${WHITE}Build${R}${GRAY} · ${model} · ${R}${dur}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Help / providers / commands menu ─────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
function printHelp() {
|
|
323
|
+
nl();
|
|
324
|
+
nl(`${CYAN}${BOLD}BoneCode${R} ${GRAY}commands${R}`);
|
|
325
|
+
nl();
|
|
326
|
+
for (const c of COMMANDS) {
|
|
327
|
+
const args = c.args ? ` ${GRAY}${c.args}${R}` : "";
|
|
328
|
+
nl(` ${CYAN}${c.name.padEnd(12)}${R}${args.padEnd(20)} ${GRAY}${c.description}${R}`);
|
|
329
|
+
}
|
|
330
|
+
nl();
|
|
331
|
+
nl(` ${CYAN}Ctrl+C${R} ${GRAY}Interrupt current request${R}`);
|
|
332
|
+
nl(` ${CYAN}Ctrl+D${R} ${GRAY}Exit BoneCode${R}`);
|
|
333
|
+
nl(` ${CYAN}↑ / ↓${R} ${GRAY}Prompt history${R}`);
|
|
334
|
+
nl(` ${CYAN}@<path>${R} ${GRAY}Attach a file (Tab completes)${R}`);
|
|
335
|
+
nl();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function fetchProviders(port: number, token: string): Promise<any[]> {
|
|
339
|
+
try { return await apiGet<any[]>(`http://localhost:${port}/v2/provider`, token); }
|
|
340
|
+
catch { return []; }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function printProviders(providers: any[], currentProvider: string) {
|
|
344
|
+
const width = cols();
|
|
345
|
+
nl();
|
|
346
|
+
nl(`${CYAN}${BOLD}Providers${R}`);
|
|
347
|
+
nl(`${GRAY}${"─".repeat(Math.min(width, 56))}${R}`);
|
|
348
|
+
for (const p of providers) {
|
|
349
|
+
const active = p.id === currentProvider;
|
|
350
|
+
const dot = active ? `${GREEN}●${R}` : `${GRAY}○${R}`;
|
|
351
|
+
const free = p.free ? ` ${GREEN}free${R}` : "";
|
|
352
|
+
const local = p.id === "local" ? ` ${CYAN}local config${R}` : "";
|
|
353
|
+
nl(` ${dot} ${active ? CYAN + BOLD : WHITE}${p.id}${R}${free}${local} ${GRAY}${p.name}${R}`);
|
|
354
|
+
if (p.freeNote) nl(` ${GRAY}${p.freeNote}${R}`);
|
|
355
|
+
if (p.models?.length) nl(` ${GRAY}${p.models.slice(0, 3).join(" ")}${R}`);
|
|
356
|
+
if (p.keyEnv) nl(` ${GRAY}${p.keyEnv}${R}`);
|
|
357
|
+
}
|
|
358
|
+
nl(`${GRAY}${"─".repeat(Math.min(width, 56))}${R}`);
|
|
359
|
+
nl(` ${GRAY}/provider <id> /model <id>${R}`);
|
|
360
|
+
nl();
|
|
361
|
+
}
|
|
362
|
+
|
|
181
363
|
// ─── Streaming ────────────────────────────────────────────────────────────────
|
|
182
364
|
|
|
183
365
|
interface StreamResult {
|
|
@@ -188,15 +370,155 @@ interface StreamResult {
|
|
|
188
370
|
interrupted: boolean;
|
|
189
371
|
}
|
|
190
372
|
|
|
373
|
+
interface ToolTrack {
|
|
374
|
+
callId: string;
|
|
375
|
+
display: ToolDisplay;
|
|
376
|
+
startedAt: number;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Stateful code-fence collapser.
|
|
381
|
+
*
|
|
382
|
+
* The LLM streams text token-by-token, including ```python\n...\n``` blocks.
|
|
383
|
+
* We don't want to dump that raw to the terminal because:
|
|
384
|
+
* (a) The code is going to be saved via a tool call anyway, so showing it
|
|
385
|
+
* twice (raw stream + saved file path) is redundant.
|
|
386
|
+
* (b) It can be 100s of lines long and overwhelms the TUI.
|
|
387
|
+
*
|
|
388
|
+
* Strategy: buffer everything between ``` and ```. When the closing fence
|
|
389
|
+
* arrives, emit a one-line marker like [code: python, 42 lines]
|
|
390
|
+
* instead of the buffered content.
|
|
391
|
+
*
|
|
392
|
+
* State machine:
|
|
393
|
+
* "text" — pass deltas through verbatim (with partial-fence detection)
|
|
394
|
+
* "fence" — buffer everything; emit marker on close fence
|
|
395
|
+
*/
|
|
396
|
+
function makeCodeFenceCollapser() {
|
|
397
|
+
let mode: "text" | "fence" = "text";
|
|
398
|
+
let lang = "";
|
|
399
|
+
let buffered = ""; // bytes accumulated inside a fence
|
|
400
|
+
let pending = ""; // partial-fence buffer (when we see backticks but don't know yet)
|
|
401
|
+
|
|
402
|
+
const lines = (s: string) => s.split("\n").length;
|
|
403
|
+
|
|
404
|
+
function flushPending(): string {
|
|
405
|
+
const out = pending;
|
|
406
|
+
pending = "";
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
feed(chunk: string): string {
|
|
412
|
+
let result = "";
|
|
413
|
+
let i = 0;
|
|
414
|
+
const buf = pending + chunk;
|
|
415
|
+
pending = "";
|
|
416
|
+
|
|
417
|
+
while (i < buf.length) {
|
|
418
|
+
if (mode === "text") {
|
|
419
|
+
// Look for ``` to enter fence mode
|
|
420
|
+
const fenceIdx = buf.indexOf("```", i);
|
|
421
|
+
if (fenceIdx === -1) {
|
|
422
|
+
// No fence in remaining text — emit it all, but hold the last 2 chars
|
|
423
|
+
// in case they're part of an upcoming fence.
|
|
424
|
+
const safeEnd = Math.max(i, buf.length - 2);
|
|
425
|
+
result += buf.slice(i, safeEnd);
|
|
426
|
+
pending = buf.slice(safeEnd);
|
|
427
|
+
i = buf.length;
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
// Emit text up to the fence
|
|
431
|
+
result += buf.slice(i, fenceIdx);
|
|
432
|
+
// Read the language (everything until newline)
|
|
433
|
+
const nlIdx = buf.indexOf("\n", fenceIdx + 3);
|
|
434
|
+
if (nlIdx === -1) {
|
|
435
|
+
// Don't have the full opening line yet — buffer
|
|
436
|
+
pending = buf.slice(fenceIdx);
|
|
437
|
+
i = buf.length;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
lang = buf.slice(fenceIdx + 3, nlIdx).trim() || "code";
|
|
441
|
+
i = nlIdx + 1;
|
|
442
|
+
mode = "fence";
|
|
443
|
+
buffered = "";
|
|
444
|
+
// Print a leading marker (will be amended when we close)
|
|
445
|
+
result += `${GRAY}┃ code: ${lang}…${R}`;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// mode === "fence" — look for closing ```
|
|
450
|
+
const closeIdx = buf.indexOf("```", i);
|
|
451
|
+
if (closeIdx === -1) {
|
|
452
|
+
buffered += buf.slice(i);
|
|
453
|
+
// Hold last 2 chars in case they're part of an upcoming close fence
|
|
454
|
+
const safeEnd = Math.max(i, buf.length - 2);
|
|
455
|
+
buffered = buffered.slice(0, buffered.length - (buf.length - safeEnd));
|
|
456
|
+
pending = buf.slice(safeEnd);
|
|
457
|
+
i = buf.length;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
// Closing fence found
|
|
461
|
+
buffered += buf.slice(i, closeIdx);
|
|
462
|
+
const lineCount = lines(buffered.replace(/\n+$/, ""));
|
|
463
|
+
// Replace the placeholder we already wrote with the final marker.
|
|
464
|
+
// Carriage return + clear line + reprint marker.
|
|
465
|
+
result += `\r${ESC}[2K ${GRAY}┃ code: ${lang}, ${lineCount} line${lineCount === 1 ? "" : "s"}${R}\n`;
|
|
466
|
+
i = closeIdx + 3;
|
|
467
|
+
// Skip optional newline after closing fence
|
|
468
|
+
if (buf[i] === "\n") i++;
|
|
469
|
+
mode = "text";
|
|
470
|
+
lang = "";
|
|
471
|
+
buffered = "";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return result;
|
|
475
|
+
},
|
|
476
|
+
flush(): string {
|
|
477
|
+
const tail = flushPending();
|
|
478
|
+
if (mode === "fence") {
|
|
479
|
+
// Stream ended mid-fence — close it with an approximate count
|
|
480
|
+
const lineCount = lines(buffered.replace(/\n+$/, ""));
|
|
481
|
+
mode = "text";
|
|
482
|
+
return tail + `\r${ESC}[2K ${GRAY}┃ code: ${lang}, ${lineCount}+ lines${R}\n`;
|
|
483
|
+
}
|
|
484
|
+
return tail;
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
191
489
|
async function streamPrompt(opts: {
|
|
192
490
|
port: number; token: string; sessionId: string;
|
|
193
491
|
model: string; provider: string; message: string;
|
|
194
|
-
|
|
492
|
+
worktree: string;
|
|
493
|
+
abortSignal: AbortSignal;
|
|
195
494
|
}): Promise<StreamResult> {
|
|
196
|
-
const { port, token, sessionId, model, provider, message,
|
|
495
|
+
const { port, token, sessionId, model, provider, message, worktree, abortSignal } = opts;
|
|
197
496
|
const t0 = Date.now();
|
|
198
497
|
let fullText = "";
|
|
199
498
|
let tokens = 0;
|
|
499
|
+
let inAssistantText = false;
|
|
500
|
+
let tools = new Map<string, ToolTrack>();
|
|
501
|
+
|
|
502
|
+
// Code-fence collapser — replaces ```...``` blocks with [code: lang, N lines]
|
|
503
|
+
// markers across delta boundaries.
|
|
504
|
+
const fence = makeCodeFenceCollapser();
|
|
505
|
+
const collapseCodeFences = (chunk: string): string => fence.feed(chunk);
|
|
506
|
+
|
|
507
|
+
// Track which message the deltas belong to so we can detect tool boundaries
|
|
508
|
+
// The compat adapter emits these event types:
|
|
509
|
+
// part.delta — text chunk
|
|
510
|
+
// tool.requested — tool call started
|
|
511
|
+
// message.updated — message saved
|
|
512
|
+
// session.updated — session state changed
|
|
513
|
+
// error — error event
|
|
514
|
+
|
|
515
|
+
const flushTextLine = () => {
|
|
516
|
+
if (inAssistantText) {
|
|
517
|
+
// Make sure assistant text ends with a newline before the next thing
|
|
518
|
+
if (fullText && !fullText.endsWith("\n")) out("\n");
|
|
519
|
+
inAssistantText = false;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
200
522
|
|
|
201
523
|
try {
|
|
202
524
|
const r = await fetch(`http://localhost:${port}/v2/session/${sessionId}/prompt`, {
|
|
@@ -205,148 +527,201 @@ async function streamPrompt(opts: {
|
|
|
205
527
|
body: JSON.stringify({ content: message, modelID: model, providerID: provider }),
|
|
206
528
|
signal: abortSignal,
|
|
207
529
|
});
|
|
208
|
-
if (!r.ok)
|
|
530
|
+
if (!r.ok) {
|
|
531
|
+
return { text: "", tokens: 0, elapsedMs: Date.now() - t0, error: `HTTP ${r.status}: ${await r.text()}`, interrupted: false };
|
|
532
|
+
}
|
|
209
533
|
|
|
210
534
|
const reader = r.body!.getReader();
|
|
211
535
|
const dec = new TextDecoder();
|
|
212
536
|
let buf = "";
|
|
537
|
+
|
|
213
538
|
while (true) {
|
|
214
539
|
const { value, done } = await reader.read();
|
|
215
540
|
if (done) break;
|
|
216
541
|
buf += dec.decode(value, { stream: true });
|
|
217
542
|
const lines = buf.split("\n");
|
|
218
543
|
buf = lines.pop() || "";
|
|
544
|
+
|
|
219
545
|
for (const raw of lines) {
|
|
220
546
|
if (!raw.startsWith("data: ")) continue;
|
|
221
547
|
const json = raw.slice(6).trim();
|
|
222
548
|
if (!json || json === "[DONE]") continue;
|
|
549
|
+
|
|
223
550
|
try {
|
|
224
551
|
const ev = JSON.parse(json);
|
|
552
|
+
|
|
553
|
+
// Text delta — assistant is generating prose
|
|
225
554
|
if (ev.type === "part.delta" && ev.delta?.type === "text") {
|
|
226
|
-
|
|
227
|
-
|
|
555
|
+
const text = ev.delta.text || "";
|
|
556
|
+
if (!text) continue;
|
|
557
|
+
if (!inAssistantText) {
|
|
558
|
+
// Start of assistant text — print the indent prefix once
|
|
559
|
+
out(` `);
|
|
560
|
+
inAssistantText = true;
|
|
561
|
+
}
|
|
562
|
+
// Process the delta through the code-fence collapser so streaming
|
|
563
|
+
// code blocks appear as "[code: lang]" placeholders instead of
|
|
564
|
+
// dumping raw source.
|
|
565
|
+
const piece = collapseCodeFences(text);
|
|
566
|
+
// Print with leading-newline indenting (so each new line gets the 3-space prefix)
|
|
567
|
+
const indented = piece.replace(/\n/g, `\n `);
|
|
568
|
+
out(indented);
|
|
569
|
+
fullText += text;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Tool requested — show concise activity line
|
|
574
|
+
if (ev.type === "tool.requested") {
|
|
575
|
+
flushTextLine();
|
|
576
|
+
const callId = ev.tool_call_id || ev.id || `${ev.tool_name}-${Date.now()}`;
|
|
577
|
+
const display = describeTool(ev.tool_name || "tool", ev.tool_input || {}, worktree);
|
|
578
|
+
tools.set(callId, { callId, display, startedAt: Date.now() });
|
|
579
|
+
renderToolStart(display);
|
|
580
|
+
continue;
|
|
228
581
|
}
|
|
229
|
-
|
|
230
|
-
|
|
582
|
+
|
|
583
|
+
// Tool completed
|
|
584
|
+
if (ev.type === "tool.completed" || ev.type === "tool.success") {
|
|
585
|
+
flushTextLine();
|
|
586
|
+
const callId = ev.tool_call_id || ev.id || "";
|
|
587
|
+
const tracked = tools.get(callId);
|
|
588
|
+
if (tracked) {
|
|
589
|
+
const ms = Date.now() - tracked.startedAt;
|
|
590
|
+
renderToolDone(tracked.display, ms);
|
|
591
|
+
tools.delete(callId);
|
|
592
|
+
}
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Tool failed
|
|
597
|
+
if (ev.type === "tool.failed" || ev.type === "tool.error") {
|
|
598
|
+
flushTextLine();
|
|
599
|
+
const callId = ev.tool_call_id || ev.id || "";
|
|
600
|
+
const tracked = tools.get(callId);
|
|
601
|
+
const err = ev.error || ev.message || "failed";
|
|
602
|
+
if (tracked) {
|
|
603
|
+
renderToolError(tracked.display, err);
|
|
604
|
+
tools.delete(callId);
|
|
605
|
+
} else {
|
|
606
|
+
nl(` ${RED}✗ ${err}${R}`);
|
|
607
|
+
}
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Retry notification
|
|
612
|
+
if (ev.type === "session.retry") {
|
|
613
|
+
flushTextLine();
|
|
614
|
+
nl(` ${YELLOW}⟳ Retry ${ev.attempt || ""}: ${ev.message || ""}${R}`);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Server-side error
|
|
619
|
+
if (ev.type === "error") {
|
|
620
|
+
flushTextLine();
|
|
621
|
+
return {
|
|
622
|
+
text: fullText,
|
|
623
|
+
tokens,
|
|
624
|
+
elapsedMs: Date.now() - t0,
|
|
625
|
+
error: ev.properties?.message || "error",
|
|
626
|
+
interrupted: false,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Compaction
|
|
631
|
+
if (ev.type === "session.compacted") {
|
|
632
|
+
flushTextLine();
|
|
633
|
+
nl(` ${BLUE}⊕ Context compacted${R}`);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
} catch {
|
|
637
|
+
// Ignore malformed events
|
|
638
|
+
}
|
|
231
639
|
}
|
|
232
640
|
}
|
|
233
641
|
|
|
642
|
+
flushTextLine();
|
|
643
|
+
|
|
644
|
+
// Drain any tools that didn't get a completion event
|
|
645
|
+
for (const tracked of tools.values()) {
|
|
646
|
+
const ms = Date.now() - tracked.startedAt;
|
|
647
|
+
renderToolDone(tracked.display, ms);
|
|
648
|
+
}
|
|
649
|
+
tools.clear();
|
|
650
|
+
|
|
651
|
+
// Flush any unclosed code fence
|
|
652
|
+
const tail = fence.flush();
|
|
653
|
+
if (tail) out(tail);
|
|
654
|
+
|
|
655
|
+
// Fetch the final message to get token totals
|
|
234
656
|
try {
|
|
235
657
|
const msgs = await apiGet<any[]>(`http://localhost:${port}/v2/session/${sessionId}/message`, token);
|
|
236
658
|
const last = msgs.filter((m: any) => m.role === "assistant").slice(-1)[0];
|
|
237
659
|
tokens = (last?.tokens?.input || 0) + (last?.tokens?.output || 0);
|
|
660
|
+
// If we didn't get any text deltas but the stored message has text, render it now
|
|
661
|
+
if (!fullText && last?.parts) {
|
|
662
|
+
for (const p of last.parts) {
|
|
663
|
+
if (p.type === "text" && p.text) {
|
|
664
|
+
out(` ${p.text.replace(/\n/g, `\n `)}\n`);
|
|
665
|
+
fullText = p.text;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
238
670
|
} catch {}
|
|
239
671
|
|
|
240
672
|
return { text: fullText, tokens, elapsedMs: Date.now() - t0, interrupted: false };
|
|
241
673
|
} catch (e: any) {
|
|
242
|
-
|
|
674
|
+
flushTextLine();
|
|
675
|
+
if (e.name === "AbortError") {
|
|
676
|
+
return { text: fullText, tokens, elapsedMs: Date.now() - t0, interrupted: true };
|
|
677
|
+
}
|
|
243
678
|
return { text: fullText, tokens, elapsedMs: Date.now() - t0, error: e.message, interrupted: false };
|
|
244
679
|
}
|
|
245
680
|
}
|
|
246
681
|
|
|
247
|
-
// ───
|
|
248
|
-
|
|
249
|
-
// User message: ┃ left border, panel background
|
|
250
|
-
function renderUserMessage(text: string) {
|
|
251
|
-
nl();
|
|
252
|
-
const lines = text.split("\n");
|
|
253
|
-
for (const l of lines) {
|
|
254
|
-
nl(`${GRAY}${VERT}${R} ${WHITE}${l}${R}`);
|
|
255
|
-
}
|
|
256
|
-
nl();
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Tool call line — inline style matching OpenCode
|
|
260
|
-
function renderToolCall(toolName: string, summary: string, done: boolean) {
|
|
261
|
-
const icon = toolIcon(toolName);
|
|
262
|
-
const color = done ? GRAY : WHITE;
|
|
263
|
-
nl(` ${color}${icon} ${summary}${R}`);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function toolIcon(name: string): string {
|
|
267
|
-
const n = name.toLowerCase();
|
|
268
|
-
if (n === "write" || n === "edit" || n === "apply_patch") return ARROW_L;
|
|
269
|
-
if (n === "read") return ARROW_R;
|
|
270
|
-
if (n === "shell" || n === "bash") return DOLLAR;
|
|
271
|
-
if (n === "glob" || n === "grep") return STAR;
|
|
272
|
-
if (n === "webfetch") return PCT;
|
|
273
|
-
return GEAR;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// End-of-turn marker: ▣ Build · model · 2.1s
|
|
277
|
-
function renderTurnEnd(model: string, elapsedMs: number, interrupted: boolean) {
|
|
278
|
-
const elapsed = (elapsedMs / 1000).toFixed(1);
|
|
279
|
-
const dur = interrupted ? `${YELLOW}interrupted${R}` : `${GRAY}${elapsed}s${R}`;
|
|
280
|
-
nl();
|
|
281
|
-
nl(` ${CYAN}${BLOCK}${R} ${WHITE}Build${R}${GRAY} · ${model} · ${R}${dur}`);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Prompt box — matches OpenCode's input area design
|
|
285
|
-
function renderPromptBox(model: string, provider: string, sessionId: string, worktree: string) {
|
|
286
|
-
const width = cols();
|
|
287
|
-
const dir = worktree.replace(/\\/g, "/").replace(/^.*\/([^/]+\/[^/]+)$/, "$1"); // last 2 path segments
|
|
288
|
-
const modelLabel = `${WHITE}Build${R}${GRAY} · ${model}${R}`;
|
|
289
|
-
const shadow = SHADE.repeat(Math.min(width - 2, 60));
|
|
682
|
+
// ─── Slash command menu — inline, above the prompt ────────────────────────────
|
|
290
683
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
nl(`${GRAY}${CORN}${shadow}${R}`);
|
|
297
|
-
nl(`${GRAY}${dir}${R} ${GRAY}esc ${DIM}interrupt${R}`);
|
|
684
|
+
interface MenuState {
|
|
685
|
+
visible: boolean;
|
|
686
|
+
options: Command[];
|
|
687
|
+
selected: number;
|
|
688
|
+
rowsRendered: number;
|
|
298
689
|
}
|
|
299
690
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
nl();
|
|
305
|
-
nl(` ${CYAN}/new${R} New session`);
|
|
306
|
-
nl(` ${CYAN}/session${R} Show session ID`);
|
|
307
|
-
nl(` ${CYAN}/model <id>${R} Switch model`);
|
|
308
|
-
nl(` ${CYAN}/provider <id>${R} Switch provider`);
|
|
309
|
-
nl(` ${CYAN}/providers${R} List all providers`);
|
|
310
|
-
nl(` ${CYAN}/clear${R} Clear screen`);
|
|
311
|
-
nl(` ${CYAN}/history${R} Last 10 prompts`);
|
|
312
|
-
nl(` ${CYAN}/exit${R} Exit`);
|
|
313
|
-
nl();
|
|
314
|
-
nl(` ${CYAN}Ctrl+C${R} Interrupt current request`);
|
|
315
|
-
nl(` ${CYAN}Ctrl+D${R} Exit`);
|
|
316
|
-
nl(` ${CYAN}↑ / ↓${R} Prompt history`);
|
|
317
|
-
nl(` ${CYAN}@<path>${R} Attach file — Tab to autocomplete`);
|
|
318
|
-
nl();
|
|
319
|
-
nl(` ${GRAY}Model shorthand: /model groq/llama-3.3-70b-versatile${R}`);
|
|
320
|
-
nl(` ${GRAY}Local config: /model local${R}`);
|
|
321
|
-
nl();
|
|
691
|
+
function filterCommands(query: string): Command[] {
|
|
692
|
+
if (!query || query === "/") return COMMANDS;
|
|
693
|
+
const q = query.toLowerCase();
|
|
694
|
+
return COMMANDS.filter(c => c.name.toLowerCase().startsWith(q));
|
|
322
695
|
}
|
|
323
696
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
697
|
+
function renderCommandMenu(state: MenuState): number {
|
|
698
|
+
if (!state.visible || state.options.length === 0) return 0;
|
|
699
|
+
const max = Math.min(state.options.length, 8);
|
|
700
|
+
for (let i = 0; i < max; i++) {
|
|
701
|
+
const c = state.options[i]!;
|
|
702
|
+
const selected = i === state.selected;
|
|
703
|
+
const prefix = selected ? `${CYAN}▌${R} ` : ` `;
|
|
704
|
+
const name = selected ? `${CYAN}${BOLD}${c.name}${R}` : `${WHITE}${c.name}${R}`;
|
|
705
|
+
const args = c.args ? ` ${GRAY}${c.args}${R}` : "";
|
|
706
|
+
const desc = `${GRAY}${c.description}${R}`;
|
|
707
|
+
nl(`${prefix}${name}${args} ${desc}`);
|
|
708
|
+
}
|
|
709
|
+
if (state.options.length > max) {
|
|
710
|
+
nl(` ${GRAY}... ${state.options.length - max} more (keep typing)${R}`);
|
|
711
|
+
return max + 1;
|
|
712
|
+
}
|
|
713
|
+
return max;
|
|
327
714
|
}
|
|
328
715
|
|
|
329
|
-
function
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
for (const p of providers) {
|
|
335
|
-
const active = p.id === currentProvider;
|
|
336
|
-
const dot = active ? `${GREEN}●${R}` : `${GRAY}○${R}`;
|
|
337
|
-
const free = p.free ? ` ${GREEN}free${R}` : "";
|
|
338
|
-
const local = p.id === "local" ? ` ${CYAN}local config${R}` : "";
|
|
339
|
-
nl(` ${dot} ${active ? CYAN + BOLD : WHITE}${p.id}${R}${free}${local} ${GRAY}${p.name}${R}`);
|
|
340
|
-
if (p.freeNote) nl(` ${GRAY}${p.freeNote}${R}`);
|
|
341
|
-
if (p.models?.length) nl(` ${GRAY}${p.models.slice(0, 3).join(" ")}${R}`);
|
|
342
|
-
if (p.keyEnv) nl(` ${GRAY}${p.keyEnv}${R}`);
|
|
716
|
+
function clearMenu(rows: number) {
|
|
717
|
+
if (rows <= 0) return;
|
|
718
|
+
// Move up `rows` lines and clear each one
|
|
719
|
+
for (let i = 0; i < rows; i++) {
|
|
720
|
+
out(`${ESC}[1A${ESC}[2K`);
|
|
343
721
|
}
|
|
344
|
-
nl(`${GRAY}${"─".repeat(Math.min(width, 56))}${R}`);
|
|
345
|
-
nl(` ${GRAY}/provider <id> /model <id>${R}`);
|
|
346
|
-
nl();
|
|
347
722
|
}
|
|
348
723
|
|
|
349
|
-
// ─── Main TUI
|
|
724
|
+
// ─── Main TUI loop ────────────────────────────────────────────────────────────
|
|
350
725
|
|
|
351
726
|
export async function runTUI(opts: {
|
|
352
727
|
port: number; token: string; model: string;
|
|
@@ -355,98 +730,217 @@ export async function runTUI(opts: {
|
|
|
355
730
|
let { model, provider } = opts;
|
|
356
731
|
const { port, token, worktree } = opts;
|
|
357
732
|
let sessionId = opts.sessionId || null;
|
|
358
|
-
let turnCount = 0;
|
|
359
733
|
const history: string[] = [];
|
|
360
734
|
let abort: AbortController | null = null;
|
|
361
735
|
let streaming = false;
|
|
362
736
|
|
|
363
|
-
//
|
|
737
|
+
// Initial session
|
|
364
738
|
if (!sessionId) {
|
|
365
739
|
try { sessionId = await createSession(port, token, worktree, "BoneCode Session"); }
|
|
366
|
-
catch (e: any) {
|
|
740
|
+
catch (e: any) {
|
|
741
|
+
process.stderr.write(`${RED}Failed to create session: ${e.message}${R}\n`);
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
367
744
|
}
|
|
368
745
|
|
|
369
746
|
// Header
|
|
370
747
|
printLogo();
|
|
371
|
-
|
|
372
|
-
nl(` ${GRAY}
|
|
373
|
-
nl(` ${GRAY}session ${sessionId!.slice(0, 8)}${R}`);
|
|
748
|
+
nl(` ${GRAY}v${getVersion()} · ${model} · ${path.basename(worktree)}${R}`);
|
|
749
|
+
nl(` ${GRAY}session ${sessionId.slice(0, 8)}${R}`);
|
|
374
750
|
nl();
|
|
375
|
-
nl(` ${GRAY}
|
|
751
|
+
nl(` ${GRAY}Type ${R}${CYAN}/${R}${GRAY} to see commands · Ctrl+C interrupt · Ctrl+D exit${R}`);
|
|
376
752
|
nl();
|
|
377
|
-
nl(); // extra blank line — ghost hint uses the line above the prompt
|
|
378
753
|
|
|
754
|
+
// ─── Slash command menu state ──────────────────────────────────────────────
|
|
755
|
+
const menu: MenuState = {
|
|
756
|
+
visible: false,
|
|
757
|
+
options: [],
|
|
758
|
+
selected: 0,
|
|
759
|
+
rowsRendered: 0,
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
// Set up readline AFTER setting up SIGINT so we control it
|
|
379
763
|
const rl = readline.createInterface({
|
|
380
|
-
input: process.stdin,
|
|
381
|
-
|
|
764
|
+
input: process.stdin,
|
|
765
|
+
output: process.stdout,
|
|
766
|
+
terminal: true,
|
|
767
|
+
historySize: 200,
|
|
382
768
|
completer: buildCompleter(worktree),
|
|
383
769
|
});
|
|
384
770
|
|
|
385
771
|
const promptStr = () => `${CYAN}${BOLD}>${R} `;
|
|
386
772
|
|
|
387
|
-
|
|
773
|
+
// ─── Ctrl+C handling ──────────────────────────────────────────────────────
|
|
774
|
+
// When streaming: abort the request AND notify server
|
|
775
|
+
// When idle: clear menu/input or hint to use /exit
|
|
776
|
+
const onSigint = async () => {
|
|
388
777
|
if (streaming && abort) {
|
|
389
778
|
abort.abort();
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
779
|
+
// Also tell the server to cancel the agent loop
|
|
780
|
+
try {
|
|
781
|
+
await fetch(`http://localhost:${port}/v2/session/${sessionId}/cancel`, {
|
|
782
|
+
method: "POST",
|
|
783
|
+
headers: { "Authorization": `Bearer ${token}` },
|
|
784
|
+
});
|
|
785
|
+
} catch { /* server may not have the endpoint, abort is enough */ }
|
|
786
|
+
// Don't reprompt here — the streamPrompt finally block will handle UI
|
|
787
|
+
return;
|
|
393
788
|
}
|
|
789
|
+
|
|
790
|
+
// Idle: clear menu if visible, else hint
|
|
791
|
+
if (menu.visible) {
|
|
792
|
+
clearMenu(menu.rowsRendered);
|
|
793
|
+
menu.visible = false;
|
|
794
|
+
menu.rowsRendered = 0;
|
|
795
|
+
menu.selected = 0;
|
|
796
|
+
}
|
|
797
|
+
out(`\n${GRAY}(Ctrl+D or /exit to quit)${R}\n`);
|
|
394
798
|
rl.setPrompt(promptStr());
|
|
395
799
|
rl.prompt();
|
|
396
800
|
};
|
|
397
|
-
|
|
801
|
+
|
|
802
|
+
// Detach readline's default SIGINT (which closes the line buffer)
|
|
803
|
+
// and route it to our handler.
|
|
804
|
+
rl.on("SIGINT", onSigint);
|
|
398
805
|
|
|
399
806
|
rl.on("close", () => {
|
|
400
|
-
|
|
807
|
+
if (streaming && abort) abort.abort();
|
|
401
808
|
nl(`\n${GRAY}Goodbye.${R}`);
|
|
402
809
|
process.exit(0);
|
|
403
810
|
});
|
|
404
811
|
|
|
405
|
-
|
|
406
|
-
|
|
812
|
+
// ─── Live menu update on every keystroke ──────────────────────────────────
|
|
813
|
+
// We hook into the keypress events of stdin to redraw the menu.
|
|
814
|
+
// The menu sits ABOVE the prompt line.
|
|
815
|
+
const stdin: any = (rl as any).input;
|
|
816
|
+
|
|
817
|
+
const updateMenu = () => {
|
|
818
|
+
if (streaming) return;
|
|
819
|
+
|
|
820
|
+
const line: string = (rl as any).line || "";
|
|
821
|
+
const startsWithSlash = line.startsWith("/");
|
|
822
|
+
const shouldShow = startsWithSlash && !line.includes(" ");
|
|
823
|
+
|
|
824
|
+
if (!shouldShow) {
|
|
825
|
+
if (menu.visible) {
|
|
826
|
+
// Need to clear the menu — but we need to preserve the current input line.
|
|
827
|
+
// Strategy: save current line content & cursor, clear menu lines above,
|
|
828
|
+
// then restore the prompt and content.
|
|
829
|
+
const cursor = (rl as any).cursor || 0;
|
|
830
|
+
out(`\r${ESC}[2K`); // clear current prompt line
|
|
831
|
+
clearMenu(menu.rowsRendered); // clear menu lines above
|
|
832
|
+
menu.visible = false;
|
|
833
|
+
menu.rowsRendered = 0;
|
|
834
|
+
menu.selected = 0;
|
|
835
|
+
// Redraw prompt + line
|
|
836
|
+
out(promptStr() + line);
|
|
837
|
+
// Restore cursor position
|
|
838
|
+
const drawn = line.length;
|
|
839
|
+
if (cursor < drawn) {
|
|
840
|
+
out(`${ESC}[${drawn - cursor}D`);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
407
845
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
846
|
+
// Filter and reset selection if list changed
|
|
847
|
+
const newOptions = filterCommands(line);
|
|
848
|
+
if (newOptions.length === 0) {
|
|
849
|
+
// Clear menu if visible
|
|
850
|
+
if (menu.visible) {
|
|
851
|
+
const cursor = (rl as any).cursor || 0;
|
|
852
|
+
out(`\r${ESC}[2K`);
|
|
853
|
+
clearMenu(menu.rowsRendered);
|
|
854
|
+
menu.visible = false;
|
|
855
|
+
menu.rowsRendered = 0;
|
|
856
|
+
out(promptStr() + line);
|
|
857
|
+
if (cursor < line.length) out(`${ESC}[${line.length - cursor}D`);
|
|
858
|
+
}
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Save current input line
|
|
863
|
+
const cursor = (rl as any).cursor || 0;
|
|
864
|
+
out(`\r${ESC}[2K`); // clear prompt line
|
|
865
|
+
clearMenu(menu.rowsRendered); // clear old menu
|
|
866
|
+
|
|
867
|
+
menu.options = newOptions;
|
|
868
|
+
menu.selected = Math.min(menu.selected, newOptions.length - 1);
|
|
869
|
+
if (menu.selected < 0) menu.selected = 0;
|
|
870
|
+
menu.visible = true;
|
|
871
|
+
|
|
872
|
+
// Render the menu (each item ends in newline)
|
|
873
|
+
menu.rowsRendered = renderCommandMenu(menu);
|
|
874
|
+
|
|
875
|
+
// Redraw prompt + line
|
|
876
|
+
out(promptStr() + line);
|
|
877
|
+
if (cursor < line.length) {
|
|
878
|
+
out(`${ESC}[${line.length - cursor}D`);
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
// Capture special keys for menu navigation
|
|
883
|
+
const onKeypress = (_chunk: any, key: any) => {
|
|
411
884
|
if (!key) return;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
process.stdout.write(`${ESC}[1A${ESC}[2K`); // up 1, clear line
|
|
420
|
-
process.stdout.write(`${ESC}[u`); // restore cursor
|
|
421
|
-
lastHint = "";
|
|
422
|
-
}
|
|
885
|
+
if (streaming) return;
|
|
886
|
+
|
|
887
|
+
if (menu.visible && menu.options.length > 0) {
|
|
888
|
+
// Arrow up/down navigate the menu
|
|
889
|
+
if (key.name === "up") {
|
|
890
|
+
menu.selected = (menu.selected - 1 + menu.options.length) % menu.options.length;
|
|
891
|
+
updateMenu();
|
|
423
892
|
return;
|
|
424
893
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
// Print hint above the prompt line
|
|
430
|
-
process.stdout.write(`${ESC}[s`); // save cursor
|
|
431
|
-
process.stdout.write(`${ESC}[1A${ESC}[2K`); // up 1, clear line
|
|
432
|
-
if (hint) {
|
|
433
|
-
// Show current typed part normal, rest in grey
|
|
434
|
-
const ghost = hint.slice(current.length);
|
|
435
|
-
process.stdout.write(`${promptStr()}${current}${GRAY}${ghost}${R}`);
|
|
894
|
+
if (key.name === "down") {
|
|
895
|
+
menu.selected = (menu.selected + 1) % menu.options.length;
|
|
896
|
+
updateMenu();
|
|
897
|
+
return;
|
|
436
898
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
899
|
+
// Tab: replace input with the selected command
|
|
900
|
+
if (key.name === "tab") {
|
|
901
|
+
const sel = menu.options[menu.selected];
|
|
902
|
+
if (sel) {
|
|
903
|
+
// Replace the line buffer with the command + space
|
|
904
|
+
(rl as any).line = sel.name + " ";
|
|
905
|
+
(rl as any).cursor = sel.name.length + 1;
|
|
906
|
+
updateMenu();
|
|
907
|
+
out(`\r${ESC}[2K`);
|
|
908
|
+
out(promptStr() + (rl as any).line);
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Default: trigger menu update on the next tick (after readline updates the buffer)
|
|
915
|
+
setImmediate(updateMenu);
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
stdin.on("keypress", onKeypress);
|
|
919
|
+
|
|
920
|
+
rl.setPrompt(promptStr());
|
|
921
|
+
rl.prompt();
|
|
440
922
|
|
|
923
|
+
// ─── Main input loop ──────────────────────────────────────────────────────
|
|
441
924
|
for await (const rawLine of rl) {
|
|
442
925
|
const text = rawLine.trim();
|
|
443
926
|
|
|
444
|
-
|
|
927
|
+
// Clear any visible menu before processing
|
|
928
|
+
if (menu.visible) {
|
|
929
|
+
menu.visible = false;
|
|
930
|
+
menu.rowsRendered = 0;
|
|
931
|
+
menu.selected = 0;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (!text) {
|
|
935
|
+
rl.setPrompt(promptStr());
|
|
936
|
+
rl.prompt();
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
445
939
|
|
|
446
940
|
history.push(text);
|
|
447
941
|
if (history.length > 200) history.shift();
|
|
448
942
|
|
|
449
|
-
// ──
|
|
943
|
+
// ── Slash commands ──────────────────────────────────────────────────────
|
|
450
944
|
if (text.startsWith("/")) {
|
|
451
945
|
const parts = text.slice(1).trim().split(/\s+/);
|
|
452
946
|
const cmd = parts[0]?.toLowerCase() || "";
|
|
@@ -460,7 +954,6 @@ export async function runTUI(opts: {
|
|
|
460
954
|
case "new":
|
|
461
955
|
try {
|
|
462
956
|
sessionId = await createSession(port, token, worktree, "New Session");
|
|
463
|
-
turnCount = 0;
|
|
464
957
|
nl(`${GREEN}✓${R} ${GRAY}session ${sessionId.slice(0, 8)}${R}`);
|
|
465
958
|
} catch (e: any) { nl(`${RED}✗ ${e.message}${R}`); }
|
|
466
959
|
break;
|
|
@@ -469,21 +962,35 @@ export async function runTUI(opts: {
|
|
|
469
962
|
nl(`${GRAY}${sessionId}${R}`);
|
|
470
963
|
break;
|
|
471
964
|
|
|
965
|
+
case "sessions": {
|
|
966
|
+
try {
|
|
967
|
+
const list = await apiGet<any[]>(`http://localhost:${port}/v2/session?limit=10`, token);
|
|
968
|
+
if (!list.length) { nl(`${GRAY}No sessions${R}`); break; }
|
|
969
|
+
nl();
|
|
970
|
+
for (const s of list) {
|
|
971
|
+
const active = s.id === sessionId;
|
|
972
|
+
const dot = active ? `${GREEN}●${R}` : `${GRAY}○${R}`;
|
|
973
|
+
const t = new Date(s.time?.updated || Date.now()).toLocaleString();
|
|
974
|
+
nl(` ${dot} ${WHITE}${(s.title || "untitled").slice(0, 50).padEnd(50)}${R} ${GRAY}${t}${R}`);
|
|
975
|
+
}
|
|
976
|
+
nl();
|
|
977
|
+
} catch (e: any) { nl(`${RED}✗ ${e.message}${R}`); }
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
|
|
472
981
|
case "model":
|
|
473
982
|
if (args[0]) {
|
|
474
983
|
if (args[0] === "local") {
|
|
475
984
|
provider = process.env.DEFAULT_PROVIDER || "openai_compatible";
|
|
476
985
|
model = process.env.DEFAULT_MODEL || "local-model";
|
|
477
|
-
nl(`${GREEN}✓${R} ${WHITE}${provider}/${model}${R}`);
|
|
478
986
|
} else if (args[0].includes("/")) {
|
|
479
987
|
const i = args[0].indexOf("/");
|
|
480
988
|
provider = args[0].slice(0, i);
|
|
481
989
|
model = args[0].slice(i + 1);
|
|
482
|
-
nl(`${GREEN}✓${R} ${WHITE}${provider}/${model}${R}`);
|
|
483
990
|
} else {
|
|
484
991
|
model = args[0];
|
|
485
|
-
nl(`${GREEN}✓${R} ${WHITE}${provider}/${model}${R}`);
|
|
486
992
|
}
|
|
993
|
+
nl(`${GREEN}✓${R} ${WHITE}${provider}/${model}${R}`);
|
|
487
994
|
} else {
|
|
488
995
|
nl(`${GRAY}${provider}/${model}${R}`);
|
|
489
996
|
}
|
|
@@ -517,7 +1024,7 @@ export async function runTUI(opts: {
|
|
|
517
1024
|
break;
|
|
518
1025
|
|
|
519
1026
|
case "history":
|
|
520
|
-
if (!history.length)
|
|
1027
|
+
if (!history.length) nl(`${GRAY}No history${R}`);
|
|
521
1028
|
else history.slice(-10).forEach((h, i) => nl(` ${GRAY}${i + 1}.${R} ${h.slice(0, 80)}`));
|
|
522
1029
|
break;
|
|
523
1030
|
|
|
@@ -527,7 +1034,7 @@ export async function runTUI(opts: {
|
|
|
527
1034
|
break;
|
|
528
1035
|
|
|
529
1036
|
default:
|
|
530
|
-
nl(`${YELLOW}Unknown: /${cmd}${R} ${GRAY}/help${R}`);
|
|
1037
|
+
nl(`${YELLOW}Unknown: /${cmd}${R} ${GRAY}Type /help for available commands${R}`);
|
|
531
1038
|
}
|
|
532
1039
|
|
|
533
1040
|
rl.setPrompt(promptStr());
|
|
@@ -535,60 +1042,53 @@ export async function runTUI(opts: {
|
|
|
535
1042
|
continue;
|
|
536
1043
|
}
|
|
537
1044
|
|
|
538
|
-
// ── Prompt
|
|
539
|
-
|
|
1045
|
+
// ── Prompt to the agent ─────────────────────────────────────────────────
|
|
540
1046
|
if (!sessionId) {
|
|
541
1047
|
try { sessionId = await createSession(port, token, worktree, text.slice(0, 50)); }
|
|
542
|
-
catch (e: any) {
|
|
1048
|
+
catch (e: any) {
|
|
1049
|
+
nl(`${RED}✗ ${e.message}${R}`);
|
|
1050
|
+
rl.setPrompt(promptStr());
|
|
1051
|
+
rl.prompt();
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
543
1054
|
}
|
|
544
1055
|
|
|
545
|
-
// Resolve @file mentions
|
|
1056
|
+
// Resolve @file mentions to absolute paths
|
|
546
1057
|
const resolved = text.replace(/@([\w./\-]+)/g, (match, fp) => {
|
|
547
1058
|
const abs = path.isAbsolute(fp) ? fp : path.resolve(worktree, fp);
|
|
548
1059
|
return fs.existsSync(abs) ? `@${abs}` : match;
|
|
549
1060
|
});
|
|
550
1061
|
|
|
551
|
-
|
|
552
|
-
renderUserMessage(resolved !== text ? `${text} ${GRAY}(files resolved)${R}` : text);
|
|
1062
|
+
renderUserMessage(text);
|
|
553
1063
|
|
|
554
|
-
//
|
|
555
|
-
out(` ${GRAY}
|
|
1064
|
+
// Show "..." spinner so the user knows something is happening
|
|
1065
|
+
out(` ${GRAY}thinking...${R}`);
|
|
556
1066
|
|
|
557
1067
|
rl.pause();
|
|
558
1068
|
streaming = true;
|
|
559
1069
|
abort = new AbortController();
|
|
560
|
-
let firstDelta = true;
|
|
561
1070
|
|
|
1071
|
+
const t0 = Date.now();
|
|
562
1072
|
const result = await streamPrompt({
|
|
563
|
-
port, token, sessionId
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
out(`\r${ESC}[2K`);
|
|
569
|
-
nl();
|
|
570
|
-
out(` `);
|
|
571
|
-
firstDelta = false;
|
|
572
|
-
}
|
|
573
|
-
// Strip markdown fences for terminal rendering
|
|
574
|
-
const clean = delta.replace(/^```\w*\n?/gm, "").replace(/^```\n?/gm, "");
|
|
575
|
-
if (clean) out(clean);
|
|
576
|
-
},
|
|
1073
|
+
port, token, sessionId,
|
|
1074
|
+
model, provider,
|
|
1075
|
+
message: resolved,
|
|
1076
|
+
worktree,
|
|
1077
|
+
abortSignal: abort.signal,
|
|
577
1078
|
});
|
|
578
1079
|
|
|
579
1080
|
streaming = false;
|
|
580
1081
|
abort = null;
|
|
581
1082
|
|
|
582
|
-
if
|
|
583
|
-
|
|
584
|
-
|
|
1083
|
+
// Clear the thinking spinner if no output happened
|
|
1084
|
+
if (!result.text && !result.error) {
|
|
1085
|
+
clearLine();
|
|
1086
|
+
} else {
|
|
1087
|
+
// Make sure the next render starts on a clean line
|
|
1088
|
+
const cur = (process.stdout as any).cursor;
|
|
1089
|
+
if (cur && cur.col !== 0) out("\n");
|
|
585
1090
|
}
|
|
586
1091
|
|
|
587
|
-
if (result.text && !result.text.endsWith("\n")) nl();
|
|
588
|
-
|
|
589
|
-
turnCount++;
|
|
590
|
-
|
|
591
|
-
// End-of-turn marker: ▣ Build · model · 2.1s
|
|
592
1092
|
renderTurnEnd(model, result.elapsedMs, result.interrupted);
|
|
593
1093
|
|
|
594
1094
|
if (result.error && !result.interrupted) {
|
|
@@ -599,9 +1099,10 @@ export async function runTUI(opts: {
|
|
|
599
1099
|
|
|
600
1100
|
rl.resume();
|
|
601
1101
|
rl.setPrompt(promptStr());
|
|
602
|
-
nl(); // blank line for ghost hint
|
|
603
1102
|
rl.prompt();
|
|
604
1103
|
}
|
|
1104
|
+
|
|
1105
|
+
stdin.removeListener("keypress", onKeypress);
|
|
605
1106
|
}
|
|
606
1107
|
|
|
607
1108
|
// ─── Entry point ──────────────────────────────────────────────────────────────
|