deepseek-cli-mode 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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 — live slash-command menu
4
+
5
+ - Typing `/` at the prompt now opens a live command menu (like Claude Code): matching commands and descriptions,
6
+ `↑`/`↓` to move, `Tab` to complete, `Enter` to run the highlighted command.
7
+ - New raw-mode line editor (`runner/line-editor.js`) for TTY sessions: cursor movement, history, bracketed paste,
8
+ Ctrl+A/E, and a menu that filters as you type.
9
+ - Piped/non-TTY input keeps the existing readline path; one-shot mode is unchanged.
10
+ - Security: the editor strips C0/C1 control characters from inserted text and renders command names/descriptions
11
+ through the sanitizer; it never executes anything itself.
12
+ - The launcher now copies every runner module into the profile (previously only `runner.js`).
13
+
3
14
  ## 1.1.0 — Claude-Code-style terminal UI
4
15
 
5
16
  - New welcome panel: rounded box with version, model, cwd, and first-run hints.
package/README.md CHANGED
@@ -134,6 +134,16 @@ deepseek "review @src/app.ts and tell me what is risky"
134
134
 
135
135
  ### Inside a session
136
136
 
137
+ Typing `/` opens a live command menu, like Claude Code:
138
+
139
+ ```
140
+ > /se
141
+ ❯ /session Print the current session id
142
+ /sessions List recent sessions in this directory
143
+ ```
144
+
145
+ `↑`/`↓` moves the selection, `Tab` completes it, `Enter` runs it. The menu filters as you type.
146
+
137
147
  | Command | What it does |
138
148
  |---|---|
139
149
  | `/help` | command list, including the live Harness command registry |
package/bin/deepseek.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // The profile keeps ONE live Agent for the whole session, so tools, approvals,
10
10
  // session persistence, and the model's request prefix stay warm between turns.
11
11
  import { spawn, spawnSync } from "node:child_process";
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
13
13
  import { createRequire } from "node:module";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, delimiter, join, resolve } from "node:path";
@@ -198,11 +198,14 @@ function ensureProfile() {
198
198
  if (!existsSync(target) || stampVersion !== VERSION) writeFileSync(target, content);
199
199
  }
200
200
 
201
- // Keep the runner exactly in sync with this package.
202
- const runnerSrc = join(root, "runner", "runner.js");
203
- const runnerDst = join(profileDir, "runner.js");
204
- const src = readFileSync(runnerSrc, "utf8");
205
- if (!existsSync(runnerDst) || readFileSync(runnerDst, "utf8") !== src) writeFileSync(runnerDst, src);
201
+ // Keep every runner file exactly in sync with this package.
202
+ const runnerSrcDir = join(root, "runner");
203
+ for (const entry of readdirSync(runnerSrcDir, { withFileTypes: true })) {
204
+ if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/.test(entry.name)) continue;
205
+ const src = readFileSync(join(runnerSrcDir, entry.name), "utf8");
206
+ const dst = join(profileDir, entry.name);
207
+ if (!existsSync(dst) || readFileSync(dst, "utf8") !== src) writeFileSync(dst, src);
208
+ }
206
209
 
207
210
  writeFileSync(stamp, VERSION + "\n");
208
211
  return profileDir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepseek-cli-mode",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Terminal (CLI) mode for DeepSeek Harness — an interactive, Claude Code-style coding agent for your shell.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,354 @@
1
+ // Minimal terminal line editor with a live slash-command menu, in the style of
2
+ // Claude Code. No dependencies. Attaches to a TTY in raw mode and renders one
3
+ // input line plus an optional suggestion list below it.
4
+ //
5
+ // Security: every string this module prints as user content is stripped of
6
+ // C0/C1 control characters (except newline/tab handled by the caller). The
7
+ // editor never executes anything; it only returns the submitted line.
8
+
9
+ const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
10
+ const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
11
+
12
+ /** Visible width of a prompt that may contain ANSI escapes. */
13
+ function visibleWidth(text) {
14
+ return String(text).replace(ANSI, "").length;
15
+ }
16
+
17
+ /** Strip terminal control characters from untrusted text. */
18
+ function clean(text) {
19
+ return String(text).replace(CONTROL_CHARS, "");
20
+ }
21
+
22
+ export class LineEditor {
23
+ /**
24
+ * @param {object} options
25
+ * @param {NodeJS.ReadStream} options.input
26
+ * @param {NodeJS.WriteStream} options.output
27
+ * @param {() => number} [options.columns]
28
+ * @param {() => void} [options.onInterrupt] called for Ctrl+C while no question is pending
29
+ * @param {() => void} [options.onEnd] called when stdin ends
30
+ */
31
+ constructor({ input, output, columns, onInterrupt, onEnd }) {
32
+ this.input = input;
33
+ this.output = output;
34
+ this.columns = columns ?? (() => this.output.columns || 80);
35
+ this.onInterrupt = onInterrupt ?? (() => {});
36
+ this.onEnd = onEnd ?? (() => {});
37
+ this.commands = [];
38
+ this.buffer = "";
39
+ this.cursor = 0;
40
+ this.history = [];
41
+ this.historyIndex = -1;
42
+ this.draft = "";
43
+ this.menu = [];
44
+ this.menuIndex = 0;
45
+ this.active = false;
46
+ this.menuEnabled = false;
47
+ this.prompt = "";
48
+ this.resolve = null;
49
+ this.renderedRows = 0;
50
+ this.paste = false;
51
+ this.escape = null;
52
+ this.started = false;
53
+ this._onData = (chunk) => this.handleData(chunk);
54
+ this._onEnd = () => {
55
+ this.onEnd();
56
+ this.settle(null, false);
57
+ };
58
+ }
59
+
60
+ start() {
61
+ if (this.started) return;
62
+ this.started = true;
63
+ this.input.setRawMode?.(true);
64
+ this.input.setEncoding?.("utf8");
65
+ this.input.resume?.();
66
+ this.input.on("data", this._onData);
67
+ this.input.on("end", this._onEnd);
68
+ this.output.write("\x1b[?2004h");
69
+ }
70
+
71
+ stop() {
72
+ if (!this.started) return;
73
+ this.started = false;
74
+ this.input.off?.("data", this._onData);
75
+ this.input.off?.("end", this._onEnd);
76
+ this.output.write("\x1b[?2004l");
77
+ this.input.setRawMode?.(false);
78
+ this.input.pause?.();
79
+ }
80
+
81
+ /** Replace the suggestion list (name + description objects). */
82
+ setCommands(commands) {
83
+ this.commands = Array.isArray(commands) ? commands : [];
84
+ }
85
+
86
+ /** Enable/disable input while a turn runs (Ctrl+C still interrupts). */
87
+ setActive(active) {
88
+ if (this.active === active) return;
89
+ this.active = active;
90
+ if (!active) this.clearRender();
91
+ }
92
+
93
+ /**
94
+ * Ask one line.
95
+ * @param {string} prompt prompt string (may contain ANSI)
96
+ * @param {boolean} menuEnabled show slash-command suggestions
97
+ * @returns {Promise<string|null>} line, or null on Ctrl+C / EOF
98
+ */
99
+ ask(prompt, menuEnabled = false) {
100
+ this.prompt = prompt;
101
+ this.menuEnabled = menuEnabled;
102
+ this.buffer = "";
103
+ this.cursor = 0;
104
+ this.menu = [];
105
+ this.menuIndex = 0;
106
+ this.historyIndex = -1;
107
+ this.draft = "";
108
+ this.active = true;
109
+ this.renderedRows = 0;
110
+ this.render();
111
+ return new Promise((resolve) => {
112
+ this.resolve = resolve;
113
+ });
114
+ }
115
+
116
+ // ---- input --------------------------------------------------------------
117
+ handleData(chunk) {
118
+ for (const char of String(chunk)) {
119
+ if (this.escape !== null) {
120
+ this.escape += char;
121
+ if (/[A-Za-z~]$/.test(char)) {
122
+ const sequence = this.escape;
123
+ this.escape = null;
124
+ this.handleEscape(sequence);
125
+ }
126
+ continue;
127
+ }
128
+ if (char === "\x1b") {
129
+ this.escape = "\x1b";
130
+ continue;
131
+ }
132
+ if (!this.active) {
133
+ if (char === "\x03") this.onInterrupt();
134
+ continue;
135
+ }
136
+ this.handleChar(char);
137
+ }
138
+ if (this.active) this.render();
139
+ }
140
+
141
+ handleChar(char) {
142
+ if (this.paste) {
143
+ if (char === "\x1b") this.escape = "\x1b";
144
+ else this.insert(char === "\n" ? " " : char);
145
+ return;
146
+ }
147
+ switch (char) {
148
+ case "\r":
149
+ case "\n":
150
+ this.submit();
151
+ return;
152
+ case "\x03": // Ctrl+C
153
+ this.settle(null);
154
+ return;
155
+ case "\x04": // Ctrl+D
156
+ this.settle(null);
157
+ return;
158
+ case "\x7f":
159
+ case "\b":
160
+ this.backspace();
161
+ return;
162
+ case "\t":
163
+ this.complete();
164
+ return;
165
+ case "\x01": // Ctrl+A
166
+ this.cursor = 0;
167
+ return;
168
+ case "\x05": // Ctrl+E
169
+ this.cursor = this.buffer.length;
170
+ return;
171
+ default:
172
+ if (char >= " ") this.insert(char);
173
+ }
174
+ }
175
+
176
+ handleEscape(sequence) {
177
+ if (sequence === "\x1b[200~") {
178
+ this.paste = true;
179
+ return;
180
+ }
181
+ if (sequence === "\x1b[201~") {
182
+ this.paste = false;
183
+ return;
184
+ }
185
+ if (this.paste) return;
186
+ switch (sequence) {
187
+ case "\x1b[A":
188
+ this.up();
189
+ break;
190
+ case "\x1b[B":
191
+ this.down();
192
+ break;
193
+ case "\x1b[C":
194
+ this.cursor = Math.min(this.cursor + 1, this.buffer.length);
195
+ break;
196
+ case "\x1b[D":
197
+ this.cursor = Math.max(this.cursor - 1, 0);
198
+ break;
199
+ case "\x1b[H":
200
+ case "\x1b[1~":
201
+ this.cursor = 0;
202
+ break;
203
+ case "\x1b[F":
204
+ case "\x1b[4~":
205
+ this.cursor = this.buffer.length;
206
+ break;
207
+ case "\x1b[3~":
208
+ this.deleteForward();
209
+ break;
210
+ default:
211
+ break;
212
+ }
213
+ }
214
+
215
+ insert(text) {
216
+ const safe = clean(text);
217
+ this.buffer = this.buffer.slice(0, this.cursor) + safe + this.buffer.slice(this.cursor);
218
+ this.cursor += safe.length;
219
+ this.historyIndex = -1;
220
+ this.menuIndex = 0;
221
+ }
222
+
223
+ backspace() {
224
+ if (this.cursor === 0) return;
225
+ this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
226
+ this.cursor -= 1;
227
+ this.historyIndex = -1;
228
+ }
229
+
230
+ deleteForward() {
231
+ if (this.cursor >= this.buffer.length) return;
232
+ this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
233
+ this.historyIndex = -1;
234
+ }
235
+
236
+ up() {
237
+ if (this.menu.length > 0) {
238
+ this.menuIndex = (this.menuIndex - 1 + this.menu.length) % this.menu.length;
239
+ return;
240
+ }
241
+ if (this.history.length === 0) return;
242
+ if (this.historyIndex === -1) {
243
+ this.draft = this.buffer;
244
+ this.historyIndex = this.history.length - 1;
245
+ } else if (this.historyIndex > 0) {
246
+ this.historyIndex -= 1;
247
+ }
248
+ this.buffer = this.history[this.historyIndex];
249
+ this.cursor = this.buffer.length;
250
+ }
251
+
252
+ down() {
253
+ if (this.menu.length > 0) {
254
+ this.menuIndex = (this.menuIndex + 1) % this.menu.length;
255
+ return;
256
+ }
257
+ if (this.historyIndex === -1) return;
258
+ if (this.historyIndex < this.history.length - 1) {
259
+ this.historyIndex += 1;
260
+ this.buffer = this.history[this.historyIndex];
261
+ } else {
262
+ this.historyIndex = -1;
263
+ this.buffer = this.draft;
264
+ }
265
+ this.cursor = this.buffer.length;
266
+ }
267
+
268
+ complete() {
269
+ const item = this.menu[this.menuIndex];
270
+ if (item === undefined) return;
271
+ this.buffer = "/" + item.name;
272
+ this.cursor = this.buffer.length;
273
+ this.menu = [];
274
+ this.menuIndex = 0;
275
+ }
276
+
277
+ submit() {
278
+ const item = this.menu[this.menuIndex];
279
+ if (item !== undefined && this.buffer.trim() !== "/" + item.name) {
280
+ this.buffer = "/" + item.name;
281
+ this.cursor = this.buffer.length;
282
+ }
283
+ const line = this.buffer;
284
+ if (line.trim() !== "" && this.history[this.history.length - 1] !== line) {
285
+ this.history.push(line);
286
+ if (this.history.length > 200) this.history.shift();
287
+ }
288
+ this.settle(line);
289
+ }
290
+
291
+ settle(value, echo = true) {
292
+ if (this.resolve === null) return;
293
+ const resolve = this.resolve;
294
+ this.resolve = null;
295
+ this.active = false;
296
+ this.clearRender();
297
+ this.menu = [];
298
+ if (echo) this.output.write("\n");
299
+ resolve(value);
300
+ }
301
+
302
+ // ---- rendering ----------------------------------------------------------
303
+ computeMenu() {
304
+ if (!this.menuEnabled) return [];
305
+ if (!this.buffer.startsWith("/")) return [];
306
+ if (/\s/.test(this.buffer)) return [];
307
+ const query = this.buffer.slice(1).toLowerCase();
308
+ const items = this.commands.filter((command) => command.name.toLowerCase().startsWith(query));
309
+ return items.slice(0, 8);
310
+ }
311
+
312
+ clearRender() {
313
+ if (this.renderedRows > 0) {
314
+ this.output.write(`\x1b[${this.renderedRows}B`);
315
+ this.output.write("\r\x1b[0J");
316
+ this.output.write(`\x1b[${this.renderedRows}A`);
317
+ }
318
+ this.output.write("\r\x1b[2K");
319
+ this.renderedRows = 0;
320
+ }
321
+
322
+ render() {
323
+ if (!this.active) return;
324
+ this.clearRender();
325
+ const columns = Math.max(40, this.columns() || 80);
326
+ const prompt = this.prompt;
327
+ const promptWidth = visibleWidth(prompt);
328
+ const available = Math.max(8, columns - promptWidth - 1);
329
+ let offset = 0;
330
+ if (this.buffer.length > available) offset = Math.max(0, this.cursor - available + 1);
331
+ const visible = this.buffer.slice(offset, offset + available);
332
+ const column = promptWidth + (this.cursor - offset);
333
+ this.output.write(prompt + visible);
334
+
335
+ this.menu = this.computeMenu();
336
+ if (this.menuIndex >= this.menu.length) this.menuIndex = 0;
337
+ const rows = this.menu.length;
338
+ if (rows > 0) {
339
+ const nameWidth = Math.min(18, Math.max(...this.menu.map((item) => item.name.length)) + 1);
340
+ for (let index = 0; index < rows; index += 1) {
341
+ const item = this.menu[index];
342
+ const selected = index === this.menuIndex;
343
+ const marker = selected ? "❯" : " ";
344
+ const name = ("/" + clean(item.name)).padEnd(nameWidth + 1);
345
+ const description = clean(item.description ?? "").slice(0, Math.max(10, columns - nameWidth - 8));
346
+ const line = ` ${marker} ${selected ? `\x1b[36m${name}\x1b[39m` : name} ${description}`;
347
+ this.output.write("\n" + line);
348
+ }
349
+ this.output.write(`\x1b[${rows}A`);
350
+ }
351
+ this.renderedRows = rows;
352
+ this.output.write(`\x1b[${column + 1}G`);
353
+ }
354
+ }
package/runner/runner.js CHANGED
@@ -23,6 +23,7 @@ import { createUserMessage } from "@deepseek-ai/dsh-llm";
23
23
  import { SessionId } from "@deepseek-ai/dsh-session";
24
24
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
25
25
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
26
+ import { LineEditor } from "./line-editor.js";
26
27
 
27
28
  /** Stable Cordis plugin name. */
28
29
  export const name = "cli-runner";
@@ -493,6 +494,41 @@ function printBanner(model) {
493
494
  out(paint(ansi.dim, " Type a task, or /help. Ctrl+C interrupts; twice exits.") + "\n\n");
494
495
  }
495
496
 
497
+ // Slash-command suggestions for the live menu (native Harness commands are
498
+ // merged in at runtime).
499
+ const BUILTIN_COMMANDS = [
500
+ { name: "clear", description: "Clear the screen" },
501
+ { name: "exit", description: "Leave the session" },
502
+ { name: "export", description: "Write this run's transcript as markdown" },
503
+ { name: "help", description: "Show commands" },
504
+ { name: "image", description: "Attach an image to the next message" },
505
+ { name: "jobs", description: "List this session's background jobs" },
506
+ { name: "model", description: "Show or switch the model" },
507
+ { name: "new", description: "Start a fresh session" },
508
+ { name: "permissions", description: "Show or switch the permission preset" },
509
+ { name: "quit", description: "Leave the session" },
510
+ { name: "reasoning", description: "Show or set the reasoning effort" },
511
+ { name: "resume", description: "Switch to another session" },
512
+ { name: "session", description: "Print the current session id" },
513
+ { name: "sessions", description: "List recent sessions in this directory" },
514
+ { name: "skills", description: "List discovered skills" },
515
+ { name: "status", description: "Session, model, cwd, token pressure" },
516
+ { name: "thinking", description: "Show the last turn's reasoning" },
517
+ { name: "todos", description: "Show the current todo list" },
518
+ ];
519
+
520
+ /** Built-ins plus whatever commands the Harness profile registers. */
521
+ function commandMenu(agent, commands) {
522
+ const merged = new Map();
523
+ for (const command of BUILTIN_COMMANDS) merged.set(command.name, command);
524
+ try {
525
+ for (const native of commands?.list(agent) ?? []) {
526
+ if (!merged.has(native.name)) merged.set(native.name, { name: native.name, description: native.description });
527
+ }
528
+ } catch {}
529
+ return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
530
+ }
531
+
496
532
  function printHelp(write = out) {
497
533
  write(`deepseek ${VERSION} — DeepSeek Harness in your terminal.
498
534
 
@@ -631,17 +667,31 @@ async function run(ctx, options, exit) {
631
667
  const write = (text) => process.stdout.write(text);
632
668
 
633
669
  // ---- line input ---------------------------------------------------------
634
- // One-shot mode must not own stdin: a piped/closed stdin would deliver EOF
635
- // and tear the runtime down before the task runs.
636
- const rlInterface = interactive
637
- ? createInterface({
670
+ // TTY: a raw-mode line editor with a live slash-command menu (Claude style).
671
+ // Non-TTY: readline over the piped stream. One-shot: no input owner at all.
672
+ const useEditor = interactive && process.stdin.isTTY === true;
673
+ const editor = useEditor
674
+ ? new LineEditor({
638
675
  input: process.stdin,
639
676
  output: process.stdout,
640
- terminal: process.stdin.isTTY,
641
- historySize: 200,
642
- removeHistoryDuplicates: true,
677
+ columns: () => process.stdout.columns || 80,
678
+ onInterrupt: () => handleInterrupt(),
679
+ onEnd: () => {
680
+ stdinClosed = true;
681
+ if (ready && !busy) shutdown(0);
682
+ },
643
683
  })
644
684
  : null;
685
+ const rlInterface =
686
+ interactive && !useEditor
687
+ ? createInterface({
688
+ input: process.stdin,
689
+ output: process.stdout,
690
+ terminal: false,
691
+ historySize: 200,
692
+ removeHistoryDuplicates: true,
693
+ })
694
+ : null;
645
695
  // Hold the input until the first agent is ready: buffered piped lines must
646
696
  // not be consumed (and dropped) while the profile is still starting.
647
697
  if (rlInterface) {
@@ -650,8 +700,14 @@ async function run(ctx, options, exit) {
650
700
  paused = true;
651
701
  } catch {}
652
702
  }
703
+ editor?.start();
704
+ editor?.setActive(false);
653
705
 
654
706
  function setPaused(value) {
707
+ if (editor) {
708
+ editor.setActive(!value);
709
+ return;
710
+ }
655
711
  if (!rlInterface || paused === value) return;
656
712
  paused = value;
657
713
  try {
@@ -672,12 +728,16 @@ async function run(ctx, options, exit) {
672
728
 
673
729
  /**
674
730
  * Ask one line. Resolves null when the question is interrupted (Ctrl+C) or
675
- * stdin closes. A permanent listener queues ahead-of-time lines (piped input,
676
- * or typing while the model works), so no line is ever dropped.
731
+ * stdin closes. On a TTY the editor renders a live command menu.
677
732
  * @param {string} prompt
733
+ * @param {boolean} [menu]
678
734
  * @returns {Promise<string | null>}
679
735
  */
680
- function ask(prompt) {
736
+ function ask(prompt, menu = false) {
737
+ if (editor) {
738
+ setPaused(false);
739
+ return editor.ask(prompt, menu);
740
+ }
681
741
  return new Promise((resolve) => {
682
742
  if (!rlInterface) {
683
743
  resolve(null);
@@ -711,6 +771,7 @@ async function run(ctx, options, exit) {
711
771
 
712
772
  function closeInput() {
713
773
  try {
774
+ editor?.stop();
714
775
  rlInterface?.close();
715
776
  } catch {}
716
777
  }
@@ -1150,7 +1211,8 @@ async function run(ctx, options, exit) {
1150
1211
  }
1151
1212
 
1152
1213
  while (!quitting) {
1153
- const line = await ask(paint(ansi.bold, "> "));
1214
+ if (editor) editor.setCommands(commandMenu(agent, commands));
1215
+ const line = await ask(paint(ansi.bold, "> "), true);
1154
1216
  if (line === null) break;
1155
1217
  const text = line.trim();
1156
1218
  if (text === "") continue;