@este.systems/dsc 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/README.md +115 -4
- package/dist/agent.js +3 -2
- package/dist/agent.js.map +1 -1
- package/dist/api.js +508 -14
- package/dist/api.js.map +1 -1
- package/dist/history.js +15 -0
- package/dist/history.js.map +1 -1
- package/dist/prompt.js +2 -0
- package/dist/prompt.js.map +1 -1
- package/dist/slash_dispatch.js +742 -0
- package/dist/slash_dispatch.js.map +1 -0
- package/dist/tools.js +184 -2
- package/dist/tools.js.map +1 -1
- package/dist/tui.js +80 -817
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
package/dist/tui.js
CHANGED
|
@@ -2,15 +2,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
import { App } from "./tui/App.js";
|
|
4
4
|
import { getState, setState } from "./store.js";
|
|
5
|
-
import {
|
|
6
|
-
import { getProvider, getProviderKey } from "./search.js";
|
|
5
|
+
import { availableModels, DEFAULT_MODEL, DeepSeekError, computeCostUsd, configPath, consumeConfigMigrationNotice, hasApiKey, isKnownModel, recordUsage, } from "./api.js";
|
|
7
6
|
import { loadInstructions } from "./instructions.js";
|
|
8
|
-
import {
|
|
7
|
+
import { readPreferences } from "./preferences.js";
|
|
9
8
|
import { callMCPTool, connectAll as connectMCP, } from "./mcp.js";
|
|
10
9
|
import { runAgent, formatCost, estimateContextTokens, } from "./agent.js";
|
|
11
10
|
import * as history from "./history.js";
|
|
12
11
|
import * as approval from "./approval.js";
|
|
13
|
-
import * as audit from "./audit.js";
|
|
14
12
|
import * as replHistory from "./repl_history.js";
|
|
15
13
|
import { promises as fsp } from "node:fs";
|
|
16
14
|
import { homedir } from "node:os";
|
|
@@ -18,8 +16,8 @@ import * as path from "node:path";
|
|
|
18
16
|
import { compactSession } from "./compact.js";
|
|
19
17
|
import { formatVersionInfo } from "./version.js";
|
|
20
18
|
import { openEditor } from "./editor.js";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
19
|
+
import { checkForUpdate } from "./update.js";
|
|
20
|
+
import { dispatchSlash } from "./slash_dispatch.js";
|
|
23
21
|
// Auto-compact threshold (token-count of estimated context). Default
|
|
24
22
|
// 50K — well below the 1M model limit, but tuned to keep per-turn input
|
|
25
23
|
// cost from creeping. Override via DSC_AUTO_COMPACT_AT; set to "0" /
|
|
@@ -65,8 +63,8 @@ function parseArgs(argv) {
|
|
|
65
63
|
}
|
|
66
64
|
else if (a === "--model" || a === "-m") {
|
|
67
65
|
const v = argv[++i];
|
|
68
|
-
if (!
|
|
69
|
-
throw new CliError(`unknown model: ${v} (available: ${
|
|
66
|
+
if (!isKnownModel(v)) {
|
|
67
|
+
throw new CliError(`unknown model: ${v} (available: ${availableModels().join(", ")})`);
|
|
70
68
|
}
|
|
71
69
|
out.model = v;
|
|
72
70
|
out.modelExplicit = true;
|
|
@@ -104,9 +102,10 @@ async function main() {
|
|
|
104
102
|
"Usage:",
|
|
105
103
|
" dsc Start the TUI",
|
|
106
104
|
" dsc \"your prompt here\" One-shot: run and exit",
|
|
105
|
+
" dsc serve [--port <n>] Headless WebSocket daemon (default 127.0.0.1:9090)",
|
|
107
106
|
"",
|
|
108
107
|
"Flags:",
|
|
109
|
-
" -m, --model <name> " +
|
|
108
|
+
" -m, --model <name> " + availableModels().join(" | "),
|
|
110
109
|
" -y, --yolo Skip approval prompts",
|
|
111
110
|
" --no-resume Don't auto-resume the latest session",
|
|
112
111
|
" --resume [id] Resume the most recent (or by id)",
|
|
@@ -540,807 +539,84 @@ async function main() {
|
|
|
540
539
|
// Returns true if the input was a recognized slash command and got handled
|
|
541
540
|
// (or rejected) — caller should not forward it to the agent. Returns false
|
|
542
541
|
// for non-slash input and unknown commands (caller decides what to do).
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
"/api-key [key] show source / save api key to config file",
|
|
581
|
-
"/search [use|key] … show / switch search provider / save brave|tavily key",
|
|
582
|
-
"/update check npm for a newer dsc and install it",
|
|
583
|
-
"/edit [text] open $EDITOR; the saved buffer becomes the next prompt",
|
|
584
|
-
"/exit exit",
|
|
585
|
-
].join("\n"));
|
|
586
|
-
return true;
|
|
587
|
-
case "clear":
|
|
588
|
-
session = history.newSession(cwd, model);
|
|
589
|
-
messages = session.messages;
|
|
590
|
-
stats = session.stats;
|
|
591
|
-
toolCtx.filesTouched = stats.files_touched;
|
|
592
|
-
toolCtx.sessionId = session.id;
|
|
593
|
-
// Per-tool always-approval is conversation-scoped, so /clear has
|
|
594
|
-
// to drop it — otherwise the user would carry over implicit trust
|
|
595
|
-
// they made to a session they just walked away from.
|
|
542
|
+
// Slash-command dispatch lives in src/slash_dispatch.ts now (shared with
|
|
543
|
+
// `dsc serve`). We wire the dispatcher's SlashContext to this front-end's
|
|
544
|
+
// imperative locals: getters read the (reassignable) session state, actions
|
|
545
|
+
// mutate it + drive ink. `emit` is the `info()` system-line sink.
|
|
546
|
+
const slashContext = {
|
|
547
|
+
cwd,
|
|
548
|
+
emit: info,
|
|
549
|
+
getModel: () => model,
|
|
550
|
+
getStats: () => stats,
|
|
551
|
+
getSession: () => session,
|
|
552
|
+
getMessages: () => messages,
|
|
553
|
+
getToolCtx: () => toolCtx,
|
|
554
|
+
getQueue: () => promptQueue,
|
|
555
|
+
getBudget: () => ({ usd: budgetUsd, warned: budgetWarned }),
|
|
556
|
+
getMcpConnections: () => mcpConnections,
|
|
557
|
+
setModel: (m) => {
|
|
558
|
+
model = m;
|
|
559
|
+
},
|
|
560
|
+
setBudget: (usd, warned) => {
|
|
561
|
+
budgetUsd = usd;
|
|
562
|
+
budgetWarned = warned;
|
|
563
|
+
},
|
|
564
|
+
persist,
|
|
565
|
+
syncStatus,
|
|
566
|
+
compact: runCompaction,
|
|
567
|
+
submit: (text) => handleSubmit(text),
|
|
568
|
+
applySession: (next, opts) => {
|
|
569
|
+
session = next;
|
|
570
|
+
messages = next.messages;
|
|
571
|
+
stats = next.stats;
|
|
572
|
+
model = next.model;
|
|
573
|
+
toolCtx.filesTouched = stats.files_touched;
|
|
574
|
+
toolCtx.sessionId = next.id;
|
|
575
|
+
// Per-tool always-approval is conversation-scoped, so /clear drops it —
|
|
576
|
+
// otherwise the user carries implicit trust into a session they walked
|
|
577
|
+
// away from.
|
|
578
|
+
if (opts.resetApprovals)
|
|
596
579
|
toolCtx.sessionApprovals = new Set();
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
.map((s, i) => {
|
|
609
|
-
const here = s.id === session.id ? "* " : " ";
|
|
610
|
-
const label = s.name ? `${s.name} (${s.model})` : s.model;
|
|
611
|
-
return `${here}${String(i + 1).padStart(2, " ")}. ${label} ${formatRelative(s.updated_at)} (${s.message_count} msgs) ${s.first_user_message || "—"}`;
|
|
612
|
-
})
|
|
613
|
-
.join("\n"));
|
|
614
|
-
}
|
|
615
|
-
return true;
|
|
616
|
-
}
|
|
617
|
-
case "save":
|
|
618
|
-
if (!arg.trim()) {
|
|
619
|
-
info("error: usage: /save <name>");
|
|
620
|
-
}
|
|
621
|
-
else {
|
|
622
|
-
session.name = arg.trim();
|
|
623
|
-
await persist();
|
|
624
|
-
info(`session saved as "${session.name}" (id ${session.id})`);
|
|
625
|
-
}
|
|
626
|
-
return true;
|
|
627
|
-
case "rename": {
|
|
628
|
-
const text = arg.trim();
|
|
629
|
-
if (!text) {
|
|
630
|
-
info(`assistant label: "${session.assistantLabel ?? "assistant:"}"`);
|
|
631
|
-
}
|
|
632
|
-
else if (text === "--reset" || text === "default") {
|
|
633
|
-
delete session.assistantLabel;
|
|
634
|
-
setState({ assistantLabel: "assistant:" });
|
|
635
|
-
await persist();
|
|
636
|
-
info("assistant label reset to default");
|
|
637
|
-
}
|
|
638
|
-
else {
|
|
639
|
-
session.assistantLabel = text;
|
|
640
|
-
setState({ assistantLabel: text });
|
|
641
|
-
await persist();
|
|
642
|
-
info(`assistant label → "${text}"`);
|
|
643
|
-
}
|
|
644
|
-
return true;
|
|
645
|
-
}
|
|
646
|
-
case "resume": {
|
|
647
|
-
const all = await history.listSessions(cwd);
|
|
648
|
-
if (!all.length) {
|
|
649
|
-
info("no sessions to resume");
|
|
650
|
-
return true;
|
|
651
|
-
}
|
|
652
|
-
let target = null;
|
|
653
|
-
if (!arg || arg === "last") {
|
|
654
|
-
target = all[0];
|
|
655
|
-
}
|
|
656
|
-
else if (/^\d+$/.test(arg)) {
|
|
657
|
-
target = all[parseInt(arg, 10) - 1] ?? null;
|
|
658
|
-
if (!target)
|
|
659
|
-
info(`error: no session at index ${arg} (have ${all.length})`);
|
|
660
|
-
}
|
|
661
|
-
else {
|
|
662
|
-
target =
|
|
663
|
-
all.find((s) => s.name === arg) ??
|
|
664
|
-
all.find((s) => s.id === arg) ??
|
|
665
|
-
null;
|
|
666
|
-
if (!target)
|
|
667
|
-
info(`error: no session with name or id ${arg}`);
|
|
668
|
-
}
|
|
669
|
-
if (target) {
|
|
670
|
-
const loaded = await history.loadSession(target.id);
|
|
671
|
-
if (!loaded) {
|
|
672
|
-
info(`error: failed to load session ${target.id}`);
|
|
673
|
-
}
|
|
674
|
-
else {
|
|
675
|
-
session = loaded;
|
|
676
|
-
messages = session.messages;
|
|
677
|
-
stats = session.stats;
|
|
678
|
-
model = session.model;
|
|
679
|
-
toolCtx.filesTouched = stats.files_touched;
|
|
680
|
-
toolCtx.sessionId = session.id;
|
|
681
|
-
const userTurns = messages.filter((m) => m.role === "user").length;
|
|
682
|
-
// Rebuild history view from the resumed session.
|
|
683
|
-
const restored = [];
|
|
684
|
-
for (const m of messages) {
|
|
685
|
-
if (m.role === "system")
|
|
686
|
-
continue;
|
|
687
|
-
restored.push({
|
|
688
|
-
id: `r-${restored.length}`,
|
|
689
|
-
role: m.role,
|
|
690
|
-
content: typeof m.content === "string" ? m.content : "",
|
|
691
|
-
tool_call_id: m.tool_call_id,
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
setState({ history: restored, current: null, model });
|
|
695
|
-
syncStatus();
|
|
696
|
-
info(`resumed ${session.id} (${userTurns} turns, model ${model})`);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
return true;
|
|
700
|
-
}
|
|
701
|
-
case "cost":
|
|
702
|
-
info(formatCost(stats, model));
|
|
703
|
-
return true;
|
|
704
|
-
case "budget": {
|
|
705
|
-
// /budget -> show current limit + spend
|
|
706
|
-
// /budget <usd> -> set a session ceiling
|
|
707
|
-
// /budget off -> clear
|
|
708
|
-
const text = arg.trim().toLowerCase();
|
|
709
|
-
if (!text) {
|
|
710
|
-
const cost = computeCostUsd(stats, model);
|
|
711
|
-
if (budgetUsd === null) {
|
|
712
|
-
info(`budget: not set (spent: $${cost.toFixed(4)})\n/budget <usd> to set a limit, /budget off to clear`);
|
|
713
|
-
}
|
|
714
|
-
else {
|
|
715
|
-
const pct = budgetUsd > 0 ? Math.round((cost / budgetUsd) * 100) : 0;
|
|
716
|
-
info(`budget: $${budgetUsd.toFixed(2)} spent: $${cost.toFixed(4)} (${pct}%) warned: ${budgetWarned ? "yes" : "no"}`);
|
|
717
|
-
}
|
|
718
|
-
return true;
|
|
719
|
-
}
|
|
720
|
-
if (text === "off" || text === "none" || text === "0") {
|
|
721
|
-
budgetUsd = null;
|
|
722
|
-
budgetWarned = false;
|
|
723
|
-
void savePreferences({ budgetUsd: null });
|
|
724
|
-
info("budget cleared (saved)");
|
|
725
|
-
return true;
|
|
726
|
-
}
|
|
727
|
-
const n = parseFloat(text);
|
|
728
|
-
if (!Number.isFinite(n) || n <= 0) {
|
|
729
|
-
info("error: usage: /budget <amount-usd> | off");
|
|
730
|
-
return true;
|
|
731
|
-
}
|
|
732
|
-
budgetUsd = n;
|
|
733
|
-
// Reset the warning flag so the user gets a fresh 80% notice
|
|
734
|
-
// against the new limit even if they're already over it.
|
|
735
|
-
budgetWarned = false;
|
|
736
|
-
void savePreferences({ budgetUsd: n });
|
|
737
|
-
info(`budget: $${n.toFixed(2)} (warn at 80%, abort the next turn at 100%) (saved)`);
|
|
738
|
-
return true;
|
|
739
|
-
}
|
|
740
|
-
case "copy": {
|
|
741
|
-
// Copy the most recent assistant response to the OS clipboard. The
|
|
742
|
-
// last *assistant* (not tool, not system, not user) is what the
|
|
743
|
-
// user usually wants — the actual answer text.
|
|
744
|
-
const state = getState();
|
|
745
|
-
let target;
|
|
746
|
-
for (let i = state.history.length - 1; i >= 0; i--) {
|
|
747
|
-
const m = state.history[i];
|
|
748
|
-
if (m.role === "assistant" && m.content) {
|
|
749
|
-
target = m;
|
|
750
|
-
break;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
if (!target) {
|
|
754
|
-
info("no assistant message to copy");
|
|
755
|
-
return true;
|
|
756
|
-
}
|
|
757
|
-
try {
|
|
758
|
-
await copyToClipboard(target.content);
|
|
759
|
-
info(`copied ${target.content.length} chars to clipboard`);
|
|
760
|
-
}
|
|
761
|
-
catch (e) {
|
|
762
|
-
info(`error: ${e.message}`);
|
|
763
|
-
}
|
|
764
|
-
return true;
|
|
765
|
-
}
|
|
766
|
-
case "model":
|
|
767
|
-
if (!arg) {
|
|
768
|
-
info(`current model: ${model}`);
|
|
769
|
-
}
|
|
770
|
-
else if (!AVAILABLE_MODELS.includes(arg)) {
|
|
771
|
-
info(`error: unknown model: ${arg} (available: ${AVAILABLE_MODELS.join(", ")})`);
|
|
772
|
-
}
|
|
773
|
-
else {
|
|
774
|
-
model = arg;
|
|
775
|
-
syncStatus();
|
|
776
|
-
info(`model → ${model}`);
|
|
777
|
-
await persist();
|
|
778
|
-
}
|
|
779
|
-
return true;
|
|
780
|
-
case "yolo":
|
|
781
|
-
toolCtx.yolo = !toolCtx.yolo;
|
|
782
|
-
setState({ yolo: toolCtx.yolo });
|
|
783
|
-
// Persist so the next launch starts the same way. Saving `false`
|
|
784
|
-
// explicitly (rather than deleting the key) means a user who
|
|
785
|
-
// *turned* yolo off doesn't get re-prompted next time if we ever
|
|
786
|
-
// add a "default-on" path; today both are equivalent.
|
|
787
|
-
void savePreferences({ yolo: toolCtx.yolo });
|
|
788
|
-
info(`yolo: ${toolCtx.yolo} (saved to ${preferencesPath()})`);
|
|
789
|
-
return true;
|
|
790
|
-
case "reasoning": {
|
|
791
|
-
const cur = getState().reasoning;
|
|
792
|
-
const next = arg === "on" ? true : arg === "off" ? false : !cur;
|
|
793
|
-
setState({ reasoning: next });
|
|
794
|
-
void savePreferences({ reasoning: next });
|
|
795
|
-
info(`reasoning: ${next ? "on" : "off"} (saved)`);
|
|
796
|
-
return true;
|
|
797
|
-
}
|
|
798
|
-
case "queue": {
|
|
799
|
-
const sub = arg.trim().toLowerCase();
|
|
800
|
-
if (sub === "clear" || sub === "drop") {
|
|
801
|
-
const n = promptQueue.length;
|
|
802
|
-
promptQueue.length = 0;
|
|
803
|
-
syncStatus();
|
|
804
|
-
info(`cleared ${n} queued prompt(s)`);
|
|
805
|
-
}
|
|
806
|
-
else if (promptQueue.length === 0) {
|
|
807
|
-
info("queue is empty");
|
|
808
|
-
}
|
|
809
|
-
else {
|
|
810
|
-
info(promptQueue
|
|
811
|
-
.map((p, i) => `${String(i + 1).padStart(2, " ")}. ${p}`)
|
|
812
|
-
.join("\n"));
|
|
813
|
-
}
|
|
814
|
-
return true;
|
|
815
|
-
}
|
|
816
|
-
case "lang": {
|
|
817
|
-
const text = arg.trim();
|
|
818
|
-
if (!text) {
|
|
819
|
-
info(`language: ${session.language ? `"${session.language}"` : "off (any language)"}`);
|
|
820
|
-
}
|
|
821
|
-
else if (text === "off" || text === "default" || text === "any") {
|
|
822
|
-
delete session.language;
|
|
823
|
-
setState({ language: undefined });
|
|
824
|
-
await persist();
|
|
825
|
-
info("language directive cleared");
|
|
826
|
-
}
|
|
827
|
-
else {
|
|
828
|
-
session.language = text;
|
|
829
|
-
setState({ language: text });
|
|
830
|
-
await persist();
|
|
831
|
-
info(`language → "${text}" (replies will be exclusively in this language)`);
|
|
832
|
-
}
|
|
833
|
-
return true;
|
|
834
|
-
}
|
|
835
|
-
case "auto-continue": {
|
|
836
|
-
const t = arg.trim();
|
|
837
|
-
if (!t) {
|
|
838
|
-
const n = getState().autoContinue;
|
|
839
|
-
info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`}`);
|
|
840
|
-
}
|
|
841
|
-
else if (t === "off" || t === "0" || t === "false") {
|
|
842
|
-
setState({ autoContinue: 0 });
|
|
843
|
-
// Saved as 0 (or absent) — clear via savePreferences with null
|
|
844
|
-
// so the key is removed from the file when value is "off".
|
|
845
|
-
void savePreferences({ autoContinue: null });
|
|
846
|
-
info("auto-continue: off (saved)");
|
|
847
|
-
}
|
|
848
|
-
else {
|
|
849
|
-
const n = parseInt(t, 10);
|
|
850
|
-
if (!Number.isFinite(n) || n < 0) {
|
|
851
|
-
info("error: usage: /auto-continue [N|off]");
|
|
852
|
-
}
|
|
853
|
-
else {
|
|
854
|
-
setState({ autoContinue: n });
|
|
855
|
-
void savePreferences({ autoContinue: n });
|
|
856
|
-
info(`auto-continue: ${n === 0 ? "off" : `up to ${n} extra budget(s)`} (saved)`);
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
return true;
|
|
860
|
-
}
|
|
861
|
-
case "version":
|
|
862
|
-
info(formatVersionInfo());
|
|
863
|
-
return true;
|
|
864
|
-
case "preferences": {
|
|
865
|
-
const sub = arg.trim().toLowerCase();
|
|
866
|
-
if (sub === "reset") {
|
|
867
|
-
await clearPreferences();
|
|
868
|
-
info(`preferences file deleted (${preferencesPath()}). Current session keeps its in-memory settings; next launch starts with defaults.`);
|
|
869
|
-
return true;
|
|
870
|
-
}
|
|
871
|
-
if (sub && sub !== "show") {
|
|
872
|
-
info("error: usage: /preferences [show|reset]");
|
|
873
|
-
return true;
|
|
874
|
-
}
|
|
875
|
-
const saved = await readPreferences();
|
|
876
|
-
const lines = [`preferences: ${preferencesPath()}`];
|
|
877
|
-
const keys = [
|
|
878
|
-
"yolo",
|
|
879
|
-
"reasoning",
|
|
880
|
-
"autoContinue",
|
|
881
|
-
"budgetUsd",
|
|
882
|
-
];
|
|
883
|
-
const present = keys.filter((k) => saved[k] !== undefined);
|
|
884
|
-
if (present.length === 0) {
|
|
885
|
-
lines.push(" (no preferences saved; defaults apply)");
|
|
886
|
-
}
|
|
887
|
-
else {
|
|
888
|
-
for (const k of present) {
|
|
889
|
-
lines.push(` ${k}: ${JSON.stringify(saved[k])}`);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
info(lines.join("\n"));
|
|
893
|
-
return true;
|
|
894
|
-
}
|
|
895
|
-
case "mcp": {
|
|
896
|
-
// Status / inspection only for now — connections are established
|
|
897
|
-
// at boot. Future: subcommands for reconnect, disable, etc.
|
|
898
|
-
if (mcpConnections.length === 0) {
|
|
899
|
-
info([
|
|
900
|
-
"No MCP servers connected.",
|
|
901
|
-
"",
|
|
902
|
-
"To wire one in, add an `mcp.servers` block to your config:",
|
|
903
|
-
` ${configPath()}`,
|
|
904
|
-
"",
|
|
905
|
-
"Example (Tavily remote MCP):",
|
|
906
|
-
' "mcp": {',
|
|
907
|
-
' "servers": {',
|
|
908
|
-
' "tavily": {',
|
|
909
|
-
' "url": "https://mcp.tavily.com/mcp/",',
|
|
910
|
-
' "headers": { "Authorization": "Bearer ${TAVILY_API_KEY}" }',
|
|
911
|
-
" }",
|
|
912
|
-
" }",
|
|
913
|
-
" }",
|
|
914
|
-
"",
|
|
915
|
-
"Restart dsc after editing — connections are made at boot.",
|
|
916
|
-
].join("\n"));
|
|
917
|
-
return true;
|
|
918
|
-
}
|
|
919
|
-
const lines = [];
|
|
920
|
-
for (const c of mcpConnections) {
|
|
921
|
-
lines.push(`── ${c.name} ──`);
|
|
922
|
-
for (const t of c.tools) {
|
|
923
|
-
// t.function.name is namespaced (mcp_<server>_<tool>); strip
|
|
924
|
-
// the prefix for readability.
|
|
925
|
-
const short = t.function.name.replace(/^mcp_[^_]+_/, "");
|
|
926
|
-
lines.push(` ${short}: ${t.function.description ?? ""}`);
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
info(lines.join("\n"));
|
|
930
|
-
return true;
|
|
931
|
-
}
|
|
932
|
-
case "instructions": {
|
|
933
|
-
// Show every overlay the agent currently sees, in the order they
|
|
934
|
-
// appear in the system prompt. Resolved fresh per call so edits
|
|
935
|
-
// since launch are reflected without restarting dsc.
|
|
936
|
-
const overlays = loadInstructions(cwd);
|
|
937
|
-
if (overlays.length === 0) {
|
|
938
|
-
info([
|
|
939
|
-
"No instruction overlays found. dsc looks for:",
|
|
940
|
-
" ~/.config/dsc/instructions.md (user-global)",
|
|
941
|
-
" AGENTS.md (project, walked up from cwd)",
|
|
942
|
-
" .dsc/instructions.md (project, dsc-specific)",
|
|
943
|
-
"Create any of these to teach the agent project conventions.",
|
|
944
|
-
].join("\n"));
|
|
945
|
-
return true;
|
|
946
|
-
}
|
|
947
|
-
const sections = overlays.map((ov) => {
|
|
948
|
-
const label = ov.kind === "user"
|
|
949
|
-
? "user"
|
|
950
|
-
: ov.kind === "agents"
|
|
951
|
-
? "AGENTS.md"
|
|
952
|
-
: ".dsc/instructions.md";
|
|
953
|
-
const lines = ov.content.split("\n");
|
|
954
|
-
const head = lines.slice(0, 20);
|
|
955
|
-
const tail = lines.length > 20
|
|
956
|
-
? `\n… (${lines.length - 20} more lines)`
|
|
957
|
-
: "";
|
|
958
|
-
return `── ${label} @ ${ov.path} ──\n${head.join("\n")}${tail}`;
|
|
959
|
-
});
|
|
960
|
-
info(sections.join("\n\n"));
|
|
961
|
-
return true;
|
|
962
|
-
}
|
|
963
|
-
case "update": {
|
|
964
|
-
// Two-phase: check first so we don't run npm install when we're
|
|
965
|
-
// already current. Force-fetches the latest (ignores the 24h cache
|
|
966
|
-
// because the user explicitly asked) and reports either way.
|
|
967
|
-
info("checking npm for a newer version…");
|
|
968
|
-
try {
|
|
969
|
-
const check = await checkForUpdate({ force: true });
|
|
970
|
-
if (!check.newerAvailable) {
|
|
971
|
-
info(`up to date (${check.current})`);
|
|
972
|
-
return true;
|
|
973
|
-
}
|
|
974
|
-
info(`installing ${check.latest} (currently ${check.current}) via npm…`);
|
|
975
|
-
let r = await runUpdate();
|
|
976
|
-
// ETARGET = npm's registry has the new version's metadata but
|
|
977
|
-
// the tarball hasn't propagated to all CDN edges yet (common
|
|
978
|
-
// for the first minute or two after `npm publish`). Wait
|
|
979
|
-
// briefly and retry once before surfacing as a failure.
|
|
980
|
-
if (!r.ok && /ETARGET/i.test(r.output)) {
|
|
981
|
-
info("tarball not on all CDN edges yet (ETARGET); retrying in 8s…");
|
|
982
|
-
await new Promise((res) => setTimeout(res, 8000));
|
|
983
|
-
r = await runUpdate();
|
|
984
|
-
}
|
|
985
|
-
// Auto-recover from EACCES on Linux/macOS: offer to set
|
|
986
|
-
// npm's prefix under ~/.local so the install doesn't need
|
|
987
|
-
// sudo, optionally append the PATH export to the user's
|
|
988
|
-
// shell rc, then retry. The user approves each step.
|
|
989
|
-
const isPermError = /EACCES|EPERM|permission/.test(r.output);
|
|
990
|
-
if (!r.ok &&
|
|
991
|
-
isPermError &&
|
|
992
|
-
process.platform !== "win32") {
|
|
993
|
-
const ans = await approval.confirm({
|
|
994
|
-
title: "Permission error installing dsc",
|
|
995
|
-
body: `npm install -g hit a permission error (likely because npm's prefix is system-owned).` +
|
|
996
|
-
`\n\nDurable fix: set npm's global prefix to ~/.local. After this, all` +
|
|
997
|
-
`\n\`npm install -g\` and \`/update\` runs work without sudo.` +
|
|
998
|
-
`\n\nDsc will:` +
|
|
999
|
-
`\n 1. mkdir -p ~/.local/{bin,lib}` +
|
|
1000
|
-
`\n 2. npm config set prefix ~/.local` +
|
|
1001
|
-
`\n 3. retry the install`,
|
|
1002
|
-
question: "Configure user prefix and retry? [y]es / [n]o (Esc rejects) ",
|
|
1003
|
-
});
|
|
1004
|
-
if (ans !== "no") {
|
|
1005
|
-
try {
|
|
1006
|
-
const dir = await setUserNpmPrefix();
|
|
1007
|
-
info(`set npm prefix to ${dir}; retrying install…`);
|
|
1008
|
-
r = await runUpdate();
|
|
1009
|
-
if (r.ok && !localBinOnPath()) {
|
|
1010
|
-
const rc = shellRcPath();
|
|
1011
|
-
if (rc) {
|
|
1012
|
-
const pathAns = await approval.confirm({
|
|
1013
|
-
title: "Add ~/.local/bin to PATH",
|
|
1014
|
-
body: `dsc now lives at ~/.local/bin/dsc but that directory isn't on your PATH.` +
|
|
1015
|
-
`\n\nDsc can append the export line to:` +
|
|
1016
|
-
`\n ${rc}` +
|
|
1017
|
-
`\n\nYou'll need to open a new terminal (or \`source\` the file) for it to take effect.`,
|
|
1018
|
-
question: `Append PATH export to ${rc}? [y]es / [n]o (Esc rejects) `,
|
|
1019
|
-
});
|
|
1020
|
-
if (pathAns !== "no") {
|
|
1021
|
-
try {
|
|
1022
|
-
const edited = await addLocalBinToShellRc();
|
|
1023
|
-
if (edited) {
|
|
1024
|
-
info(`appended PATH export to ${edited}. Open a new terminal or run:` +
|
|
1025
|
-
`\n source ${edited}`);
|
|
1026
|
-
}
|
|
1027
|
-
else {
|
|
1028
|
-
info(`(${rc} already has ~/.local/bin in PATH; no edit needed)`);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
catch (e) {
|
|
1032
|
-
info(`error appending to ${rc}: ${e.message}`);
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
else {
|
|
1036
|
-
info(`Add this line to your shell rc manually:\n export PATH="$HOME/.local/bin:$PATH"`);
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
else {
|
|
1040
|
-
info(`Couldn't autodetect your shell rc. Add this line manually:` +
|
|
1041
|
-
`\n export PATH="$HOME/.local/bin:$PATH"`);
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
catch (e) {
|
|
1046
|
-
info(`error: prefix setup failed: ${e.message}`);
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
if (!r.ok) {
|
|
1051
|
-
// Either non-permission failure, or the user declined the
|
|
1052
|
-
// auto-recovery, or the retry itself failed. Surface npm's
|
|
1053
|
-
// own output verbatim — it usually points at the cause.
|
|
1054
|
-
const tail = isPermError && process.platform !== "win32"
|
|
1055
|
-
? `\n\nQuick one-off if you'd rather: sudo npm install -g @este.systems/dsc@latest`
|
|
1056
|
-
: "";
|
|
1057
|
-
info(`error: update failed\n${r.output.slice(-1500)}${tail}`);
|
|
1058
|
-
return true;
|
|
1059
|
-
}
|
|
1060
|
-
info(`installed ${check.latest}. Exit and re-run \`dsc\` to pick up the new version.`);
|
|
1061
|
-
}
|
|
1062
|
-
catch (e) {
|
|
1063
|
-
info(`error: update failed: ${e.message}`);
|
|
1064
|
-
}
|
|
1065
|
-
return true;
|
|
1066
|
-
}
|
|
1067
|
-
case "api-key": {
|
|
1068
|
-
const text = arg.trim();
|
|
1069
|
-
if (!text) {
|
|
1070
|
-
// No arg: show where the key is configured (don't print the key
|
|
1071
|
-
// itself; that's not the kind of thing /command output should
|
|
1072
|
-
// splash to scrollback).
|
|
1073
|
-
const src = apiKeySource();
|
|
1074
|
-
if (src === "env") {
|
|
1075
|
-
info("api key: set via $DEEPSEEK_API_KEY (env)");
|
|
1076
|
-
}
|
|
1077
|
-
else if (src === "file") {
|
|
1078
|
-
info(`api key: stored in ${configPath()}`);
|
|
1079
|
-
}
|
|
1080
|
-
else {
|
|
1081
|
-
info(`api key: not set\nGet one at https://platform.deepseek.com/api_keys, then run /api-key <key> (or export DEEPSEEK_API_KEY in your shell).`);
|
|
1082
|
-
}
|
|
1083
|
-
return true;
|
|
1084
|
-
}
|
|
1085
|
-
try {
|
|
1086
|
-
const written = await saveApiKey(text);
|
|
1087
|
-
info(`api key saved to ${written}`);
|
|
1088
|
-
}
|
|
1089
|
-
catch (e) {
|
|
1090
|
-
info(`error: ${e.message}`);
|
|
1091
|
-
}
|
|
1092
|
-
return true;
|
|
1093
|
-
}
|
|
1094
|
-
case "search": {
|
|
1095
|
-
// /search -> full status
|
|
1096
|
-
// /search use <provider> -> set active provider in config
|
|
1097
|
-
// /search key <provider> -> show that provider's status + URL
|
|
1098
|
-
// /search key <provider> <key> -> save it to config
|
|
1099
|
-
const SIGNUP = {
|
|
1100
|
-
brave: "https://api-dashboard.search.brave.com/app/keys",
|
|
1101
|
-
tavily: "https://app.tavily.com/",
|
|
1102
|
-
};
|
|
1103
|
-
const ENV_KEY = {
|
|
1104
|
-
brave: "BRAVE_API_KEY",
|
|
1105
|
-
tavily: "TAVILY_API_KEY",
|
|
1106
|
-
};
|
|
1107
|
-
const keySourceFor = (p) => {
|
|
1108
|
-
if (process.env[ENV_KEY[p]])
|
|
1109
|
-
return "env";
|
|
1110
|
-
return getProviderKey(p) ? "file" : null;
|
|
1111
|
-
};
|
|
1112
|
-
const keyStatusLine = (p) => {
|
|
1113
|
-
const src = keySourceFor(p);
|
|
1114
|
-
const status = src === "env"
|
|
1115
|
-
? `set via $${ENV_KEY[p]}`
|
|
1116
|
-
: src === "file"
|
|
1117
|
-
? "stored in config"
|
|
1118
|
-
: "not set";
|
|
1119
|
-
return ` ${p}: ${status} — ${SIGNUP[p]}`;
|
|
1120
|
-
};
|
|
1121
|
-
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
1122
|
-
const sub = tokens[0];
|
|
1123
|
-
// No subcommand: full status.
|
|
1124
|
-
if (!sub) {
|
|
1125
|
-
const active = getProvider();
|
|
1126
|
-
const activeSrc = process.env.DSC_SEARCH_PROVIDER
|
|
1127
|
-
? "set via $DSC_SEARCH_PROVIDER"
|
|
1128
|
-
: "from config";
|
|
1129
|
-
info([
|
|
1130
|
-
`active provider: ${active} (${activeSrc})`,
|
|
1131
|
-
"",
|
|
1132
|
-
"keys:",
|
|
1133
|
-
keyStatusLine("brave"),
|
|
1134
|
-
keyStatusLine("tavily"),
|
|
1135
|
-
" ddg: no key needed (DuckDuckGo HTML scrape)",
|
|
1136
|
-
"",
|
|
1137
|
-
"Subcommands:",
|
|
1138
|
-
" /search use <brave|tavily|ddg> switch active provider",
|
|
1139
|
-
" /search key <provider> [key] show or save api key",
|
|
1140
|
-
].join("\n"));
|
|
1141
|
-
return true;
|
|
1142
|
-
}
|
|
1143
|
-
if (sub === "use") {
|
|
1144
|
-
const name = tokens[1];
|
|
1145
|
-
if (!name || (name !== "brave" && name !== "tavily" && name !== "ddg")) {
|
|
1146
|
-
info("error: usage: /search use <brave|tavily|ddg>");
|
|
1147
|
-
return true;
|
|
1148
|
-
}
|
|
1149
|
-
try {
|
|
1150
|
-
const written = await saveSearchProvider(name);
|
|
1151
|
-
// Warn the user if they just selected a provider whose key
|
|
1152
|
-
// isn't configured — they'll otherwise hit a 401 on the next
|
|
1153
|
-
// web_search and wonder why.
|
|
1154
|
-
const needsKey = name === "brave" || name === "tavily";
|
|
1155
|
-
const haveKey = needsKey ? !!keySourceFor(name) : true;
|
|
1156
|
-
const warn = needsKey && !haveKey
|
|
1157
|
-
? `\n(warning) ${name} has no key set. Run: /search key ${name} <key>`
|
|
1158
|
-
: "";
|
|
1159
|
-
info(`active search provider → ${name} (saved to ${written})${warn}`);
|
|
1160
|
-
}
|
|
1161
|
-
catch (e) {
|
|
1162
|
-
info(`error: ${e.message}`);
|
|
1163
|
-
}
|
|
1164
|
-
return true;
|
|
1165
|
-
}
|
|
1166
|
-
if (sub === "key") {
|
|
1167
|
-
const name = tokens[1];
|
|
1168
|
-
const keyValue = tokens.slice(2).join(" ");
|
|
1169
|
-
if (!name || (name !== "brave" && name !== "tavily")) {
|
|
1170
|
-
info("error: usage: /search key <brave|tavily> [key]");
|
|
1171
|
-
return true;
|
|
1172
|
-
}
|
|
1173
|
-
if (!keyValue) {
|
|
1174
|
-
info(`${name}: ${keySourceFor(name) ?? "not set"}\nsignup: ${SIGNUP[name]}`);
|
|
1175
|
-
return true;
|
|
1176
|
-
}
|
|
1177
|
-
try {
|
|
1178
|
-
const written = await saveSearchKey(name, keyValue);
|
|
1179
|
-
info(`${name} key saved to ${written}`);
|
|
1180
|
-
}
|
|
1181
|
-
catch (e) {
|
|
1182
|
-
info(`error: ${e.message}`);
|
|
1183
|
-
}
|
|
1184
|
-
return true;
|
|
1185
|
-
}
|
|
1186
|
-
info(`error: unknown subcommand '${sub}' (expected: use | key, or no arg for status)`);
|
|
1187
|
-
return true;
|
|
1188
|
-
}
|
|
1189
|
-
case "audit": {
|
|
1190
|
-
const sub = arg.trim();
|
|
1191
|
-
if (!sub || sub === "path") {
|
|
1192
|
-
info(audit.auditLogPath());
|
|
1193
|
-
}
|
|
1194
|
-
else if (sub.startsWith("show")) {
|
|
1195
|
-
const nRaw = sub.replace(/^show\s*/, "").trim();
|
|
1196
|
-
const limit = (() => {
|
|
1197
|
-
const n = nRaw ? parseInt(nRaw, 10) : NaN;
|
|
1198
|
-
return Number.isFinite(n) && n > 0 ? n : 10;
|
|
1199
|
-
})();
|
|
1200
|
-
try {
|
|
1201
|
-
const text = await fsp.readFile(audit.auditLogPath(), "utf8");
|
|
1202
|
-
const lines = text.split("\n").filter((l) => l.length > 0).slice(-limit);
|
|
1203
|
-
info(lines.length ? lines.join("\n") : "(empty)");
|
|
1204
|
-
}
|
|
1205
|
-
catch {
|
|
1206
|
-
info("(no audit log yet)");
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
else {
|
|
1210
|
-
info("error: usage: /audit | /audit path | /audit show [N]");
|
|
1211
|
-
}
|
|
1212
|
-
return true;
|
|
1213
|
-
}
|
|
1214
|
-
case "transcript": {
|
|
1215
|
-
const archived = session.archivedMessages ?? [];
|
|
1216
|
-
if (archived.length === 0 && messages.length === 0) {
|
|
1217
|
-
info("(no messages)");
|
|
1218
|
-
return true;
|
|
1219
|
-
}
|
|
1220
|
-
const lines = [];
|
|
1221
|
-
const renderMsg = (m, isArchived) => {
|
|
1222
|
-
const tag = isArchived ? "archived " : "";
|
|
1223
|
-
lines.push(`\n${tag}${m.role}`);
|
|
1224
|
-
if (m.tool_calls && m.tool_calls.length) {
|
|
1225
|
-
for (const tc of m.tool_calls) {
|
|
1226
|
-
lines.push(` → ${tc.function.name}(${truncate(tc.function.arguments, 200)})`);
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
if (m.tool_call_id)
|
|
1230
|
-
lines.push(` ← tool_call_id: ${m.tool_call_id}`);
|
|
1231
|
-
if (typeof m.content === "string" && m.content)
|
|
1232
|
-
lines.push(m.content);
|
|
1233
|
-
};
|
|
1234
|
-
if (archived.length) {
|
|
1235
|
-
lines.push(`── archived (${archived.length} messages)`);
|
|
1236
|
-
for (const m of archived)
|
|
1237
|
-
renderMsg(m, true);
|
|
1238
|
-
}
|
|
1239
|
-
if (messages.length) {
|
|
1240
|
-
lines.push(`── active (${messages.length} messages)`);
|
|
1241
|
-
for (const m of messages)
|
|
1242
|
-
renderMsg(m, false);
|
|
1243
|
-
}
|
|
1244
|
-
info(lines.join("\n"));
|
|
1245
|
-
return true;
|
|
1246
|
-
}
|
|
1247
|
-
case "compact": {
|
|
1248
|
-
const keepRaw = arg ? parseInt(arg, 10) : NaN;
|
|
1249
|
-
const keep = Number.isFinite(keepRaw) ? Math.max(0, keepRaw) : 4;
|
|
1250
|
-
await runCompaction(keep, false);
|
|
1251
|
-
return true;
|
|
1252
|
-
}
|
|
1253
|
-
case "export": {
|
|
1254
|
-
// `/export [path]` — writes the current session JSON. Relative
|
|
1255
|
-
// paths resolve against the launch cwd, not against the session's
|
|
1256
|
-
// recorded cwd, so it lands where the user actually is. Default
|
|
1257
|
-
// target is the cwd, named after /save'd name (or id) and date.
|
|
1258
|
-
const dest = arg.trim() || ".";
|
|
1259
|
-
const absDest = path.isAbsolute(dest) ? dest : path.resolve(cwd, dest);
|
|
1260
|
-
try {
|
|
1261
|
-
await persist(); // flush any pending writes before export
|
|
1262
|
-
const written = await history.exportSession(session.id, absDest);
|
|
1263
|
-
info(`exported to ${written}`);
|
|
1264
|
-
}
|
|
1265
|
-
catch (e) {
|
|
1266
|
-
info(`error: export failed: ${e.message}`);
|
|
1267
|
-
}
|
|
1268
|
-
return true;
|
|
1269
|
-
}
|
|
1270
|
-
case "import": {
|
|
1271
|
-
// `/import <path>` — reads a session JSON, rebinds its cwd to the
|
|
1272
|
-
// current directory so auto-resume picks it up here, and loads it
|
|
1273
|
-
// as the active session immediately. Keep the previous cwd with
|
|
1274
|
-
// `--keep-cwd` (positional flag, kept simple).
|
|
1275
|
-
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
1276
|
-
const keepCwd = tokens.includes("--keep-cwd");
|
|
1277
|
-
const file = tokens.find((t) => !t.startsWith("--"));
|
|
1278
|
-
if (!file) {
|
|
1279
|
-
info("error: usage: /import <path> [--keep-cwd]");
|
|
1280
|
-
return true;
|
|
1281
|
-
}
|
|
1282
|
-
const absFile = path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
|
1283
|
-
try {
|
|
1284
|
-
const loaded = await history.importSession(absFile, {
|
|
1285
|
-
rebindCwd: keepCwd ? undefined : cwd,
|
|
580
|
+
if (opts.rebuildView) {
|
|
581
|
+
// Rebuild the visible history from the (resumed/imported) session.
|
|
582
|
+
const restored = [];
|
|
583
|
+
for (const m of messages) {
|
|
584
|
+
if (m.role === "system")
|
|
585
|
+
continue;
|
|
586
|
+
restored.push({
|
|
587
|
+
id: `r-${restored.length}`,
|
|
588
|
+
role: m.role,
|
|
589
|
+
content: typeof m.content === "string" ? m.content : "",
|
|
590
|
+
tool_call_id: m.tool_call_id,
|
|
1286
591
|
});
|
|
1287
|
-
session = loaded;
|
|
1288
|
-
messages = session.messages;
|
|
1289
|
-
stats = session.stats;
|
|
1290
|
-
model = session.model;
|
|
1291
|
-
toolCtx.filesTouched = stats.files_touched;
|
|
1292
|
-
toolCtx.sessionId = session.id;
|
|
1293
|
-
// Refill the visible history so the user sees the imported
|
|
1294
|
-
// conversation right away (same logic the /resume path uses).
|
|
1295
|
-
const restored = [];
|
|
1296
|
-
for (const m of messages) {
|
|
1297
|
-
if (m.role === "system")
|
|
1298
|
-
continue;
|
|
1299
|
-
restored.push({
|
|
1300
|
-
id: `r-${restored.length}`,
|
|
1301
|
-
role: m.role,
|
|
1302
|
-
content: typeof m.content === "string" ? m.content : "",
|
|
1303
|
-
tool_call_id: m.tool_call_id,
|
|
1304
|
-
});
|
|
1305
|
-
}
|
|
1306
|
-
setState({ history: restored, current: null, model });
|
|
1307
|
-
syncStatus();
|
|
1308
|
-
const userTurns = messages.filter((m) => m.role === "user").length;
|
|
1309
|
-
const cwdNote = keepCwd ? "" : " (cwd rebound to here)";
|
|
1310
|
-
info(`imported ${session.id} (${userTurns} turns, model ${model})${cwdNote}`);
|
|
1311
592
|
}
|
|
1312
|
-
|
|
1313
|
-
info(`error: import failed: ${e.message}`);
|
|
1314
|
-
}
|
|
1315
|
-
return true;
|
|
593
|
+
setState({ history: restored, current: null, model });
|
|
1316
594
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
// editor gets a clean screen. We also clear `history` before the
|
|
1320
|
-
// unmount, otherwise the freshly-rendered <Static> after remount
|
|
1321
|
-
// would emit all prior turns to scrollback a second time. The
|
|
1322
|
-
// already-printed scrollback above stays untouched.
|
|
1323
|
-
const initial = arg ? arg + "\n" : "";
|
|
1324
|
-
setState({ history: [] });
|
|
1325
|
-
inkInstance?.unmount();
|
|
1326
|
-
const draft = openEditor(initial);
|
|
1327
|
-
mountApp();
|
|
1328
|
-
if (draft === null) {
|
|
1329
|
-
info("error: editor failed");
|
|
1330
|
-
}
|
|
1331
|
-
else if (!draft.trim()) {
|
|
1332
|
-
info("(empty draft, not sent)");
|
|
1333
|
-
}
|
|
1334
|
-
else {
|
|
1335
|
-
handleSubmit(draft);
|
|
1336
|
-
}
|
|
1337
|
-
return true;
|
|
595
|
+
else {
|
|
596
|
+
setState({ history: [], current: null, model });
|
|
1338
597
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
598
|
+
syncStatus();
|
|
599
|
+
},
|
|
600
|
+
runEditor: (initial) => {
|
|
601
|
+
// ink owns the terminal — unmount before spawning $EDITOR so the editor
|
|
602
|
+
// gets a clean screen. Clear `history` first so the post-remount
|
|
603
|
+
// <Static> doesn't re-emit prior turns to scrollback. Already-printed
|
|
604
|
+
// scrollback above stays untouched.
|
|
605
|
+
setState({ history: [] });
|
|
606
|
+
inkInstance?.unmount();
|
|
607
|
+
const draft = openEditor(initial);
|
|
608
|
+
mountApp();
|
|
609
|
+
return draft;
|
|
610
|
+
},
|
|
611
|
+
exit: () => {
|
|
612
|
+
clearInterval(timerId);
|
|
613
|
+
process.exit(0);
|
|
614
|
+
},
|
|
1343
615
|
};
|
|
616
|
+
// Returns true if the input was a recognized slash command and got handled
|
|
617
|
+
// (or rejected) — caller should not forward it to the agent. Returns false
|
|
618
|
+
// for non-slash input and unknown commands (caller decides what to do).
|
|
619
|
+
const handleSlash = (line) => dispatchSlash(line, slashContext);
|
|
1344
620
|
const handleSubmit = (text) => {
|
|
1345
621
|
// Persist every submitted line — slash commands included, since
|
|
1346
622
|
// recalling "/resume 3" via arrow-up is useful.
|
|
@@ -1577,19 +853,6 @@ function formatTaskLabel(name, argsJson) {
|
|
|
1577
853
|
return name;
|
|
1578
854
|
return `${name}: ${truncate(primary, 60)}`;
|
|
1579
855
|
}
|
|
1580
|
-
function formatRelative(ts) {
|
|
1581
|
-
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
1582
|
-
if (s < 60)
|
|
1583
|
-
return `${s}s ago`;
|
|
1584
|
-
const m = Math.floor(s / 60);
|
|
1585
|
-
if (m < 60)
|
|
1586
|
-
return `${m}m ago`;
|
|
1587
|
-
const h = Math.floor(m / 60);
|
|
1588
|
-
if (h < 24)
|
|
1589
|
-
return `${h}h ago`;
|
|
1590
|
-
const d = Math.floor(h / 24);
|
|
1591
|
-
return `${d}d ago`;
|
|
1592
|
-
}
|
|
1593
856
|
main().catch((e) => {
|
|
1594
857
|
process.stderr.write(`fatal: ${e.message ?? e}\n`);
|
|
1595
858
|
process.exit(1);
|