dsh-ssh-tui 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 !== '')
@@ -384,6 +386,7 @@ const PLUGIN_VERSION = (() => {
384
386
  }
385
387
  })();
386
388
  const STALL_WARNING_MS = 60000;
389
+ const CTRL_C_EXIT_WINDOW_MS = 2000;
387
390
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
388
391
  const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
389
392
  const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
@@ -581,12 +584,18 @@ const LOCAL_COMMANDS = [
581
584
  { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
582
585
  { name: 'language', description: 'switch UI language (zh / en); empty opens a picker' },
583
586
  { name: 'lang', description: 'alias of /language' },
587
+ { name: 'view', description: 'switch workspace view (detailed / compact); empty opens a picker' },
584
588
  { name: 'dialog-test', description: 'verify the question dialog' },
585
589
  ];
586
590
  function localizedCommands() {
587
- return LOCAL_COMMANDS.map(command => (command.name === 'language' || command.name === 'lang'
588
- ? { name: command.name, description: t('lang.cmd') }
589
- : 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
+ });
590
599
  }
591
600
  /**
592
601
  * Terminal cell width for one string.
@@ -600,6 +609,85 @@ function localizedCommands() {
600
609
  * Overflow into the input box is handled by clipping/padding painted rows to
601
610
  * the measured column count, not by inflating glyph width.
602
611
  */
612
+ /**
613
+ * Codex-style compact elapsed: `0s`, `1m 05s`, `1h 01m 01s`.
614
+ * Used by the workspace wait card while the model has not streamed yet.
615
+ */
616
+ export function fmtElapsedCompact(elapsedSecs) {
617
+ const secs = Math.max(0, Math.floor(elapsedSecs));
618
+ if (secs < 60)
619
+ return `${secs}s`;
620
+ if (secs < 3600) {
621
+ const minutes = Math.floor(secs / 60);
622
+ const seconds = secs % 60;
623
+ return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
624
+ }
625
+ const hours = Math.floor(secs / 3600);
626
+ const minutes = Math.floor((secs % 3600) / 60);
627
+ const seconds = secs % 60;
628
+ return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
629
+ }
630
+ /**
631
+ * Sweep highlight across `text` (Codex `shimmer.rs`). Truecolor blends a
632
+ * highlight band; otherwise DIM / default / BOLD. Process-start based so
633
+ * every paint of the same frame stays in phase.
634
+ */
635
+ export function shimmerText(text, nowMs, color) {
636
+ const chars = Array.from(text);
637
+ if (chars.length === 0)
638
+ return '';
639
+ if (!color)
640
+ return text;
641
+ const padding = 10;
642
+ const period = chars.length + padding * 2;
643
+ const sweepMs = 2000;
644
+ const pos = Math.floor(((nowMs % sweepMs) / sweepMs) * period);
645
+ const bandHalf = 5;
646
+ let out = '';
647
+ for (let index = 0; index < chars.length; index += 1) {
648
+ const dist = Math.abs(index + padding - pos);
649
+ const t = dist <= bandHalf
650
+ ? 0.5 * (1 + Math.cos(Math.PI * (dist / bandHalf)))
651
+ : 0;
652
+ const style = t < 0.2 ? '2' : t < 0.6 ? '0' : '1';
653
+ out += `\x1b[${style}m${chars[index]}\x1b[0m`;
654
+ }
655
+ return out;
656
+ }
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. */
679
+ export function waitCardCopy(input) {
680
+ const toolTitle = input.toolTitle?.trim() ?? '';
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');
685
+ if (toolTitle !== '') {
686
+ const extra = toolSummary === '' ? '' : ` ${Array.from(toolSummary).slice(0, 40).join('')}`;
687
+ return { header, detail: `${toolTitle}${extra}` };
688
+ }
689
+ return { header };
690
+ }
603
691
  export function displayWidth(text) {
604
692
  let width = 0;
605
693
  for (const char of text) {
@@ -806,6 +894,7 @@ function paintSegmentedLine(line, start, end, segments) {
806
894
  if (segments.length === 0)
807
895
  return line;
808
896
  let out = '';
897
+ let cursor = start;
809
898
  for (const seg of segments) {
810
899
  if (seg.end <= start)
811
900
  continue;
@@ -815,8 +904,14 @@ function paintSegmentedLine(line, start, end, segments) {
815
904
  const to = Math.min(seg.end, end);
816
905
  if (to <= from)
817
906
  continue;
907
+ // Gaps (the tool title) stay default foreground — do not drop them.
908
+ if (from > cursor)
909
+ out += line.slice(cursor - start, from - start);
818
910
  out += `\x1b[${seg.sgr}m${line.slice(from - start, to - start)}\x1b[0m`;
911
+ cursor = to;
819
912
  }
913
+ if (cursor < end)
914
+ out += line.slice(cursor - start, end - start);
820
915
  return out === '' ? line : out;
821
916
  }
822
917
  /** Wrap `text` and color each output line by overlapping `segments`. */
@@ -1160,16 +1255,29 @@ function backwardSliceByWidth(text, end, maxWidth) {
1160
1255
  };
1161
1256
  }
1162
1257
  /**
1163
- * Fold a long single-line input into one terminal row around the cursor.
1164
- * Only the *display* is clipped; the caller keeps the original `input` intact
1165
- * for editing and submission.
1258
+ * Fold a long input into one terminal row around the cursor.
1259
+ *
1260
+ * Newlines from a paste are display-only: they do not occupy cells, so a
1261
+ * naive `displayWidth(input)` under-counts a multi-line paste and parks the
1262
+ * caret in the middle of later text. Fold the *current line* (between the
1263
+ * surrounding newlines) and keep `\n` out of the visible slice.
1166
1264
  */
1167
1265
  export function foldInputView(input, cursor, maxWidth) {
1168
1266
  const width = Math.max(1, maxWidth);
1169
- const totalWidth = displayWidth(input);
1170
- const cursorOffset = displayWidth(input.slice(0, cursor));
1267
+ const safeCursor = Math.max(0, Math.min(cursor, input.length));
1268
+ const lineStart = input.lastIndexOf('\n', Math.max(0, safeCursor - 1)) + 1;
1269
+ const lineEndRaw = input.indexOf('\n', safeCursor);
1270
+ const lineEnd = lineEndRaw === -1 ? input.length : lineEndRaw;
1271
+ const line = input.slice(lineStart, lineEnd);
1272
+ const lineCursor = safeCursor - lineStart;
1273
+ const totalWidth = displayWidth(line);
1274
+ const cursorOffset = displayWidth(line.slice(0, lineCursor));
1275
+ const hasMoreLines = lineStart > 0 || lineEnd < input.length;
1276
+ if (totalWidth <= width && !hasMoreLines) {
1277
+ return { text: line, cursorOffset, folded: false };
1278
+ }
1171
1279
  if (totalWidth <= width) {
1172
- return { text: input, cursorOffset, folded: false };
1280
+ return { text: line, cursorOffset, folded: true };
1173
1281
  }
1174
1282
  const before = cursorOffset;
1175
1283
  const after = totalWidth - cursorOffset;
@@ -1182,9 +1290,9 @@ export function foldInputView(input, cursor, maxWidth) {
1182
1290
  // If the tail is shorter than its budget, spend the spare columns on the
1183
1291
  // side before the cursor so the cursor stays visible near its true offset.
1184
1292
  beforeBudget = Math.min(before, beforeBudget + (available - beforeBudget - afterBudget));
1185
- const beforeSlice = backwardSliceByWidth(input, cursor, beforeBudget);
1186
- const afterSlice = forwardSliceByWidth(input.slice(cursor), afterBudget);
1187
- const beforeText = input.slice(beforeSlice.start, cursor);
1293
+ const beforeSlice = backwardSliceByWidth(line, lineCursor, beforeBudget);
1294
+ const afterSlice = forwardSliceByWidth(line.slice(lineCursor), afterBudget);
1295
+ const beforeText = line.slice(beforeSlice.start, lineCursor);
1188
1296
  return {
1189
1297
  text: `${leftFolded ? '…' : ''}${beforeText}${afterSlice.text}${rightFolded ? '…' : ''}`,
1190
1298
  cursorOffset: (leftFolded ? 1 : 0) + displayWidth(beforeText),
@@ -1754,6 +1862,45 @@ function friendlyArgsSummary(name, args) {
1754
1862
  }
1755
1863
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
1756
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
+ }
1757
1904
  const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
1758
1905
  /**
1759
1906
  * Tool calls that already have a dedicated transcript card (goal/change,
@@ -2644,6 +2791,7 @@ export class SshTui {
2644
2791
  color;
2645
2792
  maxToolOutputLines;
2646
2793
  showReasoning;
2794
+ workspaceView = 'detailed';
2647
2795
  goodbye;
2648
2796
  resume;
2649
2797
  providerName;
@@ -2664,6 +2812,7 @@ export class SshTui {
2664
2812
  focusedRow = null;
2665
2813
  pendingMessages = new Map();
2666
2814
  lastActivity = Date.now();
2815
+ lastIdleCtrlCAt = 0;
2667
2816
  stalledWarningShown = false;
2668
2817
  lastPaintAt = 0;
2669
2818
  commandAbort;
@@ -2691,6 +2840,8 @@ export class SshTui {
2691
2840
  escapeBuffer = '';
2692
2841
  escapeTimer;
2693
2842
  thinkingStartedAt;
2843
+ waitStartedAt;
2844
+ waitPrompt;
2694
2845
  completionSignaled = false;
2695
2846
  replaying = false;
2696
2847
  completedAt = 0;
@@ -2727,6 +2878,7 @@ export class SshTui {
2727
2878
  this.color = config.color !== false && !noColorEnv && process.env.TERM !== 'dumb';
2728
2879
  this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
2729
2880
  this.showReasoning = config.showReasoning !== false;
2881
+ this.workspaceView = this.readWorkspaceView();
2730
2882
  this.goodbye = config.goodbye
2731
2883
  ?? this.ctx.get('tuiGoodbyeMessage')
2732
2884
  ?? `To resume this session: dsh --profile tui --resume=${this.agent.id}`;
@@ -2794,11 +2946,114 @@ export class SshTui {
2794
2946
  });
2795
2947
  }
2796
2948
  async notifyPluginUpdate() {
2797
- const notice = await checkForPluginUpdate(PLUGIN_VERSION);
2798
- if (this.disposed || notice === undefined)
2949
+ const info = await checkForPluginUpdate(PLUGIN_VERSION);
2950
+ if (this.disposed || info === undefined)
2799
2951
  return;
2800
- this.pushRow({ kind: 'system', text: notice });
2801
- 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
+ }
2802
3057
  }
2803
3058
  startRenderTimer() {
2804
3059
  if (this.renderTimer !== undefined) {
@@ -2811,6 +3066,7 @@ export class SshTui {
2811
3066
  this.updateTerminalTitle();
2812
3067
  const animating = (this.streaming !== undefined && this.streaming.reasoning !== '')
2813
3068
  || this.activeSubagents.size > 0
3069
+ || this.waitCardVisible()
2814
3070
  || this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
2815
3071
  || (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
2816
3072
  || (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
@@ -3074,15 +3330,20 @@ export class SshTui {
3074
3330
  }
3075
3331
  /** The transcript rows that support per-row expand/collapse. */
3076
3332
  collapsibleRows() {
3077
- const rows = this.rows.filter((row) => row.kind === 'reasoning'
3078
- || row.kind === 'tool'
3079
- || row.kind === 'subagent'
3080
- || row.kind === 'plan'
3081
- || row.kind === 'question'
3082
- || row.kind === 'goal'
3083
- || row.kind === 'compaction'
3084
- || row.kind === 'prompt');
3085
- if (this.streaming !== undefined && this.streaming.reasoning !== '') {
3333
+ const compact = this.isCompactView();
3334
+ const rows = this.rows.filter((row) => {
3335
+ if (compact && (row.kind === 'reasoning' || row.kind === 'prompt'))
3336
+ return false;
3337
+ return row.kind === 'reasoning'
3338
+ || row.kind === 'tool'
3339
+ || row.kind === 'subagent'
3340
+ || row.kind === 'plan'
3341
+ || row.kind === 'question'
3342
+ || row.kind === 'goal'
3343
+ || row.kind === 'compaction'
3344
+ || row.kind === 'prompt';
3345
+ });
3346
+ if (!compact && this.streaming !== undefined && this.streaming.reasoning !== '') {
3086
3347
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
3087
3348
  rows.push(this.streamingReasoning);
3088
3349
  }
@@ -3091,6 +3352,46 @@ export class SshTui {
3091
3352
  spinnerFrame(periodMs = 120) {
3092
3353
  return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
3093
3354
  }
3355
+ /**
3356
+ * Codex wait card: shown while the turn is running. Thinking/reply streams
3357
+ * feed the shimmer header; a live tool becomes the detail line.
3358
+ */
3359
+ waitCardVisible() {
3360
+ if (this.agent.status !== 'running')
3361
+ return false;
3362
+ if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
3363
+ return false;
3364
+ if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
3365
+ return false;
3366
+ return true;
3367
+ }
3368
+ beginWait(prompt) {
3369
+ this.waitStartedAt = Date.now();
3370
+ const trimmed = prompt?.replace(/\s+/gu, ' ').trim();
3371
+ this.waitPrompt = trimmed === undefined || trimmed === '' ? this.waitPrompt : trimmed;
3372
+ }
3373
+ endWait() {
3374
+ this.waitStartedAt = undefined;
3375
+ this.waitPrompt = undefined;
3376
+ }
3377
+ waitCardSource() {
3378
+ const liveTool = this.rows.findLast((row) => row.kind === 'tool' && (row.status === undefined || row.status === 'running'));
3379
+ const liveSub = this.rows.findLast((row) => row.kind === 'subagent' && row.status === 'running');
3380
+ return {
3381
+ ...(liveTool === undefined ? {} : { toolTitle: liveTool.title, toolSummary: liveTool.summary }),
3382
+ ...(liveTool !== undefined || liveSub === undefined
3383
+ ? {}
3384
+ : { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity }),
3385
+ ...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
3386
+ ...(this.streaming?.text ? { reply: this.streaming.text } : {}),
3387
+ ...(this.waitPrompt === undefined ? {} : { prompt: this.waitPrompt }),
3388
+ };
3389
+ }
3390
+ planShouldDefaultExpand(plan) {
3391
+ return plan.active === true
3392
+ || plan.pending === true
3393
+ || plan.todos.some(item => item.status === 'in_progress');
3394
+ }
3094
3395
  findSubagentRow(sessionId) {
3095
3396
  return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
3096
3397
  }
@@ -3126,6 +3427,9 @@ export class SshTui {
3126
3427
  existing.archived = true;
3127
3428
  existing.expanded = false;
3128
3429
  }
3430
+ else if (patch.expanded === undefined && this.planShouldDefaultExpand(existing)) {
3431
+ existing.expanded = true;
3432
+ }
3129
3433
  this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
3130
3434
  return existing;
3131
3435
  }
@@ -3141,7 +3445,13 @@ export class SshTui {
3141
3445
  pending: patch.pending ?? false,
3142
3446
  todos: patch.todos ?? [],
3143
3447
  ...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
3144
- expanded: false,
3448
+ expanded: this.planShouldDefaultExpand({
3449
+ kind: 'plan',
3450
+ active: patch.active ?? false,
3451
+ pending: patch.pending ?? false,
3452
+ todos: patch.todos ?? [],
3453
+ expanded: false,
3454
+ }),
3145
3455
  archived: false,
3146
3456
  };
3147
3457
  this.pushRow(row);
@@ -3539,7 +3849,26 @@ export class SshTui {
3539
3849
  addDisplay(this.styleLine(kind, line), ref);
3540
3850
  }
3541
3851
  };
3852
+ const compact = this.isCompactView();
3853
+ const compactGroups = compact
3854
+ ? compactToolGroups(this.rows.filter((row) => row.kind === 'tool'))
3855
+ : undefined;
3856
+ const compactEditCard = compactGroups?.edits[0];
3857
+ const compactCallCard = compactGroups?.calls[0];
3542
3858
  for (const row of this.rows) {
3859
+ if (compact && (row.kind === 'reasoning' || row.kind === 'prompt'))
3860
+ continue;
3861
+ if (compact && compactGroups !== undefined && row.kind === 'tool') {
3862
+ if (row === compactEditCard) {
3863
+ this.paintCompactSummary(addDisplay, row, 'edits', compactGroups, width);
3864
+ continue;
3865
+ }
3866
+ if (row === compactCallCard) {
3867
+ this.paintCompactSummary(addDisplay, row, 'calls', compactGroups, width);
3868
+ continue;
3869
+ }
3870
+ continue;
3871
+ }
3543
3872
  if (row.kind === 'brand-logo') {
3544
3873
  const variant = DEEPSEEK_LOGO_VARIANTS.find(candidate => candidate.width <= width - 2)
3545
3874
  ?? DEEPSEEK_LOGO_VARIANTS[DEEPSEEK_LOGO_VARIANTS.length - 1];
@@ -3573,7 +3902,7 @@ export class SshTui {
3573
3902
  const header = buildToolHeader({
3574
3903
  focused,
3575
3904
  expanded: row.expanded,
3576
- title: row.title,
3905
+ title: toolTitle(row.name) || row.title,
3577
3906
  summary: row.summary,
3578
3907
  status: row.status,
3579
3908
  command: row.command,
@@ -3749,7 +4078,7 @@ export class SshTui {
3749
4078
  pushRow(row.kind, row.text, row);
3750
4079
  }
3751
4080
  if (this.streaming !== undefined) {
3752
- if (this.showReasoning && this.streaming.reasoning !== '') {
4081
+ if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
3753
4082
  const block = this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
3754
4083
  const focused = this.focusedRow === block;
3755
4084
  const marker = block.expanded ? '▾' : '▸';
@@ -3780,6 +4109,20 @@ export class SshTui {
3780
4109
  }
3781
4110
  }
3782
4111
  }
4112
+ if (this.waitCardVisible()) {
4113
+ const copy = waitCardCopy(this.waitCardSource());
4114
+ const started = this.waitStartedAt ?? Date.now();
4115
+ const elapsed = fmtElapsedCompact((Date.now() - started) / 1000);
4116
+ const hint = t('wait.interrupt', { elapsed });
4117
+ const spinner = this.spinnerFrame();
4118
+ const header = this.color
4119
+ ? `${spinner} ${shimmerText(copy.header, Date.now(), true)} ${this.styleLine('system', hint)}`
4120
+ : `${spinner} ${copy.header} ${hint}`;
4121
+ addDisplay(header);
4122
+ if (copy.detail !== undefined && copy.detail !== '') {
4123
+ addDisplay(this.styleLine('system', ` └ ${copy.detail}`));
4124
+ }
4125
+ }
3783
4126
  const dialogLines = [];
3784
4127
  const addDialog = (text) => {
3785
4128
  for (const wrapped of wrap(text, Math.max(1, width))) {
@@ -3908,35 +4251,24 @@ export class SshTui {
3908
4251
  const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
3909
4252
  const promptWidth = displayWidth(promptPlain);
3910
4253
  const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
4254
+ const inputTextWidth = Math.max(1, width - promptWidth);
3911
4255
  const inputView = masked
3912
4256
  ? { text: '•'.repeat(this.input.length), cursorOffset: displayWidth('•'.repeat(this.cursor)), folded: false }
3913
4257
  : this.inputFolded
3914
- ? foldInputView(this.input, this.cursor, Math.max(1, width - promptWidth))
4258
+ ? foldInputView(this.input, this.cursor, inputTextWidth)
3915
4259
  : { text: this.input, cursorOffset: displayWidth(this.input.slice(0, this.cursor)), folded: false };
3916
- const inputTextWidth = Math.max(1, width - promptWidth);
3917
4260
  const inputTextLines = wrap(inputView.text, inputTextWidth);
3918
4261
  const inputDisplayLines = inputTextLines.map((line, index) => index === 0 ? `${prompt}${line}` : line);
3919
- // Cursor visual position. Folded/masked views are single-line and use
3920
- // the existing flat offset model; normal multi-line input maps the cursor
3921
- // index through the same wrap() layout so it stays on the right line.
4262
+ // Folded/masked views are one logical row around the caret. Place the
4263
+ // cursor with the prompt width of that row never wrap the offset
4264
+ // across the full terminal grid, which parked the caret on a later
4265
+ // chrome line after a long paste. Un-folded multi-line input still
4266
+ // maps through wrap() so newlines stay on the right visual row.
3922
4267
  let cursorRowOffset;
3923
4268
  let column;
3924
4269
  if (inputView.folded || masked) {
3925
- const grid = Math.max(1, width);
3926
- const cursorPlainOffset = promptWidth + inputView.cursorOffset;
3927
- if (!inputView.folded
3928
- && cursorPlainOffset > 0
3929
- && cursorPlainOffset % grid === 0
3930
- && Math.floor(cursorPlainOffset / grid) >= inputDisplayLines.length) {
3931
- inputDisplayLines.push('');
3932
- }
3933
- cursorRowOffset = Math.min(Math.floor(cursorPlainOffset / grid), Math.max(0, inputDisplayLines.length - 1));
3934
- column = cursorPlainOffset % grid + 1;
3935
- if (cursorPlainOffset > 0
3936
- && cursorPlainOffset % grid === 0
3937
- && Math.floor(cursorPlainOffset / grid) >= inputDisplayLines.length) {
3938
- column = grid;
3939
- }
4270
+ cursorRowOffset = 0;
4271
+ column = Math.min(width, promptWidth + inputView.cursorOffset + 1);
3940
4272
  }
3941
4273
  else {
3942
4274
  const pos = cursorVisualPosition(inputView.text, this.cursor, inputTextWidth);
@@ -4057,6 +4389,7 @@ export class SshTui {
4057
4389
  multiLineInput: inputRows > 1,
4058
4390
  queued: this.pendingMessages.size,
4059
4391
  cwdLabel: formatFooterCwd(this.workspaceCwd()),
4392
+ compactView: this.isCompactView(),
4060
4393
  };
4061
4394
  const activity = footerActivity(footer);
4062
4395
  const activityText = activity.kind === 'compacting'
@@ -4346,6 +4679,8 @@ export class SshTui {
4346
4679
  const sourceKind = source.kind ?? '';
4347
4680
  if (sourceKind === 'user') {
4348
4681
  this.pushRow({ kind: 'user', text: `❯ ${text}` });
4682
+ if (!this.replaying)
4683
+ this.beginWait(text);
4349
4684
  }
4350
4685
  else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
4351
4686
  this.pushPromptInjection(text, source.plugin);
@@ -4564,6 +4899,7 @@ export class SshTui {
4564
4899
  this.streaming = undefined;
4565
4900
  this.streamingReasoning = undefined;
4566
4901
  this.thinkingStartedAt = undefined;
4902
+ this.endWait();
4567
4903
  if (reason.kind === 'completed' && !this.replaying && !this.completionSignaled) {
4568
4904
  this.completionSignaled = true;
4569
4905
  this.completedAt = Date.now();
@@ -4604,13 +4940,18 @@ export class SshTui {
4604
4940
  if (status === 'running') {
4605
4941
  this.completionSignaled = false;
4606
4942
  this.completedAt = 0;
4943
+ if (this.waitStartedAt === undefined)
4944
+ this.beginWait();
4607
4945
  }
4608
4946
  else if (!this.completionSignaled && this.status === 'running') {
4609
4947
  this.completionSignaled = true;
4610
4948
  this.completedAt = Date.now();
4611
4949
  this.updateTerminalTitle();
4612
4950
  this.playCompletionSignal();
4951
+ this.endWait();
4613
4952
  }
4953
+ if (status !== 'running')
4954
+ this.endWait();
4614
4955
  this.status = status === 'running' ? 'running' : 'idle';
4615
4956
  this.markDirty();
4616
4957
  };
@@ -5835,13 +6176,41 @@ export class SshTui {
5835
6176
  this.pushRow({ kind: 'error', text: t('lang.settingsMissing') });
5836
6177
  }
5837
6178
  else {
5838
- await settings.replace(UI_LOCALE_NAMESPACE, { language: next });
6179
+ await this.mergeUiSettings({ language: next });
5839
6180
  applySavedLocale({ language: next });
5840
6181
  }
5841
6182
  this.forceFullPaint = true;
5842
6183
  this.pushRow({ kind: 'system', text: t('lang.switched', { name: localeDisplayName(next) }) });
5843
6184
  this.markDirty();
5844
6185
  }
6186
+ /** /view: detailed (see the work) vs compact (Codex-like summary). */
6187
+ async runViewCommand(arg) {
6188
+ const direct = parseWorkspaceView(arg);
6189
+ let next = direct;
6190
+ if (next === undefined && arg.trim() !== '') {
6191
+ this.pushRow({ kind: 'error', text: t('view.unknown', { id: arg.trim() }) });
6192
+ this.markDirty();
6193
+ return;
6194
+ }
6195
+ if (next === undefined) {
6196
+ const current = this.workspaceView;
6197
+ const answer = await this.askQuestion({
6198
+ id: 'view-pick',
6199
+ question: t('view.pick'),
6200
+ options: [
6201
+ { label: t('view.detailed'), description: current === 'detailed' ? t('view.current') : t('view.detailedDesc') },
6202
+ { label: t('view.compact'), description: current === 'compact' ? t('view.current') : t('view.compactDesc') },
6203
+ ],
6204
+ }, 0, 1, current === 'compact' ? 1 : 0);
6205
+ const picked = answer.selected[0];
6206
+ next = picked === t('view.compact') ? 'compact' : 'detailed';
6207
+ }
6208
+ this.workspaceView = next;
6209
+ await this.mergeUiSettings({ view: next });
6210
+ this.forceFullPaint = true;
6211
+ this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
6212
+ this.markDirty();
6213
+ }
5845
6214
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
5846
6215
  async runModeCommand() {
5847
6216
  const agentPresets = this.ctx.get('agentPresets');
@@ -6401,6 +6770,10 @@ export class SshTui {
6401
6770
  return;
6402
6771
  this.input = `${this.input.slice(0, this.cursor)}${normalized}${this.input.slice(this.cursor)}`;
6403
6772
  this.cursor += normalized.length;
6773
+ const cols = Math.max(10, process.stdout.columns || 80);
6774
+ const lineWidth = Math.max(1, cols - 2);
6775
+ if (normalized.includes('\n') || displayWidth(this.input) > lineWidth)
6776
+ this.inputFolded = true;
6404
6777
  this.markDirty();
6405
6778
  }
6406
6779
  handlePlainText(text) {
@@ -6487,7 +6860,10 @@ export class SshTui {
6487
6860
  this.moveCollapsibleFocus(-1);
6488
6861
  return;
6489
6862
  case '\x12':
6490
- this.toggleAllCollapsible();
6863
+ if (this.focusedRow === null)
6864
+ this.toggleCollapsible();
6865
+ else
6866
+ this.toggleAllCollapsible();
6491
6867
  return;
6492
6868
  case '\x14':
6493
6869
  this.inputFolded = !this.inputFolded;
@@ -7089,16 +7465,26 @@ export class SshTui {
7089
7465
  handleCtrlC() {
7090
7466
  if (this.dialog !== undefined) {
7091
7467
  this.handleEscape();
7468
+ this.lastIdleCtrlCAt = 0;
7092
7469
  return;
7093
7470
  }
7094
7471
  if (this.agent.status === 'running') {
7095
- this.pushRow({ kind: 'system', text: '已请求取消当前轮次…(Ctrl+C)' });
7472
+ this.lastIdleCtrlCAt = 0;
7473
+ this.pushRow({ kind: 'system', text: t('cancel.ctrlC') });
7096
7474
  this.agent.cancel({ kind: 'user' });
7097
7475
  this.status = 'cancelling…';
7098
7476
  this.markDirty();
7099
7477
  return;
7100
7478
  }
7101
- void this.requestExit(130);
7479
+ const now = Date.now();
7480
+ if (now - this.lastIdleCtrlCAt <= CTRL_C_EXIT_WINDOW_MS) {
7481
+ this.lastIdleCtrlCAt = 0;
7482
+ void this.requestExit(130);
7483
+ return;
7484
+ }
7485
+ this.lastIdleCtrlCAt = now;
7486
+ this.pushRow({ kind: 'system', text: t('exit.ctrlCAgain') });
7487
+ this.markDirty();
7102
7488
  }
7103
7489
  submit() {
7104
7490
  if (this.dialog !== undefined) {
@@ -7137,10 +7523,11 @@ export class SshTui {
7137
7523
  });
7138
7524
  if (this.agent.status === 'running') {
7139
7525
  this.pendingMessages.set(message.id, text);
7140
- this.pushRow({ kind: 'system', text: `⚡ ${text}(运行中已提交,将在下个步骤生效;Esc/Ctrl+C 可中断)` });
7526
+ this.pushRow({ kind: 'system', text: t('steer.queued', { text }) });
7141
7527
  this.agent.steer(message);
7142
7528
  }
7143
7529
  else {
7530
+ this.beginWait(text);
7144
7531
  this.agent.followup(message);
7145
7532
  }
7146
7533
  this.markDirty();
@@ -7243,6 +7630,17 @@ export class SshTui {
7243
7630
  this.markDirty();
7244
7631
  });
7245
7632
  break;
7633
+ case 'view':
7634
+ void this.runViewCommand(arg).catch((error) => {
7635
+ if (error instanceof UserQuestionError) {
7636
+ this.pushRow({ kind: 'system', text: t('help.modeCancel') });
7637
+ }
7638
+ else {
7639
+ this.pushRow({ kind: 'error', text: `/view failed: ${errorChain(error)}` });
7640
+ }
7641
+ this.markDirty();
7642
+ });
7643
+ break;
7246
7644
  case 'find':
7247
7645
  this.runFindCommand(arg);
7248
7646
  break;
@@ -7251,6 +7649,8 @@ export class SshTui {
7251
7649
  this.streaming = undefined;
7252
7650
  this.streamingReasoning = undefined;
7253
7651
  this.thinkingStartedAt = undefined;
7652
+ this.waitStartedAt = undefined;
7653
+ this.waitPrompt = undefined;
7254
7654
  this.focusedRow = null;
7255
7655
  this.searchHits = [];
7256
7656
  this.searchIndex = -1;