dsh-ssh-tui 0.3.10 → 0.4.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.
package/lib/tui.js CHANGED
@@ -24,7 +24,7 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
24
24
  import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
25
25
  import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
26
26
  import { defaultReasoningEffort } from './reasoning.js';
27
- import { checkForPluginUpdate } from './update-check.js';
27
+ import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
28
28
  import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
29
29
  import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
30
30
  import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, describeSubagentFit, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
@@ -286,6 +286,8 @@ export function formatQuotaBar(remainingPercent, width = 8) {
286
286
  }
287
287
  export function footerIdentityParts(input) {
288
288
  const parts = [];
289
+ if (input.compactView === true)
290
+ parts.push(`[${t('view.footerCompact')}]`);
289
291
  if (input.preset !== undefined && input.preset !== '')
290
292
  parts.push(`[${input.preset}]`);
291
293
  if (input.cwdLabel !== undefined && input.cwdLabel !== '')
@@ -582,12 +584,18 @@ const LOCAL_COMMANDS = [
582
584
  { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
583
585
  { name: 'language', description: 'switch UI language (zh / en); empty opens a picker' },
584
586
  { name: 'lang', description: 'alias of /language' },
587
+ { name: 'view', description: 'switch workspace view (detailed / compact); empty opens a picker' },
585
588
  { name: 'dialog-test', description: 'verify the question dialog' },
586
589
  ];
587
590
  function localizedCommands() {
588
- return LOCAL_COMMANDS.map(command => (command.name === 'language' || command.name === 'lang'
589
- ? { name: command.name, description: t('lang.cmd') }
590
- : command));
591
+ return LOCAL_COMMANDS.map(command => {
592
+ if (command.name === 'language' || command.name === 'lang') {
593
+ return { name: command.name, description: t('lang.cmd') };
594
+ }
595
+ if (command.name === 'view')
596
+ return { name: command.name, description: t('view.cmd') };
597
+ return command;
598
+ });
591
599
  }
592
600
  /**
593
601
  * Terminal cell width for one string.
@@ -646,21 +654,39 @@ export function shimmerText(text, nowMs, color) {
646
654
  }
647
655
  return out;
648
656
  }
649
- /** One-line wait copy: current tool, else the user's last prompt. */
657
+ const WAIT_SUMMARY_MAX = 18;
658
+ /**
659
+ * Codex-style short status from reasoning: first `**bold**` / heading, else
660
+ * the first short clause. Keeps the wait card in sync with what the model is
661
+ * doing instead of repeating the user's prompt.
662
+ */
663
+ export function waitSummaryFromReasoning(text, maxChars = WAIT_SUMMARY_MAX) {
664
+ const raw = text.replace(/\r\n?/gu, '\n').trim();
665
+ if (raw === '')
666
+ return undefined;
667
+ const bold = /\*\*([^*]{2,80})\*\*/u.exec(raw)?.[1]
668
+ ?? /^#{1,6}\s+(.+)$/mu.exec(raw)?.[1];
669
+ const source = (bold ?? raw.split('\n').find(line => line.trim() !== '') ?? '').replace(/\s+/gu, ' ').trim();
670
+ if (source === '')
671
+ return undefined;
672
+ const clause = source.split(/[。!?!?\n]/u)[0]?.trim() ?? source;
673
+ const chars = Array.from(clause);
674
+ if (chars.length <= maxChars)
675
+ return clause;
676
+ return `${chars.slice(0, Math.max(2, maxChars - 1)).join('')}…`;
677
+ }
678
+ /** Wait-card header + optional detail. Header tracks model work when known. */
650
679
  export function waitCardCopy(input) {
651
680
  const toolTitle = input.toolTitle?.trim() ?? '';
652
681
  const toolSummary = input.toolSummary?.trim() ?? '';
682
+ const fromReasoning = waitSummaryFromReasoning(input.reasoning ?? '');
683
+ const fromReply = waitSummaryFromReasoning(input.reply ?? '');
684
+ const header = fromReasoning ?? fromReply ?? t('wait.working');
653
685
  if (toolTitle !== '') {
654
- return {
655
- header: t('wait.working'),
656
- detail: toolSummary === '' ? toolTitle : `${toolTitle} ${toolSummary}`,
657
- };
686
+ const extra = toolSummary === '' ? '' : ` ${Array.from(toolSummary).slice(0, 40).join('')}`;
687
+ return { header, detail: `${toolTitle}${extra}` };
658
688
  }
659
- const prompt = (input.prompt ?? '').replace(/\s+/gu, ' ').trim();
660
- if (prompt !== '') {
661
- return { header: t('wait.working'), detail: prompt };
662
- }
663
- return { header: t('wait.working') };
689
+ return { header };
664
690
  }
665
691
  export function displayWidth(text) {
666
692
  let width = 0;
@@ -1836,6 +1862,45 @@ function friendlyArgsSummary(name, args) {
1836
1862
  }
1837
1863
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
1838
1864
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
1865
+ export function parseWorkspaceView(raw) {
1866
+ const id = raw.trim().toLowerCase();
1867
+ if (id === 'detailed' || id === 'detail' || id === 'full' || id === '详细')
1868
+ return 'detailed';
1869
+ if (id === 'compact' || id === 'minimal' || id === 'min' || id === '极简')
1870
+ return 'compact';
1871
+ return undefined;
1872
+ }
1873
+ export function countDiffLines(hunks) {
1874
+ if (hunks === undefined || hunks.length === 0)
1875
+ return 0;
1876
+ let total = 0;
1877
+ for (const hunk of hunks) {
1878
+ const added = hunk.newText === '' ? 0 : hunk.newText.split('\n').length;
1879
+ if (hunk.oldText === null) {
1880
+ total += added;
1881
+ continue;
1882
+ }
1883
+ const removed = hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length;
1884
+ total += added + removed;
1885
+ }
1886
+ return total;
1887
+ }
1888
+ export function compactToolGroups(tools) {
1889
+ const edits = [];
1890
+ const calls = [];
1891
+ for (const tool of tools) {
1892
+ if (DIFF_TOOL_NAMES.has(tool.name) || (tool.diff !== undefined && tool.diff.length > 0))
1893
+ edits.push(tool);
1894
+ else
1895
+ calls.push(tool);
1896
+ }
1897
+ return {
1898
+ edits,
1899
+ calls,
1900
+ editLines: edits.reduce((sum, tool) => sum + Math.max(1, countDiffLines(tool.diff)), 0),
1901
+ failedCalls: calls.filter(tool => tool.status === 'error').length,
1902
+ };
1903
+ }
1839
1904
  const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
1840
1905
  /**
1841
1906
  * Tool calls that already have a dedicated transcript card (goal/change,
@@ -2726,6 +2791,7 @@ export class SshTui {
2726
2791
  color;
2727
2792
  maxToolOutputLines;
2728
2793
  showReasoning;
2794
+ workspaceView = 'detailed';
2729
2795
  goodbye;
2730
2796
  resume;
2731
2797
  providerName;
@@ -2812,6 +2878,7 @@ export class SshTui {
2812
2878
  this.color = config.color !== false && !noColorEnv && process.env.TERM !== 'dumb';
2813
2879
  this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
2814
2880
  this.showReasoning = config.showReasoning !== false;
2881
+ this.workspaceView = this.readWorkspaceView();
2815
2882
  this.goodbye = config.goodbye
2816
2883
  ?? this.ctx.get('tuiGoodbyeMessage')
2817
2884
  ?? `To resume this session: dsh --profile tui --resume=${this.agent.id}`;
@@ -2879,11 +2946,114 @@ export class SshTui {
2879
2946
  });
2880
2947
  }
2881
2948
  async notifyPluginUpdate() {
2882
- const notice = await checkForPluginUpdate(PLUGIN_VERSION);
2883
- if (this.disposed || notice === undefined)
2949
+ const info = await checkForPluginUpdate(PLUGIN_VERSION);
2950
+ if (this.disposed || info === undefined)
2884
2951
  return;
2885
- this.pushRow({ kind: 'system', text: notice });
2886
- this.markDirty();
2952
+ const skipped = this.readSkippedUpdate();
2953
+ if (skipped !== undefined && skipped === info.latest)
2954
+ return;
2955
+ try {
2956
+ const answer = await this.askQuestion({
2957
+ id: 'plugin-update',
2958
+ question: t('update.pick', { latest: info.latest, current: info.current }),
2959
+ options: [
2960
+ { label: t('update.now'), description: t('update.nowDesc', { command: info.command }) },
2961
+ { label: t('update.later'), description: t('update.laterDesc') },
2962
+ { label: t('update.skip'), description: t('update.skipDesc', { latest: info.latest }) },
2963
+ ],
2964
+ }, 0, 1, 0);
2965
+ if (this.disposed)
2966
+ return;
2967
+ const picked = answer.selected[0];
2968
+ if (picked === t('update.skip')) {
2969
+ await this.persistSkippedUpdate(info.latest);
2970
+ this.pushRow({ kind: 'system', text: t('update.skipDesc', { latest: info.latest }) });
2971
+ this.markDirty();
2972
+ return;
2973
+ }
2974
+ if (picked !== t('update.now'))
2975
+ return;
2976
+ this.pushRow({ kind: 'system', text: t('update.installing', { latest: info.latest }) });
2977
+ this.markDirty();
2978
+ const result = await installPluginLatest(info.profile);
2979
+ if (this.disposed)
2980
+ return;
2981
+ if (result.ok) {
2982
+ this.pushRow({ kind: 'system', text: t('update.installed', { latest: info.latest, profile: info.profile }) });
2983
+ }
2984
+ else {
2985
+ this.pushRow({ kind: 'error', text: t('update.failed', { error: result.output === '' ? info.command : result.output }) });
2986
+ this.pushRow({ kind: 'system', text: t('update.manual', { command: info.command }) });
2987
+ }
2988
+ this.markDirty();
2989
+ }
2990
+ catch {
2991
+ if (this.disposed)
2992
+ return;
2993
+ this.pushRow({ kind: 'system', text: info.notice });
2994
+ this.markDirty();
2995
+ }
2996
+ }
2997
+ readSkippedUpdate() {
2998
+ const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
2999
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
3000
+ return undefined;
3001
+ const skip = raw.skipUpdate;
3002
+ return typeof skip === 'string' && skip.trim() !== '' ? skip.trim() : undefined;
3003
+ }
3004
+ async persistSkippedUpdate(latest) {
3005
+ await this.mergeUiSettings({ skipUpdate: latest });
3006
+ }
3007
+ async mergeUiSettings(patch) {
3008
+ const settings = this.ctx.get('settings');
3009
+ if (settings === undefined)
3010
+ return;
3011
+ const raw = settings.get(UI_LOCALE_NAMESPACE);
3012
+ const previous = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
3013
+ ? raw
3014
+ : {};
3015
+ await settings.replace(UI_LOCALE_NAMESPACE, { ...previous, ...patch });
3016
+ }
3017
+ readWorkspaceView() {
3018
+ const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
3019
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
3020
+ return 'detailed';
3021
+ return parseWorkspaceView(String(raw.view ?? '')) ?? 'detailed';
3022
+ }
3023
+ isCompactView() {
3024
+ return this.workspaceView === 'compact';
3025
+ }
3026
+ /** Test helper: switch the workspace view without going through /view. */
3027
+ setWorkspaceView(view) {
3028
+ this.workspaceView = view;
3029
+ }
3030
+ paintCompactSummary(addDisplay, anchor, kind, groups, width) {
3031
+ const focused = this.focusedRow === anchor;
3032
+ const marker = anchor.expanded ? '▾' : '▸';
3033
+ const running = kind === 'edits'
3034
+ ? groups.edits.some(item => item.status === undefined || item.status === 'running')
3035
+ : groups.calls.some(item => item.status === undefined || item.status === 'running');
3036
+ const spinner = running ? ` ${this.spinnerFrame()}` : '';
3037
+ const title = kind === 'edits'
3038
+ ? (groups.edits.length > 1
3039
+ ? t('compact.editsFiles', { lines: groups.editLines, files: groups.edits.length })
3040
+ : t('compact.edits', { lines: groups.editLines }))
3041
+ : (groups.failedCalls > 0
3042
+ ? t('compact.toolsFailed', { count: groups.calls.length, failed: groups.failedCalls })
3043
+ : t('compact.tools', { count: groups.calls.length }));
3044
+ const header = `${focused ? '▶ ' : ' '}${marker} ● ${title}${spinner}${anchor.expanded ? '' : t('card.expand')}`;
3045
+ const styled = this.styleLine(groups.failedCalls > 0 && kind === 'calls' ? 'error' : 'tool', header);
3046
+ addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, anchor);
3047
+ if (!anchor.expanded)
3048
+ return;
3049
+ const items = kind === 'edits' ? groups.edits : groups.calls;
3050
+ for (const item of items) {
3051
+ const state = item.status === 'error' ? 'error' : item.status === 'ok' ? 'ok' : 'running…';
3052
+ const extra = kind === 'edits'
3053
+ ? `${countDiffLines(item.diff) || 1} ${getLocale() === 'en' ? 'lines' : '行'}`
3054
+ : item.summary;
3055
+ addDisplay(this.styleLine('system', truncateToWidth(` ${item.title} ${extra} [${state}]`, width)), item);
3056
+ }
2887
3057
  }
2888
3058
  startRenderTimer() {
2889
3059
  if (this.renderTimer !== undefined) {
@@ -3160,6 +3330,22 @@ export class SshTui {
3160
3330
  }
3161
3331
  /** The transcript rows that support per-row expand/collapse. */
3162
3332
  collapsibleRows() {
3333
+ const compact = this.isCompactView();
3334
+ if (compact) {
3335
+ const groups = compactToolGroups(this.rows.filter((row) => row.kind === 'tool'));
3336
+ const rows = this.rows.filter((row) => row.kind === 'subagent'
3337
+ || row.kind === 'plan'
3338
+ || row.kind === 'question'
3339
+ || row.kind === 'goal'
3340
+ || row.kind === 'compaction');
3341
+ const callAnchor = groups.calls.at(-1);
3342
+ const editAnchor = groups.edits.at(-1);
3343
+ if (callAnchor !== undefined)
3344
+ rows.push(callAnchor);
3345
+ if (editAnchor !== undefined)
3346
+ rows.push(editAnchor);
3347
+ return rows;
3348
+ }
3163
3349
  const rows = this.rows.filter((row) => row.kind === 'reasoning'
3164
3350
  || row.kind === 'tool'
3165
3351
  || row.kind === 'subagent'
@@ -3178,16 +3364,12 @@ export class SshTui {
3178
3364
  return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
3179
3365
  }
3180
3366
  /**
3181
- * Codex wait card: shown while the turn is running and there is no live
3182
- * thinking/reply stream. A live tool keeps the card so the detail line can
3183
- * name what is happening (Codex `update_details`).
3367
+ * Codex wait card: shown while the turn is running. Thinking/reply streams
3368
+ * feed the shimmer header; a live tool becomes the detail line.
3184
3369
  */
3185
3370
  waitCardVisible() {
3186
3371
  if (this.agent.status !== 'running')
3187
3372
  return false;
3188
- if (this.streaming !== undefined && (this.streaming.reasoning !== '' || this.streaming.text !== '')) {
3189
- return false;
3190
- }
3191
3373
  if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
3192
3374
  return false;
3193
3375
  if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
@@ -3205,14 +3387,21 @@ export class SshTui {
3205
3387
  }
3206
3388
  waitCardSource() {
3207
3389
  const liveTool = this.rows.findLast((row) => row.kind === 'tool' && (row.status === undefined || row.status === 'running'));
3208
- if (liveTool !== undefined) {
3209
- return { toolTitle: liveTool.title, toolSummary: liveTool.summary, prompt: this.waitPrompt };
3210
- }
3211
3390
  const liveSub = this.rows.findLast((row) => row.kind === 'subagent' && row.status === 'running');
3212
- if (liveSub !== undefined) {
3213
- return { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity, prompt: this.waitPrompt };
3214
- }
3215
- return { prompt: this.waitPrompt };
3391
+ return {
3392
+ ...(liveTool === undefined ? {} : { toolTitle: liveTool.title, toolSummary: liveTool.summary }),
3393
+ ...(liveTool !== undefined || liveSub === undefined
3394
+ ? {}
3395
+ : { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity }),
3396
+ ...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
3397
+ ...(this.streaming?.text ? { reply: this.streaming.text } : {}),
3398
+ ...(this.waitPrompt === undefined ? {} : { prompt: this.waitPrompt }),
3399
+ };
3400
+ }
3401
+ planShouldDefaultExpand(plan) {
3402
+ return plan.active === true
3403
+ || plan.pending === true
3404
+ || plan.todos.some(item => item.status === 'in_progress');
3216
3405
  }
3217
3406
  findSubagentRow(sessionId) {
3218
3407
  return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
@@ -3249,6 +3438,9 @@ export class SshTui {
3249
3438
  existing.archived = true;
3250
3439
  existing.expanded = false;
3251
3440
  }
3441
+ else if (patch.expanded === undefined && this.planShouldDefaultExpand(existing)) {
3442
+ existing.expanded = true;
3443
+ }
3252
3444
  this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
3253
3445
  return existing;
3254
3446
  }
@@ -3264,7 +3456,13 @@ export class SshTui {
3264
3456
  pending: patch.pending ?? false,
3265
3457
  todos: patch.todos ?? [],
3266
3458
  ...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
3267
- expanded: false,
3459
+ expanded: this.planShouldDefaultExpand({
3460
+ kind: 'plan',
3461
+ active: patch.active ?? false,
3462
+ pending: patch.pending ?? false,
3463
+ todos: patch.todos ?? [],
3464
+ expanded: false,
3465
+ }),
3268
3466
  archived: false,
3269
3467
  };
3270
3468
  this.pushRow(row);
@@ -3662,7 +3860,13 @@ export class SshTui {
3662
3860
  addDisplay(this.styleLine(kind, line), ref);
3663
3861
  }
3664
3862
  };
3863
+ const compact = this.isCompactView();
3864
+ const compactGroups = compact
3865
+ ? compactToolGroups(this.rows.filter((row) => row.kind === 'tool'))
3866
+ : undefined;
3665
3867
  for (const row of this.rows) {
3868
+ if (compact && (row.kind === 'reasoning' || row.kind === 'prompt' || row.kind === 'tool'))
3869
+ continue;
3666
3870
  if (row.kind === 'brand-logo') {
3667
3871
  const variant = DEEPSEEK_LOGO_VARIANTS.find(candidate => candidate.width <= width - 2)
3668
3872
  ?? DEEPSEEK_LOGO_VARIANTS[DEEPSEEK_LOGO_VARIANTS.length - 1];
@@ -3872,7 +4076,7 @@ export class SshTui {
3872
4076
  pushRow(row.kind, row.text, row);
3873
4077
  }
3874
4078
  if (this.streaming !== undefined) {
3875
- if (this.showReasoning && this.streaming.reasoning !== '') {
4079
+ if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
3876
4080
  const block = this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
3877
4081
  const focused = this.focusedRow === block;
3878
4082
  const marker = block.expanded ? '▾' : '▸';
@@ -3903,7 +4107,17 @@ export class SshTui {
3903
4107
  }
3904
4108
  }
3905
4109
  }
3906
- else if (this.waitCardVisible()) {
4110
+ if (compact && compactGroups !== undefined) {
4111
+ const editAnchor = compactGroups.edits.at(-1);
4112
+ const callAnchor = compactGroups.calls.at(-1);
4113
+ if (callAnchor !== undefined) {
4114
+ this.paintCompactSummary(addDisplay, callAnchor, 'calls', compactGroups, width);
4115
+ }
4116
+ if (editAnchor !== undefined) {
4117
+ this.paintCompactSummary(addDisplay, editAnchor, 'edits', compactGroups, width);
4118
+ }
4119
+ }
4120
+ if (this.waitCardVisible()) {
3907
4121
  const copy = waitCardCopy(this.waitCardSource());
3908
4122
  const started = this.waitStartedAt ?? Date.now();
3909
4123
  const elapsed = fmtElapsedCompact((Date.now() - started) / 1000);
@@ -4183,6 +4397,7 @@ export class SshTui {
4183
4397
  multiLineInput: inputRows > 1,
4184
4398
  queued: this.pendingMessages.size,
4185
4399
  cwdLabel: formatFooterCwd(this.workspaceCwd()),
4400
+ compactView: this.isCompactView(),
4186
4401
  };
4187
4402
  const activity = footerActivity(footer);
4188
4403
  const activityText = activity.kind === 'compacting'
@@ -5969,13 +6184,41 @@ export class SshTui {
5969
6184
  this.pushRow({ kind: 'error', text: t('lang.settingsMissing') });
5970
6185
  }
5971
6186
  else {
5972
- await settings.replace(UI_LOCALE_NAMESPACE, { language: next });
6187
+ await this.mergeUiSettings({ language: next });
5973
6188
  applySavedLocale({ language: next });
5974
6189
  }
5975
6190
  this.forceFullPaint = true;
5976
6191
  this.pushRow({ kind: 'system', text: t('lang.switched', { name: localeDisplayName(next) }) });
5977
6192
  this.markDirty();
5978
6193
  }
6194
+ /** /view: detailed (see the work) vs compact (Codex-like summary). */
6195
+ async runViewCommand(arg) {
6196
+ const direct = parseWorkspaceView(arg);
6197
+ let next = direct;
6198
+ if (next === undefined && arg.trim() !== '') {
6199
+ this.pushRow({ kind: 'error', text: t('view.unknown', { id: arg.trim() }) });
6200
+ this.markDirty();
6201
+ return;
6202
+ }
6203
+ if (next === undefined) {
6204
+ const current = this.workspaceView;
6205
+ const answer = await this.askQuestion({
6206
+ id: 'view-pick',
6207
+ question: t('view.pick'),
6208
+ options: [
6209
+ { label: t('view.detailed'), description: current === 'detailed' ? t('view.current') : t('view.detailedDesc') },
6210
+ { label: t('view.compact'), description: current === 'compact' ? t('view.current') : t('view.compactDesc') },
6211
+ ],
6212
+ }, 0, 1, current === 'compact' ? 1 : 0);
6213
+ const picked = answer.selected[0];
6214
+ next = picked === t('view.compact') ? 'compact' : 'detailed';
6215
+ }
6216
+ this.workspaceView = next;
6217
+ await this.mergeUiSettings({ view: next });
6218
+ this.forceFullPaint = true;
6219
+ this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
6220
+ this.markDirty();
6221
+ }
5979
6222
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
5980
6223
  async runModeCommand() {
5981
6224
  const agentPresets = this.ctx.get('agentPresets');
@@ -6625,7 +6868,10 @@ export class SshTui {
6625
6868
  this.moveCollapsibleFocus(-1);
6626
6869
  return;
6627
6870
  case '\x12':
6628
- this.toggleAllCollapsible();
6871
+ if (this.focusedRow === null)
6872
+ this.toggleCollapsible();
6873
+ else
6874
+ this.toggleAllCollapsible();
6629
6875
  return;
6630
6876
  case '\x14':
6631
6877
  this.inputFolded = !this.inputFolded;
@@ -7392,6 +7638,17 @@ export class SshTui {
7392
7638
  this.markDirty();
7393
7639
  });
7394
7640
  break;
7641
+ case 'view':
7642
+ void this.runViewCommand(arg).catch((error) => {
7643
+ if (error instanceof UserQuestionError) {
7644
+ this.pushRow({ kind: 'system', text: t('help.modeCancel') });
7645
+ }
7646
+ else {
7647
+ this.pushRow({ kind: 'error', text: `/view failed: ${errorChain(error)}` });
7648
+ }
7649
+ this.markDirty();
7650
+ });
7651
+ break;
7395
7652
  case 'find':
7396
7653
  this.runFindCommand(arg);
7397
7654
  break;