mini-coder 0.5.6 → 0.5.7
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/bun.lock +13 -13
- package/package.json +3 -3
- package/src/agent.ts +141 -29
- package/src/index.ts +27 -2
- package/src/prompt.ts +84 -78
- package/src/submit.ts +53 -6
- package/src/tools.ts +160 -42
- package/src/ui/agent.ts +16 -2
- package/src/ui/commands.test.ts +1 -0
- package/src/ui/input.test.ts +29 -1
- package/src/ui/input.ts +45 -51
- package/src/ui/render-performance.test.ts +1 -0
- package/src/ui/status.test.ts +1 -0
- package/src/ui.ts +50 -10
package/bun.lock
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
"": {
|
|
6
6
|
"name": "mini-coder",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@cel-tui/components": "0.7.2",
|
|
9
|
-
"@cel-tui/core": "0.7.2",
|
|
10
|
-
"@cel-tui/types": "0.7.2",
|
|
8
|
+
"@cel-tui/components": "^0.7.2",
|
|
9
|
+
"@cel-tui/core": "^0.7.2",
|
|
10
|
+
"@cel-tui/types": "^0.7.2",
|
|
11
11
|
"@mariozechner/pi-ai": "^0.66.1",
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
@@ -90,23 +90,23 @@
|
|
|
90
90
|
|
|
91
91
|
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
|
92
92
|
|
|
93
|
-
"@biomejs/biome": ["@biomejs/biome@2.4.
|
|
93
|
+
"@biomejs/biome": ["@biomejs/biome@2.4.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.12", "@biomejs/cli-darwin-x64": "2.4.12", "@biomejs/cli-linux-arm64": "2.4.12", "@biomejs/cli-linux-arm64-musl": "2.4.12", "@biomejs/cli-linux-x64": "2.4.12", "@biomejs/cli-linux-x64-musl": "2.4.12", "@biomejs/cli-win32-arm64": "2.4.12", "@biomejs/cli-win32-x64": "2.4.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA=="],
|
|
94
94
|
|
|
95
|
-
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.
|
|
95
|
+
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BnMU4Pc3ciEVteVpZ0BK33MLr7X57F5w1dwDLDn+/iy/yTrA4Q/N2yftidFtsA4vrDh0FMXDpacNV/Tl3fbmng=="],
|
|
96
96
|
|
|
97
|
-
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.
|
|
97
|
+
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-x9uJ0bI1rJsWICp3VH8w/5PnAVD3A7SqzDpbrfoUQX1QyWrK5jSU4fRLo/wSgGeplCivbxBRKmt5Xq4/nWvq8A=="],
|
|
98
98
|
|
|
99
|
-
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.
|
|
99
|
+
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-tOwuCuZZtKi1jVzbk/5nXmIsziOB6yqN8c9r9QM0EJYPU6DpQWf11uBOSCfFKKM4H3d9ZoarvlgMfbcuD051Pw=="],
|
|
100
100
|
|
|
101
|
-
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.
|
|
101
|
+
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-FhfpkAAlKL6kwvcVap0Hgp4AhZmtd3YImg0kK1jd7C/aSoh4SfsB2f++yG1rU0lr8Y5MCFJrcSkmssiL9Xnnig=="],
|
|
102
102
|
|
|
103
|
-
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.
|
|
103
|
+
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8pFeAnLU9QdW9jCIslB/v82bI0lhBmz2ZAKc8pVMFPO0t0wAHsoEkrUQUbMkIorTRIjbqyNZHA3lEXavsPWYSw=="],
|
|
104
104
|
|
|
105
|
-
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.
|
|
105
|
+
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.12", "", { "os": "linux", "cpu": "x64" }, "sha512-dwTIgZrGutzhkQCuvHynCkyW6hJxUuyZqKKO0YNfaS2GUoRO+tOvxXZqZB6SkWAOdfZTzwaw8IEdUnIkHKHoew=="],
|
|
106
106
|
|
|
107
|
-
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.
|
|
107
|
+
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-B0DLnx0vA9ya/3v7XyCaP+/lCpnbWbMOfUFFve+xb5OxyYvdHaS55YsSddr228Y+JAFk58agCuZTsqNiw2a6ig=="],
|
|
108
108
|
|
|
109
|
-
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.
|
|
109
|
+
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.12", "", { "os": "win32", "cpu": "x64" }, "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA=="],
|
|
110
110
|
|
|
111
111
|
"@cel-tui/components": ["@cel-tui/components@0.7.2", "", { "dependencies": { "@cel-tui/core": "0.7.2", "@cel-tui/types": "0.7.2", "lextide": "^0.2.1", "yoctomarkdown": "^0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-RHq7G/lS6A733l534cSStQp+cJECkxZZWrLghbPHwGBsSeDdJ2MkrQm+02roh6EH/N40ZAYjQqplq8acRrnFFQ=="],
|
|
112
112
|
|
|
@@ -368,7 +368,7 @@
|
|
|
368
368
|
|
|
369
369
|
"path-expression-matcher": ["path-expression-matcher@1.2.1", "", {}, "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw=="],
|
|
370
370
|
|
|
371
|
-
"prettier": ["prettier@3.8.
|
|
371
|
+
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
|
372
372
|
|
|
373
373
|
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
|
374
374
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mini-coder",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "A small, fast CLI coding agent",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
"@mariozechner/pi-ai": "^0.66.1"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@biomejs/biome": "^2.4.
|
|
27
|
+
"@biomejs/biome": "^2.4.12",
|
|
28
28
|
"@types/bun": "^1.3.12",
|
|
29
29
|
"lefthook": "^2.1.5",
|
|
30
|
-
"prettier": "^3.8.
|
|
30
|
+
"prettier": "^3.8.3",
|
|
31
31
|
"typescript": "^6.0.2"
|
|
32
32
|
},
|
|
33
33
|
"license": "MIT"
|
package/src/agent.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
Tool,
|
|
19
19
|
ToolCall,
|
|
20
20
|
ToolResultMessage,
|
|
21
|
+
UserMessage,
|
|
21
22
|
} from "@mariozechner/pi-ai";
|
|
22
23
|
import { streamSimple } from "@mariozechner/pi-ai";
|
|
23
24
|
import { appendMessage } from "./session.ts";
|
|
@@ -79,6 +80,7 @@ export type AgentEvent =
|
|
|
79
80
|
args: Record<string, unknown>;
|
|
80
81
|
content: AssistantMessage["content"];
|
|
81
82
|
}
|
|
83
|
+
| { type: "user_message"; message: UserMessage }
|
|
82
84
|
| { type: "assistant_message"; message: AssistantMessage }
|
|
83
85
|
| {
|
|
84
86
|
type: "tool_start";
|
|
@@ -131,6 +133,8 @@ interface RunAgentOpts {
|
|
|
131
133
|
signal?: AbortSignal;
|
|
132
134
|
/** Callback for UI events. */
|
|
133
135
|
onEvent?: (event: AgentEvent) => void;
|
|
136
|
+
/** Dequeue the next queued steering message, if one is waiting. */
|
|
137
|
+
takeQueuedUserMessage?: () => UserMessage | null;
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
/** Result of the agent loop. */
|
|
@@ -462,6 +466,19 @@ async function streamAssistantMessage(
|
|
|
462
466
|
return mergeAssistantMessage(partialAssistantMessage, finalAssistantMessage);
|
|
463
467
|
}
|
|
464
468
|
|
|
469
|
+
function appendUserMessage(
|
|
470
|
+
db: Database,
|
|
471
|
+
sessionId: string,
|
|
472
|
+
messages: Message[],
|
|
473
|
+
userMessage: UserMessage,
|
|
474
|
+
onEvent: RunAgentOpts["onEvent"],
|
|
475
|
+
): number {
|
|
476
|
+
messages.push(userMessage);
|
|
477
|
+
const turn = appendMessage(db, sessionId, userMessage);
|
|
478
|
+
onEvent?.({ type: "user_message", message: userMessage });
|
|
479
|
+
return turn;
|
|
480
|
+
}
|
|
481
|
+
|
|
465
482
|
function appendAssistantMessage(
|
|
466
483
|
db: Database,
|
|
467
484
|
sessionId: string,
|
|
@@ -490,15 +507,22 @@ function resolveLoopStopReason(
|
|
|
490
507
|
return { messages, stopReason: assistantMessage.stopReason };
|
|
491
508
|
}
|
|
492
509
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function consumeQueuedUserMessage(
|
|
514
|
+
db: Database,
|
|
515
|
+
sessionId: string,
|
|
516
|
+
messages: Message[],
|
|
517
|
+
takeQueuedUserMessage: RunAgentOpts["takeQueuedUserMessage"],
|
|
518
|
+
onEvent: RunAgentOpts["onEvent"],
|
|
519
|
+
): number | null {
|
|
520
|
+
const queuedUserMessage = takeQueuedUserMessage?.();
|
|
521
|
+
if (!queuedUserMessage) {
|
|
522
|
+
return null;
|
|
499
523
|
}
|
|
500
524
|
|
|
501
|
-
return
|
|
525
|
+
return appendUserMessage(db, sessionId, messages, queuedUserMessage, onEvent);
|
|
502
526
|
}
|
|
503
527
|
|
|
504
528
|
function getAssistantToolCalls(message: AssistantMessage): ToolCall[] {
|
|
@@ -581,6 +605,72 @@ function appendToolResultMessage(
|
|
|
581
605
|
onEvent?.({ type: "tool_result", message: toolResultMessage });
|
|
582
606
|
}
|
|
583
607
|
|
|
608
|
+
function finalizeStoppedAssistantMessage(
|
|
609
|
+
db: Database,
|
|
610
|
+
sessionId: string,
|
|
611
|
+
messages: Message[],
|
|
612
|
+
assistantMessage: AssistantMessage,
|
|
613
|
+
stopReason: "stop" | "length",
|
|
614
|
+
takeQueuedUserMessage: RunAgentOpts["takeQueuedUserMessage"],
|
|
615
|
+
onEvent: RunAgentOpts["onEvent"],
|
|
616
|
+
): AgentLoopResult | number {
|
|
617
|
+
const queuedTurn = consumeQueuedUserMessage(
|
|
618
|
+
db,
|
|
619
|
+
sessionId,
|
|
620
|
+
messages,
|
|
621
|
+
takeQueuedUserMessage,
|
|
622
|
+
onEvent,
|
|
623
|
+
);
|
|
624
|
+
if (queuedTurn !== null) {
|
|
625
|
+
return queuedTurn;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
onEvent?.({ type: "done", message: assistantMessage });
|
|
629
|
+
return { messages, stopReason };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function executeAssistantToolCalls(
|
|
633
|
+
assistantMessage: AssistantMessage,
|
|
634
|
+
opts: Pick<
|
|
635
|
+
RunAgentOpts,
|
|
636
|
+
| "db"
|
|
637
|
+
| "sessionId"
|
|
638
|
+
| "messages"
|
|
639
|
+
| "toolHandlers"
|
|
640
|
+
| "cwd"
|
|
641
|
+
| "signal"
|
|
642
|
+
| "onEvent"
|
|
643
|
+
| "model"
|
|
644
|
+
>,
|
|
645
|
+
turn: number,
|
|
646
|
+
): Promise<AgentLoopResult | null> {
|
|
647
|
+
for (const toolCall of getAssistantToolCalls(assistantMessage)) {
|
|
648
|
+
const result = await executeToolCall(toolCall, opts);
|
|
649
|
+
appendToolResultMessage(
|
|
650
|
+
opts.db,
|
|
651
|
+
opts.sessionId,
|
|
652
|
+
opts.messages,
|
|
653
|
+
toolCall,
|
|
654
|
+
result,
|
|
655
|
+
turn,
|
|
656
|
+
opts.onEvent,
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
if (opts.signal?.aborted) {
|
|
660
|
+
opts.onEvent?.({
|
|
661
|
+
type: "aborted",
|
|
662
|
+
message: buildIncompleteAssistantMessage(
|
|
663
|
+
{ model: opts.model, signal: opts.signal },
|
|
664
|
+
assistantMessage,
|
|
665
|
+
),
|
|
666
|
+
});
|
|
667
|
+
return { messages: opts.messages, stopReason: "aborted" };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
584
674
|
// ---------------------------------------------------------------------------
|
|
585
675
|
// Agent loop
|
|
586
676
|
// ---------------------------------------------------------------------------
|
|
@@ -609,7 +699,9 @@ export async function runAgentLoop(
|
|
|
609
699
|
onEvent,
|
|
610
700
|
toolHandlers,
|
|
611
701
|
cwd,
|
|
702
|
+
takeQueuedUserMessage,
|
|
612
703
|
} = opts;
|
|
704
|
+
let currentTurn = turn;
|
|
613
705
|
|
|
614
706
|
while (true) {
|
|
615
707
|
const assistantMessage = await streamAssistantMessage(opts);
|
|
@@ -618,7 +710,7 @@ export async function runAgentLoop(
|
|
|
618
710
|
sessionId,
|
|
619
711
|
messages,
|
|
620
712
|
assistantMessage,
|
|
621
|
-
|
|
713
|
+
currentTurn,
|
|
622
714
|
onEvent,
|
|
623
715
|
);
|
|
624
716
|
|
|
@@ -631,33 +723,53 @@ export async function runAgentLoop(
|
|
|
631
723
|
return stopResult;
|
|
632
724
|
}
|
|
633
725
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
onEvent,
|
|
640
|
-
});
|
|
641
|
-
appendToolResultMessage(
|
|
726
|
+
if (
|
|
727
|
+
assistantMessage.stopReason === "stop" ||
|
|
728
|
+
assistantMessage.stopReason === "length"
|
|
729
|
+
) {
|
|
730
|
+
const finalResult = finalizeStoppedAssistantMessage(
|
|
642
731
|
db,
|
|
643
732
|
sessionId,
|
|
644
733
|
messages,
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
734
|
+
assistantMessage,
|
|
735
|
+
assistantMessage.stopReason,
|
|
736
|
+
takeQueuedUserMessage,
|
|
648
737
|
onEvent,
|
|
649
738
|
);
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
type: "aborted",
|
|
654
|
-
message: buildIncompleteAssistantMessage(
|
|
655
|
-
{ model, signal },
|
|
656
|
-
assistantMessage,
|
|
657
|
-
),
|
|
658
|
-
});
|
|
659
|
-
return { messages, stopReason: "aborted" };
|
|
739
|
+
if (typeof finalResult === "number") {
|
|
740
|
+
currentTurn = finalResult;
|
|
741
|
+
continue;
|
|
660
742
|
}
|
|
743
|
+
return finalResult;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const toolStopResult = await executeAssistantToolCalls(
|
|
747
|
+
assistantMessage,
|
|
748
|
+
{
|
|
749
|
+
db,
|
|
750
|
+
sessionId,
|
|
751
|
+
messages,
|
|
752
|
+
toolHandlers,
|
|
753
|
+
cwd,
|
|
754
|
+
signal,
|
|
755
|
+
onEvent,
|
|
756
|
+
model,
|
|
757
|
+
},
|
|
758
|
+
currentTurn,
|
|
759
|
+
);
|
|
760
|
+
if (toolStopResult) {
|
|
761
|
+
return toolStopResult;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const queuedTurn = consumeQueuedUserMessage(
|
|
765
|
+
db,
|
|
766
|
+
sessionId,
|
|
767
|
+
messages,
|
|
768
|
+
takeQueuedUserMessage,
|
|
769
|
+
onEvent,
|
|
770
|
+
);
|
|
771
|
+
if (queuedTurn !== null) {
|
|
772
|
+
currentTurn = queuedTurn;
|
|
661
773
|
}
|
|
662
774
|
}
|
|
663
775
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { basename, dirname, join } from "node:path";
|
|
14
14
|
import { isDeepStrictEqual } from "node:util";
|
|
15
15
|
import type {
|
|
16
16
|
KnownProvider,
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
OAuthCredentials,
|
|
20
20
|
ThinkingLevel,
|
|
21
21
|
Tool,
|
|
22
|
+
UserMessage,
|
|
22
23
|
} from "@mariozechner/pi-ai";
|
|
23
24
|
import { getEnvApiKey, getModels, getProviders } from "@mariozechner/pi-ai";
|
|
24
25
|
import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai/oauth";
|
|
@@ -561,6 +562,8 @@ export interface AppState {
|
|
|
561
562
|
abortController: AbortController | null;
|
|
562
563
|
/** Promise for the active conversational turn, used to serialize cleanup like `/undo`. */
|
|
563
564
|
activeTurnPromise: Promise<void> | null;
|
|
565
|
+
/** Resolved user messages queued while the current run is still active. */
|
|
566
|
+
queuedUserMessages: UserMessage[];
|
|
564
567
|
/** Whether to show thinking content. */
|
|
565
568
|
showReasoning: boolean;
|
|
566
569
|
/** Whether to show full (un-truncated) tool output. */
|
|
@@ -641,6 +644,7 @@ export async function init(): Promise<AppState> {
|
|
|
641
644
|
running: false,
|
|
642
645
|
abortController: null,
|
|
643
646
|
activeTurnPromise: null,
|
|
647
|
+
queuedUserMessages: [],
|
|
644
648
|
showReasoning: startup.showReasoning,
|
|
645
649
|
verbose: startup.verbose,
|
|
646
650
|
customModels: customResult.models,
|
|
@@ -648,6 +652,22 @@ export async function init(): Promise<AppState> {
|
|
|
648
652
|
};
|
|
649
653
|
}
|
|
650
654
|
|
|
655
|
+
/** Resolve the shell label shown in the system prompt. */
|
|
656
|
+
function resolvePromptShell(): string {
|
|
657
|
+
return basename(process.env.SHELL || "/bin/sh");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Resolve the normalized OS label shown in the system prompt. */
|
|
661
|
+
function resolvePromptOs(): "linux" | "mac" | "docker" {
|
|
662
|
+
if (process.platform === "darwin") {
|
|
663
|
+
return "mac";
|
|
664
|
+
}
|
|
665
|
+
if (existsSync("/.dockerenv") || existsSync("/run/.containerenv")) {
|
|
666
|
+
return "docker";
|
|
667
|
+
}
|
|
668
|
+
return "linux";
|
|
669
|
+
}
|
|
670
|
+
|
|
651
671
|
/**
|
|
652
672
|
* Build the system prompt for the current state.
|
|
653
673
|
*
|
|
@@ -657,7 +677,12 @@ export async function init(): Promise<AppState> {
|
|
|
657
677
|
export function buildPrompt(state: AppState): string {
|
|
658
678
|
return buildSystemPrompt({
|
|
659
679
|
cwd: state.cwd,
|
|
660
|
-
|
|
680
|
+
modelLabel: state.model
|
|
681
|
+
? `${state.model.provider}/${state.model.id}`
|
|
682
|
+
: "unknown",
|
|
683
|
+
os: resolvePromptOs(),
|
|
684
|
+
shell: resolvePromptShell(),
|
|
685
|
+
supportsImages: state.model?.input.includes("image") ?? false,
|
|
661
686
|
git: state.git,
|
|
662
687
|
agentsMd: state.agentsMd,
|
|
663
688
|
skills: state.skills,
|
package/src/prompt.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* System prompt construction.
|
|
3
3
|
*
|
|
4
|
-
* Assembles the full system prompt from
|
|
4
|
+
* Assembles the full system prompt from the core prompt template plus
|
|
5
5
|
* dynamic context: AGENTS.md files, skill catalog, plugin suffixes,
|
|
6
|
-
* and
|
|
6
|
+
* and the current environment block.
|
|
7
7
|
*
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
@@ -30,8 +30,14 @@ export interface AgentsMdFile {
|
|
|
30
30
|
interface BuildSystemPromptOpts {
|
|
31
31
|
/** Current working directory. */
|
|
32
32
|
cwd: string;
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/** Active provider/model identifier. */
|
|
34
|
+
modelLabel: string;
|
|
35
|
+
/** Normalized host OS label (`linux`, `mac`, or `docker`). */
|
|
36
|
+
os: string;
|
|
37
|
+
/** Active shell name (for example `bash` or `zsh`). */
|
|
38
|
+
shell: string;
|
|
39
|
+
/** Whether the active model supports image input. */
|
|
40
|
+
supportsImages?: boolean;
|
|
35
41
|
/** Git repository state, or `null`/`undefined` if not in a repo. */
|
|
36
42
|
git?: GitState | null;
|
|
37
43
|
/** Discovered AGENTS.md files, ordered root-to-leaf. */
|
|
@@ -153,7 +159,7 @@ export function discoverAgentsMd(
|
|
|
153
159
|
// ---------------------------------------------------------------------------
|
|
154
160
|
|
|
155
161
|
/**
|
|
156
|
-
* Format a git state snapshot into a single-line string for the
|
|
162
|
+
* Format a git state snapshot into a single-line string for the environment block.
|
|
157
163
|
*
|
|
158
164
|
* Fields are omitted when their values are zero. The git line format:
|
|
159
165
|
* `Git: branch main | 3 staged, 1 modified, 2 untracked | +5 −2 vs origin/main`
|
|
@@ -185,67 +191,79 @@ export function formatGitLine(state: GitState): string {
|
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
// ---------------------------------------------------------------------------
|
|
188
|
-
//
|
|
194
|
+
// Core prompt template
|
|
189
195
|
// ---------------------------------------------------------------------------
|
|
190
196
|
|
|
191
|
-
|
|
197
|
+
function buildCorePrompt(opts: BuildSystemPromptOpts): string {
|
|
198
|
+
const lines = [
|
|
199
|
+
"You are mini-coder, the best software engineering assistant in the world.",
|
|
200
|
+
"",
|
|
201
|
+
"The current environment is:",
|
|
202
|
+
`- LLM in use: ${opts.modelLabel}`,
|
|
203
|
+
`- OS: ${opts.os}`,
|
|
204
|
+
`- Current working directory: ${opts.cwd}`,
|
|
205
|
+
];
|
|
192
206
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
# Tools
|
|
198
|
-
|
|
199
|
-
You have these core tools:
|
|
200
|
-
|
|
201
|
-
- \`shell\` — run commands in the user's shell. Use this to explore the codebase, read tests/verifiers/examples, inspect required outputs, and run targeted checks, builds, or git commands. Prefer \`rg\` over \`grep\` for speed.
|
|
202
|
-
- \`edit\` — make exact-text replacements in files. Provide the file path, the exact text to find, and the replacement text. The old text must match exactly one location in the file. To create a new file, use an empty old text and the full file content as new text. Use this to write the exact final file content the task requires.
|
|
203
|
-
|
|
204
|
-
You may also have additional tools provided by plugins. Use them when they match the task.
|
|
205
|
-
|
|
206
|
-
Workflow: **inspect with shell → mutate with edit → verify with shell**.
|
|
207
|
-
|
|
208
|
-
# Code quality
|
|
209
|
-
|
|
210
|
-
- Conform to the codebase's existing conventions: patterns, naming, formatting, language idioms.
|
|
211
|
-
- Write correct, clear, minimal code. Prefer the simplest solution that satisfies the task's checks exactly. Don't over-engineer, don't add abstractions for hypothetical futures.
|
|
212
|
-
- Reuse before creating. Search for existing helpers before writing new ones.
|
|
213
|
-
- Tight error handling: no broad try/catch, no silent failures, no swallowed errors.
|
|
214
|
-
- Keep type safety. Avoid \`any\` casts. Use proper types and guards.
|
|
215
|
-
- Only add comments where the logic isn't self-evident.
|
|
216
|
-
|
|
217
|
-
# Editing discipline
|
|
218
|
-
|
|
219
|
-
- Read enough context before editing. Batch logical changes together rather than making many small edits.
|
|
220
|
-
- Never revert changes you didn't make unless explicitly asked.
|
|
221
|
-
- Never use destructive git commands (reset --hard, checkout --, clean -fd) unless the user requests it.
|
|
222
|
-
- Default to ASCII. Only use non-ASCII characters when the file already uses them or there's clear justification.
|
|
223
|
-
|
|
224
|
-
# Exploring the codebase
|
|
225
|
-
|
|
226
|
-
- Think first: before any tool call, decide all files and information you need.
|
|
227
|
-
- Early in the task, look for acceptance criteria in tests, verifier scripts, eval scripts, examples, and expected-output files. Do not rely on the task text alone when machine-checkable criteria are available.
|
|
228
|
-
- Before relying on a binary, interpreter, or CLI helper you have not yet confirmed, probe availability with focused checks such as \`command -v <tool>\`, \`<tool> --version\`, or \`python3 -m pip --version\`, then adapt to what is actually installed.
|
|
229
|
-
- Batch reads: if you need multiple files, read them together in parallel rather than one at a time.
|
|
230
|
-
- Only make sequential calls when a later call genuinely depends on an earlier result.
|
|
207
|
+
if (opts.git) {
|
|
208
|
+
lines.push(`- ${formatGitLine(opts.git)}`);
|
|
209
|
+
}
|
|
231
210
|
|
|
232
|
-
|
|
211
|
+
lines.push(
|
|
212
|
+
`- Shell: ${opts.shell}. Use \`command -v <name>\` to check what is available to you; do not assume environment support.`,
|
|
213
|
+
"- Edit: Safe exact-text replacement in a single file.",
|
|
214
|
+
);
|
|
233
215
|
|
|
234
|
-
|
|
235
|
-
-
|
|
236
|
-
|
|
237
|
-
- When discussing multiple options, use numbered lists so the user can reply with a number without turning it into a questionnaire.
|
|
238
|
-
- If asked for a review, focus on bugs, risks, regressions, and missing tests. Findings first, ordered by severity.
|
|
216
|
+
if (opts.supportsImages) {
|
|
217
|
+
lines.push("- Read Image: Read an image from disk.");
|
|
218
|
+
}
|
|
239
219
|
|
|
240
|
-
|
|
220
|
+
lines.push(
|
|
221
|
+
"",
|
|
222
|
+
"## Core working style:",
|
|
223
|
+
"",
|
|
224
|
+
"- Be concise, direct, and useful.",
|
|
225
|
+
"- Use a casual, solution-oriented technical tone. Avoid fluff and performative apologies.",
|
|
226
|
+
"- When the user gives a clear command, do it without adding extra work they did not ask for.",
|
|
227
|
+
"- Prefer the minimal implementation that satisfies the request exactly.",
|
|
228
|
+
"- Use YAGNI. Avoid speculative abstractions, future-proofing, and unnecessary compatibility shims.",
|
|
229
|
+
"- Preserve working behavior where possible. Prefer targeted fixes over rewrites.",
|
|
230
|
+
"- Be thorough, use fresh eyes and internal analysis before taking action.",
|
|
231
|
+
"- Make informed decisions based on the available information and best practices.",
|
|
232
|
+
"- Always verify the result of your actions.",
|
|
233
|
+
"",
|
|
234
|
+
"### Using the shell tool:",
|
|
235
|
+
"",
|
|
236
|
+
"- Always execute shell commands in non-interactive mode.",
|
|
237
|
+
"- Use the appropriate commands and package managers for the specified operating system.",
|
|
238
|
+
"- Don't assume the environment supports all commands; check before using them.",
|
|
239
|
+
"- Avoid destructive commands that can discard changes or override edits.",
|
|
240
|
+
"",
|
|
241
|
+
"### Working with code:",
|
|
242
|
+
"",
|
|
243
|
+
"- Describe changes before implementing them",
|
|
244
|
+
"- Prefer boring dependable solutions over clever ones",
|
|
245
|
+
"- Avoid creating extra files, systems or documentation outside of what was asked.",
|
|
246
|
+
"- Check requirements, and plan your changes before editing code.",
|
|
247
|
+
"- Implement the necessary changes, following good practices and proper error handling.",
|
|
248
|
+
"- Always verify your changes using compilation, testing, and manual verification when possible.",
|
|
249
|
+
"- Do not leave helpers, tests, or any other form of temporary files; clean up after yourself and leave no trace.",
|
|
250
|
+
"- Ensure you match the requested output exactly. This applies to file names, directory structure, number of files, output formats, and all other details.",
|
|
251
|
+
'- "Polish" is not optional; it counts just as much as solving the task.',
|
|
252
|
+
"",
|
|
253
|
+
"### Task management",
|
|
254
|
+
"",
|
|
255
|
+
"- Use the /tmp or equivalent temp directory to make a To-do list for your task.",
|
|
256
|
+
"- This to-do list is **extremely** helpful for breaking down your task into smaller, less complex steps.",
|
|
257
|
+
"- Not using a to-do list may cause you to forget important steps, or lose focus on the user's request; that would be unacceptable.",
|
|
258
|
+
"- Keep the to-do list up-to-date above all, mark your completions as soon as you verify them.",
|
|
259
|
+
"- A to-do item is only complete if verification was successful and thorough.",
|
|
260
|
+
'- You have the option to delegate tasks to copies of yourself with `mc -p "subtask prompt"` in the shell.',
|
|
261
|
+
"- Delegate when you are orchestrating a large to-do/plan execution.",
|
|
262
|
+
"",
|
|
263
|
+
);
|
|
241
264
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
- Run the narrowest verifier or test as soon as there is a plausible first implementation or artifact. Do not delay the first verification run until the end.
|
|
245
|
-
- When verification is down to a small number of failures, stop broad exploration and focus only on the remaining failing assertions or exact contract gaps until the last detail passes.
|
|
246
|
-
- If you encounter an error, diagnose and fix it rather than reporting it and stopping.
|
|
247
|
-
- Before concluding, run the smallest targeted verification that checks the exact contract: required files exist, names and signatures match, outputs are in the required format, and no forbidden extra artifacts were left behind.
|
|
248
|
-
- Avoid excessive looping: if you're re-reading or re-editing the same files without progress, stop and ask the user.`;
|
|
265
|
+
return lines.join("\n");
|
|
266
|
+
}
|
|
249
267
|
|
|
250
268
|
// ---------------------------------------------------------------------------
|
|
251
269
|
// System prompt assembly
|
|
@@ -255,30 +273,27 @@ Workflow: **inspect with shell → mutate with edit → verify with shell**.
|
|
|
255
273
|
* Build the full system prompt.
|
|
256
274
|
*
|
|
257
275
|
* Assembly order:
|
|
258
|
-
* 1.
|
|
276
|
+
* 1. Core prompt template (including the current environment block)
|
|
259
277
|
* 2. AGENTS.md content (project-specific)
|
|
260
278
|
* 3. Skills catalog (XML)
|
|
261
279
|
* 4. Plugin suffixes
|
|
262
|
-
* 5. Session footer (date, CWD, git)
|
|
263
280
|
*
|
|
264
281
|
* @param opts - Prompt construction options.
|
|
265
282
|
* @returns The assembled system prompt string.
|
|
266
283
|
*/
|
|
267
284
|
export function buildSystemPrompt(opts: BuildSystemPromptOpts): string {
|
|
268
|
-
const sections: string[] = [
|
|
285
|
+
const sections: string[] = [buildCorePrompt(opts)];
|
|
269
286
|
|
|
270
287
|
// 2. AGENTS.md content
|
|
271
288
|
if (opts.agentsMd && opts.agentsMd.length > 0) {
|
|
272
|
-
const agentsSection = [
|
|
273
|
-
"\n# Project Context\n",
|
|
274
|
-
"Project-specific instructions and guidelines:\n",
|
|
275
|
-
];
|
|
289
|
+
const agentsSection = [];
|
|
276
290
|
for (const file of opts.agentsMd) {
|
|
277
|
-
agentsSection.push(`## ${file.path}
|
|
291
|
+
agentsSection.push(`## ${file.path}`);
|
|
292
|
+
agentsSection.push("");
|
|
278
293
|
agentsSection.push(file.content);
|
|
279
294
|
agentsSection.push("");
|
|
280
295
|
}
|
|
281
|
-
sections.push(agentsSection.join("\n"));
|
|
296
|
+
sections.push(agentsSection.join("\n").trimEnd());
|
|
282
297
|
}
|
|
283
298
|
|
|
284
299
|
// 3. Skills catalog
|
|
@@ -294,14 +309,5 @@ export function buildSystemPrompt(opts: BuildSystemPromptOpts): string {
|
|
|
294
309
|
}
|
|
295
310
|
}
|
|
296
311
|
|
|
297
|
-
|
|
298
|
-
const footer: string[] = [];
|
|
299
|
-
footer.push(`Current date: ${opts.date}`);
|
|
300
|
-
footer.push(`Current working directory: ${opts.cwd}`);
|
|
301
|
-
if (opts.git) {
|
|
302
|
-
footer.push(formatGitLine(opts.git));
|
|
303
|
-
}
|
|
304
|
-
sections.push(footer.join("\n"));
|
|
305
|
-
|
|
306
|
-
return sections.join("\n");
|
|
312
|
+
return sections.join("\n\n");
|
|
307
313
|
}
|
package/src/submit.ts
CHANGED
|
@@ -204,12 +204,63 @@ export function resolveRawInput(
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
function recordRawPromptHistory(
|
|
208
|
+
rawInput: string,
|
|
209
|
+
state: Pick<AppState, "db" | "cwd">,
|
|
210
|
+
sessionId: string,
|
|
211
|
+
): void {
|
|
212
|
+
appendPromptHistory(state.db, {
|
|
213
|
+
text: rawInput,
|
|
214
|
+
cwd: state.cwd,
|
|
215
|
+
sessionId,
|
|
216
|
+
});
|
|
217
|
+
truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Queue resolved user content for the next model-request boundary of an active run.
|
|
222
|
+
*
|
|
223
|
+
* The raw prompt is recorded immediately in prompt history, but the model-visible
|
|
224
|
+
* `UserMessage` is only appended to session history when the agent loop consumes it.
|
|
225
|
+
*
|
|
226
|
+
* @param rawInput - Exact raw submitted prompt text.
|
|
227
|
+
* @param content - Resolved model-visible user content.
|
|
228
|
+
* @param state - Mutable application state.
|
|
229
|
+
*/
|
|
230
|
+
export function queueResolvedInput(
|
|
231
|
+
rawInput: string,
|
|
232
|
+
content: UserMessage["content"],
|
|
233
|
+
state: AppState,
|
|
234
|
+
): void {
|
|
235
|
+
if (!state.running) {
|
|
236
|
+
throw new Error("Cannot queue input while no turn is running.");
|
|
237
|
+
}
|
|
238
|
+
if (isEmptyUserContent(content)) {
|
|
239
|
+
throw new Error("Cannot queue empty input.");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const session = ensureSession(state);
|
|
243
|
+
recordRawPromptHistory(rawInput, state, session.id);
|
|
244
|
+
state.queuedUserMessages.push({
|
|
245
|
+
role: "user",
|
|
246
|
+
content,
|
|
247
|
+
timestamp: Date.now(),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
207
251
|
// ---------------------------------------------------------------------------
|
|
208
252
|
// Turn submission
|
|
209
253
|
// ---------------------------------------------------------------------------
|
|
210
254
|
|
|
211
255
|
function handleAgentEvent(event: AgentEvent, state: AppState): void {
|
|
212
256
|
switch (event.type) {
|
|
257
|
+
case "user_message":
|
|
258
|
+
state.messages.push(event.message);
|
|
259
|
+
state.contextTokens = addMessageToContextTokens(
|
|
260
|
+
state.contextTokens,
|
|
261
|
+
event.message,
|
|
262
|
+
);
|
|
263
|
+
break;
|
|
213
264
|
case "assistant_message":
|
|
214
265
|
state.messages.push(event.message);
|
|
215
266
|
state.stats = addMessageToStats(state.stats, event.message);
|
|
@@ -270,12 +321,7 @@ export async function submitResolvedInput(
|
|
|
270
321
|
}
|
|
271
322
|
|
|
272
323
|
const session = ensureSession(state);
|
|
273
|
-
|
|
274
|
-
text: rawInput,
|
|
275
|
-
cwd: state.cwd,
|
|
276
|
-
sessionId: session.id,
|
|
277
|
-
});
|
|
278
|
-
truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
|
|
324
|
+
recordRawPromptHistory(rawInput, state, session.id);
|
|
279
325
|
|
|
280
326
|
const userMessage = {
|
|
281
327
|
role: "user",
|
|
@@ -318,6 +364,7 @@ export async function submitResolvedInput(
|
|
|
318
364
|
apiKey: state.providers.get(state.model.provider),
|
|
319
365
|
effort: state.effort,
|
|
320
366
|
signal: state.abortController.signal,
|
|
367
|
+
takeQueuedUserMessage: () => state.queuedUserMessages.shift() ?? null,
|
|
321
368
|
onEvent: (event) => {
|
|
322
369
|
handleAgentEvent(event, state);
|
|
323
370
|
hooks?.onEvent?.(event, state);
|
package/src/tools.ts
CHANGED
|
@@ -490,9 +490,12 @@ interface ShellOpts {
|
|
|
490
490
|
onUpdate?: ToolUpdateCallback;
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
type ShellProcess = ReturnType<typeof Bun.spawn>;
|
|
494
|
+
|
|
493
495
|
const DEFAULT_MAX_LINES = 1000;
|
|
494
496
|
const DEFAULT_MAX_BYTES = 50_000;
|
|
495
497
|
const SHELL_UPDATE_INTERVAL_MS = 75;
|
|
498
|
+
const SHELL_STREAM_DRAIN_TIMEOUT_MS = 25;
|
|
496
499
|
|
|
497
500
|
/** Format combined stdout/stderr for display in tool results. */
|
|
498
501
|
function formatShellOutput(stdout: string, stderr: string): string {
|
|
@@ -837,26 +840,132 @@ function normalizeShellCommand(command: string): string {
|
|
|
837
840
|
}
|
|
838
841
|
}
|
|
839
842
|
|
|
840
|
-
|
|
841
|
-
|
|
843
|
+
interface ShellStreamCapture {
|
|
844
|
+
done: Promise<void>;
|
|
845
|
+
getOutput: () => string;
|
|
846
|
+
isFinished: () => boolean;
|
|
847
|
+
close: () => Promise<void>;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function startShellStreamCapture(
|
|
842
851
|
stream: ReadableStream<Uint8Array>,
|
|
843
852
|
onChunk: (chunk: string) => void,
|
|
844
|
-
):
|
|
853
|
+
): ShellStreamCapture {
|
|
845
854
|
const reader = stream.getReader();
|
|
846
855
|
const decoder = new TextDecoder();
|
|
847
856
|
let output = "";
|
|
857
|
+
let closed = false;
|
|
858
|
+
let finished = false;
|
|
859
|
+
|
|
860
|
+
const done = (async (): Promise<void> => {
|
|
861
|
+
try {
|
|
862
|
+
while (true) {
|
|
863
|
+
const { done, value } = await reader.read();
|
|
864
|
+
if (done) {
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
869
|
+
output += chunk;
|
|
870
|
+
onChunk(chunk);
|
|
871
|
+
}
|
|
872
|
+
} catch (error) {
|
|
873
|
+
if (!closed) {
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
} finally {
|
|
877
|
+
const trailing = decoder.decode();
|
|
878
|
+
output += trailing;
|
|
879
|
+
onChunk(trailing);
|
|
880
|
+
finished = true;
|
|
881
|
+
}
|
|
882
|
+
})();
|
|
848
883
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
884
|
+
return {
|
|
885
|
+
done,
|
|
886
|
+
getOutput: () => output,
|
|
887
|
+
isFinished: () => finished,
|
|
888
|
+
close: async (): Promise<void> => {
|
|
889
|
+
if (!finished) {
|
|
890
|
+
closed = true;
|
|
891
|
+
try {
|
|
892
|
+
await reader.cancel();
|
|
893
|
+
} catch {
|
|
894
|
+
// Ignore cancellation errors while closing the pipe after exit/abort.
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
await done;
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function finalizeShellStreamCaptures(
|
|
903
|
+
captures: readonly ShellStreamCapture[],
|
|
904
|
+
): Promise<void> {
|
|
905
|
+
const pending = captures
|
|
906
|
+
.filter((capture) => !capture.isFinished())
|
|
907
|
+
.map((capture) => capture.done);
|
|
908
|
+
|
|
909
|
+
if (pending.length > 0) {
|
|
910
|
+
await new Promise<void>((resolve) => {
|
|
911
|
+
const timer = setTimeout(resolve, SHELL_STREAM_DRAIN_TIMEOUT_MS);
|
|
912
|
+
void Promise.allSettled(pending).then(() => {
|
|
913
|
+
clearTimeout(timer);
|
|
914
|
+
resolve();
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
await Promise.all(captures.map((capture) => capture.close()));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function buildShellSpawnOptions(cwd: string): Parameters<typeof Bun.spawn>[1] {
|
|
923
|
+
return {
|
|
924
|
+
cwd,
|
|
925
|
+
stdout: "pipe",
|
|
926
|
+
stderr: "pipe",
|
|
927
|
+
...(process.platform === "win32" ? {} : { detached: true }),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function abortShellProcess(proc: ShellProcess): void {
|
|
932
|
+
if (proc.killed || proc.exitCode !== null) {
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (process.platform !== "win32") {
|
|
937
|
+
try {
|
|
938
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
939
|
+
return;
|
|
940
|
+
} catch {
|
|
941
|
+
// Fall through to a direct kill when the process group is unavailable.
|
|
854
942
|
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
proc.kill("SIGTERM");
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function registerShellAbort(
|
|
949
|
+
signal: AbortSignal | undefined,
|
|
950
|
+
proc: ShellProcess,
|
|
951
|
+
): (() => void) | null {
|
|
952
|
+
if (!signal) {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const abortListener = (): void => {
|
|
957
|
+
abortShellProcess(proc);
|
|
958
|
+
};
|
|
855
959
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
960
|
+
if (signal.aborted) {
|
|
961
|
+
abortShellProcess(proc);
|
|
962
|
+
return null;
|
|
859
963
|
}
|
|
964
|
+
|
|
965
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
966
|
+
return () => {
|
|
967
|
+
signal.removeEventListener("abort", abortListener);
|
|
968
|
+
};
|
|
860
969
|
}
|
|
861
970
|
|
|
862
971
|
/**
|
|
@@ -880,22 +989,13 @@ export async function executeShell(
|
|
|
880
989
|
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
|
|
881
990
|
const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
882
991
|
let updateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
992
|
+
let cleanupAbort: (() => void) | null = null;
|
|
993
|
+
let lastReportedOutput = "";
|
|
994
|
+
let lastReportAt = 0;
|
|
995
|
+
let stdoutCapture: ShellStreamCapture | null = null;
|
|
996
|
+
let stderrCapture: ShellStreamCapture | null = null;
|
|
883
997
|
|
|
884
998
|
try {
|
|
885
|
-
const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
|
|
886
|
-
cwd,
|
|
887
|
-
stdout: "pipe",
|
|
888
|
-
stderr: "pipe",
|
|
889
|
-
};
|
|
890
|
-
if (opts?.signal) spawnOpts.signal = opts.signal;
|
|
891
|
-
const command = normalizeShellCommand(args.command);
|
|
892
|
-
const proc = Bun.spawn([shell, "-c", command], spawnOpts);
|
|
893
|
-
|
|
894
|
-
let stdoutBuf = "";
|
|
895
|
-
let stderrBuf = "";
|
|
896
|
-
let lastReportedOutput = "";
|
|
897
|
-
let lastReportAt = 0;
|
|
898
|
-
|
|
899
999
|
const clearPendingUpdate = (): void => {
|
|
900
1000
|
if (updateTimer) {
|
|
901
1001
|
clearTimeout(updateTimer);
|
|
@@ -903,9 +1003,14 @@ export async function executeShell(
|
|
|
903
1003
|
}
|
|
904
1004
|
};
|
|
905
1005
|
|
|
906
|
-
const
|
|
1006
|
+
const buildOutput = (trimEnd: boolean): string => {
|
|
1007
|
+
const stdout = stdoutCapture?.getOutput() ?? "";
|
|
1008
|
+
const stderr = stderrCapture?.getOutput() ?? "";
|
|
907
1009
|
return truncateOutput(
|
|
908
|
-
formatShellOutput(
|
|
1010
|
+
formatShellOutput(
|
|
1011
|
+
trimEnd ? stdout.trimEnd() : stdout,
|
|
1012
|
+
trimEnd ? stderr.trimEnd() : stderr,
|
|
1013
|
+
),
|
|
909
1014
|
maxLines,
|
|
910
1015
|
maxBytes,
|
|
911
1016
|
);
|
|
@@ -917,7 +1022,7 @@ export async function executeShell(
|
|
|
917
1022
|
return;
|
|
918
1023
|
}
|
|
919
1024
|
|
|
920
|
-
const output =
|
|
1025
|
+
const output = buildOutput(false);
|
|
921
1026
|
if (!output || output === lastReportedOutput) {
|
|
922
1027
|
return;
|
|
923
1028
|
}
|
|
@@ -946,24 +1051,29 @@ export async function executeShell(
|
|
|
946
1051
|
}, SHELL_UPDATE_INTERVAL_MS - elapsed);
|
|
947
1052
|
};
|
|
948
1053
|
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
1054
|
+
const command = normalizeShellCommand(args.command);
|
|
1055
|
+
const proc = Bun.spawn([shell, "-c", command], buildShellSpawnOptions(cwd));
|
|
1056
|
+
cleanupAbort = registerShellAbort(opts?.signal, proc);
|
|
1057
|
+
stdoutCapture = startShellStreamCapture(
|
|
1058
|
+
proc.stdout as ReadableStream<Uint8Array>,
|
|
1059
|
+
() => {
|
|
952
1060
|
scheduleUpdate();
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
|
|
1061
|
+
},
|
|
1062
|
+
);
|
|
1063
|
+
stderrCapture = startShellStreamCapture(
|
|
1064
|
+
proc.stderr as ReadableStream<Uint8Array>,
|
|
1065
|
+
() => {
|
|
956
1066
|
scheduleUpdate();
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
|
|
1067
|
+
},
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
const exitCode = await proc.exited;
|
|
1071
|
+
cleanupAbort?.();
|
|
1072
|
+
cleanupAbort = null;
|
|
960
1073
|
|
|
1074
|
+
await finalizeShellStreamCaptures([stdoutCapture, stderrCapture]);
|
|
961
1075
|
clearPendingUpdate();
|
|
962
|
-
const output =
|
|
963
|
-
formatShellOutput(stdout.trimEnd(), stderr.trimEnd()),
|
|
964
|
-
maxLines,
|
|
965
|
-
maxBytes,
|
|
966
|
-
);
|
|
1076
|
+
const output = buildOutput(true);
|
|
967
1077
|
if (opts?.onUpdate && output && output !== lastReportedOutput) {
|
|
968
1078
|
lastReportedOutput = output;
|
|
969
1079
|
opts.onUpdate(textResult(output, false));
|
|
@@ -973,6 +1083,14 @@ export async function executeShell(
|
|
|
973
1083
|
const body = output || "(no output)";
|
|
974
1084
|
return textResult(`Exit code: ${exitCode}\n${body}`, isError);
|
|
975
1085
|
} catch (err) {
|
|
1086
|
+
cleanupAbort?.();
|
|
1087
|
+
cleanupAbort = null;
|
|
1088
|
+
const captures = [stdoutCapture, stderrCapture].filter(
|
|
1089
|
+
(capture): capture is ShellStreamCapture => capture !== null,
|
|
1090
|
+
);
|
|
1091
|
+
if (captures.length > 0) {
|
|
1092
|
+
await Promise.allSettled(captures.map((capture) => capture.close()));
|
|
1093
|
+
}
|
|
976
1094
|
if (updateTimer) {
|
|
977
1095
|
clearTimeout(updateTimer);
|
|
978
1096
|
updateTimer = null;
|
package/src/ui/agent.ts
CHANGED
|
@@ -13,7 +13,11 @@ import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
|
13
13
|
import type { AgentEvent } from "../agent.ts";
|
|
14
14
|
import { getErrorMessage } from "../errors.ts";
|
|
15
15
|
import type { AppState } from "../index.ts";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
queueResolvedInput,
|
|
18
|
+
resolveRawInput,
|
|
19
|
+
submitResolvedInput,
|
|
20
|
+
} from "../submit.ts";
|
|
17
21
|
import type {
|
|
18
22
|
PendingToolResult,
|
|
19
23
|
StreamingConversationState,
|
|
@@ -103,7 +107,12 @@ export function createUiAgentController(
|
|
|
103
107
|
break;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
if (
|
|
110
|
+
if (state.running) {
|
|
111
|
+
queueResolvedInput(rawInput, resolved.content, state);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!state.model) {
|
|
107
116
|
return;
|
|
108
117
|
}
|
|
109
118
|
|
|
@@ -154,6 +163,11 @@ export function createUiAgentController(
|
|
|
154
163
|
runtime.render();
|
|
155
164
|
break;
|
|
156
165
|
|
|
166
|
+
case "user_message":
|
|
167
|
+
runtime.scrollConversationToBottom();
|
|
168
|
+
runtime.render();
|
|
169
|
+
break;
|
|
170
|
+
|
|
157
171
|
case "assistant_message":
|
|
158
172
|
streamingContent = [];
|
|
159
173
|
runtime.render();
|
package/src/ui/commands.test.ts
CHANGED
package/src/ui/input.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { DEFAULT_THEME } from "../theme.ts";
|
|
6
6
|
import {
|
|
7
7
|
autocompleteInputPath,
|
|
8
|
+
findInputPathMatches,
|
|
8
9
|
type InputController,
|
|
9
10
|
renderInputArea,
|
|
10
11
|
} from "./input.ts";
|
|
@@ -60,7 +61,7 @@ describe("ui/input", () => {
|
|
|
60
61
|
expect(placeholder.props.italic).toBe(true);
|
|
61
62
|
});
|
|
62
63
|
|
|
63
|
-
test("autocompleteInputPath completes the last file path token", () => {
|
|
64
|
+
test("autocompleteInputPath completes the last file path token when exactly one match is available", () => {
|
|
64
65
|
const cwd = createTempDir();
|
|
65
66
|
mkdirSync(join(cwd, "src"), { recursive: true });
|
|
66
67
|
writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
|
|
@@ -70,6 +71,33 @@ describe("ui/input", () => {
|
|
|
70
71
|
);
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
test("autocompleteInputPath returns null when multiple matches are available", () => {
|
|
75
|
+
const cwd = createTempDir();
|
|
76
|
+
mkdirSync(join(cwd, "src"), { recursive: true });
|
|
77
|
+
writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
|
|
78
|
+
writeFileSync(join(cwd, "src", "utils.ts"), "", "utf-8");
|
|
79
|
+
|
|
80
|
+
expect(autocompleteInputPath("inspect src/u", cwd)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("findInputPathMatches returns sorted selectable matches for the last file path token", () => {
|
|
84
|
+
const cwd = createTempDir();
|
|
85
|
+
mkdirSync(join(cwd, "src"), { recursive: true });
|
|
86
|
+
writeFileSync(join(cwd, "src", "alpine.ts"), "", "utf-8");
|
|
87
|
+
writeFileSync(join(cwd, "src", "alpha.ts"), "", "utf-8");
|
|
88
|
+
|
|
89
|
+
expect(findInputPathMatches("inspect src/al", cwd)).toEqual([
|
|
90
|
+
{
|
|
91
|
+
label: "src/alpha.ts",
|
|
92
|
+
value: "inspect src/alpha.ts",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: "src/alpine.ts",
|
|
96
|
+
value: "inspect src/alpine.ts",
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
73
101
|
test("autocompleteInputPath returns null when no completion is available", () => {
|
|
74
102
|
const cwd = createTempDir();
|
|
75
103
|
|
package/src/ui/input.ts
CHANGED
|
@@ -25,39 +25,18 @@ export interface InputController {
|
|
|
25
25
|
onKeyPress: (key: string) => boolean | undefined;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
let prefix = values[0]!;
|
|
35
|
-
for (let i = 1; i < values.length && prefix.length > 0; i++) {
|
|
36
|
-
const value = values[i]!;
|
|
37
|
-
let j = 0;
|
|
38
|
-
while (j < prefix.length && j < value.length && prefix[j] === value[j]) {
|
|
39
|
-
j++;
|
|
40
|
-
}
|
|
41
|
-
prefix = prefix.slice(0, j);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return prefix;
|
|
28
|
+
/** A selectable path-autocomplete match for the current input draft. */
|
|
29
|
+
export interface InputPathMatch {
|
|
30
|
+
/** Path label shown in the overlay. */
|
|
31
|
+
label: string;
|
|
32
|
+
/** Full draft value to apply when this match is selected. */
|
|
33
|
+
value: string;
|
|
45
34
|
}
|
|
46
35
|
|
|
47
|
-
|
|
48
|
-
* Attempt to autocomplete the final path token in the current draft.
|
|
49
|
-
*
|
|
50
|
-
* @param value - Current input draft.
|
|
51
|
-
* @param cwd - Working directory used to resolve relative paths.
|
|
52
|
-
* @returns The completed input value, or `null` when no completion is available.
|
|
53
|
-
*/
|
|
54
|
-
export function autocompleteInputPath(
|
|
55
|
-
value: string,
|
|
56
|
-
cwd: string,
|
|
57
|
-
): string | null {
|
|
36
|
+
function listInputPathMatches(value: string, cwd: string): InputPathMatch[] {
|
|
58
37
|
const tokenMatch = /(^|\s)(\S+)$/.exec(value);
|
|
59
38
|
if (!tokenMatch?.[2]) {
|
|
60
|
-
return
|
|
39
|
+
return [];
|
|
61
40
|
}
|
|
62
41
|
|
|
63
42
|
const token = tokenMatch[2];
|
|
@@ -80,35 +59,50 @@ export function autocompleteInputPath(
|
|
|
80
59
|
}
|
|
81
60
|
})();
|
|
82
61
|
if (!entries) {
|
|
83
|
-
return
|
|
62
|
+
return [];
|
|
84
63
|
}
|
|
85
64
|
|
|
86
65
|
const showHidden = partial.startsWith(".");
|
|
87
|
-
|
|
66
|
+
return entries
|
|
88
67
|
.filter((entry) => (showHidden ? true : !entry.name.startsWith(".")))
|
|
89
68
|
.filter((entry) => entry.name.startsWith(partial))
|
|
90
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
completedName = `${match.name}${match.isDirectory() ? "/" : ""}`;
|
|
100
|
-
} else {
|
|
101
|
-
const prefix = getLongestCommonPrefix(matches.map((entry) => entry.name));
|
|
102
|
-
if (prefix.length > partial.length) {
|
|
103
|
-
completedName = prefix;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
69
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
70
|
+
.map((entry) => {
|
|
71
|
+
const completedPath = `${dirToken}${entry.name}${entry.isDirectory() ? "/" : ""}`;
|
|
72
|
+
return {
|
|
73
|
+
label: completedPath,
|
|
74
|
+
value: `${value.slice(0, tokenStart)}${completedPath}`,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
106
78
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Find selectable path matches for the final path token in the current draft.
|
|
81
|
+
*
|
|
82
|
+
* @param value - Current input draft.
|
|
83
|
+
* @param cwd - Working directory used to resolve relative paths.
|
|
84
|
+
* @returns Matching path completions with their applied draft values.
|
|
85
|
+
*/
|
|
86
|
+
export function findInputPathMatches(
|
|
87
|
+
value: string,
|
|
88
|
+
cwd: string,
|
|
89
|
+
): InputPathMatch[] {
|
|
90
|
+
return listInputPathMatches(value, cwd);
|
|
91
|
+
}
|
|
110
92
|
|
|
111
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Attempt to autocomplete the final path token in the current draft.
|
|
95
|
+
*
|
|
96
|
+
* @param value - Current input draft.
|
|
97
|
+
* @param cwd - Working directory used to resolve relative paths.
|
|
98
|
+
* @returns The completed input value, or `null` when direct completion is unavailable.
|
|
99
|
+
*/
|
|
100
|
+
export function autocompleteInputPath(
|
|
101
|
+
value: string,
|
|
102
|
+
cwd: string,
|
|
103
|
+
): string | null {
|
|
104
|
+
const matches = listInputPathMatches(value, cwd);
|
|
105
|
+
return matches.length === 1 ? matches[0]!.value : null;
|
|
112
106
|
}
|
|
113
107
|
|
|
114
108
|
/**
|
package/src/ui/status.test.ts
CHANGED
package/src/ui.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
import { platform } from "node:os";
|
|
13
|
+
import { Select } from "@cel-tui/components";
|
|
13
14
|
import {
|
|
14
15
|
cel,
|
|
15
16
|
HStack,
|
|
@@ -37,9 +38,14 @@ import {
|
|
|
37
38
|
import type { InputController } from "./ui/input.ts";
|
|
38
39
|
import {
|
|
39
40
|
autocompleteInputPath,
|
|
41
|
+
findInputPathMatches,
|
|
40
42
|
renderInputArea as renderInputAreaNode,
|
|
41
43
|
} from "./ui/input.ts";
|
|
42
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
type ActiveOverlay,
|
|
46
|
+
OVERLAY_MAX_VISIBLE,
|
|
47
|
+
renderOverlay,
|
|
48
|
+
} from "./ui/overlay.ts";
|
|
43
49
|
import { renderStatusBar } from "./ui/status.ts";
|
|
44
50
|
|
|
45
51
|
export type { InputController } from "./ui/input.ts";
|
|
@@ -286,6 +292,48 @@ function dismissOverlay(): void {
|
|
|
286
292
|
cel.render();
|
|
287
293
|
}
|
|
288
294
|
|
|
295
|
+
function openPathAutocompleteOverlay(state: AppState): void {
|
|
296
|
+
const matches = findInputPathMatches(inputValue, state.cwd);
|
|
297
|
+
if (matches.length <= 1) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const select = Select({
|
|
302
|
+
items: matches.map((match) => ({
|
|
303
|
+
label: match.label,
|
|
304
|
+
value: match.value,
|
|
305
|
+
filterText: match.label,
|
|
306
|
+
})),
|
|
307
|
+
maxVisible: OVERLAY_MAX_VISIBLE,
|
|
308
|
+
placeholder: "type to filter paths...",
|
|
309
|
+
focused: true,
|
|
310
|
+
highlightColor: state.theme.accentText,
|
|
311
|
+
onSelect: (value) => {
|
|
312
|
+
inputValue = value;
|
|
313
|
+
dismissOverlay();
|
|
314
|
+
},
|
|
315
|
+
onBlur: dismissOverlay,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
openOverlay({ select, title: "Path matches" });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function handleTabKeyPress(state: AppState): void {
|
|
322
|
+
if (inputValue.startsWith("/")) {
|
|
323
|
+
commandController.showCommandAutocomplete(state);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const completedInput = autocompleteInputPath(inputValue, state.cwd);
|
|
328
|
+
if (completedInput) {
|
|
329
|
+
inputValue = completedInput;
|
|
330
|
+
cel.render();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
openPathAutocompleteOverlay(state);
|
|
335
|
+
}
|
|
336
|
+
|
|
289
337
|
/**
|
|
290
338
|
* Render the active overlay when one is open.
|
|
291
339
|
*
|
|
@@ -344,15 +392,7 @@ export function createInputController(state: AppState): InputController {
|
|
|
344
392
|
return false;
|
|
345
393
|
}
|
|
346
394
|
if (key === "tab") {
|
|
347
|
-
|
|
348
|
-
commandController.showCommandAutocomplete(state);
|
|
349
|
-
} else {
|
|
350
|
-
const completedInput = autocompleteInputPath(inputValue, state.cwd);
|
|
351
|
-
if (completedInput) {
|
|
352
|
-
inputValue = completedInput;
|
|
353
|
-
cel.render();
|
|
354
|
-
}
|
|
355
|
-
}
|
|
395
|
+
handleTabKeyPress(state);
|
|
356
396
|
return false;
|
|
357
397
|
}
|
|
358
398
|
},
|