@este.systems/dsc 0.2.2 → 0.4.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 +166 -0
- package/README.md +85 -43
- package/bin/dsc.mjs +3 -12
- package/dist/agent.js +5 -5
- package/dist/agent.js.map +1 -1
- package/dist/api.js +108 -0
- package/dist/api.js.map +1 -1
- package/dist/approval.js +58 -27
- package/dist/approval.js.map +1 -1
- package/dist/clipboard.js +59 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/history.js +106 -0
- package/dist/history.js.map +1 -1
- package/dist/prompt.js +2 -1
- package/dist/prompt.js.map +1 -1
- package/dist/slash.js +6 -0
- package/dist/slash.js.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +74 -33
- package/dist/tools.js.map +1 -1
- package/dist/tui/ApprovalDialog.js +41 -1
- package/dist/tui/ApprovalDialog.js.map +1 -1
- package/dist/tui/History.js +14 -1
- package/dist/tui/History.js.map +1 -1
- package/dist/tui/Input.js +17 -0
- package/dist/tui/Input.js.map +1 -1
- package/dist/tui.js +357 -34
- package/dist/tui.js.map +1 -1
- package/dist/ui.js +7 -94
- package/dist/ui.js.map +1 -1
- package/dist/update.js +163 -0
- package/dist/update.js.map +1 -0
- package/package.json +5 -5
- package/dist/index.js +0 -916
- package/dist/index.js.map +0 -1
- package/dist/markdown.js +0 -543
- package/dist/markdown.js.map +0 -1
package/dist/tui.js
CHANGED
|
@@ -2,21 +2,28 @@ 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 { AVAILABLE_MODELS, DEFAULT_MODEL, DeepSeekError, computeCostUsd, configPath, hasApiKey, recordUsage, } from "./api.js";
|
|
5
|
+
import { AVAILABLE_MODELS, DEFAULT_MODEL, DeepSeekError, apiKeySource, computeCostUsd, configPath, hasApiKey, recordUsage, saveApiKey, saveSearchKey, } from "./api.js";
|
|
6
|
+
import { getProviderKey } from "./search.js";
|
|
6
7
|
import { runAgent, formatCost, estimateContextTokens, } from "./agent.js";
|
|
7
8
|
import * as history from "./history.js";
|
|
8
9
|
import * as approval from "./approval.js";
|
|
9
10
|
import * as audit from "./audit.js";
|
|
10
11
|
import * as replHistory from "./repl_history.js";
|
|
11
12
|
import { promises as fsp } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
12
15
|
import { compactSession } from "./compact.js";
|
|
13
16
|
import { formatVersionInfo } from "./version.js";
|
|
14
17
|
import { openEditor } from "./editor.js";
|
|
18
|
+
import { checkForUpdate, runUpdate } from "./update.js";
|
|
19
|
+
import { copyToClipboard } from "./clipboard.js";
|
|
15
20
|
const AUTO_COMPACT_AT_TOKENS = Number(process.env.DSC_AUTO_COMPACT ?? "0") || 0;
|
|
16
21
|
const AUTO_COMPACT_KEEP = Number(process.env.DSC_AUTO_COMPACT_KEEP ?? "4") || 4;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
22
|
+
class CliError extends Error {
|
|
23
|
+
}
|
|
24
|
+
// Full arg parsing for the TUI entry — this is the only entry point now
|
|
25
|
+
// that the readline REPL is gone. Anything the old REPL accepted needs to
|
|
26
|
+
// be handled here.
|
|
20
27
|
function parseArgs(argv) {
|
|
21
28
|
const out = {};
|
|
22
29
|
const positional = [];
|
|
@@ -30,9 +37,24 @@ function parseArgs(argv) {
|
|
|
30
37
|
out.yolo = true;
|
|
31
38
|
else if (a === "--no-resume")
|
|
32
39
|
out.noResume = true;
|
|
40
|
+
else if (a === "--resume") {
|
|
41
|
+
const v = argv[i + 1];
|
|
42
|
+
if (v && !v.startsWith("-")) {
|
|
43
|
+
out.resumeId = v;
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
out.resume = true;
|
|
47
|
+
}
|
|
48
|
+
else if (a === "--model" || a === "-m") {
|
|
49
|
+
const v = argv[++i];
|
|
50
|
+
if (!AVAILABLE_MODELS.includes(v)) {
|
|
51
|
+
throw new CliError(`unknown model: ${v} (available: ${AVAILABLE_MODELS.join(", ")})`);
|
|
52
|
+
}
|
|
53
|
+
out.model = v;
|
|
54
|
+
out.modelExplicit = true;
|
|
55
|
+
}
|
|
33
56
|
else if (a.startsWith("-")) {
|
|
34
|
-
|
|
35
|
-
// (--model, --resume <id>) to index.ts instead.
|
|
57
|
+
throw new CliError(`unknown flag: ${a}`);
|
|
36
58
|
}
|
|
37
59
|
else
|
|
38
60
|
positional.push(a);
|
|
@@ -42,41 +64,59 @@ function parseArgs(argv) {
|
|
|
42
64
|
return out;
|
|
43
65
|
}
|
|
44
66
|
async function main() {
|
|
45
|
-
|
|
67
|
+
let cli;
|
|
68
|
+
try {
|
|
69
|
+
cli = parseArgs(process.argv.slice(2));
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e instanceof CliError) {
|
|
73
|
+
process.stderr.write(`${e.message}\n`);
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
46
78
|
if (cli.version) {
|
|
47
79
|
process.stdout.write(formatVersionInfo() + "\n");
|
|
48
80
|
process.exit(0);
|
|
49
81
|
}
|
|
50
82
|
if (cli.help) {
|
|
51
83
|
process.stdout.write([
|
|
52
|
-
"dsc — CLI coding agent for DeepSeek
|
|
84
|
+
"dsc — CLI coding agent for DeepSeek",
|
|
53
85
|
"",
|
|
54
86
|
"Usage:",
|
|
55
|
-
" dsc
|
|
56
|
-
" dsc \"your prompt here\"
|
|
57
|
-
" dsc --repl Use the readline REPL instead",
|
|
87
|
+
" dsc Start the TUI",
|
|
88
|
+
" dsc \"your prompt here\" One-shot: run and exit",
|
|
58
89
|
"",
|
|
59
|
-
"Flags
|
|
60
|
-
" -
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"",
|
|
65
|
-
"
|
|
90
|
+
"Flags:",
|
|
91
|
+
" -m, --model <name> " + AVAILABLE_MODELS.join(" | "),
|
|
92
|
+
" -y, --yolo Skip approval prompts",
|
|
93
|
+
" --no-resume Don't auto-resume the latest session",
|
|
94
|
+
" --resume [id] Resume the most recent (or by id)",
|
|
95
|
+
" -v, --version Print version and exit",
|
|
96
|
+
" -h, --help Show this help",
|
|
66
97
|
].join("\n") + "\n");
|
|
67
98
|
process.exit(0);
|
|
68
99
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
100
|
+
// First-launch UX: don't hard-exit when there's no API key. Boot the
|
|
101
|
+
// TUI and surface a one-line system message telling the user how to
|
|
102
|
+
// set one via /api-key. Submitting a turn before setting the key will
|
|
103
|
+
// surface DeepSeekError, which we already render as a system error.
|
|
104
|
+
const startupKeyMissing = !hasApiKey();
|
|
75
105
|
const cwd = process.cwd();
|
|
76
106
|
await history.migrateLegacyIfPresent(cwd, DEFAULT_MODEL);
|
|
77
|
-
// Auto-resume most recent for cwd unless --no-resume; otherwise fresh.
|
|
78
107
|
let session = history.newSession(cwd, DEFAULT_MODEL);
|
|
79
|
-
if (
|
|
108
|
+
if (cli.resumeId) {
|
|
109
|
+
// Explicit --resume <id>: load that session by id, error out if it
|
|
110
|
+
// doesn't exist rather than silently falling back to a new session.
|
|
111
|
+
const loaded = await history.loadSession(cli.resumeId);
|
|
112
|
+
if (!loaded) {
|
|
113
|
+
process.stderr.write(`session not found: ${cli.resumeId}\n`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
session = loaded;
|
|
117
|
+
}
|
|
118
|
+
else if (!cli.noResume) {
|
|
119
|
+
// Auto-resume most recent for this cwd; otherwise fresh.
|
|
80
120
|
const target = await history.mostRecentForCwd(cwd);
|
|
81
121
|
if (target) {
|
|
82
122
|
const loaded = await history.loadSession(target.id);
|
|
@@ -88,13 +128,17 @@ async function main() {
|
|
|
88
128
|
// out for the new session's arrays without restarting the process.
|
|
89
129
|
let messages = session.messages;
|
|
90
130
|
let stats = session.stats;
|
|
91
|
-
|
|
131
|
+
// --model on the command line overrides the session's stored model.
|
|
132
|
+
// Without --model, the session's model wins so resuming preserves what
|
|
133
|
+
// the user picked last time.
|
|
134
|
+
let model = cli.modelExplicit && cli.model ? cli.model : session.model;
|
|
92
135
|
const initialAutoContinue = Number(process.env.DSC_AUTO_CONTINUE ?? "0") || 0;
|
|
93
136
|
const toolCtx = {
|
|
94
137
|
cwd,
|
|
95
138
|
yolo: !!cli.yolo,
|
|
96
139
|
filesTouched: stats.files_touched,
|
|
97
140
|
sessionId: session.id,
|
|
141
|
+
sessionApprovals: new Set(),
|
|
98
142
|
};
|
|
99
143
|
// Coalescing save (same shape as REPL).
|
|
100
144
|
let savePromise = null;
|
|
@@ -166,15 +210,16 @@ async function main() {
|
|
|
166
210
|
// 1-second tick so the session timer in StatusBar advances even when idle.
|
|
167
211
|
const timerId = setInterval(syncStatus, 1000);
|
|
168
212
|
// Install the approval asker — routes confirm* calls through the
|
|
169
|
-
// ApprovalDialog component. The
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
approval.setAsker((
|
|
213
|
+
// ApprovalDialog component. The structured payload (title + body + kind)
|
|
214
|
+
// goes into the dialog so the diff/preview renders inline in the yellow
|
|
215
|
+
// bordered box rather than bleeding above ink's dynamic frame.
|
|
216
|
+
approval.setAsker((req) => new Promise((resolve) => {
|
|
173
217
|
setState({
|
|
174
218
|
approval: {
|
|
175
|
-
title:
|
|
176
|
-
body: "",
|
|
177
|
-
|
|
219
|
+
title: req.title,
|
|
220
|
+
body: req.body ?? "",
|
|
221
|
+
kind: req.kind,
|
|
222
|
+
question: req.question,
|
|
178
223
|
resolve,
|
|
179
224
|
},
|
|
180
225
|
});
|
|
@@ -439,11 +484,17 @@ async function main() {
|
|
|
439
484
|
"/lang [name|off] force the model to reply in a language",
|
|
440
485
|
"/auto-continue [N|off] auto-grant N extra MAX_TOOL_DEPTH budgets",
|
|
441
486
|
"/cost show token usage and cost",
|
|
487
|
+
"/copy copy last assistant response to clipboard",
|
|
442
488
|
"/version show version info",
|
|
443
489
|
"/compact [keep] summarize old turns (default keep=4)",
|
|
444
490
|
"/transcript dump full message log",
|
|
445
491
|
"/audit [path|show N] audit log info",
|
|
446
492
|
"/queue [clear] list or clear queued prompts",
|
|
493
|
+
"/export [path] write current session JSON for transfer",
|
|
494
|
+
"/import <path> load session JSON; rebinds cwd here (--keep-cwd to skip)",
|
|
495
|
+
"/api-key [key] show source / save api key to config file",
|
|
496
|
+
"/search-key [p] [key] show search-provider keys / save brave|tavily key",
|
|
497
|
+
"/update check npm for a newer dsc and install it",
|
|
447
498
|
"/exit exit",
|
|
448
499
|
].join("\n"));
|
|
449
500
|
return true;
|
|
@@ -453,6 +504,10 @@ async function main() {
|
|
|
453
504
|
stats = session.stats;
|
|
454
505
|
toolCtx.filesTouched = stats.files_touched;
|
|
455
506
|
toolCtx.sessionId = session.id;
|
|
507
|
+
// Per-tool always-approval is conversation-scoped, so /clear has
|
|
508
|
+
// to drop it — otherwise the user would carry over implicit trust
|
|
509
|
+
// they made to a session they just walked away from.
|
|
510
|
+
toolCtx.sessionApprovals = new Set();
|
|
456
511
|
setState({ history: [], current: null });
|
|
457
512
|
info(`new session started (${session.id})`);
|
|
458
513
|
syncStatus();
|
|
@@ -560,6 +615,32 @@ async function main() {
|
|
|
560
615
|
case "cost":
|
|
561
616
|
info(formatCost(stats, model));
|
|
562
617
|
return true;
|
|
618
|
+
case "copy": {
|
|
619
|
+
// Copy the most recent assistant response to the OS clipboard. The
|
|
620
|
+
// last *assistant* (not tool, not system, not user) is what the
|
|
621
|
+
// user usually wants — the actual answer text.
|
|
622
|
+
const state = getState();
|
|
623
|
+
let target;
|
|
624
|
+
for (let i = state.history.length - 1; i >= 0; i--) {
|
|
625
|
+
const m = state.history[i];
|
|
626
|
+
if (m.role === "assistant" && m.content) {
|
|
627
|
+
target = m;
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!target) {
|
|
632
|
+
info("no assistant message to copy");
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
await copyToClipboard(target.content);
|
|
637
|
+
info(`copied ${target.content.length} chars to clipboard`);
|
|
638
|
+
}
|
|
639
|
+
catch (e) {
|
|
640
|
+
info(`error: ${e.message}`);
|
|
641
|
+
}
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
563
644
|
case "model":
|
|
564
645
|
if (!arg) {
|
|
565
646
|
info(`current model: ${model}`);
|
|
@@ -648,6 +729,117 @@ async function main() {
|
|
|
648
729
|
case "version":
|
|
649
730
|
info(formatVersionInfo());
|
|
650
731
|
return true;
|
|
732
|
+
case "update": {
|
|
733
|
+
// Two-phase: check first so we don't run npm install when we're
|
|
734
|
+
// already current. Force-fetches the latest (ignores the 24h cache
|
|
735
|
+
// because the user explicitly asked) and reports either way.
|
|
736
|
+
info("checking npm for a newer version…");
|
|
737
|
+
try {
|
|
738
|
+
const check = await checkForUpdate({ force: true });
|
|
739
|
+
if (!check.newerAvailable) {
|
|
740
|
+
info(`up to date (${check.current})`);
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
info(`installing ${check.latest} (currently ${check.current}) via npm…`);
|
|
744
|
+
const r = await runUpdate();
|
|
745
|
+
if (!r.ok) {
|
|
746
|
+
// npm prints reasonably descriptive errors itself; pass them
|
|
747
|
+
// through so EACCES / permission issues are obvious.
|
|
748
|
+
info(`error: update failed\n${r.output.slice(-1500)}\n\n` +
|
|
749
|
+
`If this is a permission error, try: sudo npm install -g @este.systems/dsc@latest`);
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
info(`installed ${check.latest}. Exit and re-run \`dsc\` to pick up the new version.`);
|
|
753
|
+
}
|
|
754
|
+
catch (e) {
|
|
755
|
+
info(`error: update failed: ${e.message}`);
|
|
756
|
+
}
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
case "api-key": {
|
|
760
|
+
const text = arg.trim();
|
|
761
|
+
if (!text) {
|
|
762
|
+
// No arg: show where the key is configured (don't print the key
|
|
763
|
+
// itself; that's not the kind of thing /command output should
|
|
764
|
+
// splash to scrollback).
|
|
765
|
+
const src = apiKeySource();
|
|
766
|
+
if (src === "env") {
|
|
767
|
+
info("api key: set via $DEEPSEEK_API_KEY (env)");
|
|
768
|
+
}
|
|
769
|
+
else if (src === "file") {
|
|
770
|
+
info(`api key: stored in ${configPath()}`);
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
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).`);
|
|
774
|
+
}
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
const written = await saveApiKey(text);
|
|
779
|
+
info(`api key saved to ${written}`);
|
|
780
|
+
}
|
|
781
|
+
catch (e) {
|
|
782
|
+
info(`error: ${e.message}`);
|
|
783
|
+
}
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
case "search-key": {
|
|
787
|
+
// /search-key -> list providers + status + URLs
|
|
788
|
+
// /search-key <provider> -> show that provider's status + URL
|
|
789
|
+
// /search-key <provider> <key> -> save it to config
|
|
790
|
+
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
791
|
+
const [providerArg, ...keyParts] = tokens;
|
|
792
|
+
const keyArg = keyParts.join(" ");
|
|
793
|
+
const SIGNUP = {
|
|
794
|
+
brave: "https://api-dashboard.search.brave.com/app/keys",
|
|
795
|
+
tavily: "https://app.tavily.com/",
|
|
796
|
+
};
|
|
797
|
+
const ENV_VAR = {
|
|
798
|
+
brave: "BRAVE_API_KEY",
|
|
799
|
+
tavily: "TAVILY_API_KEY",
|
|
800
|
+
};
|
|
801
|
+
const sourceFor = (p) => {
|
|
802
|
+
if (process.env[ENV_VAR[p]])
|
|
803
|
+
return "env";
|
|
804
|
+
return getProviderKey(p) ? "file" : null;
|
|
805
|
+
};
|
|
806
|
+
const lineFor = (p) => {
|
|
807
|
+
const src = sourceFor(p);
|
|
808
|
+
const status = src === "env"
|
|
809
|
+
? `set via $${ENV_VAR[p]}`
|
|
810
|
+
: src === "file"
|
|
811
|
+
? "stored in config"
|
|
812
|
+
: "not set";
|
|
813
|
+
return ` ${p}: ${status} — ${SIGNUP[p]}`;
|
|
814
|
+
};
|
|
815
|
+
if (!providerArg) {
|
|
816
|
+
info([
|
|
817
|
+
"search providers:",
|
|
818
|
+
lineFor("brave"),
|
|
819
|
+
lineFor("tavily"),
|
|
820
|
+
"",
|
|
821
|
+
"To save a key: /search-key <provider> <key>",
|
|
822
|
+
"Pick the active provider with DSC_SEARCH_PROVIDER or `search.provider` in the config.",
|
|
823
|
+
].join("\n"));
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
if (providerArg !== "brave" && providerArg !== "tavily") {
|
|
827
|
+
info(`error: unknown provider '${providerArg}' (expected: brave | tavily)`);
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
if (!keyArg) {
|
|
831
|
+
info(`${providerArg}: ${sourceFor(providerArg) ?? "not set"}\nsignup: ${SIGNUP[providerArg]}`);
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
const written = await saveSearchKey(providerArg, keyArg);
|
|
836
|
+
info(`${providerArg} key saved to ${written}`);
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
info(`error: ${e.message}`);
|
|
840
|
+
}
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
651
843
|
case "audit": {
|
|
652
844
|
const sub = arg.trim();
|
|
653
845
|
if (!sub || sub === "path") {
|
|
@@ -712,6 +904,70 @@ async function main() {
|
|
|
712
904
|
await runCompaction(keep, false);
|
|
713
905
|
return true;
|
|
714
906
|
}
|
|
907
|
+
case "export": {
|
|
908
|
+
// `/export [path]` — writes the current session JSON. Relative
|
|
909
|
+
// paths resolve against the launch cwd, not against the session's
|
|
910
|
+
// recorded cwd, so it lands where the user actually is. Default
|
|
911
|
+
// target is the cwd, named after /save'd name (or id) and date.
|
|
912
|
+
const dest = arg.trim() || ".";
|
|
913
|
+
const absDest = path.isAbsolute(dest) ? dest : path.resolve(cwd, dest);
|
|
914
|
+
try {
|
|
915
|
+
await persist(); // flush any pending writes before export
|
|
916
|
+
const written = await history.exportSession(session.id, absDest);
|
|
917
|
+
info(`exported to ${written}`);
|
|
918
|
+
}
|
|
919
|
+
catch (e) {
|
|
920
|
+
info(`error: export failed: ${e.message}`);
|
|
921
|
+
}
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
case "import": {
|
|
925
|
+
// `/import <path>` — reads a session JSON, rebinds its cwd to the
|
|
926
|
+
// current directory so auto-resume picks it up here, and loads it
|
|
927
|
+
// as the active session immediately. Keep the previous cwd with
|
|
928
|
+
// `--keep-cwd` (positional flag, kept simple).
|
|
929
|
+
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
930
|
+
const keepCwd = tokens.includes("--keep-cwd");
|
|
931
|
+
const file = tokens.find((t) => !t.startsWith("--"));
|
|
932
|
+
if (!file) {
|
|
933
|
+
info("error: usage: /import <path> [--keep-cwd]");
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
const absFile = path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
|
937
|
+
try {
|
|
938
|
+
const loaded = await history.importSession(absFile, {
|
|
939
|
+
rebindCwd: keepCwd ? undefined : cwd,
|
|
940
|
+
});
|
|
941
|
+
session = loaded;
|
|
942
|
+
messages = session.messages;
|
|
943
|
+
stats = session.stats;
|
|
944
|
+
model = session.model;
|
|
945
|
+
toolCtx.filesTouched = stats.files_touched;
|
|
946
|
+
toolCtx.sessionId = session.id;
|
|
947
|
+
// Refill the visible history so the user sees the imported
|
|
948
|
+
// conversation right away (same logic the /resume path uses).
|
|
949
|
+
const restored = [];
|
|
950
|
+
for (const m of messages) {
|
|
951
|
+
if (m.role === "system")
|
|
952
|
+
continue;
|
|
953
|
+
restored.push({
|
|
954
|
+
id: `r-${restored.length}`,
|
|
955
|
+
role: m.role,
|
|
956
|
+
content: typeof m.content === "string" ? m.content : "",
|
|
957
|
+
tool_call_id: m.tool_call_id,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
setState({ history: restored, current: null, model });
|
|
961
|
+
syncStatus();
|
|
962
|
+
const userTurns = messages.filter((m) => m.role === "user").length;
|
|
963
|
+
const cwdNote = keepCwd ? "" : " (cwd rebound to here)";
|
|
964
|
+
info(`imported ${session.id} (${userTurns} turns, model ${model})${cwdNote}`);
|
|
965
|
+
}
|
|
966
|
+
catch (e) {
|
|
967
|
+
info(`error: import failed: ${e.message}`);
|
|
968
|
+
}
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
715
971
|
case "edit": {
|
|
716
972
|
// ink owns the terminal — unmount before spawning $EDITOR so the
|
|
717
973
|
// editor gets a clean screen. We also clear `history` before the
|
|
@@ -747,6 +1003,16 @@ async function main() {
|
|
|
747
1003
|
void replHistory.append(text);
|
|
748
1004
|
}
|
|
749
1005
|
if (text.startsWith("/")) {
|
|
1006
|
+
// Echo the command into history so the user can see which command
|
|
1007
|
+
// produced the system-line output that follows. Without this the
|
|
1008
|
+
// info() lines look orphaned — especially when you scroll back
|
|
1009
|
+
// and there's no record of what triggered each one.
|
|
1010
|
+
setState((s) => ({
|
|
1011
|
+
history: [
|
|
1012
|
+
...s.history,
|
|
1013
|
+
{ id: `u-${s.history.length}`, role: "user", content: text },
|
|
1014
|
+
],
|
|
1015
|
+
}));
|
|
750
1016
|
void handleSlash(text);
|
|
751
1017
|
return;
|
|
752
1018
|
}
|
|
@@ -793,6 +1059,63 @@ async function main() {
|
|
|
793
1059
|
process.exit(0);
|
|
794
1060
|
}
|
|
795
1061
|
mountApp();
|
|
1062
|
+
// One-time welcome panel. Stamped via a touch-file under XDG_STATE_HOME
|
|
1063
|
+
// so we never nag the same user twice. New users get an orientation
|
|
1064
|
+
// (key, /help, hotkeys); the more-targeted "no API key" reminder fires
|
|
1065
|
+
// on every subsequent launch when no key is configured.
|
|
1066
|
+
const stateDir = (() => {
|
|
1067
|
+
const xdg = process.env.XDG_STATE_HOME;
|
|
1068
|
+
const base = xdg && xdg.length ? xdg : path.join(homedir(), ".local", "state");
|
|
1069
|
+
return path.join(base, "dsc");
|
|
1070
|
+
})();
|
|
1071
|
+
const welcomeStamp = path.join(stateDir, "welcomed");
|
|
1072
|
+
let alreadyWelcomed = false;
|
|
1073
|
+
try {
|
|
1074
|
+
await fsp.access(welcomeStamp);
|
|
1075
|
+
alreadyWelcomed = true;
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
// first launch
|
|
1079
|
+
}
|
|
1080
|
+
if (!alreadyWelcomed) {
|
|
1081
|
+
const lines = [
|
|
1082
|
+
"Welcome to dsc — a CLI coding agent for DeepSeek.",
|
|
1083
|
+
"",
|
|
1084
|
+
" /help full command list (TAB completes any /command)",
|
|
1085
|
+
" Up / Down recall past prompts",
|
|
1086
|
+
" ESC abort a running turn",
|
|
1087
|
+
" Ctrl+D exit",
|
|
1088
|
+
];
|
|
1089
|
+
if (startupKeyMissing) {
|
|
1090
|
+
lines.push("", "To get started, save your API key:", " /api-key sk-... (get one at https://platform.deepseek.com/api_keys)", " or: export DEEPSEEK_API_KEY in your shell");
|
|
1091
|
+
}
|
|
1092
|
+
info(lines.join("\n"));
|
|
1093
|
+
try {
|
|
1094
|
+
await fsp.mkdir(stateDir, { recursive: true });
|
|
1095
|
+
await fsp.writeFile(welcomeStamp, new Date().toISOString(), "utf8");
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
// best-effort; missing perms just means the welcome shows again
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
else if (startupKeyMissing) {
|
|
1102
|
+
// Targeted nudge for returning users who somehow lost their key.
|
|
1103
|
+
info(`No DeepSeek API key found. Run "/api-key <key>" to save one to ${configPath()}, or export DEEPSEEK_API_KEY in your shell.`);
|
|
1104
|
+
}
|
|
1105
|
+
// Fire-and-forget update probe. Uses the 24h cache by default so a
|
|
1106
|
+
// chatty notice doesn't appear on every launch — only once per day,
|
|
1107
|
+
// when the registry says we're behind. Failures are swallowed silently.
|
|
1108
|
+
void (async () => {
|
|
1109
|
+
try {
|
|
1110
|
+
const check = await checkForUpdate();
|
|
1111
|
+
if (check.newerAvailable) {
|
|
1112
|
+
info(`update available: ${check.latest} (you have ${check.current}). Run /update to install.`);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
// best-effort; offline launches shouldn't yell at the user
|
|
1117
|
+
}
|
|
1118
|
+
})();
|
|
796
1119
|
}
|
|
797
1120
|
function truncate(s, n) {
|
|
798
1121
|
return s.length <= n ? s : s.slice(0, n) + "…";
|