dsh-ssh-tui 0.5.2 → 0.5.3

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
@@ -21,7 +21,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials';
21
21
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
22
22
  import { SessionId } from '@deepseek-ai/dsh-session';
23
23
  import { sessionEvents, settingsNamespace } from './dsh-compat.js';
24
- import { classifyApproval, commandFromArgs, parseAutoApprovalMode } from './auto-approval.js';
24
+ import { classifyApproval, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
25
25
  import { buildReviewUserMessage, parseReviewOutput, REVIEW_SYSTEM_PROMPT } from './approval-reviewer.js';
26
26
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
27
27
  import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
@@ -124,6 +124,117 @@ export function formatTokens(n) {
124
124
  return `${scaled(n / 1_000)}K`;
125
125
  return `${scaled(n / 1_000_000)}M`;
126
126
  }
127
+ /**
128
+ * Prompt occupancy of the next request, from DSH `contextPressure`.
129
+ * Provider-agnostic: uses the routed model's advertised window, not a
130
+ * hardcoded xAI size. Compaction-basic still owns in-turn pressure at 80%.
131
+ */
132
+ export const CONTEXT_PRESSURE_WARN_RATIO = 0.8;
133
+ export const CONTEXT_PRESSURE_DANGER_RATIO = 0.95;
134
+ /** Idle auto-compact starts here so recovery finishes before the 80% in-turn trigger. */
135
+ export const CONTEXT_IDLE_COMPACT_RATIO = 0.72;
136
+ /** Prompt-side occupancy of one usage sample: uncached input plus cache traffic. */
137
+ export function promptPressureTokens(usage) {
138
+ return usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
139
+ }
140
+ /** Prefer the next-request projection; fall back to last-request pressure. */
141
+ export function contextPressureUsedTokens(pressure) {
142
+ if (pressure === undefined)
143
+ return undefined;
144
+ if (typeof pressure.projectedTokens === 'number' && Number.isFinite(pressure.projectedTokens)) {
145
+ return Math.max(0, pressure.projectedTokens);
146
+ }
147
+ if (typeof pressure.pressureTokens === 'number' && Number.isFinite(pressure.pressureTokens)) {
148
+ return Math.max(0, pressure.pressureTokens);
149
+ }
150
+ return undefined;
151
+ }
152
+ export function parseContextPressure(value) {
153
+ if (value === null || typeof value !== 'object')
154
+ return undefined;
155
+ const raw = value;
156
+ const window = typeof raw.contextWindow === 'number' && Number.isFinite(raw.contextWindow)
157
+ ? raw.contextWindow
158
+ : undefined;
159
+ if (window === undefined || window <= 0)
160
+ return undefined;
161
+ const used = contextPressureUsedTokens({
162
+ ...(typeof raw.projectedTokens === 'number' ? { projectedTokens: raw.projectedTokens } : {}),
163
+ ...(typeof raw.pressureTokens === 'number' ? { pressureTokens: raw.pressureTokens } : {}),
164
+ });
165
+ if (used === undefined)
166
+ return undefined;
167
+ return { usedTokens: used, contextWindow: window };
168
+ }
169
+ export function contextPressureView(sample) {
170
+ const percent = (sample.usedTokens / sample.contextWindow) * 100;
171
+ return {
172
+ usedTokens: sample.usedTokens,
173
+ contextWindow: sample.contextWindow,
174
+ percent,
175
+ level: percent >= CONTEXT_PRESSURE_DANGER_RATIO * 100
176
+ ? 'danger'
177
+ : percent >= CONTEXT_PRESSURE_WARN_RATIO * 100
178
+ ? 'warn'
179
+ : 'ok',
180
+ };
181
+ }
182
+ /**
183
+ * 8-segment Braille ring. Empty `⣀`; full `⣿`. Width is always 1 cell.
184
+ * Index is `ceil(percent / 12.5)` clamped to 0..8.
185
+ */
186
+ export const CONTEXT_RING_EMPTY = '⣀';
187
+ export const CONTEXT_RING_SEGMENTS = ['⣀', '⠉', '⠋', '⠛', '⠞', '⠟', '⠿', '⡿', '⣿'];
188
+ export function formatContextPressureRing(percent) {
189
+ if (!Number.isFinite(percent) || percent <= 0)
190
+ return CONTEXT_RING_EMPTY;
191
+ const filled = Math.min(8, Math.max(0, Math.ceil(percent / 12.5)));
192
+ return CONTEXT_RING_SEGMENTS[filled] ?? '⣿';
193
+ }
194
+ export function contextPressureRingColor(level) {
195
+ if (level === 'danger')
196
+ return '31';
197
+ if (level === 'warn')
198
+ return '33';
199
+ return '32';
200
+ }
201
+ export function formatContextPressureChip(view, color = false) {
202
+ const ring = formatContextPressureRing(view.percent);
203
+ const painted = color
204
+ ? `\x1b[${contextPressureRingColor(view.level)}m${ring}\x1b[0m`
205
+ : ring;
206
+ return t('footer.contextRing', {
207
+ ring: painted,
208
+ used: formatTokens(view.usedTokens),
209
+ window: formatTokens(view.contextWindow),
210
+ percent: Math.round(view.percent),
211
+ });
212
+ }
213
+ export function formatContextPressureStatusLine(view) {
214
+ if (view === undefined)
215
+ return t('status.contextNone');
216
+ return t('status.contextLine', {
217
+ used: formatTokens(view.usedTokens),
218
+ window: formatTokens(view.contextWindow),
219
+ percent: view.percent.toFixed(1),
220
+ level: t(`status.contextLevel.${view.level}`),
221
+ });
222
+ }
223
+ export function contextPressureAlertText(view) {
224
+ const vars = {
225
+ used: formatTokens(view.usedTokens),
226
+ window: formatTokens(view.contextWindow),
227
+ percent: view.percent.toFixed(0),
228
+ };
229
+ return view.level === 'danger'
230
+ ? t('context.alertDanger', vars)
231
+ : t('context.alertWarn', vars);
232
+ }
233
+ export function shouldIdleAutoCompact(view) {
234
+ if (view === undefined)
235
+ return false;
236
+ return view.usedTokens / view.contextWindow >= CONTEXT_IDLE_COMPACT_RATIO;
237
+ }
127
238
  /** Compact duration, matching the web stats line (45.2s / 2m42s). */
128
239
  export function formatDuration(ms) {
129
240
  const seconds = ms / 1_000;
@@ -368,6 +479,8 @@ export function footerIdentityParts(input) {
368
479
  if (input.quotaPercent !== undefined) {
369
480
  parts.push(formatFooterQuota(input.quotaPercent, input.quotaCode));
370
481
  }
482
+ if (input.contextChip !== undefined && input.contextChip !== '')
483
+ parts.push(input.contextChip);
371
484
  if (input.search !== undefined)
372
485
  parts.push(t('footer.search', { index: input.search.index + 1, total: input.search.total }));
373
486
  if (input.foldedInput)
@@ -611,6 +724,7 @@ export function formatStatusReport(input) {
611
724
  fit.line,
612
725
  `plan: ${input.plan}`,
613
726
  formatQuotaStatusLine(input.quota),
727
+ formatContextPressureStatusLine(input.context),
614
728
  `paint: ${input.paint}`,
615
729
  `disconnect: ${input.disconnect ?? 'pause'}`,
616
730
  input.waitingQuestions > 0 ? `questions: waiting ${input.waitingQuestions}` : 'questions: none',
@@ -653,6 +767,7 @@ const LOCAL_COMMANDS = [
653
767
  { name: 'view', key: 'cmd.view' },
654
768
  { name: 'usage', key: 'cmd.usage' },
655
769
  { name: 'balance', key: 'cmd.usage', aliasOf: 'usage' },
770
+ { name: 'quota', key: 'cmd.usage', aliasOf: 'usage' },
656
771
  { name: 'subagents', key: 'cmd.subagents' },
657
772
  { name: 'resume', key: 'cmd.resume' },
658
773
  { name: 'setup', key: 'cmd.setup' },
@@ -1995,7 +2110,7 @@ function friendlyArgsSummary(name, args) {
1995
2110
  return sliceCodePoints(args, 120);
1996
2111
  const preferred = [
1997
2112
  'path', 'file_path', 'file', 'query', 'pattern', 'url', 'command',
1998
- 'description', 'content', 'file_text', 'old_string', 'new_string',
2113
+ 'name', 'skill', 'description', 'content', 'file_text', 'old_string', 'new_string',
1999
2114
  'old_str', 'new_str', 'insert_line', 'line', 'offset', 'limit',
2000
2115
  ];
2001
2116
  const parts = [];
@@ -2114,16 +2229,35 @@ const READ_TOOL_NAMES = new Set(['read']);
2114
2229
  const TOOL_FLIP_MS = 280;
2115
2230
  export function toolTargetPath(name, args, fallback = '') {
2116
2231
  const parsed = parseJsonArgs(args);
2117
- if (parsed === null)
2118
- return fallback;
2119
2232
  if (READ_TOOL_NAMES.has(name)) {
2233
+ if (parsed === null)
2234
+ return fallback;
2120
2235
  return firstString(parsed, ['path', 'file_path', 'url']) || fallback;
2121
2236
  }
2122
2237
  if (DIFF_TOOL_NAMES.has(name)) {
2238
+ if (parsed === null)
2239
+ return fallback;
2123
2240
  return firstString(parsed, ['file_path', 'path']) || fallback;
2124
2241
  }
2125
2242
  return fallback;
2126
2243
  }
2244
+ /** Path shown on a compact single-file edit summary. */
2245
+ export function compactEditPath(item) {
2246
+ const fromArgs = toolTargetPath(item.name, item.args);
2247
+ if (fromArgs !== '')
2248
+ return fromArgs;
2249
+ const fromDiff = item.diff?.map(hunk => hunk.path ?? '').find(path => path !== '');
2250
+ if (fromDiff !== undefined && fromDiff !== '')
2251
+ return fromDiff;
2252
+ const summary = item.summary?.trim() ?? '';
2253
+ return summary;
2254
+ }
2255
+ function sameToolPath(left, right) {
2256
+ if (left === '' || right === '')
2257
+ return false;
2258
+ const normalize = (value) => value.replaceAll('\\', '/').replace(/\/+$/u, '');
2259
+ return normalize(left) === normalize(right);
2260
+ }
2127
2261
  export function countOutputLines(text) {
2128
2262
  if (text === '')
2129
2263
  return 0;
@@ -2148,8 +2282,8 @@ export function canMergeToolCall(previous, next) {
2148
2282
  if (kind === undefined || mergeableToolKind(previous.name) !== kind)
2149
2283
  return false;
2150
2284
  const previousPath = toolTargetPath(previous.name, previous.args, previous.summary);
2151
- const nextPath = toolTargetPath(next.name, next.args);
2152
- return previousPath !== '' && previousPath === nextPath;
2285
+ const nextPath = toolTargetPath(next.name, next.args, previousPath);
2286
+ return sameToolPath(previousPath, nextPath);
2153
2287
  }
2154
2288
  export function compactToolGroups(tools) {
2155
2289
  const edits = [];
@@ -2196,13 +2330,15 @@ const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
2196
2330
  const HIDDEN_TOOL_NAMES = new Set(['get_goal']);
2197
2331
  const TOOL_TITLE_KEYS = [
2198
2332
  'edit', 'write', 'str_replace_editor', 'fetch', 'list_files', 'list', 'ls',
2199
- 'find', 'search', 'delete', 'rm', 'rename', 'mv', 'mkdir', 'skills',
2333
+ 'find', 'search', 'delete', 'rm', 'rename', 'mv', 'mkdir', 'skills', 'skill',
2200
2334
  'create_goal', 'update_goal', 'complete_goal', 'clear_goal', 'pause_goal',
2201
2335
  'resume_goal', 'todo_write', 'todo', 'compact', 'glob', 'grep', 'read',
2202
2336
  'web_search', 'web_fetch',
2203
2337
  ];
2204
2338
  function toolTitle(name) {
2205
- return t(`toolTitle.${name}`, undefined, name);
2339
+ if (name === '' || name.startsWith('call-'))
2340
+ return t('card.tool');
2341
+ return t(`toolTitle.${name}`, undefined, name === 'tool' ? t('card.tool') : name);
2206
2342
  }
2207
2343
  const MAX_SUBAGENT_LOGS = 80;
2208
2344
  const TODO_STATUS_MARK = {
@@ -2237,10 +2373,7 @@ export function applyTurnEndToPlan(plan) {
2237
2373
  export function planCloseNudgeText(plan) {
2238
2374
  const leftover = plan.todos.filter(item => item.status !== 'completed');
2239
2375
  const lines = leftover.map(item => `- [${item.status}] ${item.content}`);
2240
- return [
2241
- '本轮结束时计划条还有未完成待办。请立刻再调用一次 todo_write,把已经做完的标成 completed,还没做的留 pending。不要开新任务。',
2242
- ...lines,
2243
- ].join('\n');
2376
+ return [t('plan.nudge'), ...lines].join('\n');
2244
2377
  }
2245
2378
  /** Category for jump / search. Assistant replies are not collapsible cards. */
2246
2379
  export function cardCategoryOf(row) {
@@ -2386,6 +2519,32 @@ export function isPromptInjectionMessage(sourceKind, text, plugin) {
2386
2519
  || SYSTEM_PRESET_HINT.test(text)
2387
2520
  || promptInjectionSources(text, plugin).some(id => id !== SYSTEM_PRESET_LABEL());
2388
2521
  }
2522
+ /** Official `/compact` idle-only failures, mapped to a local sentence. */
2523
+ export function formatCompactCommandError(text) {
2524
+ const raw = text.trim();
2525
+ if (raw === '')
2526
+ return t('command.failed');
2527
+ if (raw.includes('agent is not idle')
2528
+ || raw.includes('active compaction')
2529
+ || raw.includes('requires an idle agent')) {
2530
+ return t('compact.busy');
2531
+ }
2532
+ if (raw.includes('No compactable history'))
2533
+ return t('compact.nothing');
2534
+ if (raw.startsWith('Usage: /compact'))
2535
+ return t('compact.usage');
2536
+ if (raw === 'Compaction cancelled.')
2537
+ return t('compact.cancelled');
2538
+ if (raw.includes('could not produce a useful summary'))
2539
+ return t('compact.noSummary');
2540
+ if (raw.includes('history selected for compaction changed'))
2541
+ return t('compact.changed');
2542
+ if (raw.includes('did not finish cleanly'))
2543
+ return t('compact.commit');
2544
+ if (raw.includes('could not be saved'))
2545
+ return t('compact.persistence');
2546
+ return raw;
2547
+ }
2389
2548
  export function compactionHeaderText(row) {
2390
2549
  const recovered = row.prunedTokens > 0
2391
2550
  ? t('compact.recoverTokens', { tokens: formatTokens(row.prunedTokens) })
@@ -2637,6 +2796,13 @@ export function presentToolCall(name, args) {
2637
2796
  if (name === 'get_goal') {
2638
2797
  return { title: toolTitle('get_goal'), summary: friendlyArgsSummary(name, args) };
2639
2798
  }
2799
+ if (name === 'skill' || name === 'skills') {
2800
+ const skill = typeof parsed?.name === 'string' ? parsed.name.trim()
2801
+ : typeof parsed?.skill === 'string' ? parsed.skill.trim()
2802
+ : typeof parsed?.id === 'string' ? parsed.id.trim()
2803
+ : '';
2804
+ return { title: toolTitle('skill'), summary: skill || friendlyArgsSummary(name, args) };
2805
+ }
2640
2806
  if (name === 'read') {
2641
2807
  const path = typeof parsed?.path === 'string' ? parsed.path
2642
2808
  : typeof parsed?.file_path === 'string' ? parsed.file_path
@@ -3157,6 +3323,8 @@ export class SshTui {
3157
3323
  activeSubagents = new Map();
3158
3324
  subagentSessions = new Set();
3159
3325
  openToolCalls = new Map();
3326
+ /** Survives result settlement so a card-less result can still be labelled. */
3327
+ toolCallNames = new Map();
3160
3328
  stats = {
3161
3329
  turns: 0,
3162
3330
  steps: 0,
@@ -3204,6 +3372,10 @@ export class SshTui {
3204
3372
  quotaAlerted = new Set();
3205
3373
  quotaStepsSinceRefresh = 0;
3206
3374
  quotaRefreshInFlight = false;
3375
+ contextPressure;
3376
+ contextAlertLevel;
3377
+ idleCompactInFlight = false;
3378
+ lastIdleCompactAt = 0;
3207
3379
  searchHits = [];
3208
3380
  searchIndex = -1;
3209
3381
  searchQuery = '';
@@ -3469,10 +3641,15 @@ export class SshTui {
3469
3641
  addDel.del += stat.del;
3470
3642
  }
3471
3643
  }
3644
+ const singleEditPath = kind === 'edits' && groups.edits.length === 1
3645
+ ? compactEditPath(groups.edits[0])
3646
+ : '';
3472
3647
  const title = kind === 'edits'
3473
3648
  ? (groups.edits.length > 1
3474
3649
  ? t('compact.editsFiles', { files: groups.edits.length })
3475
- : t('compact.edits'))
3650
+ : singleEditPath === ''
3651
+ ? t('compact.edits')
3652
+ : t('compact.editsFile', { path: singleEditPath }))
3476
3653
  : (groups.failedCalls > 0
3477
3654
  ? t('compact.toolsFailed', { count: groups.calls.length, failed: groups.failedCalls })
3478
3655
  : t('compact.tools', { count: groups.calls.length }));
@@ -3586,6 +3763,7 @@ export class SshTui {
3586
3763
  this.streamingReasoning = undefined;
3587
3764
  this.thinkingStartedAt = undefined;
3588
3765
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
3766
+ this.refreshContextPressure({ compact: false });
3589
3767
  this.dirty = true;
3590
3768
  }
3591
3769
  /** Show the first-launch provider/API-key onboarding when nothing is configured. */
@@ -4053,11 +4231,24 @@ export class SshTui {
4053
4231
  return parts.join(' · ');
4054
4232
  }
4055
4233
  mergeIntoToolCard(previous, next) {
4234
+ const ids = previous.mergedCallIds ?? [previous.callId];
4235
+ if (!ids.includes(previous.callId))
4236
+ ids.push(previous.callId);
4237
+ if (!ids.includes(next.callId))
4238
+ ids.push(next.callId);
4239
+ previous.mergedCallIds = ids;
4056
4240
  previous.callId = next.callId;
4057
4241
  previous.name = next.name;
4058
4242
  previous.args = next.args;
4059
- previous.title = next.title;
4060
- previous.summary = next.summary;
4243
+ if (next.title !== '')
4244
+ previous.title = next.title;
4245
+ if (next.summary !== '')
4246
+ previous.summary = next.summary;
4247
+ if (next.diff !== undefined && next.diff.length > 0) {
4248
+ previous.diff = (previous.repeats ?? 1) > 1 && previous.diff !== undefined && previous.diff.length > 0
4249
+ ? [...previous.diff, ...next.diff]
4250
+ : next.diff;
4251
+ }
4061
4252
  previous.status = 'running';
4062
4253
  previous.output = '';
4063
4254
  previous.exitCode = undefined;
@@ -4066,6 +4257,14 @@ export class SshTui {
4066
4257
  if (!this.replaying)
4067
4258
  previous.flipUntil = Date.now() + TOOL_FLIP_MS;
4068
4259
  }
4260
+ findToolRowByCallId(callId) {
4261
+ return this.rows.findLast((candidate) => candidate.kind === 'tool'
4262
+ && (candidate.callId === callId || candidate.mergedCallIds?.includes(callId) === true));
4263
+ }
4264
+ findMergeableToolRow(next) {
4265
+ const previous = this.rows.findLast((candidate) => candidate.kind === 'tool');
4266
+ return canMergeToolCall(previous, next) ? previous : undefined;
4267
+ }
4069
4268
  /** Append one transcript row, bounding memory on long sessions. */
4070
4269
  pushRow(row) {
4071
4270
  this.rows.push(row);
@@ -4224,15 +4423,18 @@ export class SshTui {
4224
4423
  shouldDockPlan() {
4225
4424
  return this.findLivePlanRow() !== undefined;
4226
4425
  }
4227
- /** One follow-up per leftover list; replay and cancelled turns stay quiet. */
4228
- queuePlanCloseNudge(plan) {
4426
+ /** Send the leftover-todo nudge only from true idle, so /compact is not blocked. */
4427
+ flushPlanCloseNudge() {
4229
4428
  if (this.replaying || this.agentGone || this.planNudgePending)
4230
4429
  return;
4231
4430
  if (this.agent.status === 'running')
4232
4431
  return;
4432
+ const plan = this.findLivePlanRow();
4433
+ if (plan === undefined || plan.turnLeftOpen !== true)
4434
+ return;
4233
4435
  this.planNudgePending = true;
4234
4436
  const text = planCloseNudgeText(plan);
4235
- this.pushRow({ kind: 'system', text: '已请模型补一次待办状态(本轮只问一次)。' });
4437
+ this.pushRow({ kind: 'system', text: t('plan.nudgeQueued') });
4236
4438
  const message = createUserMessage({
4237
4439
  content: [{ type: 'text', text }],
4238
4440
  source: { kind: 'user' },
@@ -4242,7 +4444,7 @@ export class SshTui {
4242
4444
  }
4243
4445
  catch (error) {
4244
4446
  this.planNudgePending = false;
4245
- this.pushRow({ kind: 'error', text: `补待办状态失败:${errorChain(error)}` });
4447
+ this.pushRow({ kind: 'error', text: t('plan.nudgeFailed', { error: errorChain(error) }) });
4246
4448
  }
4247
4449
  }
4248
4450
  /** Compact web-style plan strip pinned above the input, not in the transcript. */
@@ -4661,7 +4863,7 @@ export class SshTui {
4661
4863
  const header = buildToolHeader({
4662
4864
  focused,
4663
4865
  expanded: row.expanded,
4664
- title: toolTitle(row.name) || row.title,
4866
+ title: toolTitle(row.name),
4665
4867
  summary: this.toolCardSummary(row),
4666
4868
  status: row.status,
4667
4869
  command: row.command,
@@ -5207,6 +5409,9 @@ export class SshTui {
5207
5409
  ...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
5208
5410
  ? {}
5209
5411
  : { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
5412
+ ...(this.contextPressure === undefined
5413
+ ? {}
5414
+ : { contextChip: formatContextPressureChip(this.contextPressure, false) }),
5210
5415
  ...(balanceText === undefined ? {} : { balanceText }),
5211
5416
  ...(this.searchHits.length > 0 && this.searchIndex >= 0
5212
5417
  ? { search: { index: this.searchIndex, total: this.searchHits.length } }
@@ -5225,7 +5430,9 @@ export class SshTui {
5225
5430
  : activity.text;
5226
5431
  const identity = footerIdentityParts(footer);
5227
5432
  const statusText = fitFooterStatusLine(activityText, identity, Math.max(1, width));
5228
- const statusLine = this.styleLine('system', statusText);
5433
+ const statusLine = this.contextPressure === undefined || !this.color
5434
+ ? this.styleLine('system', statusText)
5435
+ : this.styleLine('system', statusText).replace(formatContextPressureRing(this.contextPressure.percent), `\x1b[${contextPressureRingColor(this.contextPressure.level)}m${formatContextPressureRing(this.contextPressure.percent)}\x1b[0m\x1b[90m`);
5229
5436
  const paintRows = [
5230
5437
  ...headerLines,
5231
5438
  ...visible,
@@ -5512,6 +5719,7 @@ export class SshTui {
5512
5719
  return;
5513
5720
  }
5514
5721
  this.lastActivity = Date.now();
5722
+ this.refreshContextPressure();
5515
5723
  switch (event.type) {
5516
5724
  case 'user/message': {
5517
5725
  const text = event.data.content
@@ -5617,13 +5825,14 @@ export class SshTui {
5617
5825
  }
5618
5826
  case 'tool/call': {
5619
5827
  this.openToolCalls.set(String(event.data.callId), event.data.name);
5828
+ this.toolCallNames.set(String(event.data.callId), event.data.name);
5620
5829
  this.pendingToolTimes.set(String(event.data.callId), event.time);
5621
5830
  if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
5622
5831
  const present = presentToolCall(event.data.name, event.data.arguments);
5623
- const previous = this.rows.findLast((candidate) => candidate.kind === 'tool');
5624
- if (canMergeToolCall(previous, { name: event.data.name, args: event.data.arguments })) {
5832
+ const previous = this.findMergeableToolRow({ name: event.data.name, args: event.data.arguments });
5833
+ if (previous !== undefined) {
5625
5834
  this.mergeIntoToolCard(previous, {
5626
- callId: event.data.callId,
5835
+ callId: String(event.data.callId),
5627
5836
  name: event.data.name,
5628
5837
  args: event.data.arguments,
5629
5838
  title: present.title,
@@ -5634,7 +5843,7 @@ export class SshTui {
5634
5843
  else {
5635
5844
  const row = {
5636
5845
  kind: 'tool',
5637
- callId: event.data.callId,
5846
+ callId: String(event.data.callId),
5638
5847
  name: event.data.name,
5639
5848
  args: event.data.arguments,
5640
5849
  status: 'running',
@@ -5665,7 +5874,8 @@ export class SshTui {
5665
5874
  this.stats.toolMs += Math.max(0, event.time - dispatchedAt);
5666
5875
  this.pendingToolTimes.delete(String(event.data.message.source.callId));
5667
5876
  }
5668
- const row = this.rows.findLast((candidate) => candidate.kind === 'tool' && candidate.callId === event.data.message.source.callId);
5877
+ const callId = String(event.data.message.source.callId);
5878
+ const row = this.findToolRowByCallId(callId);
5669
5879
  const output = collectText(event.data.message.content);
5670
5880
  if (row !== undefined) {
5671
5881
  const metaDiffs = diffMetaDiffs(event.data.meta);
@@ -5696,11 +5906,21 @@ export class SshTui {
5696
5906
  }
5697
5907
  }
5698
5908
  else {
5699
- const present = presentToolCall(event.data.message.source.callId, '');
5909
+ const sourceName = event.data.message.source.name;
5910
+ const recordedName = this.toolCallNames.get(callId)
5911
+ ?? (typeof sourceName === 'string' ? sourceName : '');
5912
+ if (recordedName !== '' && HIDDEN_TOOL_NAMES.has(recordedName))
5913
+ break;
5914
+ // Never title a card with the call id (`call-<uuid>`). Prefer the
5915
+ // recorded tool name; fall back to a generic tool card.
5916
+ const toolName = recordedName === '' || recordedName.startsWith('call-')
5917
+ ? 'tool'
5918
+ : recordedName;
5919
+ const present = presentToolCall(toolName, '');
5700
5920
  this.pushRow({
5701
5921
  kind: 'tool',
5702
- callId: event.data.message.source.callId,
5703
- name: event.data.message.source.callId,
5922
+ callId,
5923
+ name: toolName,
5704
5924
  args: '',
5705
5925
  status: event.data.error === undefined ? 'ok' : 'error',
5706
5926
  output,
@@ -5762,6 +5982,7 @@ export class SshTui {
5762
5982
  case 'turn/end': {
5763
5983
  const reason = event.data.reason;
5764
5984
  this.openToolCalls.clear();
5985
+ this.toolCallNames.clear();
5765
5986
  this.pendingToolTimes.clear();
5766
5987
  this.stalledWarningShown = false;
5767
5988
  this.pendingMessages.clear();
@@ -5793,7 +6014,9 @@ export class SshTui {
5793
6014
  kind: 'system',
5794
6015
  text: planDockNote(livePlan),
5795
6016
  });
5796
- this.queuePlanCloseNudge(livePlan);
6017
+ // Driver is still `running` while `turn/end` is appended. Wait for
6018
+ // idle so this follow-up does not make `/compact` report busy.
6019
+ queueMicrotask(() => this.flushPlanCloseNudge());
5797
6020
  }
5798
6021
  }
5799
6022
  this.markDirty();
@@ -5824,6 +6047,10 @@ export class SshTui {
5824
6047
  if (status !== 'running')
5825
6048
  this.endWait();
5826
6049
  this.status = status === 'running' ? 'running' : 'idle';
6050
+ if (status !== 'running') {
6051
+ this.flushPlanCloseNudge();
6052
+ this.maybeIdleAutoCompact();
6053
+ }
5827
6054
  this.markDirty();
5828
6055
  };
5829
6056
  handleError = ({ agent, error }) => {
@@ -5938,7 +6165,7 @@ export class SshTui {
5938
6165
  if (named !== undefined)
5939
6166
  return named;
5940
6167
  }
5941
- return this.rows.findLast((row) => row.kind === 'compaction' && row.status === 'running');
6168
+ return undefined;
5942
6169
  }
5943
6170
  handleCompactionEvent(type, event) {
5944
6171
  const payload = event.data;
@@ -5961,11 +6188,13 @@ export class SshTui {
5961
6188
  const row = this.findCompactionRow(compactionId);
5962
6189
  if (type === 'compaction/prune') {
5963
6190
  const tokens = typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : 0;
6191
+ // Automatic tool-result prunes have no compactionId. Never attach them
6192
+ // to a leftover /compact card — that made the next /compact look failed.
5964
6193
  if (row !== undefined) {
5965
6194
  row.pruneCount += 1;
5966
6195
  row.prunedTokens += Math.max(0, tokens);
6196
+ this.markDirty();
5967
6197
  }
5968
- this.markDirty();
5969
6198
  return;
5970
6199
  }
5971
6200
  if (type === 'compaction/summary') {
@@ -5997,10 +6226,145 @@ export class SshTui {
5997
6226
  text: error === undefined ? '上下文压缩已完成。' : `上下文压缩失败:${error}`,
5998
6227
  });
5999
6228
  }
6000
- if (this.status.startsWith('压缩'))
6229
+ if (this.status.startsWith('压缩') || this.status.startsWith('compact')) {
6001
6230
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
6231
+ }
6232
+ this.idleCompactInFlight = false;
6233
+ this.markDirty();
6234
+ this.refreshContextPressure();
6235
+ }
6236
+ }
6237
+ readContextPressure() {
6238
+ const projections = this.ctx.get('sessionProjections');
6239
+ const fromProjection = parseContextPressure(projections?.snapshot?.(this.agent.session)?.values?.contextPressure);
6240
+ if (fromProjection !== undefined)
6241
+ return contextPressureView(fromProjection);
6242
+ const requestContext = this.agent.session.requestContext?.();
6243
+ const window = typeof requestContext?.contextWindow === 'number' && requestContext.contextWindow > 0
6244
+ ? requestContext.contextWindow
6245
+ : undefined;
6246
+ if (window === undefined)
6247
+ return undefined;
6248
+ const used = promptPressureTokens(this.stats.usage);
6249
+ if (used <= 0)
6250
+ return undefined;
6251
+ return contextPressureView({ usedTokens: used, contextWindow: window });
6252
+ }
6253
+ refreshContextPressure(options = {}) {
6254
+ const next = this.readContextPressure();
6255
+ const previous = this.contextPressure;
6256
+ this.contextPressure = next;
6257
+ if (this.replaying) {
6258
+ this.contextAlertLevel = next?.level === 'ok' ? undefined : next?.level;
6259
+ return;
6260
+ }
6261
+ if (next !== undefined && next.level !== 'ok' && next.level !== this.contextAlertLevel) {
6262
+ this.contextAlertLevel = next.level;
6263
+ this.pushRow({ kind: 'system', text: contextPressureAlertText(next) });
6264
+ }
6265
+ else if (next === undefined || next.level === 'ok') {
6266
+ this.contextAlertLevel = undefined;
6267
+ }
6268
+ if (previous?.usedTokens !== next?.usedTokens
6269
+ || previous?.contextWindow !== next?.contextWindow
6270
+ || previous?.level !== next?.level) {
6002
6271
  this.markDirty();
6003
6272
  }
6273
+ if (options.compact !== false && this.agent.status !== 'running')
6274
+ this.maybeIdleAutoCompact();
6275
+ }
6276
+ canRunCompactCommand() {
6277
+ if (this.replaying || this.agentGone || this.exiting)
6278
+ return false;
6279
+ if (this.agent.status === 'running')
6280
+ return false;
6281
+ if (this.idleCompactInFlight)
6282
+ return false;
6283
+ if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
6284
+ return false;
6285
+ return this.ctx.get('commands') !== undefined;
6286
+ }
6287
+ maybeIdleAutoCompact() {
6288
+ if (!this.canRunCompactCommand())
6289
+ return;
6290
+ if (!shouldIdleAutoCompact(this.contextPressure))
6291
+ return;
6292
+ if (Date.now() - this.lastIdleCompactAt < 8_000)
6293
+ return;
6294
+ this.dispatchCompactCommand('idle');
6295
+ }
6296
+ dispatchCompactCommand(reason) {
6297
+ const commands = this.ctx.get('commands');
6298
+ if (commands?.execute === undefined) {
6299
+ if (reason === 'user')
6300
+ this.pushRow({ kind: 'error', text: `Unknown command: /compact (try /help)` });
6301
+ return;
6302
+ }
6303
+ if (this.agent.status === 'running'
6304
+ || this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
6305
+ if (reason === 'user')
6306
+ this.pushRow({ kind: 'error', text: t('compact.busy') });
6307
+ this.markDirty();
6308
+ return;
6309
+ }
6310
+ this.idleCompactInFlight = true;
6311
+ this.lastIdleCompactAt = Date.now();
6312
+ if (reason === 'idle') {
6313
+ const view = this.contextPressure;
6314
+ this.pushRow({
6315
+ kind: 'system',
6316
+ text: view === undefined
6317
+ ? t('context.autoCompact')
6318
+ : t('context.autoCompactAt', {
6319
+ used: formatTokens(view.usedTokens),
6320
+ window: formatTokens(view.contextWindow),
6321
+ percent: view.percent.toFixed(0),
6322
+ }),
6323
+ });
6324
+ }
6325
+ this.commandAbort?.abort();
6326
+ const controller = new AbortController();
6327
+ this.commandAbort = controller;
6328
+ void commands.execute(this.agent, '/compact', [], controller.signal).then((execution) => {
6329
+ if (execution === undefined) {
6330
+ this.idleCompactInFlight = false;
6331
+ if (reason === 'user')
6332
+ this.pushRow({ kind: 'error', text: `Unknown command: /compact (try /help)` });
6333
+ return;
6334
+ }
6335
+ const compactionRunning = () => this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
6336
+ const releaseIfSettled = () => {
6337
+ queueMicrotask(() => {
6338
+ if (!compactionRunning())
6339
+ this.idleCompactInFlight = false;
6340
+ });
6341
+ };
6342
+ if (this.seenCommandDoneIds.has(String(execution.commandId))) {
6343
+ releaseIfSettled();
6344
+ return;
6345
+ }
6346
+ if (execution.result?.kind === 'error') {
6347
+ this.idleCompactInFlight = false;
6348
+ this.pushRow({
6349
+ kind: 'error',
6350
+ text: formatCompactCommandError(this.formatCommandText(String(execution.result.text ?? ''))),
6351
+ });
6352
+ }
6353
+ else if (typeof execution.result?.text === 'string' && execution.result.text !== '') {
6354
+ this.pushRow({ kind: 'system', text: this.formatCommandText(execution.result.text) });
6355
+ releaseIfSettled();
6356
+ }
6357
+ else {
6358
+ releaseIfSettled();
6359
+ }
6360
+ }).catch((error) => {
6361
+ this.idleCompactInFlight = false;
6362
+ this.pushRow({ kind: 'error', text: `/compact failed: ${errorChain(error)}` });
6363
+ }).finally(() => {
6364
+ if (this.commandAbort === controller)
6365
+ this.commandAbort = undefined;
6366
+ this.markDirty();
6367
+ });
6004
6368
  }
6005
6369
  handleCommandRun(data) {
6006
6370
  const name = String(data?.name ?? '').trim();
@@ -6064,10 +6428,14 @@ export class SshTui {
6064
6428
  const kind = typeof payload.kind === 'string' ? payload.kind : '';
6065
6429
  const text = typeof payload.text === 'string' ? payload.text.trim() : '';
6066
6430
  if (kind === 'error') {
6067
- const errText = this.formatCommandText(text);
6431
+ const errText = formatCompactCommandError(this.formatCommandText(text));
6068
6432
  this.pushRow({ kind: 'error', text: errText === '' ? t('command.failed') : errText });
6069
- if (this.status.startsWith('压缩'))
6433
+ if (this.status.startsWith('压缩') || this.status.startsWith('compact')) {
6070
6434
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
6435
+ }
6436
+ if (!this.rows.some(row => row.kind === 'compaction' && row.status === 'running')) {
6437
+ this.idleCompactInFlight = false;
6438
+ }
6071
6439
  this.markDirty();
6072
6440
  return;
6073
6441
  }
@@ -6366,19 +6734,38 @@ export class SshTui {
6366
6734
  source: { kind: 'plugin', plugin: 'dsh-ssh-tui' },
6367
6735
  })],
6368
6736
  system: REVIEW_SYSTEM_PROMPT,
6369
- maxTokens: 200,
6370
- sessionId: this.agent.session.id,
6737
+ maxTokens: 400,
6738
+ reasoningEffort: ReasoningEffortId('off'),
6371
6739
  signal,
6372
6740
  };
6373
6741
  this.aiReviewCount += 1;
6374
6742
  let text = '';
6375
- for await (const chunk of llm.stream(options)) {
6376
- if (chunk.type === 'text-delta')
6377
- text += chunk.text;
6743
+ try {
6744
+ for await (const chunk of llm.stream(options)) {
6745
+ // Classifier reads the final assistant reply only. Reasoning/thinking
6746
+ // is ignored even when it happens to contain JSON.
6747
+ if (chunk.type === 'text-delta')
6748
+ text += chunk.text;
6749
+ }
6750
+ }
6751
+ catch (error) {
6752
+ this.pushRow({
6753
+ kind: 'system',
6754
+ text: t('approval.reviewFailed', { error: errorChain(error) }),
6755
+ });
6756
+ this.markDirty();
6757
+ return undefined;
6378
6758
  }
6379
6759
  const verdict = parseReviewOutput(text);
6380
- if (verdict === undefined)
6760
+ if (verdict === undefined) {
6761
+ const preview = text.trim() === '' ? t('approval.reviewNoReply') : text.trim();
6762
+ this.pushRow({
6763
+ kind: 'system',
6764
+ text: t('approval.reviewUnparsed', { output: preview.slice(0, 160) }),
6765
+ });
6766
+ this.markDirty();
6381
6767
  return undefined;
6768
+ }
6382
6769
  this.pushRow({
6383
6770
  kind: 'system',
6384
6771
  text: t('approval.reviewRow', {
@@ -6391,6 +6778,28 @@ export class SshTui {
6391
6778
  this.markDirty();
6392
6779
  return verdict.approved ? 'allow' : 'deny';
6393
6780
  }
6781
+ recordAutoApproval(decision, risk, toolName, command, reason) {
6782
+ if (decision === 'allow')
6783
+ this.autoAllowedCount += 1;
6784
+ else
6785
+ this.autoDeniedCount += 1;
6786
+ const subject = (command ?? '').trim() !== ''
6787
+ ? command.replace(/\s+/gu, ' ').trim()
6788
+ : toolName;
6789
+ const clipped = Array.from(subject).length > 160
6790
+ ? `${Array.from(subject).slice(0, 160).join('')}…`
6791
+ : subject;
6792
+ this.pushRow({
6793
+ kind: 'system',
6794
+ text: t('approval.decisionRow', {
6795
+ verdict: decision === 'allow' ? t('approval.reviewApproved') : t('approval.reviewRejected'),
6796
+ command: clipped,
6797
+ risk,
6798
+ reason,
6799
+ }),
6800
+ });
6801
+ this.markDirty();
6802
+ }
6394
6803
  handleApproval = async (request, _next) => {
6395
6804
  // Auto mode classifies BEFORE waiting for a display, Codex-style: allow
6396
6805
  // shapes approve, danger shapes REJECT (the model reads the rejection and
@@ -6400,15 +6809,19 @@ export class SshTui {
6400
6809
  if (this.autoApprovalMode === 'auto') {
6401
6810
  const row = request.callId === undefined
6402
6811
  ? undefined
6403
- : this.rows.findLast((candidate) => candidate.kind === 'tool' && candidate.callId === request.callId);
6404
- const command = row === undefined ? undefined : commandFromArgs(row.name, row.args);
6812
+ : this.findToolRowByCallId(String(request.callId));
6813
+ const command = commandForApprovalRequest({
6814
+ toolName: request.toolName,
6815
+ ...(request.reason === undefined ? {} : { reason: request.reason }),
6816
+ ...(row === undefined ? {} : { row: { name: row.name, args: row.args, ...(row.command === undefined ? {} : { command: row.command }) } }),
6817
+ });
6405
6818
  const decision = classifyApproval(request.toolName, command);
6406
6819
  if (decision === 'allow') {
6407
- this.autoAllowedCount += 1;
6820
+ this.recordAutoApproval('allow', 'low', request.toolName, command, t('approval.ruleAllow'));
6408
6821
  return 'allowed-once';
6409
6822
  }
6410
6823
  if (decision === 'deny') {
6411
- this.autoDeniedCount += 1;
6824
+ this.recordAutoApproval('deny', 'high', request.toolName, command, t('approval.ruleDeny'));
6412
6825
  return 'rejected';
6413
6826
  }
6414
6827
  // Unknown shape: the rule table cannot judge it — hand it to the
@@ -6420,10 +6833,14 @@ export class SshTui {
6420
6833
  this.autoAllowedCount += 1;
6421
6834
  return 'allowed-once';
6422
6835
  }
6423
- if (reviewed === 'deny' || !this.hasLiveDisplay()) {
6836
+ if (reviewed === 'deny') {
6424
6837
  this.autoDeniedCount += 1;
6425
6838
  return 'rejected';
6426
6839
  }
6840
+ if (!this.hasLiveDisplay()) {
6841
+ this.recordAutoApproval('deny', 'medium', request.toolName, command, t('approval.ruleDetached'));
6842
+ return 'rejected';
6843
+ }
6427
6844
  }
6428
6845
  if (!this.hasLiveDisplay()) {
6429
6846
  try {
@@ -7120,6 +7537,20 @@ export class SshTui {
7120
7537
  const provider = this.effectiveSubagentProvider();
7121
7538
  const current = this.subagentSelection.current;
7122
7539
  const direct = arg.trim();
7540
+ if (direct.toLowerCase() === 'reset' || direct === '跟随' || direct === '默认') {
7541
+ const parentProvider = this.currentProviderId();
7542
+ const nextModel = defaultSubagentModelForProvider(parentProvider, [], this.selectionRef?.current?.model ?? this.agent.options.model);
7543
+ const persisted = await this.saveSubagentSelection({
7544
+ model: nextModel,
7545
+ reasoningEffort: undefined,
7546
+ });
7547
+ this.pushRow({
7548
+ kind: 'system',
7549
+ text: `子代理已恢复跟随父会话 ${parentProvider},模型改为 ${nextModel}${persisted ? '' : '(仅当前会话)'}。`,
7550
+ });
7551
+ this.markDirty();
7552
+ return;
7553
+ }
7123
7554
  let selectedId = direct;
7124
7555
  if (selectedId === '') {
7125
7556
  const { options, source } = await this.subagentModelOptions(provider);
@@ -7410,7 +7841,7 @@ export class SshTui {
7410
7841
  this.markDirty();
7411
7842
  }
7412
7843
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
7413
- async runModeCommand() {
7844
+ async runModeCommand(arg = '') {
7414
7845
  const agentPresets = this.ctx.get('agentPresets');
7415
7846
  if (agentPresets === undefined) {
7416
7847
  this.pushRow({ kind: 'error', text: 'agentPresets 服务不可用。' });
@@ -7423,15 +7854,30 @@ export class SshTui {
7423
7854
  this.markDirty();
7424
7855
  return;
7425
7856
  }
7426
- const answer = await this.askQuestion({
7427
- id: 'mode-pick',
7428
- question: '选择模式',
7429
- options: presets.map(preset => ({
7430
- label: preset.name ?? preset.id,
7431
- description: `${preset.id === this.presetId ? '当前 · ' : ''}${preset.description ?? ''}`.trim(),
7432
- })),
7433
- });
7434
- const selected = presets.find(preset => (preset.name ?? preset.id) === answer.selected[0]);
7857
+ const direct = arg.trim().toLowerCase();
7858
+ let selected = direct === ''
7859
+ ? undefined
7860
+ : presets.find(preset => preset.id.toLowerCase() === direct
7861
+ || (preset.name ?? '').toLowerCase() === direct);
7862
+ if (selected === undefined && direct !== '') {
7863
+ this.pushRow({
7864
+ kind: 'error',
7865
+ text: t('mode.unknown', { id: arg.trim(), available: presets.map(preset => preset.id).join(', ') }),
7866
+ });
7867
+ this.markDirty();
7868
+ return;
7869
+ }
7870
+ if (selected === undefined) {
7871
+ const answer = await this.askQuestion({
7872
+ id: 'mode-pick',
7873
+ question: '选择模式',
7874
+ options: presets.map(preset => ({
7875
+ label: preset.name ?? preset.id,
7876
+ description: `${preset.id === this.presetId ? '当前 · ' : ''}${preset.description ?? ''}`.trim(),
7877
+ })),
7878
+ });
7879
+ selected = presets.find(preset => (preset.name ?? preset.id) === answer.selected[0]);
7880
+ }
7435
7881
  if (selected === undefined)
7436
7882
  return;
7437
7883
  const selectedName = selected.name ?? selected.id;
@@ -8948,7 +9394,7 @@ export class SshTui {
8948
9394
  });
8949
9395
  break;
8950
9396
  case 'mode':
8951
- void this.runModeCommand().catch((error) => {
9397
+ void this.runModeCommand(arg).catch((error) => {
8952
9398
  if (error instanceof UserQuestionError) {
8953
9399
  this.pushRow({ kind: 'system', text: t('help.modeCancel') });
8954
9400
  }
@@ -9034,6 +9480,7 @@ export class SshTui {
9034
9480
  disconnect: this.disconnectPolicy,
9035
9481
  waitingQuestions: waiting,
9036
9482
  ...(quota === undefined ? {} : { quota }),
9483
+ ...(this.contextPressure === undefined ? {} : { context: this.contextPressure }),
9037
9484
  parentModel: model,
9038
9485
  ...(sub.provider === undefined ? {} : { subProvider: sub.provider }),
9039
9486
  subModel: sub.model,
@@ -9103,8 +9550,8 @@ export class SshTui {
9103
9550
  });
9104
9551
  break;
9105
9552
  case 'approval': {
9106
- const requested = arg === '' ? 'toggle' : arg;
9107
- if (requested === 'status') {
9553
+ const requested = arg.trim() === '' ? 'toggle' : arg.trim();
9554
+ if (isApprovalStatusArg(requested)) {
9108
9555
  this.pushRow({
9109
9556
  kind: 'system',
9110
9557
  text: this.autoApprovalMode === 'auto'
@@ -9118,7 +9565,7 @@ export class SshTui {
9118
9565
  ? this.autoApprovalMode === 'auto' ? 'off' : 'auto'
9119
9566
  : parseAutoApprovalMode(requested);
9120
9567
  if (next === undefined) {
9121
- this.pushRow({ kind: 'error', text: t('approval.unknown', { arg }) });
9568
+ this.pushRow({ kind: 'error', text: t('approval.unknown', { arg: requested }) });
9122
9569
  this.markDirty();
9123
9570
  break;
9124
9571
  }
@@ -9168,6 +9615,10 @@ export class SshTui {
9168
9615
  this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
9169
9616
  break;
9170
9617
  }
9618
+ if (command === 'compact') {
9619
+ this.dispatchCompactCommand('user');
9620
+ break;
9621
+ }
9171
9622
  this.commandAbort?.abort();
9172
9623
  const controller = new AbortController();
9173
9624
  this.commandAbort = controller;
@@ -9182,7 +9633,7 @@ export class SshTui {
9182
9633
  if (this.seenCommandDoneIds.has(String(execution.commandId)))
9183
9634
  return;
9184
9635
  if (execution.result.kind === 'error') {
9185
- this.pushRow({ kind: 'error', text: this.formatCommandText(execution.result.text) });
9636
+ this.pushRow({ kind: 'error', text: formatCompactCommandError(this.formatCommandText(execution.result.text)) });
9186
9637
  }
9187
9638
  else if (execution.result.text !== undefined && execution.result.text !== '') {
9188
9639
  this.pushRow({ kind: 'system', text: this.formatCommandText(execution.result.text) });