@deepseek-harness-tui/dsh-tui 0.7.0 → 0.7.2
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.md +9 -6
- package/bin/dsh-tui.js +67 -14
- package/cordis.patch.yml +10 -0
- package/lib/types/commands.js +1 -1
- package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
- package/lib/types/components/LoadedContextPanel.js +1 -1
- package/lib/types/components/design-system/ThemedText.d.ts +2 -2
- package/lib/types/components/design-system/ThemedText.d.ts.map +1 -1
- package/lib/types/components/design-system/ThemedText.js +1 -3
- package/lib/types/components/sessions/SessionListRow.d.ts +26 -0
- package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -0
- package/lib/types/components/sessions/SessionListRow.js +35 -0
- package/lib/types/components/sessions/SessionPreview.d.ts +25 -0
- package/lib/types/components/sessions/SessionPreview.d.ts.map +1 -0
- package/lib/types/components/sessions/SessionPreview.js +60 -0
- package/lib/types/dsh-adapter/channel.d.ts +23 -4
- package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel.js +190 -75
- package/lib/types/dsh-adapter/compat/index.d.ts +1 -1
- package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/compat/index.js +1 -1
- package/lib/types/dsh-adapter/compat/sessionLog.d.ts +8 -0
- package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -1
- package/lib/types/dsh-adapter/compat/sessionLog.js +1 -1
- package/lib/types/dsh-adapter/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/index.js +10 -1
- package/lib/types/dsh-adapter/packaged-presets.d.ts +22 -0
- package/lib/types/dsh-adapter/packaged-presets.d.ts.map +1 -0
- package/lib/types/dsh-adapter/packaged-presets.js +90 -0
- package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
- package/lib/types/dsh-adapter/plugin.js +60 -4
- package/lib/types/dsh-adapter/presets.d.ts +16 -0
- package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
- package/lib/types/dsh-adapter/presets.js +23 -0
- package/lib/types/dsh-adapter/sessions/digest.d.ts +29 -0
- package/lib/types/dsh-adapter/sessions/digest.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/digest.js +224 -0
- package/lib/types/dsh-adapter/sessions/frames.d.ts +100 -0
- package/lib/types/dsh-adapter/sessions/frames.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/frames.js +246 -0
- package/lib/types/dsh-adapter/sessions/header.d.ts +50 -0
- package/lib/types/dsh-adapter/sessions/header.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/header.js +62 -0
- package/lib/types/dsh-adapter/sessions/index.d.ts +17 -0
- package/lib/types/dsh-adapter/sessions/index.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/index.js +15 -0
- package/lib/types/dsh-adapter/sessions/list.d.ts +41 -0
- package/lib/types/dsh-adapter/sessions/list.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/list.js +213 -0
- package/lib/types/dsh-adapter/sessions/store.d.ts +48 -0
- package/lib/types/dsh-adapter/sessions/store.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/store.js +164 -0
- package/lib/types/dsh-adapter/sessions/types.d.ts +142 -0
- package/lib/types/dsh-adapter/sessions/types.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/types.js +14 -0
- package/lib/types/i18n.d.ts +122 -14
- package/lib/types/i18n.d.ts.map +1 -1
- package/lib/types/i18n.js +37 -6
- package/lib/types/screens/Chat.d.ts +3 -2
- package/lib/types/screens/Chat.d.ts.map +1 -1
- package/lib/types/screens/Chat.js +28 -144
- package/lib/types/screens/SessionBrowser.d.ts +37 -0
- package/lib/types/screens/SessionBrowser.d.ts.map +1 -0
- package/lib/types/screens/SessionBrowser.js +437 -0
- package/lib/types/sessionHistory.d.ts +0 -8
- package/lib/types/sessionHistory.d.ts.map +1 -1
- package/lib/types/sessions/format.d.ts +117 -0
- package/lib/types/sessions/format.d.ts.map +1 -0
- package/lib/types/sessions/format.js +244 -0
- package/lib/types/sessions/view.d.ts +125 -0
- package/lib/types/sessions/view.d.ts.map +1 -0
- package/lib/types/sessions/view.js +222 -0
- package/package.json +12 -5
- package/presets/liangshen/.dsh-tui-managed.json +5 -0
- package/presets/liangshen/agent.cordis.yml +389 -0
- package/presets/liangshen/compaction-epoch.mjs +81 -0
- package/presets/liangshen/custom-bash.mjs +126 -0
- package/presets/liangshen/instruction-hint.mjs +181 -0
- package/presets/liangshen/preset.yml +3 -0
- package/presets/liangshen/skill-search.mjs +142 -0
- package/presets/liangshen/tool-bootstrap.mjs +300 -0
- package/lib/invariant.js +0 -28
- package/lib/types/components/ResumePicker.d.ts +0 -26
- package/lib/types/components/ResumePicker.d.ts.map +0 -1
- package/lib/types/components/ResumePicker.js +0 -50
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text, useInput, useTerminalSize } from '../ui.js';
|
|
4
|
+
import { Divider } from '../components/design-system/Divider.js';
|
|
5
|
+
import { HintLine } from '../components/design-system/HintLine.js';
|
|
6
|
+
import { SearchBox } from '../components/SearchBox.js';
|
|
7
|
+
import { SessionListRow } from '../components/sessions/SessionListRow.js';
|
|
8
|
+
import { SessionPreview } from '../components/sessions/SessionPreview.js';
|
|
9
|
+
import { useTerminalFocus } from '../ink/hooks/use-terminal-focus.js';
|
|
10
|
+
import { isMod, isPlainReturn, modLabel } from '../utils/modifiers.js';
|
|
11
|
+
import { formatProject, spreadRow, tailWidth, truncateWidth } from '../sessions/format.js';
|
|
12
|
+
import { stringWidth } from '../ink/stringWidth.js';
|
|
13
|
+
import { anchorTop, buildView, DEFAULT_FILTERS, moveSelection, seekSelectable, sessionAt, windowEnd, } from '../sessions/view.js';
|
|
14
|
+
import { t } from '../i18n.js';
|
|
15
|
+
/**
|
|
16
|
+
* Rows the layout cannot do without: the header, the search box, the hints.
|
|
17
|
+
* Everything else — the rules, the list itself — yields before these do.
|
|
18
|
+
*/
|
|
19
|
+
const MANDATORY_LINES = 3;
|
|
20
|
+
/**
|
|
21
|
+
* The three horizontal rules, in the order they earn their row.
|
|
22
|
+
*
|
|
23
|
+
* 0 frames the header, 2 lifts the hints off the content, 1 separates the
|
|
24
|
+
* search box — and that last one is the least load-bearing, so on a terminal
|
|
25
|
+
* too short for all three it is the one that goes. Rules are decoration; a
|
|
26
|
+
* row of content, or the hint line telling the user which keys exist, is not.
|
|
27
|
+
*/
|
|
28
|
+
const RULE_PRIORITY = [0, 2, 1];
|
|
29
|
+
/** Terminal width below which the preview replaces the list instead of joining it. */
|
|
30
|
+
const SPLIT_MIN_COLUMNS = 100;
|
|
31
|
+
/** A thrown value's message, for a notification that has to say something. */
|
|
32
|
+
function message(error) {
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|
|
35
|
+
/** Rendered width of a hint: `HintLine` strips the `**` emphasis markers. */
|
|
36
|
+
function hintWidth(text) {
|
|
37
|
+
return stringWidth(text.replace(/\*\*/gu, ''));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The widest hint that fits, cut only when even the shortest will not.
|
|
41
|
+
*
|
|
42
|
+
* A wrapped hint is worse than an abbreviated one in two ways: it eats rows
|
|
43
|
+
* the list needs, and — being the last region on screen — the part that falls
|
|
44
|
+
* off the bottom is its own tail, so the keys nobody can guess are exactly
|
|
45
|
+
* the ones that disappear.
|
|
46
|
+
*
|
|
47
|
+
* @param candidates - Variants from widest to narrowest.
|
|
48
|
+
* @param budget - Columns available to the row.
|
|
49
|
+
*/
|
|
50
|
+
function fitHint(candidates, budget) {
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
if (hintWidth(candidate) <= budget)
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
return truncateWidth((candidates[candidates.length - 1] ?? '').replace(/\*\*/gu, ''), budget);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The session browser — `/resume` as a screen of its own.
|
|
59
|
+
*
|
|
60
|
+
* The old picker was a panel of eight titles and a timestamp, and the reason
|
|
61
|
+
* it could not be more than that was never layout: the data behind it was five
|
|
62
|
+
* fields wide. With every session now arriving classified, sized, dated and
|
|
63
|
+
* attributed, the surface that shows them can do the job a person actually
|
|
64
|
+
* came for — find one conversation among many.
|
|
65
|
+
*
|
|
66
|
+
* What that means concretely:
|
|
67
|
+
*
|
|
68
|
+
* - Search is always live. There is no mode to enter; typing filters, because
|
|
69
|
+
* the list is the search results.
|
|
70
|
+
* - Delegated sub-agent runs are folded away by default and revealed under
|
|
71
|
+
* their parents on demand. They are not noise to be deleted — they are the
|
|
72
|
+
* model's own work, and it is worth being able to open one — but they are
|
|
73
|
+
* not what "resume a conversation" means, and there are five of them for
|
|
74
|
+
* every conversation.
|
|
75
|
+
* - Sessions that hold no conversation are never listed, only counted, with
|
|
76
|
+
* one action to clear them.
|
|
77
|
+
* - The preview shows the end of a session, so "is this the one I was in the
|
|
78
|
+
* middle of" is answerable without resuming it.
|
|
79
|
+
*
|
|
80
|
+
* Every one of those reads bounded data, so the screen behaves the same on a
|
|
81
|
+
* fifty-session history as on a five-session one.
|
|
82
|
+
*/
|
|
83
|
+
export function SessionBrowser({ channel, home, sameProject, onClose, }) {
|
|
84
|
+
const { columns, rows } = useTerminalSize();
|
|
85
|
+
const isTerminalFocused = useTerminalFocus();
|
|
86
|
+
const [sessions, setSessions] = React.useState([]);
|
|
87
|
+
const [loaded, setLoaded] = React.useState(false);
|
|
88
|
+
const [filters, setFilters] = React.useState(DEFAULT_FILTERS);
|
|
89
|
+
// The cursor is a session ID, not a row index.
|
|
90
|
+
//
|
|
91
|
+
// Rows are reordered by almost everything the browser does: a rename touches
|
|
92
|
+
// MRU and moves its row to the top, a filter rebuilds the list, a delete
|
|
93
|
+
// removes one. An index survives none of those — it keeps pointing at a
|
|
94
|
+
// POSITION, so the cursor silently lands on a different session and the next
|
|
95
|
+
// Enter or ctrl+d acts on the wrong one. Tracking identity makes "the cursor
|
|
96
|
+
// stays on the session you were looking at" true by construction instead of
|
|
97
|
+
// something every mutation has to remember to restore.
|
|
98
|
+
const [focusId, setFocusId] = React.useState(undefined);
|
|
99
|
+
// The window start is a scroll anchor, not state: it is derived from the
|
|
100
|
+
// focus every render and only ever read back to keep a stationary cursor
|
|
101
|
+
// from re-shuffling the screen. Holding it in state would mean setting
|
|
102
|
+
// state during render to resolve a layout that render already knows.
|
|
103
|
+
const topRef = React.useRef(0);
|
|
104
|
+
const [mode, setMode] = React.useState('list');
|
|
105
|
+
const [renameText, setRenameText] = React.useState('');
|
|
106
|
+
// The browser owns the whole screen, so it has to carry its own messages.
|
|
107
|
+
// `channel.notify` renders into the conversation — which is exactly what is
|
|
108
|
+
// NOT on screen right now — so a failed delete, a refused resume, or an
|
|
109
|
+
// unreadable listing would otherwise report itself to a hidden surface and
|
|
110
|
+
// look to the user like nothing happened at all.
|
|
111
|
+
const [notice, setNotice] = React.useState();
|
|
112
|
+
const [previewOpen, setPreviewOpen] = React.useState(false);
|
|
113
|
+
const [preview, setPreview] = React.useState([]);
|
|
114
|
+
const [previewLoading, setPreviewLoading] = React.useState(false);
|
|
115
|
+
// One clock per render pass: every relative time on screen must agree, and
|
|
116
|
+
// re-reading it per row would let two rows a millisecond apart round to
|
|
117
|
+
// different minutes.
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
/**
|
|
120
|
+
* Say something, on screen and in the transcript.
|
|
121
|
+
*
|
|
122
|
+
* Both, deliberately: the line below the list is what the user can actually
|
|
123
|
+
* read right now, and the transcript entry is what they still have after
|
|
124
|
+
* they leave.
|
|
125
|
+
*/
|
|
126
|
+
const report = React.useCallback((text, tone) => {
|
|
127
|
+
setNotice({ text, tone });
|
|
128
|
+
channel.notify(text, tone === 'error' ? { color: 'error' } : {});
|
|
129
|
+
}, [channel]);
|
|
130
|
+
// Every await below is wrapped. The adapter's own paths are total functions
|
|
131
|
+
// that degrade rather than throw, but this is the UI: a rejection escaping
|
|
132
|
+
// an event handler here becomes an unhandled rejection with the terminal in
|
|
133
|
+
// raw mode and the alternate screen active — the worst possible place to
|
|
134
|
+
// find out a promise was not caught. A failure leaves the browser standing
|
|
135
|
+
// with whatever it already had.
|
|
136
|
+
const reload = React.useCallback(async () => {
|
|
137
|
+
try {
|
|
138
|
+
setSessions(await channel.listSessions());
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
report(t('session-list-failed', { err: message(error) }), 'error');
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
setLoaded(true);
|
|
145
|
+
}
|
|
146
|
+
}, [channel, report]);
|
|
147
|
+
React.useEffect(() => {
|
|
148
|
+
void reload();
|
|
149
|
+
}, [reload]);
|
|
150
|
+
const view = React.useMemo(() => buildView(sessions, filters, {
|
|
151
|
+
cwd: channel.cwd,
|
|
152
|
+
branch: channel.gitBranch,
|
|
153
|
+
currentId: channel.agentId,
|
|
154
|
+
sameProject,
|
|
155
|
+
}), [sessions, filters, channel.cwd, channel.gitBranch, channel.agentId, sameProject]);
|
|
156
|
+
// Resolve identity to a position once per render. A cursor whose session is
|
|
157
|
+
// gone — deleted, filtered out, or never there — falls to the first
|
|
158
|
+
// selectable row rather than to nothing, so the list is never unusable.
|
|
159
|
+
const focus = React.useMemo(() => {
|
|
160
|
+
const byId = view.rows.findIndex(row => row.kind === 'session' && row.session.id === focusId);
|
|
161
|
+
return byId >= 0 ? byId : Math.max(0, seekSelectable(view.rows, 0, 1));
|
|
162
|
+
}, [view.rows, focusId]);
|
|
163
|
+
const focused = sessionAt(view.rows, focus);
|
|
164
|
+
// Where the cursor is RIGHT NOW, including moves made earlier in this same
|
|
165
|
+
// tick. A held arrow key (or a paste) delivers several key events from one
|
|
166
|
+
// stdin chunk, and every one of them runs before React re-renders — so a
|
|
167
|
+
// handler that read the position from the render closure would compute all
|
|
168
|
+
// of them from the same starting row and keep only the last, silently
|
|
169
|
+
// dropping every move but one.
|
|
170
|
+
const focusRef = React.useRef(focus);
|
|
171
|
+
focusRef.current = focus;
|
|
172
|
+
/** Move the cursor by rows, then store the session it landed on. */
|
|
173
|
+
const step = (by, times = 1) => {
|
|
174
|
+
let next = focusRef.current;
|
|
175
|
+
for (let taken = 0; taken < times; taken++)
|
|
176
|
+
next = moveSelection(view.rows, next, by);
|
|
177
|
+
focusRef.current = next;
|
|
178
|
+
const landed = sessionAt(view.rows, next);
|
|
179
|
+
if (landed !== undefined)
|
|
180
|
+
setFocusId(landed.id);
|
|
181
|
+
};
|
|
182
|
+
// Preview follows the cursor. Keyed on the id so arrowing past a row does
|
|
183
|
+
// not refetch the one it came from, and guarded on unmount so a slow read
|
|
184
|
+
// landing after the cursor moved cannot overwrite a newer preview.
|
|
185
|
+
React.useEffect(() => {
|
|
186
|
+
if (!previewOpen || focused === undefined)
|
|
187
|
+
return;
|
|
188
|
+
let live = true;
|
|
189
|
+
setPreviewLoading(true);
|
|
190
|
+
void channel
|
|
191
|
+
.previewSession(focused.id)
|
|
192
|
+
.then(entries => (live ? entries : undefined))
|
|
193
|
+
.catch(() => (live ? [] : undefined))
|
|
194
|
+
.then((entries) => {
|
|
195
|
+
if (entries === undefined)
|
|
196
|
+
return;
|
|
197
|
+
setPreview(entries);
|
|
198
|
+
setPreviewLoading(false);
|
|
199
|
+
});
|
|
200
|
+
return () => {
|
|
201
|
+
live = false;
|
|
202
|
+
};
|
|
203
|
+
}, [channel, previewOpen, focused?.id]);
|
|
204
|
+
// Wide terminals put the preview beside the list; narrow ones put it in the
|
|
205
|
+
// list's place. Tab must always visibly do something — a preview that
|
|
206
|
+
// silently declines to appear below some width is a dead key.
|
|
207
|
+
const splitPreview = previewOpen && columns >= SPLIT_MIN_COLUMNS;
|
|
208
|
+
const soloPreview = previewOpen && columns < SPLIT_MIN_COLUMNS;
|
|
209
|
+
const previewWidth = splitPreview ? Math.min(56, Math.floor(columns * 0.42)) : columns;
|
|
210
|
+
const listWidth = Math.max(20, columns - (splitPreview ? previewWidth : 0));
|
|
211
|
+
// Height is distributed, never assumed. Mandatory rows first, then a row
|
|
212
|
+
// for each region the current state actually needs, then the rules while
|
|
213
|
+
// rows remain, and the list gets what is left — which may be nothing.
|
|
214
|
+
//
|
|
215
|
+
// The arithmetic below is the whole vertical layout: the regions sum to
|
|
216
|
+
// exactly `rows` at every height down to MANDATORY_LINES. Anything that
|
|
217
|
+
// assumed a fixed chrome would overflow on a short terminal, and the row
|
|
218
|
+
// that falls off the bottom is always the last one — the hints.
|
|
219
|
+
const extraLines = (mode === 'list' ? 0 : 1) + (notice === undefined ? 0 : 1);
|
|
220
|
+
const ruleBudget = Math.max(0, Math.min(RULE_PRIORITY.length, rows - MANDATORY_LINES - extraLines));
|
|
221
|
+
const rules = new Set(RULE_PRIORITY.slice(0, ruleBudget));
|
|
222
|
+
const listHeight = Math.max(0, rows - MANDATORY_LINES - extraLines - rules.size);
|
|
223
|
+
const windowTop = anchorTop(view.rows, focus, listHeight, topRef.current);
|
|
224
|
+
topRef.current = windowTop;
|
|
225
|
+
const visible = view.rows.slice(windowTop, windowEnd(view.rows, windowTop, listHeight));
|
|
226
|
+
/**
|
|
227
|
+
* Change the view.
|
|
228
|
+
*
|
|
229
|
+
* Takes a function of the CURRENT filters rather than a ready-made patch,
|
|
230
|
+
* for the same reason the cursor keeps a ref: several key events can be
|
|
231
|
+
* handled before React re-renders, and a patch built from the render
|
|
232
|
+
* closure would compute each of them from the same starting filters — two
|
|
233
|
+
* toggles in one chunk cancelling to one, or typed characters overwriting
|
|
234
|
+
* each other instead of accumulating.
|
|
235
|
+
*/
|
|
236
|
+
const applyFilters = (update) => {
|
|
237
|
+
setFilters(current => ({ ...current, ...update(current) }));
|
|
238
|
+
topRef.current = 0;
|
|
239
|
+
};
|
|
240
|
+
/** Run one mutation, report it, and re-list — reporting a failure either way. */
|
|
241
|
+
const mutate = (action, done, failed) => {
|
|
242
|
+
void (async () => {
|
|
243
|
+
let ok = false;
|
|
244
|
+
let reason;
|
|
245
|
+
try {
|
|
246
|
+
ok = await action();
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
reason = message(error);
|
|
250
|
+
}
|
|
251
|
+
report(ok ? done : reason === undefined ? failed : `${failed} · ${reason}`, ok ? 'info' : 'error');
|
|
252
|
+
await reload();
|
|
253
|
+
})();
|
|
254
|
+
};
|
|
255
|
+
const runDelete = (target) => mutate(() => channel.deleteSession(target.id), t('resume-deleted', { name: target.title.text }), t('resume-delete-failed', { name: target.title.text }));
|
|
256
|
+
const runRename = (target, title) => mutate(() => channel.renameSessionTo(target.id, title), t('rename-done', { title }), t('resume-rename-failed', { name: target.title.text }));
|
|
257
|
+
const runClean = () => {
|
|
258
|
+
// Snapshot the ids before any await: the view is rebuilt by the reload
|
|
259
|
+
// below, and deleting from a list that moved under us would be a
|
|
260
|
+
// destructive action aimed at whatever happens to be there now.
|
|
261
|
+
const ids = [...view.emptyIds];
|
|
262
|
+
void (async () => {
|
|
263
|
+
let removed = 0;
|
|
264
|
+
for (const id of ids) {
|
|
265
|
+
try {
|
|
266
|
+
if (await channel.deleteSession(id))
|
|
267
|
+
removed += 1;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// One unremovable log must not abandon the rest of the sweep.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
report(t('session-cleaned', { n: removed }), 'info');
|
|
274
|
+
await reload();
|
|
275
|
+
})();
|
|
276
|
+
};
|
|
277
|
+
useInput((input, key) => {
|
|
278
|
+
// A notice describes what the LAST action did; the next keystroke makes it
|
|
279
|
+
// stale, so it goes as soon as the user acts again.
|
|
280
|
+
if (notice !== undefined)
|
|
281
|
+
setNotice(undefined);
|
|
282
|
+
if (mode === 'confirm-delete') {
|
|
283
|
+
if (isPlainReturn(key)) {
|
|
284
|
+
setMode('list');
|
|
285
|
+
if (focused !== undefined)
|
|
286
|
+
runDelete(focused);
|
|
287
|
+
}
|
|
288
|
+
else if (key.escape) {
|
|
289
|
+
setMode('list');
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (mode === 'confirm-clean') {
|
|
294
|
+
if (isPlainReturn(key)) {
|
|
295
|
+
setMode('list');
|
|
296
|
+
runClean();
|
|
297
|
+
}
|
|
298
|
+
else if (key.escape) {
|
|
299
|
+
setMode('list');
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (mode === 'rename') {
|
|
304
|
+
if (isPlainReturn(key)) {
|
|
305
|
+
setMode('list');
|
|
306
|
+
const title = renameText.trim();
|
|
307
|
+
if (focused !== undefined && title.length > 0)
|
|
308
|
+
runRename(focused, title);
|
|
309
|
+
}
|
|
310
|
+
else if (key.escape) {
|
|
311
|
+
setMode('list');
|
|
312
|
+
}
|
|
313
|
+
else if (key.backspace || key.delete) {
|
|
314
|
+
setRenameText(text => text.slice(0, -1));
|
|
315
|
+
}
|
|
316
|
+
else if (!isMod(key) && !key.meta && input) {
|
|
317
|
+
setRenameText(text => text + input.replace(/[\r\n]+/g, ' '));
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (key.upArrow) {
|
|
322
|
+
step(-1);
|
|
323
|
+
}
|
|
324
|
+
else if (key.downArrow) {
|
|
325
|
+
step(1);
|
|
326
|
+
}
|
|
327
|
+
else if (key.pageUp || key.pageDown) {
|
|
328
|
+
// A page is "as many rows as the window holds", taken as repeated single
|
|
329
|
+
// steps so it lands on a selectable row like every other move.
|
|
330
|
+
step(key.pageDown ? 1 : -1, Math.max(1, Math.floor(listHeight / 2)));
|
|
331
|
+
}
|
|
332
|
+
else if (isPlainReturn(key)) {
|
|
333
|
+
if (focused === undefined)
|
|
334
|
+
return;
|
|
335
|
+
const target = focused;
|
|
336
|
+
// The screen closes only once the resume actually happened. Closing
|
|
337
|
+
// first and letting a refusal fall through to a notification would send
|
|
338
|
+
// that explanation to the conversation the user is not looking at, and
|
|
339
|
+
// leave them staring at an unchanged transcript wondering what Enter
|
|
340
|
+
// did. `resumeTo` reports its own reasons; this only has to stay put.
|
|
341
|
+
void channel
|
|
342
|
+
.resumeTo(target.id)
|
|
343
|
+
.then((ok) => {
|
|
344
|
+
if (ok) {
|
|
345
|
+
channel.notify(t('resume-resumed'));
|
|
346
|
+
onClose();
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
setNotice({ text: t('session-resume-refused'), tone: 'error' });
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
.catch((error) => {
|
|
353
|
+
setNotice({ text: t('session-resume-failed', { err: message(error) }), tone: 'error' });
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
else if (key.escape) {
|
|
357
|
+
// Esc backs out one layer at a time: a live query first, the screen
|
|
358
|
+
// second. Closing on the first Esc would discard a search the user is
|
|
359
|
+
// still refining.
|
|
360
|
+
if (filters.query.length > 0)
|
|
361
|
+
applyFilters(() => ({ query: '' }));
|
|
362
|
+
else
|
|
363
|
+
onClose();
|
|
364
|
+
}
|
|
365
|
+
else if (key.tab) {
|
|
366
|
+
setPreviewOpen(open => !open);
|
|
367
|
+
}
|
|
368
|
+
else if (isMod(key) && input === 'a') {
|
|
369
|
+
applyFilters(current => ({ allProjects: !current.allProjects }));
|
|
370
|
+
}
|
|
371
|
+
else if (isMod(key) && input === 'b') {
|
|
372
|
+
applyFilters(current => ({ branchOnly: !current.branchOnly }));
|
|
373
|
+
}
|
|
374
|
+
else if (isMod(key) && input === 's') {
|
|
375
|
+
applyFilters(current => ({ showSubagents: !current.showSubagents }));
|
|
376
|
+
}
|
|
377
|
+
else if (isMod(key) && input === 'r' && focused !== undefined) {
|
|
378
|
+
setRenameText(focused.title.text);
|
|
379
|
+
setMode('rename');
|
|
380
|
+
}
|
|
381
|
+
else if (isMod(key) && input === 'd' && focused !== undefined) {
|
|
382
|
+
setMode('confirm-delete');
|
|
383
|
+
}
|
|
384
|
+
else if (isMod(key) && input === 'x' && view.emptyCount > 0) {
|
|
385
|
+
setMode('confirm-clean');
|
|
386
|
+
}
|
|
387
|
+
else if (key.backspace || key.delete) {
|
|
388
|
+
applyFilters(current => ({ query: current.query.slice(0, -1) }));
|
|
389
|
+
}
|
|
390
|
+
else if (!isMod(key) && !key.meta && !key.super && input && !key.return) {
|
|
391
|
+
// Only real characters reach the query. Anything else the terminal
|
|
392
|
+
// delivers — an unbound control byte, a chord this screen does not
|
|
393
|
+
// claim, the newlines inside a paste — would otherwise be typed into
|
|
394
|
+
// the search box invisibly, leaving a filter that matches nothing for
|
|
395
|
+
// no reason the user can see.
|
|
396
|
+
const typed = input.replace(/\p{Cc}/gu, '');
|
|
397
|
+
if (typed.length > 0)
|
|
398
|
+
applyFilters(current => ({ query: current.query + typed }));
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
const counts = [];
|
|
402
|
+
counts.push(t('session-count-shown', { n: view.shown }));
|
|
403
|
+
if (view.hiddenSubagents > 0)
|
|
404
|
+
counts.push(t('session-count-subagents', { n: view.hiddenSubagents }));
|
|
405
|
+
if (view.emptyCount > 0)
|
|
406
|
+
counts.push(t('session-count-empty', { n: view.emptyCount }));
|
|
407
|
+
// The header is laid out as one pre-measured row rather than a flex row: at
|
|
408
|
+
// an exact fit, flex truncation eats a character and the row reflows, which
|
|
409
|
+
// pushes every region below it down by one. `spreadRow` owns the column
|
|
410
|
+
// arithmetic and the regression pins its invariant directly.
|
|
411
|
+
const heading = ` ${t('resume-title')}`;
|
|
412
|
+
const header = spreadRow(heading, counts.join(' · '), Math.max(0, columns - 1));
|
|
413
|
+
const scope = filters.allProjects
|
|
414
|
+
? t('session-scope-all')
|
|
415
|
+
: formatProject(channel.cwd, home);
|
|
416
|
+
// One budget for every full-width single-line region: the search box, the
|
|
417
|
+
// confirmations, the rename editor, the notice. Each of them carries text
|
|
418
|
+
// whose length nobody controls — a session title, a filesystem path, an
|
|
419
|
+
// error message — and each of them sits between the list and the hints,
|
|
420
|
+
// where one wrapped row costs the list a line and can push the hints off
|
|
421
|
+
// the bottom of the screen.
|
|
422
|
+
const inputBudget = Math.max(0, columns - 2);
|
|
423
|
+
const hint = mode === 'confirm-delete' || mode === 'confirm-clean'
|
|
424
|
+
? fitHint([t('resume-hint-delete')], inputBudget)
|
|
425
|
+
: mode === 'rename'
|
|
426
|
+
? fitHint([t('resume-hint-rename')], inputBudget)
|
|
427
|
+
: fitHint([
|
|
428
|
+
t('session-hint-list', {
|
|
429
|
+
mod: modLabel,
|
|
430
|
+
projects: filters.allProjects ? t('session-toggle-on') : t('session-toggle-off'),
|
|
431
|
+
runs: filters.showSubagents ? t('session-toggle-on') : t('session-toggle-off'),
|
|
432
|
+
}),
|
|
433
|
+
t('session-hint-list-mid', { mod: modLabel }),
|
|
434
|
+
t('session-hint-list-short'),
|
|
435
|
+
], inputBudget);
|
|
436
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: "remember", bold: true, children: header.left }), _jsx(Text, { dimColor: true, children: `${' '.repeat(header.gap)}${header.right}` })] }), rules.has(0) && (_jsx(Box, { flexShrink: 0, children: _jsx(Divider, { width: columns }) })), _jsx(Box, { flexShrink: 0, children: _jsx(SearchBox, { query: tailWidth(filters.query, inputBudget), isFocused: mode === 'list', isTerminalFocused: isTerminalFocused, placeholder: truncateWidth(t('session-search-placeholder', { scope }), inputBudget), borderless: true }) }), rules.has(1) && (_jsx(Box, { flexShrink: 0, children: _jsx(Divider, { width: columns }) })), _jsxs(Box, { flexGrow: 1, flexShrink: 1, children: [!soloPreview && (_jsxs(Box, { flexDirection: "column", width: listWidth, height: listHeight, flexShrink: 0, children: [!loaded && (_jsx(Text, { dimColor: true, italic: true, children: ` ${truncateWidth(t('session-loading'), listWidth - 2)}` })), loaded && view.rows.length === 0 && (_jsx(Text, { dimColor: true, italic: true, children: ` ${truncateWidth(t('resume-none-in-cwd'), listWidth - 2)}` })), visible.map((row, index) => row.kind === 'project' ? (_jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: "planMode", children: truncateWidth(` ${formatProject(row.project, home)}`, listWidth - 6) }), _jsx(Text, { dimColor: true, children: ` ${row.count}` })] }, `project:${row.project}:${index}`)) : (_jsx(SessionListRow, { session: row.session, width: listWidth, depth: row.depth, focused: windowTop + index === focus, now: now }, row.session.id)))] })), (splitPreview || soloPreview) && focused !== undefined && (_jsx(SessionPreview, { session: focused, entries: preview, loading: previewLoading, width: previewWidth, height: listHeight, home: home, now: now }))] }), notice !== undefined && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: notice.tone === 'error' ? 'error' : 'success', children: ` ${truncateWidth(notice.text, Math.max(0, columns - 2))}` }) })), mode === 'confirm-delete' && focused !== undefined && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: "error", children: ` ${truncateWidth(t('resume-delete-confirm', { name: focused.title.text }), inputBudget)}` }) })), mode === 'confirm-clean' && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: "warning", children: ` ${truncateWidth(t('session-clean-confirm', { n: view.emptyCount }), inputBudget)}` }) })), mode === 'rename' && (_jsx(Box, { flexShrink: 0, children: _jsx(SearchBox, { query: tailWidth(renameText, inputBudget), isFocused: true, isTerminalFocused: isTerminalFocused, placeholder: truncateWidth(t('resume-rename-placeholder'), inputBudget), prefix: "\u270E", borderless: true }) })), rules.has(2) && (_jsx(Box, { flexShrink: 0, children: _jsx(Divider, { width: columns }) })), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, italic: true, children: _jsx(HintLine, { text: hint }) }) })] }));
|
|
437
|
+
}
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/** One persisted session as the `/resume` picker lists it. */
|
|
2
|
-
export interface SessionRecord {
|
|
3
|
-
id: string;
|
|
4
|
-
title: string;
|
|
5
|
-
cwd: string;
|
|
6
|
-
createdAt: number;
|
|
7
|
-
updatedAt: number;
|
|
8
|
-
}
|
|
9
1
|
/**
|
|
10
2
|
* Store the session to resume and report the launcher invocation.
|
|
11
3
|
* Dual-writes the legacy path for pre-rename launchers (see header).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sessionHistory.d.ts","sourceRoot":"","sources":["../../src/sessionHistory.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sessionHistory.d.ts","sourceRoot":"","sources":["../../src/sessionHistory.ts"],"names":[],"mappings":"AA6BA;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CASzD;AAED,qEAAqE;AACrE,wBAAgB,iBAAiB,IAAI,IAAI,CAQxC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,SAAS,CAUrD;AAED;;;GAGG;AACH,wBAAgB,YAAY,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAiB/D;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAQpD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAUrD"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { SessionKind, TitleSource } from '../dsh-adapter/sessions/index.js';
|
|
2
|
+
import type { Theme } from '../theme.js';
|
|
3
|
+
/**
|
|
4
|
+
* Truncate to a terminal DISPLAY width, CJK-aware (a wide character costs two
|
|
5
|
+
* columns).
|
|
6
|
+
*
|
|
7
|
+
* Used wherever the cut has to be exact: Ink's own `wrap="truncate"` appends
|
|
8
|
+
* its ellipsis as soon as content is as wide as its box rather than wider,
|
|
9
|
+
* which silently eats the last character of a row laid out at its natural
|
|
10
|
+
* width — and a row that then reflows pushes every row below it down.
|
|
11
|
+
*
|
|
12
|
+
* @param text - Plain text, no ANSI.
|
|
13
|
+
* @param maxWidth - Column budget, ellipsis included.
|
|
14
|
+
* @returns `text` when it fits, otherwise a cut ending in `…`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function truncateWidth(text: string, maxWidth: number): string;
|
|
17
|
+
/** A row laid out as `left`, blank columns, then `right`. */
|
|
18
|
+
export interface SpreadRow {
|
|
19
|
+
/** The left-hand segment, cut only if it alone exceeds the row. */
|
|
20
|
+
readonly left: string;
|
|
21
|
+
/** Blank columns between the two segments; at least one. */
|
|
22
|
+
readonly gap: number;
|
|
23
|
+
/** The right-hand segment, cut to whatever room the left one left. */
|
|
24
|
+
readonly right: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Lay out one row with its two ends pushed apart.
|
|
28
|
+
*
|
|
29
|
+
* Measured in COLUMNS, which is the whole reason this is a function rather
|
|
30
|
+
* than three expressions at the call site. Every string it receives is
|
|
31
|
+
* localized, and in Chinese a character is two columns wide — arithmetic on
|
|
32
|
+
* `.length` overstates the gap by the width of the text itself, so the row
|
|
33
|
+
* overflows and the terminal either wraps it (shifting every region below it
|
|
34
|
+
* down a line) or clips it (silently eating the right-hand segment). Both
|
|
35
|
+
* failures are invisible to an English-only test.
|
|
36
|
+
*
|
|
37
|
+
* The invariant, which the regression checks exhaustively: the assembled row
|
|
38
|
+
* is never wider than `columns`.
|
|
39
|
+
*
|
|
40
|
+
* @param left - Text pinned to the start of the row.
|
|
41
|
+
* @param right - Text pinned to the end, truncated when it will not fit.
|
|
42
|
+
* @param columns - Total width available.
|
|
43
|
+
*/
|
|
44
|
+
export declare function spreadRow(left: string, right: string, columns: number): SpreadRow;
|
|
45
|
+
/**
|
|
46
|
+
* Keep the END of a string within a display width, CJK-aware.
|
|
47
|
+
*
|
|
48
|
+
* What a single-line editor does: the caret lives at the end, so that is the
|
|
49
|
+
* part worth showing. Used where text the user is typing has to stay on ONE
|
|
50
|
+
* row — a wrapped input row steals a line from the list and can push the row
|
|
51
|
+
* below it off the bottom of the screen.
|
|
52
|
+
*
|
|
53
|
+
* @param text - Plain text, no ANSI.
|
|
54
|
+
* @param maxWidth - Column budget, leading ellipsis included.
|
|
55
|
+
* @returns `text` when it fits, otherwise `…` followed by its tail.
|
|
56
|
+
*/
|
|
57
|
+
export declare function tailWidth(text: string, maxWidth: number): string;
|
|
58
|
+
/**
|
|
59
|
+
* Elapsed time as a person would say it: `just now`, `9 hours ago`, and an
|
|
60
|
+
* absolute date once "ago" stops being useful.
|
|
61
|
+
*
|
|
62
|
+
* The cut to an absolute date is at a week rather than a month because that is
|
|
63
|
+
* roughly where a relative offset stops locating anything — "23 days ago" is a
|
|
64
|
+
* number to be decoded, `Mar 3` is a memory.
|
|
65
|
+
*
|
|
66
|
+
* @param at - Epoch ms of the moment being described.
|
|
67
|
+
* @param now - Epoch ms of the present, injectable so the formatter is a pure
|
|
68
|
+
* function and its regression does not depend on a clock.
|
|
69
|
+
*/
|
|
70
|
+
export declare function formatWhen(at: number, now: number): string;
|
|
71
|
+
/**
|
|
72
|
+
* Byte size at the precision the number is worth: `812 B`, `142.9 KB`,
|
|
73
|
+
* `4.2 MB`. One decimal from kilobytes up, because the digit distinguishes a
|
|
74
|
+
* short exchange from a long one and a second would not.
|
|
75
|
+
*/
|
|
76
|
+
export declare function formatBytes(bytes: number | undefined): string | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Marker for a row's kind, or undefined for an ordinary conversation.
|
|
79
|
+
*
|
|
80
|
+
* Only the exceptional kinds are marked. A badge on every row would cost a
|
|
81
|
+
* column of width and teach nothing — what a reader needs to see at a glance
|
|
82
|
+
* is which rows are *not* the thing they came for.
|
|
83
|
+
*/
|
|
84
|
+
export declare function kindMark(kind: SessionKind): {
|
|
85
|
+
glyph: string;
|
|
86
|
+
color: keyof Theme;
|
|
87
|
+
} | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Wrap to a display width, CJK-aware.
|
|
90
|
+
*
|
|
91
|
+
* Greedy, breaking on a space when one is available in the line just filled
|
|
92
|
+
* and mid-character when it is not — which is the correct behaviour for CJK,
|
|
93
|
+
* where there are no spaces to break on and a word-only wrapper would emit one
|
|
94
|
+
* enormous unbreakable line. Newlines in the input are honoured.
|
|
95
|
+
*
|
|
96
|
+
* @param text - Plain text, no ANSI.
|
|
97
|
+
* @param width - Column budget per line.
|
|
98
|
+
* @returns The wrapped lines, never empty for non-empty input.
|
|
99
|
+
*/
|
|
100
|
+
export declare function wrapWidth(text: string, width: number): string[];
|
|
101
|
+
/** Human name of a session kind, for the preview pane's header. */
|
|
102
|
+
export declare function kindLabel(kind: SessionKind): string;
|
|
103
|
+
/**
|
|
104
|
+
* Colour for a title, by how much is actually known about it.
|
|
105
|
+
*
|
|
106
|
+
* A title the user chose is stated plainly; one a model generated reads the
|
|
107
|
+
* same, because it is a real title; a first-prompt excerpt is dimmer, because
|
|
108
|
+
* it is the session speaking rather than a name; a directory basename is
|
|
109
|
+
* dimmest of all, because it says only "nothing here was readable".
|
|
110
|
+
*/
|
|
111
|
+
export declare function titleColor(source: TitleSource, focused: boolean): keyof Theme;
|
|
112
|
+
/**
|
|
113
|
+
* Project label for a group header: the working directory, with `$HOME`
|
|
114
|
+
* collapsed to `~` so the eye lands on the part that differs.
|
|
115
|
+
*/
|
|
116
|
+
export declare function formatProject(cwd: string, home: string): string;
|
|
117
|
+
//# sourceMappingURL=format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../src/sessions/format.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAChF,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAExC;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAYpE;AAED,6DAA6D;AAC7D,MAAM,WAAW,SAAS;IACxB,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,4DAA4D;IAC5D,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,sEAAsE;IACtE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,CAajF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAchE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAc1D;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAKzE;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,KAAK,CAAA;CAAE,GAAG,SAAS,CAI7F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA4B/D;AAED,mEAAmE;AACnE,wBAAgB,SAAS,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,CAInD;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,KAAK,CAK7E;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ/D"}
|