dsh-ssh-tui 0.5.4 → 0.5.5

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
@@ -25,7 +25,7 @@ import { StringDecoder } from 'node:string_decoder';
25
25
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
26
26
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
27
27
  import { SessionId } from '@deepseek-ai/dsh-session';
28
- import { sessionEvents, settingsNamespace } from './dsh-compat.js';
28
+ import { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, listenHostEvent, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, } from './dsh-compat.js';
29
29
  import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
30
30
  import { buildReviewUserMessage, parseReviewOutput, reviewSystemPrompt } from './approval-reviewer.js';
31
31
  import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
@@ -38,17 +38,19 @@ import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRem
38
38
  import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
39
39
  import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
40
40
  import { resolveFreshSuperGrokToken } from './supergrok-token.js';
41
+ import { copyTextFromTranscript } from './copy-text.js';
41
42
  import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
42
- import { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
43
+ import { clipAnsiToWidth, hrefAtColumn, osc52Clipboard, paintedLinkHits, cursorVisualPosition, displayWidth, foldInputView, fmtElapsedCompact, lastCodePoints, padToWidth, sliceCodePoints, paintSegmentedLine, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, shimmerText, truncate, truncateToWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
43
44
  import { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, } from './paint.js';
44
45
  import { contextPressureAlertText, contextPressureRingColor, contextPressureView, formatContextPressureRing, describeProviderRoute, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatStatusReport, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
45
46
  import { crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
46
47
  import { appendSubagentLog, applyTurnEndToPlan, cardCategoryLabel, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoItemKind, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
47
48
  import { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, formatModelList, HIDDEN_TOOL_NAMES, parseExitStatus, planReviewOf, presentToolCall, READ_TOOL_NAMES, SHELL_TOOL_NAMES, toolBodyFitsWorkspace, toolBodyLines, toolTitle, TOOL_FLIP_MS, wrappedToolBodyLineCount, } from './tool-present.js';
48
- export { clipAnsiToWidth, displayWidth, foldInputView, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
49
- export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, } from './paint.js';
49
+ export { clipAnsiToWidth, cursorVisualPosition, displayWidth, foldInputView, hrefAtColumn, osc52Clipboard, osc8Enabled, paintedLinkHits, fmtElapsedCompact, padAnsiToWidth, padToWidth, renderMarkdownLines, repeatToWidth, shimmerText, truncateToWidth, visibleWidth, waitCardCopy, waitSummaryFromReasoning, wrapWaitDetails, } from './term-text.js';
50
+ export { captureHangupSignals, composePaintOutput, detectSshSession, formatLinkQualityChip, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, linkSignalPips, paintIntervalForRtt, paintLinkLabel, parseCursorPositionReply, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, waitUntilIdleOrTimeout, writeBootSplash, } from './paint.js';
50
51
  export { CONTEXT_IDLE_COMPACT_RATIO, CONTEXT_PRESSURE_DANGER_RATIO, CONTEXT_PRESSURE_WARN_RATIO, CONTEXT_RING_EMPTY, CONTEXT_RING_SEGMENTS, contextPressureAlertText, contextPressureRingColor, contextPressureUsedTokens, contextPressureView, describeProviderRoute, dropFooterQuotaPlanName, fitFooterStatsLine, fitFooterStatusLine, footerActivity, footerIdentityParts, footerStatsGroups, formatContextPressureChip, formatContextPressureRing, formatContextPressureStatusLine, formatDuration, formatFooterQuota, formatQuotaBar, formatStatusReport, formatTokens, formatTokensPerSecond, parseContextPressure, promptPressureTokens, providerShortCode, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
51
52
  export { crossedQuotaThresholds, formatAccountBalance, formatFooterBalance, formatOpenCodeGoUsage, formatQuotaSnapshot, formatQuotaStatusLine, joinUrl, openCodeSourceFor, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, quotaAlertText, quotaRefreshEverySteps, quotaRefreshEveryTurns, remainingPercentFromUsed, tightestQuotaWindow, } from './quota.js';
53
+ export { commandAcceptsAttachments, forEachSessionEvent, isAssistantStreamEvent, listPersistenceHeaders, inspectPersistenceSession, sessionEventType, sessionEvents, settingsNamespace, streamChunkOf, } from './dsh-compat.js';
52
54
  export { applyTurnEndToPlan, askSummary, cardCategoryOf, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, parsePlanTodos, planCloseNudgeText, planDockNote, planIsLive, planTitleFromMarkdown, planTurnLeftOpen, promptInjectionSources, promptInjectionTitle, subagentHeaderText, todoProgressLabel, todoSummary, } from './plan.js';
53
55
  export { buildToolHeader, canMergeToolCall, compactEditPath, compactToolBursts, compactToolGroups, countDiffAddDel, countDiffLines, countOutputLines, diffMetaDiffs, diffStatToken, friendlyJsonLines, parseExitStatus, presentToolCall, READ_TOOL_NAMES, renderToolDiff, toolBodyFitsWorkspace, toolBodyLines, toolStateColor, toolStateLabel, wrappedToolBodyLineCount, } from './tool-present.js';
54
56
  function discoverProviderModels(llm, request, signal) {
@@ -291,6 +293,7 @@ const LOCAL_COMMANDS = [
291
293
  { name: 'resume', key: 'cmd.resume' },
292
294
  { name: 'setup', key: 'cmd.setup' },
293
295
  { name: 'find', key: 'cmd.find' },
296
+ { name: 'copy', key: 'cmd.copy' },
294
297
  { name: 'language', key: 'cmd.language' },
295
298
  { name: 'lang', key: 'cmd.language', aliasOf: 'language' },
296
299
  { name: 'dialog-test', key: 'cmd.dialog-test' },
@@ -448,6 +451,16 @@ export class SshTui {
448
451
  lastStatsTurn = null;
449
452
  scrollOffset = 0;
450
453
  clickableRows = new Map();
454
+ paintedLinkHitsByRow = new Map();
455
+ copyYank = '';
456
+ /** Last OSC-52 payload (tests; empty when nothing has been copied). */
457
+ get lastCopiedText() {
458
+ return this.copyYank;
459
+ }
460
+ /** Screen-row → OSC 8 hits from the last paint (tests). */
461
+ get linkHitsByRow() {
462
+ return this.paintedLinkHitsByRow;
463
+ }
451
464
  streamingReasoning;
452
465
  escapeBuffer = '';
453
466
  escapeTimer;
@@ -455,9 +468,13 @@ export class SshTui {
455
468
  waitStartedAt;
456
469
  completionSignaled = false;
457
470
  replaying = false;
471
+ /** Display-line budget for the first paint after resume; 0 = full transcript. */
472
+ paintTailBudget = 0;
458
473
  completedAt = 0;
459
474
  lastTitleUpdateAt = 0;
460
475
  lastPaintRows = [];
476
+ lastPaintCursorColumn = 1;
477
+ lastPaintCursorRow = 1;
461
478
  lastChromeKey = '';
462
479
  lastPaintWidth = 0;
463
480
  lastPaintHeight = 0;
@@ -566,7 +583,7 @@ export class SshTui {
566
583
  this.bootBackgroundTasks();
567
584
  }
568
585
  bindAgentEvents() {
569
- this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
586
+ this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), listenHostEvent(this.ctx, 'agent/assistant-stream', this.handleAssistantStream), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
570
587
  const questions = this.ctx.get('userQuestions');
571
588
  if (questions !== undefined) {
572
589
  this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
@@ -858,9 +875,9 @@ export class SshTui {
858
875
  replayHistory() {
859
876
  this.replaying = true;
860
877
  try {
861
- for (const event of sessionEvents(this.agent.session)) {
878
+ forEachSessionEvent(this.agent.session, (event) => {
862
879
  this.handleSessionEvent(this.agent.session, event);
863
- }
880
+ });
864
881
  }
865
882
  finally {
866
883
  this.replaying = false;
@@ -870,6 +887,7 @@ export class SshTui {
870
887
  this.thinkingStartedAt = undefined;
871
888
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
872
889
  this.refreshContextPressure({ compact: false });
890
+ this.paintTailBudget = Math.max(24, this.screenRows() * 3);
873
891
  this.dirty = true;
874
892
  }
875
893
  /** Show the first-launch provider/API-key onboarding when nothing is configured. */
@@ -1209,6 +1227,14 @@ export class SshTui {
1209
1227
  process.stdout.rows = previousRows;
1210
1228
  }
1211
1229
  }
1230
+ /** Last CSI cursor column written by {@link paint} (1-based). Tests only. */
1231
+ lastPaintedCursorColumn() {
1232
+ return this.lastPaintCursorColumn;
1233
+ }
1234
+ /** Last CSI cursor row written by {@link paint} (1-based). Tests only. */
1235
+ lastPaintedCursorRow() {
1236
+ return this.lastPaintCursorRow;
1237
+ }
1212
1238
  screenColumns() {
1213
1239
  if (this.headlessDisplay || this.displayDetached) {
1214
1240
  return this.relayColumns ?? process.stdout.columns ?? 80;
@@ -1684,6 +1710,8 @@ export class SshTui {
1684
1710
  cursorRow: height,
1685
1711
  cursorColumn: 1,
1686
1712
  }));
1713
+ this.lastPaintCursorRow = height;
1714
+ this.lastPaintCursorColumn = 1;
1687
1715
  this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
1688
1716
  this.lastChromeKey = `inspect:${dialog.offset}:${width}x${height}`;
1689
1717
  this.lastPaintWidth = width;
@@ -1828,6 +1856,10 @@ export class SshTui {
1828
1856
  else {
1829
1857
  this.focusedRow = null;
1830
1858
  }
1859
+ if (this.paintTailBudget > 0) {
1860
+ this.paintTailBudget = 0;
1861
+ this.forceFullPaint = true;
1862
+ }
1831
1863
  this.pendingReveal = row;
1832
1864
  this.markDirty();
1833
1865
  }
@@ -1942,8 +1974,19 @@ export class SshTui {
1942
1974
  if (burst.after !== undefined)
1943
1975
  compactBurstByReply.set(burst.after, burst);
1944
1976
  }
1945
- let paintedLeadingCompact = false;
1946
- for (const row of this.rows) {
1977
+ const skipMiddle = this.paintTailBudget > 0 && this.scrollOffset === 0 && this.pendingReveal === undefined
1978
+ && this.rows.length > this.paintTailBudget + 8;
1979
+ const historyStart = skipMiddle ? Math.max(0, this.rows.length - this.paintTailBudget) : 0;
1980
+ let paintedLeadingCompact = skipMiddle;
1981
+ for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex += 1) {
1982
+ if (skipMiddle && rowIndex >= 4 && rowIndex < historyStart) {
1983
+ if (rowIndex === 4)
1984
+ addDisplay(this.styleLine('system', t('history.folded')));
1985
+ continue;
1986
+ }
1987
+ const row = this.rows[rowIndex];
1988
+ if (row === undefined)
1989
+ continue;
1947
1990
  if (compact && (row.kind === 'reasoning' || row.kind === 'prompt' || row.kind === 'tool'))
1948
1991
  continue;
1949
1992
  if (compact && !paintedLeadingCompact && (row.kind === 'assistant' || row.kind === 'user')) {
@@ -2415,6 +2458,11 @@ export class SshTui {
2415
2458
  // across the full terminal grid, which parked the caret on a later
2416
2459
  // chrome line after a long paste. Un-folded multi-line input still
2417
2460
  // maps through wrap() so newlines stay on the right visual row.
2461
+ //
2462
+ // A caret after a glyph that filled the row must move to col 1 of the
2463
+ // next row (and that row must exist). Parking it on the last cell
2464
+ // punches through the glyph; parking at width+1 trips DEC auto-margin
2465
+ // onto the stats line.
2418
2466
  let cursorRowOffset;
2419
2467
  let column;
2420
2468
  if (inputView.folded || masked) {
@@ -2423,18 +2471,11 @@ export class SshTui {
2423
2471
  }
2424
2472
  else {
2425
2473
  const pos = cursorVisualPosition(inputView.text, this.cursor, inputTextWidth);
2426
- // A cursor exactly at the end of a full-width row sits at the start of
2427
- // the next row; if that row does not exist yet, reserve an empty row.
2428
- if (pos.col === inputTextWidth) {
2429
- if (pos.row + 1 >= inputDisplayLines.length)
2430
- inputDisplayLines.push('');
2431
- cursorRowOffset = Math.min(pos.row + 1, Math.max(0, inputDisplayLines.length - 1));
2432
- column = (cursorRowOffset === 0 ? promptWidth : 0) + 1;
2433
- }
2434
- else {
2435
- cursorRowOffset = Math.min(pos.row, Math.max(0, inputDisplayLines.length - 1));
2436
- column = (pos.row === 0 ? promptWidth : 0) + pos.col + 1;
2437
- }
2474
+ while (pos.row >= inputDisplayLines.length)
2475
+ inputDisplayLines.push('');
2476
+ cursorRowOffset = pos.row;
2477
+ const rowPrefix = pos.row === 0 ? promptWidth : 0;
2478
+ column = Math.min(width, rowPrefix + pos.col + 1);
2438
2479
  }
2439
2480
  const inputRows = Math.max(1, inputDisplayLines.length);
2440
2481
  const yieldPlanDock = this.dialog !== undefined || suggestionLines.length > 0;
@@ -2468,10 +2509,15 @@ export class SshTui {
2468
2509
  visibleRefs.unshift(undefined);
2469
2510
  }
2470
2511
  this.clickableRows.clear();
2512
+ this.paintedLinkHitsByRow.clear();
2471
2513
  for (let index = 0; index < visibleRefs.length; index++) {
2472
2514
  const ref = visibleRefs[index];
2515
+ const screenY = headerLines.length + index + 1;
2473
2516
  if (ref !== undefined && 'expanded' in ref)
2474
- this.clickableRows.set(headerLines.length + index + 1, ref);
2517
+ this.clickableRows.set(screenY, ref);
2518
+ const hits = paintedLinkHits(visible[index] ?? '');
2519
+ if (hits.length > 0)
2520
+ this.paintedLinkHitsByRow.set(screenY, hits);
2475
2521
  }
2476
2522
  const dockPlan = this.findLivePlanRow();
2477
2523
  if (dockPlan !== undefined && planDockLines.length > 0) {
@@ -2620,6 +2666,8 @@ export class SshTui {
2620
2666
  cursorRow: row,
2621
2667
  cursorColumn: column,
2622
2668
  }));
2669
+ this.lastPaintCursorRow = row;
2670
+ this.lastPaintCursorColumn = Math.min(width, Math.max(1, column));
2623
2671
  this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
2624
2672
  this.lastChromeKey = chromeKey;
2625
2673
  this.lastPaintWidth = width;
@@ -2655,7 +2703,7 @@ export class SshTui {
2655
2703
  const desc = t(descKey, undefined, command.description);
2656
2704
  return {
2657
2705
  name: command.name,
2658
- description: command.input?.images === true
2706
+ description: commandAcceptsAttachments(command.input)
2659
2707
  ? t('cmd.withImagesSuffix', { desc })
2660
2708
  : desc,
2661
2709
  local: false,
@@ -2799,7 +2847,7 @@ export class SshTui {
2799
2847
  if (!this.color)
2800
2848
  return safe;
2801
2849
  const code = kind === 'user' ? '36' :
2802
- kind === 'assistant' ? '1;37' :
2850
+ kind === 'assistant' ? '37' :
2803
2851
  kind === 'reasoning' ? '2;3' :
2804
2852
  kind === 'brand' ? '1;38;2;77;107;253' :
2805
2853
  kind === 'tool' || kind === 'tool-result' ? '37' :
@@ -2815,6 +2863,60 @@ export class SshTui {
2815
2863
  '90';
2816
2864
  return `\x1b[${code}m${safe}\x1b[0m`;
2817
2865
  }
2866
+ /**
2867
+ * Fold one live or durable stream chunk into the in-progress assistant
2868
+ * row. 0.1.2 hosts append `assistant/chunk`; 0.1.5 emits the same chunk
2869
+ * on `agent/assistant-stream` and never writes it to the log.
2870
+ */
2871
+ applyStreamChunk(streamed) {
2872
+ const { chunk } = streamed;
2873
+ const open = this.openStepStats;
2874
+ if (open !== null && open !== undefined
2875
+ && open.turn === streamed.turn && open.step === streamed.step) {
2876
+ if (open.firstTokenTime === null
2877
+ && chunk.type === 'text-delta'
2878
+ && chunk.text !== '') {
2879
+ this.openStepStats = { ...open, firstTokenTime: streamed.time };
2880
+ }
2881
+ }
2882
+ if (chunk.type === 'usage' && chunk.usage !== undefined) {
2883
+ this.recordUsage(streamed.turn, streamed.step, chunk.usage);
2884
+ }
2885
+ if (chunk.type === 'text-delta') {
2886
+ this.streaming ??= { text: '', reasoning: '' };
2887
+ this.streaming.text += chunk.text ?? '';
2888
+ this.markDirty();
2889
+ }
2890
+ else if (chunk.type === 'reasoning-delta') {
2891
+ this.streaming ??= { text: '', reasoning: '' };
2892
+ if (this.streaming.reasoning === '' && (chunk.text ?? '') !== '') {
2893
+ this.thinkingStartedAt = Date.now();
2894
+ this.streamingReasoning = { kind: 'streaming-reasoning', expanded: false };
2895
+ }
2896
+ this.streaming.reasoning += chunk.text ?? '';
2897
+ this.markDirty();
2898
+ }
2899
+ }
2900
+ /**
2901
+ * 0.1.5 live tokens arrive as process-local `agent/assistant-stream`
2902
+ * frames (start / chunk / end). Chunk frames carry the same
2903
+ * `StreamChunk` the 0.1.2 log used to store as `assistant/chunk`.
2904
+ */
2905
+ handleAssistantStream = (...args) => {
2906
+ const payload = args[0];
2907
+ if (payload === undefined)
2908
+ return;
2909
+ const agent = payload.agent;
2910
+ if (agent === undefined || agent !== this.agent)
2911
+ return;
2912
+ if (this.replaying)
2913
+ return;
2914
+ this.lastActivity = Date.now();
2915
+ this.refreshContextPressure();
2916
+ const streamed = streamChunkOf(payload.frame);
2917
+ if (streamed !== undefined)
2918
+ this.applyStreamChunk(streamed);
2919
+ };
2818
2920
  // ── event handling ──────────────────────────────────────────────────────
2819
2921
  /**
2820
2922
  * Apply the TUI's subagent model selection to every child-agent request.
@@ -2846,7 +2948,17 @@ export class SshTui {
2846
2948
  return;
2847
2949
  }
2848
2950
  this.lastActivity = Date.now();
2849
- this.refreshContextPressure();
2951
+ if (this.replaying && isAssistantStreamEvent(event))
2952
+ return;
2953
+ if (!this.replaying)
2954
+ this.refreshContextPressure();
2955
+ const eventType = sessionEventType(event);
2956
+ if (eventType === 'assistant/chunk') {
2957
+ const streamed = streamChunkOf(event);
2958
+ if (streamed !== undefined)
2959
+ this.applyStreamChunk(streamed);
2960
+ return;
2961
+ }
2850
2962
  switch (event.type) {
2851
2963
  case 'user/message': {
2852
2964
  const text = event.data.content
@@ -2886,36 +2998,6 @@ export class SshTui {
2886
2998
  }
2887
2999
  break;
2888
3000
  }
2889
- case 'assistant/chunk': {
2890
- const chunk = event.data.chunk;
2891
- const open = this.openStepStats;
2892
- if (open !== null && open !== undefined
2893
- && open.turn === event.data.turn && open.step === event.data.step) {
2894
- if (open.firstTokenTime === null
2895
- && chunk.type === 'text-delta'
2896
- && chunk.text !== '') {
2897
- this.openStepStats = { ...open, firstTokenTime: event.time };
2898
- }
2899
- }
2900
- if (chunk.type === 'usage' && chunk.usage !== undefined) {
2901
- this.recordUsage(event.data.turn, event.data.step, chunk.usage);
2902
- }
2903
- if (chunk.type === 'text-delta') {
2904
- this.streaming ??= { text: '', reasoning: '' };
2905
- this.streaming.text += chunk.text;
2906
- this.markDirty();
2907
- }
2908
- else if (chunk.type === 'reasoning-delta') {
2909
- this.streaming ??= { text: '', reasoning: '' };
2910
- if (this.streaming.reasoning === '' && chunk.text !== '') {
2911
- this.thinkingStartedAt = Date.now();
2912
- this.streamingReasoning = { kind: 'streaming-reasoning', expanded: false };
2913
- }
2914
- this.streaming.reasoning += chunk.text;
2915
- this.markDirty();
2916
- }
2917
- break;
2918
- }
2919
3001
  case 'assistant/message': {
2920
3002
  const text = event.data.message.content
2921
3003
  .filter(block => block.type === 'text')
@@ -3658,6 +3740,8 @@ export class SshTui {
3658
3740
  const row = this.findSubagentRow(String(sessionId));
3659
3741
  if (row === undefined)
3660
3742
  return;
3743
+ if (sessionEventType(event) === 'assistant/chunk')
3744
+ return;
3661
3745
  switch (event.type) {
3662
3746
  case 'user/message': {
3663
3747
  const text = collectText(event.data.content);
@@ -3665,8 +3749,6 @@ export class SshTui {
3665
3749
  appendSubagentLog(row, { kind: 'user', text: `❯ ${truncate(text, 4)}` });
3666
3750
  break;
3667
3751
  }
3668
- case 'assistant/chunk':
3669
- break;
3670
3752
  case 'assistant/message': {
3671
3753
  const text = collectText(event.data.message.content);
3672
3754
  if (text !== '')
@@ -5512,7 +5594,8 @@ export class SshTui {
5512
5594
  return;
5513
5595
  }
5514
5596
  if (button === 0) {
5515
- this.handleMouseClick(y);
5597
+ const x = Number(sgrMouse[2]);
5598
+ this.handleMouseClick(y, x);
5516
5599
  return;
5517
5600
  }
5518
5601
  }
@@ -5542,6 +5625,11 @@ export class SshTui {
5542
5625
  this.deleteAtCursor();
5543
5626
  return;
5544
5627
  }
5628
+ // kitty / CSI-u Ctrl+Shift+C (codepoint 99, mods 6 = Ctrl+Shift)
5629
+ if (combined === '\x1b[99;6u') {
5630
+ this.copyFocusedCard();
5631
+ return;
5632
+ }
5545
5633
  const ss3 = /^\x1bO[A-Z]/u.exec(combined);
5546
5634
  if (ss3 !== null) {
5547
5635
  const rest = combined.slice(ss3[0].length);
@@ -6413,10 +6501,16 @@ export class SshTui {
6413
6501
  this.markDirty();
6414
6502
  }
6415
6503
  }
6416
- /** Toggle the collapsible row under a click on the transcript area. */
6417
- handleMouseClick(y) {
6504
+ /** Toggle the collapsible row under a click, or copy an OSC-8 link. */
6505
+ handleMouseClick(y, x = 1) {
6418
6506
  if (this.dialog !== undefined)
6419
6507
  return;
6508
+ const hits = this.paintedLinkHitsByRow.get(y);
6509
+ const href = hits === undefined ? undefined : hrefAtColumn(hits, Math.max(0, x - 1));
6510
+ if (href !== undefined && href.trim() !== '') {
6511
+ this.copyPlainText(href, t('copy.link', { url: href }));
6512
+ return;
6513
+ }
6420
6514
  if (this.cwdChipRow !== undefined && y === this.cwdChipRow) {
6421
6515
  this.announceWorkspaceCwd();
6422
6516
  return;
@@ -6426,12 +6520,40 @@ export class SshTui {
6426
6520
  return;
6427
6521
  this.toggleCard(row);
6428
6522
  }
6523
+ copyPlainText(text, notice) {
6524
+ this.copyYank = text;
6525
+ this.focusedRow = null;
6526
+ this.input = '';
6527
+ this.cursor = 0;
6528
+ this.inputFolded = false;
6529
+ this.leaveHistoryBrowse();
6530
+ this.write(osc52Clipboard(text));
6531
+ this.pushRow({ kind: 'system', text: notice });
6532
+ this.markDirty();
6533
+ }
6534
+ copyFocusedCard() {
6535
+ if (this.dialog !== undefined)
6536
+ return false;
6537
+ const picked = copyTextFromTranscript(this.rows, this.focusedRow);
6538
+ if (picked.text.trim() === '') {
6539
+ this.pushRow({ kind: 'system', text: t('copy.empty') });
6540
+ this.markDirty();
6541
+ return false;
6542
+ }
6543
+ const source = picked.source === 'focused' ? t('copy.sourceFocused') : t('copy.sourceAssistant');
6544
+ this.copyPlainText(picked.text, t('copy.ok', { chars: picked.text.length, source }));
6545
+ return true;
6546
+ }
6429
6547
  scrollInspectOrTranscript(delta) {
6430
6548
  if (this.dialog?.kind === 'inspect') {
6431
6549
  this.dialog.offset = Math.max(0, this.dialog.offset + delta);
6432
6550
  this.markDirty();
6433
6551
  return;
6434
6552
  }
6553
+ if (this.paintTailBudget > 0) {
6554
+ this.paintTailBudget = 0;
6555
+ this.forceFullPaint = true;
6556
+ }
6435
6557
  this.scrollOffset = Math.max(0, this.scrollOffset + delta);
6436
6558
  this.markDirty();
6437
6559
  }
@@ -6522,7 +6644,7 @@ export class SshTui {
6522
6644
  .map(item => {
6523
6645
  const descKey = `cmd.${item.name}`;
6524
6646
  const desc = t(descKey, undefined, item.description);
6525
- return `/${item.name.padEnd(12)} ${item.input?.images === true ? t('cmd.withImagesSuffix', { desc }) : desc} (dsh)`;
6647
+ return `/${item.name.padEnd(12)} ${commandAcceptsAttachments(item.input) ? t('cmd.withImagesSuffix', { desc }) : desc} (dsh)`;
6526
6648
  });
6527
6649
  this.pushRow({
6528
6650
  kind: 'system',
@@ -6646,6 +6768,9 @@ export class SshTui {
6646
6768
  this.markDirty();
6647
6769
  });
6648
6770
  break;
6771
+ case 'copy':
6772
+ this.copyFocusedCard();
6773
+ break;
6649
6774
  case 'find':
6650
6775
  this.runFindCommand(arg);
6651
6776
  break;