deepseek-cli-mode 1.0.2 → 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,28 @@
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
+
14
+ ## 1.1.0 — Claude-Code-style terminal UI
15
+
16
+ - New welcome panel: rounded box with version, model, cwd, and first-run hints.
17
+ - Prompt is now `> `; each turn ends with a dim status line (`model · tokens · seconds`).
18
+ - Tool activity renders as `⏺ Tool(args)` followed by a `⎿ result` summary line.
19
+ - Animated `✻ Thinking… (Ns · ctrl+c to interrupt)` spinner while the model works.
20
+ - Permission prompts use the numbered `Do you want to allow …? 1. Yes / 2. No` dialog.
21
+ - Todo list uses `☒`/`☐` checkboxes and prints only changed items after the first render.
22
+ - New `/status` command (session, model, cwd, mode, token pressure); `/session` kept.
23
+ - Security: all new rendering paths pass model/repository text through the existing
24
+ control-character sanitizer; no new process, network, or filesystem surfaces were added.
25
+
3
26
  ## 1.0.2 — runtime discovery fixes
4
27
 
5
28
  - `resolveDshBin()` now also looks for `@deepseek-ai/dsh` next to this package (same `node_modules`) and in the
package/README.md CHANGED
@@ -12,6 +12,31 @@ interactive **CLI** that boots the *same* Harness runtime (`dsh-base` + `dsh-hea
12
12
  session persistence, approvals) and keeps **one live Agent for the whole session**, so multi-turn context, tool state,
13
13
  and the model's request prefix stay warm between messages.
14
14
 
15
+ The terminal UI follows the Claude Code layout:
16
+
17
+ ```
18
+ ╭──────────────────────────────────────────────────────╮
19
+ │ ✻ DeepSeek CLI 1.1.0 │
20
+ │ │
21
+ │ /help for help · /status for your current setup │
22
+ │ model: deepseek-official/deepseek-chat │
23
+ │ cwd: C:\work\my-project │
24
+ ╰──────────────────────────────────────────────────────╯
25
+
26
+ > explain what changed and run the tests
27
+
28
+ ⏺ glob(**/*.ts)
29
+ ⎿ src/app.ts src/auth.ts tests/auth.spec.ts
30
+ ✻ Thinking… (2s · ctrl+c to interrupt)
31
+
32
+ ⏺ pwsh(npm test)
33
+ ⎿ Tests: 42 passed, 42 total
34
+
35
+ The auth change is safe; all tests pass.
36
+
37
+ ✻ deepseek-official/deepseek-chat · 8.4k tokens · 12.1s
38
+ ```
39
+
15
40
  ```
16
41
  ❯ explain what changed in this repo and run the tests
17
42
 
@@ -109,6 +134,16 @@ deepseek "review @src/app.ts and tell me what is risky"
109
134
 
110
135
  ### Inside a session
111
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
+
112
147
  | Command | What it does |
113
148
  |---|---|
114
149
  | `/help` | command list, including the live Harness command registry |
@@ -125,6 +160,7 @@ deepseek "review @src/app.ts and tell me what is risky"
125
160
  | `/export [file]` | write this run's transcript as markdown |
126
161
  | `/thinking` | print the last turn's reasoning (collapsed by default) |
127
162
  | `/session` | print the current session id (for `--resume`) |
163
+ | `/status` | session, model, cwd, mode, and token pressure |
128
164
  | `/clear` | clear the screen |
129
165
  | `/quit`, `/exit` | leave (Ctrl+C at an empty prompt also exits) |
130
166
 
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.0.2",
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";
@@ -210,6 +211,9 @@ function createRenderer({ write, capture }) {
210
211
  get lastTodos() {
211
212
  return lastTodos;
212
213
  },
214
+ get turnUsage() {
215
+ return { ...turnUsage };
216
+ },
213
217
  resetTurn() {
214
218
  streamedThisTurn = false;
215
219
  reasoning = "";
@@ -246,15 +250,24 @@ function createRenderer({ write, capture }) {
246
250
  case "tool/call": {
247
251
  if (data.name === "todo_write") break; // the checklist below is the presentation
248
252
  endLine();
249
- const icon = TOOL_ICONS[data.name] ?? "⏺";
250
253
  const preview = previewToolCall(data.name, data.arguments);
251
- write(paint(ansi.dim, paint(ansi.italic, ` ${icon} ${data.name}${preview ? " " + preview : ""}`)) + "\n");
254
+ write(
255
+ paint(ansi.cyan, "⏺ ") +
256
+ paint(ansi.bold, sanitize(data.name)) +
257
+ paint(ansi.dim, `(${preview})`) +
258
+ "\n"
259
+ );
252
260
  break;
253
261
  }
254
262
  case "tool/result": {
255
- if (data.error) {
256
- write(paint(ansi.dim, paint(ansi.italic, ` ✗ ${sanitize(data.error.name)}: ${sanitize(data.error.code)}`)) + "\n");
263
+ let summary;
264
+ if (data.error) summary = `Error: ${sanitize(data.error.name)}${data.error.code ? ` (${sanitize(data.error.code)})` : ""}`;
265
+ else {
266
+ const content = data.message?.content ?? [];
267
+ const text = content.filter((b) => b.type === "text").map((b) => b.text).join(" ").replace(/\s+/g, " ").trim();
268
+ summary = text === "" ? "Done" : truncate(text, 76);
257
269
  }
270
+ write(paint(ansi.dim, ` ⎿ ${sanitize(summary)}`) + "\n");
258
271
  break;
259
272
  }
260
273
  case "assistant/message": {
@@ -280,15 +293,15 @@ function createRenderer({ write, capture }) {
280
293
  endLine();
281
294
  flushText();
282
295
  const done = todos.filter((todo) => todo.status === "completed").length;
283
- write(paint(ansi.dim, ` ☑ todos ${done}/${todos.length}`) + "\n");
296
+ write(paint(ansi.dim, ` ☒ todos ${done}/${todos.length}`) + "\n");
284
297
  // Print the whole list once; later writes show only the items that moved.
285
298
  const shown = firstRender ? todos : todos.filter((todo) => previous.get(todo.content) !== todo.status);
286
299
  for (const todo of shown) {
287
300
  const mark =
288
301
  todo.status === "completed"
289
- ? paint(ansi.green, "✓")
302
+ ? paint(ansi.green, "☒")
290
303
  : todo.status === "in_progress"
291
- ? paint(ansi.yellow, "▸")
304
+ ? paint(ansi.yellow, "☐")
292
305
  : paint(ansi.dim, "☐");
293
306
  const content = todo.status === "completed" ? paint(ansi.dim, sanitize(todo.content)) : sanitize(todo.content);
294
307
  write(` ${mark} ${content}\n`);
@@ -442,32 +455,78 @@ function printSessionList(sessions, write = out) {
442
455
  write(paint(ansi.dim, " no sessions in this directory yet\n"));
443
456
  return;
444
457
  }
445
- write(` Recent sessions in ${process.cwd()}:\n`);
458
+ write(` Recent sessions in ${sanitize(process.cwd())}:\n`);
446
459
  sessions.slice(0, 10).forEach((session, index) => {
447
460
  const running = session.openTurn ? paint(ansi.yellow, " ▶ running") : "";
448
- write(` ${paint(ansi.cyan, String(index + 1))}) ${paint(ansi.dim, session.id)} (${paint(ansi.dim, timeAgo(session.mtime))})${running}\n`);
461
+ write(` ${paint(ansi.cyan, String(index + 1))}) ${paint(ansi.dim, sanitize(session.id))} (${paint(ansi.dim, timeAgo(session.mtime))})${running}\n`);
449
462
  });
450
463
  }
451
464
 
452
465
  // ---------------------------------------------------------------------------
453
- // Help / banner
466
+ // Help / welcome
454
467
  // ---------------------------------------------------------------------------
468
+ /** Draw a rounded box, truncating content to the terminal width. */
469
+ function box(lines, minWidth = 56) {
470
+ const cols = Math.max(40, Math.min(process.stdout.columns || 88, 100));
471
+ const width = Math.min(cols - 2, Math.max(minWidth, ...lines.map((line) => line.length + 4)));
472
+ const inner = width - 4;
473
+ const outLines = [];
474
+ outLines.push(paint(ansi.dim, "╭" + "─".repeat(width - 2) + "╮"));
475
+ for (const line of lines) {
476
+ let text = line;
477
+ if (text.length > inner) text = text.slice(0, inner - 1) + "…";
478
+ outLines.push(paint(ansi.dim, "│ ") + text.padEnd(inner) + paint(ansi.dim, " │"));
479
+ }
480
+ outLines.push(paint(ansi.dim, "╰" + "─".repeat(width - 2) + "╯"));
481
+ for (const line of outLines) out(line + "\n");
482
+ }
483
+
455
484
  function printBanner(model) {
456
- const art = [
457
- " ooo",
458
- ' " """',
459
- ' " "',
460
- ' ___:____ " "|',
461
- " ,' \\. \\//",
462
- " | O \\___/ |",
463
- "~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~",
464
- ];
485
+ const modelLine = sanitize(`${model.provider}/${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ""}`);
465
486
  out("\n");
466
- for (const line of art) out(paint(ansi.blue, line) + "\n");
467
- out(`${paint(ansi.bold, "deepseek")} ${paint(ansi.dim, VERSION)} — DeepSeek Harness in your terminal\n`);
468
- out(paint(ansi.dim, `model: ${model.provider}/${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ""}`) + "\n");
469
- out(paint(ansi.dim, `cwd: ${process.cwd()}`) + "\n");
470
- out(paint(ansi.dim, "type /help for commands, Ctrl+C to interrupt or exit") + "\n\n");
487
+ box([
488
+ `${paint(ansi.cyan, "✻")} ${paint(ansi.bold, "DeepSeek CLI")} ${paint(ansi.dim, VERSION)}`,
489
+ "",
490
+ paint(ansi.dim, " /help for help · /status for your current setup"),
491
+ paint(ansi.dim, ` model: ${modelLine}`),
492
+ paint(ansi.dim, ` cwd: ${sanitize(process.cwd())}`),
493
+ ]);
494
+ out(paint(ansi.dim, " Type a task, or /help. Ctrl+C interrupts; twice exits.") + "\n\n");
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));
471
530
  }
472
531
 
473
532
  function printHelp(write = out) {
@@ -498,6 +557,7 @@ Interactive commands:
498
557
  /export [file] write this run's transcript as markdown
499
558
  /thinking show the last turn's reasoning
500
559
  /session print the current session id (for --resume)
560
+ /status session, model, cwd, token pressure
501
561
  /clear clear the screen
502
562
  /quit, /exit leave (also Ctrl+C at an empty prompt)
503
563
 
@@ -576,6 +636,7 @@ async function run(ctx, options, exit) {
576
636
  const planMode = ctx.get("planMode");
577
637
  const permissionPresets = ctx.get("permissionPresets");
578
638
  const attachments = ctx.get("attachments");
639
+ const tokenMeter = ctx.get("tokenMeter");
579
640
 
580
641
  if (options.list) {
581
642
  printSessionList(listSessions());
@@ -606,17 +667,31 @@ async function run(ctx, options, exit) {
606
667
  const write = (text) => process.stdout.write(text);
607
668
 
608
669
  // ---- line input ---------------------------------------------------------
609
- // One-shot mode must not own stdin: a piped/closed stdin would deliver EOF
610
- // and tear the runtime down before the task runs.
611
- const rlInterface = interactive
612
- ? 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({
613
675
  input: process.stdin,
614
676
  output: process.stdout,
615
- terminal: process.stdin.isTTY,
616
- historySize: 200,
617
- removeHistoryDuplicates: true,
677
+ columns: () => process.stdout.columns || 80,
678
+ onInterrupt: () => handleInterrupt(),
679
+ onEnd: () => {
680
+ stdinClosed = true;
681
+ if (ready && !busy) shutdown(0);
682
+ },
618
683
  })
619
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;
620
695
  // Hold the input until the first agent is ready: buffered piped lines must
621
696
  // not be consumed (and dropped) while the profile is still starting.
622
697
  if (rlInterface) {
@@ -625,8 +700,14 @@ async function run(ctx, options, exit) {
625
700
  paused = true;
626
701
  } catch {}
627
702
  }
703
+ editor?.start();
704
+ editor?.setActive(false);
628
705
 
629
706
  function setPaused(value) {
707
+ if (editor) {
708
+ editor.setActive(!value);
709
+ return;
710
+ }
630
711
  if (!rlInterface || paused === value) return;
631
712
  paused = value;
632
713
  try {
@@ -647,12 +728,16 @@ async function run(ctx, options, exit) {
647
728
 
648
729
  /**
649
730
  * Ask one line. Resolves null when the question is interrupted (Ctrl+C) or
650
- * stdin closes. A permanent listener queues ahead-of-time lines (piped input,
651
- * 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.
652
732
  * @param {string} prompt
733
+ * @param {boolean} [menu]
653
734
  * @returns {Promise<string | null>}
654
735
  */
655
- function ask(prompt) {
736
+ function ask(prompt, menu = false) {
737
+ if (editor) {
738
+ setPaused(false);
739
+ return editor.ask(prompt, menu);
740
+ }
656
741
  return new Promise((resolve) => {
657
742
  if (!rlInterface) {
658
743
  resolve(null);
@@ -686,6 +771,7 @@ async function run(ctx, options, exit) {
686
771
 
687
772
  function closeInput() {
688
773
  try {
774
+ editor?.stop();
689
775
  rlInterface?.close();
690
776
  } catch {}
691
777
  }
@@ -783,24 +869,63 @@ async function run(ctx, options, exit) {
783
869
  // ---- agent lifecycle ----------------------------------------------------
784
870
  const makeAgentOptions = () => ({ ...selectionRef.current });
785
871
 
872
+ // ---- Claude-style activity indicator ------------------------------------
873
+ let spinnerTimer = null;
874
+ let spinnerIndex = 0;
875
+ let spinnerActive = false;
876
+ const SPINNER = ["✻", "✳", "✶", "✳"];
877
+
878
+ function startSpinner(label = "Thinking") {
879
+ if (!tty || options.mode === "oneshot" || spinnerActive) return;
880
+ spinnerActive = true;
881
+ spinnerIndex = 0;
882
+ const started = Date.now();
883
+ out("\x1b[?25l");
884
+ const tick = () => {
885
+ const glyph = SPINNER[spinnerIndex++ % SPINNER.length];
886
+ const seconds = Math.floor((Date.now() - started) / 1000);
887
+ out(`\r\x1b[2K${paint(ansi.dim, `${glyph} ${label}… (${seconds}s · ctrl+c to interrupt)`)}`);
888
+ };
889
+ tick();
890
+ spinnerTimer = setInterval(tick, 120);
891
+ }
892
+
893
+ function stopSpinner() {
894
+ if (!spinnerActive) return;
895
+ spinnerActive = false;
896
+ if (spinnerTimer !== null) {
897
+ clearInterval(spinnerTimer);
898
+ spinnerTimer = null;
899
+ }
900
+ out("\r\x1b[2K\x1b[?25h");
901
+ }
902
+
786
903
  const wire = (agent) => {
787
904
  agent.ctx.on("session/event", (session, event) => {
788
- if (session === agent.session) renderer.event(event);
905
+ if (session === agent.session) {
906
+ stopSpinner();
907
+ renderer.event(event);
908
+ }
789
909
  });
790
910
  agent.ctx.on("agent/assistant-stream", (payload) => {
791
- if (payload.agent === agent) renderer.frame(payload.frame);
911
+ if (payload.agent === agent) {
912
+ stopSpinner();
913
+ renderer.frame(payload.frame);
914
+ }
792
915
  });
793
916
  agent.ctx.on("user-questions/request", async (request) => answerQuestions(request));
794
917
  agent.ctx.on("approval/request", async (request) => {
795
- const toolName = request.toolName ?? "tool";
918
+ const toolName = sanitize(request.toolName ?? "tool");
796
919
  if (autoYes) return "allowed-once";
797
- if (request.reason) {
798
- out(paint(ansi.dim, paint(ansi.italic, ` ⚠ ${toolName} needs approval: ${request.reason}`)) + "\n");
799
- }
920
+ stopSpinner();
800
921
  if (!interactive || !process.stdin.isTTY) return "rejected";
801
- const answer = await ask(paint(ansi.yellow, ` ⚠ Allow ${paint(ansi.cyan, toolName)}? `) + paint(ansi.dim, "[y/N] "));
802
- if (answer === null) return "rejected";
803
- return /^y(es)?$/i.test(answer.trim()) ? "allowed-once" : "rejected";
922
+ out("\n");
923
+ if (request.reason) out(paint(ansi.dim, ` ${sanitize(request.reason)}`) + "\n");
924
+ out(` ${paint(ansi.bold, "Do you want to allow")} ${paint(ansi.cyan, toolName)}${paint(ansi.bold, "?")}\n`);
925
+ out(` ${paint(ansi.cyan, "1")}. Yes\n ${paint(ansi.cyan, "2")}. No\n`);
926
+ const answer = (await ask(paint(ansi.green, " ❯ "))) ?? "";
927
+ const choice = answer.trim().toLowerCase();
928
+ return choice === "1" || choice === "y" || choice === "yes" ? "allowed-once" : "rejected";
804
929
  });
805
930
  };
806
931
 
@@ -836,17 +961,26 @@ async function run(ctx, options, exit) {
836
961
  busy = true;
837
962
  cancelSent = false;
838
963
  renderer.resetTurn();
964
+ const startedAt = Date.now();
839
965
  setPaused(true);
966
+ startSpinner("Thinking");
840
967
  try {
841
968
  agent.followup(createUserMessage({ content, source: { kind: "user" } }));
842
969
  await agent.whenIdle();
843
970
  await persistence.flush();
844
971
  } finally {
972
+ stopSpinner();
845
973
  busy = false;
846
974
  setPaused(false);
847
975
  if (renderer.lastAssistantText && renderer.lastAssistantText !== previousAnswer) {
848
976
  transcript.push({ role: "assistant", text: renderer.lastAssistantText });
849
977
  }
978
+ if (interactive && tty) {
979
+ const usage = renderer.turnUsage;
980
+ const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
981
+ const tokens = usage.input + usage.output;
982
+ out(paint(ansi.dim, ` ✻ ${sanitize(modelDesc())}${tokens > 0 ? ` · ${formatTokens(tokens)} tokens` : ""} · ${seconds}s`) + "\n\n");
983
+ }
850
984
  }
851
985
  };
852
986
 
@@ -1057,7 +1191,7 @@ async function run(ctx, options, exit) {
1057
1191
  try {
1058
1192
  agent = resumeTarget ? await startAgent(undefined, resumeTarget) : await startAgent(SessionId(`session-${randomUUID()}`), undefined);
1059
1193
  } catch (error) {
1060
- err(`deepseek: cannot start a session: ${error instanceof Error ? error.message : String(error)}\n`);
1194
+ err(`deepseek: cannot start a session: ${sanitize(describeError(error))}\n`);
1061
1195
  await shutdown(1);
1062
1196
  return;
1063
1197
  }
@@ -1070,14 +1204,15 @@ async function run(ctx, options, exit) {
1070
1204
 
1071
1205
  printBanner(selectionRef.current);
1072
1206
  if (resumeTarget) {
1073
- out(paint(ansi.green, `▶ resumed `) + paint(ansi.dim, resumeTarget) + "\n");
1207
+ out(paint(ansi.green, `▶ resumed `) + paint(ansi.dim, sanitize(resumeTarget)) + "\n");
1074
1208
  out(paint(ansi.dim, " (history is in the session log; continue the conversation below)") + "\n\n");
1075
1209
  } else {
1076
- out(paint(ansi.dim, `session: ${agent.session.id} (resume with: deepseek --resume ${agent.session.id})`) + "\n\n");
1210
+ out(paint(ansi.dim, `session: ${sanitize(agent.session.id)} (resume with: deepseek --resume ${sanitize(agent.session.id)})`) + "\n\n");
1077
1211
  }
1078
1212
 
1079
1213
  while (!quitting) {
1080
- const line = await ask(paint(ansi.green, "❯ "));
1214
+ if (editor) editor.setCommands(commandMenu(agent, commands));
1215
+ const line = await ask(paint(ansi.bold, "> "), true);
1081
1216
  if (line === null) break;
1082
1217
  const text = line.trim();
1083
1218
  if (text === "") continue;
@@ -1098,9 +1233,9 @@ async function run(ctx, options, exit) {
1098
1233
  out(paint(ansi.dim, " ↻ new session\n"));
1099
1234
  try {
1100
1235
  agent = await startAgent(SessionId(`session-${randomUUID()}`), undefined);
1101
- out(paint(ansi.dim, ` session: ${agent.session.id}\n`));
1236
+ out(paint(ansi.dim, ` session: ${sanitize(agent.session.id)}\n`));
1102
1237
  } catch (error) {
1103
- out(paint(ansi.red, ` ✘ ${error instanceof Error ? error.message : String(error)}\n`));
1238
+ out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1104
1239
  }
1105
1240
  continue;
1106
1241
  }
@@ -1116,13 +1251,27 @@ async function run(ctx, options, exit) {
1116
1251
  out(paint(ansi.dim, ` ↻ resuming ${argument}\n`));
1117
1252
  agent = await startAgent(undefined, argument);
1118
1253
  } catch (error) {
1119
- out(paint(ansi.red, ` ✘ ${error instanceof Error ? error.message : String(error)}\n`));
1254
+ out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1120
1255
  }
1121
1256
  continue;
1122
1257
  }
1123
1258
  case "/session":
1124
- out(paint(ansi.dim, ` ${agent.session.id}\n`));
1259
+ out(paint(ansi.dim, ` ${sanitize(agent.session.id)}\n`));
1125
1260
  continue;
1261
+ case "/status": {
1262
+ out(paint(ansi.bold, " Status") + "\n");
1263
+ out(` session: ${sanitize(agent.session.id)}\n`);
1264
+ out(` model: ${sanitize(modelDesc())}\n`);
1265
+ out(` cwd: ${sanitize(process.cwd())}\n`);
1266
+ out(` mode: ${interactive ? "interactive" : "one-shot"}${autoYes ? " · auto-approve" : ""}\n`);
1267
+ try {
1268
+ if (tokenMeter !== undefined) {
1269
+ const measurement = tokenMeter.measure(agent.session);
1270
+ out(` tokens: ${measurement.totalTokens} (surface ${measurement.surfaceTokens})\n`);
1271
+ }
1272
+ } catch {}
1273
+ continue;
1274
+ }
1126
1275
  case "/model": {
1127
1276
  if (argument === "") {
1128
1277
  out(paint(ansi.dim, ` model: ${modelDesc()}\n`));
@@ -1134,7 +1283,7 @@ async function run(ctx, options, exit) {
1134
1283
  selectionRef.current = next;
1135
1284
  out(paint(ansi.green, ` ✓ model set: ${next.provider}/${next.model} (next message)\n`));
1136
1285
  } catch (error) {
1137
- out(paint(ansi.red, ` ✘ ${error instanceof Error ? error.message : String(error)}\n`));
1286
+ out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1138
1287
  }
1139
1288
  continue;
1140
1289
  }
@@ -1149,7 +1298,7 @@ async function run(ctx, options, exit) {
1149
1298
  selectionRef.current = next;
1150
1299
  out(paint(ansi.green, ` ✓ reasoning set: ${argument} (next message)\n`));
1151
1300
  } catch (error) {
1152
- out(paint(ansi.red, ` ✘ ${error instanceof Error ? error.message : String(error)}\n`));
1301
+ out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1153
1302
  }
1154
1303
  continue;
1155
1304
  }
@@ -1281,7 +1430,7 @@ async function run(ctx, options, exit) {
1281
1430
  }
1282
1431
  }
1283
1432
 
1284
- out("\n" + paint(ansi.green, "❯ ") + sanitize(text) + "\n\n");
1433
+ out("\n" + paint(ansi.bold, "> ") + sanitize(text) + "\n\n");
1285
1434
  await runTurn(agent, text);
1286
1435
  }
1287
1436
 
@@ -1310,7 +1459,7 @@ export function apply(ctx) {
1310
1459
  }
1311
1460
  run(ctx, options, exit).catch((error) => {
1312
1461
  const detail = process.env.DSH_CLI_DEBUG === "1" && error instanceof Error ? `\n${error.stack}` : "";
1313
- process.stderr.write(`deepseek: ${error instanceof Error ? error.message : String(error)}${detail}\n`);
1462
+ process.stderr.write(`deepseek: ${sanitize(describeError(error))}${detail}\n`);
1314
1463
  try {
1315
1464
  exit(1);
1316
1465
  } catch {}