dsh-ssh-tui 0.5.4 → 0.5.6

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. */
@@ -1110,6 +1128,19 @@ export class SshTui {
1110
1128
  return true;
1111
1129
  return false;
1112
1130
  }
1131
+ /**
1132
+ * Display socket closed. Replacing a leftover Display with a new HELLO,
1133
+ * or closing a socket after we already detached, is not an SSH hangup.
1134
+ */
1135
+ handleDisplayDetach(info) {
1136
+ if (this.disposed || this.hangingUp)
1137
+ return;
1138
+ if (info?.replaced === true)
1139
+ return;
1140
+ if (this.displayDetached)
1141
+ return;
1142
+ void this.handleHangup();
1143
+ }
1113
1144
  /**
1114
1145
  * SSH / TTY hangup: drop the local display, flush, and either keep the Host
1115
1146
  * (busy: thinking / reply / tools / subagents) or exit (idle).
@@ -1209,6 +1240,14 @@ export class SshTui {
1209
1240
  process.stdout.rows = previousRows;
1210
1241
  }
1211
1242
  }
1243
+ /** Last CSI cursor column written by {@link paint} (1-based). Tests only. */
1244
+ lastPaintedCursorColumn() {
1245
+ return this.lastPaintCursorColumn;
1246
+ }
1247
+ /** Last CSI cursor row written by {@link paint} (1-based). Tests only. */
1248
+ lastPaintedCursorRow() {
1249
+ return this.lastPaintCursorRow;
1250
+ }
1212
1251
  screenColumns() {
1213
1252
  if (this.headlessDisplay || this.displayDetached) {
1214
1253
  return this.relayColumns ?? process.stdout.columns ?? 80;
@@ -1268,10 +1307,8 @@ export class SshTui {
1268
1307
  onRtt: (rttMs) => {
1269
1308
  this.applyProbedRtt(rttMs);
1270
1309
  },
1271
- onDetach: () => {
1272
- if (this.disposed || this.hangingUp)
1273
- return;
1274
- void this.handleHangup();
1310
+ onDetach: (info) => {
1311
+ this.handleDisplayDetach(info);
1275
1312
  },
1276
1313
  onAttach: () => {
1277
1314
  if (this.disposed)
@@ -1684,6 +1721,8 @@ export class SshTui {
1684
1721
  cursorRow: height,
1685
1722
  cursorColumn: 1,
1686
1723
  }));
1724
+ this.lastPaintCursorRow = height;
1725
+ this.lastPaintCursorColumn = 1;
1687
1726
  this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
1688
1727
  this.lastChromeKey = `inspect:${dialog.offset}:${width}x${height}`;
1689
1728
  this.lastPaintWidth = width;
@@ -1828,6 +1867,10 @@ export class SshTui {
1828
1867
  else {
1829
1868
  this.focusedRow = null;
1830
1869
  }
1870
+ if (this.paintTailBudget > 0) {
1871
+ this.paintTailBudget = 0;
1872
+ this.forceFullPaint = true;
1873
+ }
1831
1874
  this.pendingReveal = row;
1832
1875
  this.markDirty();
1833
1876
  }
@@ -1942,8 +1985,19 @@ export class SshTui {
1942
1985
  if (burst.after !== undefined)
1943
1986
  compactBurstByReply.set(burst.after, burst);
1944
1987
  }
1945
- let paintedLeadingCompact = false;
1946
- for (const row of this.rows) {
1988
+ const skipMiddle = this.paintTailBudget > 0 && this.scrollOffset === 0 && this.pendingReveal === undefined
1989
+ && this.rows.length > this.paintTailBudget + 8;
1990
+ const historyStart = skipMiddle ? Math.max(0, this.rows.length - this.paintTailBudget) : 0;
1991
+ let paintedLeadingCompact = skipMiddle;
1992
+ for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex += 1) {
1993
+ if (skipMiddle && rowIndex >= 4 && rowIndex < historyStart) {
1994
+ if (rowIndex === 4)
1995
+ addDisplay(this.styleLine('system', t('history.folded')));
1996
+ continue;
1997
+ }
1998
+ const row = this.rows[rowIndex];
1999
+ if (row === undefined)
2000
+ continue;
1947
2001
  if (compact && (row.kind === 'reasoning' || row.kind === 'prompt' || row.kind === 'tool'))
1948
2002
  continue;
1949
2003
  if (compact && !paintedLeadingCompact && (row.kind === 'assistant' || row.kind === 'user')) {
@@ -2415,6 +2469,11 @@ export class SshTui {
2415
2469
  // across the full terminal grid, which parked the caret on a later
2416
2470
  // chrome line after a long paste. Un-folded multi-line input still
2417
2471
  // maps through wrap() so newlines stay on the right visual row.
2472
+ //
2473
+ // A caret after a glyph that filled the row must move to col 1 of the
2474
+ // next row (and that row must exist). Parking it on the last cell
2475
+ // punches through the glyph; parking at width+1 trips DEC auto-margin
2476
+ // onto the stats line.
2418
2477
  let cursorRowOffset;
2419
2478
  let column;
2420
2479
  if (inputView.folded || masked) {
@@ -2423,18 +2482,11 @@ export class SshTui {
2423
2482
  }
2424
2483
  else {
2425
2484
  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
- }
2485
+ while (pos.row >= inputDisplayLines.length)
2486
+ inputDisplayLines.push('');
2487
+ cursorRowOffset = pos.row;
2488
+ const rowPrefix = pos.row === 0 ? promptWidth : 0;
2489
+ column = Math.min(width, rowPrefix + pos.col + 1);
2438
2490
  }
2439
2491
  const inputRows = Math.max(1, inputDisplayLines.length);
2440
2492
  const yieldPlanDock = this.dialog !== undefined || suggestionLines.length > 0;
@@ -2468,10 +2520,15 @@ export class SshTui {
2468
2520
  visibleRefs.unshift(undefined);
2469
2521
  }
2470
2522
  this.clickableRows.clear();
2523
+ this.paintedLinkHitsByRow.clear();
2471
2524
  for (let index = 0; index < visibleRefs.length; index++) {
2472
2525
  const ref = visibleRefs[index];
2526
+ const screenY = headerLines.length + index + 1;
2473
2527
  if (ref !== undefined && 'expanded' in ref)
2474
- this.clickableRows.set(headerLines.length + index + 1, ref);
2528
+ this.clickableRows.set(screenY, ref);
2529
+ const hits = paintedLinkHits(visible[index] ?? '');
2530
+ if (hits.length > 0)
2531
+ this.paintedLinkHitsByRow.set(screenY, hits);
2475
2532
  }
2476
2533
  const dockPlan = this.findLivePlanRow();
2477
2534
  if (dockPlan !== undefined && planDockLines.length > 0) {
@@ -2620,6 +2677,8 @@ export class SshTui {
2620
2677
  cursorRow: row,
2621
2678
  cursorColumn: column,
2622
2679
  }));
2680
+ this.lastPaintCursorRow = row;
2681
+ this.lastPaintCursorColumn = Math.min(width, Math.max(1, column));
2623
2682
  this.lastPaintRows = paintRows.length > height ? paintRows.slice(0, height) : paintRows;
2624
2683
  this.lastChromeKey = chromeKey;
2625
2684
  this.lastPaintWidth = width;
@@ -2655,7 +2714,7 @@ export class SshTui {
2655
2714
  const desc = t(descKey, undefined, command.description);
2656
2715
  return {
2657
2716
  name: command.name,
2658
- description: command.input?.images === true
2717
+ description: commandAcceptsAttachments(command.input)
2659
2718
  ? t('cmd.withImagesSuffix', { desc })
2660
2719
  : desc,
2661
2720
  local: false,
@@ -2799,7 +2858,7 @@ export class SshTui {
2799
2858
  if (!this.color)
2800
2859
  return safe;
2801
2860
  const code = kind === 'user' ? '36' :
2802
- kind === 'assistant' ? '1;37' :
2861
+ kind === 'assistant' ? '37' :
2803
2862
  kind === 'reasoning' ? '2;3' :
2804
2863
  kind === 'brand' ? '1;38;2;77;107;253' :
2805
2864
  kind === 'tool' || kind === 'tool-result' ? '37' :
@@ -2815,6 +2874,60 @@ export class SshTui {
2815
2874
  '90';
2816
2875
  return `\x1b[${code}m${safe}\x1b[0m`;
2817
2876
  }
2877
+ /**
2878
+ * Fold one live or durable stream chunk into the in-progress assistant
2879
+ * row. 0.1.2 hosts append `assistant/chunk`; 0.1.5 emits the same chunk
2880
+ * on `agent/assistant-stream` and never writes it to the log.
2881
+ */
2882
+ applyStreamChunk(streamed) {
2883
+ const { chunk } = streamed;
2884
+ const open = this.openStepStats;
2885
+ if (open !== null && open !== undefined
2886
+ && open.turn === streamed.turn && open.step === streamed.step) {
2887
+ if (open.firstTokenTime === null
2888
+ && chunk.type === 'text-delta'
2889
+ && chunk.text !== '') {
2890
+ this.openStepStats = { ...open, firstTokenTime: streamed.time };
2891
+ }
2892
+ }
2893
+ if (chunk.type === 'usage' && chunk.usage !== undefined) {
2894
+ this.recordUsage(streamed.turn, streamed.step, chunk.usage);
2895
+ }
2896
+ if (chunk.type === 'text-delta') {
2897
+ this.streaming ??= { text: '', reasoning: '' };
2898
+ this.streaming.text += chunk.text ?? '';
2899
+ this.markDirty();
2900
+ }
2901
+ else if (chunk.type === 'reasoning-delta') {
2902
+ this.streaming ??= { text: '', reasoning: '' };
2903
+ if (this.streaming.reasoning === '' && (chunk.text ?? '') !== '') {
2904
+ this.thinkingStartedAt = Date.now();
2905
+ this.streamingReasoning = { kind: 'streaming-reasoning', expanded: false };
2906
+ }
2907
+ this.streaming.reasoning += chunk.text ?? '';
2908
+ this.markDirty();
2909
+ }
2910
+ }
2911
+ /**
2912
+ * 0.1.5 live tokens arrive as process-local `agent/assistant-stream`
2913
+ * frames (start / chunk / end). Chunk frames carry the same
2914
+ * `StreamChunk` the 0.1.2 log used to store as `assistant/chunk`.
2915
+ */
2916
+ handleAssistantStream = (...args) => {
2917
+ const payload = args[0];
2918
+ if (payload === undefined)
2919
+ return;
2920
+ const agent = payload.agent;
2921
+ if (agent === undefined || agent !== this.agent)
2922
+ return;
2923
+ if (this.replaying)
2924
+ return;
2925
+ this.lastActivity = Date.now();
2926
+ this.refreshContextPressure();
2927
+ const streamed = streamChunkOf(payload.frame);
2928
+ if (streamed !== undefined)
2929
+ this.applyStreamChunk(streamed);
2930
+ };
2818
2931
  // ── event handling ──────────────────────────────────────────────────────
2819
2932
  /**
2820
2933
  * Apply the TUI's subagent model selection to every child-agent request.
@@ -2846,7 +2959,17 @@ export class SshTui {
2846
2959
  return;
2847
2960
  }
2848
2961
  this.lastActivity = Date.now();
2849
- this.refreshContextPressure();
2962
+ if (this.replaying && isAssistantStreamEvent(event))
2963
+ return;
2964
+ if (!this.replaying)
2965
+ this.refreshContextPressure();
2966
+ const eventType = sessionEventType(event);
2967
+ if (eventType === 'assistant/chunk') {
2968
+ const streamed = streamChunkOf(event);
2969
+ if (streamed !== undefined)
2970
+ this.applyStreamChunk(streamed);
2971
+ return;
2972
+ }
2850
2973
  switch (event.type) {
2851
2974
  case 'user/message': {
2852
2975
  const text = event.data.content
@@ -2886,36 +3009,6 @@ export class SshTui {
2886
3009
  }
2887
3010
  break;
2888
3011
  }
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
3012
  case 'assistant/message': {
2920
3013
  const text = event.data.message.content
2921
3014
  .filter(block => block.type === 'text')
@@ -3658,6 +3751,8 @@ export class SshTui {
3658
3751
  const row = this.findSubagentRow(String(sessionId));
3659
3752
  if (row === undefined)
3660
3753
  return;
3754
+ if (sessionEventType(event) === 'assistant/chunk')
3755
+ return;
3661
3756
  switch (event.type) {
3662
3757
  case 'user/message': {
3663
3758
  const text = collectText(event.data.content);
@@ -3665,8 +3760,6 @@ export class SshTui {
3665
3760
  appendSubagentLog(row, { kind: 'user', text: `❯ ${truncate(text, 4)}` });
3666
3761
  break;
3667
3762
  }
3668
- case 'assistant/chunk':
3669
- break;
3670
3763
  case 'assistant/message': {
3671
3764
  const text = collectText(event.data.message.content);
3672
3765
  if (text !== '')
@@ -5512,7 +5605,8 @@ export class SshTui {
5512
5605
  return;
5513
5606
  }
5514
5607
  if (button === 0) {
5515
- this.handleMouseClick(y);
5608
+ const x = Number(sgrMouse[2]);
5609
+ this.handleMouseClick(y, x);
5516
5610
  return;
5517
5611
  }
5518
5612
  }
@@ -5542,6 +5636,11 @@ export class SshTui {
5542
5636
  this.deleteAtCursor();
5543
5637
  return;
5544
5638
  }
5639
+ // kitty / CSI-u Ctrl+Shift+C (codepoint 99, mods 6 = Ctrl+Shift)
5640
+ if (combined === '\x1b[99;6u') {
5641
+ this.copyFocusedCard();
5642
+ return;
5643
+ }
5545
5644
  const ss3 = /^\x1bO[A-Z]/u.exec(combined);
5546
5645
  if (ss3 !== null) {
5547
5646
  const rest = combined.slice(ss3[0].length);
@@ -6413,10 +6512,16 @@ export class SshTui {
6413
6512
  this.markDirty();
6414
6513
  }
6415
6514
  }
6416
- /** Toggle the collapsible row under a click on the transcript area. */
6417
- handleMouseClick(y) {
6515
+ /** Toggle the collapsible row under a click, or copy an OSC-8 link. */
6516
+ handleMouseClick(y, x = 1) {
6418
6517
  if (this.dialog !== undefined)
6419
6518
  return;
6519
+ const hits = this.paintedLinkHitsByRow.get(y);
6520
+ const href = hits === undefined ? undefined : hrefAtColumn(hits, Math.max(0, x - 1));
6521
+ if (href !== undefined && href.trim() !== '') {
6522
+ this.copyPlainText(href, t('copy.link', { url: href }));
6523
+ return;
6524
+ }
6420
6525
  if (this.cwdChipRow !== undefined && y === this.cwdChipRow) {
6421
6526
  this.announceWorkspaceCwd();
6422
6527
  return;
@@ -6426,12 +6531,40 @@ export class SshTui {
6426
6531
  return;
6427
6532
  this.toggleCard(row);
6428
6533
  }
6534
+ copyPlainText(text, notice) {
6535
+ this.copyYank = text;
6536
+ this.focusedRow = null;
6537
+ this.input = '';
6538
+ this.cursor = 0;
6539
+ this.inputFolded = false;
6540
+ this.leaveHistoryBrowse();
6541
+ this.write(osc52Clipboard(text));
6542
+ this.pushRow({ kind: 'system', text: notice });
6543
+ this.markDirty();
6544
+ }
6545
+ copyFocusedCard() {
6546
+ if (this.dialog !== undefined)
6547
+ return false;
6548
+ const picked = copyTextFromTranscript(this.rows, this.focusedRow);
6549
+ if (picked.text.trim() === '') {
6550
+ this.pushRow({ kind: 'system', text: t('copy.empty') });
6551
+ this.markDirty();
6552
+ return false;
6553
+ }
6554
+ const source = picked.source === 'focused' ? t('copy.sourceFocused') : t('copy.sourceAssistant');
6555
+ this.copyPlainText(picked.text, t('copy.ok', { chars: picked.text.length, source }));
6556
+ return true;
6557
+ }
6429
6558
  scrollInspectOrTranscript(delta) {
6430
6559
  if (this.dialog?.kind === 'inspect') {
6431
6560
  this.dialog.offset = Math.max(0, this.dialog.offset + delta);
6432
6561
  this.markDirty();
6433
6562
  return;
6434
6563
  }
6564
+ if (this.paintTailBudget > 0) {
6565
+ this.paintTailBudget = 0;
6566
+ this.forceFullPaint = true;
6567
+ }
6435
6568
  this.scrollOffset = Math.max(0, this.scrollOffset + delta);
6436
6569
  this.markDirty();
6437
6570
  }
@@ -6522,7 +6655,7 @@ export class SshTui {
6522
6655
  .map(item => {
6523
6656
  const descKey = `cmd.${item.name}`;
6524
6657
  const desc = t(descKey, undefined, item.description);
6525
- return `/${item.name.padEnd(12)} ${item.input?.images === true ? t('cmd.withImagesSuffix', { desc }) : desc} (dsh)`;
6658
+ return `/${item.name.padEnd(12)} ${commandAcceptsAttachments(item.input) ? t('cmd.withImagesSuffix', { desc }) : desc} (dsh)`;
6526
6659
  });
6527
6660
  this.pushRow({
6528
6661
  kind: 'system',
@@ -6646,6 +6779,9 @@ export class SshTui {
6646
6779
  this.markDirty();
6647
6780
  });
6648
6781
  break;
6782
+ case 'copy':
6783
+ this.copyFocusedCard();
6784
+ break;
6649
6785
  case 'find':
6650
6786
  this.runFindCommand(arg);
6651
6787
  break;