mini-coder 0.5.0 → 0.5.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 +2 -0
- package/package.json +1 -1
- package/src/agent.ts +49 -1
- package/src/git.ts +8 -1
- package/src/index.ts +30 -9
- package/src/prompt.ts +12 -5
- package/src/session.ts +211 -6
- package/src/settings.ts +10 -3
- package/src/skills.ts +11 -7
- package/src/ui/help.test.ts +18 -0
- package/src/ui/help.ts +6 -0
- package/src/ui/status.test.ts +1 -0
- package/src/ui.ts +90 -11
package/README.md
CHANGED
|
@@ -83,6 +83,8 @@ Plugins can add more tools, but the core stays intentionally small.
|
|
|
83
83
|
| `Tab` | File path autocomplete (or command filter on `/`) |
|
|
84
84
|
| `Ctrl+R` | Search global raw input history |
|
|
85
85
|
| `Ctrl+C` | Graceful exit |
|
|
86
|
+
| `Ctrl+D` | Graceful exit (EOF, when input is empty) |
|
|
87
|
+
| `:q` | Graceful exit |
|
|
86
88
|
|
|
87
89
|
## Headless one-shot mode
|
|
88
90
|
|
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -376,6 +376,44 @@ function handleAssistantStreamEvent(
|
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
function buildIncompleteAssistantMessage(
|
|
380
|
+
opts: Pick<RunAgentOpts, "model" | "signal">,
|
|
381
|
+
partialAssistantMessage?: AssistantMessage,
|
|
382
|
+
): AssistantMessage {
|
|
383
|
+
const stopReason = opts.signal?.aborted ? "aborted" : "error";
|
|
384
|
+
const errorMessage =
|
|
385
|
+
stopReason === "aborted"
|
|
386
|
+
? "Request was aborted"
|
|
387
|
+
: "Stream ended without a final assistant message";
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
role: "assistant",
|
|
391
|
+
content: partialAssistantMessage
|
|
392
|
+
? cloneAssistantContent(partialAssistantMessage.content)
|
|
393
|
+
: [],
|
|
394
|
+
api: partialAssistantMessage?.api ?? opts.model.api,
|
|
395
|
+
provider: partialAssistantMessage?.provider ?? opts.model.provider,
|
|
396
|
+
model: partialAssistantMessage?.model ?? opts.model.id,
|
|
397
|
+
usage: partialAssistantMessage?.usage ?? {
|
|
398
|
+
input: 0,
|
|
399
|
+
output: 0,
|
|
400
|
+
cacheRead: 0,
|
|
401
|
+
cacheWrite: 0,
|
|
402
|
+
totalTokens: 0,
|
|
403
|
+
cost: {
|
|
404
|
+
input: 0,
|
|
405
|
+
output: 0,
|
|
406
|
+
cacheRead: 0,
|
|
407
|
+
cacheWrite: 0,
|
|
408
|
+
total: 0,
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
stopReason,
|
|
412
|
+
errorMessage,
|
|
413
|
+
timestamp: Date.now(),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
379
417
|
async function streamAssistantMessage(
|
|
380
418
|
opts: Pick<
|
|
381
419
|
RunAgentOpts,
|
|
@@ -394,6 +432,12 @@ async function streamAssistantMessage(
|
|
|
394
432
|
buildAgentContext(opts.systemPrompt, opts.messages, opts.tools),
|
|
395
433
|
buildStreamOptions(opts.apiKey, opts.effort, opts.signal),
|
|
396
434
|
);
|
|
435
|
+
const streamResult = eventStream.result();
|
|
436
|
+
let settledStreamResult: AssistantMessage | undefined;
|
|
437
|
+
void streamResult.then((message) => {
|
|
438
|
+
settledStreamResult = message;
|
|
439
|
+
});
|
|
440
|
+
|
|
397
441
|
let assistantMessage: AssistantMessage | undefined;
|
|
398
442
|
let partialAssistantMessage: AssistantMessage | undefined;
|
|
399
443
|
|
|
@@ -406,8 +450,12 @@ async function streamAssistantMessage(
|
|
|
406
450
|
handleAssistantStreamEvent(event, opts.onEvent) ?? assistantMessage;
|
|
407
451
|
}
|
|
408
452
|
|
|
453
|
+
// `end(result)` resolves the final result without emitting a terminal event.
|
|
454
|
+
await Promise.resolve();
|
|
409
455
|
const finalAssistantMessage =
|
|
410
|
-
assistantMessage ??
|
|
456
|
+
assistantMessage ??
|
|
457
|
+
settledStreamResult ??
|
|
458
|
+
buildIncompleteAssistantMessage(opts, partialAssistantMessage);
|
|
411
459
|
if (!partialAssistantMessage) {
|
|
412
460
|
return finalAssistantMessage;
|
|
413
461
|
}
|
package/src/git.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface GitState {
|
|
|
23
23
|
root: string;
|
|
24
24
|
/** Current branch name (empty string for detached HEAD). */
|
|
25
25
|
branch: string;
|
|
26
|
+
/** Upstream tracking ref such as `origin/main`, or `null` when none exists. */
|
|
27
|
+
upstream: string | null;
|
|
26
28
|
/** Number of staged (index) changes. */
|
|
27
29
|
staged: number;
|
|
28
30
|
/** Number of unstaged working-tree modifications. */
|
|
@@ -135,8 +137,12 @@ export async function getGitState(cwd: string): Promise<GitState | null> {
|
|
|
135
137
|
if (root === null) return null;
|
|
136
138
|
|
|
137
139
|
// Run remaining commands in parallel
|
|
138
|
-
const [branch, status, revList] = await Promise.all([
|
|
140
|
+
const [branch, upstream, status, revList] = await Promise.all([
|
|
139
141
|
run(["branch", "--show-current"], cwd),
|
|
142
|
+
run(
|
|
143
|
+
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
144
|
+
cwd,
|
|
145
|
+
),
|
|
140
146
|
run(["status", "--porcelain"], cwd, false),
|
|
141
147
|
run(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], cwd),
|
|
142
148
|
]);
|
|
@@ -155,6 +161,7 @@ export async function getGitState(cwd: string): Promise<GitState | null> {
|
|
|
155
161
|
return {
|
|
156
162
|
root,
|
|
157
163
|
branch: branch ?? "",
|
|
164
|
+
upstream,
|
|
158
165
|
staged,
|
|
159
166
|
modified,
|
|
160
167
|
untracked,
|
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 { join } from "node:path";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
14
|
import { isDeepStrictEqual } from "node:util";
|
|
15
15
|
import type {
|
|
16
16
|
KnownProvider,
|
|
@@ -98,24 +98,45 @@ export const MAX_SESSIONS_PER_CWD = 20;
|
|
|
98
98
|
/** Maximum raw prompt-history entries to retain globally. */
|
|
99
99
|
export const MAX_PROMPT_HISTORY = 1_000;
|
|
100
100
|
|
|
101
|
+
function getErrorMessage(error: unknown): string {
|
|
102
|
+
return error instanceof Error ? error.message : String(error);
|
|
103
|
+
}
|
|
104
|
+
|
|
101
105
|
// ---------------------------------------------------------------------------
|
|
102
106
|
// OAuth credential persistence
|
|
103
107
|
// ---------------------------------------------------------------------------
|
|
104
108
|
|
|
105
109
|
/** Load saved OAuth credentials from disk. */
|
|
106
|
-
function loadOAuthCredentials(
|
|
107
|
-
|
|
110
|
+
function loadOAuthCredentials(
|
|
111
|
+
path = AUTH_PATH,
|
|
112
|
+
): Record<string, OAuthCredentials> {
|
|
113
|
+
if (!existsSync(path)) return {};
|
|
114
|
+
|
|
115
|
+
let parsed: unknown;
|
|
108
116
|
try {
|
|
109
|
-
|
|
110
|
-
} catch {
|
|
111
|
-
|
|
117
|
+
parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Failed to read OAuth credentials ${path}: ${getErrorMessage(error)}`,
|
|
121
|
+
);
|
|
112
122
|
}
|
|
123
|
+
|
|
124
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Failed to read OAuth credentials ${path}: expected a JSON object`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return parsed as Record<string, OAuthCredentials>;
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
/** Save OAuth credentials to disk. */
|
|
116
|
-
function saveOAuthCredentials(
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
function saveOAuthCredentials(
|
|
135
|
+
creds: Record<string, OAuthCredentials>,
|
|
136
|
+
path = AUTH_PATH,
|
|
137
|
+
): void {
|
|
138
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
139
|
+
writeFileSync(path, JSON.stringify(creds, null, 2), "utf-8");
|
|
119
140
|
}
|
|
120
141
|
|
|
121
142
|
/** Return whether refreshed OAuth credentials differ from the persisted value. */
|
package/src/prompt.ts
CHANGED
|
@@ -99,10 +99,15 @@ function readAgentsMdFile(dir: string): AgentsMdFile | null {
|
|
|
99
99
|
if (!existsSync(filePath)) {
|
|
100
100
|
return null;
|
|
101
101
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
return {
|
|
105
|
+
path: filePath,
|
|
106
|
+
content: readFileSync(filePath, "utf-8"),
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
/**
|
|
@@ -152,6 +157,7 @@ export function discoverAgentsMd(
|
|
|
152
157
|
*
|
|
153
158
|
* Fields are omitted when their values are zero. The git line format:
|
|
154
159
|
* `Git: branch main | 3 staged, 1 modified, 2 untracked | +5 −2 vs origin/main`
|
|
160
|
+
* where the trailing upstream label reflects the repository's actual tracking ref.
|
|
155
161
|
*
|
|
156
162
|
* @param state - The git state to format.
|
|
157
163
|
* @returns Formatted git status line.
|
|
@@ -171,7 +177,8 @@ export function formatGitLine(state: GitState): string {
|
|
|
171
177
|
const ab: string[] = [];
|
|
172
178
|
if (state.ahead > 0) ab.push(`+${state.ahead}`);
|
|
173
179
|
if (state.behind > 0) ab.push(`\u2212${state.behind}`);
|
|
174
|
-
|
|
180
|
+
const upstream = state.upstream ? ` vs ${state.upstream}` : "";
|
|
181
|
+
parts.push(`${ab.join(" ")}${upstream}`);
|
|
175
182
|
}
|
|
176
183
|
|
|
177
184
|
return parts.join(" | ");
|
package/src/session.ts
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { Database } from "bun:sqlite";
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
AssistantMessage,
|
|
15
|
+
Message,
|
|
16
|
+
ToolResultMessage,
|
|
17
|
+
UserMessage,
|
|
18
|
+
} from "@mariozechner/pi-ai";
|
|
14
19
|
|
|
15
20
|
// ---------------------------------------------------------------------------
|
|
16
21
|
// Types
|
|
@@ -140,6 +145,21 @@ type MaxTurnRow = { max_turn: number | null };
|
|
|
140
145
|
/** Row shape for `SELECT data` queries. */
|
|
141
146
|
type DataRow = { data: string };
|
|
142
147
|
|
|
148
|
+
const EMPTY_ASSISTANT_USAGE: AssistantMessage["usage"] = {
|
|
149
|
+
input: 0,
|
|
150
|
+
output: 0,
|
|
151
|
+
cacheRead: 0,
|
|
152
|
+
cacheWrite: 0,
|
|
153
|
+
totalTokens: 0,
|
|
154
|
+
cost: {
|
|
155
|
+
input: 0,
|
|
156
|
+
output: 0,
|
|
157
|
+
cacheRead: 0,
|
|
158
|
+
cacheWrite: 0,
|
|
159
|
+
total: 0,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
143
163
|
/** Row shape returned by `SELECT * FROM prompt_history`. */
|
|
144
164
|
type PromptHistoryRow = {
|
|
145
165
|
id: number;
|
|
@@ -319,14 +339,189 @@ function getMultipartUserPreview(
|
|
|
319
339
|
return collapsePreviewText(text);
|
|
320
340
|
}
|
|
321
341
|
|
|
342
|
+
function isTextContentBlock(
|
|
343
|
+
value: unknown,
|
|
344
|
+
): value is { type: "text"; text: string } {
|
|
345
|
+
const record = toRecord(value);
|
|
346
|
+
return record?.type === "text" && typeof record.text === "string";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function isImageContentBlock(
|
|
350
|
+
value: unknown,
|
|
351
|
+
): value is { type: "image"; data: string; mimeType: string } {
|
|
352
|
+
const record = toRecord(value);
|
|
353
|
+
return (
|
|
354
|
+
record?.type === "image" &&
|
|
355
|
+
typeof record.data === "string" &&
|
|
356
|
+
typeof record.mimeType === "string"
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isThinkingContentBlock(
|
|
361
|
+
value: unknown,
|
|
362
|
+
): value is Extract<AssistantMessage["content"][number], { type: "thinking" }> {
|
|
363
|
+
const record = toRecord(value);
|
|
364
|
+
return record?.type === "thinking" && typeof record.thinking === "string";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function isToolCallContentBlock(
|
|
368
|
+
value: unknown,
|
|
369
|
+
): value is Extract<AssistantMessage["content"][number], { type: "toolCall" }> {
|
|
370
|
+
const record = toRecord(value);
|
|
371
|
+
return (
|
|
372
|
+
record?.type === "toolCall" &&
|
|
373
|
+
typeof record.id === "string" &&
|
|
374
|
+
typeof record.name === "string" &&
|
|
375
|
+
toRecord(record.arguments) !== null
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function isAssistantUsage(value: unknown): value is AssistantMessage["usage"] {
|
|
380
|
+
const usageRecord = toRecord(value);
|
|
381
|
+
const costRecord = toRecord(usageRecord?.cost);
|
|
382
|
+
return (
|
|
383
|
+
usageRecord !== null &&
|
|
384
|
+
costRecord !== null &&
|
|
385
|
+
readFiniteNumber(usageRecord, "input") !== null &&
|
|
386
|
+
readFiniteNumber(usageRecord, "output") !== null &&
|
|
387
|
+
readFiniteNumber(usageRecord, "cacheRead") !== null &&
|
|
388
|
+
readFiniteNumber(usageRecord, "cacheWrite") !== null &&
|
|
389
|
+
readFiniteNumber(usageRecord, "totalTokens") !== null &&
|
|
390
|
+
readFiniteNumber(costRecord, "input") !== null &&
|
|
391
|
+
readFiniteNumber(costRecord, "output") !== null &&
|
|
392
|
+
readFiniteNumber(costRecord, "cacheRead") !== null &&
|
|
393
|
+
readFiniteNumber(costRecord, "cacheWrite") !== null &&
|
|
394
|
+
readFiniteNumber(costRecord, "total") !== null
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isStopReason(value: unknown): value is AssistantMessage["stopReason"] {
|
|
399
|
+
return (
|
|
400
|
+
value === "stop" ||
|
|
401
|
+
value === "length" ||
|
|
402
|
+
value === "toolUse" ||
|
|
403
|
+
value === "error" ||
|
|
404
|
+
value === "aborted"
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isUserMessageRecord(value: unknown): value is UserMessage {
|
|
409
|
+
const record = toRecord(value);
|
|
410
|
+
if (!record || record.role !== "user") {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
readFiniteNumber(record, "timestamp") !== null &&
|
|
416
|
+
(typeof record.content === "string" ||
|
|
417
|
+
(Array.isArray(record.content) &&
|
|
418
|
+
record.content.every(
|
|
419
|
+
(block) => isTextContentBlock(block) || isImageContentBlock(block),
|
|
420
|
+
)))
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function parseAssistantMessageRecord(value: unknown): AssistantMessage | null {
|
|
425
|
+
const record = toRecord(value);
|
|
426
|
+
if (!record || record.role !== "assistant") {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const timestamp = readFiniteNumber(record, "timestamp");
|
|
431
|
+
if (
|
|
432
|
+
!Array.isArray(record.content) ||
|
|
433
|
+
!record.content.every(
|
|
434
|
+
(block) =>
|
|
435
|
+
isTextContentBlock(block) ||
|
|
436
|
+
isThinkingContentBlock(block) ||
|
|
437
|
+
isToolCallContentBlock(block),
|
|
438
|
+
) ||
|
|
439
|
+
typeof record.api !== "string" ||
|
|
440
|
+
typeof record.provider !== "string" ||
|
|
441
|
+
typeof record.model !== "string" ||
|
|
442
|
+
!isStopReason(record.stopReason) ||
|
|
443
|
+
(record.errorMessage !== undefined &&
|
|
444
|
+
typeof record.errorMessage !== "string") ||
|
|
445
|
+
timestamp === null
|
|
446
|
+
) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
role: "assistant",
|
|
452
|
+
content: record.content,
|
|
453
|
+
api: record.api,
|
|
454
|
+
provider: record.provider,
|
|
455
|
+
model: record.model,
|
|
456
|
+
usage: isAssistantUsage(record.usage)
|
|
457
|
+
? record.usage
|
|
458
|
+
: structuredClone(EMPTY_ASSISTANT_USAGE),
|
|
459
|
+
stopReason: record.stopReason,
|
|
460
|
+
...(typeof record.errorMessage === "string"
|
|
461
|
+
? { errorMessage: record.errorMessage }
|
|
462
|
+
: {}),
|
|
463
|
+
timestamp,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function isToolResultMessageRecord(value: unknown): value is ToolResultMessage {
|
|
468
|
+
const record = toRecord(value);
|
|
469
|
+
if (!record || record.role !== "toolResult") {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
typeof record.toolCallId === "string" &&
|
|
475
|
+
typeof record.toolName === "string" &&
|
|
476
|
+
typeof record.isError === "boolean" &&
|
|
477
|
+
Array.isArray(record.content) &&
|
|
478
|
+
record.content.every(
|
|
479
|
+
(block) => isTextContentBlock(block) || isImageContentBlock(block),
|
|
480
|
+
) &&
|
|
481
|
+
readFiniteNumber(record, "timestamp") !== null
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function isUiMessageRecord(value: unknown): value is UiMessage {
|
|
486
|
+
const record = toRecord(value);
|
|
487
|
+
if (!record || record.role !== "ui") {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
record.kind === "info" &&
|
|
493
|
+
typeof record.content === "string" &&
|
|
494
|
+
readFiniteNumber(record, "timestamp") !== null
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function parsePersistedMessage(data: string): PersistedMessage | null {
|
|
499
|
+
let parsed: unknown;
|
|
500
|
+
try {
|
|
501
|
+
parsed = JSON.parse(data) as unknown;
|
|
502
|
+
} catch {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (
|
|
507
|
+
isUserMessageRecord(parsed) ||
|
|
508
|
+
isToolResultMessageRecord(parsed) ||
|
|
509
|
+
isUiMessageRecord(parsed)
|
|
510
|
+
) {
|
|
511
|
+
return parsed;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return parseAssistantMessageRecord(parsed);
|
|
515
|
+
}
|
|
516
|
+
|
|
322
517
|
/** Read the first-user preview cached by the session-list query. */
|
|
323
518
|
function readFirstUserPreview(messageData: string | null): string | null {
|
|
324
519
|
if (!messageData) {
|
|
325
520
|
return null;
|
|
326
521
|
}
|
|
327
522
|
|
|
328
|
-
const message =
|
|
329
|
-
if (message.role !== "user") {
|
|
523
|
+
const message = parsePersistedMessage(messageData);
|
|
524
|
+
if (!message || message.role !== "user") {
|
|
330
525
|
return null;
|
|
331
526
|
}
|
|
332
527
|
|
|
@@ -434,7 +629,7 @@ export function filterModelMessages(
|
|
|
434
629
|
}
|
|
435
630
|
|
|
436
631
|
function toRecord(value: unknown): Record<string, unknown> | null {
|
|
437
|
-
return typeof value === "object" && value !== null
|
|
632
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
438
633
|
? (value as Record<string, unknown>)
|
|
439
634
|
: null;
|
|
440
635
|
}
|
|
@@ -620,7 +815,8 @@ export function appendMessage(
|
|
|
620
815
|
* Load all messages for a session in insertion order.
|
|
621
816
|
*
|
|
622
817
|
* Messages are deserialized from their JSON representation back into
|
|
623
|
-
* persisted app messages.
|
|
818
|
+
* persisted app messages. Invalid rows are skipped so corrupted session data
|
|
819
|
+
* does not crash the app. The ordering matches the original append order
|
|
624
820
|
* (by autoincrement `id`), preserving the conversation flow.
|
|
625
821
|
*
|
|
626
822
|
* @param db - Open database handle.
|
|
@@ -633,7 +829,16 @@ export function loadMessages(
|
|
|
633
829
|
sessionId: string,
|
|
634
830
|
): PersistedMessage[] {
|
|
635
831
|
const rows = db.query<DataRow, [string]>(SQL.loadMessages).all(sessionId);
|
|
636
|
-
|
|
832
|
+
const messages: PersistedMessage[] = [];
|
|
833
|
+
|
|
834
|
+
for (const row of rows) {
|
|
835
|
+
const message = parsePersistedMessage(row.data);
|
|
836
|
+
if (message) {
|
|
837
|
+
messages.push(message);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return messages;
|
|
637
842
|
}
|
|
638
843
|
|
|
639
844
|
// ---------------------------------------------------------------------------
|
package/src/settings.ts
CHANGED
|
@@ -51,10 +51,15 @@ const THINKING_LEVELS = new Set<ThinkingLevel>([
|
|
|
51
51
|
"xhigh",
|
|
52
52
|
]);
|
|
53
53
|
|
|
54
|
+
function getErrorMessage(error: unknown): string {
|
|
55
|
+
return error instanceof Error ? error.message : String(error);
|
|
56
|
+
}
|
|
57
|
+
|
|
54
58
|
/**
|
|
55
59
|
* Load and validate user settings from disk.
|
|
56
60
|
*
|
|
57
|
-
*
|
|
61
|
+
* Missing files are treated as empty settings. Invalid JSON or unreadable files
|
|
62
|
+
* fail with a descriptive error instead of silently discarding saved state.
|
|
58
63
|
*
|
|
59
64
|
* @param path - Absolute path to `settings.json`.
|
|
60
65
|
* @returns The validated settings object.
|
|
@@ -67,8 +72,10 @@ export function loadSettings(path: string): UserSettings {
|
|
|
67
72
|
try {
|
|
68
73
|
const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
69
74
|
return sanitizeSettings(raw);
|
|
70
|
-
} catch {
|
|
71
|
-
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Failed to read settings ${path}: ${getErrorMessage(error)}`,
|
|
78
|
+
);
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
|
package/src/skills.ts
CHANGED
|
@@ -183,13 +183,17 @@ function readSkill(basePath: string, entry: string): Skill | null {
|
|
|
183
183
|
return null;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
186
|
+
try {
|
|
187
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
188
|
+
const frontmatter = parseFrontmatter(content);
|
|
189
|
+
return {
|
|
190
|
+
name: frontmatter.name ?? entry,
|
|
191
|
+
description: frontmatter.description ?? "",
|
|
192
|
+
path: skillPath,
|
|
193
|
+
};
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
/**
|
package/src/ui/help.test.ts
CHANGED
|
@@ -23,4 +23,22 @@ describe("ui/help", () => {
|
|
|
23
23
|
"/verbose Toggle verbose tool rendering (currently off)",
|
|
24
24
|
);
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
test("buildHelpText includes the current Escape input-focus note", () => {
|
|
28
|
+
const helpState: HelpRenderState = {
|
|
29
|
+
providers: new Map(),
|
|
30
|
+
model: null,
|
|
31
|
+
agentsMd: [],
|
|
32
|
+
skills: [],
|
|
33
|
+
plugins: [],
|
|
34
|
+
showReasoning: DEFAULT_SHOW_REASONING,
|
|
35
|
+
verbose: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const text = buildHelpText(helpState);
|
|
39
|
+
|
|
40
|
+
expect(text).toContain("Escape blurs the input first");
|
|
41
|
+
expect(text).toContain("Tab re-focuses the input");
|
|
42
|
+
expect(text).toContain("Escape again interrupts the current turn");
|
|
43
|
+
});
|
|
26
44
|
});
|
package/src/ui/help.ts
CHANGED
|
@@ -77,6 +77,12 @@ export function buildHelpText(state: HelpRenderState): string {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
const providerNames = Array.from(state.providers.keys());
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push("Note:");
|
|
82
|
+
lines.push(" While a turn is running, Escape blurs the input first.");
|
|
83
|
+
lines.push(" Tab re-focuses the input.");
|
|
84
|
+
lines.push(" Escape again interrupts the current turn.");
|
|
85
|
+
|
|
80
86
|
lines.push("");
|
|
81
87
|
lines.push(
|
|
82
88
|
providerNames.length > 0
|
package/src/ui/status.test.ts
CHANGED
package/src/ui.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
12
|
import { platform } from "node:os";
|
|
13
13
|
import {
|
|
14
14
|
cel,
|
|
@@ -57,6 +57,20 @@ const PULSE_WIDTH = 5;
|
|
|
57
57
|
/** Maximum number of committed messages rendered before older history is chunked. */
|
|
58
58
|
const CONVERSATION_CHUNK_MESSAGES = 50;
|
|
59
59
|
|
|
60
|
+
/** Centralized interactive quit rules for keypresses and submitted input. */
|
|
61
|
+
const QUIT_RULES: Readonly<{
|
|
62
|
+
/** Submitted raw inputs that trigger graceful quit. */
|
|
63
|
+
inputs: ReadonlySet<string>;
|
|
64
|
+
/** Keypresses that always trigger graceful quit. */
|
|
65
|
+
keysAlways: ReadonlySet<string>;
|
|
66
|
+
/** Keypresses that trigger graceful quit only when input is empty. */
|
|
67
|
+
keysWhenEmptyInput: ReadonlySet<string>;
|
|
68
|
+
}> = {
|
|
69
|
+
inputs: new Set([":q"]),
|
|
70
|
+
keysAlways: new Set(["ctrl+c"]),
|
|
71
|
+
keysWhenEmptyInput: new Set(["ctrl+d"]),
|
|
72
|
+
};
|
|
73
|
+
|
|
60
74
|
// ---------------------------------------------------------------------------
|
|
61
75
|
// UI state (module-scoped, not in AppState)
|
|
62
76
|
// ---------------------------------------------------------------------------
|
|
@@ -92,6 +106,23 @@ let stdinWasRaw = false;
|
|
|
92
106
|
/** Active overlay for interactive commands (/model, /effort, etc.). */
|
|
93
107
|
let activeOverlay: ActiveOverlay | null = null;
|
|
94
108
|
|
|
109
|
+
/** Determine whether a raw input line should trigger a graceful quit. */
|
|
110
|
+
export function isQuitInput(raw: string): boolean {
|
|
111
|
+
const trimmed = raw.trim();
|
|
112
|
+
return QUIT_RULES.inputs.has(trimmed);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Determine whether a keypress should trigger a graceful quit. */
|
|
116
|
+
export function isQuitKey(key: string, input: string): boolean {
|
|
117
|
+
if (QUIT_RULES.keysAlways.has(key)) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (input === "" && QUIT_RULES.keysWhenEmptyInput.has(key)) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
95
126
|
/**
|
|
96
127
|
* Reset all module-scoped UI state.
|
|
97
128
|
*
|
|
@@ -299,6 +330,14 @@ export function createInputController(state: AppState): InputController {
|
|
|
299
330
|
onKeyPress: (key) => {
|
|
300
331
|
if (key === "enter") {
|
|
301
332
|
const raw = inputValue;
|
|
333
|
+
|
|
334
|
+
if (isQuitInput(raw)) {
|
|
335
|
+
inputValue = "";
|
|
336
|
+
cel.render();
|
|
337
|
+
requestGracefulExit(state);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
302
341
|
inputValue = "";
|
|
303
342
|
cel.render();
|
|
304
343
|
handleInput(raw, state);
|
|
@@ -338,10 +377,49 @@ export function renderInputArea(
|
|
|
338
377
|
// Runtime helpers and controllers
|
|
339
378
|
// ---------------------------------------------------------------------------
|
|
340
379
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
380
|
+
/** Browser process handle used by the platform opener helper. */
|
|
381
|
+
interface BrowserOpenProcess {
|
|
382
|
+
/** Detach the browser opener so the app does not wait on it. */
|
|
383
|
+
unref: () => void;
|
|
384
|
+
/** Optional error listener used by real child-process implementations. */
|
|
385
|
+
on?: (event: "error", listener: (error: Error) => void) => void;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Optional runtime overrides for browser launching. */
|
|
389
|
+
interface OpenInBrowserRuntime {
|
|
390
|
+
/** Platform used to choose the opener binary. */
|
|
391
|
+
platform?: NodeJS.Platform;
|
|
392
|
+
/** Process launcher used for tests. */
|
|
393
|
+
spawn?: (
|
|
394
|
+
command: string,
|
|
395
|
+
args: string[],
|
|
396
|
+
options: { detached: boolean; stdio: "ignore" },
|
|
397
|
+
) => BrowserOpenProcess;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Open a URL in the user's default browser without invoking a shell. */
|
|
401
|
+
export function openInBrowser(
|
|
402
|
+
url: string,
|
|
403
|
+
runtime?: OpenInBrowserRuntime,
|
|
404
|
+
): void {
|
|
405
|
+
const command =
|
|
406
|
+
(runtime?.platform ?? platform()) === "darwin" ? "open" : "xdg-open";
|
|
407
|
+
const launch =
|
|
408
|
+
runtime?.spawn ??
|
|
409
|
+
((
|
|
410
|
+
cmd: string,
|
|
411
|
+
args: string[],
|
|
412
|
+
options: { detached: boolean; stdio: "ignore" },
|
|
413
|
+
) => {
|
|
414
|
+
return spawn(cmd, args, options);
|
|
415
|
+
});
|
|
416
|
+
const child = launch(command, [url], {
|
|
417
|
+
detached: true,
|
|
418
|
+
stdio: "ignore",
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
child.on?.("error", () => {});
|
|
422
|
+
child.unref();
|
|
345
423
|
}
|
|
346
424
|
|
|
347
425
|
function scrollConversationToBottom(): void {
|
|
@@ -418,6 +496,11 @@ async function gracefulExit(state: AppState): Promise<void> {
|
|
|
418
496
|
process.exit(0);
|
|
419
497
|
}
|
|
420
498
|
|
|
499
|
+
/** Request a graceful exit and hard-fail if shutdown errors. */
|
|
500
|
+
function requestGracefulExit(state: AppState): void {
|
|
501
|
+
gracefulExit(state).catch(() => process.exit(1));
|
|
502
|
+
}
|
|
503
|
+
|
|
421
504
|
/** Restore the terminal to the shell before suspending. */
|
|
422
505
|
function suspendTerminalUi(): void {
|
|
423
506
|
stopDividerAnimation();
|
|
@@ -539,12 +622,8 @@ export function renderBaseLayout(
|
|
|
539
622
|
commandController.showInputHistoryOverlay(state);
|
|
540
623
|
return;
|
|
541
624
|
}
|
|
542
|
-
if (key
|
|
543
|
-
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
if (key === "ctrl+d" && inputValue === "") {
|
|
547
|
-
gracefulExit(state).catch(() => process.exit(1));
|
|
625
|
+
if (isQuitKey(key, inputValue)) {
|
|
626
|
+
requestGracefulExit(state);
|
|
548
627
|
return;
|
|
549
628
|
}
|
|
550
629
|
if (key === "ctrl+z") {
|