koneck 2.21.1 → 2.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +26 -0
- package/dist/engine.js.map +1 -1
- package/dist/ink-chat.d.ts.map +1 -1
- package/dist/ink-chat.js +323 -15
- package/dist/ink-chat.js.map +1 -1
- package/dist/session-bus.d.ts +112 -0
- package/dist/session-bus.d.ts.map +1 -0
- package/dist/session-bus.js +280 -0
- package/dist/session-bus.js.map +1 -0
- package/package.json +1 -1
package/dist/ink-chat.js
CHANGED
|
@@ -4,12 +4,13 @@ import { Box, Static, Text, render, useApp, useInput } from 'ink';
|
|
|
4
4
|
import { execa } from 'execa';
|
|
5
5
|
import { createAgentSession, buildClient } from './engine.js';
|
|
6
6
|
import { estimateCost, formatCost } from './pricing.js';
|
|
7
|
-
import { generateSessionId, saveSession, listSessions } from './session.js';
|
|
7
|
+
import { generateSessionId, saveSession, listSessions, loadSession } from './session.js';
|
|
8
8
|
import { loadMemory } from './memory.js';
|
|
9
9
|
import { loadKoneckConfig, saveKoneckConfig, setConfigKey, configKeyDescriptions, CONFIG_FILE } from './config-store.js';
|
|
10
10
|
import { PROVIDERS, resolveProvider, getApiKey, addUserProvider, userProvidersPath } from './providers.js';
|
|
11
11
|
import { listCheckpoints, revertCheckpoint } from './checkpoint.js';
|
|
12
12
|
import { Markdown, Panel } from './markdown-ink.js';
|
|
13
|
+
import { startBus, listSessions as listBusSessions, resolveTarget, deliver, } from './session-bus.js';
|
|
13
14
|
const CYAN = '#54D7E7';
|
|
14
15
|
const GREEN = '#9BCB8F';
|
|
15
16
|
const CRIMSON = '#E8878D';
|
|
@@ -209,6 +210,9 @@ const COMMANDS = [
|
|
|
209
210
|
{ cmd: '/provider', desc: 'Show or switch provider' },
|
|
210
211
|
{ cmd: '/status', desc: 'Provider, model, mode, tokens, cost, workspace' },
|
|
211
212
|
{ cmd: '/revert', desc: 'Undo file edits KONECK made' },
|
|
213
|
+
{ cmd: '/sessions', desc: 'List running KONECK sessions and their state' },
|
|
214
|
+
{ cmd: '/send', desc: 'Hand work to another session: /send <repo> <instruction>' },
|
|
215
|
+
{ cmd: '/ask', desc: 'Ask another session and wait: /ask <repo> <question>' },
|
|
212
216
|
{ cmd: '/agents', desc: 'How parallel sub-agents are configured' },
|
|
213
217
|
{ cmd: '/subtask', desc: 'Run a task in an isolated sub-agent' },
|
|
214
218
|
{ cmd: '/clear', desc: 'Clear conversation history' },
|
|
@@ -225,7 +229,8 @@ const COMMANDS = [
|
|
|
225
229
|
{ cmd: '/rewind', desc: 'Drop the last exchange from history' },
|
|
226
230
|
{ cmd: '/copy', desc: 'Copy the last response to the clipboard' },
|
|
227
231
|
{ cmd: '/save', desc: 'Save session to disk' },
|
|
228
|
-
{ cmd: '/
|
|
232
|
+
{ cmd: '/history', desc: 'List saved sessions on disk' },
|
|
233
|
+
{ cmd: '/resume', desc: 'Resume a saved session (picker, or /resume <id>)' },
|
|
229
234
|
{ cmd: '/exit', desc: 'Save and exit' },
|
|
230
235
|
{ cmd: '/quit', desc: 'Save and exit (alias of /exit)' },
|
|
231
236
|
];
|
|
@@ -352,12 +357,17 @@ function App({ config: initialConfig }) {
|
|
|
352
357
|
}))[0];
|
|
353
358
|
const [rows, setRows] = useState([{ role: 'header' }]);
|
|
354
359
|
const [draft, setDraft] = useState('');
|
|
360
|
+
/** Caret offset into `draft`. Kept beside it so every edit can place the caret deliberately. */
|
|
361
|
+
const [caret, setCaret] = useState(0);
|
|
355
362
|
const [busy, setBusy] = useState(false);
|
|
356
363
|
const [agentState, setAgentState] = useState('ready');
|
|
357
364
|
const [mode, setMode] = useState('auto');
|
|
358
365
|
const [effort, setEffort] = useState('medium');
|
|
359
366
|
const [showAnalytics, setShowAnalytics] = useState(false);
|
|
360
367
|
const [sessionId] = useState(() => generateSessionId());
|
|
368
|
+
/** Messages that arrived while a turn was running; drained when the agent goes idle. */
|
|
369
|
+
const inboxRef = useRef([]);
|
|
370
|
+
const busRef = useRef(null);
|
|
361
371
|
const [stats, setStats] = useState({ turns: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 });
|
|
362
372
|
const [lastAgentText, setLastAgentText] = useState('');
|
|
363
373
|
// Masked credential entry. The value is held in component state for the session and passed
|
|
@@ -368,6 +378,7 @@ function App({ config: initialConfig }) {
|
|
|
368
378
|
const [pickIndex, setPickIndex] = useState(0);
|
|
369
379
|
const [pickQuery, setPickQuery] = useState('');
|
|
370
380
|
const visiblePickerItems = React.useMemo(() => (picker ? filterPickerItems(picker.items, pickQuery) : []), [picker, pickQuery]);
|
|
381
|
+
const [inboxTick, setInboxTick] = useState(0);
|
|
371
382
|
const [spinFrame, setSpinFrame] = useState(0);
|
|
372
383
|
const [shimmerFrame, setShimmerFrame] = useState(0);
|
|
373
384
|
const [spinWord, setSpinWord] = useState(0);
|
|
@@ -376,6 +387,55 @@ function App({ config: initialConfig }) {
|
|
|
376
387
|
const turnRecoveredRef = useRef(false); // a tool failed and was worked around
|
|
377
388
|
const busyStart = useRef(0);
|
|
378
389
|
const wordTimer = useRef(0);
|
|
390
|
+
// Register on the session bus so other running KONECK instances can reach this one.
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
const bus = startBus({ id: sessionId, cwd: cfg.cwd, model: cfg.model, provider: cfg.provider }, {
|
|
393
|
+
onSend: (from, payload) => {
|
|
394
|
+
// Never interrupt a turn: queue it and let the idle drain pick it up.
|
|
395
|
+
inboxRef.current.push({ from, payload });
|
|
396
|
+
process.stdout.write('\u0007'); // a bell, so an unattended session is noticed
|
|
397
|
+
setInboxTick(t => t + 1);
|
|
398
|
+
},
|
|
399
|
+
onAsk: async (from, question) => {
|
|
400
|
+
// A question is answered from this session's own context, in its own sub-session, so
|
|
401
|
+
// the asking side gets a real answer without this session losing its place.
|
|
402
|
+
const active = await getSession();
|
|
403
|
+
const before = active.messages.length;
|
|
404
|
+
try {
|
|
405
|
+
await active.send(`[Question from the KONECK session in ${from.repoName} (${from.cwd})]\n${question}\n\n` +
|
|
406
|
+
'Answer concisely from what you know about this project. Do not modify any file.');
|
|
407
|
+
const last = active.messages
|
|
408
|
+
.filter(m => m.role === 'assistant' && typeof m.content === 'string' && m.content.trim())
|
|
409
|
+
.at(-1);
|
|
410
|
+
return typeof last?.content === 'string' ? last.content.trim() : '(no answer)';
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
// The exchange is not part of this session's own conversation.
|
|
414
|
+
active.messages.splice(before);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
busRef.current = bus;
|
|
419
|
+
return () => bus?.close();
|
|
420
|
+
}, []);
|
|
421
|
+
// The bus only knows idle from busy if it is told.
|
|
422
|
+
useEffect(() => { busRef.current?.setStatus(busy ? 'busy' : 'idle'); }, [busy]);
|
|
423
|
+
// Drain the inbox the moment the agent is free, so a handoff is never lost or interleaved.
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (busy || inboxRef.current.length === 0)
|
|
426
|
+
return;
|
|
427
|
+
const queued = inboxRef.current.splice(0);
|
|
428
|
+
for (const { from, payload } of queued) {
|
|
429
|
+
addRow({ role: 'steps', steps: [{ kind: 'say',
|
|
430
|
+
text: `Handoff from ${from.repoName} (${from.cwd})\n\n${payload.summary}` +
|
|
431
|
+
(payload.changedFiles.length ? `\n\nChanged: ${payload.changedFiles.join(', ')}` : '') }] });
|
|
432
|
+
}
|
|
433
|
+
const combined = queued.map(q => `[Handoff from the KONECK session in ${q.from.repoName} (${q.from.cwd})]\n` +
|
|
434
|
+
`Context: ${q.payload.summary}\n` +
|
|
435
|
+
(q.payload.changedFiles.length ? `Files they changed: ${q.payload.changedFiles.join(', ')}\n` : '') +
|
|
436
|
+
`Your task: ${q.payload.instruction}`).join('\n\n');
|
|
437
|
+
submitDraft(combined);
|
|
438
|
+
}, [busy, inboxTick]);
|
|
379
439
|
// Drive spinner + elapsed counter while busy
|
|
380
440
|
useEffect(() => {
|
|
381
441
|
if (!busy) {
|
|
@@ -420,6 +480,75 @@ function App({ config: initialConfig }) {
|
|
|
420
480
|
turns: stats.turns, totalTokens: stats.totalTokens,
|
|
421
481
|
}, s?.messages ?? []);
|
|
422
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Compresses this session's state into something worth sending. One small model call, capped
|
|
485
|
+
* hard, because the point of a handoff is to save the receiver the reading, not to move the
|
|
486
|
+
* transcript across the wire.
|
|
487
|
+
*/
|
|
488
|
+
async function buildHandoff(instruction) {
|
|
489
|
+
const changed = [...new Set(rows.flatMap(r => (r.steps ?? [])
|
|
490
|
+
.filter((st) => st.kind === 'tool')
|
|
491
|
+
.filter(st => ['write_file', 'search_replace', 'apply_diff', 'move_file', 'delete_file'].includes(st.name))
|
|
492
|
+
.map(st => st.detail)
|
|
493
|
+
.filter(Boolean)))].slice(0, 12);
|
|
494
|
+
const transcript = rows
|
|
495
|
+
.filter(r => r.role === 'user' || r.role === 'agent')
|
|
496
|
+
.slice(-8)
|
|
497
|
+
.map(r => `${r.role === 'user' ? 'User' : 'Agent'}: ${(r.text ?? '').slice(0, 400)}`)
|
|
498
|
+
.join('\n');
|
|
499
|
+
let summary = transcript ? transcript.slice(0, 600) : 'No work has been done in this session yet.';
|
|
500
|
+
if (transcript.trim() !== '') {
|
|
501
|
+
try {
|
|
502
|
+
const client = buildClient(cfg);
|
|
503
|
+
const res = await client.chat.completions.create({
|
|
504
|
+
model: cfg.model,
|
|
505
|
+
max_tokens: 220,
|
|
506
|
+
temperature: 0,
|
|
507
|
+
messages: [{ role: 'user', content: 'Summarise this coding session in 2-3 sentences for a colleague picking up related ' +
|
|
508
|
+
'work in another repository. State what was done and anything they must know. No preamble.\n\n' +
|
|
509
|
+
transcript }],
|
|
510
|
+
});
|
|
511
|
+
const text = res.choices[0]?.message?.content;
|
|
512
|
+
if (typeof text === 'string' && text.trim())
|
|
513
|
+
summary = text.trim();
|
|
514
|
+
}
|
|
515
|
+
catch { /* the raw tail is a serviceable fallback */ }
|
|
516
|
+
}
|
|
517
|
+
return { summary, changedFiles: changed, instruction };
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Replaces the live conversation with a saved one. The agent session is rebuilt from the
|
|
521
|
+
* stored messages, so the model genuinely has the old context rather than a summary of it,
|
|
522
|
+
* and the transcript is repainted so the screen matches what the agent now believes.
|
|
523
|
+
*/
|
|
524
|
+
async function resumeSession(id) {
|
|
525
|
+
try {
|
|
526
|
+
const { meta, messages } = await loadSession(cfg.cwd, id);
|
|
527
|
+
activeSession.p = createAgentSession({ ...cfg, silent: true, ...sessionOpts }, messages);
|
|
528
|
+
const restored = [{ role: 'header' }];
|
|
529
|
+
for (const m of messages) {
|
|
530
|
+
if (m.role === 'user' && typeof m.content === 'string' && !m.content.startsWith('[')) {
|
|
531
|
+
restored.push({ role: 'user', text: m.content });
|
|
532
|
+
}
|
|
533
|
+
else if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
|
|
534
|
+
restored.push({ role: 'agent', text: m.content });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
setRows(restored);
|
|
538
|
+
setStats({
|
|
539
|
+
turns: meta.turns, promptTokens: 0,
|
|
540
|
+
completionTokens: 0, totalTokens: meta.totalTokens,
|
|
541
|
+
});
|
|
542
|
+
addSystem(`Resumed ${id}\n` +
|
|
543
|
+
` ${meta.turns} turns, ${meta.totalTokens.toLocaleString()} tokens\n` +
|
|
544
|
+
` originally: ${meta.provider}/${meta.model}\n` +
|
|
545
|
+
` task: ${meta.task.slice(0, 90)}\n\n` +
|
|
546
|
+
`The agent has the full history; carry on where it left off.`);
|
|
547
|
+
}
|
|
548
|
+
catch (e) {
|
|
549
|
+
addSystem(`Could not resume ${id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
423
552
|
/** Live model list from the connected provider's OpenAI-compatible /models endpoint. */
|
|
424
553
|
async function fetchModels() {
|
|
425
554
|
const client = buildClient(cfg);
|
|
@@ -459,6 +588,11 @@ function App({ config: initialConfig }) {
|
|
|
459
588
|
if (kind === 'command') {
|
|
460
589
|
// Leave it in the composer so arguments can still be typed before submitting.
|
|
461
590
|
setDraft(item.value + ' ');
|
|
591
|
+
setCaret(item.value.length + 1);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (kind === 'resume') {
|
|
595
|
+
await resumeSession(item.value);
|
|
462
596
|
return;
|
|
463
597
|
}
|
|
464
598
|
if (kind === 'effort') {
|
|
@@ -681,7 +815,31 @@ function App({ config: initialConfig }) {
|
|
|
681
815
|
addSystem(`Saved: ${file}`);
|
|
682
816
|
return;
|
|
683
817
|
}
|
|
684
|
-
case '/
|
|
818
|
+
case '/resume': {
|
|
819
|
+
const saved = await listSessions(cfg.cwd).catch(() => []);
|
|
820
|
+
if (saved.length === 0) {
|
|
821
|
+
addSystem('No saved sessions in .koneck/sessions/ for this workspace.');
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
// With an id, restore straight away; without one, offer a picker.
|
|
825
|
+
const pick = arg.trim();
|
|
826
|
+
if (pick === '') {
|
|
827
|
+
openPicker('resume', saved.slice(0, 20).map(x => ({
|
|
828
|
+
value: x.id,
|
|
829
|
+
label: x.id,
|
|
830
|
+
desc: `${x.turns} turns ${x.totalTokens.toLocaleString()} tok ${x.task.slice(0, 44)}`,
|
|
831
|
+
})));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const chosen = saved.find(x => x.id === pick) ?? saved.find(x => x.id.startsWith(pick));
|
|
835
|
+
if (!chosen) {
|
|
836
|
+
addSystem(`No saved session matching "${pick}". /history lists them.`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
await resumeSession(chosen.id);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
case '/history': {
|
|
685
843
|
const sessions = await listSessions(cfg.cwd).catch(() => []);
|
|
686
844
|
if (!sessions.length) {
|
|
687
845
|
addSystem('No sessions found.');
|
|
@@ -797,6 +955,73 @@ function App({ config: initialConfig }) {
|
|
|
797
955
|
setKeyPrompt({ provider: cfg.provider, env: def?.apiKeyEnv ?? 'KONECK_API_KEY', value: '' });
|
|
798
956
|
return;
|
|
799
957
|
}
|
|
958
|
+
case '/sessions': {
|
|
959
|
+
const live = listBusSessions();
|
|
960
|
+
if (live.length === 0) {
|
|
961
|
+
addSystem('No KONECK sessions are registered.');
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const rows = live.map(x => {
|
|
965
|
+
const me = x.id === sessionId ? ' (this one)' : '';
|
|
966
|
+
// Kept short so the row cannot wrap and tear the column alignment.
|
|
967
|
+
const full = x.cwd.replace(process.env['HOME'] ?? '', '~');
|
|
968
|
+
const parts = full.split('/').filter(Boolean);
|
|
969
|
+
const where = full.length <= 30 || parts.length <= 2 ? full : '.../' + parts.slice(-2).join('/');
|
|
970
|
+
return `${('#' + x.id).padEnd(22)} ${x.repoName.slice(0, 16).padEnd(17)} ${x.status.toUpperCase().padEnd(5)} ${where}${me}`;
|
|
971
|
+
});
|
|
972
|
+
addSystem(`${'SESSION'.padEnd(22)} ${'REPO'.padEnd(17)} ${'STATE'.padEnd(5)} DIRECTORY\n` +
|
|
973
|
+
rows.join('\n') +
|
|
974
|
+
`\n\nAddress a session by repo or folder name, not its id:\n` +
|
|
975
|
+
` /send ${live.find(x => x.id !== sessionId)?.repoName ?? '<repo>'} <instruction>\n` +
|
|
976
|
+
` /ask ${live.find(x => x.id !== sessionId)?.repoName ?? '<repo>'} <question>`);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
case '/send':
|
|
980
|
+
case '/ask': {
|
|
981
|
+
const twoWay = cmd === '/ask';
|
|
982
|
+
const gap = arg.indexOf(' ');
|
|
983
|
+
if (gap === -1) {
|
|
984
|
+
addSystem(`Usage: ${cmd} <target> <${twoWay ? 'question' : 'instruction'}>\n` +
|
|
985
|
+
`Target is a repo or folder name, or #id. /sessions lists them.`);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const target = arg.slice(0, gap);
|
|
989
|
+
const body = arg.slice(gap + 1).trim();
|
|
990
|
+
if (body === '') {
|
|
991
|
+
addSystem(`Nothing to ${twoWay ? 'ask' : 'send'}.`);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const found = resolveTarget(target, sessionId);
|
|
995
|
+
if (!found.ok) {
|
|
996
|
+
addSystem(found.error);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
const bus = busRef.current;
|
|
1000
|
+
if (!bus) {
|
|
1001
|
+
addSystem('This session is not on the bus, so it cannot send.');
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const from = { id: sessionId, repoName: bus.record.repoName, cwd: cfg.cwd };
|
|
1005
|
+
if (twoWay) {
|
|
1006
|
+
addSystem(`Asking ${found.session.repoName}: "${body}"\nWaiting for its answer…`);
|
|
1007
|
+
const reply = await deliver(found.session, { kind: 'ask', token: found.session.authToken, from, question: body });
|
|
1008
|
+
addSystem(reply.ok
|
|
1009
|
+
? `${found.session.repoName} replied:\n\n${reply.answer ?? '(empty)'}`
|
|
1010
|
+
: `${found.session.repoName} could not answer: ${reply.error}`);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
// A handoff carries a summary, never the transcript: the receiving session pays for
|
|
1014
|
+
// every token, and a raw history mostly restates what this one already worked out.
|
|
1015
|
+
const payload = await buildHandoff(body);
|
|
1016
|
+
const reply = await deliver(found.session, { kind: 'send', token: found.session.authToken, from, instruction: body, payload });
|
|
1017
|
+
addSystem(reply.ok
|
|
1018
|
+
? `Handed off to ${found.session.repoName} (${found.session.status}).\n` +
|
|
1019
|
+
(found.session.status === 'busy'
|
|
1020
|
+
? 'It is mid-task, so the work is queued and starts when it finishes.'
|
|
1021
|
+
: 'It picks the work up immediately.')
|
|
1022
|
+
: `Could not reach ${found.session.repoName}: ${reply.error}`);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
800
1025
|
case '/agents': {
|
|
801
1026
|
// spawn_agents is a tool the model calls itself; this reports how it is configured
|
|
802
1027
|
// rather than pretending to be a separate agent registry.
|
|
@@ -923,8 +1148,10 @@ function App({ config: initialConfig }) {
|
|
|
923
1148
|
if (key.escape) {
|
|
924
1149
|
// The command palette owns the composer while open, so cancelling clears the "/…"
|
|
925
1150
|
// it put there rather than leaving an orphan the user has to backspace away.
|
|
926
|
-
if (picker.kind === 'command')
|
|
1151
|
+
if (picker.kind === 'command') {
|
|
927
1152
|
setDraft('');
|
|
1153
|
+
setCaret(0);
|
|
1154
|
+
}
|
|
928
1155
|
closePicker();
|
|
929
1156
|
return;
|
|
930
1157
|
}
|
|
@@ -954,12 +1181,16 @@ function App({ config: initialConfig }) {
|
|
|
954
1181
|
if (picker.kind === 'command' && pickQuery === '') {
|
|
955
1182
|
closePicker();
|
|
956
1183
|
setDraft('');
|
|
1184
|
+
setCaret(0);
|
|
957
1185
|
return;
|
|
958
1186
|
}
|
|
959
1187
|
setPickQuery(q => q.slice(0, -1));
|
|
960
1188
|
setPickIndex(0);
|
|
961
|
-
if (picker.kind === 'command')
|
|
962
|
-
|
|
1189
|
+
if (picker.kind === 'command') {
|
|
1190
|
+
const t = '/' + pickQuery.slice(0, -1);
|
|
1191
|
+
setDraft(t);
|
|
1192
|
+
setCaret(t.length);
|
|
1193
|
+
}
|
|
963
1194
|
return;
|
|
964
1195
|
}
|
|
965
1196
|
// A space means the command name is finished and arguments follow, so the palette
|
|
@@ -967,14 +1198,17 @@ function App({ config: initialConfig }) {
|
|
|
967
1198
|
// the list, and left Enter with nothing to select.
|
|
968
1199
|
if (input === ' ' && picker.kind === 'command') {
|
|
969
1200
|
closePicker();
|
|
970
|
-
setDraft(d =>
|
|
1201
|
+
setDraft(d => { const t = d.endsWith(' ') ? d : d + ' '; setCaret(t.length); return t; });
|
|
971
1202
|
return;
|
|
972
1203
|
}
|
|
973
1204
|
if (!key.ctrl && !key.meta && input) {
|
|
974
1205
|
setPickQuery(q => q + input);
|
|
975
1206
|
setPickIndex(0);
|
|
976
|
-
if (picker.kind === 'command')
|
|
977
|
-
|
|
1207
|
+
if (picker.kind === 'command') {
|
|
1208
|
+
const t = '/' + pickQuery + input;
|
|
1209
|
+
setDraft(t);
|
|
1210
|
+
setCaret(t.length);
|
|
1211
|
+
}
|
|
978
1212
|
return;
|
|
979
1213
|
}
|
|
980
1214
|
return;
|
|
@@ -998,19 +1232,87 @@ function App({ config: initialConfig }) {
|
|
|
998
1232
|
submitDraft(draft);
|
|
999
1233
|
return;
|
|
1000
1234
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1235
|
+
// ── Line editing ────────────────────────────────────────────────────────
|
|
1236
|
+
// The composer used to be append-and-backspace only, so fixing a typo near the start of a
|
|
1237
|
+
// long prompt meant deleting everything after it. Everything below is the readline set a
|
|
1238
|
+
// shell gives you, so an edit costs one keystroke rather than a retype.
|
|
1239
|
+
if (key.leftArrow) {
|
|
1240
|
+
setCaret(c => (key.ctrl || key.meta ? wordStart(draft, c) : Math.max(0, c - 1)));
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
if (key.rightArrow) {
|
|
1244
|
+
setCaret(c => (key.ctrl || key.meta ? wordEnd(draft, c) : Math.min(draft.length, c + 1)));
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
// ctrl+a / ctrl+e are line start and end, as in bash. Home/End arrive as these on most
|
|
1248
|
+
// terminals, so both spellings land here.
|
|
1249
|
+
if (key.ctrl && input === 'a') {
|
|
1250
|
+
setCaret(0);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (key.ctrl && input === 'e') {
|
|
1254
|
+
setCaret(draft.length);
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
// ctrl+u clears to line start, ctrl+k to line end, ctrl+w deletes the word behind.
|
|
1258
|
+
if (key.ctrl && input === 'u') {
|
|
1259
|
+
setDraft(d => d.slice(caret));
|
|
1260
|
+
setCaret(0);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (key.ctrl && input === 'k') {
|
|
1264
|
+
setDraft(d => d.slice(0, caret));
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
if (key.ctrl && input === 'w') {
|
|
1268
|
+
const from = wordStart(draft, caret);
|
|
1269
|
+
setDraft(d => d.slice(0, from) + d.slice(caret));
|
|
1270
|
+
setCaret(from);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (key.backspace) {
|
|
1274
|
+
if (caret === 0)
|
|
1275
|
+
return;
|
|
1276
|
+
setDraft(d => d.slice(0, caret - 1) + d.slice(caret));
|
|
1277
|
+
setCaret(c => Math.max(0, c - 1));
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (key.delete) { // forward delete leaves the caret put
|
|
1281
|
+
setDraft(d => d.slice(0, caret) + d.slice(caret + 1));
|
|
1003
1282
|
return;
|
|
1004
1283
|
}
|
|
1005
1284
|
// Typing "/" on an empty composer opens the command palette.
|
|
1006
1285
|
if (input === '/' && draft === '') {
|
|
1007
1286
|
setDraft('/');
|
|
1287
|
+
setCaret(1);
|
|
1008
1288
|
openPicker('command', COMMANDS.map(c => ({ value: c.cmd, label: c.cmd, desc: c.desc })));
|
|
1009
1289
|
return;
|
|
1010
1290
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1291
|
+
// Insert at the caret rather than appending, so typing mid-string works. A paste arrives as
|
|
1292
|
+
// one chunk and is inserted whole.
|
|
1293
|
+
if (!key.ctrl && !key.meta && input) {
|
|
1294
|
+
setDraft(d => d.slice(0, caret) + input + d.slice(caret));
|
|
1295
|
+
setCaret(c => c + input.length);
|
|
1296
|
+
}
|
|
1013
1297
|
});
|
|
1298
|
+
/** Start of the word at or before `i`, for word-wise movement and deletion. */
|
|
1299
|
+
function wordStart(text, i) {
|
|
1300
|
+
let j = i;
|
|
1301
|
+
while (j > 0 && /\s/.test(text[j - 1] ?? ''))
|
|
1302
|
+
j--; // skip the gap
|
|
1303
|
+
while (j > 0 && !/\s/.test(text[j - 1] ?? ''))
|
|
1304
|
+
j--; // then the word
|
|
1305
|
+
return j;
|
|
1306
|
+
}
|
|
1307
|
+
/** Start of the word after `i`. */
|
|
1308
|
+
function wordEnd(text, i) {
|
|
1309
|
+
let j = i;
|
|
1310
|
+
while (j < text.length && !/\s/.test(text[j] ?? ''))
|
|
1311
|
+
j++;
|
|
1312
|
+
while (j < text.length && /\s/.test(text[j] ?? ''))
|
|
1313
|
+
j++;
|
|
1314
|
+
return j;
|
|
1315
|
+
}
|
|
1014
1316
|
/** Sends the composer contents: a slash command, or a task for the agent. */
|
|
1015
1317
|
function submitDraft(raw) {
|
|
1016
1318
|
{
|
|
@@ -1018,6 +1320,7 @@ function App({ config: initialConfig }) {
|
|
|
1018
1320
|
if (!task)
|
|
1019
1321
|
return;
|
|
1020
1322
|
setDraft('');
|
|
1323
|
+
setCaret(0);
|
|
1021
1324
|
addRow({ role: 'user', text: task });
|
|
1022
1325
|
if (task.startsWith('/')) {
|
|
1023
1326
|
const spaceIdx = task.indexOf(' ');
|
|
@@ -1152,7 +1455,8 @@ function App({ config: initialConfig }) {
|
|
|
1152
1455
|
const title = picker.kind === 'command' ? 'Commands'
|
|
1153
1456
|
: picker.kind === 'model' ? `Models - ${cfg.provider}`
|
|
1154
1457
|
: picker.kind === 'effort' ? 'Reasoning effort'
|
|
1155
|
-
: '
|
|
1458
|
+
: picker.kind === 'resume' ? 'Resume a saved session'
|
|
1459
|
+
: 'Connect provider';
|
|
1156
1460
|
let lastGroup;
|
|
1157
1461
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: CYAN, bold: true, children: title }), _jsxs(Text, { color: DIM, children: [list.length, " match", list.length === 1 ? '' : 'es', " \u00B7 esc to close"] })] }), list.length === 0 && _jsxs(Text, { color: MUTED, children: ["No match for \"", pickQuery, "\""] }), shown.map((item, i) => {
|
|
1158
1462
|
const absolute = Math.max(0, start) + i;
|
|
@@ -1163,7 +1467,11 @@ function App({ config: initialConfig }) {
|
|
|
1163
1467
|
return (_jsxs(Box, { flexDirection: "column", children: [header && _jsx(Text, { color: DIM, children: header }), _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: (`${selected ? '❯ ' : ' '}${label.padEnd(36)}${item.current ? '(current) ' : ''}${item.desc}`)
|
|
1164
1468
|
.slice(0, width).padEnd(width) })] }, item.value + absolute));
|
|
1165
1469
|
}), list.length > PICKER_ROWS && (_jsxs(Text, { color: DIM, children: ["\u2191\u2193 navigate \u00B7 enter select \u00B7 showing ", Math.max(0, start) + 1, "\u2013", Math.max(0, start) + shown.length, " of ", list.length] }))] }));
|
|
1166
|
-
})(), keyPrompt && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: AMBER, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsxs(Text, { color: AMBER, bold: true, children: ["API key for ", keyPrompt.provider] }), _jsx(Text, { color: MUTED, children: "Paste it and press enter. Held in memory for this session only; esc to cancel." }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [keyPrompt.env, ": "] }), _jsx(Text, { color: INK, children: '*'.repeat(Math.min(keyPrompt.value.length, 48)) }), _jsx(Text, { color: CYAN, children: "\u2588" })] })] })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: "\u276F " }), _jsx(Text, { color: INK, children: draft
|
|
1470
|
+
})(), keyPrompt && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: AMBER, paddingX: 1, marginTop: 1, width: barWidth, children: [_jsxs(Text, { color: AMBER, bold: true, children: ["API key for ", keyPrompt.provider] }), _jsx(Text, { color: MUTED, children: "Paste it and press enter. Held in memory for this session only; esc to cancel." }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: MUTED, children: [keyPrompt.env, ": "] }), _jsx(Text, { color: INK, children: '*'.repeat(Math.min(keyPrompt.value.length, 48)) }), _jsx(Text, { color: CYAN, children: "\u2588" })] })] })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: "\u276F " }), _jsx(Text, { color: INK, children: draft.slice(0, caret) }), busy
|
|
1471
|
+
? _jsx(Text, { color: INK, children: draft.slice(caret) })
|
|
1472
|
+
: caret < draft.length
|
|
1473
|
+
? _jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: CYAN, color: "#10222A", children: draft[caret] }), _jsx(Text, { color: INK, children: draft.slice(caret + 1) })] })
|
|
1474
|
+
: _jsx(Text, { color: CYAN, children: "\u2588" })] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { wrap: "truncate", backgroundColor: "#24343B", children: [_jsx(Text, { color: MUTED, children: "TAB " }), _jsx(Text, { color: INK, children: "Analytics" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: CYAN, children: "/HELP" }), _jsx(Text, { color: INK, children: " Commands" }), _jsx(Text, { color: MUTED, children: " / SHIFT+TAB " }), _jsx(Text, { color: INK, children: "Mode" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: statusColor, children: statusLabel.toLowerCase() }), _jsx(Text, { color: MUTED, children: " / MODE: " }), _jsx(Text, { color: INK, children: mode }), _jsx(Text, { color: MUTED, children: " / EFFORT: " }), _jsx(Text, { color: effort === 'medium' ? INK : AMBER, children: effort }), _jsx(Text, { color: MUTED, children: " / MODEL: " }), _jsx(Text, { color: INK, children: modelShort }), _jsx(Text, { color: MUTED, children: " / PING: " }), _jsx(Text, { color: INK, children: "50ms" })] }) })] }));
|
|
1167
1475
|
}
|
|
1168
1476
|
export async function runInkChatMode(config) {
|
|
1169
1477
|
// resolveConfig has already layered CLI flags above the /config store, so the config
|