dsh-ssh-tui 0.3.9 → 0.4.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 +25 -14
- package/README.md +13 -14
- package/lib/i18n/en.js +32 -3
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/index.js +2 -0
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/zh.js +32 -3
- package/lib/i18n/zh.js.map +1 -1
- package/lib/tui.js +454 -54
- package/lib/tui.js.map +1 -1
- package/lib/types/i18n/index.d.ts +4 -0
- package/lib/types/tui.d.ts +75 -5
- package/lib/types/update-check.d.ts +16 -2
- package/lib/update-check.js +46 -5
- package/lib/update-check.js.map +1 -1
- package/package.json +1 -1
package/lib/tui.js
CHANGED
|
@@ -24,7 +24,7 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
|
24
24
|
import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
|
|
25
25
|
import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
|
|
26
26
|
import { defaultReasoningEffort } from './reasoning.js';
|
|
27
|
-
import { checkForPluginUpdate } from './update-check.js';
|
|
27
|
+
import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
|
|
28
28
|
import { ROUTE_MEMORY_NAMESPACE, parseRouteMemory, rememberedRouteFor, upsertRememberedRoute, } from './route-memory.js';
|
|
29
29
|
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model';
|
|
30
30
|
import { DEFAULT_SUBAGENT_MODEL, SUBAGENT_SETTINGS_NAMESPACE, defaultSubagentModelForProvider, describeSubagentFit, subagentModelMatchesProvider, subagentSettingsValue, } from './subagent-model.js';
|
|
@@ -286,6 +286,8 @@ export function formatQuotaBar(remainingPercent, width = 8) {
|
|
|
286
286
|
}
|
|
287
287
|
export function footerIdentityParts(input) {
|
|
288
288
|
const parts = [];
|
|
289
|
+
if (input.compactView === true)
|
|
290
|
+
parts.push(`[${t('view.footerCompact')}]`);
|
|
289
291
|
if (input.preset !== undefined && input.preset !== '')
|
|
290
292
|
parts.push(`[${input.preset}]`);
|
|
291
293
|
if (input.cwdLabel !== undefined && input.cwdLabel !== '')
|
|
@@ -384,6 +386,7 @@ const PLUGIN_VERSION = (() => {
|
|
|
384
386
|
}
|
|
385
387
|
})();
|
|
386
388
|
const STALL_WARNING_MS = 60000;
|
|
389
|
+
const CTRL_C_EXIT_WINDOW_MS = 2000;
|
|
387
390
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
388
391
|
const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
|
|
389
392
|
const SUBAGENT_DEFAULT_EFFORT_LABEL = () => t('footer.effortDefault');
|
|
@@ -581,12 +584,18 @@ const LOCAL_COMMANDS = [
|
|
|
581
584
|
{ name: 'find', description: 'search thinking / plan / subagent / reply cards' },
|
|
582
585
|
{ name: 'language', description: 'switch UI language (zh / en); empty opens a picker' },
|
|
583
586
|
{ name: 'lang', description: 'alias of /language' },
|
|
587
|
+
{ name: 'view', description: 'switch workspace view (detailed / compact); empty opens a picker' },
|
|
584
588
|
{ name: 'dialog-test', description: 'verify the question dialog' },
|
|
585
589
|
];
|
|
586
590
|
function localizedCommands() {
|
|
587
|
-
return LOCAL_COMMANDS.map(command =>
|
|
588
|
-
|
|
589
|
-
|
|
591
|
+
return LOCAL_COMMANDS.map(command => {
|
|
592
|
+
if (command.name === 'language' || command.name === 'lang') {
|
|
593
|
+
return { name: command.name, description: t('lang.cmd') };
|
|
594
|
+
}
|
|
595
|
+
if (command.name === 'view')
|
|
596
|
+
return { name: command.name, description: t('view.cmd') };
|
|
597
|
+
return command;
|
|
598
|
+
});
|
|
590
599
|
}
|
|
591
600
|
/**
|
|
592
601
|
* Terminal cell width for one string.
|
|
@@ -600,6 +609,85 @@ function localizedCommands() {
|
|
|
600
609
|
* Overflow into the input box is handled by clipping/padding painted rows to
|
|
601
610
|
* the measured column count, not by inflating glyph width.
|
|
602
611
|
*/
|
|
612
|
+
/**
|
|
613
|
+
* Codex-style compact elapsed: `0s`, `1m 05s`, `1h 01m 01s`.
|
|
614
|
+
* Used by the workspace wait card while the model has not streamed yet.
|
|
615
|
+
*/
|
|
616
|
+
export function fmtElapsedCompact(elapsedSecs) {
|
|
617
|
+
const secs = Math.max(0, Math.floor(elapsedSecs));
|
|
618
|
+
if (secs < 60)
|
|
619
|
+
return `${secs}s`;
|
|
620
|
+
if (secs < 3600) {
|
|
621
|
+
const minutes = Math.floor(secs / 60);
|
|
622
|
+
const seconds = secs % 60;
|
|
623
|
+
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
|
624
|
+
}
|
|
625
|
+
const hours = Math.floor(secs / 3600);
|
|
626
|
+
const minutes = Math.floor((secs % 3600) / 60);
|
|
627
|
+
const seconds = secs % 60;
|
|
628
|
+
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Sweep highlight across `text` (Codex `shimmer.rs`). Truecolor blends a
|
|
632
|
+
* highlight band; otherwise DIM / default / BOLD. Process-start based so
|
|
633
|
+
* every paint of the same frame stays in phase.
|
|
634
|
+
*/
|
|
635
|
+
export function shimmerText(text, nowMs, color) {
|
|
636
|
+
const chars = Array.from(text);
|
|
637
|
+
if (chars.length === 0)
|
|
638
|
+
return '';
|
|
639
|
+
if (!color)
|
|
640
|
+
return text;
|
|
641
|
+
const padding = 10;
|
|
642
|
+
const period = chars.length + padding * 2;
|
|
643
|
+
const sweepMs = 2000;
|
|
644
|
+
const pos = Math.floor(((nowMs % sweepMs) / sweepMs) * period);
|
|
645
|
+
const bandHalf = 5;
|
|
646
|
+
let out = '';
|
|
647
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
648
|
+
const dist = Math.abs(index + padding - pos);
|
|
649
|
+
const t = dist <= bandHalf
|
|
650
|
+
? 0.5 * (1 + Math.cos(Math.PI * (dist / bandHalf)))
|
|
651
|
+
: 0;
|
|
652
|
+
const style = t < 0.2 ? '2' : t < 0.6 ? '0' : '1';
|
|
653
|
+
out += `\x1b[${style}m${chars[index]}\x1b[0m`;
|
|
654
|
+
}
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
657
|
+
const WAIT_SUMMARY_MAX = 18;
|
|
658
|
+
/**
|
|
659
|
+
* Codex-style short status from reasoning: first `**bold**` / heading, else
|
|
660
|
+
* the first short clause. Keeps the wait card in sync with what the model is
|
|
661
|
+
* doing instead of repeating the user's prompt.
|
|
662
|
+
*/
|
|
663
|
+
export function waitSummaryFromReasoning(text, maxChars = WAIT_SUMMARY_MAX) {
|
|
664
|
+
const raw = text.replace(/\r\n?/gu, '\n').trim();
|
|
665
|
+
if (raw === '')
|
|
666
|
+
return undefined;
|
|
667
|
+
const bold = /\*\*([^*]{2,80})\*\*/u.exec(raw)?.[1]
|
|
668
|
+
?? /^#{1,6}\s+(.+)$/mu.exec(raw)?.[1];
|
|
669
|
+
const source = (bold ?? raw.split('\n').find(line => line.trim() !== '') ?? '').replace(/\s+/gu, ' ').trim();
|
|
670
|
+
if (source === '')
|
|
671
|
+
return undefined;
|
|
672
|
+
const clause = source.split(/[。!?!?\n]/u)[0]?.trim() ?? source;
|
|
673
|
+
const chars = Array.from(clause);
|
|
674
|
+
if (chars.length <= maxChars)
|
|
675
|
+
return clause;
|
|
676
|
+
return `${chars.slice(0, Math.max(2, maxChars - 1)).join('')}…`;
|
|
677
|
+
}
|
|
678
|
+
/** Wait-card header + optional detail. Header tracks model work when known. */
|
|
679
|
+
export function waitCardCopy(input) {
|
|
680
|
+
const toolTitle = input.toolTitle?.trim() ?? '';
|
|
681
|
+
const toolSummary = input.toolSummary?.trim() ?? '';
|
|
682
|
+
const fromReasoning = waitSummaryFromReasoning(input.reasoning ?? '');
|
|
683
|
+
const fromReply = waitSummaryFromReasoning(input.reply ?? '');
|
|
684
|
+
const header = fromReasoning ?? fromReply ?? t('wait.working');
|
|
685
|
+
if (toolTitle !== '') {
|
|
686
|
+
const extra = toolSummary === '' ? '' : ` ${Array.from(toolSummary).slice(0, 40).join('')}`;
|
|
687
|
+
return { header, detail: `${toolTitle}${extra}` };
|
|
688
|
+
}
|
|
689
|
+
return { header };
|
|
690
|
+
}
|
|
603
691
|
export function displayWidth(text) {
|
|
604
692
|
let width = 0;
|
|
605
693
|
for (const char of text) {
|
|
@@ -806,6 +894,7 @@ function paintSegmentedLine(line, start, end, segments) {
|
|
|
806
894
|
if (segments.length === 0)
|
|
807
895
|
return line;
|
|
808
896
|
let out = '';
|
|
897
|
+
let cursor = start;
|
|
809
898
|
for (const seg of segments) {
|
|
810
899
|
if (seg.end <= start)
|
|
811
900
|
continue;
|
|
@@ -815,8 +904,14 @@ function paintSegmentedLine(line, start, end, segments) {
|
|
|
815
904
|
const to = Math.min(seg.end, end);
|
|
816
905
|
if (to <= from)
|
|
817
906
|
continue;
|
|
907
|
+
// Gaps (the tool title) stay default foreground — do not drop them.
|
|
908
|
+
if (from > cursor)
|
|
909
|
+
out += line.slice(cursor - start, from - start);
|
|
818
910
|
out += `\x1b[${seg.sgr}m${line.slice(from - start, to - start)}\x1b[0m`;
|
|
911
|
+
cursor = to;
|
|
819
912
|
}
|
|
913
|
+
if (cursor < end)
|
|
914
|
+
out += line.slice(cursor - start, end - start);
|
|
820
915
|
return out === '' ? line : out;
|
|
821
916
|
}
|
|
822
917
|
/** Wrap `text` and color each output line by overlapping `segments`. */
|
|
@@ -1160,16 +1255,29 @@ function backwardSliceByWidth(text, end, maxWidth) {
|
|
|
1160
1255
|
};
|
|
1161
1256
|
}
|
|
1162
1257
|
/**
|
|
1163
|
-
* Fold a long
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1258
|
+
* Fold a long input into one terminal row around the cursor.
|
|
1259
|
+
*
|
|
1260
|
+
* Newlines from a paste are display-only: they do not occupy cells, so a
|
|
1261
|
+
* naive `displayWidth(input)` under-counts a multi-line paste and parks the
|
|
1262
|
+
* caret in the middle of later text. Fold the *current line* (between the
|
|
1263
|
+
* surrounding newlines) and keep `\n` out of the visible slice.
|
|
1166
1264
|
*/
|
|
1167
1265
|
export function foldInputView(input, cursor, maxWidth) {
|
|
1168
1266
|
const width = Math.max(1, maxWidth);
|
|
1169
|
-
const
|
|
1170
|
-
const
|
|
1267
|
+
const safeCursor = Math.max(0, Math.min(cursor, input.length));
|
|
1268
|
+
const lineStart = input.lastIndexOf('\n', Math.max(0, safeCursor - 1)) + 1;
|
|
1269
|
+
const lineEndRaw = input.indexOf('\n', safeCursor);
|
|
1270
|
+
const lineEnd = lineEndRaw === -1 ? input.length : lineEndRaw;
|
|
1271
|
+
const line = input.slice(lineStart, lineEnd);
|
|
1272
|
+
const lineCursor = safeCursor - lineStart;
|
|
1273
|
+
const totalWidth = displayWidth(line);
|
|
1274
|
+
const cursorOffset = displayWidth(line.slice(0, lineCursor));
|
|
1275
|
+
const hasMoreLines = lineStart > 0 || lineEnd < input.length;
|
|
1276
|
+
if (totalWidth <= width && !hasMoreLines) {
|
|
1277
|
+
return { text: line, cursorOffset, folded: false };
|
|
1278
|
+
}
|
|
1171
1279
|
if (totalWidth <= width) {
|
|
1172
|
-
return { text:
|
|
1280
|
+
return { text: line, cursorOffset, folded: true };
|
|
1173
1281
|
}
|
|
1174
1282
|
const before = cursorOffset;
|
|
1175
1283
|
const after = totalWidth - cursorOffset;
|
|
@@ -1182,9 +1290,9 @@ export function foldInputView(input, cursor, maxWidth) {
|
|
|
1182
1290
|
// If the tail is shorter than its budget, spend the spare columns on the
|
|
1183
1291
|
// side before the cursor so the cursor stays visible near its true offset.
|
|
1184
1292
|
beforeBudget = Math.min(before, beforeBudget + (available - beforeBudget - afterBudget));
|
|
1185
|
-
const beforeSlice = backwardSliceByWidth(
|
|
1186
|
-
const afterSlice = forwardSliceByWidth(
|
|
1187
|
-
const beforeText =
|
|
1293
|
+
const beforeSlice = backwardSliceByWidth(line, lineCursor, beforeBudget);
|
|
1294
|
+
const afterSlice = forwardSliceByWidth(line.slice(lineCursor), afterBudget);
|
|
1295
|
+
const beforeText = line.slice(beforeSlice.start, lineCursor);
|
|
1188
1296
|
return {
|
|
1189
1297
|
text: `${leftFolded ? '…' : ''}${beforeText}${afterSlice.text}${rightFolded ? '…' : ''}`,
|
|
1190
1298
|
cursorOffset: (leftFolded ? 1 : 0) + displayWidth(beforeText),
|
|
@@ -1754,6 +1862,45 @@ function friendlyArgsSummary(name, args) {
|
|
|
1754
1862
|
}
|
|
1755
1863
|
const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
|
|
1756
1864
|
const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
|
|
1865
|
+
export function parseWorkspaceView(raw) {
|
|
1866
|
+
const id = raw.trim().toLowerCase();
|
|
1867
|
+
if (id === 'detailed' || id === 'detail' || id === 'full' || id === '详细')
|
|
1868
|
+
return 'detailed';
|
|
1869
|
+
if (id === 'compact' || id === 'minimal' || id === 'min' || id === '极简')
|
|
1870
|
+
return 'compact';
|
|
1871
|
+
return undefined;
|
|
1872
|
+
}
|
|
1873
|
+
export function countDiffLines(hunks) {
|
|
1874
|
+
if (hunks === undefined || hunks.length === 0)
|
|
1875
|
+
return 0;
|
|
1876
|
+
let total = 0;
|
|
1877
|
+
for (const hunk of hunks) {
|
|
1878
|
+
const added = hunk.newText === '' ? 0 : hunk.newText.split('\n').length;
|
|
1879
|
+
if (hunk.oldText === null) {
|
|
1880
|
+
total += added;
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
const removed = hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length;
|
|
1884
|
+
total += added + removed;
|
|
1885
|
+
}
|
|
1886
|
+
return total;
|
|
1887
|
+
}
|
|
1888
|
+
export function compactToolGroups(tools) {
|
|
1889
|
+
const edits = [];
|
|
1890
|
+
const calls = [];
|
|
1891
|
+
for (const tool of tools) {
|
|
1892
|
+
if (DIFF_TOOL_NAMES.has(tool.name) || (tool.diff !== undefined && tool.diff.length > 0))
|
|
1893
|
+
edits.push(tool);
|
|
1894
|
+
else
|
|
1895
|
+
calls.push(tool);
|
|
1896
|
+
}
|
|
1897
|
+
return {
|
|
1898
|
+
edits,
|
|
1899
|
+
calls,
|
|
1900
|
+
editLines: edits.reduce((sum, tool) => sum + Math.max(1, countDiffLines(tool.diff)), 0),
|
|
1901
|
+
failedCalls: calls.filter(tool => tool.status === 'error').length,
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1757
1904
|
const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
|
|
1758
1905
|
/**
|
|
1759
1906
|
* Tool calls that already have a dedicated transcript card (goal/change,
|
|
@@ -2644,6 +2791,7 @@ export class SshTui {
|
|
|
2644
2791
|
color;
|
|
2645
2792
|
maxToolOutputLines;
|
|
2646
2793
|
showReasoning;
|
|
2794
|
+
workspaceView = 'detailed';
|
|
2647
2795
|
goodbye;
|
|
2648
2796
|
resume;
|
|
2649
2797
|
providerName;
|
|
@@ -2664,6 +2812,7 @@ export class SshTui {
|
|
|
2664
2812
|
focusedRow = null;
|
|
2665
2813
|
pendingMessages = new Map();
|
|
2666
2814
|
lastActivity = Date.now();
|
|
2815
|
+
lastIdleCtrlCAt = 0;
|
|
2667
2816
|
stalledWarningShown = false;
|
|
2668
2817
|
lastPaintAt = 0;
|
|
2669
2818
|
commandAbort;
|
|
@@ -2691,6 +2840,8 @@ export class SshTui {
|
|
|
2691
2840
|
escapeBuffer = '';
|
|
2692
2841
|
escapeTimer;
|
|
2693
2842
|
thinkingStartedAt;
|
|
2843
|
+
waitStartedAt;
|
|
2844
|
+
waitPrompt;
|
|
2694
2845
|
completionSignaled = false;
|
|
2695
2846
|
replaying = false;
|
|
2696
2847
|
completedAt = 0;
|
|
@@ -2727,6 +2878,7 @@ export class SshTui {
|
|
|
2727
2878
|
this.color = config.color !== false && !noColorEnv && process.env.TERM !== 'dumb';
|
|
2728
2879
|
this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
|
|
2729
2880
|
this.showReasoning = config.showReasoning !== false;
|
|
2881
|
+
this.workspaceView = this.readWorkspaceView();
|
|
2730
2882
|
this.goodbye = config.goodbye
|
|
2731
2883
|
?? this.ctx.get('tuiGoodbyeMessage')
|
|
2732
2884
|
?? `To resume this session: dsh --profile tui --resume=${this.agent.id}`;
|
|
@@ -2794,11 +2946,114 @@ export class SshTui {
|
|
|
2794
2946
|
});
|
|
2795
2947
|
}
|
|
2796
2948
|
async notifyPluginUpdate() {
|
|
2797
|
-
const
|
|
2798
|
-
if (this.disposed ||
|
|
2949
|
+
const info = await checkForPluginUpdate(PLUGIN_VERSION);
|
|
2950
|
+
if (this.disposed || info === undefined)
|
|
2799
2951
|
return;
|
|
2800
|
-
this.
|
|
2801
|
-
|
|
2952
|
+
const skipped = this.readSkippedUpdate();
|
|
2953
|
+
if (skipped !== undefined && skipped === info.latest)
|
|
2954
|
+
return;
|
|
2955
|
+
try {
|
|
2956
|
+
const answer = await this.askQuestion({
|
|
2957
|
+
id: 'plugin-update',
|
|
2958
|
+
question: t('update.pick', { latest: info.latest, current: info.current }),
|
|
2959
|
+
options: [
|
|
2960
|
+
{ label: t('update.now'), description: t('update.nowDesc', { command: info.command }) },
|
|
2961
|
+
{ label: t('update.later'), description: t('update.laterDesc') },
|
|
2962
|
+
{ label: t('update.skip'), description: t('update.skipDesc', { latest: info.latest }) },
|
|
2963
|
+
],
|
|
2964
|
+
}, 0, 1, 0);
|
|
2965
|
+
if (this.disposed)
|
|
2966
|
+
return;
|
|
2967
|
+
const picked = answer.selected[0];
|
|
2968
|
+
if (picked === t('update.skip')) {
|
|
2969
|
+
await this.persistSkippedUpdate(info.latest);
|
|
2970
|
+
this.pushRow({ kind: 'system', text: t('update.skipDesc', { latest: info.latest }) });
|
|
2971
|
+
this.markDirty();
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
if (picked !== t('update.now'))
|
|
2975
|
+
return;
|
|
2976
|
+
this.pushRow({ kind: 'system', text: t('update.installing', { latest: info.latest }) });
|
|
2977
|
+
this.markDirty();
|
|
2978
|
+
const result = await installPluginLatest(info.profile);
|
|
2979
|
+
if (this.disposed)
|
|
2980
|
+
return;
|
|
2981
|
+
if (result.ok) {
|
|
2982
|
+
this.pushRow({ kind: 'system', text: t('update.installed', { latest: info.latest, profile: info.profile }) });
|
|
2983
|
+
}
|
|
2984
|
+
else {
|
|
2985
|
+
this.pushRow({ kind: 'error', text: t('update.failed', { error: result.output === '' ? info.command : result.output }) });
|
|
2986
|
+
this.pushRow({ kind: 'system', text: t('update.manual', { command: info.command }) });
|
|
2987
|
+
}
|
|
2988
|
+
this.markDirty();
|
|
2989
|
+
}
|
|
2990
|
+
catch {
|
|
2991
|
+
if (this.disposed)
|
|
2992
|
+
return;
|
|
2993
|
+
this.pushRow({ kind: 'system', text: info.notice });
|
|
2994
|
+
this.markDirty();
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
readSkippedUpdate() {
|
|
2998
|
+
const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
|
|
2999
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
3000
|
+
return undefined;
|
|
3001
|
+
const skip = raw.skipUpdate;
|
|
3002
|
+
return typeof skip === 'string' && skip.trim() !== '' ? skip.trim() : undefined;
|
|
3003
|
+
}
|
|
3004
|
+
async persistSkippedUpdate(latest) {
|
|
3005
|
+
await this.mergeUiSettings({ skipUpdate: latest });
|
|
3006
|
+
}
|
|
3007
|
+
async mergeUiSettings(patch) {
|
|
3008
|
+
const settings = this.ctx.get('settings');
|
|
3009
|
+
if (settings === undefined)
|
|
3010
|
+
return;
|
|
3011
|
+
const raw = settings.get(UI_LOCALE_NAMESPACE);
|
|
3012
|
+
const previous = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
|
|
3013
|
+
? raw
|
|
3014
|
+
: {};
|
|
3015
|
+
await settings.replace(UI_LOCALE_NAMESPACE, { ...previous, ...patch });
|
|
3016
|
+
}
|
|
3017
|
+
readWorkspaceView() {
|
|
3018
|
+
const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
|
|
3019
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
3020
|
+
return 'detailed';
|
|
3021
|
+
return parseWorkspaceView(String(raw.view ?? '')) ?? 'detailed';
|
|
3022
|
+
}
|
|
3023
|
+
isCompactView() {
|
|
3024
|
+
return this.workspaceView === 'compact';
|
|
3025
|
+
}
|
|
3026
|
+
/** Test helper: switch the workspace view without going through /view. */
|
|
3027
|
+
setWorkspaceView(view) {
|
|
3028
|
+
this.workspaceView = view;
|
|
3029
|
+
}
|
|
3030
|
+
paintCompactSummary(addDisplay, anchor, kind, groups, width) {
|
|
3031
|
+
const focused = this.focusedRow === anchor;
|
|
3032
|
+
const marker = anchor.expanded ? '▾' : '▸';
|
|
3033
|
+
const running = kind === 'edits'
|
|
3034
|
+
? groups.edits.some(item => item.status === undefined || item.status === 'running')
|
|
3035
|
+
: groups.calls.some(item => item.status === undefined || item.status === 'running');
|
|
3036
|
+
const spinner = running ? ` ${this.spinnerFrame()}` : '';
|
|
3037
|
+
const title = kind === 'edits'
|
|
3038
|
+
? (groups.edits.length > 1
|
|
3039
|
+
? t('compact.editsFiles', { lines: groups.editLines, files: groups.edits.length })
|
|
3040
|
+
: t('compact.edits', { lines: groups.editLines }))
|
|
3041
|
+
: (groups.failedCalls > 0
|
|
3042
|
+
? t('compact.toolsFailed', { count: groups.calls.length, failed: groups.failedCalls })
|
|
3043
|
+
: t('compact.tools', { count: groups.calls.length }));
|
|
3044
|
+
const header = `${focused ? '▶ ' : ' '}${marker} ● ${title}${spinner}${anchor.expanded ? '' : t('card.expand')}`;
|
|
3045
|
+
const styled = this.styleLine(groups.failedCalls > 0 && kind === 'calls' ? 'error' : 'tool', header);
|
|
3046
|
+
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, anchor);
|
|
3047
|
+
if (!anchor.expanded)
|
|
3048
|
+
return;
|
|
3049
|
+
const items = kind === 'edits' ? groups.edits : groups.calls;
|
|
3050
|
+
for (const item of items) {
|
|
3051
|
+
const state = item.status === 'error' ? 'error' : item.status === 'ok' ? 'ok' : 'running…';
|
|
3052
|
+
const extra = kind === 'edits'
|
|
3053
|
+
? `${countDiffLines(item.diff) || 1} ${getLocale() === 'en' ? 'lines' : '行'}`
|
|
3054
|
+
: item.summary;
|
|
3055
|
+
addDisplay(this.styleLine('system', truncateToWidth(` ${item.title} ${extra} [${state}]`, width)), item);
|
|
3056
|
+
}
|
|
2802
3057
|
}
|
|
2803
3058
|
startRenderTimer() {
|
|
2804
3059
|
if (this.renderTimer !== undefined) {
|
|
@@ -2811,6 +3066,7 @@ export class SshTui {
|
|
|
2811
3066
|
this.updateTerminalTitle();
|
|
2812
3067
|
const animating = (this.streaming !== undefined && this.streaming.reasoning !== '')
|
|
2813
3068
|
|| this.activeSubagents.size > 0
|
|
3069
|
+
|| this.waitCardVisible()
|
|
2814
3070
|
|| this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
|
|
2815
3071
|
|| (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
|
|
2816
3072
|
|| (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
|
|
@@ -3074,15 +3330,20 @@ export class SshTui {
|
|
|
3074
3330
|
}
|
|
3075
3331
|
/** The transcript rows that support per-row expand/collapse. */
|
|
3076
3332
|
collapsibleRows() {
|
|
3077
|
-
const
|
|
3078
|
-
|
|
3079
|
-
|| row.kind === '
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3333
|
+
const compact = this.isCompactView();
|
|
3334
|
+
const rows = this.rows.filter((row) => {
|
|
3335
|
+
if (compact && (row.kind === 'reasoning' || row.kind === 'prompt'))
|
|
3336
|
+
return false;
|
|
3337
|
+
return row.kind === 'reasoning'
|
|
3338
|
+
|| row.kind === 'tool'
|
|
3339
|
+
|| row.kind === 'subagent'
|
|
3340
|
+
|| row.kind === 'plan'
|
|
3341
|
+
|| row.kind === 'question'
|
|
3342
|
+
|| row.kind === 'goal'
|
|
3343
|
+
|| row.kind === 'compaction'
|
|
3344
|
+
|| row.kind === 'prompt';
|
|
3345
|
+
});
|
|
3346
|
+
if (!compact && this.streaming !== undefined && this.streaming.reasoning !== '') {
|
|
3086
3347
|
this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
3087
3348
|
rows.push(this.streamingReasoning);
|
|
3088
3349
|
}
|
|
@@ -3091,6 +3352,46 @@ export class SshTui {
|
|
|
3091
3352
|
spinnerFrame(periodMs = 120) {
|
|
3092
3353
|
return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
|
|
3093
3354
|
}
|
|
3355
|
+
/**
|
|
3356
|
+
* Codex wait card: shown while the turn is running. Thinking/reply streams
|
|
3357
|
+
* feed the shimmer header; a live tool becomes the detail line.
|
|
3358
|
+
*/
|
|
3359
|
+
waitCardVisible() {
|
|
3360
|
+
if (this.agent.status !== 'running')
|
|
3361
|
+
return false;
|
|
3362
|
+
if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
|
|
3363
|
+
return false;
|
|
3364
|
+
if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
|
|
3365
|
+
return false;
|
|
3366
|
+
return true;
|
|
3367
|
+
}
|
|
3368
|
+
beginWait(prompt) {
|
|
3369
|
+
this.waitStartedAt = Date.now();
|
|
3370
|
+
const trimmed = prompt?.replace(/\s+/gu, ' ').trim();
|
|
3371
|
+
this.waitPrompt = trimmed === undefined || trimmed === '' ? this.waitPrompt : trimmed;
|
|
3372
|
+
}
|
|
3373
|
+
endWait() {
|
|
3374
|
+
this.waitStartedAt = undefined;
|
|
3375
|
+
this.waitPrompt = undefined;
|
|
3376
|
+
}
|
|
3377
|
+
waitCardSource() {
|
|
3378
|
+
const liveTool = this.rows.findLast((row) => row.kind === 'tool' && (row.status === undefined || row.status === 'running'));
|
|
3379
|
+
const liveSub = this.rows.findLast((row) => row.kind === 'subagent' && row.status === 'running');
|
|
3380
|
+
return {
|
|
3381
|
+
...(liveTool === undefined ? {} : { toolTitle: liveTool.title, toolSummary: liveTool.summary }),
|
|
3382
|
+
...(liveTool !== undefined || liveSub === undefined
|
|
3383
|
+
? {}
|
|
3384
|
+
: { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity }),
|
|
3385
|
+
...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
|
|
3386
|
+
...(this.streaming?.text ? { reply: this.streaming.text } : {}),
|
|
3387
|
+
...(this.waitPrompt === undefined ? {} : { prompt: this.waitPrompt }),
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
planShouldDefaultExpand(plan) {
|
|
3391
|
+
return plan.active === true
|
|
3392
|
+
|| plan.pending === true
|
|
3393
|
+
|| plan.todos.some(item => item.status === 'in_progress');
|
|
3394
|
+
}
|
|
3094
3395
|
findSubagentRow(sessionId) {
|
|
3095
3396
|
return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
|
|
3096
3397
|
}
|
|
@@ -3126,6 +3427,9 @@ export class SshTui {
|
|
|
3126
3427
|
existing.archived = true;
|
|
3127
3428
|
existing.expanded = false;
|
|
3128
3429
|
}
|
|
3430
|
+
else if (patch.expanded === undefined && this.planShouldDefaultExpand(existing)) {
|
|
3431
|
+
existing.expanded = true;
|
|
3432
|
+
}
|
|
3129
3433
|
this.archiveStalePlans(planIsLive(existing) ? existing : undefined);
|
|
3130
3434
|
return existing;
|
|
3131
3435
|
}
|
|
@@ -3141,7 +3445,13 @@ export class SshTui {
|
|
|
3141
3445
|
pending: patch.pending ?? false,
|
|
3142
3446
|
todos: patch.todos ?? [],
|
|
3143
3447
|
...(patch.planMarkdown === undefined ? {} : { planMarkdown: patch.planMarkdown }),
|
|
3144
|
-
expanded:
|
|
3448
|
+
expanded: this.planShouldDefaultExpand({
|
|
3449
|
+
kind: 'plan',
|
|
3450
|
+
active: patch.active ?? false,
|
|
3451
|
+
pending: patch.pending ?? false,
|
|
3452
|
+
todos: patch.todos ?? [],
|
|
3453
|
+
expanded: false,
|
|
3454
|
+
}),
|
|
3145
3455
|
archived: false,
|
|
3146
3456
|
};
|
|
3147
3457
|
this.pushRow(row);
|
|
@@ -3539,7 +3849,26 @@ export class SshTui {
|
|
|
3539
3849
|
addDisplay(this.styleLine(kind, line), ref);
|
|
3540
3850
|
}
|
|
3541
3851
|
};
|
|
3852
|
+
const compact = this.isCompactView();
|
|
3853
|
+
const compactGroups = compact
|
|
3854
|
+
? compactToolGroups(this.rows.filter((row) => row.kind === 'tool'))
|
|
3855
|
+
: undefined;
|
|
3856
|
+
const compactEditCard = compactGroups?.edits[0];
|
|
3857
|
+
const compactCallCard = compactGroups?.calls[0];
|
|
3542
3858
|
for (const row of this.rows) {
|
|
3859
|
+
if (compact && (row.kind === 'reasoning' || row.kind === 'prompt'))
|
|
3860
|
+
continue;
|
|
3861
|
+
if (compact && compactGroups !== undefined && row.kind === 'tool') {
|
|
3862
|
+
if (row === compactEditCard) {
|
|
3863
|
+
this.paintCompactSummary(addDisplay, row, 'edits', compactGroups, width);
|
|
3864
|
+
continue;
|
|
3865
|
+
}
|
|
3866
|
+
if (row === compactCallCard) {
|
|
3867
|
+
this.paintCompactSummary(addDisplay, row, 'calls', compactGroups, width);
|
|
3868
|
+
continue;
|
|
3869
|
+
}
|
|
3870
|
+
continue;
|
|
3871
|
+
}
|
|
3543
3872
|
if (row.kind === 'brand-logo') {
|
|
3544
3873
|
const variant = DEEPSEEK_LOGO_VARIANTS.find(candidate => candidate.width <= width - 2)
|
|
3545
3874
|
?? DEEPSEEK_LOGO_VARIANTS[DEEPSEEK_LOGO_VARIANTS.length - 1];
|
|
@@ -3573,7 +3902,7 @@ export class SshTui {
|
|
|
3573
3902
|
const header = buildToolHeader({
|
|
3574
3903
|
focused,
|
|
3575
3904
|
expanded: row.expanded,
|
|
3576
|
-
title: row.title,
|
|
3905
|
+
title: toolTitle(row.name) || row.title,
|
|
3577
3906
|
summary: row.summary,
|
|
3578
3907
|
status: row.status,
|
|
3579
3908
|
command: row.command,
|
|
@@ -3749,7 +4078,7 @@ export class SshTui {
|
|
|
3749
4078
|
pushRow(row.kind, row.text, row);
|
|
3750
4079
|
}
|
|
3751
4080
|
if (this.streaming !== undefined) {
|
|
3752
|
-
if (this.showReasoning && this.streaming.reasoning !== '') {
|
|
4081
|
+
if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
|
|
3753
4082
|
const block = this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
|
|
3754
4083
|
const focused = this.focusedRow === block;
|
|
3755
4084
|
const marker = block.expanded ? '▾' : '▸';
|
|
@@ -3780,6 +4109,20 @@ export class SshTui {
|
|
|
3780
4109
|
}
|
|
3781
4110
|
}
|
|
3782
4111
|
}
|
|
4112
|
+
if (this.waitCardVisible()) {
|
|
4113
|
+
const copy = waitCardCopy(this.waitCardSource());
|
|
4114
|
+
const started = this.waitStartedAt ?? Date.now();
|
|
4115
|
+
const elapsed = fmtElapsedCompact((Date.now() - started) / 1000);
|
|
4116
|
+
const hint = t('wait.interrupt', { elapsed });
|
|
4117
|
+
const spinner = this.spinnerFrame();
|
|
4118
|
+
const header = this.color
|
|
4119
|
+
? `${spinner} ${shimmerText(copy.header, Date.now(), true)} ${this.styleLine('system', hint)}`
|
|
4120
|
+
: `${spinner} ${copy.header} ${hint}`;
|
|
4121
|
+
addDisplay(header);
|
|
4122
|
+
if (copy.detail !== undefined && copy.detail !== '') {
|
|
4123
|
+
addDisplay(this.styleLine('system', ` └ ${copy.detail}`));
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
3783
4126
|
const dialogLines = [];
|
|
3784
4127
|
const addDialog = (text) => {
|
|
3785
4128
|
for (const wrapped of wrap(text, Math.max(1, width))) {
|
|
@@ -3908,35 +4251,24 @@ export class SshTui {
|
|
|
3908
4251
|
const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
|
|
3909
4252
|
const promptWidth = displayWidth(promptPlain);
|
|
3910
4253
|
const masked = this.dialog?.kind === 'onboarding' && this.onboarding?.step === 'key';
|
|
4254
|
+
const inputTextWidth = Math.max(1, width - promptWidth);
|
|
3911
4255
|
const inputView = masked
|
|
3912
4256
|
? { text: '•'.repeat(this.input.length), cursorOffset: displayWidth('•'.repeat(this.cursor)), folded: false }
|
|
3913
4257
|
: this.inputFolded
|
|
3914
|
-
? foldInputView(this.input, this.cursor,
|
|
4258
|
+
? foldInputView(this.input, this.cursor, inputTextWidth)
|
|
3915
4259
|
: { text: this.input, cursorOffset: displayWidth(this.input.slice(0, this.cursor)), folded: false };
|
|
3916
|
-
const inputTextWidth = Math.max(1, width - promptWidth);
|
|
3917
4260
|
const inputTextLines = wrap(inputView.text, inputTextWidth);
|
|
3918
4261
|
const inputDisplayLines = inputTextLines.map((line, index) => index === 0 ? `${prompt}${line}` : line);
|
|
3919
|
-
//
|
|
3920
|
-
// the
|
|
3921
|
-
//
|
|
4262
|
+
// Folded/masked views are one logical row around the caret. Place the
|
|
4263
|
+
// cursor with the prompt width of that row — never wrap the offset
|
|
4264
|
+
// across the full terminal grid, which parked the caret on a later
|
|
4265
|
+
// chrome line after a long paste. Un-folded multi-line input still
|
|
4266
|
+
// maps through wrap() so newlines stay on the right visual row.
|
|
3922
4267
|
let cursorRowOffset;
|
|
3923
4268
|
let column;
|
|
3924
4269
|
if (inputView.folded || masked) {
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
if (!inputView.folded
|
|
3928
|
-
&& cursorPlainOffset > 0
|
|
3929
|
-
&& cursorPlainOffset % grid === 0
|
|
3930
|
-
&& Math.floor(cursorPlainOffset / grid) >= inputDisplayLines.length) {
|
|
3931
|
-
inputDisplayLines.push('');
|
|
3932
|
-
}
|
|
3933
|
-
cursorRowOffset = Math.min(Math.floor(cursorPlainOffset / grid), Math.max(0, inputDisplayLines.length - 1));
|
|
3934
|
-
column = cursorPlainOffset % grid + 1;
|
|
3935
|
-
if (cursorPlainOffset > 0
|
|
3936
|
-
&& cursorPlainOffset % grid === 0
|
|
3937
|
-
&& Math.floor(cursorPlainOffset / grid) >= inputDisplayLines.length) {
|
|
3938
|
-
column = grid;
|
|
3939
|
-
}
|
|
4270
|
+
cursorRowOffset = 0;
|
|
4271
|
+
column = Math.min(width, promptWidth + inputView.cursorOffset + 1);
|
|
3940
4272
|
}
|
|
3941
4273
|
else {
|
|
3942
4274
|
const pos = cursorVisualPosition(inputView.text, this.cursor, inputTextWidth);
|
|
@@ -4057,6 +4389,7 @@ export class SshTui {
|
|
|
4057
4389
|
multiLineInput: inputRows > 1,
|
|
4058
4390
|
queued: this.pendingMessages.size,
|
|
4059
4391
|
cwdLabel: formatFooterCwd(this.workspaceCwd()),
|
|
4392
|
+
compactView: this.isCompactView(),
|
|
4060
4393
|
};
|
|
4061
4394
|
const activity = footerActivity(footer);
|
|
4062
4395
|
const activityText = activity.kind === 'compacting'
|
|
@@ -4346,6 +4679,8 @@ export class SshTui {
|
|
|
4346
4679
|
const sourceKind = source.kind ?? '';
|
|
4347
4680
|
if (sourceKind === 'user') {
|
|
4348
4681
|
this.pushRow({ kind: 'user', text: `❯ ${text}` });
|
|
4682
|
+
if (!this.replaying)
|
|
4683
|
+
this.beginWait(text);
|
|
4349
4684
|
}
|
|
4350
4685
|
else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
|
|
4351
4686
|
this.pushPromptInjection(text, source.plugin);
|
|
@@ -4564,6 +4899,7 @@ export class SshTui {
|
|
|
4564
4899
|
this.streaming = undefined;
|
|
4565
4900
|
this.streamingReasoning = undefined;
|
|
4566
4901
|
this.thinkingStartedAt = undefined;
|
|
4902
|
+
this.endWait();
|
|
4567
4903
|
if (reason.kind === 'completed' && !this.replaying && !this.completionSignaled) {
|
|
4568
4904
|
this.completionSignaled = true;
|
|
4569
4905
|
this.completedAt = Date.now();
|
|
@@ -4604,13 +4940,18 @@ export class SshTui {
|
|
|
4604
4940
|
if (status === 'running') {
|
|
4605
4941
|
this.completionSignaled = false;
|
|
4606
4942
|
this.completedAt = 0;
|
|
4943
|
+
if (this.waitStartedAt === undefined)
|
|
4944
|
+
this.beginWait();
|
|
4607
4945
|
}
|
|
4608
4946
|
else if (!this.completionSignaled && this.status === 'running') {
|
|
4609
4947
|
this.completionSignaled = true;
|
|
4610
4948
|
this.completedAt = Date.now();
|
|
4611
4949
|
this.updateTerminalTitle();
|
|
4612
4950
|
this.playCompletionSignal();
|
|
4951
|
+
this.endWait();
|
|
4613
4952
|
}
|
|
4953
|
+
if (status !== 'running')
|
|
4954
|
+
this.endWait();
|
|
4614
4955
|
this.status = status === 'running' ? 'running' : 'idle';
|
|
4615
4956
|
this.markDirty();
|
|
4616
4957
|
};
|
|
@@ -5835,13 +6176,41 @@ export class SshTui {
|
|
|
5835
6176
|
this.pushRow({ kind: 'error', text: t('lang.settingsMissing') });
|
|
5836
6177
|
}
|
|
5837
6178
|
else {
|
|
5838
|
-
await
|
|
6179
|
+
await this.mergeUiSettings({ language: next });
|
|
5839
6180
|
applySavedLocale({ language: next });
|
|
5840
6181
|
}
|
|
5841
6182
|
this.forceFullPaint = true;
|
|
5842
6183
|
this.pushRow({ kind: 'system', text: t('lang.switched', { name: localeDisplayName(next) }) });
|
|
5843
6184
|
this.markDirty();
|
|
5844
6185
|
}
|
|
6186
|
+
/** /view: detailed (see the work) vs compact (Codex-like summary). */
|
|
6187
|
+
async runViewCommand(arg) {
|
|
6188
|
+
const direct = parseWorkspaceView(arg);
|
|
6189
|
+
let next = direct;
|
|
6190
|
+
if (next === undefined && arg.trim() !== '') {
|
|
6191
|
+
this.pushRow({ kind: 'error', text: t('view.unknown', { id: arg.trim() }) });
|
|
6192
|
+
this.markDirty();
|
|
6193
|
+
return;
|
|
6194
|
+
}
|
|
6195
|
+
if (next === undefined) {
|
|
6196
|
+
const current = this.workspaceView;
|
|
6197
|
+
const answer = await this.askQuestion({
|
|
6198
|
+
id: 'view-pick',
|
|
6199
|
+
question: t('view.pick'),
|
|
6200
|
+
options: [
|
|
6201
|
+
{ label: t('view.detailed'), description: current === 'detailed' ? t('view.current') : t('view.detailedDesc') },
|
|
6202
|
+
{ label: t('view.compact'), description: current === 'compact' ? t('view.current') : t('view.compactDesc') },
|
|
6203
|
+
],
|
|
6204
|
+
}, 0, 1, current === 'compact' ? 1 : 0);
|
|
6205
|
+
const picked = answer.selected[0];
|
|
6206
|
+
next = picked === t('view.compact') ? 'compact' : 'detailed';
|
|
6207
|
+
}
|
|
6208
|
+
this.workspaceView = next;
|
|
6209
|
+
await this.mergeUiSettings({ view: next });
|
|
6210
|
+
this.forceFullPaint = true;
|
|
6211
|
+
this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
|
|
6212
|
+
this.markDirty();
|
|
6213
|
+
}
|
|
5845
6214
|
/** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
|
|
5846
6215
|
async runModeCommand() {
|
|
5847
6216
|
const agentPresets = this.ctx.get('agentPresets');
|
|
@@ -6401,6 +6770,10 @@ export class SshTui {
|
|
|
6401
6770
|
return;
|
|
6402
6771
|
this.input = `${this.input.slice(0, this.cursor)}${normalized}${this.input.slice(this.cursor)}`;
|
|
6403
6772
|
this.cursor += normalized.length;
|
|
6773
|
+
const cols = Math.max(10, process.stdout.columns || 80);
|
|
6774
|
+
const lineWidth = Math.max(1, cols - 2);
|
|
6775
|
+
if (normalized.includes('\n') || displayWidth(this.input) > lineWidth)
|
|
6776
|
+
this.inputFolded = true;
|
|
6404
6777
|
this.markDirty();
|
|
6405
6778
|
}
|
|
6406
6779
|
handlePlainText(text) {
|
|
@@ -6487,7 +6860,10 @@ export class SshTui {
|
|
|
6487
6860
|
this.moveCollapsibleFocus(-1);
|
|
6488
6861
|
return;
|
|
6489
6862
|
case '\x12':
|
|
6490
|
-
this.
|
|
6863
|
+
if (this.focusedRow === null)
|
|
6864
|
+
this.toggleCollapsible();
|
|
6865
|
+
else
|
|
6866
|
+
this.toggleAllCollapsible();
|
|
6491
6867
|
return;
|
|
6492
6868
|
case '\x14':
|
|
6493
6869
|
this.inputFolded = !this.inputFolded;
|
|
@@ -7089,16 +7465,26 @@ export class SshTui {
|
|
|
7089
7465
|
handleCtrlC() {
|
|
7090
7466
|
if (this.dialog !== undefined) {
|
|
7091
7467
|
this.handleEscape();
|
|
7468
|
+
this.lastIdleCtrlCAt = 0;
|
|
7092
7469
|
return;
|
|
7093
7470
|
}
|
|
7094
7471
|
if (this.agent.status === 'running') {
|
|
7095
|
-
this.
|
|
7472
|
+
this.lastIdleCtrlCAt = 0;
|
|
7473
|
+
this.pushRow({ kind: 'system', text: t('cancel.ctrlC') });
|
|
7096
7474
|
this.agent.cancel({ kind: 'user' });
|
|
7097
7475
|
this.status = 'cancelling…';
|
|
7098
7476
|
this.markDirty();
|
|
7099
7477
|
return;
|
|
7100
7478
|
}
|
|
7101
|
-
|
|
7479
|
+
const now = Date.now();
|
|
7480
|
+
if (now - this.lastIdleCtrlCAt <= CTRL_C_EXIT_WINDOW_MS) {
|
|
7481
|
+
this.lastIdleCtrlCAt = 0;
|
|
7482
|
+
void this.requestExit(130);
|
|
7483
|
+
return;
|
|
7484
|
+
}
|
|
7485
|
+
this.lastIdleCtrlCAt = now;
|
|
7486
|
+
this.pushRow({ kind: 'system', text: t('exit.ctrlCAgain') });
|
|
7487
|
+
this.markDirty();
|
|
7102
7488
|
}
|
|
7103
7489
|
submit() {
|
|
7104
7490
|
if (this.dialog !== undefined) {
|
|
@@ -7137,10 +7523,11 @@ export class SshTui {
|
|
|
7137
7523
|
});
|
|
7138
7524
|
if (this.agent.status === 'running') {
|
|
7139
7525
|
this.pendingMessages.set(message.id, text);
|
|
7140
|
-
this.pushRow({ kind: 'system', text:
|
|
7526
|
+
this.pushRow({ kind: 'system', text: t('steer.queued', { text }) });
|
|
7141
7527
|
this.agent.steer(message);
|
|
7142
7528
|
}
|
|
7143
7529
|
else {
|
|
7530
|
+
this.beginWait(text);
|
|
7144
7531
|
this.agent.followup(message);
|
|
7145
7532
|
}
|
|
7146
7533
|
this.markDirty();
|
|
@@ -7243,6 +7630,17 @@ export class SshTui {
|
|
|
7243
7630
|
this.markDirty();
|
|
7244
7631
|
});
|
|
7245
7632
|
break;
|
|
7633
|
+
case 'view':
|
|
7634
|
+
void this.runViewCommand(arg).catch((error) => {
|
|
7635
|
+
if (error instanceof UserQuestionError) {
|
|
7636
|
+
this.pushRow({ kind: 'system', text: t('help.modeCancel') });
|
|
7637
|
+
}
|
|
7638
|
+
else {
|
|
7639
|
+
this.pushRow({ kind: 'error', text: `/view failed: ${errorChain(error)}` });
|
|
7640
|
+
}
|
|
7641
|
+
this.markDirty();
|
|
7642
|
+
});
|
|
7643
|
+
break;
|
|
7246
7644
|
case 'find':
|
|
7247
7645
|
this.runFindCommand(arg);
|
|
7248
7646
|
break;
|
|
@@ -7251,6 +7649,8 @@ export class SshTui {
|
|
|
7251
7649
|
this.streaming = undefined;
|
|
7252
7650
|
this.streamingReasoning = undefined;
|
|
7253
7651
|
this.thinkingStartedAt = undefined;
|
|
7652
|
+
this.waitStartedAt = undefined;
|
|
7653
|
+
this.waitPrompt = undefined;
|
|
7254
7654
|
this.focusedRow = null;
|
|
7255
7655
|
this.searchHits = [];
|
|
7256
7656
|
this.searchIndex = -1;
|