dsh-ssh-tui 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +63 -38
- package/README.md +44 -20
- package/cordis.patch.yml +6 -0
- package/docs/screenshots/compare.png +0 -0
- package/docs/screenshots/headless.png +0 -0
- package/docs/screenshots/slow-link.gif +0 -0
- package/docs/screenshots/workspace.png +0 -0
- package/lib/approval-reviewer.js +62 -0
- package/lib/approval-reviewer.js.map +1 -0
- package/lib/auto-approval.js +124 -0
- package/lib/auto-approval.js.map +1 -0
- package/lib/display-sock.js +583 -0
- package/lib/display-sock.js.map +1 -0
- package/lib/dsh-compat.js +45 -0
- package/lib/dsh-compat.js.map +1 -0
- package/lib/i18n/en.js +108 -14
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +2 -1
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +108 -14
- package/lib/i18n/zh.js.map +1 -1
- package/lib/index.js +110 -6
- package/lib/index.js.map +1 -1
- package/lib/picker.js +14 -1
- package/lib/picker.js.map +1 -1
- package/lib/provider-catalog.js +169 -0
- package/lib/provider-catalog.js.map +1 -0
- package/lib/route-memory.js +1 -1
- package/lib/route-memory.js.map +1 -1
- package/lib/session-list.js +116 -11
- package/lib/session-list.js.map +1 -1
- package/lib/session-lock.js +182 -6
- package/lib/session-lock.js.map +1 -1
- package/lib/startup.js +2 -0
- package/lib/startup.js.map +1 -1
- package/lib/subagent-model.js +1 -1
- package/lib/subagent-model.js.map +1 -1
- package/lib/tui.js +1776 -370
- package/lib/tui.js.map +1 -1
- package/lib/types/approval-reviewer.d.ts +20 -0
- package/lib/types/auto-approval.d.ts +42 -0
- package/lib/types/display-sock.d.ts +74 -0
- package/lib/types/dsh-compat.d.ts +30 -0
- package/lib/types/i18n/index.d.ts +2 -0
- package/lib/types/picker.d.ts +4 -0
- package/lib/types/provider-catalog.d.ts +35 -0
- package/lib/types/session-list.d.ts +8 -1
- package/lib/types/session-lock.d.ts +51 -0
- package/lib/types/tui.d.ts +208 -24
- package/package.json +46 -30
package/lib/tui.js
CHANGED
|
@@ -20,8 +20,12 @@ import { StringDecoder } from 'node:string_decoder';
|
|
|
20
20
|
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
|
-
import { settingsNamespace } from '
|
|
23
|
+
import { sessionEvents, settingsNamespace } from './dsh-compat.js';
|
|
24
|
+
import { classifyApproval, commandFromArgs, parseAutoApprovalMode } from './auto-approval.js';
|
|
25
|
+
import { buildReviewUserMessage, parseReviewOutput, REVIEW_SYSTEM_PROMPT } from './approval-reviewer.js';
|
|
26
|
+
import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
|
|
24
27
|
import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
|
|
28
|
+
import { detachFromSshSession, DisplayHost, sessionSockPath } from './display-sock.js';
|
|
25
29
|
import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
|
|
26
30
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
27
31
|
import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
|
|
@@ -88,12 +92,29 @@ function providerTemplates() {
|
|
|
88
92
|
},
|
|
89
93
|
};
|
|
90
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The template the wizard's current step works against: the five pinned
|
|
97
|
+
* shapes, or the web-catalog preset chosen through option 6.
|
|
98
|
+
*/
|
|
99
|
+
function onboardTemplate(state) {
|
|
100
|
+
if (state.providerType === 'catalog') {
|
|
101
|
+
return {
|
|
102
|
+
label: state.catalog?.name ?? state.catalog?.id ?? '',
|
|
103
|
+
defaultId: state.catalog?.id ?? '',
|
|
104
|
+
defaultBaseUrl: '',
|
|
105
|
+
defaultModels: state.catalog?.modelIds ?? [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return providerTemplates()[state.providerType];
|
|
109
|
+
}
|
|
91
110
|
const RENDER_INTERVAL_MS = 160;
|
|
92
111
|
const LOCAL_PAINT_INTERVAL_MS = 80;
|
|
93
112
|
const WAIT_INDICATOR_MS = 8000;
|
|
94
113
|
const MIN_PAINT_INTERVAL_MS = 40;
|
|
95
114
|
const MAX_PAINT_INTERVAL_MS = 1000;
|
|
96
115
|
const DSR_PROBE_TIMEOUT_MS = 800;
|
|
116
|
+
/** Give a running turn this long to settle after cancel before we flush anyway. */
|
|
117
|
+
const HANGUP_CANCEL_TIMEOUT_MS = 10_000;
|
|
97
118
|
/** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
|
|
98
119
|
export function formatTokens(n) {
|
|
99
120
|
const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
|
|
@@ -132,6 +153,50 @@ export function resolvePaintIntervalMs(configured, env = process.env, options =
|
|
|
132
153
|
export function detectSshSession(env = process.env) {
|
|
133
154
|
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
134
155
|
}
|
|
156
|
+
/** Node errno on a write/close that means the TTY is gone (SSH drop, HUP). */
|
|
157
|
+
export function isHangupErrno(error) {
|
|
158
|
+
const code = error?.code;
|
|
159
|
+
return code === 'EIO' || code === 'EPIPE' || code === 'ENXIO' || code === 'ECONNRESET';
|
|
160
|
+
}
|
|
161
|
+
const HANGUP_SIGNAL_NAMES = ['SIGHUP', 'SIGTERM', 'SIGINT'];
|
|
162
|
+
/**
|
|
163
|
+
* Replace launcher SIGTERM/SIGINT/SIGHUP handlers with `handler`. SSH drop
|
|
164
|
+
* otherwise lets `dsh` dispose the whole tree before this plugin can detach.
|
|
165
|
+
*/
|
|
166
|
+
export function captureHangupSignals(handler) {
|
|
167
|
+
for (const name of HANGUP_SIGNAL_NAMES) {
|
|
168
|
+
process.removeAllListeners(name);
|
|
169
|
+
process.prependListener(name, handler);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export function releaseHangupSignals(handler) {
|
|
173
|
+
for (const name of HANGUP_SIGNAL_NAMES) {
|
|
174
|
+
process.removeListener(name, handler);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** After detach, extra HUP/TERM from sshd must not kill the leftover Host. */
|
|
178
|
+
export function ignoreFurtherHangupSignals() {
|
|
179
|
+
const ignore = () => { };
|
|
180
|
+
for (const name of HANGUP_SIGNAL_NAMES) {
|
|
181
|
+
process.removeAllListeners(name);
|
|
182
|
+
process.on(name, ignore);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Wait until `isIdle` is true or `timeoutMs` elapses. Used after cancel so a
|
|
187
|
+
* hangup can flush a settled session log instead of tearing a live write.
|
|
188
|
+
*/
|
|
189
|
+
export async function waitUntilIdleOrTimeout(isIdle, timeoutMs, now = Date.now, wait = (ms) => new Promise(resolve => {
|
|
190
|
+
setTimeout(resolve, ms);
|
|
191
|
+
})) {
|
|
192
|
+
const deadline = now() + Math.max(0, timeoutMs);
|
|
193
|
+
while (!isIdle()) {
|
|
194
|
+
if (now() >= deadline)
|
|
195
|
+
return 'timeout';
|
|
196
|
+
await wait(Math.min(50, Math.max(0, deadline - now())));
|
|
197
|
+
}
|
|
198
|
+
return 'idle';
|
|
199
|
+
}
|
|
135
200
|
/** Map a CSI-6n round-trip to a paint cadence. Unknown RTT uses the SSH default. */
|
|
136
201
|
export function paintIntervalForRtt(rttMs) {
|
|
137
202
|
if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
|
|
@@ -297,6 +362,9 @@ export function footerIdentityParts(input) {
|
|
|
297
362
|
parts.push(model);
|
|
298
363
|
if (input.subDiffers)
|
|
299
364
|
parts.push(`sub:${input.subModel}`);
|
|
365
|
+
if (input.balanceText !== undefined && input.balanceText !== '') {
|
|
366
|
+
parts.push(input.balanceText);
|
|
367
|
+
}
|
|
300
368
|
if (input.quotaPercent !== undefined) {
|
|
301
369
|
parts.push(formatFooterQuota(input.quotaPercent, input.quotaCode));
|
|
302
370
|
}
|
|
@@ -386,6 +454,8 @@ const PLUGIN_VERSION = (() => {
|
|
|
386
454
|
}
|
|
387
455
|
})();
|
|
388
456
|
const STALL_WARNING_MS = 60000;
|
|
457
|
+
const DEFAULT_DETACHED_IDLE_MS = 6 * 60 * 60 * 1000;
|
|
458
|
+
const PICKER_WINDOW = 12;
|
|
389
459
|
const CTRL_C_EXIT_WINDOW_MS = 2000;
|
|
390
460
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
391
461
|
const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
|
|
@@ -542,6 +612,7 @@ export function formatStatusReport(input) {
|
|
|
542
612
|
`plan: ${input.plan}`,
|
|
543
613
|
formatQuotaStatusLine(input.quota),
|
|
544
614
|
`paint: ${input.paint}`,
|
|
615
|
+
`disconnect: ${input.disconnect ?? 'pause'}`,
|
|
545
616
|
input.waitingQuestions > 0 ? `questions: waiting ${input.waitingQuestions}` : 'questions: none',
|
|
546
617
|
];
|
|
547
618
|
}
|
|
@@ -566,36 +637,41 @@ export function providerUsesLocalOAuth(provider) {
|
|
|
566
637
|
return id === 'xai' || id === 'grok' || id.startsWith('xai-');
|
|
567
638
|
}
|
|
568
639
|
const LOCAL_COMMANDS = [
|
|
569
|
-
{ name: 'help',
|
|
570
|
-
{ name: 'model',
|
|
571
|
-
{ name: '
|
|
572
|
-
{ name: '
|
|
573
|
-
{ name: '
|
|
574
|
-
{ name: '
|
|
575
|
-
{ name: '
|
|
576
|
-
{ name: '
|
|
577
|
-
{ name: '
|
|
578
|
-
{ name: '
|
|
579
|
-
{ name: '
|
|
580
|
-
{ name: '
|
|
581
|
-
{ name: '
|
|
582
|
-
{ name: '
|
|
583
|
-
{ name: '
|
|
584
|
-
{ name: '
|
|
585
|
-
{ name: '
|
|
586
|
-
{ name: '
|
|
587
|
-
{ name: '
|
|
588
|
-
{ name: '
|
|
640
|
+
{ name: 'help', key: 'cmd.help' },
|
|
641
|
+
{ name: 'model', key: 'cmd.model' },
|
|
642
|
+
{ name: 'effort', key: 'cmd.effort' },
|
|
643
|
+
{ name: 'provider', key: 'cmd.provider' },
|
|
644
|
+
{ name: 'submodel', key: 'cmd.submodel' },
|
|
645
|
+
{ name: 'subeffort', key: 'cmd.subeffort' },
|
|
646
|
+
{ name: 'mode', key: 'cmd.mode' },
|
|
647
|
+
{ name: 'quit', key: 'cmd.quit' },
|
|
648
|
+
{ name: 'exit', key: 'cmd.quit', aliasOf: 'quit' },
|
|
649
|
+
{ name: 'clear', key: 'cmd.clear' },
|
|
650
|
+
{ name: 'status', key: 'cmd.status' },
|
|
651
|
+
{ name: 'disconnect', key: 'cmd.disconnect' },
|
|
652
|
+
{ name: 'approval', key: 'cmd.approval' },
|
|
653
|
+
{ name: 'view', key: 'cmd.view' },
|
|
654
|
+
{ name: 'usage', key: 'cmd.usage' },
|
|
655
|
+
{ name: 'balance', key: 'cmd.usage', aliasOf: 'usage' },
|
|
656
|
+
{ name: 'subagents', key: 'cmd.subagents' },
|
|
657
|
+
{ name: 'resume', key: 'cmd.resume' },
|
|
658
|
+
{ name: 'setup', key: 'cmd.setup' },
|
|
659
|
+
{ name: 'find', key: 'cmd.find' },
|
|
660
|
+
{ name: 'language', key: 'cmd.language' },
|
|
661
|
+
{ name: 'lang', key: 'cmd.language', aliasOf: 'language' },
|
|
662
|
+
{ name: 'dialog-test', key: 'cmd.dialog-test' },
|
|
589
663
|
];
|
|
664
|
+
function commandDescription(name, aliasOf) {
|
|
665
|
+
if (aliasOf !== undefined)
|
|
666
|
+
return t('cmd.aliasOf', { name: aliasOf });
|
|
667
|
+
return t(`cmd.${name}`);
|
|
668
|
+
}
|
|
590
669
|
function localizedCommands() {
|
|
591
|
-
return LOCAL_COMMANDS.map(command => {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
return { name: command.name, description: t('view.cmd') };
|
|
597
|
-
return command;
|
|
598
|
-
});
|
|
670
|
+
return LOCAL_COMMANDS.map(command => ({
|
|
671
|
+
name: command.name,
|
|
672
|
+
description: commandDescription(command.name, 'aliasOf' in command ? command.aliasOf : undefined),
|
|
673
|
+
...('aliasOf' in command ? { aliasOf: command.aliasOf } : {}),
|
|
674
|
+
}));
|
|
599
675
|
}
|
|
600
676
|
/**
|
|
601
677
|
* Terminal cell width for one string.
|
|
@@ -654,40 +730,111 @@ export function shimmerText(text, nowMs, color) {
|
|
|
654
730
|
}
|
|
655
731
|
return out;
|
|
656
732
|
}
|
|
657
|
-
const WAIT_SUMMARY_MAX = 18;
|
|
658
733
|
/**
|
|
659
|
-
* Codex
|
|
660
|
-
* the first
|
|
661
|
-
*
|
|
734
|
+
* Codex `extract_first_bold`: the first **closed** `**bold**` in the thinking
|
|
735
|
+
* stream, else the first markdown heading. An unclosed `**` means the title
|
|
736
|
+
* has not arrived yet, so return undefined and keep the default header —
|
|
737
|
+
* never fall back to hard-truncated reasoning, reply, or prompt text.
|
|
662
738
|
*/
|
|
663
|
-
export function waitSummaryFromReasoning(text
|
|
664
|
-
const raw = text.replace(/\r\n?/gu, '\n')
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
return
|
|
676
|
-
|
|
739
|
+
export function waitSummaryFromReasoning(text) {
|
|
740
|
+
const raw = text.replace(/\r\n?/gu, '\n');
|
|
741
|
+
const chars = Array.from(raw);
|
|
742
|
+
for (let i = 0; i + 1 < chars.length; i += 1) {
|
|
743
|
+
if (chars[i] !== '*' || chars[i + 1] !== '*')
|
|
744
|
+
continue;
|
|
745
|
+
let j = i + 2;
|
|
746
|
+
while (j + 1 < chars.length && !(chars[j] === '*' && chars[j + 1] === '*'))
|
|
747
|
+
j += 1;
|
|
748
|
+
if (j + 1 >= chars.length)
|
|
749
|
+
return undefined;
|
|
750
|
+
const inner = chars.slice(i + 2, j).join('').replace(/\s+/gu, ' ').trim();
|
|
751
|
+
return inner === '' ? undefined : inner;
|
|
752
|
+
}
|
|
753
|
+
const heading = /^#{1,6}\s+(.+)$/mu.exec(raw)?.[1];
|
|
754
|
+
const source = heading?.replace(/\s+/gu, ' ').trim() ?? '';
|
|
755
|
+
return source === '' ? undefined : source;
|
|
677
756
|
}
|
|
678
757
|
/** Wait-card header + optional detail. Header tracks model work when known. */
|
|
679
758
|
export function waitCardCopy(input) {
|
|
680
759
|
const toolTitle = input.toolTitle?.trim() ?? '';
|
|
681
760
|
const toolSummary = input.toolSummary?.trim() ?? '';
|
|
682
|
-
const
|
|
683
|
-
const fromReply = waitSummaryFromReasoning(input.reply ?? '');
|
|
684
|
-
const header = fromReasoning ?? fromReply ?? t('wait.working');
|
|
761
|
+
const header = waitSummaryFromReasoning(input.reasoning ?? '') ?? t('wait.working');
|
|
685
762
|
if (toolTitle !== '') {
|
|
686
|
-
|
|
687
|
-
return { header, detail: `${toolTitle}${extra}` };
|
|
763
|
+
return { header, detail: toolSummary === '' ? toolTitle : `${toolTitle} ${toolSummary}` };
|
|
688
764
|
}
|
|
689
765
|
return { header };
|
|
690
766
|
}
|
|
767
|
+
const WAIT_DETAIL_PREFIX = ' └ ';
|
|
768
|
+
const WAIT_DETAIL_MAX_LINES = 3;
|
|
769
|
+
/**
|
|
770
|
+
* Codex `wrapped_details_lines`: word-wrap the wait-card detail under the
|
|
771
|
+
* ` └ ` prefix, continue wrapped rows at the prefix width, cap at 3 rows and
|
|
772
|
+
* end the last one with an ellipsis when the text does not fit.
|
|
773
|
+
*/
|
|
774
|
+
export function wrapWaitDetails(detail, width, maxLines = WAIT_DETAIL_MAX_LINES) {
|
|
775
|
+
const prefixWidth = displayWidth(WAIT_DETAIL_PREFIX);
|
|
776
|
+
const contentWidth = Math.max(1, width - prefixWidth);
|
|
777
|
+
const rows = [];
|
|
778
|
+
let current = '';
|
|
779
|
+
const flush = () => {
|
|
780
|
+
if (current !== '')
|
|
781
|
+
rows.push(current);
|
|
782
|
+
current = '';
|
|
783
|
+
};
|
|
784
|
+
for (const word of detail.split(/\s+/u)) {
|
|
785
|
+
if (word === '')
|
|
786
|
+
continue;
|
|
787
|
+
let rest = word;
|
|
788
|
+
while (displayWidth(rest) > contentWidth) {
|
|
789
|
+
flush();
|
|
790
|
+
let cut = 0;
|
|
791
|
+
let used = 0;
|
|
792
|
+
for (const char of rest) {
|
|
793
|
+
const charWidth = displayWidth(char);
|
|
794
|
+
if (used + charWidth > contentWidth)
|
|
795
|
+
break;
|
|
796
|
+
used += charWidth;
|
|
797
|
+
cut += char.length;
|
|
798
|
+
}
|
|
799
|
+
if (cut === 0)
|
|
800
|
+
cut = firstCodePointLength(rest);
|
|
801
|
+
rows.push(rest.slice(0, cut));
|
|
802
|
+
rest = rest.slice(cut);
|
|
803
|
+
}
|
|
804
|
+
if (rest === '')
|
|
805
|
+
continue;
|
|
806
|
+
if (current === '')
|
|
807
|
+
current = rest;
|
|
808
|
+
else if (displayWidth(current) + 1 + displayWidth(rest) <= contentWidth)
|
|
809
|
+
current += ` ${rest}`;
|
|
810
|
+
else {
|
|
811
|
+
flush();
|
|
812
|
+
current = rest;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
flush();
|
|
816
|
+
if (rows.length === 0)
|
|
817
|
+
return [];
|
|
818
|
+
const overflow = rows.length > maxLines;
|
|
819
|
+
const kept = overflow ? rows.slice(0, maxLines) : rows;
|
|
820
|
+
if (overflow) {
|
|
821
|
+
// Codex rewrites the last kept row with an explicit ellipsis so it reads
|
|
822
|
+
// as "more below", even when the row itself still has spare room.
|
|
823
|
+
const last = kept[maxLines - 1] ?? '';
|
|
824
|
+
const limit = Math.max(1, contentWidth - 1);
|
|
825
|
+
let cut = 0;
|
|
826
|
+
let used = 0;
|
|
827
|
+
for (const char of last) {
|
|
828
|
+
const charWidth = displayWidth(char);
|
|
829
|
+
if (used + charWidth > limit)
|
|
830
|
+
break;
|
|
831
|
+
used += charWidth;
|
|
832
|
+
cut += char.length;
|
|
833
|
+
}
|
|
834
|
+
kept[maxLines - 1] = `${last.slice(0, cut)}…`;
|
|
835
|
+
}
|
|
836
|
+
return kept.map((line, index) => index === 0 ? `${WAIT_DETAIL_PREFIX}${line}` : `${' '.repeat(prefixWidth)}${line}`);
|
|
837
|
+
}
|
|
691
838
|
export function displayWidth(text) {
|
|
692
839
|
let width = 0;
|
|
693
840
|
for (const char of text) {
|
|
@@ -1463,24 +1610,17 @@ export function quotaAlertText(snapshot, window) {
|
|
|
1463
1610
|
});
|
|
1464
1611
|
}
|
|
1465
1612
|
/**
|
|
1466
|
-
* How often to re-fetch quota,
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
* Hourly/5h: every 10 steps, every 4 when near a threshold.
|
|
1470
|
-
* Weekly: every 50 steps, every 10 when near.
|
|
1471
|
-
* Monthly: every 80 steps, every 20 when near.
|
|
1613
|
+
* How often to re-fetch quota or prepaid balance, counted in model steps.
|
|
1614
|
+
* Default is every 10 steps. Near a remaining-percent threshold, hourly
|
|
1615
|
+
* windows refresh every 4 steps.
|
|
1472
1616
|
*/
|
|
1473
1617
|
export function quotaRefreshEverySteps(window) {
|
|
1474
1618
|
if (window === undefined)
|
|
1475
1619
|
return 10;
|
|
1476
1620
|
const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
|
|
1477
|
-
if (window.period === 'hourly')
|
|
1478
|
-
return
|
|
1479
|
-
|
|
1480
|
-
return near ? 10 : 50;
|
|
1481
|
-
if (window.period === 'monthly')
|
|
1482
|
-
return near ? 20 : 80;
|
|
1483
|
-
return near ? 10 : 50;
|
|
1621
|
+
if (window.period === 'hourly' && near)
|
|
1622
|
+
return 4;
|
|
1623
|
+
return 10;
|
|
1484
1624
|
}
|
|
1485
1625
|
/** @deprecated Same cadence as {@link quotaRefreshEverySteps}; the name predates step accounting. */
|
|
1486
1626
|
export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
|
|
@@ -1694,6 +1834,18 @@ export function parseOpenAiCompatibleBalance(payload, provider, path) {
|
|
|
1694
1834
|
return undefined;
|
|
1695
1835
|
return { provider, plan: provider, lines, sourcePath: path };
|
|
1696
1836
|
}
|
|
1837
|
+
/** Compact footer chip: `余额 86.42 CNY`. Prefers remaining/available lines. */
|
|
1838
|
+
export function formatFooterBalance(snapshot) {
|
|
1839
|
+
const preferred = snapshot.lines.find(line => /剩余|可用|余额|available|remaining|credit/iu.test(line.label))
|
|
1840
|
+
?? snapshot.lines[0];
|
|
1841
|
+
if (preferred === undefined)
|
|
1842
|
+
return undefined;
|
|
1843
|
+
const amount = preferred.amount.trim();
|
|
1844
|
+
if (amount === '')
|
|
1845
|
+
return undefined;
|
|
1846
|
+
const currency = preferred.currency === undefined || preferred.currency === '' ? '' : ` ${preferred.currency}`;
|
|
1847
|
+
return t('footer.balance', { amount: `${amount}${currency}` });
|
|
1848
|
+
}
|
|
1697
1849
|
export function formatAccountBalance(snapshot) {
|
|
1698
1850
|
const header = [`${snapshot.plan} 余额(${snapshot.provider})`];
|
|
1699
1851
|
if (snapshot.available === false)
|
|
@@ -1803,6 +1955,14 @@ function parseJsonArgs(args) {
|
|
|
1803
1955
|
return null;
|
|
1804
1956
|
}
|
|
1805
1957
|
}
|
|
1958
|
+
function firstString(record, keys) {
|
|
1959
|
+
for (const key of keys) {
|
|
1960
|
+
const value = record[key];
|
|
1961
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
1962
|
+
return value;
|
|
1963
|
+
}
|
|
1964
|
+
return '';
|
|
1965
|
+
}
|
|
1806
1966
|
/** A short scalar rendering of one argument value, or null for objects/arrays. */
|
|
1807
1967
|
function scalarText(value) {
|
|
1808
1968
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
@@ -1862,6 +2022,46 @@ function friendlyArgsSummary(name, args) {
|
|
|
1862
2022
|
}
|
|
1863
2023
|
const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
|
|
1864
2024
|
const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
|
|
2025
|
+
/** Sliding window of `windowSize` items that keeps `cursor` visible. */
|
|
2026
|
+
export function pickerWindowStart(cursor, total, windowSize = PICKER_WINDOW) {
|
|
2027
|
+
if (total <= windowSize)
|
|
2028
|
+
return 0;
|
|
2029
|
+
const maxStart = Math.max(0, total - windowSize);
|
|
2030
|
+
const start = cursor - Math.floor((windowSize - 1) / 2);
|
|
2031
|
+
return Math.max(0, Math.min(maxStart, start));
|
|
2032
|
+
}
|
|
2033
|
+
export function parseDisconnectPolicy(raw) {
|
|
2034
|
+
const id = raw.trim().toLowerCase();
|
|
2035
|
+
if (id === 'pause' || id === 'cancel' || id === '暂停')
|
|
2036
|
+
return 'pause';
|
|
2037
|
+
if (id === 'continue' || id === 'keep' || id === '继续')
|
|
2038
|
+
return 'continue';
|
|
2039
|
+
return undefined;
|
|
2040
|
+
}
|
|
2041
|
+
const UNDECLARED_EFFORT_IDS = ['off', 'low', 'medium', 'high', 'max', 'xhigh'];
|
|
2042
|
+
const DEFAULT_EFFORT_ALIASES = new Set(['default', 'none', 'auto', 'reset', '默认']);
|
|
2043
|
+
function effortChoices(ids) {
|
|
2044
|
+
return ids.map(id => ({ id, label: t(`effort.option.${id}`) }));
|
|
2045
|
+
}
|
|
2046
|
+
function undeclaredEffortChoices() {
|
|
2047
|
+
return effortChoices(UNDECLARED_EFFORT_IDS);
|
|
2048
|
+
}
|
|
2049
|
+
function localOAuthEffortChoices(modelId) {
|
|
2050
|
+
return effortChoices(modelId === 'grok-4.6'
|
|
2051
|
+
? ['off', 'low', 'medium', 'high', 'xhigh']
|
|
2052
|
+
: ['off', 'low', 'medium', 'high']);
|
|
2053
|
+
}
|
|
2054
|
+
/** Parse `/effort high` / `/subeffort default`. Empty or unknown → undefined. */
|
|
2055
|
+
export function parseEffortArg(raw) {
|
|
2056
|
+
const id = raw.trim().toLowerCase();
|
|
2057
|
+
if (id === '')
|
|
2058
|
+
return undefined;
|
|
2059
|
+
if (DEFAULT_EFFORT_ALIASES.has(id))
|
|
2060
|
+
return { kind: 'default' };
|
|
2061
|
+
if (/^[a-z][a-z0-9_-]{0,31}$/u.test(id))
|
|
2062
|
+
return { kind: 'id', id };
|
|
2063
|
+
return undefined;
|
|
2064
|
+
}
|
|
1865
2065
|
export function parseWorkspaceView(raw) {
|
|
1866
2066
|
const id = raw.trim().toLowerCase();
|
|
1867
2067
|
if (id === 'detailed' || id === 'detail' || id === 'full' || id === '详细')
|
|
@@ -1885,6 +2085,72 @@ export function countDiffLines(hunks) {
|
|
|
1885
2085
|
}
|
|
1886
2086
|
return total;
|
|
1887
2087
|
}
|
|
2088
|
+
/** Added / removed line counts for a diff (`oldText: null` means a new file). */
|
|
2089
|
+
export function countDiffAddDel(hunks) {
|
|
2090
|
+
const stat = { add: 0, del: 0 };
|
|
2091
|
+
if (hunks === undefined)
|
|
2092
|
+
return stat;
|
|
2093
|
+
for (const hunk of hunks) {
|
|
2094
|
+
stat.add += hunk.newText === '' ? 0 : hunk.newText.split('\n').length;
|
|
2095
|
+
if (hunk.oldText !== null) {
|
|
2096
|
+
stat.del += hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
return stat;
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Git diffstat token, deletions first like `-13 +24`. Zero parts drop out
|
|
2103
|
+
* (a new file shows only `+24`); empty when the diff has no counted lines.
|
|
2104
|
+
*/
|
|
2105
|
+
export function diffStatToken(add, del) {
|
|
2106
|
+
const parts = [];
|
|
2107
|
+
if (del > 0)
|
|
2108
|
+
parts.push(`-${del}`);
|
|
2109
|
+
if (add > 0)
|
|
2110
|
+
parts.push(`+${add}`);
|
|
2111
|
+
return parts.join(' ');
|
|
2112
|
+
}
|
|
2113
|
+
const READ_TOOL_NAMES = new Set(['read']);
|
|
2114
|
+
const TOOL_FLIP_MS = 280;
|
|
2115
|
+
export function toolTargetPath(name, args, fallback = '') {
|
|
2116
|
+
const parsed = parseJsonArgs(args);
|
|
2117
|
+
if (parsed === null)
|
|
2118
|
+
return fallback;
|
|
2119
|
+
if (READ_TOOL_NAMES.has(name)) {
|
|
2120
|
+
return firstString(parsed, ['path', 'file_path', 'url']) || fallback;
|
|
2121
|
+
}
|
|
2122
|
+
if (DIFF_TOOL_NAMES.has(name)) {
|
|
2123
|
+
return firstString(parsed, ['file_path', 'path']) || fallback;
|
|
2124
|
+
}
|
|
2125
|
+
return fallback;
|
|
2126
|
+
}
|
|
2127
|
+
export function countOutputLines(text) {
|
|
2128
|
+
if (text === '')
|
|
2129
|
+
return 0;
|
|
2130
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
2131
|
+
return body === '' ? 0 : body.split('\n').length;
|
|
2132
|
+
}
|
|
2133
|
+
function mergeableToolKind(name) {
|
|
2134
|
+
if (READ_TOOL_NAMES.has(name))
|
|
2135
|
+
return 'read';
|
|
2136
|
+
if (DIFF_TOOL_NAMES.has(name))
|
|
2137
|
+
return 'edit';
|
|
2138
|
+
return undefined;
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Consecutive same-path reads (or edits) collapse onto one card.
|
|
2142
|
+
* A → B → C → A becomes four cards; A ×5 stays one card with repeats=5.
|
|
2143
|
+
*/
|
|
2144
|
+
export function canMergeToolCall(previous, next) {
|
|
2145
|
+
if (previous === undefined)
|
|
2146
|
+
return false;
|
|
2147
|
+
const kind = mergeableToolKind(next.name);
|
|
2148
|
+
if (kind === undefined || mergeableToolKind(previous.name) !== kind)
|
|
2149
|
+
return false;
|
|
2150
|
+
const previousPath = toolTargetPath(previous.name, previous.args, previous.summary);
|
|
2151
|
+
const nextPath = toolTargetPath(next.name, next.args);
|
|
2152
|
+
return previousPath !== '' && previousPath === nextPath;
|
|
2153
|
+
}
|
|
1888
2154
|
export function compactToolGroups(tools) {
|
|
1889
2155
|
const edits = [];
|
|
1890
2156
|
const calls = [];
|
|
@@ -1897,10 +2163,30 @@ export function compactToolGroups(tools) {
|
|
|
1897
2163
|
return {
|
|
1898
2164
|
edits,
|
|
1899
2165
|
calls,
|
|
1900
|
-
editLines: edits.reduce((sum, tool) => sum + Math.max(1, countDiffLines(tool.diff)), 0),
|
|
1901
2166
|
failedCalls: calls.filter(tool => tool.status === 'error').length,
|
|
1902
2167
|
};
|
|
1903
2168
|
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Split compact-view tools into the bursts that belong with each assistant
|
|
2171
|
+
* reply: tools after reply N sit with that reply, until the next reply.
|
|
2172
|
+
*/
|
|
2173
|
+
export function compactToolBursts(rows) {
|
|
2174
|
+
const bursts = [];
|
|
2175
|
+
let current = { after: undefined, tools: [] };
|
|
2176
|
+
bursts.push(current);
|
|
2177
|
+
for (const row of rows) {
|
|
2178
|
+
if (row.kind === 'assistant') {
|
|
2179
|
+
current = { after: row, tools: [] };
|
|
2180
|
+
bursts.push(current);
|
|
2181
|
+
continue;
|
|
2182
|
+
}
|
|
2183
|
+
if (row.kind === 'tool')
|
|
2184
|
+
current.tools.push(row);
|
|
2185
|
+
}
|
|
2186
|
+
return bursts
|
|
2187
|
+
.map(burst => ({ after: burst.after, groups: compactToolGroups(burst.tools) }))
|
|
2188
|
+
.filter(burst => burst.groups.calls.length > 0 || burst.groups.edits.length > 0);
|
|
2189
|
+
}
|
|
1904
2190
|
const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
|
|
1905
2191
|
/**
|
|
1906
2192
|
* Tool calls that already have a dedicated transcript card (goal/change,
|
|
@@ -2432,10 +2718,10 @@ export function toolStateLabel(status) {
|
|
|
2432
2718
|
return 'error';
|
|
2433
2719
|
return 'running…';
|
|
2434
2720
|
}
|
|
2435
|
-
/** Header + SGR spans: default title, dim operand, colored
|
|
2721
|
+
/** Header + SGR spans: default title, dim operand, colored ●. `[ok]` is omitted — the green dot is enough. */
|
|
2436
2722
|
export function buildToolHeader(input) {
|
|
2437
2723
|
const running = input.status === undefined || input.status === 'running';
|
|
2438
|
-
const
|
|
2724
|
+
const stateToken = input.status === 'ok' ? '' : `[${toolStateLabel(input.status)}]`;
|
|
2439
2725
|
const exit = !running && input.command !== undefined
|
|
2440
2726
|
? input.signal !== undefined
|
|
2441
2727
|
? ` [信号 ${input.signal}]`
|
|
@@ -2445,25 +2731,56 @@ export function buildToolHeader(input) {
|
|
|
2445
2731
|
: '';
|
|
2446
2732
|
const spinner = input.spinner ?? '';
|
|
2447
2733
|
const prefix = input.focused ? '▶ ' : ' ';
|
|
2448
|
-
const
|
|
2734
|
+
const flipping = input.flipping === true;
|
|
2735
|
+
const marker = flipping ? '◇' : input.expanded ? '▾' : '▸';
|
|
2449
2736
|
const lead = `${prefix}${marker} ● ${input.title}`;
|
|
2450
2737
|
const summaryText = input.summary === '' ? '' : ` ${input.summary}`;
|
|
2451
|
-
const
|
|
2452
|
-
const
|
|
2453
|
-
const
|
|
2738
|
+
const statToken = input.diffStat === undefined ? '' : diffStatToken(input.diffStat.add, input.diffStat.del);
|
|
2739
|
+
const statText = statToken === '' ? '' : ` ${statToken}`;
|
|
2740
|
+
const stateGap = stateToken === '' ? '' : ' ';
|
|
2741
|
+
const tail = `${stateGap}${stateToken}${exit}${spinner}`;
|
|
2742
|
+
const plain = `${lead}${summaryText}${statText}${tail}`;
|
|
2454
2743
|
const stateCode = toolStateColor(input.status);
|
|
2455
2744
|
const dotIndex = lead.indexOf('●');
|
|
2456
|
-
const stateIndex = lead.length + summaryText.length +
|
|
2745
|
+
const stateIndex = stateToken === '' ? -1 : lead.length + summaryText.length + statText.length + stateGap.length;
|
|
2457
2746
|
const segments = [];
|
|
2747
|
+
if (flipping) {
|
|
2748
|
+
const markerIndex = prefix.length;
|
|
2749
|
+
segments.push({ start: markerIndex, end: markerIndex + marker.length, sgr: '36' });
|
|
2750
|
+
}
|
|
2458
2751
|
if (dotIndex >= 0)
|
|
2459
2752
|
segments.push({ start: dotIndex, end: dotIndex + '●'.length, sgr: stateCode });
|
|
2460
2753
|
if (summaryText.length > 0) {
|
|
2461
2754
|
segments.push({ start: lead.length, end: lead.length + summaryText.length, sgr: '90' });
|
|
2462
2755
|
}
|
|
2463
|
-
|
|
2756
|
+
if (statToken !== '') {
|
|
2757
|
+
// Git diffstat colors: deletions red, additions green. The token sits
|
|
2758
|
+
// two cells after the summary, deletions before the joining space.
|
|
2759
|
+
const statStart = lead.length + summaryText.length + 2;
|
|
2760
|
+
const delEnd = statToken.indexOf(' +');
|
|
2761
|
+
if (statToken.startsWith('-')) {
|
|
2762
|
+
segments.push({
|
|
2763
|
+
start: statStart,
|
|
2764
|
+
end: statStart + (delEnd === -1 ? statToken.length : delEnd),
|
|
2765
|
+
sgr: '31',
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
if (delEnd !== -1) {
|
|
2769
|
+
const addStart = statStart + delEnd + 1;
|
|
2770
|
+
segments.push({ start: addStart, end: statStart + statToken.length, sgr: '32' });
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
if (stateIndex >= 0) {
|
|
2774
|
+
segments.push({ start: stateIndex, end: stateIndex + stateToken.length + exit.length, sgr: stateCode });
|
|
2775
|
+
}
|
|
2776
|
+
else if (exit !== '') {
|
|
2777
|
+
const exitIndex = lead.length + summaryText.length + statText.length;
|
|
2778
|
+
segments.push({ start: exitIndex, end: exitIndex + exit.length, sgr: stateCode });
|
|
2779
|
+
}
|
|
2464
2780
|
if (spinner !== '') {
|
|
2781
|
+
const spinnerStart = (stateIndex >= 0 ? stateIndex + stateToken.length + exit.length : lead.length + summaryText.length + statText.length + exit.length);
|
|
2465
2782
|
segments.push({
|
|
2466
|
-
start:
|
|
2783
|
+
start: spinnerStart,
|
|
2467
2784
|
end: plain.length,
|
|
2468
2785
|
sgr: '90',
|
|
2469
2786
|
});
|
|
@@ -2657,14 +2974,6 @@ export function toolBodyLines(row, maxLines) {
|
|
|
2657
2974
|
}
|
|
2658
2975
|
return unlimited ? out : capDisplayLines(out, maxLines);
|
|
2659
2976
|
}
|
|
2660
|
-
function firstString(record, keys) {
|
|
2661
|
-
for (const key of keys) {
|
|
2662
|
-
const value = record[key];
|
|
2663
|
-
if (typeof value === 'string' && value.trim() !== '')
|
|
2664
|
-
return value;
|
|
2665
|
-
}
|
|
2666
|
-
return '';
|
|
2667
|
-
}
|
|
2668
2977
|
function specializedToolBody(row, maxLines = Number.MAX_SAFE_INTEGER) {
|
|
2669
2978
|
const name = row.name ?? '';
|
|
2670
2979
|
const args = parseJsonArgs(row.args);
|
|
@@ -2773,12 +3082,25 @@ export class SshTui {
|
|
|
2773
3082
|
agent;
|
|
2774
3083
|
rows = [];
|
|
2775
3084
|
streaming;
|
|
3085
|
+
autoApprovalMode = 'off';
|
|
3086
|
+
autoAllowedCount = 0;
|
|
3087
|
+
autoDeniedCount = 0;
|
|
3088
|
+
aiReviewCount = 0;
|
|
3089
|
+
/** Host knobs folded from the session log: auto mode needs approval=ask to see requests. */
|
|
3090
|
+
hostSandboxMode;
|
|
3091
|
+
hostApprovalPolicy;
|
|
3092
|
+
approvalMismatchWarned = false;
|
|
3093
|
+
/** Web-aligned provider presets from the host's pi-ai catalog (undefined until loaded / when unreachable). */
|
|
3094
|
+
catalogPresets;
|
|
3095
|
+
catalogLoad;
|
|
2776
3096
|
input = '';
|
|
2777
3097
|
cursor = 0;
|
|
2778
3098
|
inputFolded = false;
|
|
2779
3099
|
inPaste = false;
|
|
2780
3100
|
history = [];
|
|
2781
3101
|
historyIndex = -1;
|
|
3102
|
+
/** Live input parked while browsing history with ↑. Restored by ↓ past the newest item. */
|
|
3103
|
+
historyDraft = '';
|
|
2782
3104
|
status = 'idle';
|
|
2783
3105
|
dialog;
|
|
2784
3106
|
dialogQueue = [];
|
|
@@ -2786,6 +3108,21 @@ export class SshTui {
|
|
|
2786
3108
|
dirty = true;
|
|
2787
3109
|
disposed = false;
|
|
2788
3110
|
exiting = false;
|
|
3111
|
+
hangingUp = false;
|
|
3112
|
+
onDirectResize = () => {
|
|
3113
|
+
this.forceFullPaint = true;
|
|
3114
|
+
this.dirty = true;
|
|
3115
|
+
this.paint();
|
|
3116
|
+
};
|
|
3117
|
+
headlessDisplay;
|
|
3118
|
+
disconnectPolicy;
|
|
3119
|
+
detachedIdleTimer;
|
|
3120
|
+
displayDetached = false;
|
|
3121
|
+
displayHost;
|
|
3122
|
+
relayColumns;
|
|
3123
|
+
relayRows;
|
|
3124
|
+
onHangup;
|
|
3125
|
+
onReattach;
|
|
2789
3126
|
renderTimer;
|
|
2790
3127
|
decoder = new StringDecoder('utf8');
|
|
2791
3128
|
color;
|
|
@@ -2816,6 +3153,7 @@ export class SshTui {
|
|
|
2816
3153
|
stalledWarningShown = false;
|
|
2817
3154
|
lastPaintAt = 0;
|
|
2818
3155
|
commandAbort;
|
|
3156
|
+
seenCommandDoneIds = new Set();
|
|
2819
3157
|
activeSubagents = new Map();
|
|
2820
3158
|
subagentSessions = new Set();
|
|
2821
3159
|
openToolCalls = new Map();
|
|
@@ -2841,7 +3179,6 @@ export class SshTui {
|
|
|
2841
3179
|
escapeTimer;
|
|
2842
3180
|
thinkingStartedAt;
|
|
2843
3181
|
waitStartedAt;
|
|
2844
|
-
waitPrompt;
|
|
2845
3182
|
completionSignaled = false;
|
|
2846
3183
|
replaying = false;
|
|
2847
3184
|
completedAt = 0;
|
|
@@ -2863,6 +3200,7 @@ export class SshTui {
|
|
|
2863
3200
|
sessionTitle = '';
|
|
2864
3201
|
llmRetry;
|
|
2865
3202
|
quotaSnapshot;
|
|
3203
|
+
balanceSnapshot;
|
|
2866
3204
|
quotaAlerted = new Set();
|
|
2867
3205
|
quotaStepsSinceRefresh = 0;
|
|
2868
3206
|
quotaRefreshInFlight = false;
|
|
@@ -2879,6 +3217,7 @@ export class SshTui {
|
|
|
2879
3217
|
this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
|
|
2880
3218
|
this.showReasoning = config.showReasoning !== false;
|
|
2881
3219
|
this.workspaceView = this.readWorkspaceView();
|
|
3220
|
+
this.autoApprovalMode = this.readAutoApprovalMode();
|
|
2882
3221
|
this.goodbye = config.goodbye
|
|
2883
3222
|
?? this.ctx.get('tuiGoodbyeMessage')
|
|
2884
3223
|
?? `To resume this session: dsh --profile tui --resume=${this.agent.id}`;
|
|
@@ -2888,6 +3227,10 @@ export class SshTui {
|
|
|
2888
3227
|
this.subagentSelection = config.subagentSelection ?? { current: { model: DEFAULT_SUBAGENT_MODEL } };
|
|
2889
3228
|
this.onSwitchSession = config.onSwitchSession;
|
|
2890
3229
|
this.onSelectionChanged = config.onSelectionChanged;
|
|
3230
|
+
this.onHangup = config.onHangup;
|
|
3231
|
+
this.onReattach = config.onReattach;
|
|
3232
|
+
this.headlessDisplay = config.headlessDisplay === true;
|
|
3233
|
+
this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
|
|
2891
3234
|
this.resumePicker = config.resumePicker === true;
|
|
2892
3235
|
this.presetId = config.presetId ?? 'standard';
|
|
2893
3236
|
this.presetName = config.presetName ?? this.presetId;
|
|
@@ -2905,15 +3248,31 @@ export class SshTui {
|
|
|
2905
3248
|
}
|
|
2906
3249
|
/** Enter raw mode, switch to the alternate screen, and start listening. */
|
|
2907
3250
|
start() {
|
|
3251
|
+
detachFromSshSession();
|
|
3252
|
+
captureHangupSignals(this.handleHangupSignal);
|
|
3253
|
+
this.bindAgentEvents();
|
|
3254
|
+
void this.ensureDisplayHost().catch((error) => {
|
|
3255
|
+
if (this.disposed)
|
|
3256
|
+
return;
|
|
3257
|
+
this.pushRow({ kind: 'error', text: `显示通道启动失败: ${errorChain(error)}` });
|
|
3258
|
+
this.markDirty();
|
|
3259
|
+
});
|
|
3260
|
+
if (this.headlessDisplay) {
|
|
3261
|
+
this.displayDetached = true;
|
|
3262
|
+
this.startRenderTimer();
|
|
3263
|
+
this.bootBackgroundTasks();
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
2908
3266
|
process.stdin.setRawMode(true);
|
|
2909
3267
|
process.stdin.resume();
|
|
2910
|
-
process.stdout.on('resize', this.
|
|
2911
|
-
process.
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
3268
|
+
process.stdout.on('resize', this.onDirectResize);
|
|
3269
|
+
if (process.platform !== 'win32') {
|
|
3270
|
+
process.on('SIGWINCH', this.onDirectResize);
|
|
3271
|
+
}
|
|
3272
|
+
process.stdin.prependListener('end', this.handleHangupStream);
|
|
3273
|
+
process.stdin.prependListener('close', this.handleHangupStream);
|
|
3274
|
+
process.stdout.on('error', this.handleIoError);
|
|
3275
|
+
process.stdin.on('error', this.handleIoError);
|
|
2917
3276
|
this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
|
|
2918
3277
|
this.render();
|
|
2919
3278
|
this.updateTerminalTitle();
|
|
@@ -2926,6 +3285,23 @@ export class SshTui {
|
|
|
2926
3285
|
process.stdin.on('data', this.handleData);
|
|
2927
3286
|
this.startRenderTimer();
|
|
2928
3287
|
});
|
|
3288
|
+
this.bootBackgroundTasks();
|
|
3289
|
+
}
|
|
3290
|
+
bindAgentEvents() {
|
|
3291
|
+
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));
|
|
3292
|
+
const questions = this.ctx.get('userQuestions');
|
|
3293
|
+
if (questions !== undefined) {
|
|
3294
|
+
this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
bootBackgroundTasks() {
|
|
3298
|
+
// Warm the web-aligned provider catalog so /setup has it ready instantly
|
|
3299
|
+
// (runs on both the interactive and the detached-display path).
|
|
3300
|
+
this.catalogLoad = loadProviderCatalog([process.argv[1], this.ctx.baseUrl]);
|
|
3301
|
+
this.catalogLoad.then(presets => {
|
|
3302
|
+
this.catalogPresets = presets;
|
|
3303
|
+
this.markDirty();
|
|
3304
|
+
}).catch(() => { });
|
|
2929
3305
|
void this.maybeRunOnboarding().catch((error) => {
|
|
2930
3306
|
if (this.disposed)
|
|
2931
3307
|
return;
|
|
@@ -3020,6 +3396,51 @@ export class SshTui {
|
|
|
3020
3396
|
return 'detailed';
|
|
3021
3397
|
return parseWorkspaceView(String(raw.view ?? '')) ?? 'detailed';
|
|
3022
3398
|
}
|
|
3399
|
+
readDisconnectPolicy() {
|
|
3400
|
+
const env = parseDisconnectPolicy(process.env.DSH_TUI_DISCONNECT ?? '');
|
|
3401
|
+
if (env !== undefined)
|
|
3402
|
+
return env;
|
|
3403
|
+
const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
|
|
3404
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
3405
|
+
return 'pause';
|
|
3406
|
+
return parseDisconnectPolicy(String(raw.disconnect ?? '')) ?? 'pause';
|
|
3407
|
+
}
|
|
3408
|
+
readAutoApprovalMode() {
|
|
3409
|
+
const env = parseAutoApprovalMode(process.env.DSH_TUI_AUTO_APPROVAL ?? '');
|
|
3410
|
+
if (env !== undefined)
|
|
3411
|
+
return env;
|
|
3412
|
+
const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
|
|
3413
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
3414
|
+
return 'off';
|
|
3415
|
+
return parseAutoApprovalMode(String(raw.autoApproval ?? '')) ?? 'off';
|
|
3416
|
+
}
|
|
3417
|
+
detachedIdleMs() {
|
|
3418
|
+
const raw = Number.parseInt(process.env.DSH_TUI_DETACHED_IDLE_MS ?? '', 10);
|
|
3419
|
+
if (Number.isFinite(raw) && raw > 0)
|
|
3420
|
+
return raw;
|
|
3421
|
+
return DEFAULT_DETACHED_IDLE_MS;
|
|
3422
|
+
}
|
|
3423
|
+
clearDetachedIdleTimer() {
|
|
3424
|
+
if (this.detachedIdleTimer !== undefined)
|
|
3425
|
+
clearTimeout(this.detachedIdleTimer);
|
|
3426
|
+
this.detachedIdleTimer = undefined;
|
|
3427
|
+
}
|
|
3428
|
+
armDetachedIdleTimer() {
|
|
3429
|
+
this.clearDetachedIdleTimer();
|
|
3430
|
+
const idleMs = this.detachedIdleMs();
|
|
3431
|
+
this.detachedIdleTimer = setTimeout(() => {
|
|
3432
|
+
if (this.disposed || this.exiting)
|
|
3433
|
+
return;
|
|
3434
|
+
if (this.displayHost?.attached === true)
|
|
3435
|
+
return;
|
|
3436
|
+
if (this.agent.status === 'running') {
|
|
3437
|
+
this.armDetachedIdleTimer();
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
void this.requestExit(0);
|
|
3441
|
+
}, idleMs);
|
|
3442
|
+
this.detachedIdleTimer.unref?.();
|
|
3443
|
+
}
|
|
3023
3444
|
isCompactView() {
|
|
3024
3445
|
return this.workspaceView === 'compact';
|
|
3025
3446
|
}
|
|
@@ -3027,32 +3448,75 @@ export class SshTui {
|
|
|
3027
3448
|
setWorkspaceView(view) {
|
|
3028
3449
|
this.workspaceView = view;
|
|
3029
3450
|
}
|
|
3451
|
+
paintCompactBurst(addDisplay, groups, width) {
|
|
3452
|
+
const callAnchor = groups.calls.at(-1);
|
|
3453
|
+
const editAnchor = groups.edits.at(-1);
|
|
3454
|
+
if (callAnchor !== undefined)
|
|
3455
|
+
this.paintCompactSummary(addDisplay, callAnchor, 'calls', groups, width);
|
|
3456
|
+
if (editAnchor !== undefined)
|
|
3457
|
+
this.paintCompactSummary(addDisplay, editAnchor, 'edits', groups, width);
|
|
3458
|
+
}
|
|
3030
3459
|
paintCompactSummary(addDisplay, anchor, kind, groups, width) {
|
|
3031
|
-
const
|
|
3032
|
-
const
|
|
3033
|
-
const
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3460
|
+
const items = kind === 'edits' ? groups.edits : groups.calls;
|
|
3461
|
+
const running = items.some(item => item.status === undefined || item.status === 'running');
|
|
3462
|
+
const failed = items.length > 0 && items.every(item => item.status === 'error');
|
|
3463
|
+
const status = running ? 'running' : failed ? 'error' : 'ok';
|
|
3464
|
+
const addDel = { add: 0, del: 0 };
|
|
3465
|
+
if (kind === 'edits') {
|
|
3466
|
+
for (const item of groups.edits) {
|
|
3467
|
+
const stat = countDiffAddDel(item.diff);
|
|
3468
|
+
addDel.add += stat.add;
|
|
3469
|
+
addDel.del += stat.del;
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3037
3472
|
const title = kind === 'edits'
|
|
3038
3473
|
? (groups.edits.length > 1
|
|
3039
|
-
? t('compact.editsFiles', {
|
|
3040
|
-
: t('compact.edits'
|
|
3474
|
+
? t('compact.editsFiles', { files: groups.edits.length })
|
|
3475
|
+
: t('compact.edits'))
|
|
3041
3476
|
: (groups.failedCalls > 0
|
|
3042
3477
|
? t('compact.toolsFailed', { count: groups.calls.length, failed: groups.failedCalls })
|
|
3043
3478
|
: t('compact.tools', { count: groups.calls.length }));
|
|
3044
|
-
const header =
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3479
|
+
const header = buildToolHeader({
|
|
3480
|
+
focused: this.focusedRow === anchor,
|
|
3481
|
+
expanded: anchor.expanded,
|
|
3482
|
+
title,
|
|
3483
|
+
summary: '',
|
|
3484
|
+
status,
|
|
3485
|
+
spinner: running ? ` ${this.spinnerFrame()}` : '',
|
|
3486
|
+
flipping: items.some(item => item.flipUntil !== undefined && Date.now() < item.flipUntil),
|
|
3487
|
+
...(kind === 'edits' && (addDel.add > 0 || addDel.del > 0) ? { diffStat: addDel } : {}),
|
|
3488
|
+
});
|
|
3489
|
+
const headerSegments = this.color ? header.segments : [];
|
|
3490
|
+
if (!anchor.expanded) {
|
|
3491
|
+
const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
|
|
3492
|
+
const styled = headerSegments.length === 0
|
|
3493
|
+
? this.styleLine('tool', collapsed)
|
|
3494
|
+
: paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
|
|
3495
|
+
addDisplay(this.focusedRow === anchor && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, anchor);
|
|
3048
3496
|
return;
|
|
3049
|
-
|
|
3497
|
+
}
|
|
3498
|
+
const expandedHeaderLines = headerSegments.length === 0
|
|
3499
|
+
? wrap(header.plain, width).map(line => this.styleLine('tool', line))
|
|
3500
|
+
: wrapSegmented(header.plain, Math.max(1, width), headerSegments);
|
|
3501
|
+
for (const wrapped of expandedHeaderLines) {
|
|
3502
|
+
addDisplay(this.focusedRow === anchor && this.color ? `\x1b[7m${wrapped}\x1b[27m` : wrapped, anchor);
|
|
3503
|
+
}
|
|
3050
3504
|
for (const item of items) {
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3505
|
+
if (kind === 'edits') {
|
|
3506
|
+
const stat = countDiffAddDel(item.diff);
|
|
3507
|
+
const token = diffStatToken(stat.add, stat.del);
|
|
3508
|
+
const extra = token === ''
|
|
3509
|
+
? `${countDiffLines(item.diff) || 1} ${getLocale() === 'en' ? 'lines' : '行'}`
|
|
3510
|
+
: token;
|
|
3511
|
+
addDisplay(this.styleLine('tool-result', truncateToWidth(` ${item.title} ${extra}`, width)), item);
|
|
3512
|
+
for (const line of toolBodyLines(item, Number.MAX_SAFE_INTEGER)) {
|
|
3513
|
+
this.paintToolBodyLine(addDisplay, item, line, width);
|
|
3514
|
+
}
|
|
3515
|
+
continue;
|
|
3516
|
+
}
|
|
3517
|
+
const extra = item.summary;
|
|
3518
|
+
const state = item.status === 'error' ? ' [error]' : item.status === 'ok' ? '' : ' [running…]';
|
|
3519
|
+
addDisplay(this.styleLine('tool-result', truncateToWidth(` ${item.title} ${extra}${state}`, width)), item);
|
|
3056
3520
|
}
|
|
3057
3521
|
}
|
|
3058
3522
|
startRenderTimer() {
|
|
@@ -3067,7 +3531,8 @@ export class SshTui {
|
|
|
3067
3531
|
const animating = (this.streaming !== undefined && this.streaming.reasoning !== '')
|
|
3068
3532
|
|| this.activeSubagents.size > 0
|
|
3069
3533
|
|| this.waitCardVisible()
|
|
3070
|
-
|| this.rows.some(row => (row.kind === '
|
|
3534
|
+
|| this.rows.some(row => (row.kind === 'tool' && row.flipUntil !== undefined && now < row.flipUntil)
|
|
3535
|
+
|| (row.kind === 'question' && row.status === 'waiting')
|
|
3071
3536
|
|| (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
|
|
3072
3537
|
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
|
|
3073
3538
|
|| (row.kind === 'compaction' && row.status === 'running'));
|
|
@@ -3110,7 +3575,7 @@ export class SshTui {
|
|
|
3110
3575
|
replayHistory() {
|
|
3111
3576
|
this.replaying = true;
|
|
3112
3577
|
try {
|
|
3113
|
-
for (const event of this.agent.session
|
|
3578
|
+
for (const event of sessionEvents(this.agent.session)) {
|
|
3114
3579
|
this.handleSessionEvent(this.agent.session, event);
|
|
3115
3580
|
}
|
|
3116
3581
|
}
|
|
@@ -3192,12 +3657,25 @@ export class SshTui {
|
|
|
3192
3657
|
baseUrl: '',
|
|
3193
3658
|
key: '',
|
|
3194
3659
|
models: [],
|
|
3660
|
+
catalogPresets: undefined,
|
|
3661
|
+
catalog: undefined,
|
|
3662
|
+
providerCursor: 0,
|
|
3195
3663
|
saving: false,
|
|
3196
3664
|
resolve: (saved) => {
|
|
3197
3665
|
this.onboardingCompletion = undefined;
|
|
3198
3666
|
resolve(saved);
|
|
3199
3667
|
},
|
|
3200
3668
|
};
|
|
3669
|
+
// Web-aligned catalog presets warm at construction; adopt whatever is
|
|
3670
|
+
// ready now and update the open wizard when the load settles.
|
|
3671
|
+
const state = this.onboarding;
|
|
3672
|
+
state.catalogPresets = this.catalogPresets;
|
|
3673
|
+
void this.catalogLoad?.then(presets => {
|
|
3674
|
+
if (this.onboarding === state && state.catalogPresets === undefined && presets !== undefined) {
|
|
3675
|
+
state.catalogPresets = presets;
|
|
3676
|
+
this.markDirty();
|
|
3677
|
+
}
|
|
3678
|
+
});
|
|
3201
3679
|
this.input = '';
|
|
3202
3680
|
this.cursor = 0;
|
|
3203
3681
|
this.dialog = { kind: 'onboarding' };
|
|
@@ -3218,12 +3696,64 @@ export class SshTui {
|
|
|
3218
3696
|
this.showNextDialog();
|
|
3219
3697
|
this.markDirty();
|
|
3220
3698
|
}
|
|
3221
|
-
/**
|
|
3699
|
+
/**
|
|
3700
|
+
* Drop the TTY without disposing the agent. Safe to call when the fd is
|
|
3701
|
+
* already dead: DECSET restore is best-effort and never throws.
|
|
3702
|
+
*/
|
|
3703
|
+
detachDisplay() {
|
|
3704
|
+
if (this.displayDetached)
|
|
3705
|
+
return;
|
|
3706
|
+
if (this.renderTimer !== undefined)
|
|
3707
|
+
clearInterval(this.renderTimer);
|
|
3708
|
+
this.renderTimer = undefined;
|
|
3709
|
+
if (this.escapeTimer !== undefined)
|
|
3710
|
+
clearTimeout(this.escapeTimer);
|
|
3711
|
+
this.escapeTimer = undefined;
|
|
3712
|
+
process.stdin.removeListener('data', this.handleData);
|
|
3713
|
+
process.stdout.removeListener('resize', this.onDirectResize);
|
|
3714
|
+
process.stdout.removeListener('error', this.handleIoError);
|
|
3715
|
+
process.stdin.removeListener('error', this.handleIoError);
|
|
3716
|
+
process.stdin.removeListener('end', this.handleHangupStream);
|
|
3717
|
+
process.stdin.removeListener('close', this.handleHangupStream);
|
|
3718
|
+
process.removeListener('SIGWINCH', this.onDirectResize);
|
|
3719
|
+
if (!this.hangingUp)
|
|
3720
|
+
releaseHangupSignals(this.handleHangupSignal);
|
|
3721
|
+
try {
|
|
3722
|
+
process.stdin.setRawMode(false);
|
|
3723
|
+
}
|
|
3724
|
+
catch {
|
|
3725
|
+
// stdin may already be closed after SIGHUP.
|
|
3726
|
+
}
|
|
3727
|
+
try {
|
|
3728
|
+
process.stdin.pause();
|
|
3729
|
+
}
|
|
3730
|
+
catch {
|
|
3731
|
+
// ignore
|
|
3732
|
+
}
|
|
3733
|
+
try {
|
|
3734
|
+
process.stdout.write('\x1b]0;\x07');
|
|
3735
|
+
process.stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
|
|
3736
|
+
process.stdout.write(`\x1b[?1000l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
|
|
3737
|
+
}
|
|
3738
|
+
catch (error) {
|
|
3739
|
+
if (!isHangupErrno(error)) {
|
|
3740
|
+
try {
|
|
3741
|
+
process.stderr.write(`dsh-ssh-tui: failed to restore terminal: ${errorChain(error)}\n`);
|
|
3742
|
+
}
|
|
3743
|
+
catch {
|
|
3744
|
+
// both pipes gone
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
this.displayDetached = true;
|
|
3749
|
+
}
|
|
3750
|
+
/** Restore the terminal and drop event wiring. Does not flush or exit. */
|
|
3222
3751
|
async dispose() {
|
|
3223
3752
|
if (this.disposed)
|
|
3224
3753
|
return;
|
|
3225
3754
|
this.disposed = true;
|
|
3226
3755
|
this.exiting = true;
|
|
3756
|
+
this.clearDetachedIdleTimer();
|
|
3227
3757
|
const dialog = this.dialog;
|
|
3228
3758
|
const queued = this.dialogQueue.splice(0);
|
|
3229
3759
|
this.dialog = undefined;
|
|
@@ -3246,12 +3776,6 @@ export class SshTui {
|
|
|
3246
3776
|
pending.reject(new UserQuestionError('TUI closed before the question was answered', 'ASK_ABORTED'));
|
|
3247
3777
|
}
|
|
3248
3778
|
}
|
|
3249
|
-
if (this.renderTimer !== undefined)
|
|
3250
|
-
clearInterval(this.renderTimer);
|
|
3251
|
-
this.renderTimer = undefined;
|
|
3252
|
-
if (this.escapeTimer !== undefined)
|
|
3253
|
-
clearTimeout(this.escapeTimer);
|
|
3254
|
-
this.escapeTimer = undefined;
|
|
3255
3779
|
this.commandAbort?.abort();
|
|
3256
3780
|
this.commandAbort = undefined;
|
|
3257
3781
|
for (const dispose of this.disposers.splice(0)) {
|
|
@@ -3259,31 +3783,123 @@ export class SshTui {
|
|
|
3259
3783
|
}
|
|
3260
3784
|
this.userQuestionDisposer?.();
|
|
3261
3785
|
this.userQuestionDisposer = undefined;
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
this.write('\x1b]0;\x07');
|
|
3268
|
-
// Clear every screen (regular + scrollback) before restoring the terminal.
|
|
3269
|
-
// In no-alternate-screen mode this removes the last painted frame that
|
|
3270
|
-
// would otherwise stay behind the shell prompt after exit.
|
|
3271
|
-
this.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
|
|
3272
|
-
this.write(`\x1b[?1000l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
|
|
3786
|
+
this.detachDisplay();
|
|
3787
|
+
const host = this.displayHost;
|
|
3788
|
+
this.displayHost = undefined;
|
|
3789
|
+
if (host !== undefined)
|
|
3790
|
+
await host.close();
|
|
3273
3791
|
}
|
|
3274
3792
|
/** Human-facing exit with goodbye and flush; called from key handling. */
|
|
3275
3793
|
async requestExit(code) {
|
|
3276
|
-
if (this.
|
|
3794
|
+
if (this.hangingUp)
|
|
3795
|
+
return;
|
|
3796
|
+
if (this.disposed)
|
|
3797
|
+
return;
|
|
3798
|
+
this.exiting = true;
|
|
3799
|
+
this.clearDetachedIdleTimer();
|
|
3800
|
+
this.displayHost?.sendGoodbye();
|
|
3801
|
+
await this.dispose();
|
|
3802
|
+
if (!this.headlessDisplay)
|
|
3803
|
+
this.writeGoodbye();
|
|
3804
|
+
await this.flushSession();
|
|
3805
|
+
this.exitProcess(code);
|
|
3806
|
+
}
|
|
3807
|
+
/**
|
|
3808
|
+
* True while the session is doing work the user would lose by killing the
|
|
3809
|
+
* Host: a running turn (thinking / reply / tools), live subagents, in-flight
|
|
3810
|
+
* compaction, or an LLM retry. Idle (including a waiting approval dialog
|
|
3811
|
+
* after the turn has already settled) is not busy — hangup then exits
|
|
3812
|
+
* instead of leaving a leftover process.
|
|
3813
|
+
*/
|
|
3814
|
+
isBusyForHangupKeepalive() {
|
|
3815
|
+
if (this.agent.status === 'running')
|
|
3816
|
+
return true;
|
|
3817
|
+
if (this.activeSubagents.size > 0)
|
|
3818
|
+
return true;
|
|
3819
|
+
if (this.streaming !== undefined)
|
|
3820
|
+
return true;
|
|
3821
|
+
if (this.openToolCalls.size > 0)
|
|
3822
|
+
return true;
|
|
3823
|
+
if (this.llmRetry !== undefined)
|
|
3824
|
+
return true;
|
|
3825
|
+
if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
|
|
3826
|
+
return true;
|
|
3827
|
+
return false;
|
|
3828
|
+
}
|
|
3829
|
+
/**
|
|
3830
|
+
* SSH / TTY hangup: drop the local display, flush, and either keep the Host
|
|
3831
|
+
* (busy: thinking / reply / tools / subagents) or exit (idle).
|
|
3832
|
+
* When keeping the Host, `pause` cancels the turn; `continue` lets it finish.
|
|
3833
|
+
* Ctrl+C is not a hangup.
|
|
3834
|
+
*/
|
|
3835
|
+
async handleHangup() {
|
|
3836
|
+
if (this.hangingUp || this.disposed)
|
|
3837
|
+
return;
|
|
3838
|
+
this.hangingUp = true;
|
|
3839
|
+
ignoreFurtherHangupSignals();
|
|
3840
|
+
this.detachDisplay();
|
|
3841
|
+
const busy = this.isBusyForHangupKeepalive();
|
|
3842
|
+
const pauseTurn = this.disconnectPolicy !== 'continue';
|
|
3843
|
+
if (pauseTurn && this.agent.status === 'running') {
|
|
3844
|
+
try {
|
|
3845
|
+
this.agent.cancel({ kind: 'user' });
|
|
3846
|
+
}
|
|
3847
|
+
catch {
|
|
3848
|
+
// cancel is best-effort; we still flush below.
|
|
3849
|
+
}
|
|
3850
|
+
await waitUntilIdleOrTimeout(() => this.agent.status !== 'running', HANGUP_CANCEL_TIMEOUT_MS);
|
|
3851
|
+
}
|
|
3852
|
+
await this.flushSession();
|
|
3853
|
+
const keepHost = this.displayHost !== undefined && busy;
|
|
3854
|
+
if (keepHost) {
|
|
3855
|
+
this.hangingUp = false;
|
|
3856
|
+
this.armDetachedIdleTimer();
|
|
3857
|
+
await this.onHangup?.();
|
|
3277
3858
|
return;
|
|
3859
|
+
}
|
|
3278
3860
|
this.exiting = true;
|
|
3279
3861
|
await this.dispose();
|
|
3280
|
-
|
|
3862
|
+
this.exitProcess(129);
|
|
3863
|
+
}
|
|
3864
|
+
handleHangupSignal = () => {
|
|
3865
|
+
void this.handleHangup();
|
|
3866
|
+
};
|
|
3867
|
+
handleHangupStream = () => {
|
|
3868
|
+
void this.handleHangup();
|
|
3869
|
+
};
|
|
3870
|
+
handleIoError = (error) => {
|
|
3871
|
+
if (isHangupErrno(error))
|
|
3872
|
+
void this.handleHangup();
|
|
3873
|
+
};
|
|
3874
|
+
writeGoodbye() {
|
|
3875
|
+
try {
|
|
3876
|
+
process.stdout.write(`\n${sanitizeTerminalText(this.goodbye)}\n`);
|
|
3877
|
+
}
|
|
3878
|
+
catch (error) {
|
|
3879
|
+
if (!isHangupErrno(error)) {
|
|
3880
|
+
try {
|
|
3881
|
+
process.stderr.write(`dsh-ssh-tui: failed to write goodbye: ${errorChain(error)}\n`);
|
|
3882
|
+
}
|
|
3883
|
+
catch {
|
|
3884
|
+
// both pipes gone
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
async flushSession() {
|
|
3281
3890
|
try {
|
|
3282
3891
|
await this.ctx.get('sessions')?.flush(this.agent.session);
|
|
3283
3892
|
}
|
|
3284
3893
|
catch (error) {
|
|
3285
|
-
|
|
3894
|
+
try {
|
|
3895
|
+
process.stderr.write(`dsh-ssh-tui: failed to flush session: ${errorChain(error)}\n`);
|
|
3896
|
+
}
|
|
3897
|
+
catch {
|
|
3898
|
+
// stderr may be gone after hangup
|
|
3899
|
+
}
|
|
3286
3900
|
}
|
|
3901
|
+
}
|
|
3902
|
+
exitProcess(code) {
|
|
3287
3903
|
const exit = this.ctx.get('appExit');
|
|
3288
3904
|
if (exit !== undefined)
|
|
3289
3905
|
exit(code);
|
|
@@ -3309,12 +3925,147 @@ export class SshTui {
|
|
|
3309
3925
|
process.stdout.rows = previousRows;
|
|
3310
3926
|
}
|
|
3311
3927
|
}
|
|
3312
|
-
|
|
3313
|
-
|
|
3928
|
+
screenColumns() {
|
|
3929
|
+
if (this.headlessDisplay || this.displayDetached) {
|
|
3930
|
+
return this.relayColumns ?? process.stdout.columns ?? 80;
|
|
3931
|
+
}
|
|
3932
|
+
return process.stdout.columns ?? 80;
|
|
3314
3933
|
}
|
|
3315
|
-
|
|
3316
|
-
this.
|
|
3934
|
+
screenRows() {
|
|
3935
|
+
if (this.headlessDisplay || this.displayDetached) {
|
|
3936
|
+
return this.relayRows ?? process.stdout.rows ?? 24;
|
|
3937
|
+
}
|
|
3938
|
+
return process.stdout.rows ?? 24;
|
|
3939
|
+
}
|
|
3940
|
+
write(chunk) {
|
|
3941
|
+
const host = this.displayHost;
|
|
3942
|
+
if (host?.attached === true) {
|
|
3943
|
+
host.sendStdout(chunk);
|
|
3944
|
+
if (this.displayDetached)
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3947
|
+
else if (this.displayDetached) {
|
|
3948
|
+
return;
|
|
3949
|
+
}
|
|
3950
|
+
try {
|
|
3951
|
+
process.stdout.write(chunk);
|
|
3952
|
+
}
|
|
3953
|
+
catch (error) {
|
|
3954
|
+
if (isHangupErrno(error)) {
|
|
3955
|
+
void this.handleHangup();
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
throw error;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
async ensureDisplayHost() {
|
|
3962
|
+
if (this.displayHost !== undefined || this.disposed)
|
|
3963
|
+
return;
|
|
3964
|
+
const host = new DisplayHost(sessionSockPath(String(this.agent.id)), {
|
|
3965
|
+
onStdin: (bytes) => {
|
|
3966
|
+
if (this.disposed)
|
|
3967
|
+
return;
|
|
3968
|
+
this.handleData(bytes);
|
|
3969
|
+
},
|
|
3970
|
+
onResize: (columns, rows) => {
|
|
3971
|
+
const changed = this.relayColumns !== columns || this.relayRows !== rows;
|
|
3972
|
+
this.relayColumns = columns;
|
|
3973
|
+
this.relayRows = rows;
|
|
3974
|
+
if (this.displayHost?.attached === true && this.displayDetached) {
|
|
3975
|
+
this.attachRelayDisplay();
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
if (changed) {
|
|
3979
|
+
this.forceFullPaint = true;
|
|
3980
|
+
this.dirty = true;
|
|
3981
|
+
this.paint();
|
|
3982
|
+
}
|
|
3983
|
+
},
|
|
3984
|
+
onRtt: (rttMs) => {
|
|
3985
|
+
this.applyProbedRtt(rttMs);
|
|
3986
|
+
},
|
|
3987
|
+
onDetach: () => {
|
|
3988
|
+
if (this.disposed || this.hangingUp)
|
|
3989
|
+
return;
|
|
3990
|
+
void this.handleHangup();
|
|
3991
|
+
},
|
|
3992
|
+
onAttach: () => {
|
|
3993
|
+
if (this.disposed)
|
|
3994
|
+
return;
|
|
3995
|
+
if (this.relayColumns !== undefined && this.relayRows !== undefined) {
|
|
3996
|
+
this.attachRelayDisplay();
|
|
3997
|
+
}
|
|
3998
|
+
},
|
|
3999
|
+
});
|
|
4000
|
+
await host.listen();
|
|
4001
|
+
if (this.disposed) {
|
|
4002
|
+
await host.close();
|
|
4003
|
+
return;
|
|
4004
|
+
}
|
|
4005
|
+
this.displayHost = host;
|
|
4006
|
+
}
|
|
4007
|
+
/** Re-open DECSET and start painting to an attached Display relay. */
|
|
4008
|
+
attachRelayDisplay() {
|
|
4009
|
+
this.displayDetached = false;
|
|
4010
|
+
this.hangingUp = false;
|
|
4011
|
+
this.clearDetachedIdleTimer();
|
|
4012
|
+
this.lastActivity = Date.now();
|
|
4013
|
+
this.stalledWarningShown = false;
|
|
4014
|
+
this.lastPaintRows = [];
|
|
4015
|
+
this.lastChromeKey = '';
|
|
4016
|
+
this.lastTranscriptStart = -1;
|
|
4017
|
+
this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
|
|
4018
|
+
this.forceFullPaint = true;
|
|
4019
|
+
this.dirty = true;
|
|
4020
|
+
this.paint();
|
|
4021
|
+
this.startRenderTimer();
|
|
4022
|
+
void this.onReattach?.();
|
|
4023
|
+
}
|
|
4024
|
+
currentDisconnectPolicy() {
|
|
4025
|
+
return this.disconnectPolicy;
|
|
4026
|
+
}
|
|
4027
|
+
applyProbedRtt(rttMs) {
|
|
4028
|
+
const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
4029
|
+
this.paintLink = 'ssh';
|
|
4030
|
+
this.paintProbed = rttMs !== undefined;
|
|
4031
|
+
this.paintRttMs = rttMs;
|
|
4032
|
+
if (!(Number.isFinite(envOverride) && envOverride > 0)) {
|
|
4033
|
+
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
|
|
4034
|
+
this.startRenderTimer();
|
|
4035
|
+
}
|
|
4036
|
+
this.markDirty();
|
|
4037
|
+
}
|
|
4038
|
+
markDirty = () => {
|
|
4039
|
+
this.dirty = true;
|
|
3317
4040
|
};
|
|
4041
|
+
toolCardSummary(row) {
|
|
4042
|
+
const repeats = row.repeats ?? 1;
|
|
4043
|
+
const parts = [];
|
|
4044
|
+
if (row.summary !== '')
|
|
4045
|
+
parts.push(row.summary);
|
|
4046
|
+
if (repeats > 1)
|
|
4047
|
+
parts.push(t('tool.repeatCount', { count: repeats }));
|
|
4048
|
+
if (READ_TOOL_NAMES.has(row.name) && (row.totalChars !== undefined || row.totalLines !== undefined)) {
|
|
4049
|
+
const chars = row.totalChars ?? 0;
|
|
4050
|
+
const lines = row.totalLines ?? 0;
|
|
4051
|
+
parts.push(t('tool.readStats', { chars: formatTokens(chars), lines: String(lines) }));
|
|
4052
|
+
}
|
|
4053
|
+
return parts.join(' · ');
|
|
4054
|
+
}
|
|
4055
|
+
mergeIntoToolCard(previous, next) {
|
|
4056
|
+
previous.callId = next.callId;
|
|
4057
|
+
previous.name = next.name;
|
|
4058
|
+
previous.args = next.args;
|
|
4059
|
+
previous.title = next.title;
|
|
4060
|
+
previous.summary = next.summary;
|
|
4061
|
+
previous.status = 'running';
|
|
4062
|
+
previous.output = '';
|
|
4063
|
+
previous.exitCode = undefined;
|
|
4064
|
+
previous.signal = undefined;
|
|
4065
|
+
previous.repeats = (previous.repeats ?? 1) + 1;
|
|
4066
|
+
if (!this.replaying)
|
|
4067
|
+
previous.flipUntil = Date.now() + TOOL_FLIP_MS;
|
|
4068
|
+
}
|
|
3318
4069
|
/** Append one transcript row, bounding memory on long sessions. */
|
|
3319
4070
|
pushRow(row) {
|
|
3320
4071
|
this.rows.push(row);
|
|
@@ -3331,19 +4082,31 @@ export class SshTui {
|
|
|
3331
4082
|
/** The transcript rows that support per-row expand/collapse. */
|
|
3332
4083
|
collapsibleRows() {
|
|
3333
4084
|
const compact = this.isCompactView();
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
return false;
|
|
3337
|
-
return row.kind === 'reasoning'
|
|
3338
|
-
|| row.kind === 'tool'
|
|
3339
|
-
|| row.kind === 'subagent'
|
|
4085
|
+
if (compact) {
|
|
4086
|
+
const rows = this.rows.filter((row) => row.kind === 'subagent'
|
|
3340
4087
|
|| row.kind === 'plan'
|
|
3341
4088
|
|| row.kind === 'question'
|
|
3342
4089
|
|| row.kind === 'goal'
|
|
3343
|
-
|| row.kind === 'compaction'
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
4090
|
+
|| row.kind === 'compaction');
|
|
4091
|
+
for (const burst of compactToolBursts(this.rows)) {
|
|
4092
|
+
const callAnchor = burst.groups.calls.at(-1);
|
|
4093
|
+
const editAnchor = burst.groups.edits.at(-1);
|
|
4094
|
+
if (callAnchor !== undefined)
|
|
4095
|
+
rows.push(callAnchor);
|
|
4096
|
+
if (editAnchor !== undefined)
|
|
4097
|
+
rows.push(editAnchor);
|
|
4098
|
+
}
|
|
4099
|
+
return rows;
|
|
4100
|
+
}
|
|
4101
|
+
const rows = this.rows.filter((row) => row.kind === 'reasoning'
|
|
4102
|
+
|| row.kind === 'tool'
|
|
4103
|
+
|| row.kind === 'subagent'
|
|
4104
|
+
|| row.kind === 'plan'
|
|
4105
|
+
|| row.kind === 'question'
|
|
4106
|
+
|| row.kind === 'goal'
|
|
4107
|
+
|| row.kind === 'compaction'
|
|
4108
|
+
|| row.kind === 'prompt');
|
|
4109
|
+
if (this.streaming !== undefined && this.streaming.reasoning !== '') {
|
|
3347
4110
|
this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
3348
4111
|
rows.push(this.streamingReasoning);
|
|
3349
4112
|
}
|
|
@@ -3353,26 +4116,27 @@ export class SshTui {
|
|
|
3353
4116
|
return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
|
|
3354
4117
|
}
|
|
3355
4118
|
/**
|
|
3356
|
-
* Codex wait card: shown while the turn is running.
|
|
3357
|
-
*
|
|
4119
|
+
* Codex wait card: shown while the turn is running. The live thinking
|
|
4120
|
+
* stream feeds the shimmer header; a live tool becomes the detail rows.
|
|
4121
|
+
* While the reply itself is streaming, the transcript paints those tokens
|
|
4122
|
+
* and the card yields (Codex hides the status row once output commits).
|
|
3358
4123
|
*/
|
|
3359
4124
|
waitCardVisible() {
|
|
3360
4125
|
if (this.agent.status !== 'running')
|
|
3361
4126
|
return false;
|
|
4127
|
+
if (this.streaming?.text)
|
|
4128
|
+
return false;
|
|
3362
4129
|
if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
|
|
3363
4130
|
return false;
|
|
3364
4131
|
if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
|
|
3365
4132
|
return false;
|
|
3366
4133
|
return true;
|
|
3367
4134
|
}
|
|
3368
|
-
beginWait(
|
|
4135
|
+
beginWait() {
|
|
3369
4136
|
this.waitStartedAt = Date.now();
|
|
3370
|
-
const trimmed = prompt?.replace(/\s+/gu, ' ').trim();
|
|
3371
|
-
this.waitPrompt = trimmed === undefined || trimmed === '' ? this.waitPrompt : trimmed;
|
|
3372
4137
|
}
|
|
3373
4138
|
endWait() {
|
|
3374
4139
|
this.waitStartedAt = undefined;
|
|
3375
|
-
this.waitPrompt = undefined;
|
|
3376
4140
|
}
|
|
3377
4141
|
waitCardSource() {
|
|
3378
4142
|
const liveTool = this.rows.findLast((row) => row.kind === 'tool' && (row.status === undefined || row.status === 'running'));
|
|
@@ -3383,8 +4147,6 @@ export class SshTui {
|
|
|
3383
4147
|
? {}
|
|
3384
4148
|
: { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity }),
|
|
3385
4149
|
...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
|
|
3386
|
-
...(this.streaming?.text ? { reply: this.streaming.text } : {}),
|
|
3387
|
-
...(this.waitPrompt === undefined ? {} : { prompt: this.waitPrompt }),
|
|
3388
4150
|
};
|
|
3389
4151
|
}
|
|
3390
4152
|
planShouldDefaultExpand(plan) {
|
|
@@ -3684,8 +4446,8 @@ export class SshTui {
|
|
|
3684
4446
|
}
|
|
3685
4447
|
toggleCard(target) {
|
|
3686
4448
|
if (target.kind === 'tool' && !target.expanded) {
|
|
3687
|
-
const width = Math.max(10,
|
|
3688
|
-
const height = Math.max(6,
|
|
4449
|
+
const width = Math.max(10, this.screenColumns());
|
|
4450
|
+
const height = Math.max(6, this.screenRows());
|
|
3689
4451
|
const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
|
|
3690
4452
|
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
3691
4453
|
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
@@ -3711,8 +4473,8 @@ export class SshTui {
|
|
|
3711
4473
|
this.focusedRow = null;
|
|
3712
4474
|
}
|
|
3713
4475
|
else {
|
|
3714
|
-
const width = Math.max(10,
|
|
3715
|
-
const height = Math.max(6,
|
|
4476
|
+
const width = Math.max(10, this.screenColumns());
|
|
4477
|
+
const height = Math.max(6, this.screenRows());
|
|
3716
4478
|
const workspace = this.workspaceRowsFor(width, height);
|
|
3717
4479
|
for (const row of rows) {
|
|
3718
4480
|
if (row.kind === 'tool') {
|
|
@@ -3736,8 +4498,8 @@ export class SshTui {
|
|
|
3736
4498
|
if (row === undefined)
|
|
3737
4499
|
return;
|
|
3738
4500
|
if (row.kind === 'tool') {
|
|
3739
|
-
const width = Math.max(10,
|
|
3740
|
-
const height = Math.max(6,
|
|
4501
|
+
const width = Math.max(10, this.screenColumns());
|
|
4502
|
+
const height = Math.max(6, this.screenRows());
|
|
3741
4503
|
const body = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
|
|
3742
4504
|
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
3743
4505
|
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
@@ -3823,8 +4585,8 @@ export class SshTui {
|
|
|
3823
4585
|
paint = () => {
|
|
3824
4586
|
if (this.exiting)
|
|
3825
4587
|
return;
|
|
3826
|
-
const width = Math.max(10,
|
|
3827
|
-
const height = Math.max(6,
|
|
4588
|
+
const width = Math.max(10, this.screenColumns());
|
|
4589
|
+
const height = Math.max(6, this.screenRows());
|
|
3828
4590
|
if (this.dialog?.kind === 'inspect') {
|
|
3829
4591
|
this.paintInspectOverlay(width, height);
|
|
3830
4592
|
return;
|
|
@@ -3850,24 +4612,21 @@ export class SshTui {
|
|
|
3850
4612
|
}
|
|
3851
4613
|
};
|
|
3852
4614
|
const compact = this.isCompactView();
|
|
3853
|
-
const
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
4615
|
+
const compactBursts = compact ? compactToolBursts(this.rows) : [];
|
|
4616
|
+
const compactBurstByReply = new Map();
|
|
4617
|
+
for (const burst of compactBursts) {
|
|
4618
|
+
if (burst.after !== undefined)
|
|
4619
|
+
compactBurstByReply.set(burst.after, burst);
|
|
4620
|
+
}
|
|
4621
|
+
let paintedLeadingCompact = false;
|
|
3858
4622
|
for (const row of this.rows) {
|
|
3859
|
-
if (compact && (row.kind === 'reasoning' || row.kind === 'prompt'))
|
|
3860
|
-
continue;
|
|
3861
|
-
if (compact && compactGroups !== undefined && row.kind === 'tool') {
|
|
3862
|
-
if (row === compactEditCard) {
|
|
3863
|
-
this.paintCompactSummary(addDisplay, row, 'edits', compactGroups, width);
|
|
3864
|
-
continue;
|
|
3865
|
-
}
|
|
3866
|
-
if (row === compactCallCard) {
|
|
3867
|
-
this.paintCompactSummary(addDisplay, row, 'calls', compactGroups, width);
|
|
3868
|
-
continue;
|
|
3869
|
-
}
|
|
4623
|
+
if (compact && (row.kind === 'reasoning' || row.kind === 'prompt' || row.kind === 'tool'))
|
|
3870
4624
|
continue;
|
|
4625
|
+
if (compact && !paintedLeadingCompact && (row.kind === 'assistant' || row.kind === 'user')) {
|
|
4626
|
+
const leading = compactBursts.find(burst => burst.after === undefined);
|
|
4627
|
+
if (leading !== undefined)
|
|
4628
|
+
this.paintCompactBurst(addDisplay, leading.groups, width);
|
|
4629
|
+
paintedLeadingCompact = true;
|
|
3871
4630
|
}
|
|
3872
4631
|
if (row.kind === 'brand-logo') {
|
|
3873
4632
|
const variant = DEEPSEEK_LOGO_VARIANTS.find(candidate => candidate.width <= width - 2)
|
|
@@ -3903,12 +4662,16 @@ export class SshTui {
|
|
|
3903
4662
|
focused,
|
|
3904
4663
|
expanded: row.expanded,
|
|
3905
4664
|
title: toolTitle(row.name) || row.title,
|
|
3906
|
-
summary: row
|
|
4665
|
+
summary: this.toolCardSummary(row),
|
|
3907
4666
|
status: row.status,
|
|
3908
4667
|
command: row.command,
|
|
3909
4668
|
signal: row.signal,
|
|
3910
4669
|
exitCode: row.exitCode,
|
|
3911
4670
|
spinner: running ? ` ${this.spinnerFrame()}` : '',
|
|
4671
|
+
flipping: row.flipUntil !== undefined && Date.now() < row.flipUntil,
|
|
4672
|
+
...(row.diff !== undefined && row.diff.length > 0
|
|
4673
|
+
? { diffStat: countDiffAddDel(row.diff) }
|
|
4674
|
+
: {}),
|
|
3912
4675
|
});
|
|
3913
4676
|
const headerSegments = this.color ? header.segments : [];
|
|
3914
4677
|
if (!row.expanded) {
|
|
@@ -4076,6 +4839,13 @@ export class SshTui {
|
|
|
4076
4839
|
continue;
|
|
4077
4840
|
}
|
|
4078
4841
|
pushRow(row.kind, row.text, row);
|
|
4842
|
+
if (compact && row.kind === 'assistant') {
|
|
4843
|
+
const burst = compactBurstByReply.get(row);
|
|
4844
|
+
const lastAssistant = this.rows.findLast((item) => item.kind === 'assistant');
|
|
4845
|
+
if (burst !== undefined && (row !== lastAssistant || this.streaming === undefined)) {
|
|
4846
|
+
this.paintCompactBurst(addDisplay, burst.groups, width);
|
|
4847
|
+
}
|
|
4848
|
+
}
|
|
4079
4849
|
}
|
|
4080
4850
|
if (this.streaming !== undefined) {
|
|
4081
4851
|
if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
|
|
@@ -4109,6 +4879,18 @@ export class SshTui {
|
|
|
4109
4879
|
}
|
|
4110
4880
|
}
|
|
4111
4881
|
}
|
|
4882
|
+
if (compact) {
|
|
4883
|
+
const lastAssistant = this.rows.findLast((row) => row.kind === 'assistant');
|
|
4884
|
+
if (this.streaming !== undefined) {
|
|
4885
|
+
const openBurst = compactBursts.find(burst => burst.after === lastAssistant);
|
|
4886
|
+
if (openBurst !== undefined)
|
|
4887
|
+
this.paintCompactBurst(addDisplay, openBurst.groups, width);
|
|
4888
|
+
}
|
|
4889
|
+
const leading = compactBursts.find(burst => burst.after === undefined);
|
|
4890
|
+
if (leading !== undefined && !paintedLeadingCompact) {
|
|
4891
|
+
this.paintCompactBurst(addDisplay, leading.groups, width);
|
|
4892
|
+
}
|
|
4893
|
+
}
|
|
4112
4894
|
if (this.waitCardVisible()) {
|
|
4113
4895
|
const copy = waitCardCopy(this.waitCardSource());
|
|
4114
4896
|
const started = this.waitStartedAt ?? Date.now();
|
|
@@ -4119,8 +4901,8 @@ export class SshTui {
|
|
|
4119
4901
|
? `${spinner} ${shimmerText(copy.header, Date.now(), true)} ${this.styleLine('system', hint)}`
|
|
4120
4902
|
: `${spinner} ${copy.header} ${hint}`;
|
|
4121
4903
|
addDisplay(header);
|
|
4122
|
-
|
|
4123
|
-
addDisplay(this.styleLine('system',
|
|
4904
|
+
for (const line of wrapWaitDetails(copy.detail ?? '', width)) {
|
|
4905
|
+
addDisplay(this.styleLine('system', line));
|
|
4124
4906
|
}
|
|
4125
4907
|
}
|
|
4126
4908
|
const dialogLines = [];
|
|
@@ -4137,32 +4919,47 @@ export class SshTui {
|
|
|
4137
4919
|
else if (this.dialog.kind === 'onboarding') {
|
|
4138
4920
|
const ob = this.onboarding;
|
|
4139
4921
|
if (ob !== undefined) {
|
|
4140
|
-
const template =
|
|
4922
|
+
const template = onboardTemplate(ob);
|
|
4141
4923
|
const providerLabel = `${template.label}${template.defaultBaseUrl === '' ? '' : `(${template.defaultBaseUrl})`}`;
|
|
4142
4924
|
switch (ob.step) {
|
|
4143
|
-
case 'provider':
|
|
4925
|
+
case 'provider': {
|
|
4926
|
+
const options = this.mergedProviderEntries(ob);
|
|
4144
4927
|
addDialog(t('onboard.title'));
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4928
|
+
if (options.length === 0) {
|
|
4929
|
+
addDialog(t('onboard.catalogEmpty'));
|
|
4930
|
+
break;
|
|
4931
|
+
}
|
|
4932
|
+
const start = pickerWindowStart(ob.providerCursor, options.length);
|
|
4933
|
+
const end = Math.min(options.length, start + PICKER_WINDOW);
|
|
4934
|
+
if (start > 0)
|
|
4935
|
+
addDialog(` ↑ 还有 ${start} 项`);
|
|
4936
|
+
for (let index = start; index < end; index += 1) {
|
|
4937
|
+
const option = options[index];
|
|
4938
|
+
if (option === undefined)
|
|
4939
|
+
continue;
|
|
4940
|
+
const focused = index === ob.providerCursor ? '›' : ' ';
|
|
4941
|
+
addDialog(` ${focused} ○ ${option.label}${option.detail === '' ? '' : ` — ${option.detail}`}`);
|
|
4942
|
+
}
|
|
4943
|
+
if (end < options.length)
|
|
4944
|
+
addDialog(` ↓ 还有 ${options.length - end} 项`);
|
|
4945
|
+
if (this.input.trim() !== '')
|
|
4946
|
+
addDialog(t('onboard.catalogHint', { count: options.length }));
|
|
4150
4947
|
addDialog(t('onboard.pickHint'));
|
|
4151
4948
|
break;
|
|
4949
|
+
}
|
|
4152
4950
|
case 'id':
|
|
4153
4951
|
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
4154
4952
|
addDialog(t('onboard.idPrompt'));
|
|
4155
4953
|
addDialog(t('onboard.default', { value: template.defaultId }));
|
|
4156
4954
|
addDialog(t('onboard.enterEsc'));
|
|
4157
4955
|
break;
|
|
4158
|
-
case 'key':
|
|
4159
|
-
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
4160
|
-
addDialog(t('onboard.keyPrompt'));
|
|
4161
|
-
addDialog(t('onboard.enterEsc'));
|
|
4162
|
-
break;
|
|
4163
4956
|
case 'base-url':
|
|
4164
4957
|
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
4165
|
-
addDialog(t('onboard.basePrompt', {
|
|
4958
|
+
addDialog(t('onboard.basePrompt', {
|
|
4959
|
+
fallback: template.defaultBaseUrl !== ''
|
|
4960
|
+
? template.defaultBaseUrl
|
|
4961
|
+
: ob.providerType === 'catalog' ? t('onboard.baseFallbackCatalog') : t('onboard.baseFallback'),
|
|
4962
|
+
}));
|
|
4166
4963
|
addDialog(t('onboard.enterEsc'));
|
|
4167
4964
|
break;
|
|
4168
4965
|
case 'models':
|
|
@@ -4173,6 +4970,8 @@ export class SshTui {
|
|
|
4173
4970
|
: t('onboard.default', { value: template.defaultModels.join(', ') }));
|
|
4174
4971
|
if (template.api !== undefined)
|
|
4175
4972
|
addDialog(t('onboard.ctrlF'));
|
|
4973
|
+
if (ob.providerType === 'catalog')
|
|
4974
|
+
addDialog(t('onboard.modelsCatalogHint'));
|
|
4176
4975
|
addDialog(t('onboard.enterEsc'));
|
|
4177
4976
|
break;
|
|
4178
4977
|
case 'confirm':
|
|
@@ -4180,7 +4979,9 @@ export class SshTui {
|
|
|
4180
4979
|
addDialog(t('onboard.confirmProvider', { label: providerLabel }));
|
|
4181
4980
|
addDialog(` Provider ID: ${ob.providerId}`);
|
|
4182
4981
|
addDialog(t('onboard.confirmBase', { url: ob.baseUrl === '' ? (template.defaultBaseUrl || t('onboard.defaultParen')) : ob.baseUrl }));
|
|
4183
|
-
addDialog(t('onboard.confirmApi', {
|
|
4982
|
+
addDialog(t('onboard.confirmApi', {
|
|
4983
|
+
api: template.api ?? (ob.providerType === 'catalog' ? t('onboard.apiCatalog') : 'deepseek-official'),
|
|
4984
|
+
}));
|
|
4184
4985
|
addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
|
|
4185
4986
|
addDialog(t('onboard.confirmKey', {
|
|
4186
4987
|
head: sliceCodePoints(ob.key, 6),
|
|
@@ -4214,17 +5015,27 @@ export class SshTui {
|
|
|
4214
5015
|
}
|
|
4215
5016
|
const options = d.question.options ?? [];
|
|
4216
5017
|
const approve = d.question.intent?.approve;
|
|
4217
|
-
|
|
5018
|
+
const start = pickerWindowStart(d.cursor, options.length);
|
|
5019
|
+
const end = Math.min(options.length, start + PICKER_WINDOW);
|
|
5020
|
+
if (start > 0)
|
|
5021
|
+
addDialog(` ↑ 还有 ${start} 项`);
|
|
5022
|
+
for (let index = start; index < end; index += 1) {
|
|
5023
|
+
const option = options[index];
|
|
5024
|
+
if (option === undefined)
|
|
5025
|
+
continue;
|
|
4218
5026
|
const marker = d.selected.has(index) ? '●' : '○';
|
|
4219
|
-
const key = QUESTION_OPTION_KEYS[index] ?? '
|
|
5027
|
+
const key = QUESTION_OPTION_KEYS[index] ?? '↕';
|
|
5028
|
+
const focused = index === d.cursor ? '›' : ' ';
|
|
4220
5029
|
const recommended = option.label === approve ? '(推荐)' : '';
|
|
4221
5030
|
const extra = option.description === undefined ? '' : ` — ${option.description}`;
|
|
4222
|
-
addDialog(`
|
|
5031
|
+
addDialog(` ${focused}${key} ${marker} ${option.label}${recommended}${extra}`);
|
|
4223
5032
|
}
|
|
5033
|
+
if (end < options.length)
|
|
5034
|
+
addDialog(` ↓ 还有 ${options.length - end} 项`);
|
|
4224
5035
|
if (options.length === 0) {
|
|
4225
5036
|
addDialog(' (自由输入:在下方输入后按 Enter)');
|
|
4226
5037
|
}
|
|
4227
|
-
addDialog(` ${d.question.multiSelect === true ? '
|
|
5038
|
+
addDialog(` ${d.question.multiSelect === true ? '↑/↓ 或数字/字母切换,Enter 提交' : '↑/↓ 或数字/字母选择,Enter 提交'},Esc 取消`);
|
|
4228
5039
|
}
|
|
4229
5040
|
}
|
|
4230
5041
|
const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
|
|
@@ -4240,13 +5051,24 @@ export class SshTui {
|
|
|
4240
5051
|
this.suggestionIndex = Math.max(0, this.commandSuggestions.length - 1);
|
|
4241
5052
|
}
|
|
4242
5053
|
const suggestionLines = [];
|
|
4243
|
-
|
|
5054
|
+
const suggestionStart = pickerWindowStart(this.suggestionIndex, this.commandSuggestions.length);
|
|
5055
|
+
const suggestionEnd = Math.min(this.commandSuggestions.length, suggestionStart + PICKER_WINDOW);
|
|
5056
|
+
if (suggestionStart > 0) {
|
|
5057
|
+
suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreAbove', { count: suggestionStart })}`)));
|
|
5058
|
+
}
|
|
5059
|
+
for (let index = suggestionStart; index < suggestionEnd; index += 1) {
|
|
5060
|
+
const command = this.commandSuggestions[index];
|
|
5061
|
+
if (command === undefined)
|
|
5062
|
+
continue;
|
|
4244
5063
|
const marker = index === this.suggestionIndex ? '›' : ' ';
|
|
4245
5064
|
const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
|
|
4246
5065
|
suggestionLines.push(index === this.suggestionIndex && this.color
|
|
4247
5066
|
? `\x1b[7m${fitLine(line)}\x1b[27m`
|
|
4248
5067
|
: this.styleLine('system', fitLine(line)));
|
|
4249
5068
|
}
|
|
5069
|
+
if (suggestionEnd < this.commandSuggestions.length) {
|
|
5070
|
+
suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreBelow', { count: this.commandSuggestions.length - suggestionEnd })}`)));
|
|
5071
|
+
}
|
|
4250
5072
|
const promptPlain = this.color ? '❯ ' : '> ';
|
|
4251
5073
|
const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
|
|
4252
5074
|
const promptWidth = displayWidth(promptPlain);
|
|
@@ -4292,7 +5114,7 @@ export class SshTui {
|
|
|
4292
5114
|
: [];
|
|
4293
5115
|
const inputDivider = this.styleLine('system', repeatToWidth('─', width));
|
|
4294
5116
|
const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
|
|
4295
|
-
const available = Math.max(
|
|
5117
|
+
const available = Math.max(0, height - reserved - dialogLines.length);
|
|
4296
5118
|
const maxOffset = Math.max(0, display.length - available);
|
|
4297
5119
|
const reveal = this.pendingReveal;
|
|
4298
5120
|
if (reveal !== undefined) {
|
|
@@ -4350,11 +5172,14 @@ export class SshTui {
|
|
|
4350
5172
|
const idleMs = Date.now() - this.lastActivity;
|
|
4351
5173
|
const livePlan = this.findLivePlanRow();
|
|
4352
5174
|
const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
|
|
5175
|
+
const current = this.selectionRef?.current;
|
|
5176
|
+
const provider = this.currentProviderId();
|
|
4353
5177
|
const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
|
|
5178
|
+
const balanceText = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider
|
|
5179
|
+
? formatFooterBalance(this.balanceSnapshot)
|
|
5180
|
+
: undefined;
|
|
4354
5181
|
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
4355
5182
|
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
4356
|
-
const current = this.selectionRef?.current;
|
|
4357
|
-
const provider = this.currentProviderId();
|
|
4358
5183
|
const parentModel = current?.model ?? this.agent.options.model ?? '';
|
|
4359
5184
|
const sub = this.subagentSelection.current;
|
|
4360
5185
|
const footer = {
|
|
@@ -4382,6 +5207,7 @@ export class SshTui {
|
|
|
4382
5207
|
...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
|
|
4383
5208
|
? {}
|
|
4384
5209
|
: { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
|
|
5210
|
+
...(balanceText === undefined ? {} : { balanceText }),
|
|
4385
5211
|
...(this.searchHits.length > 0 && this.searchIndex >= 0
|
|
4386
5212
|
? { search: { index: this.searchIndex, total: this.searchHits.length } }
|
|
4387
5213
|
: {}),
|
|
@@ -4432,6 +5258,7 @@ export class SshTui {
|
|
|
4432
5258
|
this.suggestionIndex,
|
|
4433
5259
|
this.activeSubagents.size,
|
|
4434
5260
|
this.dialog?.kind ?? '',
|
|
5261
|
+
this.dialog?.kind === 'questions' ? String(this.dialog.cursor) : '',
|
|
4435
5262
|
planDockLines.join('\n'),
|
|
4436
5263
|
String(chromeStart),
|
|
4437
5264
|
].join('\x1f');
|
|
@@ -4483,19 +5310,34 @@ export class SshTui {
|
|
|
4483
5310
|
if (!input.startsWith('/'))
|
|
4484
5311
|
return [];
|
|
4485
5312
|
const prefix = input.slice(1).toLowerCase();
|
|
4486
|
-
const
|
|
4487
|
-
name
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
5313
|
+
const local = localizedCommands()
|
|
5314
|
+
.filter(command => command.name !== 'dialog-test' && (prefix !== '' || command.aliasOf === undefined))
|
|
5315
|
+
.map(command => ({ name: command.name, description: command.description, local: true }));
|
|
5316
|
+
const seen = new Set(local.map(command => command.name));
|
|
5317
|
+
const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
|
|
5318
|
+
.filter(command => !seen.has(command.name))
|
|
5319
|
+
.map(command => {
|
|
5320
|
+
const descKey = `cmd.${command.name}`;
|
|
5321
|
+
const desc = t(descKey, undefined, command.description);
|
|
5322
|
+
return {
|
|
5323
|
+
name: command.name,
|
|
5324
|
+
description: command.input?.images === true
|
|
5325
|
+
? t('cmd.withImagesSuffix', { desc })
|
|
5326
|
+
: desc,
|
|
5327
|
+
local: false,
|
|
5328
|
+
};
|
|
5329
|
+
});
|
|
5330
|
+
const all = [...local, ...dsh];
|
|
4495
5331
|
const filtered = prefix === ''
|
|
4496
5332
|
? all
|
|
4497
5333
|
: all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
|
|
4498
|
-
|
|
5334
|
+
if (prefix === '')
|
|
5335
|
+
return filtered;
|
|
5336
|
+
return filtered.sort((a, b) => {
|
|
5337
|
+
const aStart = a.name.startsWith(prefix) ? 0 : 1;
|
|
5338
|
+
const bStart = b.name.startsWith(prefix) ? 0 : 1;
|
|
5339
|
+
return aStart - bStart;
|
|
5340
|
+
});
|
|
4499
5341
|
}
|
|
4500
5342
|
suggestionsVisible() {
|
|
4501
5343
|
return this.commandSuggestions.length > 0 && this.dialog === undefined;
|
|
@@ -4598,11 +5440,13 @@ export class SshTui {
|
|
|
4598
5440
|
const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
|
|
4599
5441
|
if (disabled)
|
|
4600
5442
|
return;
|
|
4601
|
-
|
|
5443
|
+
this.write('\x07');
|
|
4602
5444
|
}
|
|
4603
5445
|
render = () => {
|
|
4604
5446
|
if (!this.dirty || this.exiting)
|
|
4605
5447
|
return;
|
|
5448
|
+
if ((this.displayDetached || this.headlessDisplay) && this.displayHost?.attached !== true)
|
|
5449
|
+
return;
|
|
4606
5450
|
if (this.agent.status === 'running'
|
|
4607
5451
|
&& Date.now() - this.lastActivity > STALL_WARNING_MS
|
|
4608
5452
|
&& !this.stalledWarningShown
|
|
@@ -4680,7 +5524,7 @@ export class SshTui {
|
|
|
4680
5524
|
if (sourceKind === 'user') {
|
|
4681
5525
|
this.pushRow({ kind: 'user', text: `❯ ${text}` });
|
|
4682
5526
|
if (!this.replaying)
|
|
4683
|
-
this.beginWait(
|
|
5527
|
+
this.beginWait();
|
|
4684
5528
|
}
|
|
4685
5529
|
else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
|
|
4686
5530
|
this.pushPromptInjection(text, source.plugin);
|
|
@@ -4776,21 +5620,34 @@ export class SshTui {
|
|
|
4776
5620
|
this.pendingToolTimes.set(String(event.data.callId), event.time);
|
|
4777
5621
|
if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
|
|
4778
5622
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
4779
|
-
const
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
5623
|
+
const previous = this.rows.findLast((candidate) => candidate.kind === 'tool');
|
|
5624
|
+
if (canMergeToolCall(previous, { name: event.data.name, args: event.data.arguments })) {
|
|
5625
|
+
this.mergeIntoToolCard(previous, {
|
|
5626
|
+
callId: event.data.callId,
|
|
5627
|
+
name: event.data.name,
|
|
5628
|
+
args: event.data.arguments,
|
|
5629
|
+
title: present.title,
|
|
5630
|
+
summary: present.summary,
|
|
5631
|
+
...present.diff === undefined ? {} : { diff: present.diff },
|
|
5632
|
+
});
|
|
5633
|
+
}
|
|
5634
|
+
else {
|
|
5635
|
+
const row = {
|
|
5636
|
+
kind: 'tool',
|
|
5637
|
+
callId: event.data.callId,
|
|
5638
|
+
name: event.data.name,
|
|
5639
|
+
args: event.data.arguments,
|
|
5640
|
+
status: 'running',
|
|
5641
|
+
output: '',
|
|
5642
|
+
title: present.title,
|
|
5643
|
+
summary: present.summary,
|
|
5644
|
+
...present.command === undefined ? {} : { command: present.command },
|
|
5645
|
+
...present.cwd === undefined ? {} : { cwd: present.cwd },
|
|
5646
|
+
...present.diff === undefined ? {} : { diff: present.diff },
|
|
5647
|
+
expanded: false,
|
|
5648
|
+
};
|
|
5649
|
+
this.pushRow(row);
|
|
5650
|
+
}
|
|
4794
5651
|
}
|
|
4795
5652
|
if (event.data.name === 'exit_plan_mode') {
|
|
4796
5653
|
const markdown = planMarkdownFromArgs(event.data.arguments);
|
|
@@ -4813,7 +5670,9 @@ export class SshTui {
|
|
|
4813
5670
|
if (row !== undefined) {
|
|
4814
5671
|
const metaDiffs = diffMetaDiffs(event.data.meta);
|
|
4815
5672
|
if (metaDiffs !== null) {
|
|
4816
|
-
row.diff =
|
|
5673
|
+
row.diff = (row.repeats ?? 1) > 1 && row.diff !== undefined && row.diff.length > 0
|
|
5674
|
+
? [...row.diff, ...metaDiffs]
|
|
5675
|
+
: metaDiffs;
|
|
4817
5676
|
}
|
|
4818
5677
|
const isShell = SHELL_TOOL_NAMES.has(row.name);
|
|
4819
5678
|
if (isShell) {
|
|
@@ -4831,6 +5690,10 @@ export class SshTui {
|
|
|
4831
5690
|
|| event.data.message.content[0]?.isError === true
|
|
4832
5691
|
|| (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
|
|
4833
5692
|
row.status = failed ? 'error' : 'ok';
|
|
5693
|
+
if (READ_TOOL_NAMES.has(row.name)) {
|
|
5694
|
+
row.totalChars = (row.totalChars ?? 0) + row.output.length;
|
|
5695
|
+
row.totalLines = (row.totalLines ?? 0) + countOutputLines(row.output);
|
|
5696
|
+
}
|
|
4834
5697
|
}
|
|
4835
5698
|
else {
|
|
4836
5699
|
const present = presentToolCall(event.data.message.source.callId, '');
|
|
@@ -4882,6 +5745,14 @@ export class SshTui {
|
|
|
4882
5745
|
this.markDirty();
|
|
4883
5746
|
break;
|
|
4884
5747
|
}
|
|
5748
|
+
case 'sandbox/mode':
|
|
5749
|
+
this.hostSandboxMode = String(event.data.mode ?? '');
|
|
5750
|
+
break;
|
|
5751
|
+
case 'approval/policy':
|
|
5752
|
+
this.hostApprovalPolicy = String(event.data.policy ?? '');
|
|
5753
|
+
if (this.hostApprovalPolicy === 'never')
|
|
5754
|
+
this.warnApprovalMismatch();
|
|
5755
|
+
break;
|
|
4885
5756
|
case 'turn/start':
|
|
4886
5757
|
this.stalledWarningShown = false;
|
|
4887
5758
|
this.llmRetry = undefined;
|
|
@@ -5161,19 +6032,48 @@ export class SshTui {
|
|
|
5161
6032
|
});
|
|
5162
6033
|
this.markDirty();
|
|
5163
6034
|
}
|
|
6035
|
+
formatCommandText(text) {
|
|
6036
|
+
const permMatch = text.match(/^current preset (\S+) \(available: (.+)\)$/);
|
|
6037
|
+
if (permMatch) {
|
|
6038
|
+
const current = permMatch[1] ?? '';
|
|
6039
|
+
const avail = permMatch[2] ?? '';
|
|
6040
|
+
const localize = (name) => {
|
|
6041
|
+
const key = `preset.${name}`;
|
|
6042
|
+
const trans = t(key);
|
|
6043
|
+
return trans !== key ? t('preset.named', { name, label: trans }) : name;
|
|
6044
|
+
};
|
|
6045
|
+
const currentLabel = localize(current);
|
|
6046
|
+
const availLabel = avail.split(', ').map(s => localize(s.trim())).join(t('list.sep'));
|
|
6047
|
+
return t('permission.currentInfo', { current: currentLabel, available: availLabel });
|
|
6048
|
+
}
|
|
6049
|
+
const permSwitched = text.match(/^preset (\S+)$/);
|
|
6050
|
+
if (permSwitched) {
|
|
6051
|
+
const name = permSwitched[1] ?? '';
|
|
6052
|
+
const key = `preset.${name}`;
|
|
6053
|
+
const trans = t(key);
|
|
6054
|
+
const label = trans !== key ? t('preset.named', { name, label: trans }) : name;
|
|
6055
|
+
return t('permission.switched', { preset: label });
|
|
6056
|
+
}
|
|
6057
|
+
return text;
|
|
6058
|
+
}
|
|
5164
6059
|
handleCommandDone(data) {
|
|
5165
6060
|
const payload = data !== null && typeof data === 'object' ? data : {};
|
|
6061
|
+
const commandId = typeof payload.commandId === 'string' ? payload.commandId : '';
|
|
6062
|
+
if (commandId !== '')
|
|
6063
|
+
this.seenCommandDoneIds.add(commandId);
|
|
5166
6064
|
const kind = typeof payload.kind === 'string' ? payload.kind : '';
|
|
5167
6065
|
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
|
5168
6066
|
if (kind === 'error') {
|
|
5169
|
-
this.
|
|
6067
|
+
const errText = this.formatCommandText(text);
|
|
6068
|
+
this.pushRow({ kind: 'error', text: errText === '' ? t('command.failed') : errText });
|
|
5170
6069
|
if (this.status.startsWith('压缩'))
|
|
5171
6070
|
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
5172
6071
|
this.markDirty();
|
|
5173
6072
|
return;
|
|
5174
6073
|
}
|
|
5175
|
-
if (text !== '')
|
|
5176
|
-
this.pushRow({ kind: 'system', text });
|
|
6074
|
+
if (text !== '') {
|
|
6075
|
+
this.pushRow({ kind: 'system', text: this.formatCommandText(text) });
|
|
6076
|
+
}
|
|
5177
6077
|
this.markDirty();
|
|
5178
6078
|
}
|
|
5179
6079
|
handleGoalChange(data) {
|
|
@@ -5369,7 +6269,170 @@ export class SshTui {
|
|
|
5369
6269
|
this.markDirty();
|
|
5370
6270
|
};
|
|
5371
6271
|
// ── approval and questions ──────────────────────────────────────────────
|
|
6272
|
+
hasLiveDisplay() {
|
|
6273
|
+
if (this.disposed || this.exiting)
|
|
6274
|
+
return false;
|
|
6275
|
+
if (this.headlessDisplay || this.displayDetached)
|
|
6276
|
+
return this.displayHost?.attached === true;
|
|
6277
|
+
return true;
|
|
6278
|
+
}
|
|
6279
|
+
waitForLiveDisplay(signal) {
|
|
6280
|
+
if (this.hasLiveDisplay())
|
|
6281
|
+
return Promise.resolve();
|
|
6282
|
+
return new Promise((resolve, reject) => {
|
|
6283
|
+
let settled = false;
|
|
6284
|
+
const finish = (ok) => {
|
|
6285
|
+
if (settled)
|
|
6286
|
+
return;
|
|
6287
|
+
settled = true;
|
|
6288
|
+
signal?.removeEventListener('abort', onAbort);
|
|
6289
|
+
clearInterval(timer);
|
|
6290
|
+
if (ok)
|
|
6291
|
+
resolve();
|
|
6292
|
+
else
|
|
6293
|
+
reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
|
|
6294
|
+
};
|
|
6295
|
+
const onAbort = () => { finish(false); };
|
|
6296
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
6297
|
+
const timer = setInterval(() => {
|
|
6298
|
+
if (this.disposed || this.exiting) {
|
|
6299
|
+
finish(false);
|
|
6300
|
+
return;
|
|
6301
|
+
}
|
|
6302
|
+
if (this.hasLiveDisplay())
|
|
6303
|
+
finish(true);
|
|
6304
|
+
}, 200);
|
|
6305
|
+
timer.unref?.();
|
|
6306
|
+
});
|
|
6307
|
+
}
|
|
6308
|
+
/**
|
|
6309
|
+
* Auto mode rides the approval waterfall: when the host approval policy is
|
|
6310
|
+
* `never` no request is ever produced, so the classifier silently does
|
|
6311
|
+
* nothing. Say so instead of letting the user believe a guard is active.
|
|
6312
|
+
*/
|
|
6313
|
+
warnApprovalMismatch() {
|
|
6314
|
+
if (this.autoApprovalMode !== 'auto' || this.approvalMismatchWarned)
|
|
6315
|
+
return;
|
|
6316
|
+
if (this.hostApprovalPolicy !== 'never')
|
|
6317
|
+
return;
|
|
6318
|
+
this.approvalMismatchWarned = true;
|
|
6319
|
+
this.pushRow({ kind: 'system', text: t('approval.mismatchNever') });
|
|
6320
|
+
this.markDirty();
|
|
6321
|
+
}
|
|
6322
|
+
/**
|
|
6323
|
+
* AI review for rule-table `ask` outcomes: one shot at the subagent
|
|
6324
|
+
* model route with a compact, injection-fenced context. Returns
|
|
6325
|
+
* 'allow' | 'deny', or undefined when the reviewer is unavailable or its
|
|
6326
|
+
* output was unusable (caller falls back to prompt/reject).
|
|
6327
|
+
*/
|
|
6328
|
+
async reviewUnknownWithModel(request, command) {
|
|
6329
|
+
const llm = this.ctx.get('llm');
|
|
6330
|
+
if (llm === undefined)
|
|
6331
|
+
return undefined;
|
|
6332
|
+
const selection = this.subagentSelection.current;
|
|
6333
|
+
const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
6334
|
+
const provider = selection.provider ?? parentProvider;
|
|
6335
|
+
const model = subagentModelMatchesProvider(provider, selection.model)
|
|
6336
|
+
? selection.model
|
|
6337
|
+
: defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
|
|
6338
|
+
// 最近模型输出/思考(≤2 段)与最新用户消息(≤400 字)
|
|
6339
|
+
const segments = [];
|
|
6340
|
+
for (let i = this.rows.length - 1; i >= 0 && segments.length < 2; i -= 1) {
|
|
6341
|
+
const row = this.rows[i];
|
|
6342
|
+
if (row !== undefined && (row.kind === 'assistant' || row.kind === 'reasoning') && row.text.trim() !== '') {
|
|
6343
|
+
segments.unshift(row.text);
|
|
6344
|
+
}
|
|
6345
|
+
}
|
|
6346
|
+
let userText = '';
|
|
6347
|
+
for (let i = this.rows.length - 1; i >= 0; i -= 1) {
|
|
6348
|
+
const row = this.rows[i];
|
|
6349
|
+
if (row !== undefined && row.kind === 'user') {
|
|
6350
|
+
userText = row.text.replace(/^❯\s*/u, '');
|
|
6351
|
+
break;
|
|
6352
|
+
}
|
|
6353
|
+
}
|
|
6354
|
+
const signals = [request.signal, AbortSignal.timeout(15_000)].filter(s => s !== undefined);
|
|
6355
|
+
const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined;
|
|
6356
|
+
const options = {
|
|
6357
|
+
provider,
|
|
6358
|
+
model,
|
|
6359
|
+
messages: [createUserMessage({
|
|
6360
|
+
content: [{ type: 'text', text: buildReviewUserMessage({
|
|
6361
|
+
userText,
|
|
6362
|
+
segments,
|
|
6363
|
+
toolName: request.toolName,
|
|
6364
|
+
command: command ?? `(无命令参数,工具:${request.toolName})`,
|
|
6365
|
+
}) }],
|
|
6366
|
+
source: { kind: 'plugin', plugin: 'dsh-ssh-tui' },
|
|
6367
|
+
})],
|
|
6368
|
+
system: REVIEW_SYSTEM_PROMPT,
|
|
6369
|
+
maxTokens: 200,
|
|
6370
|
+
sessionId: this.agent.session.id,
|
|
6371
|
+
signal,
|
|
6372
|
+
};
|
|
6373
|
+
this.aiReviewCount += 1;
|
|
6374
|
+
let text = '';
|
|
6375
|
+
for await (const chunk of llm.stream(options)) {
|
|
6376
|
+
if (chunk.type === 'text-delta')
|
|
6377
|
+
text += chunk.text;
|
|
6378
|
+
}
|
|
6379
|
+
const verdict = parseReviewOutput(text);
|
|
6380
|
+
if (verdict === undefined)
|
|
6381
|
+
return undefined;
|
|
6382
|
+
this.pushRow({
|
|
6383
|
+
kind: 'system',
|
|
6384
|
+
text: t('approval.reviewRow', {
|
|
6385
|
+
verdict: verdict.approved ? t('approval.reviewApproved') : t('approval.reviewRejected'),
|
|
6386
|
+
risk: verdict.risk,
|
|
6387
|
+
authorization: verdict.authorization,
|
|
6388
|
+
reason: verdict.reason,
|
|
6389
|
+
}),
|
|
6390
|
+
});
|
|
6391
|
+
this.markDirty();
|
|
6392
|
+
return verdict.approved ? 'allow' : 'deny';
|
|
6393
|
+
}
|
|
5372
6394
|
handleApproval = async (request, _next) => {
|
|
6395
|
+
// Auto mode classifies BEFORE waiting for a display, Codex-style: allow
|
|
6396
|
+
// shapes approve, danger shapes REJECT (the model reads the rejection and
|
|
6397
|
+
// adapts instead of paging the human), unknown shapes ask only while a
|
|
6398
|
+
// human is attached — detached turns reject so they complete instead of
|
|
6399
|
+
// stalling toward the idle kill.
|
|
6400
|
+
if (this.autoApprovalMode === 'auto') {
|
|
6401
|
+
const row = request.callId === undefined
|
|
6402
|
+
? undefined
|
|
6403
|
+
: this.rows.findLast((candidate) => candidate.kind === 'tool' && candidate.callId === request.callId);
|
|
6404
|
+
const command = row === undefined ? undefined : commandFromArgs(row.name, row.args);
|
|
6405
|
+
const decision = classifyApproval(request.toolName, command);
|
|
6406
|
+
if (decision === 'allow') {
|
|
6407
|
+
this.autoAllowedCount += 1;
|
|
6408
|
+
return 'allowed-once';
|
|
6409
|
+
}
|
|
6410
|
+
if (decision === 'deny') {
|
|
6411
|
+
this.autoDeniedCount += 1;
|
|
6412
|
+
return 'rejected';
|
|
6413
|
+
}
|
|
6414
|
+
// Unknown shape: the rule table cannot judge it — hand it to the
|
|
6415
|
+
// subagent-configured model with compact context (AI review). Without
|
|
6416
|
+
// a display there is nobody to fall back on, so unreviewable asks
|
|
6417
|
+
// reject and the turn completes instead of stalling.
|
|
6418
|
+
const reviewed = await this.reviewUnknownWithModel(request, command);
|
|
6419
|
+
if (reviewed === 'allow') {
|
|
6420
|
+
this.autoAllowedCount += 1;
|
|
6421
|
+
return 'allowed-once';
|
|
6422
|
+
}
|
|
6423
|
+
if (reviewed === 'deny' || !this.hasLiveDisplay()) {
|
|
6424
|
+
this.autoDeniedCount += 1;
|
|
6425
|
+
return 'rejected';
|
|
6426
|
+
}
|
|
6427
|
+
}
|
|
6428
|
+
if (!this.hasLiveDisplay()) {
|
|
6429
|
+
try {
|
|
6430
|
+
await this.waitForLiveDisplay(request.signal);
|
|
6431
|
+
}
|
|
6432
|
+
catch {
|
|
6433
|
+
return 'cancelled';
|
|
6434
|
+
}
|
|
6435
|
+
}
|
|
5373
6436
|
const agentLabel = request.agent.id === this.agent.id
|
|
5374
6437
|
? '当前会话'
|
|
5375
6438
|
: `子代理 ${request.agent.id}`;
|
|
@@ -5392,6 +6455,8 @@ export class SshTui {
|
|
|
5392
6455
|
});
|
|
5393
6456
|
};
|
|
5394
6457
|
handleUserQuestions = async (request) => {
|
|
6458
|
+
if (!this.hasLiveDisplay())
|
|
6459
|
+
await this.waitForLiveDisplay(request.signal);
|
|
5395
6460
|
const answers = [];
|
|
5396
6461
|
const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
|
|
5397
6462
|
? undefined
|
|
@@ -5536,6 +6601,7 @@ export class SshTui {
|
|
|
5536
6601
|
index,
|
|
5537
6602
|
total,
|
|
5538
6603
|
selected: new Set(preselected !== undefined && preselected >= 0 ? [preselected] : []),
|
|
6604
|
+
cursor: preselected !== undefined && preselected >= 0 ? preselected : 0,
|
|
5539
6605
|
resolve: (selection) => {
|
|
5540
6606
|
this.settleQuestion(dialog, () => resolve(selection));
|
|
5541
6607
|
},
|
|
@@ -5637,13 +6703,9 @@ export class SshTui {
|
|
|
5637
6703
|
return false;
|
|
5638
6704
|
}
|
|
5639
6705
|
}
|
|
5640
|
-
/** How many endpoint-listed models fit on one picker page alongside navigation. */
|
|
5641
|
-
MODEL_PAGE_SIZE = 7;
|
|
5642
|
-
MODEL_PAGE_PREV = '« 上一页';
|
|
5643
|
-
MODEL_PAGE_NEXT = '» 下一页';
|
|
5644
6706
|
/**
|
|
5645
|
-
* One pick across a possibly long model list
|
|
5646
|
-
*
|
|
6707
|
+
* One pick across a possibly long model list. The question dialog keeps a
|
|
6708
|
+
* 12-row sliding window so dozens of models stay selectable with ↑/↓.
|
|
5647
6709
|
*/
|
|
5648
6710
|
async pickModelOption(modelOptions, provider, sourceLabel, currentModel) {
|
|
5649
6711
|
const seen = new Set();
|
|
@@ -5655,40 +6717,19 @@ export class SshTui {
|
|
|
5655
6717
|
});
|
|
5656
6718
|
if (unique.length === 0)
|
|
5657
6719
|
return undefined;
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
const pageCount = Math.max(1, Math.ceil(unique.length / this.MODEL_PAGE_SIZE));
|
|
5664
|
-
const currentPage = Math.floor(offset / this.MODEL_PAGE_SIZE) + 1;
|
|
5665
|
-
const options = page.map(option => ({
|
|
6720
|
+
const currentIndex = unique.findIndex(option => option.id === currentModel && option.id !== '__switch_provider__');
|
|
6721
|
+
const answer = await this.askQuestion({
|
|
6722
|
+
id: 'model-pick',
|
|
6723
|
+
question: `选择模型(提供商 ${provider} · ${sourceLabel}${unique.length > PICKER_WINDOW ? ` · ${unique.length} 个,↑/↓ 翻看` : ''})`,
|
|
6724
|
+
options: unique.map(option => ({
|
|
5666
6725
|
label: option.label,
|
|
5667
6726
|
description: option.id === currentModel ? '当前' : undefined,
|
|
5668
|
-
}))
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
const answer = await this.askQuestion({
|
|
5675
|
-
id: 'model-pick',
|
|
5676
|
-
question: `选择模型(提供商 ${provider} · ${sourceLabel}${hasPrev || hasNext ? `,第 ${currentPage}/${pageCount} 页` : ''})`,
|
|
5677
|
-
options,
|
|
5678
|
-
}, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
|
|
5679
|
-
const picked = options.find(option => option.label === answer.selected[0]);
|
|
5680
|
-
if (picked === undefined)
|
|
5681
|
-
return undefined;
|
|
5682
|
-
if (picked.label === this.MODEL_PAGE_NEXT) {
|
|
5683
|
-
offset += this.MODEL_PAGE_SIZE;
|
|
5684
|
-
continue;
|
|
5685
|
-
}
|
|
5686
|
-
if (picked.label === this.MODEL_PAGE_PREV) {
|
|
5687
|
-
offset = Math.max(0, offset - this.MODEL_PAGE_SIZE);
|
|
5688
|
-
continue;
|
|
5689
|
-
}
|
|
5690
|
-
return page.find(option => option.label === picked.label);
|
|
5691
|
-
}
|
|
6727
|
+
})),
|
|
6728
|
+
}, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
|
|
6729
|
+
const picked = answer.selected[0];
|
|
6730
|
+
if (picked === undefined)
|
|
6731
|
+
return undefined;
|
|
6732
|
+
return unique.find(option => option.label === picked);
|
|
5692
6733
|
}
|
|
5693
6734
|
/** Live adapter routes the TUI can switch to, plus the current selection. */
|
|
5694
6735
|
listSelectableProviders() {
|
|
@@ -5860,38 +6901,38 @@ export class SshTui {
|
|
|
5860
6901
|
effortOptions = [];
|
|
5861
6902
|
}
|
|
5862
6903
|
if (effortOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
5863
|
-
effortOptions = modelId
|
|
5864
|
-
? [
|
|
5865
|
-
{ id: 'off', label: 'Off' },
|
|
5866
|
-
{ id: 'low', label: 'Low' },
|
|
5867
|
-
{ id: 'medium', label: 'Medium' },
|
|
5868
|
-
{ id: 'high', label: 'High' },
|
|
5869
|
-
{ id: 'xhigh', label: 'Extra high' },
|
|
5870
|
-
]
|
|
5871
|
-
: [
|
|
5872
|
-
{ id: 'off', label: 'Off' },
|
|
5873
|
-
{ id: 'low', label: 'Low' },
|
|
5874
|
-
{ id: 'medium', label: 'Medium' },
|
|
5875
|
-
{ id: 'high', label: 'High' },
|
|
5876
|
-
];
|
|
5877
|
-
}
|
|
5878
|
-
let effort;
|
|
5879
|
-
if (effortOptions.length > 0) {
|
|
5880
|
-
const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
|
|
5881
|
-
const currentEffort = current?.provider === provider
|
|
5882
|
-
? String(current?.reasoningEffort ?? '')
|
|
5883
|
-
: rememberedEffort;
|
|
5884
|
-
const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
|
|
5885
|
-
const effortAnswer = await this.askQuestion({
|
|
5886
|
-
id: 'effort-pick',
|
|
5887
|
-
question: `选择思考强度(${modelId})`,
|
|
5888
|
-
options: effortOptions.map(option => ({
|
|
5889
|
-
label: option.label,
|
|
5890
|
-
description: option.id === currentEffort ? '当前' : undefined,
|
|
5891
|
-
})),
|
|
5892
|
-
}, 0, 1, currentIndex);
|
|
5893
|
-
effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
6904
|
+
effortOptions = localOAuthEffortChoices(modelId);
|
|
5894
6905
|
}
|
|
6906
|
+
const isUndeclared = effortOptions.length === 0;
|
|
6907
|
+
const available = isUndeclared ? undeclaredEffortChoices() : effortOptions;
|
|
6908
|
+
const choices = [
|
|
6909
|
+
{
|
|
6910
|
+
id: undefined,
|
|
6911
|
+
label: t('footer.effortDefault'),
|
|
6912
|
+
desc: isUndeclared ? t('effort.descUndeclared') : t('effort.descFollow'),
|
|
6913
|
+
},
|
|
6914
|
+
...available.map(opt => ({
|
|
6915
|
+
id: opt.id,
|
|
6916
|
+
label: opt.label,
|
|
6917
|
+
desc: undefined,
|
|
6918
|
+
})),
|
|
6919
|
+
];
|
|
6920
|
+
const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
|
|
6921
|
+
const currentEffort = current?.provider === provider
|
|
6922
|
+
? String(current?.reasoningEffort ?? '')
|
|
6923
|
+
: rememberedEffort;
|
|
6924
|
+
const currentIndex = Math.max(0, choices.findIndex(option => option.id === (currentEffort === '' ? undefined : currentEffort)));
|
|
6925
|
+
const effortAnswer = await this.askQuestion({
|
|
6926
|
+
id: 'effort-pick',
|
|
6927
|
+
question: isUndeclared
|
|
6928
|
+
? t('effort.pickUndeclared', { model: modelId })
|
|
6929
|
+
: t('effort.pick', { model: modelId }),
|
|
6930
|
+
options: choices.map(option => ({
|
|
6931
|
+
label: option.label,
|
|
6932
|
+
description: option.id === (currentEffort === '' ? undefined : currentEffort) ? t('disconnect.current') : option.desc,
|
|
6933
|
+
})),
|
|
6934
|
+
}, 0, 1, currentIndex);
|
|
6935
|
+
const effort = choices.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
5895
6936
|
const next = {
|
|
5896
6937
|
provider,
|
|
5897
6938
|
model: modelId,
|
|
@@ -5903,9 +6944,11 @@ export class SshTui {
|
|
|
5903
6944
|
await this.persistDefaultSelection(next);
|
|
5904
6945
|
await this.rememberRoute(next);
|
|
5905
6946
|
const kind = describeProviderRoute(provider);
|
|
6947
|
+
const effortText = effort ?? t('effort.defaultShort');
|
|
6948
|
+
const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
|
|
5906
6949
|
this.pushRow({
|
|
5907
6950
|
kind: 'system',
|
|
5908
|
-
text:
|
|
6951
|
+
text: t('effort.switchedModel', { kind: kind.kind, provider, model: modelId, effort: effortText, note }),
|
|
5909
6952
|
});
|
|
5910
6953
|
const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
|
|
5911
6954
|
const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
@@ -6002,10 +7045,16 @@ export class SshTui {
|
|
|
6002
7045
|
});
|
|
6003
7046
|
}
|
|
6004
7047
|
clearQuotaForProvider(provider) {
|
|
6005
|
-
|
|
7048
|
+
const quotaSame = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider;
|
|
7049
|
+
const balanceSame = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider;
|
|
7050
|
+
if (quotaSame && balanceSame)
|
|
6006
7051
|
return;
|
|
6007
|
-
|
|
6008
|
-
|
|
7052
|
+
if (!quotaSame) {
|
|
7053
|
+
this.quotaSnapshot = undefined;
|
|
7054
|
+
this.quotaAlerted.clear();
|
|
7055
|
+
}
|
|
7056
|
+
if (!balanceSame)
|
|
7057
|
+
this.balanceSnapshot = undefined;
|
|
6009
7058
|
this.quotaStepsSinceRefresh = 0;
|
|
6010
7059
|
this.markDirty();
|
|
6011
7060
|
}
|
|
@@ -6093,8 +7142,98 @@ export class SshTui {
|
|
|
6093
7142
|
});
|
|
6094
7143
|
this.markDirty();
|
|
6095
7144
|
}
|
|
7145
|
+
/** /effort: pick or set the reasoning effort for the current model. */
|
|
7146
|
+
async runEffortCommand(arg) {
|
|
7147
|
+
const provider = this.currentProviderId();
|
|
7148
|
+
const current = this.selectionRef?.current;
|
|
7149
|
+
if (current === undefined || current.model === undefined) {
|
|
7150
|
+
this.pushRow({ kind: 'error', text: t('effort.noModel') });
|
|
7151
|
+
this.markDirty();
|
|
7152
|
+
return;
|
|
7153
|
+
}
|
|
7154
|
+
const modelId = current.model;
|
|
7155
|
+
const llm = this.ctx.get('llm');
|
|
7156
|
+
let declaredOptions = [];
|
|
7157
|
+
try {
|
|
7158
|
+
const info = await llm?.resolveModelInfo(provider, modelId);
|
|
7159
|
+
declaredOptions = (info?.reasoning?.efforts ?? []).map(e => ({ id: String(e.id), label: e.name }));
|
|
7160
|
+
}
|
|
7161
|
+
catch {
|
|
7162
|
+
declaredOptions = [];
|
|
7163
|
+
}
|
|
7164
|
+
if (declaredOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
7165
|
+
declaredOptions = localOAuthEffortChoices(modelId);
|
|
7166
|
+
}
|
|
7167
|
+
const parsed = parseEffortArg(arg ?? '');
|
|
7168
|
+
if ((arg ?? '').trim() !== '') {
|
|
7169
|
+
if (parsed === undefined) {
|
|
7170
|
+
this.pushRow({ kind: 'error', text: t('effort.unknown', { id: (arg ?? '').trim() }) });
|
|
7171
|
+
this.markDirty();
|
|
7172
|
+
return;
|
|
7173
|
+
}
|
|
7174
|
+
const allowed = declaredOptions.length === 0
|
|
7175
|
+
? UNDECLARED_EFFORT_IDS
|
|
7176
|
+
: declaredOptions.map(option => option.id);
|
|
7177
|
+
if (parsed.kind === 'id' && !allowed.includes(parsed.id)) {
|
|
7178
|
+
this.pushRow({ kind: 'error', text: t('effort.unknown', { id: parsed.id }) });
|
|
7179
|
+
this.markDirty();
|
|
7180
|
+
return;
|
|
7181
|
+
}
|
|
7182
|
+
await this.setReasoningEffort(provider, modelId, parsed.kind === 'default' ? undefined : parsed.id, declaredOptions.length === 0);
|
|
7183
|
+
return;
|
|
7184
|
+
}
|
|
7185
|
+
const isUndeclared = declaredOptions.length === 0;
|
|
7186
|
+
const available = isUndeclared ? undeclaredEffortChoices() : declaredOptions;
|
|
7187
|
+
const currentEffort = current.reasoningEffort === undefined ? undefined : String(current.reasoningEffort);
|
|
7188
|
+
const choices = [
|
|
7189
|
+
{
|
|
7190
|
+
id: undefined,
|
|
7191
|
+
label: t('footer.effortDefault'),
|
|
7192
|
+
desc: isUndeclared ? t('effort.descUndeclared') : t('effort.descFollow'),
|
|
7193
|
+
},
|
|
7194
|
+
...available.map(opt => ({
|
|
7195
|
+
id: opt.id,
|
|
7196
|
+
label: opt.label,
|
|
7197
|
+
desc: undefined,
|
|
7198
|
+
})),
|
|
7199
|
+
];
|
|
7200
|
+
const currentIndex = Math.max(0, choices.findIndex(c => c.id === currentEffort));
|
|
7201
|
+
const answer = await this.askQuestion({
|
|
7202
|
+
id: 'effort-pick',
|
|
7203
|
+
question: isUndeclared
|
|
7204
|
+
? t('effort.pickCurrentUndeclared', { provider, model: modelId })
|
|
7205
|
+
: t('effort.pickCurrent', { provider, model: modelId }),
|
|
7206
|
+
options: choices.map(c => ({
|
|
7207
|
+
label: c.label,
|
|
7208
|
+
description: c.id === currentEffort ? t('disconnect.current') : c.desc,
|
|
7209
|
+
})),
|
|
7210
|
+
}, 0, 1, currentIndex);
|
|
7211
|
+
const picked = choices.find(c => c.label === answer.selected[0]);
|
|
7212
|
+
if (picked === undefined)
|
|
7213
|
+
return;
|
|
7214
|
+
await this.setReasoningEffort(provider, modelId, picked.id, isUndeclared);
|
|
7215
|
+
}
|
|
7216
|
+
async setReasoningEffort(provider, modelId, effort, isUndeclared) {
|
|
7217
|
+
const next = {
|
|
7218
|
+
provider,
|
|
7219
|
+
model: modelId,
|
|
7220
|
+
...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
|
|
7221
|
+
};
|
|
7222
|
+
if (this.selectionRef !== undefined)
|
|
7223
|
+
this.selectionRef.current = next;
|
|
7224
|
+
this.onSelectionChanged?.(next);
|
|
7225
|
+
await this.persistDefaultSelection(next);
|
|
7226
|
+
await this.rememberRoute(next);
|
|
7227
|
+
const effortText = effort ?? t('effort.defaultExplicit');
|
|
7228
|
+
const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
|
|
7229
|
+
this.pushRow({
|
|
7230
|
+
kind: 'system',
|
|
7231
|
+
text: t('effort.updated', { provider, model: modelId, effort: effortText, note }),
|
|
7232
|
+
});
|
|
7233
|
+
this.markDirty();
|
|
7234
|
+
}
|
|
6096
7235
|
/** /subeffort: pick the reasoning effort subagent children use. */
|
|
6097
|
-
async runSubeffortCommand() {
|
|
7236
|
+
async runSubeffortCommand(arg) {
|
|
6098
7237
|
const provider = this.effectiveSubagentProvider();
|
|
6099
7238
|
const current = this.subagentSelection.current;
|
|
6100
7239
|
const llm = this.ctx.get('llm');
|
|
@@ -6106,30 +7245,58 @@ export class SshTui {
|
|
|
6106
7245
|
catch {
|
|
6107
7246
|
effortOptions = [];
|
|
6108
7247
|
}
|
|
6109
|
-
|
|
7248
|
+
const parsed = parseEffortArg(arg ?? '');
|
|
7249
|
+
if ((arg ?? '').trim() !== '') {
|
|
7250
|
+
if (parsed === undefined) {
|
|
7251
|
+
this.pushRow({ kind: 'error', text: t('effort.unknown', { id: (arg ?? '').trim() }) });
|
|
7252
|
+
this.markDirty();
|
|
7253
|
+
return;
|
|
7254
|
+
}
|
|
7255
|
+
const allowed = effortOptions.length === 0
|
|
7256
|
+
? UNDECLARED_EFFORT_IDS
|
|
7257
|
+
: effortOptions.map(option => option.id);
|
|
7258
|
+
if (parsed.kind === 'id' && !allowed.includes(parsed.id)) {
|
|
7259
|
+
this.pushRow({ kind: 'error', text: t('effort.unknown', { id: parsed.id }) });
|
|
7260
|
+
this.markDirty();
|
|
7261
|
+
return;
|
|
7262
|
+
}
|
|
7263
|
+
const targetEffort = parsed.kind === 'default' ? undefined : parsed.id;
|
|
7264
|
+
const next = {
|
|
7265
|
+
...current,
|
|
7266
|
+
...(targetEffort === undefined ? { reasoningEffort: undefined } : { reasoningEffort: ReasoningEffortId(targetEffort) }),
|
|
7267
|
+
};
|
|
7268
|
+
const persisted = await this.saveSubagentSelection(next);
|
|
6110
7269
|
this.pushRow({
|
|
6111
7270
|
kind: 'system',
|
|
6112
|
-
text:
|
|
7271
|
+
text: `${targetEffort === undefined
|
|
7272
|
+
? t('effort.subDefault')
|
|
7273
|
+
: t('effort.subSwitched', { effort: targetEffort })}${persisted ? '' : t('effort.sessionOnly')}`,
|
|
6113
7274
|
});
|
|
6114
7275
|
this.markDirty();
|
|
6115
7276
|
return;
|
|
6116
7277
|
}
|
|
7278
|
+
const isUndeclared = effortOptions.length === 0;
|
|
7279
|
+
const available = isUndeclared ? undeclaredEffortChoices() : effortOptions;
|
|
6117
7280
|
const choices = [
|
|
6118
|
-
{
|
|
6119
|
-
|
|
7281
|
+
{
|
|
7282
|
+
id: undefined,
|
|
7283
|
+
label: SUBAGENT_DEFAULT_EFFORT_LABEL(),
|
|
7284
|
+
desc: isUndeclared ? t('effort.subDescUndeclared') : t('effort.subDescFollow'),
|
|
7285
|
+
},
|
|
7286
|
+
...available.map(option => ({ id: option.id, label: option.label, desc: undefined })),
|
|
6120
7287
|
];
|
|
7288
|
+
const currentEffort = current.reasoningEffort === undefined ? undefined : String(current.reasoningEffort);
|
|
7289
|
+
const currentIndex = Math.max(0, choices.findIndex(c => c.id === currentEffort));
|
|
6121
7290
|
const answer = await this.askQuestion({
|
|
6122
7291
|
id: 'subagent-effort-pick',
|
|
6123
|
-
question:
|
|
7292
|
+
question: isUndeclared
|
|
7293
|
+
? t('effort.pickSubUndeclared', { provider, model: current.model })
|
|
7294
|
+
: t('effort.pickSub', { provider, model: current.model }),
|
|
6124
7295
|
options: choices.map(option => ({
|
|
6125
7296
|
label: option.label,
|
|
6126
|
-
description: option.id ===
|
|
6127
|
-
? '清空自定义强度,跟随提供商/模型默认'
|
|
6128
|
-
: option.id === String(current.reasoningEffort)
|
|
6129
|
-
? '当前'
|
|
6130
|
-
: undefined,
|
|
7297
|
+
description: option.id === currentEffort ? t('disconnect.current') : option.desc,
|
|
6131
7298
|
})),
|
|
6132
|
-
});
|
|
7299
|
+
}, 0, 1, currentIndex);
|
|
6133
7300
|
const picked = choices.find(option => option.label === answer.selected[0]);
|
|
6134
7301
|
if (picked === undefined)
|
|
6135
7302
|
return;
|
|
@@ -6143,8 +7310,8 @@ export class SshTui {
|
|
|
6143
7310
|
this.pushRow({
|
|
6144
7311
|
kind: 'system',
|
|
6145
7312
|
text: `${picked.id === undefined
|
|
6146
|
-
? '
|
|
6147
|
-
:
|
|
7313
|
+
? t('effort.subDefault')
|
|
7314
|
+
: t('effort.subSwitched', { effort: picked.id })}${persisted ? '' : t('effort.sessionOnly')}`,
|
|
6148
7315
|
});
|
|
6149
7316
|
this.markDirty();
|
|
6150
7317
|
}
|
|
@@ -6211,6 +7378,37 @@ export class SshTui {
|
|
|
6211
7378
|
this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
|
|
6212
7379
|
this.markDirty();
|
|
6213
7380
|
}
|
|
7381
|
+
/** /disconnect: pause (default) or continue the turn after SSH drop. */
|
|
7382
|
+
async runDisconnectCommand(arg) {
|
|
7383
|
+
const direct = parseDisconnectPolicy(arg);
|
|
7384
|
+
let next = direct;
|
|
7385
|
+
if (next === undefined && arg.trim() !== '') {
|
|
7386
|
+
this.pushRow({ kind: 'error', text: t('disconnect.unknown', { id: arg.trim() }) });
|
|
7387
|
+
this.markDirty();
|
|
7388
|
+
return;
|
|
7389
|
+
}
|
|
7390
|
+
if (next === undefined) {
|
|
7391
|
+
const current = this.disconnectPolicy;
|
|
7392
|
+
const answer = await this.askQuestion({
|
|
7393
|
+
id: 'disconnect-pick',
|
|
7394
|
+
question: t('disconnect.pick'),
|
|
7395
|
+
options: [
|
|
7396
|
+
{ label: t('disconnect.pause'), description: current === 'pause' ? t('disconnect.current') : t('disconnect.pauseDesc') },
|
|
7397
|
+
{ label: t('disconnect.continue'), description: current === 'continue' ? t('disconnect.current') : t('disconnect.continueDesc') },
|
|
7398
|
+
],
|
|
7399
|
+
}, 0, 1, current === 'continue' ? 1 : 0);
|
|
7400
|
+
const picked = answer.selected[0];
|
|
7401
|
+
next = picked === t('disconnect.continue') ? 'continue' : 'pause';
|
|
7402
|
+
}
|
|
7403
|
+
this.disconnectPolicy = next;
|
|
7404
|
+
await this.mergeUiSettings({ disconnect: next });
|
|
7405
|
+
this.forceFullPaint = true;
|
|
7406
|
+
this.pushRow({
|
|
7407
|
+
kind: 'system',
|
|
7408
|
+
text: t('disconnect.switched', { name: next === 'continue' ? t('disconnect.continue') : t('disconnect.pause') }),
|
|
7409
|
+
});
|
|
7410
|
+
this.markDirty();
|
|
7411
|
+
}
|
|
6214
7412
|
/** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
|
|
6215
7413
|
async runModeCommand() {
|
|
6216
7414
|
const agentPresets = this.ctx.get('agentPresets');
|
|
@@ -6237,7 +7435,7 @@ export class SshTui {
|
|
|
6237
7435
|
if (selected === undefined)
|
|
6238
7436
|
return;
|
|
6239
7437
|
const selectedName = selected.name ?? selected.id;
|
|
6240
|
-
const hasWork = this.agent.session.
|
|
7438
|
+
const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
|
|
6241
7439
|
if (!hasWork) {
|
|
6242
7440
|
await agentPresets.recompose(this.agent.ctx, selected.id);
|
|
6243
7441
|
this.presetId = selected.id;
|
|
@@ -6382,9 +7580,8 @@ export class SshTui {
|
|
|
6382
7580
|
const quota = await this.refreshQuota({ reason: 'command', announce: true });
|
|
6383
7581
|
if (quota !== undefined)
|
|
6384
7582
|
return;
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
|
|
7583
|
+
if (this.balanceSnapshot !== undefined) {
|
|
7584
|
+
this.pushRow({ kind: 'system', text: formatAccountBalance(this.balanceSnapshot) });
|
|
6388
7585
|
return;
|
|
6389
7586
|
}
|
|
6390
7587
|
const provider = this.currentProviderId();
|
|
@@ -6433,14 +7630,37 @@ export class SshTui {
|
|
|
6433
7630
|
const provider = this.currentProviderId();
|
|
6434
7631
|
const snapshot = await this.fetchQuotaSnapshot(provider);
|
|
6435
7632
|
if (snapshot !== undefined) {
|
|
7633
|
+
this.balanceSnapshot = undefined;
|
|
6436
7634
|
this.applyQuotaSnapshot(snapshot, options.announce);
|
|
6437
7635
|
return snapshot;
|
|
6438
7636
|
}
|
|
7637
|
+
try {
|
|
7638
|
+
const balance = await this.fetchAccountBalance(provider);
|
|
7639
|
+
if (balance !== undefined) {
|
|
7640
|
+
this.balanceSnapshot = balance;
|
|
7641
|
+
this.quotaSnapshot = undefined;
|
|
7642
|
+
this.quotaAlerted.clear();
|
|
7643
|
+
if (options.announce)
|
|
7644
|
+
this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
|
|
7645
|
+
this.markDirty();
|
|
7646
|
+
return undefined;
|
|
7647
|
+
}
|
|
7648
|
+
}
|
|
7649
|
+
catch (error) {
|
|
7650
|
+
if (options.reason === 'command')
|
|
7651
|
+
throw error;
|
|
7652
|
+
this.markDirty();
|
|
7653
|
+
return this.quotaSnapshot;
|
|
7654
|
+
}
|
|
6439
7655
|
if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
|
|
6440
7656
|
this.quotaSnapshot = undefined;
|
|
6441
7657
|
this.quotaAlerted.clear();
|
|
6442
7658
|
this.markDirty();
|
|
6443
7659
|
}
|
|
7660
|
+
if (this.balanceSnapshot !== undefined && this.balanceSnapshot.provider !== provider) {
|
|
7661
|
+
this.balanceSnapshot = undefined;
|
|
7662
|
+
this.markDirty();
|
|
7663
|
+
}
|
|
6444
7664
|
return undefined;
|
|
6445
7665
|
}
|
|
6446
7666
|
finally {
|
|
@@ -6578,6 +7798,12 @@ export class SshTui {
|
|
|
6578
7798
|
if (this.dialog?.kind === 'inspect') {
|
|
6579
7799
|
this.scrollInspectOrTranscript(-1);
|
|
6580
7800
|
}
|
|
7801
|
+
else if (this.moveQuestionCursor(-1)) {
|
|
7802
|
+
return;
|
|
7803
|
+
}
|
|
7804
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(-1)) {
|
|
7805
|
+
return;
|
|
7806
|
+
}
|
|
6581
7807
|
else if (this.suggestionsVisible()) {
|
|
6582
7808
|
this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
|
|
6583
7809
|
this.markDirty();
|
|
@@ -6593,6 +7819,12 @@ export class SshTui {
|
|
|
6593
7819
|
if (this.dialog?.kind === 'inspect') {
|
|
6594
7820
|
this.scrollInspectOrTranscript(1);
|
|
6595
7821
|
}
|
|
7822
|
+
else if (this.moveQuestionCursor(1)) {
|
|
7823
|
+
return;
|
|
7824
|
+
}
|
|
7825
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(1)) {
|
|
7826
|
+
return;
|
|
7827
|
+
}
|
|
6596
7828
|
else if (this.suggestionsVisible()) {
|
|
6597
7829
|
this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
|
|
6598
7830
|
this.markDirty();
|
|
@@ -6633,11 +7865,11 @@ export class SshTui {
|
|
|
6633
7865
|
return;
|
|
6634
7866
|
}
|
|
6635
7867
|
if (combined === '\x1b[5~') {
|
|
6636
|
-
this.scrollInspectOrTranscript(Math.max(3, Math.floor((
|
|
7868
|
+
this.scrollInspectOrTranscript(Math.max(3, Math.floor(this.screenRows() / 2)));
|
|
6637
7869
|
return;
|
|
6638
7870
|
}
|
|
6639
7871
|
if (combined === '\x1b[6~') {
|
|
6640
|
-
this.scrollInspectOrTranscript(-Math.max(3, Math.floor((
|
|
7872
|
+
this.scrollInspectOrTranscript(-Math.max(3, Math.floor(this.screenRows() / 2)));
|
|
6641
7873
|
return;
|
|
6642
7874
|
}
|
|
6643
7875
|
if (parseCursorPositionReply(combined) !== undefined)
|
|
@@ -6768,9 +8000,10 @@ export class SshTui {
|
|
|
6768
8000
|
const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
6769
8001
|
if (normalized === '')
|
|
6770
8002
|
return;
|
|
8003
|
+
this.leaveHistoryBrowse();
|
|
6771
8004
|
this.input = `${this.input.slice(0, this.cursor)}${normalized}${this.input.slice(this.cursor)}`;
|
|
6772
8005
|
this.cursor += normalized.length;
|
|
6773
|
-
const cols = Math.max(10,
|
|
8006
|
+
const cols = Math.max(10, this.screenColumns());
|
|
6774
8007
|
const lineWidth = Math.max(1, cols - 2);
|
|
6775
8008
|
if (normalized.includes('\n') || displayWidth(this.input) > lineWidth)
|
|
6776
8009
|
this.inputFolded = true;
|
|
@@ -6844,12 +8077,14 @@ export class SshTui {
|
|
|
6844
8077
|
this.markDirty();
|
|
6845
8078
|
return;
|
|
6846
8079
|
case '\x15':
|
|
8080
|
+
this.leaveHistoryBrowse();
|
|
6847
8081
|
this.input = '';
|
|
6848
8082
|
this.cursor = 0;
|
|
6849
8083
|
this.inputFolded = false;
|
|
6850
8084
|
this.markDirty();
|
|
6851
8085
|
return;
|
|
6852
8086
|
case '\x0b':
|
|
8087
|
+
this.leaveHistoryBrowse();
|
|
6853
8088
|
this.input = this.input.slice(0, this.cursor);
|
|
6854
8089
|
this.markDirty();
|
|
6855
8090
|
return;
|
|
@@ -6902,11 +8137,27 @@ export class SshTui {
|
|
|
6902
8137
|
return;
|
|
6903
8138
|
}
|
|
6904
8139
|
if (char >= ' ' && char !== '\x7f') {
|
|
8140
|
+
this.leaveHistoryBrowse();
|
|
6905
8141
|
this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
|
|
6906
8142
|
this.cursor += char.length;
|
|
6907
8143
|
this.markDirty();
|
|
6908
8144
|
}
|
|
6909
8145
|
}
|
|
8146
|
+
moveQuestionCursor(delta) {
|
|
8147
|
+
const dialog = this.dialog;
|
|
8148
|
+
if (dialog === undefined || dialog.kind !== 'questions')
|
|
8149
|
+
return false;
|
|
8150
|
+
const count = dialog.question.options?.length ?? 0;
|
|
8151
|
+
if (count === 0)
|
|
8152
|
+
return false;
|
|
8153
|
+
dialog.cursor = Math.max(0, Math.min(count - 1, dialog.cursor + delta));
|
|
8154
|
+
if (dialog.question.multiSelect !== true) {
|
|
8155
|
+
dialog.selected.clear();
|
|
8156
|
+
dialog.selected.add(dialog.cursor);
|
|
8157
|
+
}
|
|
8158
|
+
this.markDirty();
|
|
8159
|
+
return true;
|
|
8160
|
+
}
|
|
6910
8161
|
handleDialogChar(text) {
|
|
6911
8162
|
const dialog = this.dialog;
|
|
6912
8163
|
if (dialog === undefined)
|
|
@@ -6933,6 +8184,7 @@ export class SshTui {
|
|
|
6933
8184
|
const key = text.toLowerCase();
|
|
6934
8185
|
const index = QUESTION_OPTION_KEYS.indexOf(key);
|
|
6935
8186
|
if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
|
|
8187
|
+
dialog.cursor = index;
|
|
6936
8188
|
if (dialog.question.multiSelect === true) {
|
|
6937
8189
|
if (dialog.selected.has(index))
|
|
6938
8190
|
dialog.selected.delete(index);
|
|
@@ -6976,31 +8228,86 @@ export class SshTui {
|
|
|
6976
8228
|
this.markDirty();
|
|
6977
8229
|
}
|
|
6978
8230
|
}
|
|
8231
|
+
/**
|
|
8232
|
+
* The wizard's first-step list: the pinned templates plus the web-catalog
|
|
8233
|
+
* presets (deduped), filtered by the search box text.
|
|
8234
|
+
*/
|
|
8235
|
+
mergedProviderEntries(state) {
|
|
8236
|
+
const templates = providerTemplates();
|
|
8237
|
+
const templateEntries = [
|
|
8238
|
+
{ key: 'template:official', label: templates.official.label, detail: 'api.deepseek.com' },
|
|
8239
|
+
{ key: 'template:opencode-go', label: templates['opencode-go'].label, detail: 'opencode.ai/zen/go · Responses' },
|
|
8240
|
+
{ key: 'template:openai-completions', label: templates['openai-completions'].label, detail: 'openai-completions' },
|
|
8241
|
+
{ key: 'template:openai-responses', label: templates['openai-responses'].label, detail: 'openai-responses' },
|
|
8242
|
+
{ key: 'template:anthropic-messages', label: templates['anthropic-messages'].label, detail: 'anthropic-messages' },
|
|
8243
|
+
];
|
|
8244
|
+
return mergeProviderEntries(templateEntries, state.catalogPresets ?? [], ['deepseek', 'opencode-go'], this.input);
|
|
8245
|
+
}
|
|
8246
|
+
moveProviderCursor(delta) {
|
|
8247
|
+
const state = this.onboarding;
|
|
8248
|
+
if (state === undefined || state.step !== 'provider')
|
|
8249
|
+
return false;
|
|
8250
|
+
const total = this.mergedProviderEntries(state).length;
|
|
8251
|
+
if (total === 0)
|
|
8252
|
+
return false;
|
|
8253
|
+
state.providerCursor = Math.max(0, Math.min(total - 1, state.providerCursor + delta));
|
|
8254
|
+
this.markDirty();
|
|
8255
|
+
return true;
|
|
8256
|
+
}
|
|
6979
8257
|
handleOnboardingChar(text) {
|
|
6980
8258
|
const state = this.onboarding;
|
|
6981
8259
|
if (state === undefined)
|
|
6982
8260
|
return;
|
|
6983
8261
|
switch (state.step) {
|
|
6984
|
-
case 'provider':
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
8262
|
+
case 'provider': {
|
|
8263
|
+
const options = this.mergedProviderEntries(state);
|
|
8264
|
+
if (text === '\r' || text === '\n') {
|
|
8265
|
+
const index = Math.min(state.providerCursor, options.length - 1);
|
|
8266
|
+
const entry = options[index];
|
|
8267
|
+
if (entry === undefined) {
|
|
8268
|
+
this.markDirty();
|
|
8269
|
+
return;
|
|
8270
|
+
}
|
|
8271
|
+
state.providerId = '';
|
|
8272
|
+
state.baseUrl = '';
|
|
8273
|
+
state.key = '';
|
|
8274
|
+
state.models = [];
|
|
8275
|
+
this.input = '';
|
|
8276
|
+
this.cursor = 0;
|
|
8277
|
+
if (entry.catalog !== undefined) {
|
|
8278
|
+
state.providerType = 'catalog';
|
|
8279
|
+
state.catalog = entry.catalog;
|
|
8280
|
+
state.step = 'id';
|
|
8281
|
+
}
|
|
8282
|
+
else {
|
|
8283
|
+
state.providerType = entry.key.slice('template:'.length);
|
|
7000
8284
|
this.advanceOnboarding();
|
|
7001
8285
|
}
|
|
8286
|
+
this.markDirty();
|
|
8287
|
+
return;
|
|
8288
|
+
}
|
|
8289
|
+
if (text === '\x7f') {
|
|
8290
|
+
if (this.input !== '') {
|
|
8291
|
+
this.input = this.input.slice(0, -1);
|
|
8292
|
+
state.providerCursor = 0;
|
|
8293
|
+
}
|
|
8294
|
+
this.markDirty();
|
|
8295
|
+
return;
|
|
8296
|
+
}
|
|
8297
|
+
let changed = false;
|
|
8298
|
+
for (const char of text) {
|
|
8299
|
+
if (char >= ' ' && char !== '\x7f') {
|
|
8300
|
+
this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
|
|
8301
|
+
this.cursor += char.length;
|
|
8302
|
+
changed = true;
|
|
8303
|
+
}
|
|
8304
|
+
}
|
|
8305
|
+
if (changed) {
|
|
8306
|
+
state.providerCursor = 0;
|
|
8307
|
+
this.markDirty();
|
|
7002
8308
|
}
|
|
7003
8309
|
return;
|
|
8310
|
+
}
|
|
7004
8311
|
case 'id':
|
|
7005
8312
|
case 'base-url':
|
|
7006
8313
|
case 'key':
|
|
@@ -7012,7 +8319,7 @@ export class SshTui {
|
|
|
7012
8319
|
if (text === '\r' || text === '\n') {
|
|
7013
8320
|
const value = this.input.trim();
|
|
7014
8321
|
if (state.step === 'id') {
|
|
7015
|
-
const template =
|
|
8322
|
+
const template = onboardTemplate(state);
|
|
7016
8323
|
const id = value === '' ? template.defaultId : value;
|
|
7017
8324
|
if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
|
|
7018
8325
|
this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
|
|
@@ -7022,7 +8329,7 @@ export class SshTui {
|
|
|
7022
8329
|
state.providerId = id;
|
|
7023
8330
|
}
|
|
7024
8331
|
else if (state.step === 'key') {
|
|
7025
|
-
if (value === '') {
|
|
8332
|
+
if (value === '' && state.providerType !== 'catalog') {
|
|
7026
8333
|
this.pushRow({ kind: 'error', text: 'API Key 不能为空,请重新输入。' });
|
|
7027
8334
|
this.markDirty();
|
|
7028
8335
|
return;
|
|
@@ -7030,7 +8337,7 @@ export class SshTui {
|
|
|
7030
8337
|
state.key = value;
|
|
7031
8338
|
}
|
|
7032
8339
|
else if (state.step === 'models') {
|
|
7033
|
-
const template =
|
|
8340
|
+
const template = onboardTemplate(state);
|
|
7034
8341
|
const parsed = value === ''
|
|
7035
8342
|
? template.defaultModels
|
|
7036
8343
|
: value.split(/[\s,,]+/u).filter(Boolean);
|
|
@@ -7109,12 +8416,12 @@ export class SshTui {
|
|
|
7109
8416
|
const state = this.onboarding;
|
|
7110
8417
|
if (state === undefined || state.step !== 'models')
|
|
7111
8418
|
return;
|
|
7112
|
-
const template =
|
|
8419
|
+
const template = onboardTemplate(state);
|
|
7113
8420
|
const providerType = state.providerType;
|
|
7114
8421
|
const baseUrl = state.baseUrl;
|
|
7115
8422
|
const key = state.key;
|
|
7116
8423
|
const baseURL = baseUrl === '' ? template.defaultBaseUrl : baseUrl;
|
|
7117
|
-
if (baseURL === '') {
|
|
8424
|
+
if (baseURL === '' && providerType !== 'catalog') {
|
|
7118
8425
|
this.pushRow({ kind: 'error', text: '请先填写 Base URL 再获取模型列表。' });
|
|
7119
8426
|
this.markDirty();
|
|
7120
8427
|
return;
|
|
@@ -7127,7 +8434,10 @@ export class SshTui {
|
|
|
7127
8434
|
if (llm === undefined)
|
|
7128
8435
|
throw new Error('llm 服务不可用');
|
|
7129
8436
|
const discovered = await discoverProviderModels(llm, {
|
|
7130
|
-
baseURL
|
|
8437
|
+
...(providerType === 'catalog' && state.catalog !== undefined && baseURL === ''
|
|
8438
|
+
? {}
|
|
8439
|
+
: { baseURL }),
|
|
8440
|
+
...(providerType === 'catalog' && state.catalog !== undefined ? { provider: state.catalog.id } : {}),
|
|
7131
8441
|
...(template.api === undefined ? {} : { api: template.api }),
|
|
7132
8442
|
...(key === '' ? {} : { apiKey: key }),
|
|
7133
8443
|
}, AbortSignal.timeout(15_000));
|
|
@@ -7167,7 +8477,7 @@ export class SshTui {
|
|
|
7167
8477
|
try {
|
|
7168
8478
|
const credentials = this.ctx.get('credentials');
|
|
7169
8479
|
const settings = this.ctx.get('settings');
|
|
7170
|
-
const template =
|
|
8480
|
+
const template = onboardTemplate(state);
|
|
7171
8481
|
if (state.providerType === 'official') {
|
|
7172
8482
|
const envRef = 'DEEPSEEK_API_KEY';
|
|
7173
8483
|
await this.saveCredential(credentials, envRef, state.key);
|
|
@@ -7226,15 +8536,24 @@ export class SshTui {
|
|
|
7226
8536
|
mergedIds.push(id);
|
|
7227
8537
|
}
|
|
7228
8538
|
}
|
|
8539
|
+
// Catalog routes mirror the web's model settings: an empty profile is
|
|
8540
|
+
// valid — the installed catalog serves endpoint, protocol, and models,
|
|
8541
|
+
// and an absent key defers to the provider's own environment auth.
|
|
8542
|
+
const catalogRoute = state.providerType === 'catalog' && state.catalog !== undefined;
|
|
8543
|
+
const keyless = catalogRoute && state.key === '';
|
|
7229
8544
|
const profile = {
|
|
7230
8545
|
displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
|
|
7231
8546
|
? existing.displayName
|
|
7232
8547
|
: template.label,
|
|
7233
|
-
apiKeyEnv: envRef,
|
|
8548
|
+
...(keyless ? {} : { apiKeyEnv: envRef }),
|
|
7234
8549
|
api: template.api ?? existing?.api,
|
|
7235
|
-
|
|
7236
|
-
?
|
|
7237
|
-
:
|
|
8550
|
+
...(catalogRoute && state.baseUrl === ''
|
|
8551
|
+
? {}
|
|
8552
|
+
: {
|
|
8553
|
+
baseURL: state.baseUrl === ''
|
|
8554
|
+
? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
|
|
8555
|
+
: state.baseUrl,
|
|
8556
|
+
}),
|
|
7238
8557
|
models: mergedIds.map(id => ({
|
|
7239
8558
|
id,
|
|
7240
8559
|
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
@@ -7253,7 +8572,7 @@ export class SshTui {
|
|
|
7253
8572
|
}
|
|
7254
8573
|
// Only store the key when its provider profile actually made it to
|
|
7255
8574
|
// settings; otherwise the saved key points at an unusable route.
|
|
7256
|
-
if (saved)
|
|
8575
|
+
if (saved && !keyless)
|
|
7257
8576
|
await this.saveCredential(credentials, envRef, state.key);
|
|
7258
8577
|
if (saved) {
|
|
7259
8578
|
const selection = {
|
|
@@ -7507,6 +8826,8 @@ export class SshTui {
|
|
|
7507
8826
|
if (text === '')
|
|
7508
8827
|
return;
|
|
7509
8828
|
if (text.startsWith('/')) {
|
|
8829
|
+
this.historyIndex = this.history.length;
|
|
8830
|
+
this.historyDraft = '';
|
|
7510
8831
|
this.runCommand(text);
|
|
7511
8832
|
return;
|
|
7512
8833
|
}
|
|
@@ -7514,6 +8835,7 @@ export class SshTui {
|
|
|
7514
8835
|
return;
|
|
7515
8836
|
this.history.push(text);
|
|
7516
8837
|
this.historyIndex = this.history.length;
|
|
8838
|
+
this.historyDraft = '';
|
|
7517
8839
|
this.input = '';
|
|
7518
8840
|
this.cursor = 0;
|
|
7519
8841
|
this.inputFolded = false;
|
|
@@ -7527,7 +8849,7 @@ export class SshTui {
|
|
|
7527
8849
|
this.agent.steer(message);
|
|
7528
8850
|
}
|
|
7529
8851
|
else {
|
|
7530
|
-
this.beginWait(
|
|
8852
|
+
this.beginWait();
|
|
7531
8853
|
this.agent.followup(message);
|
|
7532
8854
|
}
|
|
7533
8855
|
this.markDirty();
|
|
@@ -7538,10 +8860,16 @@ export class SshTui {
|
|
|
7538
8860
|
switch (command) {
|
|
7539
8861
|
case 'help': {
|
|
7540
8862
|
const local = localizedCommands()
|
|
7541
|
-
.filter(item => item.name !== 'help' && item.
|
|
8863
|
+
.filter(item => item.name !== 'help' && item.aliasOf === undefined)
|
|
7542
8864
|
.map(item => `/${item.name.padEnd(12)} ${item.description}`);
|
|
8865
|
+
const seen = new Set(localizedCommands().map(item => item.name));
|
|
7543
8866
|
const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
|
|
7544
|
-
.
|
|
8867
|
+
.filter(item => !seen.has(item.name))
|
|
8868
|
+
.map(item => {
|
|
8869
|
+
const descKey = `cmd.${item.name}`;
|
|
8870
|
+
const desc = t(descKey, undefined, item.description);
|
|
8871
|
+
return `/${item.name.padEnd(12)} ${item.input?.images === true ? t('cmd.withImagesSuffix', { desc }) : desc} (dsh)`;
|
|
8872
|
+
});
|
|
7545
8873
|
this.pushRow({
|
|
7546
8874
|
kind: 'system',
|
|
7547
8875
|
text: [
|
|
@@ -7555,6 +8883,7 @@ export class SshTui {
|
|
|
7555
8883
|
t('help.intro5'),
|
|
7556
8884
|
t('help.intro6'),
|
|
7557
8885
|
t('help.intro7'),
|
|
8886
|
+
t('help.intro8'),
|
|
7558
8887
|
].join('\n'),
|
|
7559
8888
|
});
|
|
7560
8889
|
break;
|
|
@@ -7574,6 +8903,17 @@ export class SshTui {
|
|
|
7574
8903
|
this.markDirty();
|
|
7575
8904
|
});
|
|
7576
8905
|
break;
|
|
8906
|
+
case 'effort':
|
|
8907
|
+
void this.runEffortCommand(arg).catch((error) => {
|
|
8908
|
+
if (error instanceof UserQuestionError) {
|
|
8909
|
+
this.pushRow({ kind: 'system', text: t('help.effortCancel') });
|
|
8910
|
+
}
|
|
8911
|
+
else {
|
|
8912
|
+
this.pushRow({ kind: 'error', text: `/effort failed: ${errorChain(error)}` });
|
|
8913
|
+
}
|
|
8914
|
+
this.markDirty();
|
|
8915
|
+
});
|
|
8916
|
+
break;
|
|
7577
8917
|
case 'provider':
|
|
7578
8918
|
void this.runProviderCommand().catch((error) => {
|
|
7579
8919
|
if (error instanceof UserQuestionError) {
|
|
@@ -7597,7 +8937,7 @@ export class SshTui {
|
|
|
7597
8937
|
});
|
|
7598
8938
|
break;
|
|
7599
8939
|
case 'subeffort':
|
|
7600
|
-
void this.runSubeffortCommand().catch((error) => {
|
|
8940
|
+
void this.runSubeffortCommand(arg).catch((error) => {
|
|
7601
8941
|
if (error instanceof UserQuestionError) {
|
|
7602
8942
|
this.pushRow({ kind: 'system', text: t('help.subeffortCancel') });
|
|
7603
8943
|
}
|
|
@@ -7641,6 +8981,17 @@ export class SshTui {
|
|
|
7641
8981
|
this.markDirty();
|
|
7642
8982
|
});
|
|
7643
8983
|
break;
|
|
8984
|
+
case 'disconnect':
|
|
8985
|
+
void this.runDisconnectCommand(arg).catch((error) => {
|
|
8986
|
+
if (error instanceof UserQuestionError) {
|
|
8987
|
+
this.pushRow({ kind: 'system', text: t('help.modeCancel') });
|
|
8988
|
+
}
|
|
8989
|
+
else {
|
|
8990
|
+
this.pushRow({ kind: 'error', text: `/disconnect failed: ${errorChain(error)}` });
|
|
8991
|
+
}
|
|
8992
|
+
this.markDirty();
|
|
8993
|
+
});
|
|
8994
|
+
break;
|
|
7644
8995
|
case 'find':
|
|
7645
8996
|
this.runFindCommand(arg);
|
|
7646
8997
|
break;
|
|
@@ -7650,7 +9001,6 @@ export class SshTui {
|
|
|
7650
9001
|
this.streamingReasoning = undefined;
|
|
7651
9002
|
this.thinkingStartedAt = undefined;
|
|
7652
9003
|
this.waitStartedAt = undefined;
|
|
7653
|
-
this.waitPrompt = undefined;
|
|
7654
9004
|
this.focusedRow = null;
|
|
7655
9005
|
this.searchHits = [];
|
|
7656
9006
|
this.searchIndex = -1;
|
|
@@ -7681,6 +9031,7 @@ export class SshTui {
|
|
|
7681
9031
|
activeSubagents: this.activeSubagents.size,
|
|
7682
9032
|
plan: plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off',
|
|
7683
9033
|
paint: formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed),
|
|
9034
|
+
disconnect: this.disconnectPolicy,
|
|
7684
9035
|
waitingQuestions: waiting,
|
|
7685
9036
|
...(quota === undefined ? {} : { quota }),
|
|
7686
9037
|
parentModel: model,
|
|
@@ -7751,6 +9102,40 @@ export class SshTui {
|
|
|
7751
9102
|
this.markDirty();
|
|
7752
9103
|
});
|
|
7753
9104
|
break;
|
|
9105
|
+
case 'approval': {
|
|
9106
|
+
const requested = arg === '' ? 'toggle' : arg;
|
|
9107
|
+
if (requested === 'status') {
|
|
9108
|
+
this.pushRow({
|
|
9109
|
+
kind: 'system',
|
|
9110
|
+
text: this.autoApprovalMode === 'auto'
|
|
9111
|
+
? t('approval.statusAuto', { allowed: this.autoAllowedCount, denied: this.autoDeniedCount })
|
|
9112
|
+
: t('approval.statusOff'),
|
|
9113
|
+
});
|
|
9114
|
+
this.markDirty();
|
|
9115
|
+
break;
|
|
9116
|
+
}
|
|
9117
|
+
const next = requested === 'toggle'
|
|
9118
|
+
? this.autoApprovalMode === 'auto' ? 'off' : 'auto'
|
|
9119
|
+
: parseAutoApprovalMode(requested);
|
|
9120
|
+
if (next === undefined) {
|
|
9121
|
+
this.pushRow({ kind: 'error', text: t('approval.unknown', { arg }) });
|
|
9122
|
+
this.markDirty();
|
|
9123
|
+
break;
|
|
9124
|
+
}
|
|
9125
|
+
this.autoApprovalMode = next;
|
|
9126
|
+
void this.mergeUiSettings({ autoApproval: next }).catch((error) => {
|
|
9127
|
+
this.pushRow({ kind: 'error', text: `/approval failed: ${errorChain(error)}` });
|
|
9128
|
+
this.markDirty();
|
|
9129
|
+
});
|
|
9130
|
+
this.pushRow({
|
|
9131
|
+
kind: 'system',
|
|
9132
|
+
text: next === 'auto' ? t('approval.autoOn') : t('approval.autoOff'),
|
|
9133
|
+
});
|
|
9134
|
+
if (next === 'auto')
|
|
9135
|
+
this.warnApprovalMismatch();
|
|
9136
|
+
this.markDirty();
|
|
9137
|
+
break;
|
|
9138
|
+
}
|
|
7754
9139
|
case 'setup':
|
|
7755
9140
|
void this.runOnboarding();
|
|
7756
9141
|
break;
|
|
@@ -7791,14 +9176,16 @@ export class SshTui {
|
|
|
7791
9176
|
this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
|
|
7792
9177
|
return;
|
|
7793
9178
|
}
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
9179
|
+
// command/run + command/done already paint via handleCommandDone
|
|
9180
|
+
// when the session log is live. Fall back if those events never
|
|
9181
|
+
// arrived (no persistence, or a handler that skipped the log).
|
|
9182
|
+
if (this.seenCommandDoneIds.has(String(execution.commandId)))
|
|
9183
|
+
return;
|
|
9184
|
+
if (execution.result.kind === 'error') {
|
|
9185
|
+
this.pushRow({ kind: 'error', text: this.formatCommandText(execution.result.text) });
|
|
7799
9186
|
}
|
|
7800
|
-
else {
|
|
7801
|
-
this.pushRow({ kind: '
|
|
9187
|
+
else if (execution.result.text !== undefined && execution.result.text !== '') {
|
|
9188
|
+
this.pushRow({ kind: 'system', text: this.formatCommandText(execution.result.text) });
|
|
7802
9189
|
}
|
|
7803
9190
|
}).catch((error) => {
|
|
7804
9191
|
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|
|
@@ -7846,6 +9233,7 @@ export class SshTui {
|
|
|
7846
9233
|
backspace() {
|
|
7847
9234
|
if (this.cursor === 0)
|
|
7848
9235
|
return;
|
|
9236
|
+
this.leaveHistoryBrowse();
|
|
7849
9237
|
const range = this.graphemeBefore(this.cursor);
|
|
7850
9238
|
this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
|
|
7851
9239
|
this.cursor = range.start;
|
|
@@ -7855,6 +9243,7 @@ export class SshTui {
|
|
|
7855
9243
|
const range = this.graphemeAfter(this.cursor);
|
|
7856
9244
|
if (range === undefined)
|
|
7857
9245
|
return;
|
|
9246
|
+
this.leaveHistoryBrowse();
|
|
7858
9247
|
this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
|
|
7859
9248
|
this.cursor = range.start;
|
|
7860
9249
|
this.markDirty();
|
|
@@ -7890,6 +9279,8 @@ export class SshTui {
|
|
|
7890
9279
|
historyBack() {
|
|
7891
9280
|
if (this.history.length === 0)
|
|
7892
9281
|
return;
|
|
9282
|
+
if (this.historyIndex === this.history.length)
|
|
9283
|
+
this.historyDraft = this.input;
|
|
7893
9284
|
if (this.historyIndex <= 0)
|
|
7894
9285
|
return;
|
|
7895
9286
|
this.historyIndex -= 1;
|
|
@@ -7898,13 +9289,22 @@ export class SshTui {
|
|
|
7898
9289
|
this.markDirty();
|
|
7899
9290
|
}
|
|
7900
9291
|
historyForward() {
|
|
7901
|
-
if (this.historyIndex >= this.history.length)
|
|
9292
|
+
if (this.historyIndex < 0 || this.historyIndex >= this.history.length)
|
|
7902
9293
|
return;
|
|
7903
9294
|
this.historyIndex += 1;
|
|
7904
|
-
this.input = this.
|
|
9295
|
+
this.input = this.historyIndex >= this.history.length
|
|
9296
|
+
? this.historyDraft
|
|
9297
|
+
: (this.history[this.historyIndex] ?? '');
|
|
7905
9298
|
this.cursor = this.input.length;
|
|
7906
9299
|
this.markDirty();
|
|
7907
9300
|
}
|
|
9301
|
+
/** Typing while browsing history detaches from the saved item. */
|
|
9302
|
+
leaveHistoryBrowse() {
|
|
9303
|
+
if (this.historyIndex >= 0 && this.historyIndex < this.history.length) {
|
|
9304
|
+
this.historyIndex = this.history.length;
|
|
9305
|
+
this.historyDraft = this.input;
|
|
9306
|
+
}
|
|
9307
|
+
}
|
|
7908
9308
|
}
|
|
7909
9309
|
function optionsLength(dialog) {
|
|
7910
9310
|
return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
|
|
@@ -7972,6 +9372,12 @@ export function mountTui(ctx, config) {
|
|
|
7972
9372
|
stopWaiting();
|
|
7973
9373
|
await controller?.dispose();
|
|
7974
9374
|
},
|
|
9375
|
+
async handleHangup() {
|
|
9376
|
+
await controller?.handleHangup();
|
|
9377
|
+
},
|
|
9378
|
+
disconnectPolicy() {
|
|
9379
|
+
return controller?.currentDisconnectPolicy() ?? 'pause';
|
|
9380
|
+
},
|
|
7975
9381
|
};
|
|
7976
9382
|
}
|
|
7977
9383
|
//# sourceMappingURL=tui.js.map
|