dsh-ssh-tui 0.4.1 → 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 +1759 -361
- 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);
|
|
@@ -3332,18 +4083,19 @@ export class SshTui {
|
|
|
3332
4083
|
collapsibleRows() {
|
|
3333
4084
|
const compact = this.isCompactView();
|
|
3334
4085
|
if (compact) {
|
|
3335
|
-
const groups = compactToolGroups(this.rows.filter((row) => row.kind === 'tool'));
|
|
3336
4086
|
const rows = this.rows.filter((row) => row.kind === 'subagent'
|
|
3337
4087
|
|| row.kind === 'plan'
|
|
3338
4088
|
|| row.kind === 'question'
|
|
3339
4089
|
|| row.kind === 'goal'
|
|
3340
4090
|
|| row.kind === 'compaction');
|
|
3341
|
-
const
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
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
|
+
}
|
|
3347
4099
|
return rows;
|
|
3348
4100
|
}
|
|
3349
4101
|
const rows = this.rows.filter((row) => row.kind === 'reasoning'
|
|
@@ -3364,26 +4116,27 @@ export class SshTui {
|
|
|
3364
4116
|
return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
|
|
3365
4117
|
}
|
|
3366
4118
|
/**
|
|
3367
|
-
* Codex wait card: shown while the turn is running.
|
|
3368
|
-
*
|
|
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).
|
|
3369
4123
|
*/
|
|
3370
4124
|
waitCardVisible() {
|
|
3371
4125
|
if (this.agent.status !== 'running')
|
|
3372
4126
|
return false;
|
|
4127
|
+
if (this.streaming?.text)
|
|
4128
|
+
return false;
|
|
3373
4129
|
if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
|
|
3374
4130
|
return false;
|
|
3375
4131
|
if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
|
|
3376
4132
|
return false;
|
|
3377
4133
|
return true;
|
|
3378
4134
|
}
|
|
3379
|
-
beginWait(
|
|
4135
|
+
beginWait() {
|
|
3380
4136
|
this.waitStartedAt = Date.now();
|
|
3381
|
-
const trimmed = prompt?.replace(/\s+/gu, ' ').trim();
|
|
3382
|
-
this.waitPrompt = trimmed === undefined || trimmed === '' ? this.waitPrompt : trimmed;
|
|
3383
4137
|
}
|
|
3384
4138
|
endWait() {
|
|
3385
4139
|
this.waitStartedAt = undefined;
|
|
3386
|
-
this.waitPrompt = undefined;
|
|
3387
4140
|
}
|
|
3388
4141
|
waitCardSource() {
|
|
3389
4142
|
const liveTool = this.rows.findLast((row) => row.kind === 'tool' && (row.status === undefined || row.status === 'running'));
|
|
@@ -3394,8 +4147,6 @@ export class SshTui {
|
|
|
3394
4147
|
? {}
|
|
3395
4148
|
: { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity }),
|
|
3396
4149
|
...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
|
|
3397
|
-
...(this.streaming?.text ? { reply: this.streaming.text } : {}),
|
|
3398
|
-
...(this.waitPrompt === undefined ? {} : { prompt: this.waitPrompt }),
|
|
3399
4150
|
};
|
|
3400
4151
|
}
|
|
3401
4152
|
planShouldDefaultExpand(plan) {
|
|
@@ -3695,8 +4446,8 @@ export class SshTui {
|
|
|
3695
4446
|
}
|
|
3696
4447
|
toggleCard(target) {
|
|
3697
4448
|
if (target.kind === 'tool' && !target.expanded) {
|
|
3698
|
-
const width = Math.max(10,
|
|
3699
|
-
const height = Math.max(6,
|
|
4449
|
+
const width = Math.max(10, this.screenColumns());
|
|
4450
|
+
const height = Math.max(6, this.screenRows());
|
|
3700
4451
|
const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
|
|
3701
4452
|
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
3702
4453
|
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
@@ -3722,8 +4473,8 @@ export class SshTui {
|
|
|
3722
4473
|
this.focusedRow = null;
|
|
3723
4474
|
}
|
|
3724
4475
|
else {
|
|
3725
|
-
const width = Math.max(10,
|
|
3726
|
-
const height = Math.max(6,
|
|
4476
|
+
const width = Math.max(10, this.screenColumns());
|
|
4477
|
+
const height = Math.max(6, this.screenRows());
|
|
3727
4478
|
const workspace = this.workspaceRowsFor(width, height);
|
|
3728
4479
|
for (const row of rows) {
|
|
3729
4480
|
if (row.kind === 'tool') {
|
|
@@ -3747,8 +4498,8 @@ export class SshTui {
|
|
|
3747
4498
|
if (row === undefined)
|
|
3748
4499
|
return;
|
|
3749
4500
|
if (row.kind === 'tool') {
|
|
3750
|
-
const width = Math.max(10,
|
|
3751
|
-
const height = Math.max(6,
|
|
4501
|
+
const width = Math.max(10, this.screenColumns());
|
|
4502
|
+
const height = Math.max(6, this.screenRows());
|
|
3752
4503
|
const body = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
|
|
3753
4504
|
const bodyRows = wrappedToolBodyLineCount(body, width);
|
|
3754
4505
|
if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
|
|
@@ -3834,8 +4585,8 @@ export class SshTui {
|
|
|
3834
4585
|
paint = () => {
|
|
3835
4586
|
if (this.exiting)
|
|
3836
4587
|
return;
|
|
3837
|
-
const width = Math.max(10,
|
|
3838
|
-
const height = Math.max(6,
|
|
4588
|
+
const width = Math.max(10, this.screenColumns());
|
|
4589
|
+
const height = Math.max(6, this.screenRows());
|
|
3839
4590
|
if (this.dialog?.kind === 'inspect') {
|
|
3840
4591
|
this.paintInspectOverlay(width, height);
|
|
3841
4592
|
return;
|
|
@@ -3861,12 +4612,22 @@ export class SshTui {
|
|
|
3861
4612
|
}
|
|
3862
4613
|
};
|
|
3863
4614
|
const compact = this.isCompactView();
|
|
3864
|
-
const
|
|
3865
|
-
|
|
3866
|
-
|
|
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;
|
|
3867
4622
|
for (const row of this.rows) {
|
|
3868
4623
|
if (compact && (row.kind === 'reasoning' || row.kind === 'prompt' || row.kind === 'tool'))
|
|
3869
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;
|
|
4630
|
+
}
|
|
3870
4631
|
if (row.kind === 'brand-logo') {
|
|
3871
4632
|
const variant = DEEPSEEK_LOGO_VARIANTS.find(candidate => candidate.width <= width - 2)
|
|
3872
4633
|
?? DEEPSEEK_LOGO_VARIANTS[DEEPSEEK_LOGO_VARIANTS.length - 1];
|
|
@@ -3901,12 +4662,16 @@ export class SshTui {
|
|
|
3901
4662
|
focused,
|
|
3902
4663
|
expanded: row.expanded,
|
|
3903
4664
|
title: toolTitle(row.name) || row.title,
|
|
3904
|
-
summary: row
|
|
4665
|
+
summary: this.toolCardSummary(row),
|
|
3905
4666
|
status: row.status,
|
|
3906
4667
|
command: row.command,
|
|
3907
4668
|
signal: row.signal,
|
|
3908
4669
|
exitCode: row.exitCode,
|
|
3909
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
|
+
: {}),
|
|
3910
4675
|
});
|
|
3911
4676
|
const headerSegments = this.color ? header.segments : [];
|
|
3912
4677
|
if (!row.expanded) {
|
|
@@ -4074,6 +4839,13 @@ export class SshTui {
|
|
|
4074
4839
|
continue;
|
|
4075
4840
|
}
|
|
4076
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
|
+
}
|
|
4077
4849
|
}
|
|
4078
4850
|
if (this.streaming !== undefined) {
|
|
4079
4851
|
if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
|
|
@@ -4107,14 +4879,16 @@ export class SshTui {
|
|
|
4107
4879
|
}
|
|
4108
4880
|
}
|
|
4109
4881
|
}
|
|
4110
|
-
if (compact
|
|
4111
|
-
const
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
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);
|
|
4115
4888
|
}
|
|
4116
|
-
|
|
4117
|
-
|
|
4889
|
+
const leading = compactBursts.find(burst => burst.after === undefined);
|
|
4890
|
+
if (leading !== undefined && !paintedLeadingCompact) {
|
|
4891
|
+
this.paintCompactBurst(addDisplay, leading.groups, width);
|
|
4118
4892
|
}
|
|
4119
4893
|
}
|
|
4120
4894
|
if (this.waitCardVisible()) {
|
|
@@ -4127,8 +4901,8 @@ export class SshTui {
|
|
|
4127
4901
|
? `${spinner} ${shimmerText(copy.header, Date.now(), true)} ${this.styleLine('system', hint)}`
|
|
4128
4902
|
: `${spinner} ${copy.header} ${hint}`;
|
|
4129
4903
|
addDisplay(header);
|
|
4130
|
-
|
|
4131
|
-
addDisplay(this.styleLine('system',
|
|
4904
|
+
for (const line of wrapWaitDetails(copy.detail ?? '', width)) {
|
|
4905
|
+
addDisplay(this.styleLine('system', line));
|
|
4132
4906
|
}
|
|
4133
4907
|
}
|
|
4134
4908
|
const dialogLines = [];
|
|
@@ -4145,32 +4919,47 @@ export class SshTui {
|
|
|
4145
4919
|
else if (this.dialog.kind === 'onboarding') {
|
|
4146
4920
|
const ob = this.onboarding;
|
|
4147
4921
|
if (ob !== undefined) {
|
|
4148
|
-
const template =
|
|
4922
|
+
const template = onboardTemplate(ob);
|
|
4149
4923
|
const providerLabel = `${template.label}${template.defaultBaseUrl === '' ? '' : `(${template.defaultBaseUrl})`}`;
|
|
4150
4924
|
switch (ob.step) {
|
|
4151
|
-
case 'provider':
|
|
4925
|
+
case 'provider': {
|
|
4926
|
+
const options = this.mergedProviderEntries(ob);
|
|
4152
4927
|
addDialog(t('onboard.title'));
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
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 }));
|
|
4158
4947
|
addDialog(t('onboard.pickHint'));
|
|
4159
4948
|
break;
|
|
4949
|
+
}
|
|
4160
4950
|
case 'id':
|
|
4161
4951
|
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
4162
4952
|
addDialog(t('onboard.idPrompt'));
|
|
4163
4953
|
addDialog(t('onboard.default', { value: template.defaultId }));
|
|
4164
4954
|
addDialog(t('onboard.enterEsc'));
|
|
4165
4955
|
break;
|
|
4166
|
-
case 'key':
|
|
4167
|
-
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
4168
|
-
addDialog(t('onboard.keyPrompt'));
|
|
4169
|
-
addDialog(t('onboard.enterEsc'));
|
|
4170
|
-
break;
|
|
4171
4956
|
case 'base-url':
|
|
4172
4957
|
addDialog(t('onboard.providerLine', { label: providerLabel }));
|
|
4173
|
-
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
|
+
}));
|
|
4174
4963
|
addDialog(t('onboard.enterEsc'));
|
|
4175
4964
|
break;
|
|
4176
4965
|
case 'models':
|
|
@@ -4181,6 +4970,8 @@ export class SshTui {
|
|
|
4181
4970
|
: t('onboard.default', { value: template.defaultModels.join(', ') }));
|
|
4182
4971
|
if (template.api !== undefined)
|
|
4183
4972
|
addDialog(t('onboard.ctrlF'));
|
|
4973
|
+
if (ob.providerType === 'catalog')
|
|
4974
|
+
addDialog(t('onboard.modelsCatalogHint'));
|
|
4184
4975
|
addDialog(t('onboard.enterEsc'));
|
|
4185
4976
|
break;
|
|
4186
4977
|
case 'confirm':
|
|
@@ -4188,7 +4979,9 @@ export class SshTui {
|
|
|
4188
4979
|
addDialog(t('onboard.confirmProvider', { label: providerLabel }));
|
|
4189
4980
|
addDialog(` Provider ID: ${ob.providerId}`);
|
|
4190
4981
|
addDialog(t('onboard.confirmBase', { url: ob.baseUrl === '' ? (template.defaultBaseUrl || t('onboard.defaultParen')) : ob.baseUrl }));
|
|
4191
|
-
addDialog(t('onboard.confirmApi', {
|
|
4982
|
+
addDialog(t('onboard.confirmApi', {
|
|
4983
|
+
api: template.api ?? (ob.providerType === 'catalog' ? t('onboard.apiCatalog') : 'deepseek-official'),
|
|
4984
|
+
}));
|
|
4192
4985
|
addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
|
|
4193
4986
|
addDialog(t('onboard.confirmKey', {
|
|
4194
4987
|
head: sliceCodePoints(ob.key, 6),
|
|
@@ -4222,17 +5015,27 @@ export class SshTui {
|
|
|
4222
5015
|
}
|
|
4223
5016
|
const options = d.question.options ?? [];
|
|
4224
5017
|
const approve = d.question.intent?.approve;
|
|
4225
|
-
|
|
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;
|
|
4226
5026
|
const marker = d.selected.has(index) ? '●' : '○';
|
|
4227
|
-
const key = QUESTION_OPTION_KEYS[index] ?? '
|
|
5027
|
+
const key = QUESTION_OPTION_KEYS[index] ?? '↕';
|
|
5028
|
+
const focused = index === d.cursor ? '›' : ' ';
|
|
4228
5029
|
const recommended = option.label === approve ? '(推荐)' : '';
|
|
4229
5030
|
const extra = option.description === undefined ? '' : ` — ${option.description}`;
|
|
4230
|
-
addDialog(`
|
|
5031
|
+
addDialog(` ${focused}${key} ${marker} ${option.label}${recommended}${extra}`);
|
|
4231
5032
|
}
|
|
5033
|
+
if (end < options.length)
|
|
5034
|
+
addDialog(` ↓ 还有 ${options.length - end} 项`);
|
|
4232
5035
|
if (options.length === 0) {
|
|
4233
5036
|
addDialog(' (自由输入:在下方输入后按 Enter)');
|
|
4234
5037
|
}
|
|
4235
|
-
addDialog(` ${d.question.multiSelect === true ? '
|
|
5038
|
+
addDialog(` ${d.question.multiSelect === true ? '↑/↓ 或数字/字母切换,Enter 提交' : '↑/↓ 或数字/字母选择,Enter 提交'},Esc 取消`);
|
|
4236
5039
|
}
|
|
4237
5040
|
}
|
|
4238
5041
|
const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
|
|
@@ -4248,13 +5051,24 @@ export class SshTui {
|
|
|
4248
5051
|
this.suggestionIndex = Math.max(0, this.commandSuggestions.length - 1);
|
|
4249
5052
|
}
|
|
4250
5053
|
const suggestionLines = [];
|
|
4251
|
-
|
|
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;
|
|
4252
5063
|
const marker = index === this.suggestionIndex ? '›' : ' ';
|
|
4253
5064
|
const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
|
|
4254
5065
|
suggestionLines.push(index === this.suggestionIndex && this.color
|
|
4255
5066
|
? `\x1b[7m${fitLine(line)}\x1b[27m`
|
|
4256
5067
|
: this.styleLine('system', fitLine(line)));
|
|
4257
5068
|
}
|
|
5069
|
+
if (suggestionEnd < this.commandSuggestions.length) {
|
|
5070
|
+
suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreBelow', { count: this.commandSuggestions.length - suggestionEnd })}`)));
|
|
5071
|
+
}
|
|
4258
5072
|
const promptPlain = this.color ? '❯ ' : '> ';
|
|
4259
5073
|
const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
|
|
4260
5074
|
const promptWidth = displayWidth(promptPlain);
|
|
@@ -4300,7 +5114,7 @@ export class SshTui {
|
|
|
4300
5114
|
: [];
|
|
4301
5115
|
const inputDivider = this.styleLine('system', repeatToWidth('─', width));
|
|
4302
5116
|
const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
|
|
4303
|
-
const available = Math.max(
|
|
5117
|
+
const available = Math.max(0, height - reserved - dialogLines.length);
|
|
4304
5118
|
const maxOffset = Math.max(0, display.length - available);
|
|
4305
5119
|
const reveal = this.pendingReveal;
|
|
4306
5120
|
if (reveal !== undefined) {
|
|
@@ -4358,11 +5172,14 @@ export class SshTui {
|
|
|
4358
5172
|
const idleMs = Date.now() - this.lastActivity;
|
|
4359
5173
|
const livePlan = this.findLivePlanRow();
|
|
4360
5174
|
const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
|
|
5175
|
+
const current = this.selectionRef?.current;
|
|
5176
|
+
const provider = this.currentProviderId();
|
|
4361
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;
|
|
4362
5181
|
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
4363
5182
|
const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
|
|
4364
|
-
const current = this.selectionRef?.current;
|
|
4365
|
-
const provider = this.currentProviderId();
|
|
4366
5183
|
const parentModel = current?.model ?? this.agent.options.model ?? '';
|
|
4367
5184
|
const sub = this.subagentSelection.current;
|
|
4368
5185
|
const footer = {
|
|
@@ -4390,6 +5207,7 @@ export class SshTui {
|
|
|
4390
5207
|
...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
|
|
4391
5208
|
? {}
|
|
4392
5209
|
: { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
|
|
5210
|
+
...(balanceText === undefined ? {} : { balanceText }),
|
|
4393
5211
|
...(this.searchHits.length > 0 && this.searchIndex >= 0
|
|
4394
5212
|
? { search: { index: this.searchIndex, total: this.searchHits.length } }
|
|
4395
5213
|
: {}),
|
|
@@ -4440,6 +5258,7 @@ export class SshTui {
|
|
|
4440
5258
|
this.suggestionIndex,
|
|
4441
5259
|
this.activeSubagents.size,
|
|
4442
5260
|
this.dialog?.kind ?? '',
|
|
5261
|
+
this.dialog?.kind === 'questions' ? String(this.dialog.cursor) : '',
|
|
4443
5262
|
planDockLines.join('\n'),
|
|
4444
5263
|
String(chromeStart),
|
|
4445
5264
|
].join('\x1f');
|
|
@@ -4491,19 +5310,34 @@ export class SshTui {
|
|
|
4491
5310
|
if (!input.startsWith('/'))
|
|
4492
5311
|
return [];
|
|
4493
5312
|
const prefix = input.slice(1).toLowerCase();
|
|
4494
|
-
const
|
|
4495
|
-
name
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
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];
|
|
4503
5331
|
const filtered = prefix === ''
|
|
4504
5332
|
? all
|
|
4505
5333
|
: all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
|
|
4506
|
-
|
|
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
|
+
});
|
|
4507
5341
|
}
|
|
4508
5342
|
suggestionsVisible() {
|
|
4509
5343
|
return this.commandSuggestions.length > 0 && this.dialog === undefined;
|
|
@@ -4606,11 +5440,13 @@ export class SshTui {
|
|
|
4606
5440
|
const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
|
|
4607
5441
|
if (disabled)
|
|
4608
5442
|
return;
|
|
4609
|
-
|
|
5443
|
+
this.write('\x07');
|
|
4610
5444
|
}
|
|
4611
5445
|
render = () => {
|
|
4612
5446
|
if (!this.dirty || this.exiting)
|
|
4613
5447
|
return;
|
|
5448
|
+
if ((this.displayDetached || this.headlessDisplay) && this.displayHost?.attached !== true)
|
|
5449
|
+
return;
|
|
4614
5450
|
if (this.agent.status === 'running'
|
|
4615
5451
|
&& Date.now() - this.lastActivity > STALL_WARNING_MS
|
|
4616
5452
|
&& !this.stalledWarningShown
|
|
@@ -4688,7 +5524,7 @@ export class SshTui {
|
|
|
4688
5524
|
if (sourceKind === 'user') {
|
|
4689
5525
|
this.pushRow({ kind: 'user', text: `❯ ${text}` });
|
|
4690
5526
|
if (!this.replaying)
|
|
4691
|
-
this.beginWait(
|
|
5527
|
+
this.beginWait();
|
|
4692
5528
|
}
|
|
4693
5529
|
else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
|
|
4694
5530
|
this.pushPromptInjection(text, source.plugin);
|
|
@@ -4784,21 +5620,34 @@ export class SshTui {
|
|
|
4784
5620
|
this.pendingToolTimes.set(String(event.data.callId), event.time);
|
|
4785
5621
|
if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
|
|
4786
5622
|
const present = presentToolCall(event.data.name, event.data.arguments);
|
|
4787
|
-
const
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
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
|
+
}
|
|
4802
5651
|
}
|
|
4803
5652
|
if (event.data.name === 'exit_plan_mode') {
|
|
4804
5653
|
const markdown = planMarkdownFromArgs(event.data.arguments);
|
|
@@ -4821,7 +5670,9 @@ export class SshTui {
|
|
|
4821
5670
|
if (row !== undefined) {
|
|
4822
5671
|
const metaDiffs = diffMetaDiffs(event.data.meta);
|
|
4823
5672
|
if (metaDiffs !== null) {
|
|
4824
|
-
row.diff =
|
|
5673
|
+
row.diff = (row.repeats ?? 1) > 1 && row.diff !== undefined && row.diff.length > 0
|
|
5674
|
+
? [...row.diff, ...metaDiffs]
|
|
5675
|
+
: metaDiffs;
|
|
4825
5676
|
}
|
|
4826
5677
|
const isShell = SHELL_TOOL_NAMES.has(row.name);
|
|
4827
5678
|
if (isShell) {
|
|
@@ -4839,6 +5690,10 @@ export class SshTui {
|
|
|
4839
5690
|
|| event.data.message.content[0]?.isError === true
|
|
4840
5691
|
|| (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
|
|
4841
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
|
+
}
|
|
4842
5697
|
}
|
|
4843
5698
|
else {
|
|
4844
5699
|
const present = presentToolCall(event.data.message.source.callId, '');
|
|
@@ -4890,6 +5745,14 @@ export class SshTui {
|
|
|
4890
5745
|
this.markDirty();
|
|
4891
5746
|
break;
|
|
4892
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;
|
|
4893
5756
|
case 'turn/start':
|
|
4894
5757
|
this.stalledWarningShown = false;
|
|
4895
5758
|
this.llmRetry = undefined;
|
|
@@ -5169,19 +6032,48 @@ export class SshTui {
|
|
|
5169
6032
|
});
|
|
5170
6033
|
this.markDirty();
|
|
5171
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
|
+
}
|
|
5172
6059
|
handleCommandDone(data) {
|
|
5173
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);
|
|
5174
6064
|
const kind = typeof payload.kind === 'string' ? payload.kind : '';
|
|
5175
6065
|
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
|
5176
6066
|
if (kind === 'error') {
|
|
5177
|
-
this.
|
|
6067
|
+
const errText = this.formatCommandText(text);
|
|
6068
|
+
this.pushRow({ kind: 'error', text: errText === '' ? t('command.failed') : errText });
|
|
5178
6069
|
if (this.status.startsWith('压缩'))
|
|
5179
6070
|
this.status = this.agent.status === 'running' ? 'running' : 'idle';
|
|
5180
6071
|
this.markDirty();
|
|
5181
6072
|
return;
|
|
5182
6073
|
}
|
|
5183
|
-
if (text !== '')
|
|
5184
|
-
this.pushRow({ kind: 'system', text });
|
|
6074
|
+
if (text !== '') {
|
|
6075
|
+
this.pushRow({ kind: 'system', text: this.formatCommandText(text) });
|
|
6076
|
+
}
|
|
5185
6077
|
this.markDirty();
|
|
5186
6078
|
}
|
|
5187
6079
|
handleGoalChange(data) {
|
|
@@ -5377,7 +6269,170 @@ export class SshTui {
|
|
|
5377
6269
|
this.markDirty();
|
|
5378
6270
|
};
|
|
5379
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
|
+
}
|
|
5380
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
|
+
}
|
|
5381
6436
|
const agentLabel = request.agent.id === this.agent.id
|
|
5382
6437
|
? '当前会话'
|
|
5383
6438
|
: `子代理 ${request.agent.id}`;
|
|
@@ -5400,6 +6455,8 @@ export class SshTui {
|
|
|
5400
6455
|
});
|
|
5401
6456
|
};
|
|
5402
6457
|
handleUserQuestions = async (request) => {
|
|
6458
|
+
if (!this.hasLiveDisplay())
|
|
6459
|
+
await this.waitForLiveDisplay(request.signal);
|
|
5403
6460
|
const answers = [];
|
|
5404
6461
|
const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
|
|
5405
6462
|
? undefined
|
|
@@ -5544,6 +6601,7 @@ export class SshTui {
|
|
|
5544
6601
|
index,
|
|
5545
6602
|
total,
|
|
5546
6603
|
selected: new Set(preselected !== undefined && preselected >= 0 ? [preselected] : []),
|
|
6604
|
+
cursor: preselected !== undefined && preselected >= 0 ? preselected : 0,
|
|
5547
6605
|
resolve: (selection) => {
|
|
5548
6606
|
this.settleQuestion(dialog, () => resolve(selection));
|
|
5549
6607
|
},
|
|
@@ -5645,13 +6703,9 @@ export class SshTui {
|
|
|
5645
6703
|
return false;
|
|
5646
6704
|
}
|
|
5647
6705
|
}
|
|
5648
|
-
/** How many endpoint-listed models fit on one picker page alongside navigation. */
|
|
5649
|
-
MODEL_PAGE_SIZE = 7;
|
|
5650
|
-
MODEL_PAGE_PREV = '« 上一页';
|
|
5651
|
-
MODEL_PAGE_NEXT = '» 下一页';
|
|
5652
6706
|
/**
|
|
5653
|
-
* One pick across a possibly long model list
|
|
5654
|
-
*
|
|
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 ↑/↓.
|
|
5655
6709
|
*/
|
|
5656
6710
|
async pickModelOption(modelOptions, provider, sourceLabel, currentModel) {
|
|
5657
6711
|
const seen = new Set();
|
|
@@ -5663,40 +6717,19 @@ export class SshTui {
|
|
|
5663
6717
|
});
|
|
5664
6718
|
if (unique.length === 0)
|
|
5665
6719
|
return undefined;
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
const pageCount = Math.max(1, Math.ceil(unique.length / this.MODEL_PAGE_SIZE));
|
|
5672
|
-
const currentPage = Math.floor(offset / this.MODEL_PAGE_SIZE) + 1;
|
|
5673
|
-
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 => ({
|
|
5674
6725
|
label: option.label,
|
|
5675
6726
|
description: option.id === currentModel ? '当前' : undefined,
|
|
5676
|
-
}))
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
const answer = await this.askQuestion({
|
|
5683
|
-
id: 'model-pick',
|
|
5684
|
-
question: `选择模型(提供商 ${provider} · ${sourceLabel}${hasPrev || hasNext ? `,第 ${currentPage}/${pageCount} 页` : ''})`,
|
|
5685
|
-
options,
|
|
5686
|
-
}, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
|
|
5687
|
-
const picked = options.find(option => option.label === answer.selected[0]);
|
|
5688
|
-
if (picked === undefined)
|
|
5689
|
-
return undefined;
|
|
5690
|
-
if (picked.label === this.MODEL_PAGE_NEXT) {
|
|
5691
|
-
offset += this.MODEL_PAGE_SIZE;
|
|
5692
|
-
continue;
|
|
5693
|
-
}
|
|
5694
|
-
if (picked.label === this.MODEL_PAGE_PREV) {
|
|
5695
|
-
offset = Math.max(0, offset - this.MODEL_PAGE_SIZE);
|
|
5696
|
-
continue;
|
|
5697
|
-
}
|
|
5698
|
-
return page.find(option => option.label === picked.label);
|
|
5699
|
-
}
|
|
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);
|
|
5700
6733
|
}
|
|
5701
6734
|
/** Live adapter routes the TUI can switch to, plus the current selection. */
|
|
5702
6735
|
listSelectableProviders() {
|
|
@@ -5868,38 +6901,38 @@ export class SshTui {
|
|
|
5868
6901
|
effortOptions = [];
|
|
5869
6902
|
}
|
|
5870
6903
|
if (effortOptions.length === 0 && providerUsesLocalOAuth(provider)) {
|
|
5871
|
-
effortOptions = modelId
|
|
5872
|
-
? [
|
|
5873
|
-
{ id: 'off', label: 'Off' },
|
|
5874
|
-
{ id: 'low', label: 'Low' },
|
|
5875
|
-
{ id: 'medium', label: 'Medium' },
|
|
5876
|
-
{ id: 'high', label: 'High' },
|
|
5877
|
-
{ id: 'xhigh', label: 'Extra high' },
|
|
5878
|
-
]
|
|
5879
|
-
: [
|
|
5880
|
-
{ id: 'off', label: 'Off' },
|
|
5881
|
-
{ id: 'low', label: 'Low' },
|
|
5882
|
-
{ id: 'medium', label: 'Medium' },
|
|
5883
|
-
{ id: 'high', label: 'High' },
|
|
5884
|
-
];
|
|
5885
|
-
}
|
|
5886
|
-
let effort;
|
|
5887
|
-
if (effortOptions.length > 0) {
|
|
5888
|
-
const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
|
|
5889
|
-
const currentEffort = current?.provider === provider
|
|
5890
|
-
? String(current?.reasoningEffort ?? '')
|
|
5891
|
-
: rememberedEffort;
|
|
5892
|
-
const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
|
|
5893
|
-
const effortAnswer = await this.askQuestion({
|
|
5894
|
-
id: 'effort-pick',
|
|
5895
|
-
question: `选择思考强度(${modelId})`,
|
|
5896
|
-
options: effortOptions.map(option => ({
|
|
5897
|
-
label: option.label,
|
|
5898
|
-
description: option.id === currentEffort ? '当前' : undefined,
|
|
5899
|
-
})),
|
|
5900
|
-
}, 0, 1, currentIndex);
|
|
5901
|
-
effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
|
|
6904
|
+
effortOptions = localOAuthEffortChoices(modelId);
|
|
5902
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;
|
|
5903
6936
|
const next = {
|
|
5904
6937
|
provider,
|
|
5905
6938
|
model: modelId,
|
|
@@ -5911,9 +6944,11 @@ export class SshTui {
|
|
|
5911
6944
|
await this.persistDefaultSelection(next);
|
|
5912
6945
|
await this.rememberRoute(next);
|
|
5913
6946
|
const kind = describeProviderRoute(provider);
|
|
6947
|
+
const effortText = effort ?? t('effort.defaultShort');
|
|
6948
|
+
const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
|
|
5914
6949
|
this.pushRow({
|
|
5915
6950
|
kind: 'system',
|
|
5916
|
-
text:
|
|
6951
|
+
text: t('effort.switchedModel', { kind: kind.kind, provider, model: modelId, effort: effortText, note }),
|
|
5917
6952
|
});
|
|
5918
6953
|
const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
|
|
5919
6954
|
const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
|
|
@@ -6010,10 +7045,16 @@ export class SshTui {
|
|
|
6010
7045
|
});
|
|
6011
7046
|
}
|
|
6012
7047
|
clearQuotaForProvider(provider) {
|
|
6013
|
-
|
|
7048
|
+
const quotaSame = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider;
|
|
7049
|
+
const balanceSame = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider;
|
|
7050
|
+
if (quotaSame && balanceSame)
|
|
6014
7051
|
return;
|
|
6015
|
-
|
|
6016
|
-
|
|
7052
|
+
if (!quotaSame) {
|
|
7053
|
+
this.quotaSnapshot = undefined;
|
|
7054
|
+
this.quotaAlerted.clear();
|
|
7055
|
+
}
|
|
7056
|
+
if (!balanceSame)
|
|
7057
|
+
this.balanceSnapshot = undefined;
|
|
6017
7058
|
this.quotaStepsSinceRefresh = 0;
|
|
6018
7059
|
this.markDirty();
|
|
6019
7060
|
}
|
|
@@ -6101,8 +7142,98 @@ export class SshTui {
|
|
|
6101
7142
|
});
|
|
6102
7143
|
this.markDirty();
|
|
6103
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
|
+
}
|
|
6104
7235
|
/** /subeffort: pick the reasoning effort subagent children use. */
|
|
6105
|
-
async runSubeffortCommand() {
|
|
7236
|
+
async runSubeffortCommand(arg) {
|
|
6106
7237
|
const provider = this.effectiveSubagentProvider();
|
|
6107
7238
|
const current = this.subagentSelection.current;
|
|
6108
7239
|
const llm = this.ctx.get('llm');
|
|
@@ -6114,30 +7245,58 @@ export class SshTui {
|
|
|
6114
7245
|
catch {
|
|
6115
7246
|
effortOptions = [];
|
|
6116
7247
|
}
|
|
6117
|
-
|
|
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);
|
|
6118
7269
|
this.pushRow({
|
|
6119
7270
|
kind: 'system',
|
|
6120
|
-
text:
|
|
7271
|
+
text: `${targetEffort === undefined
|
|
7272
|
+
? t('effort.subDefault')
|
|
7273
|
+
: t('effort.subSwitched', { effort: targetEffort })}${persisted ? '' : t('effort.sessionOnly')}`,
|
|
6121
7274
|
});
|
|
6122
7275
|
this.markDirty();
|
|
6123
7276
|
return;
|
|
6124
7277
|
}
|
|
7278
|
+
const isUndeclared = effortOptions.length === 0;
|
|
7279
|
+
const available = isUndeclared ? undeclaredEffortChoices() : effortOptions;
|
|
6125
7280
|
const choices = [
|
|
6126
|
-
{
|
|
6127
|
-
|
|
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 })),
|
|
6128
7287
|
];
|
|
7288
|
+
const currentEffort = current.reasoningEffort === undefined ? undefined : String(current.reasoningEffort);
|
|
7289
|
+
const currentIndex = Math.max(0, choices.findIndex(c => c.id === currentEffort));
|
|
6129
7290
|
const answer = await this.askQuestion({
|
|
6130
7291
|
id: 'subagent-effort-pick',
|
|
6131
|
-
question:
|
|
7292
|
+
question: isUndeclared
|
|
7293
|
+
? t('effort.pickSubUndeclared', { provider, model: current.model })
|
|
7294
|
+
: t('effort.pickSub', { provider, model: current.model }),
|
|
6132
7295
|
options: choices.map(option => ({
|
|
6133
7296
|
label: option.label,
|
|
6134
|
-
description: option.id ===
|
|
6135
|
-
? '清空自定义强度,跟随提供商/模型默认'
|
|
6136
|
-
: option.id === String(current.reasoningEffort)
|
|
6137
|
-
? '当前'
|
|
6138
|
-
: undefined,
|
|
7297
|
+
description: option.id === currentEffort ? t('disconnect.current') : option.desc,
|
|
6139
7298
|
})),
|
|
6140
|
-
});
|
|
7299
|
+
}, 0, 1, currentIndex);
|
|
6141
7300
|
const picked = choices.find(option => option.label === answer.selected[0]);
|
|
6142
7301
|
if (picked === undefined)
|
|
6143
7302
|
return;
|
|
@@ -6151,8 +7310,8 @@ export class SshTui {
|
|
|
6151
7310
|
this.pushRow({
|
|
6152
7311
|
kind: 'system',
|
|
6153
7312
|
text: `${picked.id === undefined
|
|
6154
|
-
? '
|
|
6155
|
-
:
|
|
7313
|
+
? t('effort.subDefault')
|
|
7314
|
+
: t('effort.subSwitched', { effort: picked.id })}${persisted ? '' : t('effort.sessionOnly')}`,
|
|
6156
7315
|
});
|
|
6157
7316
|
this.markDirty();
|
|
6158
7317
|
}
|
|
@@ -6219,6 +7378,37 @@ export class SshTui {
|
|
|
6219
7378
|
this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
|
|
6220
7379
|
this.markDirty();
|
|
6221
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
|
+
}
|
|
6222
7412
|
/** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
|
|
6223
7413
|
async runModeCommand() {
|
|
6224
7414
|
const agentPresets = this.ctx.get('agentPresets');
|
|
@@ -6245,7 +7435,7 @@ export class SshTui {
|
|
|
6245
7435
|
if (selected === undefined)
|
|
6246
7436
|
return;
|
|
6247
7437
|
const selectedName = selected.name ?? selected.id;
|
|
6248
|
-
const hasWork = this.agent.session.
|
|
7438
|
+
const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
|
|
6249
7439
|
if (!hasWork) {
|
|
6250
7440
|
await agentPresets.recompose(this.agent.ctx, selected.id);
|
|
6251
7441
|
this.presetId = selected.id;
|
|
@@ -6390,9 +7580,8 @@ export class SshTui {
|
|
|
6390
7580
|
const quota = await this.refreshQuota({ reason: 'command', announce: true });
|
|
6391
7581
|
if (quota !== undefined)
|
|
6392
7582
|
return;
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
|
|
7583
|
+
if (this.balanceSnapshot !== undefined) {
|
|
7584
|
+
this.pushRow({ kind: 'system', text: formatAccountBalance(this.balanceSnapshot) });
|
|
6396
7585
|
return;
|
|
6397
7586
|
}
|
|
6398
7587
|
const provider = this.currentProviderId();
|
|
@@ -6441,14 +7630,37 @@ export class SshTui {
|
|
|
6441
7630
|
const provider = this.currentProviderId();
|
|
6442
7631
|
const snapshot = await this.fetchQuotaSnapshot(provider);
|
|
6443
7632
|
if (snapshot !== undefined) {
|
|
7633
|
+
this.balanceSnapshot = undefined;
|
|
6444
7634
|
this.applyQuotaSnapshot(snapshot, options.announce);
|
|
6445
7635
|
return snapshot;
|
|
6446
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
|
+
}
|
|
6447
7655
|
if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
|
|
6448
7656
|
this.quotaSnapshot = undefined;
|
|
6449
7657
|
this.quotaAlerted.clear();
|
|
6450
7658
|
this.markDirty();
|
|
6451
7659
|
}
|
|
7660
|
+
if (this.balanceSnapshot !== undefined && this.balanceSnapshot.provider !== provider) {
|
|
7661
|
+
this.balanceSnapshot = undefined;
|
|
7662
|
+
this.markDirty();
|
|
7663
|
+
}
|
|
6452
7664
|
return undefined;
|
|
6453
7665
|
}
|
|
6454
7666
|
finally {
|
|
@@ -6586,6 +7798,12 @@ export class SshTui {
|
|
|
6586
7798
|
if (this.dialog?.kind === 'inspect') {
|
|
6587
7799
|
this.scrollInspectOrTranscript(-1);
|
|
6588
7800
|
}
|
|
7801
|
+
else if (this.moveQuestionCursor(-1)) {
|
|
7802
|
+
return;
|
|
7803
|
+
}
|
|
7804
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(-1)) {
|
|
7805
|
+
return;
|
|
7806
|
+
}
|
|
6589
7807
|
else if (this.suggestionsVisible()) {
|
|
6590
7808
|
this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
|
|
6591
7809
|
this.markDirty();
|
|
@@ -6601,6 +7819,12 @@ export class SshTui {
|
|
|
6601
7819
|
if (this.dialog?.kind === 'inspect') {
|
|
6602
7820
|
this.scrollInspectOrTranscript(1);
|
|
6603
7821
|
}
|
|
7822
|
+
else if (this.moveQuestionCursor(1)) {
|
|
7823
|
+
return;
|
|
7824
|
+
}
|
|
7825
|
+
else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(1)) {
|
|
7826
|
+
return;
|
|
7827
|
+
}
|
|
6604
7828
|
else if (this.suggestionsVisible()) {
|
|
6605
7829
|
this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
|
|
6606
7830
|
this.markDirty();
|
|
@@ -6641,11 +7865,11 @@ export class SshTui {
|
|
|
6641
7865
|
return;
|
|
6642
7866
|
}
|
|
6643
7867
|
if (combined === '\x1b[5~') {
|
|
6644
|
-
this.scrollInspectOrTranscript(Math.max(3, Math.floor((
|
|
7868
|
+
this.scrollInspectOrTranscript(Math.max(3, Math.floor(this.screenRows() / 2)));
|
|
6645
7869
|
return;
|
|
6646
7870
|
}
|
|
6647
7871
|
if (combined === '\x1b[6~') {
|
|
6648
|
-
this.scrollInspectOrTranscript(-Math.max(3, Math.floor((
|
|
7872
|
+
this.scrollInspectOrTranscript(-Math.max(3, Math.floor(this.screenRows() / 2)));
|
|
6649
7873
|
return;
|
|
6650
7874
|
}
|
|
6651
7875
|
if (parseCursorPositionReply(combined) !== undefined)
|
|
@@ -6776,9 +8000,10 @@ export class SshTui {
|
|
|
6776
8000
|
const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
6777
8001
|
if (normalized === '')
|
|
6778
8002
|
return;
|
|
8003
|
+
this.leaveHistoryBrowse();
|
|
6779
8004
|
this.input = `${this.input.slice(0, this.cursor)}${normalized}${this.input.slice(this.cursor)}`;
|
|
6780
8005
|
this.cursor += normalized.length;
|
|
6781
|
-
const cols = Math.max(10,
|
|
8006
|
+
const cols = Math.max(10, this.screenColumns());
|
|
6782
8007
|
const lineWidth = Math.max(1, cols - 2);
|
|
6783
8008
|
if (normalized.includes('\n') || displayWidth(this.input) > lineWidth)
|
|
6784
8009
|
this.inputFolded = true;
|
|
@@ -6852,12 +8077,14 @@ export class SshTui {
|
|
|
6852
8077
|
this.markDirty();
|
|
6853
8078
|
return;
|
|
6854
8079
|
case '\x15':
|
|
8080
|
+
this.leaveHistoryBrowse();
|
|
6855
8081
|
this.input = '';
|
|
6856
8082
|
this.cursor = 0;
|
|
6857
8083
|
this.inputFolded = false;
|
|
6858
8084
|
this.markDirty();
|
|
6859
8085
|
return;
|
|
6860
8086
|
case '\x0b':
|
|
8087
|
+
this.leaveHistoryBrowse();
|
|
6861
8088
|
this.input = this.input.slice(0, this.cursor);
|
|
6862
8089
|
this.markDirty();
|
|
6863
8090
|
return;
|
|
@@ -6910,11 +8137,27 @@ export class SshTui {
|
|
|
6910
8137
|
return;
|
|
6911
8138
|
}
|
|
6912
8139
|
if (char >= ' ' && char !== '\x7f') {
|
|
8140
|
+
this.leaveHistoryBrowse();
|
|
6913
8141
|
this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
|
|
6914
8142
|
this.cursor += char.length;
|
|
6915
8143
|
this.markDirty();
|
|
6916
8144
|
}
|
|
6917
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
|
+
}
|
|
6918
8161
|
handleDialogChar(text) {
|
|
6919
8162
|
const dialog = this.dialog;
|
|
6920
8163
|
if (dialog === undefined)
|
|
@@ -6941,6 +8184,7 @@ export class SshTui {
|
|
|
6941
8184
|
const key = text.toLowerCase();
|
|
6942
8185
|
const index = QUESTION_OPTION_KEYS.indexOf(key);
|
|
6943
8186
|
if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
|
|
8187
|
+
dialog.cursor = index;
|
|
6944
8188
|
if (dialog.question.multiSelect === true) {
|
|
6945
8189
|
if (dialog.selected.has(index))
|
|
6946
8190
|
dialog.selected.delete(index);
|
|
@@ -6984,31 +8228,86 @@ export class SshTui {
|
|
|
6984
8228
|
this.markDirty();
|
|
6985
8229
|
}
|
|
6986
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
|
+
}
|
|
6987
8257
|
handleOnboardingChar(text) {
|
|
6988
8258
|
const state = this.onboarding;
|
|
6989
8259
|
if (state === undefined)
|
|
6990
8260
|
return;
|
|
6991
8261
|
switch (state.step) {
|
|
6992
|
-
case 'provider':
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
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);
|
|
7008
8284
|
this.advanceOnboarding();
|
|
7009
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();
|
|
7010
8308
|
}
|
|
7011
8309
|
return;
|
|
8310
|
+
}
|
|
7012
8311
|
case 'id':
|
|
7013
8312
|
case 'base-url':
|
|
7014
8313
|
case 'key':
|
|
@@ -7020,7 +8319,7 @@ export class SshTui {
|
|
|
7020
8319
|
if (text === '\r' || text === '\n') {
|
|
7021
8320
|
const value = this.input.trim();
|
|
7022
8321
|
if (state.step === 'id') {
|
|
7023
|
-
const template =
|
|
8322
|
+
const template = onboardTemplate(state);
|
|
7024
8323
|
const id = value === '' ? template.defaultId : value;
|
|
7025
8324
|
if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
|
|
7026
8325
|
this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
|
|
@@ -7030,7 +8329,7 @@ export class SshTui {
|
|
|
7030
8329
|
state.providerId = id;
|
|
7031
8330
|
}
|
|
7032
8331
|
else if (state.step === 'key') {
|
|
7033
|
-
if (value === '') {
|
|
8332
|
+
if (value === '' && state.providerType !== 'catalog') {
|
|
7034
8333
|
this.pushRow({ kind: 'error', text: 'API Key 不能为空,请重新输入。' });
|
|
7035
8334
|
this.markDirty();
|
|
7036
8335
|
return;
|
|
@@ -7038,7 +8337,7 @@ export class SshTui {
|
|
|
7038
8337
|
state.key = value;
|
|
7039
8338
|
}
|
|
7040
8339
|
else if (state.step === 'models') {
|
|
7041
|
-
const template =
|
|
8340
|
+
const template = onboardTemplate(state);
|
|
7042
8341
|
const parsed = value === ''
|
|
7043
8342
|
? template.defaultModels
|
|
7044
8343
|
: value.split(/[\s,,]+/u).filter(Boolean);
|
|
@@ -7117,12 +8416,12 @@ export class SshTui {
|
|
|
7117
8416
|
const state = this.onboarding;
|
|
7118
8417
|
if (state === undefined || state.step !== 'models')
|
|
7119
8418
|
return;
|
|
7120
|
-
const template =
|
|
8419
|
+
const template = onboardTemplate(state);
|
|
7121
8420
|
const providerType = state.providerType;
|
|
7122
8421
|
const baseUrl = state.baseUrl;
|
|
7123
8422
|
const key = state.key;
|
|
7124
8423
|
const baseURL = baseUrl === '' ? template.defaultBaseUrl : baseUrl;
|
|
7125
|
-
if (baseURL === '') {
|
|
8424
|
+
if (baseURL === '' && providerType !== 'catalog') {
|
|
7126
8425
|
this.pushRow({ kind: 'error', text: '请先填写 Base URL 再获取模型列表。' });
|
|
7127
8426
|
this.markDirty();
|
|
7128
8427
|
return;
|
|
@@ -7135,7 +8434,10 @@ export class SshTui {
|
|
|
7135
8434
|
if (llm === undefined)
|
|
7136
8435
|
throw new Error('llm 服务不可用');
|
|
7137
8436
|
const discovered = await discoverProviderModels(llm, {
|
|
7138
|
-
baseURL
|
|
8437
|
+
...(providerType === 'catalog' && state.catalog !== undefined && baseURL === ''
|
|
8438
|
+
? {}
|
|
8439
|
+
: { baseURL }),
|
|
8440
|
+
...(providerType === 'catalog' && state.catalog !== undefined ? { provider: state.catalog.id } : {}),
|
|
7139
8441
|
...(template.api === undefined ? {} : { api: template.api }),
|
|
7140
8442
|
...(key === '' ? {} : { apiKey: key }),
|
|
7141
8443
|
}, AbortSignal.timeout(15_000));
|
|
@@ -7175,7 +8477,7 @@ export class SshTui {
|
|
|
7175
8477
|
try {
|
|
7176
8478
|
const credentials = this.ctx.get('credentials');
|
|
7177
8479
|
const settings = this.ctx.get('settings');
|
|
7178
|
-
const template =
|
|
8480
|
+
const template = onboardTemplate(state);
|
|
7179
8481
|
if (state.providerType === 'official') {
|
|
7180
8482
|
const envRef = 'DEEPSEEK_API_KEY';
|
|
7181
8483
|
await this.saveCredential(credentials, envRef, state.key);
|
|
@@ -7234,15 +8536,24 @@ export class SshTui {
|
|
|
7234
8536
|
mergedIds.push(id);
|
|
7235
8537
|
}
|
|
7236
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 === '';
|
|
7237
8544
|
const profile = {
|
|
7238
8545
|
displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
|
|
7239
8546
|
? existing.displayName
|
|
7240
8547
|
: template.label,
|
|
7241
|
-
apiKeyEnv: envRef,
|
|
8548
|
+
...(keyless ? {} : { apiKeyEnv: envRef }),
|
|
7242
8549
|
api: template.api ?? existing?.api,
|
|
7243
|
-
|
|
7244
|
-
?
|
|
7245
|
-
:
|
|
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
|
+
}),
|
|
7246
8557
|
models: mergedIds.map(id => ({
|
|
7247
8558
|
id,
|
|
7248
8559
|
...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
|
|
@@ -7261,7 +8572,7 @@ export class SshTui {
|
|
|
7261
8572
|
}
|
|
7262
8573
|
// Only store the key when its provider profile actually made it to
|
|
7263
8574
|
// settings; otherwise the saved key points at an unusable route.
|
|
7264
|
-
if (saved)
|
|
8575
|
+
if (saved && !keyless)
|
|
7265
8576
|
await this.saveCredential(credentials, envRef, state.key);
|
|
7266
8577
|
if (saved) {
|
|
7267
8578
|
const selection = {
|
|
@@ -7515,6 +8826,8 @@ export class SshTui {
|
|
|
7515
8826
|
if (text === '')
|
|
7516
8827
|
return;
|
|
7517
8828
|
if (text.startsWith('/')) {
|
|
8829
|
+
this.historyIndex = this.history.length;
|
|
8830
|
+
this.historyDraft = '';
|
|
7518
8831
|
this.runCommand(text);
|
|
7519
8832
|
return;
|
|
7520
8833
|
}
|
|
@@ -7522,6 +8835,7 @@ export class SshTui {
|
|
|
7522
8835
|
return;
|
|
7523
8836
|
this.history.push(text);
|
|
7524
8837
|
this.historyIndex = this.history.length;
|
|
8838
|
+
this.historyDraft = '';
|
|
7525
8839
|
this.input = '';
|
|
7526
8840
|
this.cursor = 0;
|
|
7527
8841
|
this.inputFolded = false;
|
|
@@ -7535,7 +8849,7 @@ export class SshTui {
|
|
|
7535
8849
|
this.agent.steer(message);
|
|
7536
8850
|
}
|
|
7537
8851
|
else {
|
|
7538
|
-
this.beginWait(
|
|
8852
|
+
this.beginWait();
|
|
7539
8853
|
this.agent.followup(message);
|
|
7540
8854
|
}
|
|
7541
8855
|
this.markDirty();
|
|
@@ -7546,10 +8860,16 @@ export class SshTui {
|
|
|
7546
8860
|
switch (command) {
|
|
7547
8861
|
case 'help': {
|
|
7548
8862
|
const local = localizedCommands()
|
|
7549
|
-
.filter(item => item.name !== 'help' && item.
|
|
8863
|
+
.filter(item => item.name !== 'help' && item.aliasOf === undefined)
|
|
7550
8864
|
.map(item => `/${item.name.padEnd(12)} ${item.description}`);
|
|
8865
|
+
const seen = new Set(localizedCommands().map(item => item.name));
|
|
7551
8866
|
const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
|
|
7552
|
-
.
|
|
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
|
+
});
|
|
7553
8873
|
this.pushRow({
|
|
7554
8874
|
kind: 'system',
|
|
7555
8875
|
text: [
|
|
@@ -7563,6 +8883,7 @@ export class SshTui {
|
|
|
7563
8883
|
t('help.intro5'),
|
|
7564
8884
|
t('help.intro6'),
|
|
7565
8885
|
t('help.intro7'),
|
|
8886
|
+
t('help.intro8'),
|
|
7566
8887
|
].join('\n'),
|
|
7567
8888
|
});
|
|
7568
8889
|
break;
|
|
@@ -7582,6 +8903,17 @@ export class SshTui {
|
|
|
7582
8903
|
this.markDirty();
|
|
7583
8904
|
});
|
|
7584
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;
|
|
7585
8917
|
case 'provider':
|
|
7586
8918
|
void this.runProviderCommand().catch((error) => {
|
|
7587
8919
|
if (error instanceof UserQuestionError) {
|
|
@@ -7605,7 +8937,7 @@ export class SshTui {
|
|
|
7605
8937
|
});
|
|
7606
8938
|
break;
|
|
7607
8939
|
case 'subeffort':
|
|
7608
|
-
void this.runSubeffortCommand().catch((error) => {
|
|
8940
|
+
void this.runSubeffortCommand(arg).catch((error) => {
|
|
7609
8941
|
if (error instanceof UserQuestionError) {
|
|
7610
8942
|
this.pushRow({ kind: 'system', text: t('help.subeffortCancel') });
|
|
7611
8943
|
}
|
|
@@ -7649,6 +8981,17 @@ export class SshTui {
|
|
|
7649
8981
|
this.markDirty();
|
|
7650
8982
|
});
|
|
7651
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;
|
|
7652
8995
|
case 'find':
|
|
7653
8996
|
this.runFindCommand(arg);
|
|
7654
8997
|
break;
|
|
@@ -7658,7 +9001,6 @@ export class SshTui {
|
|
|
7658
9001
|
this.streamingReasoning = undefined;
|
|
7659
9002
|
this.thinkingStartedAt = undefined;
|
|
7660
9003
|
this.waitStartedAt = undefined;
|
|
7661
|
-
this.waitPrompt = undefined;
|
|
7662
9004
|
this.focusedRow = null;
|
|
7663
9005
|
this.searchHits = [];
|
|
7664
9006
|
this.searchIndex = -1;
|
|
@@ -7689,6 +9031,7 @@ export class SshTui {
|
|
|
7689
9031
|
activeSubagents: this.activeSubagents.size,
|
|
7690
9032
|
plan: plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off',
|
|
7691
9033
|
paint: formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed),
|
|
9034
|
+
disconnect: this.disconnectPolicy,
|
|
7692
9035
|
waitingQuestions: waiting,
|
|
7693
9036
|
...(quota === undefined ? {} : { quota }),
|
|
7694
9037
|
parentModel: model,
|
|
@@ -7759,6 +9102,40 @@ export class SshTui {
|
|
|
7759
9102
|
this.markDirty();
|
|
7760
9103
|
});
|
|
7761
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
|
+
}
|
|
7762
9139
|
case 'setup':
|
|
7763
9140
|
void this.runOnboarding();
|
|
7764
9141
|
break;
|
|
@@ -7799,14 +9176,16 @@ export class SshTui {
|
|
|
7799
9176
|
this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
|
|
7800
9177
|
return;
|
|
7801
9178
|
}
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
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) });
|
|
7807
9186
|
}
|
|
7808
|
-
else {
|
|
7809
|
-
this.pushRow({ kind: '
|
|
9187
|
+
else if (execution.result.text !== undefined && execution.result.text !== '') {
|
|
9188
|
+
this.pushRow({ kind: 'system', text: this.formatCommandText(execution.result.text) });
|
|
7810
9189
|
}
|
|
7811
9190
|
}).catch((error) => {
|
|
7812
9191
|
this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
|
|
@@ -7854,6 +9233,7 @@ export class SshTui {
|
|
|
7854
9233
|
backspace() {
|
|
7855
9234
|
if (this.cursor === 0)
|
|
7856
9235
|
return;
|
|
9236
|
+
this.leaveHistoryBrowse();
|
|
7857
9237
|
const range = this.graphemeBefore(this.cursor);
|
|
7858
9238
|
this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
|
|
7859
9239
|
this.cursor = range.start;
|
|
@@ -7863,6 +9243,7 @@ export class SshTui {
|
|
|
7863
9243
|
const range = this.graphemeAfter(this.cursor);
|
|
7864
9244
|
if (range === undefined)
|
|
7865
9245
|
return;
|
|
9246
|
+
this.leaveHistoryBrowse();
|
|
7866
9247
|
this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
|
|
7867
9248
|
this.cursor = range.start;
|
|
7868
9249
|
this.markDirty();
|
|
@@ -7898,6 +9279,8 @@ export class SshTui {
|
|
|
7898
9279
|
historyBack() {
|
|
7899
9280
|
if (this.history.length === 0)
|
|
7900
9281
|
return;
|
|
9282
|
+
if (this.historyIndex === this.history.length)
|
|
9283
|
+
this.historyDraft = this.input;
|
|
7901
9284
|
if (this.historyIndex <= 0)
|
|
7902
9285
|
return;
|
|
7903
9286
|
this.historyIndex -= 1;
|
|
@@ -7906,13 +9289,22 @@ export class SshTui {
|
|
|
7906
9289
|
this.markDirty();
|
|
7907
9290
|
}
|
|
7908
9291
|
historyForward() {
|
|
7909
|
-
if (this.historyIndex >= this.history.length)
|
|
9292
|
+
if (this.historyIndex < 0 || this.historyIndex >= this.history.length)
|
|
7910
9293
|
return;
|
|
7911
9294
|
this.historyIndex += 1;
|
|
7912
|
-
this.input = this.
|
|
9295
|
+
this.input = this.historyIndex >= this.history.length
|
|
9296
|
+
? this.historyDraft
|
|
9297
|
+
: (this.history[this.historyIndex] ?? '');
|
|
7913
9298
|
this.cursor = this.input.length;
|
|
7914
9299
|
this.markDirty();
|
|
7915
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
|
+
}
|
|
7916
9308
|
}
|
|
7917
9309
|
function optionsLength(dialog) {
|
|
7918
9310
|
return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
|
|
@@ -7980,6 +9372,12 @@ export function mountTui(ctx, config) {
|
|
|
7980
9372
|
stopWaiting();
|
|
7981
9373
|
await controller?.dispose();
|
|
7982
9374
|
},
|
|
9375
|
+
async handleHangup() {
|
|
9376
|
+
await controller?.handleHangup();
|
|
9377
|
+
},
|
|
9378
|
+
disconnectPolicy() {
|
|
9379
|
+
return controller?.currentDisconnectPolicy() ?? 'pause';
|
|
9380
|
+
},
|
|
7983
9381
|
};
|
|
7984
9382
|
}
|
|
7985
9383
|
//# sourceMappingURL=tui.js.map
|