dsh-composer-plus 1.7.0 → 1.7.1

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.
Files changed (4) hide show
  1. package/README.md +4 -4
  2. package/client.js +23 -11
  3. package/index.js +16 -7
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -25,14 +25,14 @@ dsh plugin --profile web add dsh-composer-plus
25
25
 
26
26
  Then restart dsh. (The CLI can't install into the desktop profile; the app manages it.)
27
27
 
28
- > dsh's pnpm skips versions younger than 24 hours. Right after a release, add the version: `dsh-composer-plus@1.7.0`.
28
+ > dsh's pnpm skips versions younger than 24 hours. Right after a release, add the version: `dsh-composer-plus@1.7.1`.
29
29
 
30
30
  ## Features
31
31
 
32
32
  | Feature | How to use it | Screenshot |
33
33
  |---|---|---|
34
34
  | **Changes panel** | Right sidebar → `+` → **Changes**. Pick **Uncommitted**, **Unstaged**, **Staged** or **Branch** to see changed files with `+/-` counts; click a file for its diff. **Last Turn** opens dsh's own review of the latest turn. | <a href="assets/features/changesPanel.webp"><img src="assets/features/changesPanel.webp" width="160" alt="Changes panel screenshot"></a> |
35
- | **Feature ideas** | Right sidebar → `+` → **Feature ideas** → **Suggest features**. The chat's own model reads a short summary of the chat's project (top-level files, the README and manifest heads, the last 15 commits) and suggests up to 8 features, shown as a stack of cards. Swipe a card right (or click **✓**, or press `→`) to start a new chat in the same project: it starts in plan mode with the idea as the request, so nothing changes before you approve the plan. Swipe left (or **✕**, or `←`) to skip; **Undo** (or `Z`) brings a skipped card back. **More ideas** adds cards. The model runs only when you click. | <a href="assets/features/featureIdeas.webp"><img src="assets/features/featureIdeas.webp" width="160" alt="Feature ideas screenshot"></a> |
35
+ | **Feature ideas** | Right sidebar → `+` → **Feature ideas** → **Suggest features**. The chat's own model reads a short summary of the chat's project (top-level files, the README and manifest heads, the last 15 commits) and suggests up to 8 features, shown as a stack of cards in dsh's UI language. Swipe a card right (or click **✓**, or press `→`; the card flies off to its side in each case) to start a new chat in the same project: it starts in plan mode with the idea as the request, so nothing changes before you approve the plan. Swipe left (or **✕**, or `←`) to skip; **Undo** (or `Z`) brings a skipped card back. **More ideas** adds cards. The model runs only when you click. | <a href="assets/features/featureIdeas.webp"><img src="assets/features/featureIdeas.webp" width="160" alt="Feature ideas screenshot"></a> |
36
36
  | **Input history** | In an empty composer, press `↑` / `↓` to walk through messages you sent, like a terminal. | <a href="assets/features/inputHistory.webp"><img src="assets/features/inputHistory.webp" width="160" alt="Input history screenshot"></a> |
37
37
  | **Plan mode shortcut** | `Shift+Tab` adds `/plan ` to the start of the draft (press again to remove it). If the chat is already in plan mode, `Shift+Tab` leaves it. | <a href="assets/features/planToggle.webp"><img src="assets/features/planToggle.webp" width="160" alt="Plan mode shortcut screenshot"></a> |
38
38
  | **Keep composer focus** | After you send a `/` command or skill, the cursor stays in the composer, and you can type again at once. This also works for keyboard macros that type a command and press `Enter`. dsh makes the composer read-only while it resolves the command, and the browser then removes the focus. This feature puts the focus back, unless a menu, a dialog or another field has it. | <a href="assets/features/keepFocus.webp"><img src="assets/features/keepFocus.webp" width="160" alt="Keep composer focus screenshot"></a> |
@@ -109,12 +109,12 @@ Tested on dsh `0.2.0-rc.2` (desktop and web) and `0.2.1-rc.2` (web). CI runs the
109
109
  - **Mobile connect says the gateway is not running:** "no IPv4 address on en0" means this Mac has no Wi-Fi/Ethernet address; "EADDRINUSE" means another dsh (or app) uses port 19390 or 19391. Only one dsh can run Mobile connect at a time.
110
110
  - **Mobile connect or Resume after restart says "Restart dsh":** the plugin was updated while dsh was running; the new Host half loads after a restart.
111
111
  - **A chat did not continue after a restart:** Resume after restart was off when dsh quit, you stopped that turn yourself, the turn had ended (done or failed), the chat was archived, or more than 20 chats were running (the newest 20 continue).
112
- - **Install fails right after a release:** add the version (`dsh-composer-plus@1.7.0`), see the install note above.
112
+ - **Install fails right after a release:** add the version (`dsh-composer-plus@1.7.1`), see the install note above.
113
113
 
114
114
  ## Limits
115
115
 
116
116
  - Message-free Continue relies on dsh’s `agent/pre-step` → `agent/request` lifecycle. Recheck compatibility when upgrading dsh.
117
- - **Feature ideas** keeps the cards in memory for each chat until dsh or the page reloads; then **Suggest features** asks the model again. The model sees only the short project summary, not the code, so an idea can repeat something the project already has: skip it. The new chat runs in plan mode only when the profile has dsh's `/plan` command; otherwise the idea goes as an ordinary first message.
117
+ - **Feature ideas** keeps the cards in memory for each chat until dsh or the page reloads; then **Suggest features** asks the model again. The cards come in the UI language that dsh has when you click; after you change the language, cards already on the stack keep theirs and **More ideas** uses the new one. The model sees only the short project summary, not the code, so an idea can repeat something the project already has: skip it. The new chat runs in plan mode only when the profile has dsh's `/plan` command; otherwise the idea goes as an ordinary first message.
118
118
  - **Resume after restart** continues the turn; it does not replay it. A tool call that was running at the quit ends with dsh's “outcome unknown” result, and the model is told not to repeat finished work, but a command that is not safe to run twice can run again. Subagents, approvals and questions that were open are not restored: their parent chat continues and asks again. At most 20 chats continue at one start.
119
119
 
120
120
  - dsh's queue has no `move` action, so a reorder rewrites the affected rows' text. If the agent picks up a row mid-move, the move is undone and nothing is lost. Messages with images or files can't be moved.
package/client.js CHANGED
@@ -1303,11 +1303,13 @@ body:has(${OPEN_PROJECT}) [${COLLAPSE_HOST}] { display: inline-flex; }
1303
1303
  const deckChanged = (deck, patch) => { Object.assign(deck, patch); for (const fn of [...deck.listeners]) fn(); };
1304
1304
  /** One network action per deck: while ideas load or a chat starts, other loads and swipes wait. */
1305
1305
  const deckBusy = (deck) => deck.status === 'loading' || deck.status === 'starting';
1306
- async function suggestIdeas(deck, sessionId) {
1306
+ /** `locale` is dsh's UI language at the click: the model writes the cards in it (ponytail: cards already on the deck keep
1307
+ * their language when the UI language changes; the next load uses the new one). */
1308
+ async function suggestIdeas(deck, sessionId, locale) {
1307
1309
  if (deckBusy(deck)) return;
1308
1310
  deckChanged(deck, { status: 'loading', error: undefined });
1309
1311
  try {
1310
- const response = await fetch(IDEAS_ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId }) });
1312
+ const response = await fetch(IDEAS_ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, ...(locale ? { locale } : {}) }) });
1311
1313
  const data = await response.json().catch(() => ({}));
1312
1314
  if (response.status === 404) throw new Error('restart');
1313
1315
  if (!response.ok) throw new Error(data?.error ?? `HTTP ${response.status}`);
@@ -1359,7 +1361,8 @@ body:has(${OPEN_PROJECT}) [${COLLAPSE_HOST}] { display: inline-flex; }
1359
1361
  .cpi-list{padding:4px 12px 16px}
1360
1362
  .cpi-list h3{font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary);margin:8px 0}
1361
1363
  .cpi-list button{display:block;width:100%;text-align:left;padding:6px 8px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1362
- .cpi-list button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}`;
1364
+ .cpi-list button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
1365
+ @media (prefers-reduced-motion:reduce){.cpi-card{transition:none!important}}`;
1363
1366
  function IdeaCard({ card, top, depth, drag, t }) {
1364
1367
  const dx = top ? drag.dx : 0;
1365
1368
  const style = top
@@ -1374,15 +1377,24 @@ body:has(${OPEN_PROJECT}) [${COLLAPSE_HOST}] { display: inline-flex; }
1374
1377
  card.why ? React.createElement('div', { className: 'cpi-why' }, card.why) : null,
1375
1378
  card.effort ? React.createElement('span', { className: 'cpi-effort' }, t(`ideas.effort.${card.effort}`)) : null);
1376
1379
  }
1377
- function IdeasTab({ sessionId, t, openSession }) {
1380
+ function IdeasTab({ sessionId, t, openSession, uiLocale }) {
1378
1381
  const deck = ideaDeck(sessionId);
1379
1382
  const [, setVersion] = React.useState(0);
1380
1383
  React.useEffect(() => { const fn = () => setVersion((n) => n + 1); deck.listeners.add(fn); return () => { deck.listeners.delete(fn); }; }, [deck]);
1381
1384
  const [drag, setDrag] = React.useState({ dx: 0, dy: 0, active: false });
1382
1385
  const stackRef = React.useRef(null);
1383
1386
  const decide = (direction) => { setDrag({ dx: 0, dy: 0, active: false }); decideIdea(deck, sessionId, direction, openSession); };
1387
+ // A drag release, ✕/✓ and ←/→ all fly the top card off to its side first, then decide; one card at a time.
1388
+ const flying = React.useRef(false);
1389
+ const fling = (direction) => {
1390
+ if (flying.current || deckBusy(deck) || deck.cards.length === 0) return;
1391
+ flying.current = true;
1392
+ const width = stackRef.current?.getBoundingClientRect().width ?? 300;
1393
+ setDrag({ dx: (direction === 'right' ? 1 : -1) * width * 1.5, dy: 0, active: false });
1394
+ setTimeout(() => { flying.current = false; decide(direction); }, 180);
1395
+ };
1384
1396
  const start = (event) => {
1385
- if (event.button !== 0 || deckBusy(deck)) return;
1397
+ if (event.button !== 0 || deckBusy(deck) || flying.current) return;
1386
1398
  const card = event.currentTarget;
1387
1399
  card.setPointerCapture?.(event.pointerId);
1388
1400
  const x0 = event.clientX; const y0 = event.clientY;
@@ -1397,19 +1409,19 @@ body:has(${OPEN_PROJECT}) [${COLLAPSE_HOST}] { display: inline-flex; }
1397
1409
  const width = stackRef.current?.getBoundingClientRect().width ?? 300;
1398
1410
  const direction = e.type === 'pointercancel' ? null : swipeDecision(e.clientX - x0, width, speed);
1399
1411
  if (direction === null) setDrag({ dx: 0, dy: 0, active: false });
1400
- else { setDrag({ dx: (direction === 'right' ? 1 : -1) * width * 1.5, dy: 0, active: false }); setTimeout(() => decide(direction), 180); }
1412
+ else fling(direction);
1401
1413
  };
1402
1414
  card.addEventListener('pointermove', move); card.addEventListener('pointerup', end); card.addEventListener('pointercancel', end);
1403
1415
  };
1404
1416
  const onKeyDown = (event) => {
1405
1417
  if (event.altKey || event.ctrlKey || event.metaKey || event.target.closest?.('input, textarea, [contenteditable="true"]')) return;
1406
- if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { event.preventDefault(); if (deck.cards.length > 0) decide(event.key === 'ArrowRight' ? 'right' : 'left'); }
1418
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { event.preventDefault(); fling(event.key === 'ArrowRight' ? 'right' : 'left'); }
1407
1419
  else if ((event.key === 'z' || event.key === 'Backspace') && deck.history.length > 0) { event.preventDefault(); undoIdea(deck); }
1408
1420
  };
1409
1421
  const busy = deckBusy(deck);
1410
1422
  const errorText = deck.error === undefined ? null : deck.error === 'restart' ? t('ideas.restart') : t('ideas.failed', { error: deck.error });
1411
1423
  const suggest = React.createElement('button', { type: 'button', className: `cpi-btn${deck.cards.length === 0 ? ' cpi-primary' : ''}`, disabled: busy,
1412
- onClick: () => suggestIdeas(deck, sessionId) }, deck.status === 'loading' ? t('ideas.loading') : deck.cards.length === 0 ? t('ideas.suggest') : t('ideas.more'));
1424
+ onClick: () => suggestIdeas(deck, sessionId, uiLocale?.()) }, deck.status === 'loading' ? t('ideas.loading') : deck.cards.length === 0 ? t('ideas.suggest') : t('ideas.more'));
1413
1425
  const head = React.createElement('div', { className: 'cpi-head' },
1414
1426
  React.createElement('span', { className: 'cpi-grow', style: { color: 'var(--dsw-alias-label-secondary)' } }, deck.cards.length > 0 ? t('ideas.left', { count: String(deck.cards.length) }) : ''),
1415
1427
  deck.history.length > 0 ? React.createElement('button', { type: 'button', className: 'cpi-btn', onClick: () => undoIdea(deck), title: t('ideas.undoHint') }, t('ideas.undo')) : null,
@@ -1424,8 +1436,8 @@ body:has(${OPEN_PROJECT}) [${COLLAPSE_HOST}] { display: inline-flex; }
1424
1436
  React.createElement('div', { className: 'cpi-stack', ref: stackRef },
1425
1437
  ...visible.map((card, depth) => React.createElement(IdeaCard, { key: card.id, card, top: depth === 0, depth, drag: { ...drag, start }, t })).reverse()),
1426
1438
  React.createElement('div', { className: 'cpi-actions' },
1427
- React.createElement('button', { type: 'button', className: 'cpi-btn cpi-round', 'data-side': 'left', disabled: busy, 'aria-label': t('ideas.skip'), title: t('ideas.skipHint'), onClick: () => decide('left') }, '✕'),
1428
- React.createElement('button', { type: 'button', className: 'cpi-btn cpi-round', 'data-side': 'right', disabled: busy, 'aria-label': t('ideas.build'), title: t('ideas.buildHint'), onClick: () => decide('right') }, '✓')),
1439
+ React.createElement('button', { type: 'button', className: 'cpi-btn cpi-round', 'data-side': 'left', disabled: busy, 'aria-label': t('ideas.skip'), title: t('ideas.skipHint'), onClick: () => fling('left') }, '✕'),
1440
+ React.createElement('button', { type: 'button', className: 'cpi-btn cpi-round', 'data-side': 'right', disabled: busy, 'aria-label': t('ideas.build'), title: t('ideas.buildHint'), onClick: () => fling('right') }, '✓')),
1429
1441
  React.createElement('div', { className: 'cpi-note' }, deck.status === 'starting' ? t('ideas.starting') : t('ideas.hint')));
1430
1442
  return React.createElement('section', { className: 'cpi-root', 'data-composer-plus-ideas': '', tabIndex: -1, onKeyDown, 'aria-busy': busy },
1431
1443
  React.createElement('style', null, IDEAS_CSS), head,
@@ -2153,7 +2165,7 @@ body:has(${OPEN_PROJECT}) [${COLLAPSE_HOST}] { display: inline-flex; }
2153
2165
  name: 'sidebar.right.pane.tab',
2154
2166
  key: IDEAS_ID,
2155
2167
  locale: LOCALE_NS,
2156
- inject: () => ({ openSession: (sessionId) => scope.uiWorkspace.openSession(sessionId) }),
2168
+ inject: () => ({ openSession: (sessionId) => scope.uiWorkspace.openSession(sessionId), uiLocale: () => ctx.locale.getLocale?.().active }),
2157
2169
  }, IdeasTab)), 'dsh-composer-plus: ideas tab body');
2158
2170
  scope.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
2159
2171
  name: 'sidebar.right.pane.tab.title',
package/index.js CHANGED
@@ -435,8 +435,16 @@ Answer with one JSON array only, with no other text. Each element is an object w
435
435
  - "title": the feature name, at most 8 words.
436
436
  - "summary": what the feature does, 1 or 2 sentences.
437
437
  - "why": the benefit for the users, 1 sentence.
438
- - "effort": one of "small", "medium", "large".
439
- Write the text values in the language of the project brief.`;
438
+ - "effort": one of "small", "medium", "large". Always write this value in English.`;
439
+ /** A locale id such as "tr" or "pt-BR"; anything else is ignored, so the prompt never takes free text from the browser. */
440
+ const LOCALE_ID = /^[a-z]{2,3}(-[a-z0-9]{2,8}){0,3}$/i;
441
+ /** The language line of the idea prompt: the user's UI language when the client sends one, else the brief's language. */
442
+ export function ideasLanguage(locale) {
443
+ if (typeof locale !== 'string' || !LOCALE_ID.test(locale)) return 'Write "title", "summary" and "why" in the language of the project brief.';
444
+ let name;
445
+ try { name = new Intl.DisplayNames(['en'], { type: 'language' }).of(locale); } catch { name = undefined; }
446
+ return `Write "title", "summary" and "why" in ${name && name !== locale ? `${name} (${locale})` : locale}, also when the project brief uses another language.`;
447
+ }
440
448
 
441
449
  const clip = (value, max) => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '');
442
450
  /** Ideas from the model's answer: the first JSON array in it (a code fence is fine), each idea cleaned and bounded. Throws
@@ -470,8 +478,9 @@ function ideasRoute(events, defaultModel) {
470
478
  return undefined;
471
479
  }
472
480
 
473
- /** One model call for feature ideas about the chat's project. Read-only: no chat message, nothing written to the project. */
474
- export async function suggestIdeas({ llm, sessionQuery, subprocess, defaultModel }, sessionId, signal) {
481
+ /** One model call for feature ideas about the chat's project, written in `locale` (the dsh UI language) when given.
482
+ * Read-only: no chat message, nothing written to the project. */
483
+ export async function suggestIdeas({ llm, sessionQuery, subprocess, defaultModel }, sessionId, signal, locale) {
475
484
  const observation = await sessionQuery.observeSession(sessionId, { signal, projectionMode: 'none' });
476
485
  let cwd, route;
477
486
  try { cwd = observation.header?.cwd; route = ideasRoute(observation.events ?? [], defaultModel); } finally { observation[Symbol.dispose]?.(); }
@@ -489,7 +498,7 @@ export async function suggestIdeas({ llm, sessionQuery, subprocess, defaultModel
489
498
  });
490
499
  let text = '';
491
500
  let finish;
492
- for await (const chunk of call.stream({ ...call.config, system: IDEAS_SYSTEM, signal: callSignal,
501
+ for await (const chunk of call.stream({ ...call.config, system: `${IDEAS_SYSTEM}\n${ideasLanguage(locale)}`, signal: callSignal,
493
502
  messages: [{ role: 'user', content: [{ type: 'text', text: `Project folder name: ${cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? ''}\n\n${brief}` }] }] })) {
494
503
  if (chunk.type === 'text-delta') text += chunk.text;
495
504
  else if (chunk.type === 'finish') finish = chunk.reason;
@@ -681,7 +690,7 @@ export function apply(ctx) {
681
690
  installIdeas(ctx);
682
691
  }
683
692
 
684
- /** Feature ideas: `POST {sessionId}` asks the chat's model for ideas; `POST {sessionId, idea}` starts a chat for one idea.
693
+ /** Feature ideas: `POST {sessionId, locale?}` asks the chat's model for ideas; `POST {sessionId, idea}` starts a chat for one idea.
685
694
  * Both act only on the chat's own project folder; the browser never sends a path. */
686
695
  function installIdeas(ctx) {
687
696
  const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
@@ -703,7 +712,7 @@ function installIdeas(ctx) {
703
712
  const llm = get('llm');
704
713
  if (llm === undefined) return json({ error: 'this dsh profile cannot call a model' }, 409);
705
714
  try {
706
- return json(await suggestIdeas({ llm, sessionQuery: ctx.sessionQuery, subprocess: ctx.subprocess, defaultModel: get('agentDefaultModel') }, body.sessionId, request.signal));
715
+ return json(await suggestIdeas({ llm, sessionQuery: ctx.sessionQuery, subprocess: ctx.subprocess, defaultModel: get('agentDefaultModel') }, body.sessionId, request.signal, body.locale));
707
716
  } catch (error) { return json({ error: String(error?.message ?? error) }, status(error)); }
708
717
  },
709
718
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-composer-plus",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, swipe-card feature ideas, input history, Shift+Tab /plan, one-click Continue, resume running chats after a restart, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, iPhone access with the Tether app, automatic updates",
5
5
  "icon": "./assets/icon.svg",
6
6
  "license": "MIT",