deepseek-cli-mode 1.1.0 → 1.3.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.3.0 — pickers, Shift+Tab permission cycling, permanent mode footer
4
+
5
+ - `/model`, `/reasoning`, `/permissions`, and `/resume` now open an interactive picker (`↑`/`↓`, `Enter`,
6
+ `Ctrl+C` to cancel) instead of requiring an argument. `/model` lists the models discovered from the provider.
7
+ - **Shift+Tab** cycles the permission mode (read-only → workspace-write → danger-full-access) without losing the
8
+ typed line, with a transient hint.
9
+ - A permanent dim footer above the prompt shows the active permission mode, model, and the Shift+Tab hint:
10
+ `⏵⏵ workspace-write · deepseek-official/deepseek-chat · shift+tab to cycle`.
11
+ - Direct arguments still work (`/model <id>`, `/reasoning high`, `/permissions read-only`, `/resume <id>`).
12
+ - Security: picker labels/descriptions and the footer pass through the sanitizer; permission changes still require a
13
+ user keystroke (the model cannot trigger them).
14
+
15
+ ## 1.2.0 — live slash-command menu
16
+
17
+ - Typing `/` at the prompt now opens a live command menu (like Claude Code): matching commands and descriptions,
18
+ `↑`/`↓` to move, `Tab` to complete, `Enter` to run the highlighted command.
19
+ - New raw-mode line editor (`runner/line-editor.js`) for TTY sessions: cursor movement, history, bracketed paste,
20
+ Ctrl+A/E, and a menu that filters as you type.
21
+ - Piped/non-TTY input keeps the existing readline path; one-shot mode is unchanged.
22
+ - Security: the editor strips C0/C1 control characters from inserted text and renders command names/descriptions
23
+ through the sanitizer; it never executes anything itself.
24
+ - The launcher now copies every runner module into the profile (previously only `runner.js`).
25
+
3
26
  ## 1.1.0 — Claude-Code-style terminal UI
4
27
 
5
28
  - New welcome panel: rounded box with version, model, cwd, and first-run hints.
package/README.md CHANGED
@@ -134,6 +134,24 @@ 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
+
147
+ `/model`, `/reasoning`, `/permissions`, and `/resume` open the same kind of picker when called without an argument.
148
+ **Shift+Tab** cycles the permission mode, and a dim footer above the prompt always shows the active one:
149
+
150
+ ```
151
+ ⏵⏵ workspace-write · deepseek-official/deepseek-chat · shift+tab to cycle
152
+ >
153
+ ```
154
+
137
155
  | Command | What it does |
138
156
  |---|---|
139
157
  | `/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.3.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,462 @@
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, onShiftTab }) {
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.onShiftTab = onShiftTab ?? (() => undefined);
38
+ this.commands = [];
39
+ this.buffer = "";
40
+ this.cursor = 0;
41
+ this.history = [];
42
+ this.historyIndex = -1;
43
+ this.draft = "";
44
+ this.menu = [];
45
+ this.menuIndex = 0;
46
+ this.active = false;
47
+ this.menuEnabled = false;
48
+ this.prompt = "";
49
+ this.resolve = null;
50
+ this.renderedRows = 0;
51
+ this.paste = false;
52
+ this.escape = null;
53
+ this.started = false;
54
+ this.selectMode = false;
55
+ this.selectOptions = [];
56
+ this.selectIndex = 0;
57
+ this.hint = "";
58
+ this._onData = (chunk) => this.handleData(chunk);
59
+ this._onEnd = () => {
60
+ this.onEnd();
61
+ this.settle(null, false);
62
+ };
63
+ }
64
+
65
+ start() {
66
+ if (this.started) return;
67
+ this.started = true;
68
+ this.input.setRawMode?.(true);
69
+ this.input.setEncoding?.("utf8");
70
+ this.input.resume?.();
71
+ this.input.on("data", this._onData);
72
+ this.input.on("end", this._onEnd);
73
+ this.output.write("\x1b[?2004h");
74
+ }
75
+
76
+ stop() {
77
+ if (!this.started) return;
78
+ this.started = false;
79
+ this.input.off?.("data", this._onData);
80
+ this.input.off?.("end", this._onEnd);
81
+ this.output.write("\x1b[?2004l");
82
+ this.input.setRawMode?.(false);
83
+ this.input.pause?.();
84
+ }
85
+
86
+ /** Replace the suggestion list (name + description objects). */
87
+ setCommands(commands) {
88
+ this.commands = Array.isArray(commands) ? commands : [];
89
+ }
90
+
91
+ /** Enable/disable input while a turn runs (Ctrl+C still interrupts). */
92
+ setActive(active) {
93
+ if (this.active === active) return;
94
+ this.active = active;
95
+ if (!active) this.clearRender();
96
+ }
97
+
98
+ /**
99
+ * Ask one line.
100
+ * @param {string} prompt prompt string (may contain ANSI)
101
+ * @param {boolean} menuEnabled show slash-command suggestions
102
+ * @returns {Promise<string|null>} line, or null on Ctrl+C / EOF
103
+ */
104
+ ask(prompt, menuEnabled = false) {
105
+ this.prompt = prompt;
106
+ this.menuEnabled = menuEnabled;
107
+ this.buffer = "";
108
+ this.cursor = 0;
109
+ this.menu = [];
110
+ this.menuIndex = 0;
111
+ this.historyIndex = -1;
112
+ this.draft = "";
113
+ this.active = true;
114
+ this.renderedRows = 0;
115
+ this.render();
116
+ return new Promise((resolve) => {
117
+ this.resolve = resolve;
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Ask the user to pick one option from a list.
123
+ * @param {string} title prompt line
124
+ * @param {{label: string, description?: string, value: string}[]} options
125
+ * @param {number} [initial]
126
+ * @returns {Promise<string|null>}
127
+ */
128
+ askSelect(title, options, initial = 0) {
129
+ this.selectMode = true;
130
+ this.selectOptions = Array.isArray(options) ? options : [];
131
+ this.selectIndex = Math.max(0, Math.min(initial, this.selectOptions.length - 1));
132
+ this.prompt = title;
133
+ this.menuEnabled = false;
134
+ this.hint = "";
135
+ this.buffer = "";
136
+ this.cursor = 0;
137
+ this.active = true;
138
+ this.renderedRows = 0;
139
+ this.output.write("\x1b[?25l");
140
+ this.render();
141
+ return new Promise((resolve) => {
142
+ this.resolve = resolve;
143
+ });
144
+ }
145
+
146
+ chooseSelect() {
147
+ const option = this.selectOptions[this.selectIndex];
148
+ this.selectMode = false;
149
+ this.selectOptions = [];
150
+ this.settle(option === undefined ? null : option.value);
151
+ }
152
+
153
+ cancelSelect() {
154
+ if (!this.selectMode) return;
155
+ this.selectMode = false;
156
+ this.selectOptions = [];
157
+ this.settle(null);
158
+ }
159
+
160
+ // ---- input --------------------------------------------------------------
161
+ handleData(chunk) {
162
+ for (const char of String(chunk)) {
163
+ if (this.escape !== null) {
164
+ if (this.escape === "\x1b" && char !== "[" && char !== "O") {
165
+ // Lone Esc (not a CSI/SS3 sequence).
166
+ this.escape = null;
167
+ if (this.selectMode) this.cancelSelect();
168
+ else if (this.active) {
169
+ this.hint = "";
170
+ this.menu = [];
171
+ this.render();
172
+ }
173
+ continue;
174
+ }
175
+ this.escape += char;
176
+ if (/[A-Za-z~]$/.test(char)) {
177
+ const sequence = this.escape;
178
+ this.escape = null;
179
+ this.handleEscape(sequence);
180
+ }
181
+ continue;
182
+ }
183
+ if (char === "\x1b") {
184
+ this.escape = "\x1b";
185
+ continue;
186
+ }
187
+ if (!this.active) {
188
+ if (char === "\x03") this.onInterrupt();
189
+ continue;
190
+ }
191
+ this.handleChar(char);
192
+ }
193
+ if (this.active) this.render();
194
+ }
195
+
196
+ handleChar(char) {
197
+ if (this.selectMode) {
198
+ if (char === "\r" || char === "\n") this.chooseSelect();
199
+ else if (char === "\x03" || char === "\x04") this.cancelSelect();
200
+ return;
201
+ }
202
+ if (this.paste) {
203
+ if (char === "\x1b") this.escape = "\x1b";
204
+ else this.insert(char === "\n" ? " " : char);
205
+ return;
206
+ }
207
+ this.hint = "";
208
+ switch (char) {
209
+ case "\r":
210
+ case "\n":
211
+ this.submit();
212
+ return;
213
+ case "\x03": // Ctrl+C
214
+ this.settle(null);
215
+ return;
216
+ case "\x04": // Ctrl+D
217
+ this.settle(null);
218
+ return;
219
+ case "\x7f":
220
+ case "\b":
221
+ this.backspace();
222
+ return;
223
+ case "\t":
224
+ this.complete();
225
+ return;
226
+ case "\x01": // Ctrl+A
227
+ this.cursor = 0;
228
+ return;
229
+ case "\x05": // Ctrl+E
230
+ this.cursor = this.buffer.length;
231
+ return;
232
+ default:
233
+ if (char >= " ") this.insert(char);
234
+ }
235
+ }
236
+
237
+ handleEscape(sequence) {
238
+ if (sequence === "\x1b[200~") {
239
+ this.paste = true;
240
+ return;
241
+ }
242
+ if (sequence === "\x1b[201~") {
243
+ this.paste = false;
244
+ return;
245
+ }
246
+ if (this.paste) return;
247
+ if (sequence === "\x1b[Z") {
248
+ // Shift+Tab: cycle a mode (permission preset) while keeping the line.
249
+ if (this.selectMode) return;
250
+ this.hint = this.onShiftTab?.() ?? "";
251
+ return;
252
+ }
253
+ if (this.selectMode) {
254
+ if (sequence === "\x1b[A") {
255
+ this.selectIndex = (this.selectIndex - 1 + this.selectOptions.length) % Math.max(1, this.selectOptions.length);
256
+ } else if (sequence === "\x1b[B") {
257
+ this.selectIndex = (this.selectIndex + 1) % Math.max(1, this.selectOptions.length);
258
+ }
259
+ return;
260
+ }
261
+ switch (sequence) {
262
+ case "\x1b[A":
263
+ this.up();
264
+ break;
265
+ case "\x1b[B":
266
+ this.down();
267
+ break;
268
+ case "\x1b[C":
269
+ this.cursor = Math.min(this.cursor + 1, this.buffer.length);
270
+ break;
271
+ case "\x1b[D":
272
+ this.cursor = Math.max(this.cursor - 1, 0);
273
+ break;
274
+ case "\x1b[H":
275
+ case "\x1b[1~":
276
+ this.cursor = 0;
277
+ break;
278
+ case "\x1b[F":
279
+ case "\x1b[4~":
280
+ this.cursor = this.buffer.length;
281
+ break;
282
+ case "\x1b[3~":
283
+ this.deleteForward();
284
+ break;
285
+ default:
286
+ break;
287
+ }
288
+ }
289
+
290
+ insert(text) {
291
+ const safe = clean(text);
292
+ this.buffer = this.buffer.slice(0, this.cursor) + safe + this.buffer.slice(this.cursor);
293
+ this.cursor += safe.length;
294
+ this.historyIndex = -1;
295
+ this.menuIndex = 0;
296
+ }
297
+
298
+ backspace() {
299
+ if (this.cursor === 0) return;
300
+ this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
301
+ this.cursor -= 1;
302
+ this.historyIndex = -1;
303
+ }
304
+
305
+ deleteForward() {
306
+ if (this.cursor >= this.buffer.length) return;
307
+ this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
308
+ this.historyIndex = -1;
309
+ }
310
+
311
+ up() {
312
+ if (this.menu.length > 0) {
313
+ this.menuIndex = (this.menuIndex - 1 + this.menu.length) % this.menu.length;
314
+ return;
315
+ }
316
+ if (this.history.length === 0) return;
317
+ if (this.historyIndex === -1) {
318
+ this.draft = this.buffer;
319
+ this.historyIndex = this.history.length - 1;
320
+ } else if (this.historyIndex > 0) {
321
+ this.historyIndex -= 1;
322
+ }
323
+ this.buffer = this.history[this.historyIndex];
324
+ this.cursor = this.buffer.length;
325
+ }
326
+
327
+ down() {
328
+ if (this.menu.length > 0) {
329
+ this.menuIndex = (this.menuIndex + 1) % this.menu.length;
330
+ return;
331
+ }
332
+ if (this.historyIndex === -1) return;
333
+ if (this.historyIndex < this.history.length - 1) {
334
+ this.historyIndex += 1;
335
+ this.buffer = this.history[this.historyIndex];
336
+ } else {
337
+ this.historyIndex = -1;
338
+ this.buffer = this.draft;
339
+ }
340
+ this.cursor = this.buffer.length;
341
+ }
342
+
343
+ complete() {
344
+ const item = this.menu[this.menuIndex];
345
+ if (item === undefined) return;
346
+ this.buffer = "/" + item.name;
347
+ this.cursor = this.buffer.length;
348
+ this.menu = [];
349
+ this.menuIndex = 0;
350
+ }
351
+
352
+ submit() {
353
+ const item = this.menu[this.menuIndex];
354
+ if (item !== undefined && this.buffer.trim() !== "/" + item.name) {
355
+ this.buffer = "/" + item.name;
356
+ this.cursor = this.buffer.length;
357
+ }
358
+ const line = this.buffer;
359
+ if (line.trim() !== "" && this.history[this.history.length - 1] !== line) {
360
+ this.history.push(line);
361
+ if (this.history.length > 200) this.history.shift();
362
+ }
363
+ this.settle(line);
364
+ }
365
+
366
+ settle(value, echo = true) {
367
+ if (this.resolve === null) return;
368
+ const resolve = this.resolve;
369
+ this.resolve = null;
370
+ this.active = false;
371
+ this.selectMode = false;
372
+ this.selectOptions = [];
373
+ this.hint = "";
374
+ this.clearRender();
375
+ this.menu = [];
376
+ this.output.write("\x1b[?25h");
377
+ if (echo) this.output.write("\n");
378
+ resolve(value);
379
+ }
380
+
381
+ // ---- rendering ----------------------------------------------------------
382
+ computeMenu() {
383
+ if (!this.menuEnabled) return [];
384
+ if (!this.buffer.startsWith("/")) return [];
385
+ if (/\s/.test(this.buffer)) return [];
386
+ const query = this.buffer.slice(1).toLowerCase();
387
+ const items = this.commands.filter((command) => command.name.toLowerCase().startsWith(query));
388
+ return items.slice(0, 8);
389
+ }
390
+
391
+ clearRender() {
392
+ if (this.renderedRows > 0) {
393
+ this.output.write(`\x1b[${this.renderedRows}B`);
394
+ this.output.write("\r\x1b[0J");
395
+ this.output.write(`\x1b[${this.renderedRows}A`);
396
+ }
397
+ this.output.write("\r\x1b[2K");
398
+ this.renderedRows = 0;
399
+ }
400
+
401
+ render() {
402
+ if (!this.active) return;
403
+ if (this.selectMode) {
404
+ this.renderSelect();
405
+ return;
406
+ }
407
+ this.clearRender();
408
+ const columns = Math.max(40, this.columns() || 80);
409
+ const prompt = this.prompt;
410
+ const promptWidth = visibleWidth(prompt);
411
+ const available = Math.max(8, columns - promptWidth - 1);
412
+ let offset = 0;
413
+ if (this.buffer.length > available) offset = Math.max(0, this.cursor - available + 1);
414
+ const visible = this.buffer.slice(offset, offset + available);
415
+ const column = promptWidth + (this.cursor - offset);
416
+ this.output.write(prompt + visible);
417
+
418
+ this.menu = this.computeMenu();
419
+ if (this.menuIndex >= this.menu.length) this.menuIndex = 0;
420
+ let rows = 0;
421
+ if (this.menu.length > 0) {
422
+ const nameWidth = Math.min(18, Math.max(...this.menu.map((item) => item.name.length)) + 1);
423
+ for (let index = 0; index < this.menu.length; index += 1) {
424
+ const item = this.menu[index];
425
+ const selected = index === this.menuIndex;
426
+ const marker = selected ? "❯" : " ";
427
+ const name = ("/" + clean(item.name)).padEnd(nameWidth + 1);
428
+ const description = clean(item.description ?? "").slice(0, Math.max(10, columns - nameWidth - 8));
429
+ const line = ` ${marker} ${selected ? `\x1b[36m${name}\x1b[39m` : name} ${description}`;
430
+ this.output.write("\n" + line);
431
+ }
432
+ rows = this.menu.length;
433
+ }
434
+ if (this.hint !== "") {
435
+ this.output.write(`\n \x1b[2m${clean(this.hint)}\x1b[22m`);
436
+ rows += 1;
437
+ }
438
+ if (rows > 0) this.output.write(`\x1b[${rows}A`);
439
+ this.renderedRows = rows;
440
+ this.output.write(`\x1b[${column + 1}G`);
441
+ }
442
+
443
+ renderSelect() {
444
+ this.clearRender();
445
+ const columns = Math.max(40, this.columns() || 80);
446
+ this.output.write(this.prompt);
447
+ const options = this.selectOptions;
448
+ const rows = options.length;
449
+ for (let index = 0; index < rows; index += 1) {
450
+ const option = options[index];
451
+ const selected = index === this.selectIndex;
452
+ const marker = selected ? "❯" : " ";
453
+ const label = clean(option.label ?? option.value ?? "");
454
+ const description = option.description ? ` \x1b[2m${clean(option.description)}\x1b[22m` : "";
455
+ const line = ` ${marker} ${selected ? `\x1b[36m${label}\x1b[39m` : label}${description}`;
456
+ this.output.write("\n" + line.slice(0, columns + 16));
457
+ }
458
+ if (rows > 0) this.output.write(`\x1b[${rows}A`);
459
+ this.renderedRows = rows;
460
+ this.output.write(`\x1b[${visibleWidth(this.prompt) + 1}G`);
461
+ }
462
+ }
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
 
@@ -511,9 +547,9 @@ Interactive commands:
511
547
  /new start a fresh session
512
548
  /sessions list recent sessions in this directory
513
549
  /resume <id> switch to another session
514
- /model [id] show or switch the model (e.g. /model deepseek-v4-pro)
515
- /reasoning [off|high|max] show or set the reasoning effort
516
- /permissions [p] show or switch the permission preset (native: /permission)
550
+ /model [id] pick a model, or pass an id directly
551
+ /reasoning [off|high|max] pick a reasoning effort
552
+ /permissions [p] pick a permission mode (native: /permission; Shift+Tab cycles)
517
553
  /skills list the skills discovered in this workspace
518
554
  /jobs list this session's background jobs
519
555
  /todos show the current todo list
@@ -528,6 +564,12 @@ Interactive commands:
528
564
  Harness commands (shared with the Web and Desktop apps):
529
565
  /compact, /goal, /plan, and any command a plugin registers.
530
566
  Type /help inside a session to see the live list.
567
+
568
+ Keyboard:
569
+ Shift+Tab cycle the permission mode (workspace-write, read-only, …)
570
+ Ctrl+C interrupt a running turn; twice exits
571
+ ↑/↓ history (or move in the / command menu)
572
+ Tab complete the highlighted / command
531
573
  `);
532
574
  }
533
575
 
@@ -601,6 +643,7 @@ async function run(ctx, options, exit) {
601
643
  const permissionPresets = ctx.get("permissionPresets");
602
644
  const attachments = ctx.get("attachments");
603
645
  const tokenMeter = ctx.get("tokenMeter");
646
+ const llm = ctx.get("llm");
604
647
 
605
648
  if (options.list) {
606
649
  printSessionList(listSessions());
@@ -616,6 +659,7 @@ async function run(ctx, options, exit) {
616
659
  const autoYes = options.yes || process.env.DSH_CLI_YES === "1";
617
660
 
618
661
  let handle = null;
662
+ let activeAgent = null;
619
663
  let busy = false;
620
664
  let quitting = false;
621
665
  let cancelSent = false;
@@ -631,17 +675,32 @@ async function run(ctx, options, exit) {
631
675
  const write = (text) => process.stdout.write(text);
632
676
 
633
677
  // ---- 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({
678
+ // TTY: a raw-mode line editor with a live slash-command menu (Claude style).
679
+ // Non-TTY: readline over the piped stream. One-shot: no input owner at all.
680
+ const useEditor = interactive && process.stdin.isTTY === true;
681
+ const editor = useEditor
682
+ ? new LineEditor({
638
683
  input: process.stdin,
639
684
  output: process.stdout,
640
- terminal: process.stdin.isTTY,
641
- historySize: 200,
642
- removeHistoryDuplicates: true,
685
+ columns: () => process.stdout.columns || 80,
686
+ onInterrupt: () => handleInterrupt(),
687
+ onShiftTab: () => cyclePermission(),
688
+ onEnd: () => {
689
+ stdinClosed = true;
690
+ if (ready && !busy) shutdown(0);
691
+ },
643
692
  })
644
693
  : null;
694
+ const rlInterface =
695
+ interactive && !useEditor
696
+ ? createInterface({
697
+ input: process.stdin,
698
+ output: process.stdout,
699
+ terminal: false,
700
+ historySize: 200,
701
+ removeHistoryDuplicates: true,
702
+ })
703
+ : null;
645
704
  // Hold the input until the first agent is ready: buffered piped lines must
646
705
  // not be consumed (and dropped) while the profile is still starting.
647
706
  if (rlInterface) {
@@ -650,8 +709,14 @@ async function run(ctx, options, exit) {
650
709
  paused = true;
651
710
  } catch {}
652
711
  }
712
+ editor?.start();
713
+ editor?.setActive(false);
653
714
 
654
715
  function setPaused(value) {
716
+ if (editor) {
717
+ editor.setActive(!value);
718
+ return;
719
+ }
655
720
  if (!rlInterface || paused === value) return;
656
721
  paused = value;
657
722
  try {
@@ -672,12 +737,16 @@ async function run(ctx, options, exit) {
672
737
 
673
738
  /**
674
739
  * 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.
740
+ * stdin closes. On a TTY the editor renders a live command menu.
677
741
  * @param {string} prompt
742
+ * @param {boolean} [menu]
678
743
  * @returns {Promise<string | null>}
679
744
  */
680
- function ask(prompt) {
745
+ function ask(prompt, menu = false) {
746
+ if (editor) {
747
+ setPaused(false);
748
+ return editor.ask(prompt, menu);
749
+ }
681
750
  return new Promise((resolve) => {
682
751
  if (!rlInterface) {
683
752
  resolve(null);
@@ -711,6 +780,7 @@ async function run(ctx, options, exit) {
711
780
 
712
781
  function closeInput() {
713
782
  try {
783
+ editor?.stop();
714
784
  rlInterface?.close();
715
785
  } catch {}
716
786
  }
@@ -888,6 +958,7 @@ async function run(ctx, options, exit) {
888
958
  setup,
889
959
  });
890
960
  handle = created;
961
+ activeAgent = created.agent;
891
962
  wire(created.agent);
892
963
  await created.agent.whenIdle();
893
964
  return created.agent;
@@ -935,6 +1006,73 @@ async function run(ctx, options, exit) {
935
1006
  return `${current.provider}/${current.model}${current.reasoningEffort ? ` · ${current.reasoningEffort}` : ""}`;
936
1007
  };
937
1008
 
1009
+ // ---- permission state, pickers, status bar ------------------------------
1010
+ function currentPermission() {
1011
+ try {
1012
+ if (permissionPresets !== undefined && activeAgent !== null) return permissionPresets.current(activeAgent.session);
1013
+ } catch {}
1014
+ return "workspace-write";
1015
+ }
1016
+
1017
+ /** Shift+Tab: cycle the sandbox/approval preset and return a hint label. */
1018
+ function cyclePermission() {
1019
+ try {
1020
+ if (permissionPresets === undefined || activeAgent === null) return "";
1021
+ const options = permissionPresets.catalog().options.map((option) => option.value);
1022
+ if (options.length === 0) return "";
1023
+ const current = currentPermission();
1024
+ const next = options[(Math.max(0, options.indexOf(current)) + 1) % options.length];
1025
+ permissionPresets.set(activeAgent.session, next);
1026
+ return `⏵⏵ ${next} (shift+tab to cycle)`;
1027
+ } catch {
1028
+ return "";
1029
+ }
1030
+ }
1031
+
1032
+ /** Permanent one-line footer above the prompt. */
1033
+ function printStatusBar() {
1034
+ if (!tty || !interactive) return;
1035
+ out(paint(ansi.dim, ` ⏵⏵ ${sanitize(currentPermission())} · ${sanitize(modelDesc())} · shift+tab to cycle`) + "\n");
1036
+ }
1037
+
1038
+ /** One list picker shared by /model, /reasoning, /permissions, /resume. */
1039
+ async function selectOption(title, options, initial = 0) {
1040
+ if (options.length === 0) return null;
1041
+ if (editor) {
1042
+ printStatusBar();
1043
+ return editor.askSelect(paint(ansi.bold, ` ${title}`), options, initial);
1044
+ }
1045
+ out(`\n ${sanitize(title)}\n`);
1046
+ options.forEach((option, index) => {
1047
+ out(` ${index + 1}) ${sanitize(option.label)}${option.description ? paint(ansi.dim, ` — ${sanitize(option.description)}`) : ""}\n`);
1048
+ });
1049
+ const answer = await ask(paint(ansi.green, " choose: "));
1050
+ const index = Number.parseInt(answer ?? "", 10) - 1;
1051
+ return options[index]?.value ?? null;
1052
+ }
1053
+
1054
+ async function applyModelSelection(modelId) {
1055
+ const next = { ...selectionRef.current, model: modelId };
1056
+ try {
1057
+ await defaultModel.saveSelection(next);
1058
+ selectionRef.current = next;
1059
+ out(paint(ansi.green, ` ✓ model set: ${sanitize(next.provider)}/${sanitize(next.model)} (next message)\n`));
1060
+ } catch (error) {
1061
+ out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1062
+ }
1063
+ }
1064
+
1065
+ async function applyReasoningSelection(effort) {
1066
+ const next = { ...selectionRef.current, reasoningEffort: effort === "default" ? undefined : effort };
1067
+ try {
1068
+ await defaultModel.saveSelection(next);
1069
+ selectionRef.current = next;
1070
+ out(paint(ansi.green, ` ✓ reasoning set: ${sanitize(effort)} (next message)\n`));
1071
+ } catch (error) {
1072
+ out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1073
+ }
1074
+ }
1075
+
938
1076
  // ---- parity helpers -----------------------------------------------------
939
1077
  const transcript = [];
940
1078
  const pendingImages = [];
@@ -1150,7 +1288,9 @@ async function run(ctx, options, exit) {
1150
1288
  }
1151
1289
 
1152
1290
  while (!quitting) {
1153
- const line = await ask(paint(ansi.bold, "> "));
1291
+ printStatusBar();
1292
+ if (editor) editor.setCommands(commandMenu(agent, commands));
1293
+ const line = await ask(paint(ansi.bold, "> "), true);
1154
1294
  if (line === null) break;
1155
1295
  const text = line.trim();
1156
1296
  if (text === "") continue;
@@ -1181,13 +1321,27 @@ async function run(ctx, options, exit) {
1181
1321
  printSessionList(listSessions(), write);
1182
1322
  continue;
1183
1323
  case "/resume": {
1184
- if (argument === "") {
1185
- out(paint(ansi.yellow, " usage: /resume <session-id> (see /sessions)\n"));
1186
- continue;
1324
+ let target = argument;
1325
+ if (target === "") {
1326
+ const sessions = listSessions();
1327
+ if (sessions.length === 0) {
1328
+ out(paint(ansi.yellow, " no sessions in this directory yet\n"));
1329
+ continue;
1330
+ }
1331
+ target = await selectOption(
1332
+ "Resume session",
1333
+ sessions.slice(0, 10).map((session) => ({
1334
+ label: session.id,
1335
+ description: timeAgo(session.mtime) + (session.openTurn ? " · running" : ""),
1336
+ value: session.id,
1337
+ })),
1338
+ 0
1339
+ );
1340
+ if (target === null) continue;
1187
1341
  }
1188
1342
  try {
1189
- out(paint(ansi.dim, ` ↻ resuming ${argument}\n`));
1190
- agent = await startAgent(undefined, argument);
1343
+ out(paint(ansi.dim, ` ↻ resuming ${sanitize(target)}\n`));
1344
+ agent = await startAgent(undefined, target);
1191
1345
  } catch (error) {
1192
1346
  out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1193
1347
  }
@@ -1211,33 +1365,45 @@ async function run(ctx, options, exit) {
1211
1365
  continue;
1212
1366
  }
1213
1367
  case "/model": {
1214
- if (argument === "") {
1215
- out(paint(ansi.dim, ` model: ${modelDesc()}\n`));
1368
+ if (argument !== "") {
1369
+ await applyModelSelection(argument);
1216
1370
  continue;
1217
1371
  }
1218
- const next = { ...selectionRef.current, model: argument };
1372
+ let models = [];
1219
1373
  try {
1220
- await defaultModel.saveSelection(next);
1221
- selectionRef.current = next;
1222
- out(paint(ansi.green, ` ✓ model set: ${next.provider}/${next.model} (next message)\n`));
1223
- } catch (error) {
1224
- out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1374
+ models = llm === undefined ? [] : await llm.listModels(selectionRef.current.provider);
1375
+ } catch {}
1376
+ if (models.length === 0) {
1377
+ out(paint(ansi.dim, ` model: ${sanitize(modelDesc())}`) + "\n");
1378
+ out(paint(ansi.dim, " no model list is discoverable here — use /model <id>\n"));
1379
+ continue;
1225
1380
  }
1381
+ const currentIndex = Math.max(0, models.findIndex((model) => model.id === selectionRef.current.model));
1382
+ const chosen = await selectOption(
1383
+ "Select model",
1384
+ models.map((model) => ({ label: model.name || model.id, description: model.id, value: model.id })),
1385
+ currentIndex
1386
+ );
1387
+ if (chosen !== null) await applyModelSelection(chosen);
1226
1388
  continue;
1227
1389
  }
1228
1390
  case "/reasoning": {
1229
- if (argument === "") {
1230
- out(paint(ansi.dim, ` reasoning: ${selectionRef.current.reasoningEffort ?? "default"}\n`));
1391
+ if (argument !== "") {
1392
+ await applyReasoningSelection(argument);
1231
1393
  continue;
1232
1394
  }
1233
- const next = { ...selectionRef.current, reasoningEffort: argument === "default" ? undefined : argument };
1234
- try {
1235
- await defaultModel.saveSelection(next);
1236
- selectionRef.current = next;
1237
- out(paint(ansi.green, ` ✓ reasoning set: ${argument} (next message)\n`));
1238
- } catch (error) {
1239
- out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1240
- }
1395
+ const efforts = ["off", "high", "max", "default"];
1396
+ const current = selectionRef.current.reasoningEffort ?? "default";
1397
+ const chosen = await selectOption(
1398
+ "Select reasoning effort",
1399
+ efforts.map((effort) => ({
1400
+ label: effort,
1401
+ description: effort === "default" ? "provider default" : `effort: ${effort}`,
1402
+ value: effort,
1403
+ })),
1404
+ Math.max(0, efforts.indexOf(current))
1405
+ );
1406
+ if (chosen !== null) await applyReasoningSelection(chosen);
1241
1407
  continue;
1242
1408
  }
1243
1409
  case "/thinking": {
@@ -1319,19 +1485,25 @@ async function run(ctx, options, exit) {
1319
1485
  continue;
1320
1486
  }
1321
1487
  try {
1322
- const catalog = permissionPresets.catalog();
1323
- if (argument === "") {
1324
- out(paint(ansi.bold, "Permission presets:") + "\n");
1325
- for (const option of catalog.options) {
1326
- out(` ${paint(ansi.cyan, option.value.padEnd(18))} ${option.name}${option.description ? paint(ansi.dim, ` — ${option.description}`) : ""}\n`);
1327
- }
1328
- out(paint(ansi.dim, ` default: ${catalog.defaultPreset}`) + "\n");
1329
- } else {
1488
+ if (argument !== "") {
1330
1489
  permissionPresets.set(agent.session, argument);
1331
- out(paint(ansi.green, ` ✓ permissions: ${argument} (applies to the next tool call)\n`));
1490
+ out(paint(ansi.green, ` ✓ permission mode: ${sanitize(argument)}\n`));
1491
+ continue;
1492
+ }
1493
+ const catalog = permissionPresets.catalog();
1494
+ const options = catalog.options.map((option) => ({
1495
+ label: option.value === catalog.defaultPreset ? `${option.value} (default)` : option.value,
1496
+ description: option.description || option.name,
1497
+ value: option.value,
1498
+ }));
1499
+ const initial = Math.max(0, options.findIndex((option) => option.value === currentPermission()));
1500
+ const chosen = await selectOption("Permission mode", options, initial);
1501
+ if (chosen !== null) {
1502
+ permissionPresets.set(agent.session, chosen);
1503
+ out(paint(ansi.green, ` ✓ permission mode: ${sanitize(chosen)}\n`));
1332
1504
  }
1333
1505
  } catch (error) {
1334
- out(paint(ansi.red, ` ✘ ${describeError(error)}\n`));
1506
+ out(paint(ansi.red, ` ✘ ${sanitize(describeError(error))}\n`));
1335
1507
  }
1336
1508
  continue;
1337
1509
  }