@antst/dashi 0.1.0-alpha.7 → 0.1.0-alpha.9

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.
@@ -1,4 +1,5 @@
1
1
  import { createRenderer } from './renderer.js';
2
+ import { runEditor } from './external-editor.js';
2
3
  import { initialViewState, reduce, } from './state.js';
3
4
  import { createTerminalGuard } from './terminal-guard.js';
4
5
  function suspendProcess() {
@@ -44,7 +45,14 @@ export function createTerminalShell(options) {
44
45
  async function run(effect) {
45
46
  switch (effect.type) {
46
47
  case 'activate-overlay':
47
- await options.activateOverlay?.(effect.value);
48
+ if (effect.value.kind === 'open-file') {
49
+ const path = effect.value.path;
50
+ await guard.withCookedTerminal(() => runEditor(process.env.EDITOR?.trim() || 'vi', path, options.cwd)).catch(error => {
51
+ dispatch({ type: 'runtime-error', message: error instanceof Error ? error.message : String(error) });
52
+ });
53
+ }
54
+ else
55
+ await options.activateOverlay?.(effect.value);
48
56
  return;
49
57
  case 'attach': {
50
58
  try {
@@ -168,6 +176,7 @@ export function createTerminalShell(options) {
168
176
  return;
169
177
  failed = true;
170
178
  accepting = false;
179
+ renderer.discardSecretComposer();
171
180
  const detail = error instanceof Error ? error.stack ?? error.message : String(error);
172
181
  writeError(`dashi: terminal failure\n${detail}\n`);
173
182
  try {
@@ -193,6 +202,9 @@ export function createTerminalShell(options) {
193
202
  function dispatch(action) {
194
203
  if (!accepting)
195
204
  return;
205
+ const question = state.decisions[0]?.kind === 'question' ? state.decisions[0].questions[state.decisions[0].index] : undefined;
206
+ if (question?.secret === true && ['decision-submit', 'decision-cancel', 'decision-withdrawn', 'escape', 'terminal-lost'].includes(action.type))
207
+ renderer.discardSecretComposer();
196
208
  const [next, effects] = reduce(state, action);
197
209
  state = next;
198
210
  if (effects.length === 0)
@@ -216,6 +228,7 @@ export function createTerminalShell(options) {
216
228
  },
217
229
  dispatch,
218
230
  async dispose() {
231
+ renderer.discardSecretComposer();
219
232
  const [next, effects] = reduce(state, { type: 'terminal-lost' });
220
233
  state = next;
221
234
  for (const effect of effects)
package/lib/catalogs.d.ts CHANGED
@@ -6,6 +6,8 @@ import type { SessionSearchHit } from '@deepseek-ai/dsh-session-query';
6
6
  import type { Overlay, OverlayOption, RootView } from './state.js';
7
7
  /** Build command and skill completion rows for one exact root. */
8
8
  export declare function completionOptions(ctx: Context, agent: Agent, input: string, caret?: number, signal?: AbortSignal): Promise<readonly OverlayOption[]>;
9
+ /** Build a filtered view of the skills resolved for one live session. */
10
+ export declare function skillOverlay(ctx: Context, agent: Agent, filter: string, signal?: AbortSignal): Promise<Overlay>;
9
11
  /** Build the cold session picker without adopting any candidate. */
10
12
  export declare function sessionOverlay(items: readonly SessionSummary[], currentId: string, cwd?: string): Overlay;
11
13
  /** Flatten DSH's provider/model/effort catalog into one terminal picker. */
package/lib/catalogs.js CHANGED
@@ -69,6 +69,23 @@ export async function completionOptions(ctx, agent, input, caret = input.length,
69
69
  }));
70
70
  return [...commands, ...skills];
71
71
  }
72
+ /** Build a filtered view of the skills resolved for one live session. */
73
+ export async function skillOverlay(ctx, agent, filter, signal) {
74
+ const lookup = { cwd: agent.session.header.cwd, scope: agent, signal };
75
+ const needle = filter.trim().toLocaleLowerCase();
76
+ const skills = (await ctx.skills.list(lookup)).filter(skill => needle === ''
77
+ || skill.name.toLocaleLowerCase().includes(needle)
78
+ || skill.description.toLocaleLowerCase().includes(needle));
79
+ const definitions = await Promise.all(skills.map(skill => ctx.skills.get(skill.name, lookup)));
80
+ return {
81
+ cursor: 0, kind: 'list', purpose: 'skills', title: needle === '' ? 'Skills' : `Skills · ${filter.trim()}`,
82
+ options: skills.map((skill, index) => ({
83
+ detail: `${skill.description} · user ${skill.invocation.userInvocable ? 'yes' : 'no'} · model ${skill.invocation.modelInvocable ? 'yes' : 'no'} · source ${skill.source} · provider ${skill.provider} · path ${definitions[index]?.path ?? 'unavailable'}`,
84
+ label: `/${skill.name}`,
85
+ value: { kind: 'insert', text: `/${skill.name} ` },
86
+ })),
87
+ };
88
+ }
72
89
  /** Build the cold session picker without adopting any candidate. */
73
90
  export function sessionOverlay(items, currentId, cwd) {
74
91
  return {
@@ -0,0 +1,5 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { Agent } from '@deepseek-ai/dsh-agent';
3
+ import type { Overlay } from './state.js';
4
+ import { type ToolPresenter } from './tool-presentation.js';
5
+ export declare function diffOverlay(ctx: Context, agent: Agent, presenter: ToolPresenter, mode: '' | 'turn', signal: AbortSignal): Promise<Overlay>;
@@ -0,0 +1,24 @@
1
+ import { HUMAN_SHELL_STREAM_BYTES } from './shell-command.js';
2
+ import { boundedBody } from './tool-presentation.js';
3
+ import { foldCells } from './transcript.js';
4
+ export async function diffOverlay(ctx, agent, presenter, mode, signal) {
5
+ if (mode === 'turn') {
6
+ const events = agent.session.snapshotEvents();
7
+ const start = events.map(event => event.type).lastIndexOf('turn/start');
8
+ const cells = (start < 0 ? [] : foldCells(events.slice(start), presenter)).filter(cell => cell.pending !== true && cell.tool?.card === 'diff' && (cell.tool.diffs?.length ?? 0) > 0);
9
+ return { cells, kind: 'info', lines: cells.length === 0 ? ['No write/edit changes in the last turn.'] : [], title: 'Last turn diff' };
10
+ }
11
+ const command = 'git diff --no-ext-diff --no-color HEAD --';
12
+ const result = await ctx.shell.run(ctx.shell.resolve({
13
+ command, sandboxPolicy: ctx.sandboxPolicy.resolve({ session: agent.session }), signal,
14
+ stdoutMaxBytes: HUMAN_SHELL_STREAM_BYTES, timeoutMs: 30_000, workdir: agent.session.header.cwd,
15
+ }));
16
+ const output = [result.stdout.text, result.stderr.text].filter(Boolean).join('\n');
17
+ const cell = {
18
+ key: 'diff:working', kind: result.exitCode === 0 ? 'tool' : 'error', text: 'git diff',
19
+ tool: { card: 'terminal', title: 'git diff', status: result.exitCode === 0 ? output === '' ? 'clean' : 'changed'
20
+ : result.exitCode === null ? result.signal ?? 'failed' : `exit ${String(result.exitCode)}`,
21
+ ...(output === '' ? {} : { body: boundedBody(output) }) },
22
+ };
23
+ return { cells: [cell], kind: 'info', lines: [], title: 'Working tree diff' };
24
+ }
@@ -1,2 +1,3 @@
1
+ export declare function runEditor(command: string, file: string, cwd: string): Promise<void>;
1
2
  /** Edit one draft through the user's terminal editor and always remove its file. */
2
3
  export declare function editDraftExternally(text: string, cwd: string): Promise<string>;
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdtemp, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- function runEditor(command, file, cwd) {
5
+ export function runEditor(command, file, cwd) {
6
6
  return new Promise((resolve, reject) => {
7
7
  const child = spawn('/bin/sh', ['-c', `exec ${command} "$1"`, 'dashi-editor', file], {
8
8
  cwd, stdio: 'inherit',
package/lib/help.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare const VERSION_LINE: string;
2
+ export declare const FLAG_HELP: readonly ["Launch flags: --inline | --fullscreen · --accessible · -r, --resume [NAME|UUID] · -c, --continue · --fork-session · --all", " -n, --name TITLE · --agent PRESET · --session-id UUID · --model ID [--provider ID] · --effort LEVEL", " --permission PRESET · --yolo | --dangerously-skip-permissions · --image PATH · -h, --help"];
package/lib/help.js ADDED
@@ -0,0 +1,6 @@
1
+ import dashi from '../package.json' with { type: 'json' };
2
+ import dsh from '@deepseek-ai/dsh-cmdline/package.json' with { type: 'json' };
3
+ export const VERSION_LINE = `dashi ${dashi.version} on DSH ${dsh.version}`;
4
+ export const FLAG_HELP = [
5
+ 'Launch flags: --inline | --fullscreen · --accessible · -r, --resume [NAME|UUID] · -c, --continue · --fork-session · --all', ' -n, --name TITLE · --agent PRESET · --session-id UUID · --model ID [--provider ID] · --effort LEVEL', ' --permission PRESET · --yolo | --dangerously-skip-permissions · --image PATH · -h, --help'
6
+ ];
package/lib/index.js CHANGED
@@ -6,17 +6,20 @@ import { createTerminalShell } from './application.js';
6
6
  import { sessionOverlay } from './catalogs.js';
7
7
  import { readClipboardImage } from './clipboard-image.js';
8
8
  import { editDraftExternally } from './external-editor.js';
9
+ import { FLAG_HELP, VERSION_LINE } from './help.js';
9
10
  import { formatSessionList, isSessionId, parseSessionListArgs, sessionMatches, sessionNotFound, } from './session-list.js';
10
11
  import { createSessionRuntime } from './session-runtime.js';
11
12
  import { TuiRoot } from './tui-root.js';
12
13
  export const name = 'dashi';
13
14
  export const inject = [
14
- 'agentPresets', 'agents', 'attachments', 'cmdlineArgs', 'commands', 'fs', 'permissionPresets', 'sessionController',
15
- 'sandboxPolicy', 'sessionProjections', 'sessionQuery', 'sessions', 'shell', 'skills', 'tools',
15
+ 'agentPresets', 'agents', 'attachments', 'authorization', 'cmdlineArgs', 'commands', 'credentials', 'fs', 'loader', 'permissionPresets',
16
+ 'pluginInventory', 'sessionController', 'sandboxPolicy', 'sessionProjections', 'sessionQuery', 'sessions', 'settings', 'shell', 'skills', 'tools',
16
17
  ];
17
18
  const validatedVersions = JSON.parse(readFileSync(new URL('../validated-dsh-versions.json', import.meta.url), 'utf8'));
18
19
  function parseArgs(args) {
19
20
  let accessible = false;
21
+ let agentPreset, sessionId;
22
+ let forkSession = false;
20
23
  let inline = true;
21
24
  let name;
22
25
  let effort;
@@ -37,13 +40,15 @@ function parseArgs(args) {
37
40
  inline = true;
38
41
  else if (argument === '--fullscreen')
39
42
  inline = false;
40
- else if (argument === '--name' || argument === '--image' || argument === '--effort'
41
- || argument === '--model' || argument === '--permission' || argument === '--provider') {
43
+ else if (['--name', '-n', '--agent', '--image', '--effort', '--model', '--permission', '--provider',
44
+ '--session-id'].includes(argument ?? '')) {
42
45
  const value = args[++index];
43
46
  if (value === undefined || value === '')
44
47
  return undefined;
45
- if (argument === '--name')
48
+ if (argument === '--name' || argument === '-n')
46
49
  name = value;
50
+ else if (argument === '--agent')
51
+ agentPreset = value;
47
52
  else if (argument === '--image')
48
53
  images.push(value);
49
54
  else if (argument === '--effort')
@@ -52,6 +57,8 @@ function parseArgs(args) {
52
57
  model = value;
53
58
  else if (argument === '--permission')
54
59
  permission = value;
60
+ else if (argument === '--session-id')
61
+ sessionId = value;
55
62
  else
56
63
  provider = value;
57
64
  }
@@ -68,6 +75,8 @@ function parseArgs(args) {
68
75
  resumeAll = true;
69
76
  else if (argument === '--continue' || argument === '-c')
70
77
  useContinue = true;
78
+ else if (argument === '--fork-session')
79
+ forkSession = true;
71
80
  else if (argument === '--yolo' || argument === '--dangerously-skip-permissions') {
72
81
  permission = 'danger-full-access';
73
82
  }
@@ -79,12 +88,15 @@ function parseArgs(args) {
79
88
  if (resume !== undefined && (resumePicker || useContinue) || resumePicker && useContinue
80
89
  || name !== undefined && (resume !== undefined || resumePicker || useContinue)
81
90
  || resumeAll && resume === undefined && !resumePicker || resumeAll && useContinue
82
- || provider !== undefined && model === undefined)
91
+ || provider !== undefined && model === undefined
92
+ || sessionId !== undefined && (!isSessionId(sessionId) || resume !== undefined || resumePicker || useContinue)
93
+ || agentPreset !== undefined && (resume !== undefined || resumePicker || useContinue)
94
+ || forkSession && resume === undefined && !resumePicker && !useContinue)
83
95
  return undefined;
84
96
  return {
85
97
  accessible,
86
98
  continue: useContinue,
87
- inline,
99
+ ...(agentPreset === undefined ? {} : { agentPreset }), forkSession, inline,
88
100
  resumeAll,
89
101
  resumePicker,
90
102
  ...(images.length === 0 ? {} : { images }),
@@ -94,7 +106,7 @@ function parseArgs(args) {
94
106
  ...(permission === undefined ? {} : { permission }),
95
107
  ...(prompt.length === 0 ? {} : { prompt: prompt.join(' ') }),
96
108
  ...(provider === undefined ? {} : { provider }),
97
- ...(resume === undefined ? {} : { resume }),
109
+ ...(resume === undefined ? {} : { resume }), ...(sessionId === undefined ? {} : { sessionId }),
98
110
  };
99
111
  }
100
112
  function packageVersion(path) {
@@ -154,6 +166,11 @@ export function apply(ctx) {
154
166
  throw new Error('dashi: the launcher must provide ctx.appExit, ctx.appReady, and ctx.cmdlineArgs');
155
167
  }
156
168
  const args = cmdline.get();
169
+ if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
170
+ process.stdout.write(`${VERSION_LINE}\n\nUsage: dashi [options] [prompt]\n\n${FLAG_HELP.join('\n')}\n`);
171
+ exit(0);
172
+ return;
173
+ }
157
174
  if (args[0] === 'sessions') {
158
175
  const options = parseSessionListArgs(args);
159
176
  if (options === undefined) {
package/lib/input.js CHANGED
@@ -102,6 +102,8 @@ export function installInput(tui, bindings) {
102
102
  bindings.dispatch({ type: 'overlay-move', offset: -1 });
103
103
  else if (matchesKey(data, 'down'))
104
104
  bindings.dispatch({ type: 'overlay-move', offset: 1 });
105
+ else if (matchesKey(data, 'ctrl+o'))
106
+ bindings.dispatch({ type: 'toggle-tool-mode' });
105
107
  else if (matchesKey(data, 'enter'))
106
108
  bindings.dispatch({ type: 'overlay-submit', execute: true });
107
109
  else if (matchesKey(data, 'tab'))
@@ -0,0 +1,3 @@
1
+ import type { Agent } from '@deepseek-ai/dsh-agent';
2
+ import type { Overlay } from './state.js';
3
+ export declare function memoryOverlay(agent: Agent): Overlay;
package/lib/memory.js ADDED
@@ -0,0 +1,27 @@
1
+ import { resolve } from 'node:path';
2
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
3
+ export function memoryOverlay(agent) {
4
+ const cwd = agent.session.header.cwd ?? process.cwd();
5
+ let root = cwd;
6
+ const active = new Map();
7
+ for (const seq of agent.session.surface.nodes) {
8
+ const event = agent.session.eventAt(seq);
9
+ if (event?.type !== 'user/message' || event.data.source.kind !== 'agent-instructions')
10
+ continue;
11
+ const source = event.data.source, identity = source.baselineIdentity === undefined
12
+ ? undefined : JSON.parse(source.baselineIdentity);
13
+ if (typeof identity?.projectRoot === 'string')
14
+ root = resolve(cwd, identity.projectRoot);
15
+ for (const change of source.changes) {
16
+ if (change.action === 'remove')
17
+ active.delete(change.scope);
18
+ else
19
+ active.set(change.scope, { path: change.path, scope: change.scope.split('\0')[0] ?? change.scope });
20
+ }
21
+ }
22
+ return { cursor: 0, kind: 'list', options: [...active.values()].map(item => {
23
+ const homePath = /^(?:~\/\.dsh|\$DSH_HOME)\/(.*)$/u.exec(item.path)?.[1];
24
+ return { detail: `scope ${item.scope}`, label: item.path,
25
+ value: { kind: 'open-file', path: resolve(homePath === undefined ? root : resolveDshHome(), homePath ?? item.path) } };
26
+ }), purpose: 'memory', title: 'Memory' };
27
+ }
package/lib/renderer.d.ts CHANGED
@@ -6,6 +6,7 @@ export type CopyResult = 'ok' | 'too-large' | 'unsupported';
6
6
  export interface Renderer extends TerminalSession {
7
7
  bell(): void;
8
8
  copy(text: string): CopyResult;
9
+ discardSecretComposer(): void;
9
10
  materializedCells(): number;
10
11
  setComposer(text: string, cursor?: ComposerCursor): void;
11
12
  }
package/lib/renderer.js CHANGED
@@ -14,6 +14,7 @@ const OVERSCAN_CELLS = 8;
14
14
  const THEME_GENERATION = 1;
15
15
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
16
16
  const SPINNER_INTERVAL_MS = 80;
17
+ const MASK_TOKEN = new RegExp(String.raw `\u001B(?:\[[0-?]*[ -/]*[@-~]|[_P^X][^\u0007]*(?:\u0007|\u001B\\))|[^\s]`, 'gu');
17
18
  export function supportsOsc52(terminalType) {
18
19
  return terminalType?.toLocaleLowerCase() !== 'dumb';
19
20
  }
@@ -160,14 +161,22 @@ export function createRenderer(options) {
160
161
  const tui = options.inline
161
162
  ? new TuiMainScreen(terminal, true)
162
163
  : new TuiAltScreen(terminal, true, undefined, { mouse: true });
163
- const editor = new Editor(tui, theme(), { paddingX: 1 });
164
+ let editor = new Editor(tui, theme(), { paddingX: 1 });
164
165
  editor.disableSubmit = true;
165
166
  editor.onChange = text => {
167
+ const decision = options.readState().decisions[0];
166
168
  const caret = caretOffset(text, editor.getCursor());
167
169
  options.dispatch({
168
- type: 'composer-changed', text, caret, completion: hasCompletionTrigger(text, caret),
170
+ type: 'composer-changed', text: decision?.kind === 'question' && decision.questions[decision.index]?.secret === true ? editor.getExpandedText() : text, caret, completion: hasCompletionTrigger(text, caret),
169
171
  });
170
172
  };
173
+ const replaceEditor = () => {
174
+ const discarded = editor;
175
+ editor = new Editor(tui, theme(), { paddingX: 1 });
176
+ editor.disableSubmit = true;
177
+ editor.onChange = discarded.onChange;
178
+ tui.setFocus(editor);
179
+ };
171
180
  const lineCache = new Map();
172
181
  let animationTimer;
173
182
  const cellLines = (cell, width, toolMode, now) => {
@@ -203,14 +212,18 @@ export function createRenderer(options) {
203
212
  return [`${RED}Invalid empty question batch${RESET}`];
204
213
  const planReview = question.intent?.kind === 'plan-review';
205
214
  const heading = planReview ? 'Plan review' : question.header ?? `Question ${String(decision.index + 1)}/${String(decision.questions.length)}`;
215
+ const customAllowed = question.allowCustom !== false;
206
216
  const selected = question.options[decision.cursor]?.label ?? 'Other (type an answer)';
207
- const announced = announcement(plain(heading), question.options.length + 1, selected, decision.cursor);
217
+ const announced = announcement(plain(heading), question.options.length + Number(customAllowed), selected, decision.cursor);
208
218
  const prompt = wrapTextWithAnsi(`${BOLD_CYAN}${announced}${RESET}\n${plain(question.question, true)}`, width);
209
219
  const choiceRows = question.options.map((option, index) => optionLine(option.description === undefined ? option.label : `${option.label} — ${option.description}`, index + 1, decision.cursor === index, decision.selected.includes(option.label)));
210
- choiceRows.push(optionLine('Other (type an answer)', question.options.length + 1, decision.cursor === question.options.length, false));
220
+ if (customAllowed)
221
+ choiceRows.push(optionLine('Other (type an answer)', question.options.length + 1, decision.cursor === question.options.length, false));
211
222
  const detailLimit = planReview ? Math.max(3, rows - prompt.length - choiceRows.length - 5) : 3;
212
223
  const detail = question.detail === undefined ? [] : wrapTextWithAnsi(`${DIM}${plain(question.detail, true)}${RESET}`, width).slice(0, detailLimit);
213
- const custom = decision.cursor === question.options.length ? editor.render(width) : [];
224
+ const editorLines = decision.cursor === question.options.length ? editor.render(width) : [];
225
+ const custom = question.secret === true
226
+ ? editorLines.map((value, index) => index === 0 || index === editorLines.length - 1 ? value : value.replace(MASK_TOKEN, token => token.startsWith('\u001B') ? token : '•')) : editorLines;
214
227
  const rendered = [...prompt, ...detail, ...choiceRows, ...custom];
215
228
  return rows < 12 ? rendered.slice(0, Math.max(2, rows - 4)) : rendered;
216
229
  };
@@ -268,9 +281,11 @@ export function createRenderer(options) {
268
281
  if (overlay.kind === 'details')
269
282
  return detailsPanel(overlay, state, width, rows);
270
283
  if (overlay.kind === 'info') {
284
+ const cards = overlay.cells?.flatMap(cell => wrapTextWithAnsi(cellText(cell, state.toolMode, accessible), width)) ?? [];
271
285
  return [
272
- `${BOLD_CYAN}${announcement(plain(overlay.title), overlay.lines.length)}${RESET}`,
286
+ `${BOLD_CYAN}${announcement(plain(overlay.title), overlay.lines.length + (overlay.cells?.length ?? 0))}${RESET}`,
273
287
  ...overlay.lines.flatMap(value => wrapTextWithAnsi(plain(value, true), width)),
288
+ ...cards,
274
289
  `${DIM}Enter or Esc to close${RESET}`,
275
290
  ].slice(0, Math.max(2, rows - 3));
276
291
  }
@@ -404,6 +419,7 @@ export function createRenderer(options) {
404
419
  terminal.write(`\u001B]52;c;${Buffer.from(safe).toString('base64')}\u0007`);
405
420
  return 'ok';
406
421
  },
422
+ discardSecretComposer: replaceEditor,
407
423
  drainInput: () => terminal.drainInput(),
408
424
  materializedCells: () => materializedCount,
409
425
  render: force => { tui.renderNow(force); },
@@ -2,9 +2,11 @@ import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { ActivatableOverlayValue, DecisionAnswer, DraftAttachment, OverlayOption, PendingDecision, RootView, SendMode, TerminalCell, UiAction } from './state.js';
3
3
  import type { TuiRoot } from './tui-root.js';
4
4
  export interface RootLaunchOptions {
5
+ readonly agentPreset?: string;
5
6
  readonly continue: boolean;
6
7
  readonly cwd: string;
7
8
  readonly effort?: string;
9
+ readonly forkSession: boolean;
8
10
  readonly name?: string;
9
11
  readonly images?: readonly string[];
10
12
  readonly model?: string;
@@ -12,6 +14,7 @@ export interface RootLaunchOptions {
12
14
  readonly prompt?: string;
13
15
  readonly provider?: string;
14
16
  readonly resume?: string;
17
+ readonly sessionId?: string;
15
18
  }
16
19
  export interface SessionRuntime {
17
20
  readonly initialAttachments: readonly DraftAttachment[];
@@ -1,15 +1,23 @@
1
+ import { basename } from 'node:path';
2
+ import { AuthorizationDeclinedError } from '@deepseek-ai/dsh-authorization';
3
+ import { parseCredentialKey } from '@deepseek-ai/dsh-credentials';
1
4
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
5
  import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session';
3
- import { agentPresetOverlay, completionOptions, contextOverlay, currentModel, modelOverlay, permissionOverlay, searchOverlay, sessionOverlay, statusOverlay, } from './catalogs.js';
6
+ import { agentPresetOverlay, completionOptions, contextOverlay, currentModel, modelOverlay, permissionOverlay, searchOverlay, sessionOverlay, skillOverlay, statusOverlay, } from './catalogs.js';
7
+ import { diffOverlay } from './diff-view.js';
4
8
  import { eventsFromRecords } from './history-records.js';
5
9
  import { admitDraftImages, encodeDraftImages, readDraftImages } from './image-input.js';
6
10
  import { contextPercent, jobViews, subagentViews } from './presentation.js';
7
11
  import { humanPrompt, humanPrompts, inheritedTurn, rewindActionOverlay, rewindOverlay, } from './rewind.js';
8
12
  import { createToolPresenter } from './tool-presentation.js';
9
- import { runHumanShell } from './shell-command.js';
13
+ import { quoteShellWord, runHumanShell } from './shell-command.js';
10
14
  import { markdownTranscript } from './session-export.js';
11
15
  import { isSessionId, sessionMatches, sessionNotFound } from './session-list.js';
16
+ import { memoryOverlay } from './memory.js';
12
17
  import { foldCells, pendingShellCells } from './transcript.js';
18
+ function starterAgents(cwd) {
19
+ return `# ${basename(cwd)}\n\n## Working agreement\n\n- Add project-specific architecture guidance.\n- Add required build, test, and lint commands.\n- Add repository conventions and constraints.\n`;
20
+ }
13
21
  function detail(error) {
14
22
  return error instanceof Error ? error.message : String(error);
15
23
  }
@@ -71,6 +79,17 @@ async function firstControl(iterator) {
71
79
  function usage(raw, expected) {
72
80
  return raw.trim() === '' ? undefined : { kind: 'error', text: `usage: ${expected}` };
73
81
  }
82
+ function configValue(raw) {
83
+ try {
84
+ return JSON.parse(raw);
85
+ }
86
+ catch {
87
+ return raw;
88
+ }
89
+ }
90
+ function configLayer(value) {
91
+ return value === undefined ? '—' : JSON.stringify(value);
92
+ }
74
93
  /** Create or resume the exact current root and own its replaceable terminal binding. */
75
94
  export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, decisions) {
76
95
  const lifetime = new AbortController();
@@ -78,11 +97,13 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
78
97
  let started = false;
79
98
  let shutdown;
80
99
  let decisionSequence = 0;
81
- const initialId = options.resume === undefined
100
+ let initialId = options.resume === undefined
82
101
  ? options.continue
83
102
  ? continuation((await ctx.sessionController.list({}, lifetime.signal)).items, options.cwd)
84
- : (await ctx.sessionController.create({ cwd: options.cwd })).sessionId
103
+ : (await ctx.sessionController.create({ cwd: options.cwd, ...(options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset }), ...(options.sessionId === undefined ? {} : { sessionId: SessionId(options.sessionId) }) })).sessionId
85
104
  : SessionId(options.resume);
105
+ if (options.forkSession)
106
+ initialId = (await ctx.sessionController.fork({ sessionId: initialId })).sessionId;
86
107
  const prepare = async (sessionId, name) => {
87
108
  const abort = new AbortController();
88
109
  try {
@@ -178,6 +199,15 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
178
199
  signal?.removeEventListener('abort', abortDecision);
179
200
  });
180
201
  };
202
+ const authorizationPrompt = (bound, prompt, detail) => decide({
203
+ answers: [], cursor: 0, custom: '', id: `authorization:${String(++decisionSequence)}`, index: 0, kind: 'question', owner: { id: bound.root.id, ...(bound.root.title === undefined ? {} : { label: bound.root.title }) }, selected: [],
204
+ questions: [{ allowCustom: prompt.kind !== 'select', ...(detail === undefined ? {} : { detail }), header: 'Login', id: 'authorization', multiSelect: false, options: prompt.kind === 'select' ? prompt.options : [], question: prompt.message, secret: prompt.kind === 'secret' }],
205
+ }, prompt.signal).then(result => prompt.kind === 'select' ? prompt.options[prompt.options.findIndex(option => option.label === result.answers[0]?.selected[0])]?.id ?? ''
206
+ : result.answers[0]?.custom ?? '').catch(error => {
207
+ if (prompt.signal?.aborted !== true && error.code === 'ASK_ABORTED')
208
+ throw new AuthorizationDeclinedError();
209
+ throw error;
210
+ });
181
211
  const transcriptCells = (bound) => [
182
212
  ...foldCells(bound.events, bound.presenter, { truncatedStart: bound.hasMore }),
183
213
  ...pendingShellCells(bound.agent.inbox.nextStep),
@@ -451,7 +481,7 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
451
481
  if (nativePermission === undefined)
452
482
  throw new Error('dashi: DSH supplied no /permission command');
453
483
  const ensureCurrent = (invocation) => current().agent === invocation.agent ? undefined : { kind: 'error', text: 'this root is no longer current' };
454
- return [
484
+ const definitions = [
455
485
  {
456
486
  name: 'help', description: 'Show keyboard and command help',
457
487
  handler: (invocation) => {
@@ -491,6 +521,133 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
491
521
  return { kind: 'success' };
492
522
  },
493
523
  },
524
+ {
525
+ name: 'memory', description: 'Open an instruction file loaded by DSH',
526
+ handler: (invocation) => {
527
+ const invalid = ensureCurrent(invocation) ?? usage(invocation.rawInput, '/memory');
528
+ if (invalid !== undefined)
529
+ return invalid;
530
+ dispatch({ type: 'open-overlay', overlay: memoryOverlay(bound.agent) });
531
+ return { kind: 'success' };
532
+ },
533
+ },
534
+ {
535
+ name: 'skills', description: 'List skills resolved for this session', input: { hint: '[TEXT]' },
536
+ handler: async (invocation) => {
537
+ const stale = ensureCurrent(invocation);
538
+ if (stale !== undefined)
539
+ return stale;
540
+ const overlay = await skillOverlay(ctx, bound.agent, invocation.rawInput, invocation.signal);
541
+ const replaced = ensureCurrent(invocation);
542
+ if (replaced !== undefined)
543
+ return replaced;
544
+ dispatch({ type: 'open-overlay', overlay });
545
+ return { kind: 'success' };
546
+ },
547
+ },
548
+ {
549
+ name: 'config', description: 'Show or update native DSH settings', input: { hint: '[NAMESPACE KEY=VALUE]' },
550
+ handler: async (invocation) => {
551
+ const stale = ensureCurrent(invocation);
552
+ if (stale !== undefined)
553
+ return stale;
554
+ const raw = invocation.rawInput.trim();
555
+ if (raw === '')
556
+ return { kind: 'success', text: ctx.settings.describe({ redactSecrets: true }).map(item => `${String(item.ns)}\n value: ${configLayer(item.value)}\n base: ${configLayer(item.base)}\n user: ${configLayer(item.user)}`).join('\n') };
557
+ const match = /^(\S+)\s+([^\s=]+)=(.*)$/su.exec(raw);
558
+ if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) {
559
+ return { kind: 'error', text: 'usage: /config [NAMESPACE KEY=VALUE]' };
560
+ }
561
+ const value = configValue(match[3]);
562
+ if (match[2].includes('.'))
563
+ await ctx.settings.mutate(match[1], [{ op: 'set', path: match[2].split('.'), value }]);
564
+ else
565
+ await ctx.settings.update(match[1], { [match[2]]: value });
566
+ return { kind: 'success', text: `updated ${match[1]}.${match[2]}` };
567
+ },
568
+ },
569
+ { name: 'login', description: 'List or start provider sign-in', input: { hint: '[KEY [METHOD]]' },
570
+ handler: async (invocation) => {
571
+ const stale = ensureCurrent(invocation);
572
+ if (stale !== undefined)
573
+ return stale;
574
+ const values = invocation.rawInput.trim().split(/\s+/u).filter(Boolean);
575
+ if (values.length === 0)
576
+ return { kind: 'success', text: ctx.authorization.list().map(entry => `${String(entry.key)} · ${entry.label} · ${entry.methods.map(method => `${method.id}: ${method.label}`).join(', ')}${entry.inFlight ? ' · in progress' : ''}`).join('\n') || 'no authorization flows' };
577
+ if (values.length > 2)
578
+ return { kind: 'error', text: 'usage: /login [KEY [METHOD]]' };
579
+ const entry = ctx.authorization.describe(parseCredentialKey(values[0]));
580
+ if (entry === undefined)
581
+ return { kind: 'error', text: `no authorization flow for ${values[0]}` };
582
+ const notices = [];
583
+ const outcome = await ctx.authorization.begin({ key: entry.key, ...(values[1] === undefined ? {} : { method: values[1] }), signal: invocation.signal,
584
+ interaction: { notify: notice => {
585
+ notices.push(notice.message, ...(notice.url === undefined ? [] : [notice.url]), ...(notice.code === undefined ? [] : [`Code: ${notice.code}`]));
586
+ dispatch({ type: 'open-overlay', overlay: { kind: 'info', title: 'Login', lines: notices } });
587
+ }, prompt: prompt => authorizationPrompt(bound, prompt, notices.join('\n')) },
588
+ }).finally(() => { dispatch({ type: 'overlay-close' }); });
589
+ return { kind: 'success', text: outcome.status === 'authorized' ? `signed in to ${entry.label}` : 'login cancelled' };
590
+ },
591
+ },
592
+ { name: 'logout', description: 'List or forget provider credentials', input: { hint: '[KEY]' },
593
+ handler: async (invocation) => {
594
+ const stale = ensureCurrent(invocation);
595
+ if (stale !== undefined)
596
+ return stale;
597
+ const raw = invocation.rawInput.trim();
598
+ if (raw === '')
599
+ return { kind: 'success', text: (await ctx.credentials.listRecords()).map(record => `${String(record.key)} · ${record.kind}`).join('\n') || 'no stored credentials' };
600
+ if (/\s/u.test(raw))
601
+ return { kind: 'error', text: 'usage: /logout [KEY]' };
602
+ await ctx.credentials.deleteRecord(parseCredentialKey(raw));
603
+ return { kind: 'success', text: `signed out ${raw}` };
604
+ },
605
+ },
606
+ {
607
+ name: 'diff', description: 'Show working tree or last-turn file changes', input: { hint: '[turn]' }, handler: async (invocation) => {
608
+ const invalid = ensureCurrent(invocation);
609
+ if (invalid !== undefined)
610
+ return invalid;
611
+ const mode = invocation.rawInput.trim();
612
+ if (mode !== '' && mode !== 'turn')
613
+ return { kind: 'error', text: 'usage: /diff [turn]' };
614
+ dispatch({ type: 'open-overlay', overlay: await diffOverlay(ctx, bound.agent, bound.presenter, mode, lifetime.signal) });
615
+ return { kind: 'success' };
616
+ },
617
+ },
618
+ {
619
+ name: 'plugins', description: 'List the running profile plugins',
620
+ handler: async (invocation) => {
621
+ const stale = ensureCurrent(invocation);
622
+ if (stale !== undefined)
623
+ return stale;
624
+ if (invocation.rawInput.trim() !== '')
625
+ return { kind: 'error', text: 'usage: /plugins' };
626
+ const servers = new Map();
627
+ for (const entry of ctx.loader.entries())
628
+ if (entry.options.name === '@deepseek-ai/dsh-mcp-client'
629
+ && typeof entry.options.config?.serverName === 'string')
630
+ servers.set(entry.id, entry.options.config.serverName);
631
+ const snapshot = await ctx.get('pluginInventory').list();
632
+ return { kind: 'success', text: snapshot.entries.map(entry => `${String(entry.entryId)} · ${entry.moduleName}${servers.has(String(entry.entryId)) ? ` · ${servers.get(String(entry.entryId))}` : ''} · ${entry.enabled ? 'enabled' : 'disabled'} · ${entry.fiberPhase ?? 'inactive'}`).join('\n') };
633
+ },
634
+ },
635
+ {
636
+ name: 'plugin', description: 'Run DSH profile plugin management', input: { hint: 'ARGS' },
637
+ handler: async (invocation) => {
638
+ const stale = ensureCurrent(invocation);
639
+ if (stale !== undefined)
640
+ return stale;
641
+ if (invocation.rawInput.trim() === '')
642
+ return { kind: 'error', text: 'usage: /plugin ARGS' };
643
+ const command = `dsh plugin --profile ${quoteShellWord(decodeURIComponent(new URL(ctx.baseUrl).pathname).split('/').at(-2))}${invocation.rawInput}`;
644
+ const [message, result] = await bound.agent.runMaintenance(signal => runHumanShell(ctx, bound.agent, command, signal, 'changes load on the next launch; exit and run dashi again'));
645
+ bound.agent.inject(message);
646
+ await ctx.sessions.flush(bound.agent.session);
647
+ publish(bound);
648
+ return result.exitCode === 0 ? { kind: 'success' } : { kind: 'error', text: `dsh plugin exited ${String(result.exitCode ?? result.signal ?? 'without status')}` };
649
+ },
650
+ },
494
651
  {
495
652
  name: 'new', description: 'Start a new session', input: { hint: '[--name TITLE]' },
496
653
  handler: async (invocation) => {
@@ -628,15 +785,17 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
628
785
  },
629
786
  },
630
787
  {
631
- name: 'copy', description: 'Copy the latest completed assistant response',
788
+ name: 'copy', description: 'Copy an assistant response or code block', input: { hint: '[N|code]' },
632
789
  handler: (invocation) => {
633
790
  const stale = ensureCurrent(invocation);
634
791
  if (stale !== undefined)
635
792
  return stale;
636
- const invalid = usage(invocation.rawInput, '/copy');
637
- if (invalid !== undefined)
638
- return invalid;
639
- dispatch({ type: 'copy-latest' });
793
+ const raw = invocation.rawInput.trim();
794
+ const selection = raw === '' ? 1 : raw === 'code' ? 'code' : Number(raw);
795
+ if (raw !== '' && selection !== 'code' && (!/^[1-9]\d*$/u.test(raw) || !Number.isSafeInteger(selection))) {
796
+ return { kind: 'error', text: 'usage: /copy [N|code]' };
797
+ }
798
+ dispatch({ selection, type: 'copy-assistant' });
640
799
  return { kind: 'success' };
641
800
  },
642
801
  },
@@ -669,6 +828,32 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
669
828
  return { kind: 'success' };
670
829
  },
671
830
  },
831
+ {
832
+ name: 'init', description: 'Create a starter AGENTS.md',
833
+ handler: async (invocation) => {
834
+ const stale = ensureCurrent(invocation);
835
+ if (stale !== undefined)
836
+ return stale;
837
+ const invalid = usage(invocation.rawInput, '/init');
838
+ if (invalid !== undefined)
839
+ return invalid;
840
+ const target = await ctx.fs.resolve('AGENTS.md', { cwd: bound.root.cwd, signal: lifetime.signal });
841
+ await ctx.fs.writeText(target, starterAgents(bound.root.cwd), { kind: 'createIfAbsent' }, lifetime.signal, ctx.sandboxPolicy.resolve({ session: bound.agent.session }));
842
+ return { kind: 'success', text: `created ${ctx.fs.processPath(target)}` };
843
+ },
844
+ },
845
+ {
846
+ name: 'effort', description: 'Select the reasoning effort', input: { hint: 'LEVEL' },
847
+ handler: async (invocation) => {
848
+ const invalid = ensureCurrent(invocation);
849
+ const effort = invocation.rawInput.trim();
850
+ if (invalid !== undefined || effort === '')
851
+ return invalid ?? { kind: 'error', text: 'usage: /effort LEVEL' };
852
+ const selected = currentModel(ctx, bound.agent) ?? (await ctx.sessionController.modelCatalog()).default;
853
+ await activate({ kind: 'model', effort, model: selected.model, provider: selected.provider });
854
+ return { kind: 'success' };
855
+ },
856
+ },
672
857
  {
673
858
  ...nativePermission,
674
859
  handler: async (invocation) => {
@@ -709,6 +894,8 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
709
894
  },
710
895
  },
711
896
  ];
897
+ return [...definitions, ...[['quit', 'exit'], ['reset', 'clear'], ['continue', 'resume'], ['branch', 'fork']]
898
+ .map(([name, target]) => ({ ...definitions.find(definition => definition.name === target), name }))];
712
899
  };
713
900
  async function install(bound) {
714
901
  const id = bound.root.id;
@@ -1027,7 +1214,8 @@ export async function createSessionRuntime(ctx, options, tuiRoot, dispatch, deci
1027
1214
  if (attachments.length > 0)
1028
1215
  throw new Error('!command does not accept image attachments');
1029
1216
  await bound.agent.runMaintenance(async (signal) => {
1030
- bound.agent.inject(await runHumanShell(ctx, bound.agent, command, signal));
1217
+ const [message] = await runHumanShell(ctx, bound.agent, command, signal);
1218
+ bound.agent.inject(message);
1031
1219
  await ctx.sessions.flush(bound.agent.session);
1032
1220
  });
1033
1221
  publish(bound);
@@ -5,5 +5,6 @@ import type { ShellRunResult } from '@deepseek-ai/dsh-shell';
5
5
  export declare const HUMAN_SHELL_TIMEOUT_MS = 30000;
6
6
  export declare const HUMAN_SHELL_STREAM_BYTES: number;
7
7
  export declare function shellResultText(command: string, result: ShellRunResult): string;
8
+ export declare function quoteShellWord(value: string): string;
8
9
  /** Run an explicit human command through DSH and identify its next-step context. */
9
- export declare function runHumanShell(ctx: Context, agent: Agent, command: string, signal: AbortSignal): Promise<UserMessage>;
10
+ export declare function runHumanShell(ctx: Context, agent: Agent, command: string, signal: AbortSignal, success?: string): Promise<readonly [UserMessage, ShellRunResult]>;
@@ -1,4 +1,5 @@
1
1
  import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
2
3
  export const HUMAN_SHELL_TIMEOUT_MS = 30_000;
3
4
  export const HUMAN_SHELL_STREAM_BYTES = 32 * 1024;
4
5
  function tail(output) {
@@ -42,21 +43,23 @@ export function shellResultText(command, result) {
42
43
  }
43
44
  return parts.join('\n');
44
45
  }
46
+ export function quoteShellWord(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
45
47
  /** Run an explicit human command through DSH and identify its next-step context. */
46
- export async function runHumanShell(ctx, agent, command, signal) {
48
+ export async function runHumanShell(ctx, agent, command, signal, success) {
47
49
  const result = await ctx.shell.run(ctx.shell.resolve({
48
50
  command,
51
+ env: { DSH_HOME: resolveDshHome() },
49
52
  sandboxPolicy: ctx.sandboxPolicy.resolve({ session: agent.session }),
50
53
  signal,
51
54
  stdoutMaxBytes: HUMAN_SHELL_STREAM_BYTES,
52
55
  timeoutMs: HUMAN_SHELL_TIMEOUT_MS,
53
56
  workdir: agent.session.header.cwd,
54
57
  }));
55
- return createUserMessage({
56
- content: [{ type: 'text', text: shellResultText(command, result) }],
57
- source: {
58
- form: 'notice', kind: 'plugin', plugin: 'dashi',
59
- summary: boundContextSummary(`Shell: ${command.replace(/\s+/gu, ' ').trim()}`),
60
- },
61
- });
58
+ return [createUserMessage({
59
+ content: [{ type: 'text', text: shellResultText(command, result) + (result.exitCode === 0 && success ? `\n${success}` : '') }],
60
+ source: {
61
+ form: 'notice', kind: 'plugin', plugin: 'dashi',
62
+ summary: boundContextSummary(`Shell: ${command.replace(/\s+/gu, ' ').trim()}`),
63
+ },
64
+ }), result];
62
65
  }
package/lib/state.d.ts CHANGED
@@ -87,6 +87,7 @@ export interface QuestionOption {
87
87
  readonly label: string;
88
88
  }
89
89
  export interface QuestionItem {
90
+ readonly allowCustom?: boolean;
90
91
  readonly detail?: string;
91
92
  readonly header?: string;
92
93
  readonly id: string;
@@ -97,6 +98,7 @@ export interface QuestionItem {
97
98
  readonly multiSelect: boolean;
98
99
  readonly options: readonly QuestionOption[];
99
100
  readonly question: string;
101
+ readonly secret?: boolean;
100
102
  }
101
103
  export interface QuestionAnswer {
102
104
  readonly custom?: string;
@@ -129,6 +131,9 @@ export type OverlayValue = {
129
131
  readonly path: string;
130
132
  readonly source: string;
131
133
  readonly text: string;
134
+ } | {
135
+ readonly kind: 'copy';
136
+ readonly text: string;
132
137
  } | {
133
138
  readonly kind: 'insert';
134
139
  readonly text: string;
@@ -137,6 +142,9 @@ export type OverlayValue = {
137
142
  readonly effort?: string;
138
143
  readonly model: string;
139
144
  readonly provider: string;
145
+ } | {
146
+ readonly kind: 'open-file';
147
+ readonly path: string;
140
148
  } | {
141
149
  readonly interrupt?: true;
142
150
  readonly kind: 'fork';
@@ -179,6 +187,8 @@ export type OverlayValue = {
179
187
  };
180
188
  export type ActivatableOverlayValue = Exclude<OverlayValue, {
181
189
  kind: 'attach';
190
+ } | {
191
+ kind: 'copy';
182
192
  } | {
183
193
  kind: 'insert';
184
194
  }>;
@@ -197,9 +207,10 @@ export type Overlay = {
197
207
  readonly kind: 'list';
198
208
  readonly notice?: string;
199
209
  readonly options: readonly OverlayOption[];
200
- readonly purpose: 'agents' | 'completion' | 'model' | 'permission' | 'resume' | 'rewind' | 'rewind-action' | 'search';
210
+ readonly purpose: 'agents' | 'completion' | 'copy' | 'memory' | 'model' | 'permission' | 'resume' | 'rewind' | 'rewind-action' | 'search' | 'skills';
201
211
  readonly title: string;
202
212
  } | {
213
+ readonly cells?: readonly TerminalCell[];
203
214
  readonly kind: 'info';
204
215
  readonly lines: readonly string[];
205
216
  readonly title: string;
@@ -424,7 +435,8 @@ export type UiAction = {
424
435
  readonly hasMore?: boolean;
425
436
  readonly rootId?: string;
426
437
  } | {
427
- readonly type: 'copy-latest';
438
+ readonly selection: number | 'code';
439
+ readonly type: 'copy-assistant';
428
440
  };
429
441
  export type UiEffect = {
430
442
  readonly type: 'activate-overlay';
package/lib/state.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { FLAG_HELP, VERSION_LINE } from './help.js';
1
2
  export function initialViewState(cwd, inline, root, cells = [], prompts = [], historyHasMore = false, attachments = []) {
2
3
  return {
3
4
  attachments, cells, composer: '', cwd, decisions: [], exitArmed: false, historyHasMore, inline,
@@ -24,13 +25,14 @@ function moveCursor(decision, cursor) {
24
25
  const question = decision.questions[decision.index];
25
26
  if (question === undefined)
26
27
  return decision;
27
- return { ...decision, cursor: Math.max(0, Math.min(question.options.length, cursor)) };
28
+ return { ...decision, cursor: Math.max(0, Math.min(question.options.length - (question.allowCustom === false ? 1 : 0), cursor)) };
28
29
  }
29
30
  function help() {
30
31
  return {
31
32
  kind: 'info',
32
33
  title: 'Help',
33
34
  lines: [
35
+ VERSION_LINE,
34
36
  'Enter send · Ctrl+J newline · Ctrl+T steer/next turn',
35
37
  'Esc interrupt · Ctrl+C clear/interrupt · Ctrl+C/D again within 2s to exit',
36
38
  '/ and @ complete live · Tab insert · Shift+Tab permission · Ctrl+O cards',
@@ -39,8 +41,9 @@ function help() {
39
41
  'Ctrl+B activity details for jobs and subagents',
40
42
  '@ paths require DSH file-reference · Ctrl+V pastes an image · Backspace removes it',
41
43
  'Ctrl+S stash · Ctrl+G editor · F1 help · Ctrl+L redraw · Ctrl+Z suspend',
42
- '/new /clear /resume [--all] [NAME|UUID] /fork /rewind /rename /model /permission /agents /queue · launch -r/-c --model/--effort/--permission/--yolo',
43
- '/status /context /tasks /history /export /copy /exit',
44
+ '/new /clear /reset /resume /continue [--all] [NAME|UUID] /fork /branch /rewind /rename /model /effort /permission /agents /queue /status /context',
45
+ '/init /memory /skills /config /login /logout /diff /plugins /plugin /tasks /history /export /copy /exit /quit',
46
+ ...FLAG_HELP,
44
47
  ],
45
48
  };
46
49
  }
@@ -83,13 +86,24 @@ function refreshSearch(search, cells) {
83
86
  const found = matches(cells, search.query);
84
87
  return { ...search, cursor: Math.min(search.cursor, Math.max(0, found.length - 1)), matches: found };
85
88
  }
86
- function latestCompletedAssistant(cells) {
89
+ function completedAssistant(cells, nth) {
90
+ let seen = 0;
87
91
  for (let index = cells.length - 1; index >= 0; index--) {
88
92
  const cell = cells[index];
89
- if (cell?.kind === 'assistant' && cell.pending !== true)
93
+ if (cell?.kind === 'assistant' && cell.pending !== true && ++seen === nth)
90
94
  return cell;
91
95
  }
92
96
  }
97
+ function codeBlockOptions(text) {
98
+ const fence = /(?:^|\r?\n)(`{3,}|~{3,})([^\r\n]*)\r?\n([\s\S]*?)\r?\n\1[ \t]*(?=\r?\n|$)/gu;
99
+ return [...text.matchAll(fence)].map(match => {
100
+ const code = match[3] ?? '';
101
+ return {
102
+ label: `${match[2]?.trim() || 'text'} · ${code.split(/\r?\n/u)[0] ?? ''}`,
103
+ value: { kind: 'copy', text: code },
104
+ };
105
+ });
106
+ }
93
107
  export function reduce(state, action) {
94
108
  const head = state.decisions[0];
95
109
  switch (action.type) {
@@ -215,7 +229,7 @@ export function reduce(state, action) {
215
229
  const question = head.questions[head.index];
216
230
  if (question === undefined)
217
231
  return [state, []];
218
- const custom = head.cursor === question.options.length ? head.custom.trim() : undefined;
232
+ const custom = question.allowCustom !== false && head.cursor === question.options.length ? question.secret === true ? head.custom : head.custom.trim() : undefined;
219
233
  if (custom !== undefined && custom === '')
220
234
  return [state, []];
221
235
  const option = question.options[head.cursor];
@@ -407,6 +421,10 @@ export function reduce(state, action) {
407
421
  type: 'attach', path: option.value.path, source: option.value.source, text: option.value.text,
408
422
  }, ...redraw()]];
409
423
  }
424
+ if (option.value.kind === 'copy') {
425
+ const { overlay: _overlay, ...withoutOverlay } = state;
426
+ return [withoutOverlay, [{ type: 'copy', text: option.value.text }, ...redraw()]];
427
+ }
410
428
  if (option.value.kind === 'permission' && option.danger === true) {
411
429
  return [{ ...state, overlay: {
412
430
  acceptLabel: 'Enable',
@@ -633,9 +651,15 @@ export function reduce(state, action) {
633
651
  }),
634
652
  }, redraw()];
635
653
  }
636
- case 'copy-latest': {
637
- const cell = latestCompletedAssistant(state.cells);
638
- return cell === undefined ? [state, []] : [state, [{ type: 'copy', text: cell.text }]];
654
+ case 'copy-assistant': {
655
+ const cell = completedAssistant(state.cells, action.selection === 'code' ? 1 : action.selection);
656
+ if (cell === undefined)
657
+ return [state, []];
658
+ if (action.selection !== 'code')
659
+ return [state, [{ type: 'copy', text: cell.text }]];
660
+ return [{ ...state, overlay: {
661
+ cursor: 0, kind: 'list', options: codeBlockOptions(cell.text), purpose: 'copy', title: 'Copy code block',
662
+ } }, redraw()];
639
663
  }
640
664
  }
641
665
  }
package/lib/transcript.js CHANGED
@@ -29,7 +29,8 @@ function shellCell(message, pending) {
29
29
  function collapsedContextCell(message, key) {
30
30
  const source = message.source;
31
31
  const form = source.form;
32
- if (source.kind !== 'plugin' || !['instructions', 'catalog', 'snapshot', 'notice', 'recall'].includes(String(form))) {
32
+ if (source.kind !== 'agent-instructions'
33
+ && (source.kind !== 'plugin' || !['instructions', 'catalog', 'snapshot', 'notice', 'recall'].includes(String(form)))) {
33
34
  return undefined;
34
35
  }
35
36
  const text = messageText(message.content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antst/dashi",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "A terminal UI for DeepSeek Harness",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -37,15 +37,21 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/cordis": "4.0.2",
40
+ "@deepseek-ai/cordis-plugin-loader": "1.0.3",
40
41
  "@deepseek-ai/dsh-agent": "0.1.2-rc.1",
42
+ "@deepseek-ai/dsh-agent-instructions": "0.1.2-rc.1",
41
43
  "@deepseek-ai/dsh-agent-presets": "0.1.2-rc.1",
42
44
  "@deepseek-ai/dsh-api-session-controller": "0.1.2-rc.1",
43
45
  "@deepseek-ai/dsh-attachment": "0.1.2-rc.1",
46
+ "@deepseek-ai/dsh-authorization": "0.1.2-rc.1",
44
47
  "@deepseek-ai/dsh-cmdline": "0.1.2-rc.1",
45
48
  "@deepseek-ai/dsh-commands": "0.1.2-rc.1",
49
+ "@deepseek-ai/dsh-credentials": "0.1.2-rc.1",
46
50
  "@deepseek-ai/dsh-compaction": "0.1.2-rc.1",
47
51
  "@deepseek-ai/dsh-file-reference": "0.1.2-rc.1",
48
52
  "@deepseek-ai/dsh-fs": "0.1.2-rc.1",
53
+ "@deepseek-ai/dsh-home-paths": "0.1.2-rc.1",
54
+ "@deepseek-ai/dsh-host-plugin-inventory": "0.1.2-rc.1",
49
55
  "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
50
56
  "@deepseek-ai/dsh-permission-presets": "0.1.2-rc.1",
51
57
  "@deepseek-ai/dsh-plan-mode": "0.1.2-rc.1",
@@ -53,6 +59,7 @@
53
59
  "@deepseek-ai/dsh-session": "0.1.2-rc.1",
54
60
  "@deepseek-ai/dsh-session-projection": "0.1.2-rc.1",
55
61
  "@deepseek-ai/dsh-session-query": "0.1.2-rc.1",
62
+ "@deepseek-ai/dsh-settings": "0.1.2-rc.1",
56
63
  "@deepseek-ai/dsh-shell": "0.1.2-rc.1",
57
64
  "@deepseek-ai/dsh-skill": "0.1.2-rc.1",
58
65
  "@deepseek-ai/dsh-subagent": "0.1.2-rc.1",