ispbills-icli 8.5.5 → 8.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/session.js +133 -17
- package/src/tui/app.js +501 -67
- package/src/tui/components.js +130 -18
- package/src/tui/composer.js +19 -0
package/package.json
CHANGED
package/src/session.js
CHANGED
|
@@ -1,9 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'fs';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
|
|
5
5
|
const SESSION_DIR = join(homedir(), '.config', 'ispbills-icli', 'sessions');
|
|
6
6
|
|
|
7
|
+
function safeJson(raw, fallback = null) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
return fallback;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeMessages(messages = []) {
|
|
16
|
+
return messages
|
|
17
|
+
.filter((m) => m && typeof m.role === 'string')
|
|
18
|
+
.map((m) => ({ role: m.role, content: String(m.content ?? '') }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function autoNameFromMessages(messages = []) {
|
|
22
|
+
const firstUser = messages.find((m) => m?.role === 'user' && String(m.content || '').trim());
|
|
23
|
+
const text = String(firstUser?.content || '').replace(/\s+/g, ' ').trim();
|
|
24
|
+
if (!text) return 'Untitled session';
|
|
25
|
+
return text.slice(0, 40).trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeSession(session = {}) {
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
const messages = normalizeMessages(session.messages);
|
|
31
|
+
const id = String(session.id || Date.now().toString(36));
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
name: String(session.name || autoNameFromMessages(messages) || 'Untitled session'),
|
|
35
|
+
cwd: String(session.cwd || process.cwd()),
|
|
36
|
+
branch: String(session.branch || ''),
|
|
37
|
+
model: String(session.model || ''),
|
|
38
|
+
createdAt: session.createdAt || now,
|
|
39
|
+
updatedAt: session.updatedAt || now,
|
|
40
|
+
messages,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sessionPathFor(id) {
|
|
45
|
+
return join(SESSION_DIR, `${id}.json`);
|
|
46
|
+
}
|
|
47
|
+
|
|
7
48
|
export function sessionDir() {
|
|
8
49
|
return SESSION_DIR;
|
|
9
50
|
}
|
|
@@ -12,30 +53,105 @@ export function initSessionDir() {
|
|
|
12
53
|
if (!existsSync(SESSION_DIR)) mkdirSync(SESSION_DIR, { recursive: true });
|
|
13
54
|
}
|
|
14
55
|
|
|
15
|
-
export function
|
|
16
|
-
|
|
17
|
-
|
|
56
|
+
export function saveSession(session) {
|
|
57
|
+
try {
|
|
58
|
+
initSessionDir();
|
|
59
|
+
const existing = loadSessionById(session?.id);
|
|
60
|
+
const normalized = normalizeSession({
|
|
61
|
+
...existing,
|
|
62
|
+
...session,
|
|
63
|
+
id: session?.id || existing?.id,
|
|
64
|
+
createdAt: session?.createdAt || existing?.createdAt,
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
name: session?.name || existing?.name,
|
|
67
|
+
});
|
|
68
|
+
if (!normalized.name || normalized.name === 'Untitled session') {
|
|
69
|
+
normalized.name = autoNameFromMessages(normalized.messages);
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(sessionPathFor(normalized.id), JSON.stringify(normalized, null, 2) + '\n');
|
|
72
|
+
return normalized;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
18
76
|
}
|
|
19
77
|
|
|
20
|
-
|
|
21
|
-
|
|
78
|
+
export function loadSessionById(id) {
|
|
79
|
+
if (!id) return null;
|
|
80
|
+
const path = sessionPathFor(id);
|
|
81
|
+
if (!existsSync(path)) return null;
|
|
82
|
+
const loaded = safeJson(readFileSync(path, 'utf8'), null);
|
|
83
|
+
return loaded ? normalizeSession(loaded) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function deleteSession(id) {
|
|
22
87
|
try {
|
|
23
|
-
|
|
88
|
+
const path = sessionPathFor(id);
|
|
89
|
+
if (!existsSync(path)) return false;
|
|
90
|
+
unlinkSync(path);
|
|
91
|
+
return true;
|
|
24
92
|
} catch {
|
|
25
|
-
|
|
93
|
+
return false;
|
|
26
94
|
}
|
|
27
95
|
}
|
|
28
96
|
|
|
29
|
-
export function
|
|
97
|
+
export function listSessionsDetailed() {
|
|
30
98
|
if (!existsSync(SESSION_DIR)) return [];
|
|
31
|
-
return readdirSync(SESSION_DIR)
|
|
99
|
+
return readdirSync(SESSION_DIR)
|
|
100
|
+
.filter((f) => f.endsWith('.json'))
|
|
101
|
+
.map((file) => {
|
|
102
|
+
const full = join(SESSION_DIR, file);
|
|
103
|
+
const session = safeJson(readFileSync(full, 'utf8'), null);
|
|
104
|
+
if (!session) return null;
|
|
105
|
+
const normalized = normalizeSession(session);
|
|
106
|
+
const stats = statSync(full);
|
|
107
|
+
return {
|
|
108
|
+
id: normalized.id,
|
|
109
|
+
name: normalized.name || autoNameFromMessages(normalized.messages),
|
|
110
|
+
cwd: normalized.cwd,
|
|
111
|
+
branch: normalized.branch,
|
|
112
|
+
model: normalized.model,
|
|
113
|
+
createdAt: normalized.createdAt,
|
|
114
|
+
updatedAt: normalized.updatedAt,
|
|
115
|
+
messageCount: normalized.messages.length,
|
|
116
|
+
messages: normalized.messages,
|
|
117
|
+
mtimeMs: stats.mtimeMs,
|
|
118
|
+
};
|
|
119
|
+
})
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
|
32
122
|
}
|
|
33
123
|
|
|
34
|
-
export function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
124
|
+
export function loadMostRecent() {
|
|
125
|
+
const [session] = listSessionsDetailed();
|
|
126
|
+
if (!session?.id) return null;
|
|
127
|
+
return loadSessionById(session.id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function listSessions() {
|
|
131
|
+
return listSessionsDetailed().map((s) => s.id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function loadSession(sessionPathOrId) {
|
|
135
|
+
if (!sessionPathOrId) return [];
|
|
136
|
+
if (sessionPathOrId.endsWith?.('.json')) {
|
|
137
|
+
const session = safeJson(readFileSync(sessionPathOrId, 'utf8'), null);
|
|
138
|
+
return normalizeSession(session || {}).messages;
|
|
139
|
+
}
|
|
140
|
+
return loadSessionById(sessionPathOrId)?.messages || [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function newSessionPath() {
|
|
144
|
+
initSessionDir();
|
|
145
|
+
return sessionPathFor(Date.now().toString(36));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function saveMessage(sessionPathOrId, message) {
|
|
149
|
+
if (!message) return null;
|
|
150
|
+
const id = String(sessionPathOrId || Date.now().toString(36)).replace(/\.json$/, '');
|
|
151
|
+
const existing = loadSessionById(id);
|
|
152
|
+
return saveSession({
|
|
153
|
+
...(existing || {}),
|
|
154
|
+
id,
|
|
155
|
+
messages: [...(existing?.messages || []), message],
|
|
156
|
+
});
|
|
41
157
|
}
|
package/src/tui/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import { C, glyphs, midDot, dash } from './theme.js';
|
|
|
5
5
|
import {
|
|
6
6
|
Banner, TabBar, FollowupChips, UserMessage, AssistantMessage, StatusLines, Reasoning,
|
|
7
7
|
Plan, Connect, Notice, Thinking, Choice, ShellOutput, SearchOverlay, HelpOverlay,
|
|
8
|
+
SessionPicker, DiffViewer, LabeledNotice,
|
|
8
9
|
} from './components.js';
|
|
9
10
|
import { Composer } from './composer.js';
|
|
10
11
|
import { streamChat } from '../client.js';
|
|
@@ -19,6 +20,7 @@ import { execSync, spawnSync } from 'child_process';
|
|
|
19
20
|
import { homedir } from 'os';
|
|
20
21
|
import { dirname, isAbsolute, relative, resolve } from 'path';
|
|
21
22
|
import { existsSync, readFileSync } from 'fs';
|
|
23
|
+
import { deleteSession, loadMostRecent, loadSessionById, saveSession, listSessionsDetailed } from '../session.js';
|
|
22
24
|
|
|
23
25
|
function splitArgs(s) {
|
|
24
26
|
const out = [];
|
|
@@ -52,6 +54,126 @@ function approxTokens(messages) {
|
|
|
52
54
|
return Math.round(total / 4);
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
const TOKEN_LIMIT = 100000;
|
|
58
|
+
const SESSION_SORTS = ['relevance', 'last used', 'created', 'name'];
|
|
59
|
+
|
|
60
|
+
function sessionAutoName(messages = []) {
|
|
61
|
+
const firstUser = messages.find((m) => m?.role === 'user' && String(m.content || '').trim());
|
|
62
|
+
const text = String(firstUser?.content || '').replace(/\s+/g, ' ').trim();
|
|
63
|
+
return text ? text.slice(0, 40).trim() : 'Untitled session';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderConversationItems(messages = [], cols = 80) {
|
|
67
|
+
return messages
|
|
68
|
+
.filter((m) => m?.role === 'user' || m?.role === 'assistant')
|
|
69
|
+
.map((m) => ({
|
|
70
|
+
id: nextId(),
|
|
71
|
+
cols,
|
|
72
|
+
ts: Date.now(),
|
|
73
|
+
type: m.role,
|
|
74
|
+
text: String(m.content || ''),
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function contextUsageLabel(tokens) {
|
|
79
|
+
const ratio = tokens / TOKEN_LIMIT;
|
|
80
|
+
if (ratio >= 0.5) return `~${Math.round(ratio * 100)}% ctx`;
|
|
81
|
+
if (tokens <= 0) return '';
|
|
82
|
+
return `~${Math.max(1, Math.round(tokens / 1000))}k ctx`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function usageBar(value, max, width = 16) {
|
|
86
|
+
const safeMax = Math.max(1, max);
|
|
87
|
+
const filled = Math.max(0, Math.min(width, Math.round((value / safeMax) * width)));
|
|
88
|
+
return `${'■'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatDuration(ms = 0) {
|
|
92
|
+
const mins = Math.max(0, Math.round(ms / 60000));
|
|
93
|
+
if (mins < 1) return 'under a minute';
|
|
94
|
+
if (mins === 1) return '1 minute';
|
|
95
|
+
if (mins < 60) return `${mins} minutes`;
|
|
96
|
+
const hours = Math.floor(mins / 60);
|
|
97
|
+
const rem = mins % 60;
|
|
98
|
+
return rem ? `${hours}h ${rem}m` : `${hours}h`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sessionRelevanceScore(session, cwd) {
|
|
102
|
+
const here = resolve(cwd || process.cwd());
|
|
103
|
+
const there = resolve(session?.cwd || here);
|
|
104
|
+
if (there === here) return 4;
|
|
105
|
+
if (here.startsWith(there + '/')) return 3;
|
|
106
|
+
if (there.startsWith(here + '/')) return 2;
|
|
107
|
+
const home = homedir();
|
|
108
|
+
if (relative(home, there).slice(0, 2) !== '..') return 1;
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sortSessions(sessions, mode, cwd) {
|
|
113
|
+
const list = [...sessions];
|
|
114
|
+
if (mode === 'created') {
|
|
115
|
+
return list.sort((a, b) => new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime());
|
|
116
|
+
}
|
|
117
|
+
if (mode === 'name') {
|
|
118
|
+
return list.sort((a, b) => {
|
|
119
|
+
const an = String(a.name || '').trim();
|
|
120
|
+
const bn = String(b.name || '').trim();
|
|
121
|
+
if (!an) return 1;
|
|
122
|
+
if (!bn) return -1;
|
|
123
|
+
return an.localeCompare(bn);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (mode === 'last used') {
|
|
127
|
+
return list.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
|
128
|
+
}
|
|
129
|
+
return list.sort((a, b) => {
|
|
130
|
+
const score = sessionRelevanceScore(b, cwd) - sessionRelevanceScore(a, cwd);
|
|
131
|
+
if (score !== 0) return score;
|
|
132
|
+
return new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseDiff(raw = '') {
|
|
137
|
+
const lines = [];
|
|
138
|
+
let file = '';
|
|
139
|
+
let oldLine = 0;
|
|
140
|
+
let newLine = 0;
|
|
141
|
+
for (const content of String(raw || '').split('\n')) {
|
|
142
|
+
if (content.startsWith('diff --git ')) {
|
|
143
|
+
file = content.replace(/^diff --git a\/(.+?) b\/.+$/, '$1');
|
|
144
|
+
lines.push({ type: 'header', content, file });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (content.startsWith('@@')) {
|
|
148
|
+
const match = content.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
149
|
+
oldLine = Number(match?.[1] || 0);
|
|
150
|
+
newLine = Number(match?.[2] || 0);
|
|
151
|
+
lines.push({ type: 'hunk', content, file, oldLine, newLine });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (content.startsWith('+++ ') || content.startsWith('--- ') || content.startsWith('index ')) {
|
|
155
|
+
lines.push({ type: 'header', content, file });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (content.startsWith('+')) {
|
|
159
|
+
lines.push({ type: 'add', content, file, lineNumber: newLine });
|
|
160
|
+
newLine += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (content.startsWith('-')) {
|
|
164
|
+
lines.push({ type: 'del', content, file, lineNumber: oldLine });
|
|
165
|
+
oldLine += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
lines.push({ type: 'ctx', content, file, oldLine, newLine });
|
|
169
|
+
if (!content.startsWith('\\')) {
|
|
170
|
+
oldLine += 1;
|
|
171
|
+
newLine += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
|
|
55
177
|
function pkgVersion() {
|
|
56
178
|
try {
|
|
57
179
|
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || 'unknown';
|
|
@@ -114,14 +236,24 @@ const MODES = ['ask', 'plan', 'autopilot'];
|
|
|
114
236
|
|
|
115
237
|
export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, showBanner = true, agentName, resumeMode }) {
|
|
116
238
|
const { exit } = useApp();
|
|
117
|
-
const
|
|
239
|
+
const initialResumeRef = useRef(undefined);
|
|
240
|
+
if (initialResumeRef.current === undefined) {
|
|
241
|
+
const resumed = resumeMode ? loadMostRecent() : null;
|
|
242
|
+
if (resumed?.cwd && existsSync(resumed.cwd)) {
|
|
243
|
+
try { process.chdir(resumed.cwd); } catch {}
|
|
244
|
+
}
|
|
245
|
+
initialResumeRef.current = resumed;
|
|
246
|
+
}
|
|
247
|
+
const initialSession = initialResumeRef.current;
|
|
248
|
+
const initialMessages = initialSession?.messages || [];
|
|
249
|
+
const { cols, rows } = useTerminalSize();
|
|
118
250
|
const colsRef = useRef(cols);
|
|
119
251
|
useEffect(() => { colsRef.current = cols; }, [cols]);
|
|
120
252
|
|
|
121
|
-
const [items, setItems] = useState(
|
|
253
|
+
const [items, setItems] = useState(() => renderConversationItems(initialMessages, cols));
|
|
122
254
|
const [live, setLive] = useState(null);
|
|
123
255
|
const [busy, setBusy] = useState(false);
|
|
124
|
-
const [model, setModel] = useState(cfg._model);
|
|
256
|
+
const [model, setModel] = useState(initialSession?.model || cfg._model);
|
|
125
257
|
const [showReasoning, setShowReasoning] = useState(false);
|
|
126
258
|
const [mode, setMode] = useState('ask');
|
|
127
259
|
const [activeTab, setActiveTab] = useState('session');
|
|
@@ -133,27 +265,33 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
133
265
|
const [clearEpoch, setClearEpoch] = useState(0);
|
|
134
266
|
const [composerClearToken, setComposerClearToken] = useState(0);
|
|
135
267
|
const [composerText, setComposerText] = useState('');
|
|
268
|
+
const [composerPreset, setComposerPreset] = useState({ token: 0, text: '' });
|
|
136
269
|
const [search, setSearch] = useState(null);
|
|
137
270
|
const [expandedItems, setExpandedItems] = useState(null);
|
|
138
271
|
const [allowAll, setAllowAll] = useState(Boolean(cfg?.tui?.allowAll));
|
|
139
272
|
const [streamerMode, setStreamerMode] = useState(Boolean(cfg?.tui?.streamerMode));
|
|
140
273
|
const [shellMode, setShellMode] = useState(false);
|
|
141
274
|
const [trusted, setTrusted] = useState(isTrustedDir(cfg, process.cwd()));
|
|
275
|
+
const [sessionPicker, setSessionPicker] = useState(null);
|
|
276
|
+
const [diffState, setDiffState] = useState(null);
|
|
142
277
|
|
|
143
|
-
const convo = useRef(
|
|
278
|
+
const convo = useRef(initialMessages);
|
|
144
279
|
const plain = useRef([]);
|
|
145
280
|
const abortRef = useRef(null);
|
|
146
281
|
const ctrlC = useRef(0);
|
|
147
282
|
const apRef = useRef(false);
|
|
148
283
|
const modeRef = useRef('ask');
|
|
149
|
-
const modelRef = useRef(cfg._model);
|
|
150
|
-
const lastAnswerRef = useRef('');
|
|
284
|
+
const modelRef = useRef(initialSession?.model || cfg._model);
|
|
285
|
+
const lastAnswerRef = useRef([...initialMessages].reverse().find((m) => m.role === 'assistant')?.content || '');
|
|
151
286
|
const choiceActionRef = useRef(null);
|
|
152
|
-
const branchRef = useRef(gitBranch());
|
|
287
|
+
const branchRef = useRef(initialSession?.branch || gitBranch(process.cwd()));
|
|
153
288
|
const queuedRef = useRef(null);
|
|
154
289
|
const activeTurns = useRef(0);
|
|
155
290
|
const dismissTimers = useRef(new Map());
|
|
156
291
|
const dispatchSubmitRef = useRef(null);
|
|
292
|
+
const sessionIdRef = useRef(initialSession?.id || Date.now().toString(36));
|
|
293
|
+
const sessionCreatedAtRef = useRef(initialSession?.createdAt || new Date().toISOString());
|
|
294
|
+
const sessionNameRef = useRef(initialSession?.name || sessionAutoName(initialMessages));
|
|
157
295
|
|
|
158
296
|
useEffect(() => {
|
|
159
297
|
try { process.stdout.write('\x1b[?2004h'); } catch {}
|
|
@@ -168,15 +306,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
168
306
|
try { saveConfig(cfg); } catch {}
|
|
169
307
|
}, [cfg]);
|
|
170
308
|
|
|
171
|
-
useEffect(() => {
|
|
172
|
-
if (agentName) {
|
|
173
|
-
setTimeout(() => pushNotice(`${glyphs.bolt} Agent: ${agentName} — delegating to named agent.`, C.accent, true), 200);
|
|
174
|
-
}
|
|
175
|
-
if (resumeMode) {
|
|
176
|
-
setTimeout(() => pushNotice(`${glyphs.arrow} Resume mode — last session info will appear in /session`, C.muted, true), 200);
|
|
177
|
-
}
|
|
178
|
-
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
179
|
-
|
|
180
309
|
const setModeState = useCallback((next) => {
|
|
181
310
|
modeRef.current = next;
|
|
182
311
|
apRef.current = next === 'autopilot';
|
|
@@ -200,14 +329,15 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
200
329
|
|
|
201
330
|
useEffect(() => {
|
|
202
331
|
for (const item of items) {
|
|
203
|
-
if (
|
|
332
|
+
if (!['notice', 'info', 'error'].includes(item.type) || !item.autoDismiss || dismissTimers.current.has(item.id)) continue;
|
|
204
333
|
const timer = setTimeout(() => removeItem(item.id), 4000);
|
|
205
334
|
dismissTimers.current.set(item.id, timer);
|
|
206
335
|
}
|
|
207
336
|
}, [items, removeItem]);
|
|
208
337
|
|
|
209
|
-
const pushNotice = useCallback((text, color = C.gray, autoDismiss = false) => {
|
|
210
|
-
|
|
338
|
+
const pushNotice = useCallback((text, color = C.gray, autoDismiss = false, type = null) => {
|
|
339
|
+
const resolvedType = type || (color === C.red || color === C.error ? 'error' : 'info');
|
|
340
|
+
push({ type: resolvedType, text, color, autoDismiss });
|
|
211
341
|
}, [push]);
|
|
212
342
|
|
|
213
343
|
const log = (line) => plain.current.push(line);
|
|
@@ -220,6 +350,43 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
220
350
|
setClearEpoch((n) => n + 1);
|
|
221
351
|
}, []);
|
|
222
352
|
|
|
353
|
+
const replaceTimeline = useCallback((messages = []) => {
|
|
354
|
+
try { process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); } catch {}
|
|
355
|
+
setItems(renderConversationItems(messages, colsRef.current));
|
|
356
|
+
setExpandedItems(null);
|
|
357
|
+
setSearch(null);
|
|
358
|
+
setClearEpoch((n) => n + 1);
|
|
359
|
+
}, []);
|
|
360
|
+
|
|
361
|
+
const saveCurrentSession = useCallback((overrides = {}) => {
|
|
362
|
+
const saved = saveSession({
|
|
363
|
+
id: sessionIdRef.current,
|
|
364
|
+
name: overrides.name || sessionNameRef.current || sessionAutoName(convo.current),
|
|
365
|
+
cwd: process.cwd(),
|
|
366
|
+
branch: branchRef.current,
|
|
367
|
+
model: modelRef.current || model || '',
|
|
368
|
+
createdAt: overrides.createdAt || sessionCreatedAtRef.current,
|
|
369
|
+
updatedAt: new Date().toISOString(),
|
|
370
|
+
messages: overrides.messages || convo.current,
|
|
371
|
+
});
|
|
372
|
+
if (saved?.name) sessionNameRef.current = saved.name;
|
|
373
|
+
if (saved?.createdAt) sessionCreatedAtRef.current = saved.createdAt;
|
|
374
|
+
return saved;
|
|
375
|
+
}, [model]);
|
|
376
|
+
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (agentName) {
|
|
379
|
+
setTimeout(() => pushNotice(`${glyphs.bolt} Agent: ${agentName} — delegating to named agent.`, C.accent, true), 200);
|
|
380
|
+
}
|
|
381
|
+
if (resumeMode) {
|
|
382
|
+
if (initialSession?.id) {
|
|
383
|
+
setTimeout(() => pushNotice(`${glyphs.arrow} Resumed session: ${initialSession.name || sessionAutoName(initialMessages)}`, C.muted, true), 200);
|
|
384
|
+
} else {
|
|
385
|
+
setTimeout(() => pushNotice(`${glyphs.arrow} No saved session found to continue.`, C.muted, true), 200);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
389
|
+
|
|
223
390
|
const queueSubmission = useCallback((text) => {
|
|
224
391
|
const q = String(text || '').trim();
|
|
225
392
|
if (!q) { pushNotice('Nothing to queue.', C.muted, true); return; }
|
|
@@ -313,6 +480,64 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
313
480
|
push({ type: 'shell', command, output, code: res.status ?? 0 });
|
|
314
481
|
}, [log, push, pushNotice, shellMode]);
|
|
315
482
|
|
|
483
|
+
const loadSessionIntoApp = useCallback((session, notice = 'Session loaded.') => {
|
|
484
|
+
if (!session?.id) return;
|
|
485
|
+
if (session.cwd && existsSync(session.cwd)) {
|
|
486
|
+
try { process.chdir(session.cwd); } catch {}
|
|
487
|
+
}
|
|
488
|
+
branchRef.current = session.branch || gitBranch(process.cwd());
|
|
489
|
+
setTrusted(isTrustedDir(cfg, process.cwd()));
|
|
490
|
+
sessionIdRef.current = session.id;
|
|
491
|
+
sessionCreatedAtRef.current = session.createdAt || new Date().toISOString();
|
|
492
|
+
sessionNameRef.current = session.name || sessionAutoName(session.messages);
|
|
493
|
+
convo.current = session.messages || [];
|
|
494
|
+
lastAnswerRef.current = [...convo.current].reverse().find((m) => m.role === 'assistant')?.content || '';
|
|
495
|
+
modelRef.current = session.model || modelRef.current;
|
|
496
|
+
setModel(session.model || modelRef.current);
|
|
497
|
+
setModeState('ask');
|
|
498
|
+
setSessionPicker(null);
|
|
499
|
+
setDiffState(null);
|
|
500
|
+
replaceTimeline(convo.current);
|
|
501
|
+
setComposerClearToken((n) => n + 1);
|
|
502
|
+
pushNotice(notice, C.green, true);
|
|
503
|
+
saveCurrentSession();
|
|
504
|
+
}, [cfg, pushNotice, replaceTimeline, saveCurrentSession, setModeState]);
|
|
505
|
+
|
|
506
|
+
const openSessionPicker = useCallback(() => {
|
|
507
|
+
const sort = sessionPicker?.sort || 'relevance';
|
|
508
|
+
const sessions = sortSessions(listSessionsDetailed(), sort, process.cwd());
|
|
509
|
+
if (!sessions.length) {
|
|
510
|
+
pushNotice('No saved sessions yet.', C.muted, true);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
setSessionPicker({ sort, sel: 0, sessions });
|
|
514
|
+
}, [pushNotice, sessionPicker?.sort]);
|
|
515
|
+
|
|
516
|
+
const loadDiffState = useCallback((overrides = {}) => {
|
|
517
|
+
const staged = overrides.staged ?? diffState?.staged ?? false;
|
|
518
|
+
const whitespace = overrides.whitespace ?? diffState?.whitespace ?? false;
|
|
519
|
+
const args = ['diff'];
|
|
520
|
+
if (staged) args.push('--cached');
|
|
521
|
+
if (whitespace) args.push('--ignore-all-space');
|
|
522
|
+
const res = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
523
|
+
const raw = String(res.stdout || res.stderr || '');
|
|
524
|
+
const lines = parseDiff(raw);
|
|
525
|
+
if (!lines.length) {
|
|
526
|
+
pushNotice('No diff to show.', C.muted, true);
|
|
527
|
+
setDiffState(null);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
setDiffState((prev) => ({
|
|
531
|
+
lines,
|
|
532
|
+
raw,
|
|
533
|
+
sel: Math.min(prev?.sel || 0, Math.max(0, lines.length - 1)),
|
|
534
|
+
staged,
|
|
535
|
+
whitespace,
|
|
536
|
+
comments: prev?.comments || {},
|
|
537
|
+
commentInput: null,
|
|
538
|
+
}));
|
|
539
|
+
}, [diffState?.staged, diffState?.whitespace, pushNotice]);
|
|
540
|
+
|
|
316
541
|
const runTurn = useCallback(async (question, opts = {}) => {
|
|
317
542
|
const { depth = 0, archive = null, label = '', synthetic = false } = opts;
|
|
318
543
|
activeTurns.current += 1;
|
|
@@ -321,9 +546,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
321
546
|
log(`\n${glyphs.prompt} ${question}\n`);
|
|
322
547
|
}
|
|
323
548
|
convo.current.push({ role: 'user', content: question });
|
|
549
|
+
if (!sessionNameRef.current || sessionNameRef.current === 'Untitled session') {
|
|
550
|
+
sessionNameRef.current = sessionAutoName(convo.current);
|
|
551
|
+
}
|
|
324
552
|
|
|
325
553
|
// Auto-compact at ~95% of context budget (approx 100k token limit)
|
|
326
|
-
const TOKEN_LIMIT = 100000;
|
|
327
554
|
if (approxTokens(convo.current) > TOKEN_LIMIT * 0.95) {
|
|
328
555
|
pushNotice(`${glyphs.arrow} Context at 95% — auto-compacting history…`, C.warning, true);
|
|
329
556
|
const snapshot = convo.current;
|
|
@@ -352,7 +579,11 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
352
579
|
|
|
353
580
|
try {
|
|
354
581
|
const preamble = memoryPreamble(cfg);
|
|
355
|
-
const
|
|
582
|
+
const request = modeRef.current === 'plan' && !synthetic
|
|
583
|
+
? `[PLAN MODE] Before implementing, ask 2-3 clarifying questions about this request, then wait for answers before creating any implementation plan. Request: ${question}`
|
|
584
|
+
: question;
|
|
585
|
+
const outboundBase = [...convo.current.slice(0, -1), { role: 'user', content: request }];
|
|
586
|
+
const outbound = preamble ? [preamble, ...outboundBase] : outboundBase;
|
|
356
587
|
const { reply, text, meta } = await streamChat(cfg, outbound, {
|
|
357
588
|
signal: abort.signal,
|
|
358
589
|
context,
|
|
@@ -387,8 +618,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
387
618
|
push({ type: 'assistant', text: text || '(no response)' });
|
|
388
619
|
log(text || '');
|
|
389
620
|
}
|
|
390
|
-
|
|
391
|
-
|
|
621
|
+
const assistantContent = text || reply.summary || reply.question || reply.note || '';
|
|
622
|
+
if (assistantContent) convo.current.push({ role: 'assistant', content: assistantContent });
|
|
623
|
+
lastAnswerRef.current = assistantContent || lastAnswerRef.current;
|
|
624
|
+
saveCurrentSession();
|
|
392
625
|
|
|
393
626
|
if (Array.isArray(reply.suggestions) && reply.suggestions.length) {
|
|
394
627
|
setChips(reply.suggestions.slice(0, 4));
|
|
@@ -444,7 +677,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
444
677
|
activeTurns.current = Math.max(0, activeTurns.current - 1);
|
|
445
678
|
maybeProcessQueue();
|
|
446
679
|
}
|
|
447
|
-
}, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice]);
|
|
680
|
+
}, [allowAll, cfg, log, maybeProcessQueue, push, pushNotice, saveCurrentSession]);
|
|
448
681
|
|
|
449
682
|
const showExpanded = useCallback((kind, sourceItems) => {
|
|
450
683
|
if (!sourceItems.length) {
|
|
@@ -473,6 +706,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
473
706
|
if (q === '/exit' || q === 'exit' || q === 'quit') { doExit(); return; }
|
|
474
707
|
if (q === '/clear') { clearAll(); return; }
|
|
475
708
|
if (q === '/new') {
|
|
709
|
+
sessionIdRef.current = Date.now().toString(36);
|
|
710
|
+
sessionCreatedAtRef.current = new Date().toISOString();
|
|
711
|
+
sessionNameRef.current = 'Untitled session';
|
|
476
712
|
convo.current = [];
|
|
477
713
|
clearAll();
|
|
478
714
|
pushNotice(`${glyphs.check} New conversation started.`, C.green);
|
|
@@ -499,11 +735,24 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
499
735
|
pushNotice(`Mode: ${next} ${dash()} Shift+Tab to cycle.`, C.accent);
|
|
500
736
|
return;
|
|
501
737
|
}
|
|
502
|
-
if (q === '/context'
|
|
738
|
+
if (q === '/context') {
|
|
503
739
|
const tokens = approxTokens(convo.current);
|
|
504
740
|
pushNotice(`Context: ~${tokens.toLocaleString()} tokens (${convo.current.length} messages). /compact to summarise.`);
|
|
505
741
|
return;
|
|
506
742
|
}
|
|
743
|
+
if (q === '/usage') {
|
|
744
|
+
const messages = convo.current.length;
|
|
745
|
+
const tokens = approxTokens(convo.current);
|
|
746
|
+
const durationMs = Date.now() - new Date(sessionCreatedAtRef.current).getTime();
|
|
747
|
+
pushNotice([
|
|
748
|
+
'Usage ─────────────────────────────────',
|
|
749
|
+
` Messages ${usageBar(messages, 20)} ${messages} msgs`,
|
|
750
|
+
` Tokens ${usageBar(tokens, TOKEN_LIMIT)} ${tokens >= 1000 ? `~${(tokens / 1000).toFixed(1)}k` : `~${tokens}`}`,
|
|
751
|
+
` Session ${formatDuration(durationMs)}`,
|
|
752
|
+
` Model ${model || modelRef.current || 'server default'}`,
|
|
753
|
+
].join('\n'));
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
507
756
|
if (q === '/compact' || q.startsWith('/compact ')) {
|
|
508
757
|
const focus = q.slice('/compact'.length).trim();
|
|
509
758
|
const prompt = focus
|
|
@@ -522,9 +771,10 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
522
771
|
const tokens = approxTokens(convo.current);
|
|
523
772
|
const branch = branchRef.current;
|
|
524
773
|
const cwd = shortenPath(process.cwd());
|
|
525
|
-
const name =
|
|
774
|
+
const name = sessionNameRef.current || sessionAutoName(convo.current);
|
|
526
775
|
pushNotice([
|
|
527
776
|
`Session ${dash()} ${name}`,
|
|
777
|
+
`ID ${dash()} ${sessionIdRef.current}`,
|
|
528
778
|
`Path ${dash()} ${cwd}${branch ? ' [' + branch + ']' : ''}`,
|
|
529
779
|
`Messages ${dash()} ${convo.current.length}`,
|
|
530
780
|
`Tokens ${dash()} ~${tokens.toLocaleString()} (approx)`,
|
|
@@ -533,15 +783,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
533
783
|
].join('\n'));
|
|
534
784
|
return;
|
|
535
785
|
}
|
|
536
|
-
if (q === '/resume' || q
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
786
|
+
if (q === '/resume' || q === '/continue') {
|
|
787
|
+
openSessionPicker();
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (q.startsWith('/resume ') || q.startsWith('/continue ')) {
|
|
791
|
+
const id = q.split(/\s+/).slice(1).join(' ').trim();
|
|
792
|
+
const session = loadSessionById(id);
|
|
793
|
+
if (!session) {
|
|
794
|
+
pushNotice(`Session not found: ${id}`, C.red);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
loadSessionIntoApp(session, `Session loaded: ${session.name || id}`);
|
|
545
798
|
return;
|
|
546
799
|
}
|
|
547
800
|
if (q === '/share') {
|
|
@@ -569,6 +822,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
569
822
|
setModel(name);
|
|
570
823
|
cfg._model = name;
|
|
571
824
|
persistCfg();
|
|
825
|
+
saveCurrentSession();
|
|
572
826
|
pushNotice(`${glyphs.check} Model set to ${name}. It applies to your next message.`, C.green);
|
|
573
827
|
}
|
|
574
828
|
return;
|
|
@@ -588,6 +842,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
588
842
|
const ok = isTrustedDir(cfg, target);
|
|
589
843
|
setTrusted(ok);
|
|
590
844
|
if (!ok) openTrustDialog(target);
|
|
845
|
+
saveCurrentSession();
|
|
591
846
|
pushNotice(`Changed directory to ${shortenPath(target)}`, C.green, true);
|
|
592
847
|
} catch (e) {
|
|
593
848
|
pushNotice(`Could not change directory: ${e.message}`, C.red);
|
|
@@ -606,10 +861,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
606
861
|
return;
|
|
607
862
|
}
|
|
608
863
|
if (q === '/rename' || q.startsWith('/rename ')) {
|
|
609
|
-
const name = q.slice('/rename'.length).trim() ||
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
persistCfg();
|
|
864
|
+
const name = q.slice('/rename'.length).trim() || sessionAutoName(convo.current);
|
|
865
|
+
sessionNameRef.current = name;
|
|
866
|
+
saveCurrentSession({ name });
|
|
613
867
|
pushNotice(`${glyphs.check} Session renamed to ${name}.`, C.green);
|
|
614
868
|
return;
|
|
615
869
|
}
|
|
@@ -655,10 +909,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
655
909
|
return;
|
|
656
910
|
}
|
|
657
911
|
if (q === '/diff') {
|
|
658
|
-
|
|
659
|
-
const body = (r.stdout || '').trim() || (r.stderr || '').trim() || 'No recent diff in the backup repo.';
|
|
660
|
-
pushNotice(`backup repo diff\n${body.slice(0, 3500)}`, r.ok ? C.muted : C.red);
|
|
661
|
-
});
|
|
912
|
+
loadDiffState({ staged: false, whitespace: false });
|
|
662
913
|
return;
|
|
663
914
|
}
|
|
664
915
|
if (q === '/review' || q.startsWith('/review ')) {
|
|
@@ -1078,6 +1329,9 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1078
1329
|
return;
|
|
1079
1330
|
}
|
|
1080
1331
|
if (q === '/reset') {
|
|
1332
|
+
sessionIdRef.current = Date.now().toString(36);
|
|
1333
|
+
sessionCreatedAtRef.current = new Date().toISOString();
|
|
1334
|
+
sessionNameRef.current = 'Untitled session';
|
|
1081
1335
|
convo.current = [];
|
|
1082
1336
|
clearAll();
|
|
1083
1337
|
pushNotice(`${glyphs.check} Conversation reset.`, C.green);
|
|
@@ -1096,19 +1350,6 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1096
1350
|
}
|
|
1097
1351
|
return;
|
|
1098
1352
|
}
|
|
1099
|
-
if (q === '/continue' || q.startsWith('/continue ')) {
|
|
1100
|
-
// same as /resume
|
|
1101
|
-
const name = cfg?.tui?.sessionNames?.[process.cwd()] || '(unnamed)';
|
|
1102
|
-
pushNotice([
|
|
1103
|
-
`Session ${dash()} ${name}`,
|
|
1104
|
-
`Path ${dash()} ${shortenPath(process.cwd())}`,
|
|
1105
|
-
`Msgs ${dash()} ${convo.current.length}`,
|
|
1106
|
-
`Last AI ${dash()} ${(lastAnswerRef.current || '(none)').slice(0, 120)}`,
|
|
1107
|
-
'',
|
|
1108
|
-
'Session persistence coming in a future release. Use /session for current session info.',
|
|
1109
|
-
].join('\n'));
|
|
1110
|
-
return;
|
|
1111
|
-
}
|
|
1112
1353
|
const turnOpts = {};
|
|
1113
1354
|
let question = q;
|
|
1114
1355
|
if (q.startsWith('/')) {
|
|
@@ -1125,7 +1366,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1125
1366
|
}
|
|
1126
1367
|
}
|
|
1127
1368
|
runTurn(question, turnOpts);
|
|
1128
|
-
}, [allowAll, cfg, clearAll, doExit, doGist,
|
|
1369
|
+
}, [allowAll, cfg, clearAll, doExit, doGist, loadDiffState, loadSessionIntoApp, model, onExternal, openSessionPicker, openTrustDialog, persistCfg, push, pushNotice, runShellCommand, runTurn, saveCurrentSession, setModeState, shellMode, streamerMode, trusted]);
|
|
1129
1370
|
|
|
1130
1371
|
useEffect(() => {
|
|
1131
1372
|
dispatchSubmitRef.current = dispatchSubmit;
|
|
@@ -1138,14 +1379,73 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1138
1379
|
const pickChoice = useCallback((value) => {
|
|
1139
1380
|
const act = choiceActionRef.current;
|
|
1140
1381
|
choiceActionRef.current = null;
|
|
1382
|
+
const currentChoice = choice;
|
|
1141
1383
|
setChoice(null);
|
|
1142
1384
|
if (act) { act(value); return; }
|
|
1143
|
-
if (value == null) {
|
|
1385
|
+
if (value == null) {
|
|
1386
|
+
if (/tool request|approval|shell:/i.test(`${currentChoice?.title || ''} ${currentChoice?.note || ''}`)) {
|
|
1387
|
+
setChoice({
|
|
1388
|
+
title: 'Tell Copilot what to do differently:',
|
|
1389
|
+
note: '',
|
|
1390
|
+
sel: 0,
|
|
1391
|
+
text: '',
|
|
1392
|
+
focusText: true,
|
|
1393
|
+
allowText: true,
|
|
1394
|
+
options: [],
|
|
1395
|
+
});
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
pushNotice('Dismissed.', C.muted, true);
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1144
1401
|
runTurn(String(value));
|
|
1145
|
-
}, [pushNotice, runTurn]);
|
|
1402
|
+
}, [choice, pushNotice, runTurn]);
|
|
1403
|
+
|
|
1404
|
+
useInput((input, key) => {
|
|
1405
|
+
if (!sessionPicker) return;
|
|
1406
|
+
if (key.escape || (key.ctrl && input === 'c')) { setSessionPicker(null); return; }
|
|
1407
|
+
if (key.upArrow || input === 'k') {
|
|
1408
|
+
setSessionPicker((p) => ({ ...p, sel: Math.max(0, p.sel - 1) }));
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (key.downArrow || input === 'j') {
|
|
1412
|
+
setSessionPicker((p) => ({ ...p, sel: Math.min(Math.max(0, p.sessions.length - 1), p.sel + 1) }));
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (input === 's') {
|
|
1416
|
+
setSessionPicker((p) => {
|
|
1417
|
+
const nextSort = SESSION_SORTS[(SESSION_SORTS.indexOf(p.sort) + 1) % SESSION_SORTS.length];
|
|
1418
|
+
return {
|
|
1419
|
+
...p,
|
|
1420
|
+
sort: nextSort,
|
|
1421
|
+
sel: 0,
|
|
1422
|
+
sessions: sortSessions(listSessionsDetailed(), nextSort, process.cwd()),
|
|
1423
|
+
};
|
|
1424
|
+
});
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (input === 'd') {
|
|
1428
|
+
const current = sessionPicker.sessions[sessionPicker.sel];
|
|
1429
|
+
if (!current) return;
|
|
1430
|
+
const deleted = deleteSession(current.id);
|
|
1431
|
+
const sessions = sortSessions(listSessionsDetailed(), sessionPicker.sort, process.cwd());
|
|
1432
|
+
setSessionPicker(sessions.length ? {
|
|
1433
|
+
...sessionPicker,
|
|
1434
|
+
sessions,
|
|
1435
|
+
sel: Math.min(sessionPicker.sel, Math.max(0, sessions.length - 1)),
|
|
1436
|
+
} : null);
|
|
1437
|
+
pushNotice(deleted ? `Deleted session: ${current.name || current.id}` : `Could not delete session: ${current.id}`, deleted ? C.green : C.red, true);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
if (key.return) {
|
|
1441
|
+
const current = sessionPicker.sessions[sessionPicker.sel];
|
|
1442
|
+
if (current) loadSessionIntoApp(loadSessionById(current.id) || current, `Session loaded: ${current.name || current.id}`);
|
|
1443
|
+
}
|
|
1444
|
+
});
|
|
1146
1445
|
|
|
1147
1446
|
useInput((input, key) => {
|
|
1148
1447
|
if (!choice) return;
|
|
1448
|
+
if (sessionPicker || diffState) return;
|
|
1149
1449
|
if (key.escape || (key.ctrl && input === 'c')) { setChoice(null); return; }
|
|
1150
1450
|
if (key.tab && choice.allowText) { setChoice((c) => ({ ...c, focusText: !c.focusText })); return; }
|
|
1151
1451
|
if (key.upArrow) { setChoice((c) => ({ ...c, focusText: false, sel: Math.max(0, c.sel - 1) })); return; }
|
|
@@ -1171,6 +1471,96 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1171
1471
|
}
|
|
1172
1472
|
});
|
|
1173
1473
|
|
|
1474
|
+
useInput((input, key) => {
|
|
1475
|
+
if (!diffState) return;
|
|
1476
|
+
if (choice || search || sessionPicker) return;
|
|
1477
|
+
const commenting = diffState.commentInput;
|
|
1478
|
+
if (commenting) {
|
|
1479
|
+
if (key.escape) {
|
|
1480
|
+
setDiffState((d) => ({ ...d, commentInput: null }));
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (key.return) {
|
|
1484
|
+
const text = String(commenting.text || '').trim();
|
|
1485
|
+
if (!text) {
|
|
1486
|
+
setDiffState((d) => ({ ...d, commentInput: null }));
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
setDiffState((d) => ({
|
|
1490
|
+
...d,
|
|
1491
|
+
comments: { ...(d.comments || {}), [commenting.lineIndex]: { ...commenting, text } },
|
|
1492
|
+
commentInput: null,
|
|
1493
|
+
}));
|
|
1494
|
+
pushNotice('Diff comment saved.', C.green, true);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (key.backspace || key.delete) {
|
|
1498
|
+
setDiffState((d) => ({ ...d, commentInput: { ...d.commentInput, text: d.commentInput.text.slice(0, -1) } }));
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (input && !key.ctrl && !key.meta) {
|
|
1502
|
+
const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
|
|
1503
|
+
if (printable) setDiffState((d) => ({ ...d, commentInput: { ...d.commentInput, text: d.commentInput.text + printable } }));
|
|
1504
|
+
}
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const page = Math.max(4, rows - 12);
|
|
1509
|
+
const fileIndexes = diffState.lines.map((line, idx) => line.type === 'header' && /^diff --git /.test(line.content) ? idx : -1).filter((idx) => idx >= 0);
|
|
1510
|
+
const moveFile = (dir) => {
|
|
1511
|
+
const current = fileIndexes.findIndex((idx) => idx >= diffState.sel);
|
|
1512
|
+
const nextIndex = dir > 0
|
|
1513
|
+
? fileIndexes[Math.min(fileIndexes.length - 1, Math.max(0, current + 1))]
|
|
1514
|
+
: fileIndexes[Math.max(0, (current <= 0 ? fileIndexes.length : current) - 1)];
|
|
1515
|
+
if (typeof nextIndex === 'number') setDiffState((d) => ({ ...d, sel: nextIndex }));
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
if (key.escape || (key.ctrl && input === 'c')) { setDiffState(null); return; }
|
|
1519
|
+
if (input === 'j' || key.downArrow) { setDiffState((d) => ({ ...d, sel: Math.min(d.lines.length - 1, d.sel + 1) })); return; }
|
|
1520
|
+
if (input === 'k' || key.upArrow) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - 1) })); return; }
|
|
1521
|
+
if (input === 'g' || key.home) { setDiffState((d) => ({ ...d, sel: 0 })); return; }
|
|
1522
|
+
if (input === 'G' || key.end) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.lines.length - 1) })); return; }
|
|
1523
|
+
if (key.pageDown) { setDiffState((d) => ({ ...d, sel: Math.min(d.lines.length - 1, d.sel + page) })); return; }
|
|
1524
|
+
if (key.pageUp) { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - page) })); return; }
|
|
1525
|
+
if (key.ctrl && input === 'd') { setDiffState((d) => ({ ...d, sel: Math.min(d.lines.length - 1, d.sel + Math.max(2, Math.floor(page / 2))) })); return; }
|
|
1526
|
+
if (key.ctrl && input === 'u') { setDiffState((d) => ({ ...d, sel: Math.max(0, d.sel - Math.max(2, Math.floor(page / 2))) })); return; }
|
|
1527
|
+
if (input === 'h' || key.leftArrow) { moveFile(-1); return; }
|
|
1528
|
+
if (input === 'l' || key.rightArrow) { moveFile(1); return; }
|
|
1529
|
+
if (input === 'b') { loadDiffState({ staged: !diffState.staged, whitespace: diffState.whitespace }); return; }
|
|
1530
|
+
if (input === 'w') { loadDiffState({ staged: diffState.staged, whitespace: !diffState.whitespace }); return; }
|
|
1531
|
+
if (input === 'c') {
|
|
1532
|
+
const line = diffState.lines[diffState.sel];
|
|
1533
|
+
if (!line) return;
|
|
1534
|
+
setDiffState((d) => ({
|
|
1535
|
+
...d,
|
|
1536
|
+
commentInput: {
|
|
1537
|
+
lineIndex: d.sel,
|
|
1538
|
+
file: line.file || 'diff',
|
|
1539
|
+
lineNumber: line.lineNumber || line.newLine || line.oldLine || 0,
|
|
1540
|
+
text: d.comments?.[d.sel]?.text || '',
|
|
1541
|
+
content: line.content,
|
|
1542
|
+
},
|
|
1543
|
+
}));
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
if (input === 's') {
|
|
1547
|
+
const comments = Object.values(diffState.comments || {});
|
|
1548
|
+
pushNotice(comments.length
|
|
1549
|
+
? comments.map((c, i) => `${i + 1}. ${c.file}:${c.lineNumber || '?'} — ${c.text}`).join('\n')
|
|
1550
|
+
: 'No diff comments yet.', C.muted);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
if (key.return) {
|
|
1554
|
+
const comments = Object.values(diffState.comments || {});
|
|
1555
|
+
if (!comments.length) return;
|
|
1556
|
+
setDiffState(null);
|
|
1557
|
+
runTurn([
|
|
1558
|
+
'Review these git diff comments/questions and explain the relevant changes:',
|
|
1559
|
+
...comments.map((c, i) => `${i + 1}. ${c.file}:${c.lineNumber || '?'} ${c.content}\nComment: ${c.text}`),
|
|
1560
|
+
].join('\n\n'));
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1174
1564
|
const openSearch = useCallback(() => {
|
|
1175
1565
|
setSearch({ query: '', active: 0 });
|
|
1176
1566
|
setHint('Timeline search');
|
|
@@ -1201,7 +1591,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1201
1591
|
});
|
|
1202
1592
|
|
|
1203
1593
|
useInput((input, key) => {
|
|
1204
|
-
if (choice || search) return;
|
|
1594
|
+
if (choice || search || sessionPicker || diffState) return;
|
|
1205
1595
|
if (key.ctrl && input === 'c') {
|
|
1206
1596
|
if (busy) { abortRef.current?.abort(); setHint(''); return; }
|
|
1207
1597
|
const now = Date.now();
|
|
@@ -1215,6 +1605,17 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1215
1605
|
if (key.escape && shellMode && !composerText.trim()) { setShellMode(false); pushNotice('Exited shell mode.', C.muted, true); return; }
|
|
1216
1606
|
if (key.escape && expandedItems) { setExpandedItems(null); return; }
|
|
1217
1607
|
if (key.escape && chips.length) { setChips([]); return; }
|
|
1608
|
+
if (activeTab !== 'session' && key.return) {
|
|
1609
|
+
setActiveTab('session');
|
|
1610
|
+
setHint('Session tab active');
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (activeTab !== 'session' && input === 'c') {
|
|
1614
|
+
setActiveTab('session');
|
|
1615
|
+
setComposerPreset({ token: Date.now(), text: '#' });
|
|
1616
|
+
pushNotice('Composer primed with an ISP reference. Continue typing after #.', C.muted, true);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1218
1619
|
if (key.tab && key.shift && !busy) {
|
|
1219
1620
|
const cur = MODES.indexOf(modeRef.current);
|
|
1220
1621
|
const next = MODES[(cur + 1) % MODES.length];
|
|
@@ -1251,7 +1652,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1251
1652
|
{ cmd: '/offline', desc: 'All offline devices' },
|
|
1252
1653
|
{ cmd: '/flapping [device]', desc: 'Flapping interfaces' },
|
|
1253
1654
|
],
|
|
1254
|
-
hint: '
|
|
1655
|
+
hint: 'Press Enter to switch back to Session and use the composer',
|
|
1255
1656
|
},
|
|
1256
1657
|
customers: {
|
|
1257
1658
|
title: 'Customers',
|
|
@@ -1263,7 +1664,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1263
1664
|
{ cmd: '/onu [id]', desc: 'ONU signal and status' },
|
|
1264
1665
|
{ cmd: '/bandwidth [device]', desc: 'Traffic / bandwidth' },
|
|
1265
1666
|
],
|
|
1266
|
-
hint: '
|
|
1667
|
+
hint: 'Press Enter to switch back to Session and send commands',
|
|
1267
1668
|
},
|
|
1268
1669
|
alarms: {
|
|
1269
1670
|
title: 'Alarms',
|
|
@@ -1275,7 +1676,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1275
1676
|
{ cmd: '/optical [device]', desc: 'Optical RX/TX (dBm)' },
|
|
1276
1677
|
{ cmd: '/reachable', desc: 'Reachability sweep' },
|
|
1277
1678
|
],
|
|
1278
|
-
hint: '
|
|
1679
|
+
hint: 'Press Enter to switch back to Session and type a command',
|
|
1279
1680
|
},
|
|
1280
1681
|
};
|
|
1281
1682
|
|
|
@@ -1290,6 +1691,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1290
1691
|
case 'connect': return html`<${Connect} key=${it.id} reply=${it.reply} />`;
|
|
1291
1692
|
case 'activity': return html`<${StatusLines} key=${it.id} lines=${it.status} busy=${false} />`;
|
|
1292
1693
|
case 'notice': return html`<${Notice} key=${it.id} text=${it.text} color=${it.color} />`;
|
|
1694
|
+
case 'info': return html`<${LabeledNotice} key=${it.id} label="ℹ Info" text=${it.text} color=${C.muted} bodyColor=${it.color || C.white} cols=${c} ts=${it.ts} />`;
|
|
1695
|
+
case 'error': return html`<${LabeledNotice} key=${it.id} label="✖ Error" text=${it.text} color=${C.error} bodyColor=${it.color || C.white} cols=${c} ts=${it.ts} />`;
|
|
1293
1696
|
case 'shell': return html`<${ShellOutput} key=${it.id} command=${it.command} output=${it.output} code=${it.code} cols=${c} ts=${it.ts} />`;
|
|
1294
1697
|
case 'help': return html`<${HelpOverlay} key=${it.id} cols=${c} ts=${it.ts} />`;
|
|
1295
1698
|
default: return null;
|
|
@@ -1299,7 +1702,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1299
1702
|
const cwd = shortenPath(process.cwd());
|
|
1300
1703
|
const branch = branchRef.current;
|
|
1301
1704
|
const tokenCount = approxTokens(convo.current);
|
|
1302
|
-
const tokenLabel =
|
|
1705
|
+
const tokenLabel = contextUsageLabel(tokenCount);
|
|
1303
1706
|
const modelLabel = streamerMode ? '' : shortModel(model || modelRef.current || 'server default');
|
|
1304
1707
|
const footerLeft = [cwd, branch ? `${glyphs.gitBranch}${branch}` : null].filter(Boolean).join(' ');
|
|
1305
1708
|
const footerRight = hint
|
|
@@ -1342,9 +1745,34 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1342
1745
|
|
|
1343
1746
|
${search ? html`<${SearchOverlay} query=${search.query} matches=${searchMatches} active=${search.active} cols=${cols} />` : null}
|
|
1344
1747
|
|
|
1748
|
+
${diffState
|
|
1749
|
+
? html`<${Box} flexDirection="column">
|
|
1750
|
+
<${DiffViewer}
|
|
1751
|
+
diff=${diffState.lines}
|
|
1752
|
+
sel=${diffState.sel}
|
|
1753
|
+
staged=${diffState.staged}
|
|
1754
|
+
whitespace=${diffState.whitespace}
|
|
1755
|
+
cols=${cols}
|
|
1756
|
+
height=${Math.max(8, rows - 14)}
|
|
1757
|
+
commentCount=${Object.keys(diffState.comments || {}).length}
|
|
1758
|
+
/>
|
|
1759
|
+
${diffState.commentInput
|
|
1760
|
+
? html`<${Choice}
|
|
1761
|
+
title=${`Tell Copilot what stands out about ${diffState.commentInput.file}:${diffState.commentInput.lineNumber || '?'}`}
|
|
1762
|
+
note=${diffState.commentInput.content}
|
|
1763
|
+
sel=${0}
|
|
1764
|
+
allowText=${true}
|
|
1765
|
+
text=${diffState.commentInput.text}
|
|
1766
|
+
focusText=${true}
|
|
1767
|
+
options=${[]}
|
|
1768
|
+
/>`
|
|
1769
|
+
: null}
|
|
1770
|
+
<//>`
|
|
1771
|
+
: null}
|
|
1772
|
+
|
|
1345
1773
|
<${TabBar} active=${activeTab} cols=${cols} mode=${composerMode} />
|
|
1346
1774
|
|
|
1347
|
-
${tabPanel
|
|
1775
|
+
${tabPanel && !diffState
|
|
1348
1776
|
? html`<${Box} flexDirection="column" paddingX=${1} paddingY=${1}>
|
|
1349
1777
|
<${Text} color=${C.brand} bold>${tabPanel.title} — Quick Commands<//>\n
|
|
1350
1778
|
${tabPanel.commands.map((c, i) =>
|
|
@@ -1352,7 +1780,7 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1352
1780
|
<${Text} color=${C.slash}>${c.cmd.padEnd(24)}<//>
|
|
1353
1781
|
<${Text} color=${C.muted}>${c.desc}<//>
|
|
1354
1782
|
<//>`)}
|
|
1355
|
-
\n<${Text} color=${C.muted}>${tabPanel.hint}<//>
|
|
1783
|
+
\n<${Text} color=${C.muted}>${tabPanel.hint} · Press Enter to return to Session · Press c to start a #reference<//>
|
|
1356
1784
|
<//>`
|
|
1357
1785
|
: null}
|
|
1358
1786
|
|
|
@@ -1361,6 +1789,8 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1361
1789
|
<${Box} flexDirection="column">
|
|
1362
1790
|
${choice
|
|
1363
1791
|
? html`<${Choice} ...${choice} />`
|
|
1792
|
+
: sessionPicker
|
|
1793
|
+
? html`<${SessionPicker} sessions=${sessionPicker.sessions} sel=${sessionPicker.sel} sort=${sessionPicker.sort} cols=${cols} />`
|
|
1364
1794
|
: html`<${Composer}
|
|
1365
1795
|
onSubmit=${dispatchSubmit}
|
|
1366
1796
|
onSubmitKeep=${dispatchSubmit}
|
|
@@ -1368,14 +1798,18 @@ export function App({ cfg, display = {}, onExternal, onExit, initialQuestion, sh
|
|
|
1368
1798
|
onSearch=${openSearch}
|
|
1369
1799
|
onNotice=${(text, color = C.muted) => pushNotice(text, color, true)}
|
|
1370
1800
|
onEmptyEsc=${() => { if (shellMode) setShellMode(false); else if (expandedItems) setExpandedItems(null); }}
|
|
1801
|
+
onActivateSession=${() => { setActiveTab('session'); setHint('Session tab active'); }}
|
|
1371
1802
|
onValueChange=${setComposerText}
|
|
1372
1803
|
onEmptySubmit=${() => {
|
|
1373
1804
|
if (chips.length) dispatchSubmit(chips[activeChip]);
|
|
1374
1805
|
}}
|
|
1375
1806
|
busy=${busy}
|
|
1376
|
-
blocked=${Boolean(choice || search || !trusted)}
|
|
1807
|
+
blocked=${Boolean(choice || search || sessionPicker || diffState || !trusted)}
|
|
1377
1808
|
width=${cols}
|
|
1378
1809
|
mode=${composerMode}
|
|
1810
|
+
activeTab=${activeTab}
|
|
1811
|
+
presetText=${composerPreset.text}
|
|
1812
|
+
presetToken=${composerPreset.token}
|
|
1379
1813
|
clearToken=${composerClearToken}
|
|
1380
1814
|
/>`}
|
|
1381
1815
|
|
package/src/tui/components.js
CHANGED
|
@@ -16,20 +16,51 @@ const LOGO = [
|
|
|
16
16
|
];
|
|
17
17
|
|
|
18
18
|
const HELP_ROWS = [
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
'┌─────────────────────────────────────────────────────────┐',
|
|
20
|
+
'│ iCLI — QUICK REFERENCE │',
|
|
21
|
+
'├────────────────────┬────────────────────────────────────┤',
|
|
22
|
+
'│ LAUNCH │ icli [--banner] [--experimental] │',
|
|
23
|
+
'│ ONE-SHOT │ icli -p "prompt" [--allow-all] │',
|
|
24
|
+
'│ RESUME LAST │ icli --continue │',
|
|
25
|
+
'├────────────────────┼────────────────────────────────────┤',
|
|
26
|
+
'│ CYCLE MODES │ Shift+Tab │',
|
|
27
|
+
'│ CANCEL │ Esc (or Ctrl+C once) │',
|
|
28
|
+
'│ EXIT │ Ctrl+C twice or /exit │',
|
|
29
|
+
'│ SHUTDOWN │ Ctrl+D │',
|
|
30
|
+
'│ CLEAR SCREEN │ Ctrl+L │',
|
|
31
|
+
'│ CLEAR HISTORY │ /clear │',
|
|
32
|
+
'├────────────────────┼────────────────────────────────────┤',
|
|
33
|
+
'│ REASONING │ Ctrl+T │',
|
|
34
|
+
'│ EXPAND RECENT │ Ctrl+O │',
|
|
35
|
+
'│ EXPAND ALL │ Ctrl+E │',
|
|
36
|
+
'│ TIMELINE SEARCH │ Ctrl+F │',
|
|
37
|
+
'│ PAGE SCROLL │ Page Up / Page Down │',
|
|
38
|
+
'├────────────────────┼────────────────────────────────────┤',
|
|
39
|
+
'│ FILE MENTION │ @path/to/file │',
|
|
40
|
+
'│ ISP REFERENCE │ #NUMBER │',
|
|
41
|
+
'│ SHELL BYPASS │ !command │',
|
|
42
|
+
'│ QUICK HELP │ ? (empty input) │',
|
|
43
|
+
'│ EXTERNAL EDITOR │ Ctrl+G │',
|
|
44
|
+
'│ PASTE ATTACHMENT │ Ctrl+V │',
|
|
45
|
+
'│ QUEUE MESSAGE │ Ctrl+Enter while busy │',
|
|
46
|
+
'├────────────────────┼────────────────────────────────────┤',
|
|
47
|
+
'│ LINE START/END │ Ctrl+A / Ctrl+E │',
|
|
48
|
+
'│ WORD JUMP │ Alt+← / Alt+→ │',
|
|
49
|
+
'│ DELETE WORD │ Ctrl+W │',
|
|
50
|
+
'│ DELETE TO END │ Ctrl+K │',
|
|
51
|
+
'│ DELETE TO START │ Ctrl+U │',
|
|
52
|
+
'│ HISTORY NAV │ ↑ / ↓ │',
|
|
53
|
+
'│ ACCEPT MENU │ Tab │',
|
|
54
|
+
'│ MULTILINE INPUT │ Shift+Enter │',
|
|
55
|
+
'├────────────────────┼────────────────────────────────────┤',
|
|
56
|
+
'│ MODEL │ /model │',
|
|
57
|
+
'│ CONTEXT / USAGE │ /context or /usage │',
|
|
58
|
+
'│ COMPACT HISTORY │ /compact [focus] │',
|
|
59
|
+
'│ DIFF VIEW │ /diff │',
|
|
60
|
+
'│ SESSION INFO │ /session │',
|
|
61
|
+
'│ RESUME SESSION │ /resume │',
|
|
62
|
+
'│ HELP │ /help │',
|
|
63
|
+
'└────────────────────┴────────────────────────────────────┘',
|
|
33
64
|
];
|
|
34
65
|
|
|
35
66
|
function formatTs(ts) {
|
|
@@ -39,6 +70,21 @@ function formatTs(ts) {
|
|
|
39
70
|
} catch {
|
|
40
71
|
return '';
|
|
41
72
|
}
|
|
73
|
+
|
|
74
|
+
function formatDate(ts) {
|
|
75
|
+
if (!ts) return '';
|
|
76
|
+
try {
|
|
77
|
+
return new Date(ts).toLocaleString([], {
|
|
78
|
+
year: 'numeric',
|
|
79
|
+
month: 'short',
|
|
80
|
+
day: '2-digit',
|
|
81
|
+
hour: '2-digit',
|
|
82
|
+
minute: '2-digit',
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
42
88
|
}
|
|
43
89
|
|
|
44
90
|
function SpeakerLine({ label, color, cols = 80, ts, rightLabel = '' }) {
|
|
@@ -310,14 +356,24 @@ export function Notice({ text, color = C.gray }) {
|
|
|
310
356
|
return html`<${Box} marginTop=${1}><${Text} color=${color}>${text}<//><//>`;
|
|
311
357
|
}
|
|
312
358
|
|
|
359
|
+
export function LabeledNotice({ label, text, color = C.gray, cols = 80, ts, bodyColor = C.white }) {
|
|
360
|
+
return html`
|
|
361
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
362
|
+
<${Sep} cols=${cols} />
|
|
363
|
+
<${SpeakerLine} label=${label} color=${color} cols=${cols} ts=${ts} />
|
|
364
|
+
<${Box} paddingX=${2}>
|
|
365
|
+
<${Text} color=${bodyColor}>${text}<//>
|
|
366
|
+
<//>
|
|
367
|
+
<//>`;
|
|
368
|
+
}
|
|
369
|
+
|
|
313
370
|
export function HelpOverlay({ cols = 80, ts }) {
|
|
314
371
|
return html`
|
|
315
372
|
<${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1} width=${cols}>
|
|
316
373
|
<${SpeakerLine} label="? Help" color=${C.accent} cols=${Math.max(10, cols - 2)} ts=${ts} />
|
|
317
|
-
${HELP_ROWS.map((
|
|
318
|
-
<${Box} key=${
|
|
319
|
-
<${Text} color=${C.
|
|
320
|
-
<${Text} color=${C.muted}>${desc}<//>
|
|
374
|
+
${HELP_ROWS.map((line, i) => html`
|
|
375
|
+
<${Box} key=${'help-' + i} paddingX=${1}>
|
|
376
|
+
<${Text} color=${i === 0 || i === HELP_ROWS.length - 1 || /├|┬|┴/.test(line) ? C.muted : C.white}>${line}<//>
|
|
321
377
|
<//>`)}
|
|
322
378
|
<//>`;
|
|
323
379
|
}
|
|
@@ -341,6 +397,62 @@ export function SearchOverlay({ query = '', matches = [], active = 0, cols = 80
|
|
|
341
397
|
<//>`;
|
|
342
398
|
}
|
|
343
399
|
|
|
400
|
+
export function SessionPicker({ sessions = [], sel = 0, cols = 80, sort = 'relevance' }) {
|
|
401
|
+
return html`
|
|
402
|
+
<${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1} width=${cols}>
|
|
403
|
+
<${Text} color=${C.accent} bold>${glyphs.arrow} Resume session<//>
|
|
404
|
+
<${Text} color=${C.muted}>Sort: ${sort} · Enter open · d delete · s sort · Esc close<//>
|
|
405
|
+
<${Box} marginTop=${1}>
|
|
406
|
+
<${Text} color=${C.muted} bold>${'Name'.padEnd(24)} ${'Path'.padEnd(Math.max(12, cols - 54))} ${'Updated'.padEnd(18)} Msgs<//>
|
|
407
|
+
<//>
|
|
408
|
+
${sessions.length
|
|
409
|
+
? sessions.map((session, i) => {
|
|
410
|
+
const on = i === sel;
|
|
411
|
+
const name = String(session.name || 'Untitled session').slice(0, 24).padEnd(24);
|
|
412
|
+
const path = String(session.cwd || '').slice(0, Math.max(12, cols - 54)).padEnd(Math.max(12, cols - 54));
|
|
413
|
+
const updated = formatDate(session.updatedAt).slice(0, 18).padEnd(18);
|
|
414
|
+
return html`
|
|
415
|
+
<${Box} key=${session.id}>
|
|
416
|
+
<${Text} color=${on ? C.accent : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
|
|
417
|
+
<${Text} color=${on ? C.white : C.brand} bold=${on}>${name}<//>
|
|
418
|
+
<${Text} color=${C.muted}> ${path} ${updated} ${String(session.messageCount || 0).padStart(4)}<//>
|
|
419
|
+
<//>`;
|
|
420
|
+
})
|
|
421
|
+
: html`<${Text} color=${C.muted}>No saved sessions.<//>`}
|
|
422
|
+
<//>`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function DiffViewer({ diff = [], sel = 0, staged = false, whitespace = false, cols = 80, height = 18, commentCount = 0 }) {
|
|
426
|
+
const maxVisible = Math.max(6, height);
|
|
427
|
+
const start = Math.max(0, Math.min(sel - Math.floor(maxVisible / 2), Math.max(0, diff.length - maxVisible)));
|
|
428
|
+
const visible = diff.slice(start, start + maxVisible);
|
|
429
|
+
return html`
|
|
430
|
+
<${Box} flexDirection="column" marginTop=${1} borderStyle=${borderStyle()} borderColor=${C.accent} paddingX=${1} width=${cols}>
|
|
431
|
+
<${Text} color=${C.accent} bold>${glyphs.expand} Diff viewer<//>
|
|
432
|
+
<${Text} color=${C.muted}>${staged ? 'staged (git diff --cached)' : 'unstaged (git diff)'} · whitespace ${whitespace ? 'ignored' : 'shown'} · comments ${commentCount}<//>
|
|
433
|
+
${visible.map((line, i) => {
|
|
434
|
+
const idx = start + i;
|
|
435
|
+
const on = idx === sel;
|
|
436
|
+
const prefix = String(line.content || '').slice(0, Math.max(1, cols - 6));
|
|
437
|
+
const color = line.type === 'add'
|
|
438
|
+
? C.success
|
|
439
|
+
: line.type === 'del'
|
|
440
|
+
? C.error
|
|
441
|
+
: line.type === 'header'
|
|
442
|
+
? C.accent
|
|
443
|
+
: line.type === 'hunk'
|
|
444
|
+
? C.muted
|
|
445
|
+
: C.white;
|
|
446
|
+
return html`
|
|
447
|
+
<${Box} key=${String(idx) + prefix}>
|
|
448
|
+
<${Text} color=${on ? C.warning : C.muted}>${on ? glyphs.prompt + ' ' : ' '}<//>
|
|
449
|
+
<${Text} color=${color} bold=${line.type === 'header'} inverse=${on}>${prefix}<//>
|
|
450
|
+
<//>`;
|
|
451
|
+
})}
|
|
452
|
+
<${Text} color=${C.muted}>j/k move PgUp/PgDn scroll h/l file g/G ends b staged w whitespace c comment Enter submit Esc close<//>
|
|
453
|
+
<//>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
344
456
|
export function Thinking({ label = 'Working', startedAt }) {
|
|
345
457
|
const [, tick] = useState(0);
|
|
346
458
|
useEffect(() => {
|
package/src/tui/composer.js
CHANGED
|
@@ -85,11 +85,15 @@ export function Composer({
|
|
|
85
85
|
onSearch,
|
|
86
86
|
onNotice,
|
|
87
87
|
onEmptyEsc,
|
|
88
|
+
onActivateSession,
|
|
88
89
|
onValueChange,
|
|
89
90
|
busy,
|
|
90
91
|
blocked = false,
|
|
91
92
|
width = 80,
|
|
92
93
|
mode = 'ask',
|
|
94
|
+
activeTab = 'session',
|
|
95
|
+
presetText = '',
|
|
96
|
+
presetToken = 0,
|
|
93
97
|
clearToken = 0,
|
|
94
98
|
}) {
|
|
95
99
|
const [value, setValue] = useState('');
|
|
@@ -124,6 +128,16 @@ export function Composer({
|
|
|
124
128
|
setHistSearchSel(0);
|
|
125
129
|
}, [clearToken]);
|
|
126
130
|
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!presetToken) return;
|
|
133
|
+
setValue(String(presetText || ''));
|
|
134
|
+
setCursor(String(presetText || '').length);
|
|
135
|
+
setHistIdx(-1);
|
|
136
|
+
setHistSearch(false);
|
|
137
|
+
setHistSearchQ('');
|
|
138
|
+
setHistSearchSel(0);
|
|
139
|
+
}, [presetText, presetToken]);
|
|
140
|
+
|
|
127
141
|
useEffect(() => {
|
|
128
142
|
onValueChange?.(value);
|
|
129
143
|
}, [value, onValueChange]);
|
|
@@ -222,6 +236,10 @@ export function Composer({
|
|
|
222
236
|
}
|
|
223
237
|
if (blocked) return;
|
|
224
238
|
if (busy) return;
|
|
239
|
+
if (activeTab !== 'session' && enter) {
|
|
240
|
+
onActivateSession?.();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
225
243
|
|
|
226
244
|
if (histSearch) {
|
|
227
245
|
if (key.escape) { setHistSearch(false); setHistSearchQ(''); setHistSearchSel(0); return; }
|
|
@@ -465,6 +483,7 @@ export function Composer({
|
|
|
465
483
|
|
|
466
484
|
<${Box} width=${width} paddingX=${1}>
|
|
467
485
|
<${Text} color=${promptColor} bold>${promptGlyph} <//>
|
|
486
|
+
${busy ? html`<${Text} color=${C.muted}>(working…) <//>` : null}
|
|
468
487
|
${modePrefix ? html`<${Text} color=${mode === 'shell' ? C.warning : C.accent} bold>${modePrefix}<//>` : null}
|
|
469
488
|
${empty
|
|
470
489
|
? html`<${Box} flexGrow=${1}>
|