koneck 2.28.0 → 2.30.0-next.1
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 +32 -0
- package/dist/checkpoint.d.ts +20 -0
- package/dist/checkpoint.d.ts.map +1 -1
- package/dist/checkpoint.js +169 -10
- package/dist/checkpoint.js.map +1 -1
- package/dist/completion-notices.d.ts +8 -0
- package/dist/completion-notices.d.ts.map +1 -0
- package/dist/completion-notices.js +14 -0
- package/dist/completion-notices.js.map +1 -0
- package/dist/config-store.d.ts +2 -0
- package/dist/config-store.d.ts.map +1 -1
- package/dist/config-store.js +6 -2
- package/dist/config-store.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +28 -1
- package/dist/config.js.map +1 -1
- package/dist/engine.d.ts +9 -12
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +48 -26
- package/dist/engine.js.map +1 -1
- package/dist/git-diff.d.ts +16 -0
- package/dist/git-diff.d.ts.map +1 -0
- package/dist/git-diff.js +56 -0
- package/dist/git-diff.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -28
- package/dist/index.js.map +1 -1
- package/dist/ink-chat.d.ts +73 -1
- package/dist/ink-chat.d.ts.map +1 -1
- package/dist/ink-chat.js +898 -67
- package/dist/ink-chat.js.map +1 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +67 -9
- package/dist/permissions.js.map +1 -1
- package/dist/preflight.d.ts +24 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +87 -0
- package/dist/preflight.js.map +1 -0
- package/dist/refactor.d.ts +7 -3
- package/dist/refactor.d.ts.map +1 -1
- package/dist/refactor.js +122 -35
- package/dist/refactor.js.map +1 -1
- package/dist/run-limits.d.ts +9 -0
- package/dist/run-limits.d.ts.map +1 -0
- package/dist/run-limits.js +13 -0
- package/dist/run-limits.js.map +1 -0
- package/dist/session-bus.d.ts +14 -0
- package/dist/session-bus.d.ts.map +1 -1
- package/dist/session-bus.js +24 -3
- package/dist/session-bus.js.map +1 -1
- package/dist/session.d.ts +20 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +27 -7
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/web/api.d.ts.map +1 -1
- package/dist/web/api.js +37 -21
- package/dist/web/api.js.map +1 -1
- package/dist/web/events.d.ts +15 -0
- package/dist/web/events.d.ts.map +1 -1
- package/dist/web/events.js.map +1 -1
- package/dist/web/search.d.ts +12 -0
- package/dist/web/search.d.ts.map +1 -1
- package/dist/web/search.js +38 -0
- package/dist/web/search.js.map +1 -1
- package/dist/web/sessions.d.ts +61 -2
- package/dist/web/sessions.d.ts.map +1 -1
- package/dist/web/sessions.js +94 -4
- package/dist/web/sessions.js.map +1 -1
- package/dist/web/ui-client.d.ts.map +1 -1
- package/dist/web/ui-client.js +0 -0
- package/dist/web/ui-client.js.map +1 -1
- package/dist/web/ui-css.d.ts.map +1 -1
- package/dist/web/ui-css.js +22 -0
- package/dist/web/ui-css.js.map +1 -1
- package/dist/web/ui.d.ts.map +1 -1
- package/dist/web/ui.js +13 -1
- package/dist/web/ui.js.map +1 -1
- package/dist/workspace-trust.d.ts +2 -0
- package/dist/workspace-trust.d.ts.map +1 -1
- package/dist/workspace-trust.js +12 -0
- package/dist/workspace-trust.js.map +1 -1
- package/dist/worktree.d.ts +2 -0
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +68 -4
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -1
package/dist/ink-chat.js
CHANGED
|
@@ -9,9 +9,10 @@ import { loadMemory } from './memory.js';
|
|
|
9
9
|
import { loadKoneckConfig, saveKoneckConfig, setConfigKey, configKeyDescriptions, CONFIG_FILE, CONFIG_SCHEMA, stepValue, displayValue } from './config-store.js';
|
|
10
10
|
import { PROVIDERS, resolveProvider, getApiKey, addUserProvider, userProvidersPath, needsNoCredential, servesLocally } from './providers.js';
|
|
11
11
|
import { resolveEndpoint, normaliseEndpoint, withEndpoint, savedEndpoints, shortSource, resolveModelChoice, withModel } from './endpoints.js';
|
|
12
|
-
import { loadProjectConfig, loadGlobalConfig } from './config.js';
|
|
13
|
-
import { listCheckpoints, revertCheckpoint, snapshotTree, filesTouchedBy } from './checkpoint.js';
|
|
12
|
+
import { loadProjectConfig, loadGlobalConfig, saveGlobalConfig, withoutStoredApiKey } from './config.js';
|
|
13
|
+
import { listCheckpoints, revertCheckpoint, previewCheckpoint, snapshotTree, filesTouchedBy, createWorkspaceCheckpoint, checkpointScope } from './checkpoint.js';
|
|
14
14
|
import { fileDiff, diffSummary } from './diff-view.js';
|
|
15
|
+
import { untrackedPatches } from './git-diff.js';
|
|
15
16
|
import { listWorkspaceEntries, filterEntries, activeReference } from './workspace-files.js';
|
|
16
17
|
import { isSlashCommand, findMentions, shortenMentions } from './mentions.js';
|
|
17
18
|
import { bar, humanTokens, humanDuration, diffStatSince, modelInfoFrom, contextWindowFor, recordStatus, classifyResponse, stateLabel, readModelStatus, computeUiWidth, } from './usage.js';
|
|
@@ -33,7 +34,7 @@ const VERSION = (() => {
|
|
|
33
34
|
}
|
|
34
35
|
})();
|
|
35
36
|
import { startBus, listSessions as listBusSessions, resolveTarget, deliver, } from './session-bus.js';
|
|
36
|
-
import { MODES as MODE_SPECS, modeSpec, decide, loadRules, saveRules, addRule, commandPrefixSuggestion, describeRule, } from './permissions.js';
|
|
37
|
+
import { MODES as MODE_SPECS, modeSpec, decide, loadRules, saveRules, addRule, commandPrefixSuggestion, describeRule, ruleMatches, toolRisk, isReadOnlyCommand, } from './permissions.js';
|
|
37
38
|
import { AskQueue } from './ask-queue.js';
|
|
38
39
|
import { planSummary } from './plan.js';
|
|
39
40
|
import { logoFor } from './logo.js';
|
|
@@ -43,8 +44,12 @@ import { renderTrajectory } from './trajectory.js';
|
|
|
43
44
|
import { describeSandbox, resolveSandbox } from './sandbox.js';
|
|
44
45
|
import { doctorChecks, doctorReport } from './doctor.js';
|
|
45
46
|
import { runQualityGate } from './quality-gate.js';
|
|
47
|
+
import { runPreflight, preflightReport, isPreflightReady } from './preflight.js';
|
|
46
48
|
import { listAuditEvents } from './audit.js';
|
|
47
49
|
import { initializePolicy, loadPolicy, POLICY_FILE } from './policy.js';
|
|
50
|
+
import { isWorkspaceTrusted, trustWorkspace, untrustWorkspace } from './workspace-trust.js';
|
|
51
|
+
import { loadMCPConfig } from './mcp.js';
|
|
52
|
+
import { renameSymbol, extractInterface, moveSymbol } from './refactor.js';
|
|
48
53
|
const CYAN = '#54D7E7';
|
|
49
54
|
const GREEN = '#9BCB8F';
|
|
50
55
|
const CRIMSON = '#E8878D';
|
|
@@ -130,6 +135,10 @@ function fitCells(text, width, ellipsis = false) {
|
|
|
130
135
|
const head = sliceToWidth(text, Math.max(0, width - 3));
|
|
131
136
|
return head.text + '.'.repeat(Math.min(3, width)) + ' '.repeat(Math.max(0, width - head.width - 3));
|
|
132
137
|
}
|
|
138
|
+
/** A picker label uses all of its allocated column, only ellipsising at the actual boundary. */
|
|
139
|
+
export function pickerLabel(text, width) {
|
|
140
|
+
return fitCells(text, width, true);
|
|
141
|
+
}
|
|
133
142
|
/**
|
|
134
143
|
* Wraps text while keeping the shape it was written in.
|
|
135
144
|
*
|
|
@@ -258,6 +267,10 @@ export function liveReplyLines(termRows) {
|
|
|
258
267
|
const rows = termRows && termRows > 0 ? termRows : 24;
|
|
259
268
|
return rows < 12 ? 1 : 2;
|
|
260
269
|
}
|
|
270
|
+
/** Sampling cadence for live stream refs: quick enough to read, slow enough not to flash. */
|
|
271
|
+
export function liveRefreshDelay(idleForMs) {
|
|
272
|
+
return idleForMs < 2_000 ? 500 : 1_000;
|
|
273
|
+
}
|
|
261
274
|
const SPINNER = G.spinner;
|
|
262
275
|
/**
|
|
263
276
|
* A highlight that sweeps left to right through the word, so the label itself looks like it is
|
|
@@ -507,11 +520,22 @@ const COMMANDS = [
|
|
|
507
520
|
{ cmd: '/endpoint', desc: "Show or change this provider's URL, e.g. a remote Ollama" },
|
|
508
521
|
{ cmd: '/redraw', desc: 'Repaint the terminal — same as ctrl+L' },
|
|
509
522
|
{ cmd: '/key', desc: 'Enter an API key for the current provider' },
|
|
523
|
+
{ cmd: '/logout', desc: 'Forget Koneck-stored API credentials; keep preferences' },
|
|
510
524
|
{ cmd: '/model', desc: 'Show or set the model by name' },
|
|
511
525
|
{ cmd: '/provider', desc: 'Show or switch provider' },
|
|
512
526
|
{ cmd: '/status', desc: 'Provider, model, mode, tokens, cost, workspace' },
|
|
527
|
+
{ cmd: '/permissions', desc: 'Show or set approval posture: on or off' },
|
|
528
|
+
{ cmd: '/worktree', desc: 'Show or set isolated-worktree default for future CLI tasks' },
|
|
529
|
+
{ cmd: '/goal', desc: 'Set, inspect, or clear the session’s persistent outcome' },
|
|
513
530
|
{ cmd: '/doctor', desc: 'Check the active provider, workspace, and developer tools' },
|
|
514
|
-
{ cmd: '/
|
|
531
|
+
{ cmd: '/context', desc: 'Show the active model context-window source and limit' },
|
|
532
|
+
{ cmd: '/mcp', desc: 'Show configured MCP integrations and live tools' },
|
|
533
|
+
{ cmd: '/init', desc: 'Create or refresh KONECK.md project instructions' },
|
|
534
|
+
{ cmd: '/rename', desc: 'Safely rename a TypeScript symbol across the workspace' },
|
|
535
|
+
{ cmd: '/extract', desc: 'Extract a TypeScript class interface' },
|
|
536
|
+
{ cmd: '/move', desc: 'Move a TypeScript symbol and update imports' },
|
|
537
|
+
{ cmd: '/checkpoint', desc: 'Create or list whole-workspace restore points' },
|
|
538
|
+
{ cmd: '/revert', desc: 'Restore an edit checkpoint or named workspace snapshot' },
|
|
515
539
|
{ cmd: '/sessions', desc: 'List running KONECK sessions and their state' },
|
|
516
540
|
{ cmd: '/send', desc: 'Hand work to another session: /send <repo> <instruction>' },
|
|
517
541
|
{ cmd: '/ask', desc: 'Ask another session and wait: /ask <repo> <question>' },
|
|
@@ -519,6 +543,9 @@ const COMMANDS = [
|
|
|
519
543
|
{ cmd: '/subtask', desc: 'Run a task in an isolated sub-agent' },
|
|
520
544
|
{ cmd: '/aside', desc: 'Ask a side question without interrupting the running task' },
|
|
521
545
|
{ cmd: '/clear', desc: 'Clear conversation history' },
|
|
546
|
+
{ cmd: '/cancel', desc: 'Stop the current task and keep your composer text' },
|
|
547
|
+
{ cmd: '/retry', desc: 'Run the last non-secret task again' },
|
|
548
|
+
{ cmd: '/queue', desc: 'Inspect or clear prompts waiting behind the current task' },
|
|
522
549
|
{ cmd: '/new', desc: 'New session (alias of /clear)' },
|
|
523
550
|
{ cmd: '/reset', desc: 'Reset session (alias of /clear)' },
|
|
524
551
|
{ cmd: '/mode', desc: 'Set mode: auto, plan or code' },
|
|
@@ -537,9 +564,16 @@ const COMMANDS = [
|
|
|
537
564
|
{ cmd: '/fork', desc: 'Copy this session and continue on the copy' },
|
|
538
565
|
{ cmd: '/archive', desc: 'Keep a session but hide it from /resume' },
|
|
539
566
|
{ cmd: '/diff', desc: 'Show uncommitted git changes' },
|
|
567
|
+
{ cmd: '/changes', desc: 'Summarize staged, unstaged, and untracked changes' },
|
|
540
568
|
{ cmd: '/quality', desc: 'Run the detected lint and test checks' },
|
|
569
|
+
{ cmd: '/preflight', desc: 'Run the deterministic ready-to-review gate' },
|
|
570
|
+
{ cmd: '/ship', desc: 'Run readiness checks, then an enforced read-only review' },
|
|
541
571
|
{ cmd: '/audit', desc: 'Show recent redacted tool activity' },
|
|
542
572
|
{ cmd: '/policy', desc: 'Show or initialize workspace safety rules' },
|
|
573
|
+
{ cmd: '/trust', desc: 'Show, persist, or revoke this workspace trust grant' },
|
|
574
|
+
{ cmd: '/review', desc: 'Review the complete working tree for defects (/review --fix)' },
|
|
575
|
+
{ cmd: '/code-review', desc: 'Alias for /review' },
|
|
576
|
+
{ cmd: '/security-review', desc: 'Audit the complete working tree for security risks' },
|
|
543
577
|
{ cmd: '/verify', desc: 'Run the project test command' },
|
|
544
578
|
{ cmd: '/rewind', desc: 'Drop the last exchange from history' },
|
|
545
579
|
{ cmd: '/copy', desc: 'Copy the last response to the clipboard' },
|
|
@@ -549,6 +583,25 @@ const COMMANDS = [
|
|
|
549
583
|
{ cmd: '/exit', desc: 'Save and exit' },
|
|
550
584
|
{ cmd: '/quit', desc: 'Save and exit (alias of /exit)' },
|
|
551
585
|
];
|
|
586
|
+
/** Logical families for the searchable command palette; headers reuse an existing row. */
|
|
587
|
+
export function commandGroup(command) {
|
|
588
|
+
if (command === '/help')
|
|
589
|
+
return 'Start here';
|
|
590
|
+
if (new Set(['/connect', '/endpoint', '/key', '/logout', '/model', '/models', '/provider', '/doctor', '/context', '/mcp']).has(command))
|
|
591
|
+
return 'Connection';
|
|
592
|
+
if (new Set(['/goal', '/permissions', '/worktree', '/mode', '/effort', '/config', '/policy', '/trust']).has(command))
|
|
593
|
+
return 'Control';
|
|
594
|
+
if (new Set(['/init', '/rename', '/extract', '/move', '/checkpoint', '/revert', '/diff', '/changes', '/quality', '/preflight', '/ship', '/review', '/code-review', '/security-review', '/verify']).has(command))
|
|
595
|
+
return 'Workspace';
|
|
596
|
+
if (new Set(['/sessions', '/history', '/resume', '/save', '/name', '/fork', '/archive', '/rewind', '/clear', '/reset', '/new', '/exit', '/quit']).has(command))
|
|
597
|
+
return 'Sessions';
|
|
598
|
+
if (new Set(['/send', '/ask', '/agents', '/subtask', '/aside', '/queue']).has(command))
|
|
599
|
+
return 'Collaboration';
|
|
600
|
+
if (new Set(['/usage', '/tokens', '/cost', '/memory', '/activity', '/trajectory', '/audit']).has(command))
|
|
601
|
+
return 'Insight';
|
|
602
|
+
return 'Interface';
|
|
603
|
+
}
|
|
604
|
+
const COMMAND_GROUP_ORDER = ['Start here', 'Workspace', 'Connection', 'Control', 'Collaboration', 'Insight', 'Sessions', 'Interface'];
|
|
552
605
|
/** How hard the model is asked to think. Shown in the /effort picker and the footer. */
|
|
553
606
|
const EFFORT_BLURB = {
|
|
554
607
|
low: 'Brief answers, fewest tokens, fastest turnaround',
|
|
@@ -556,6 +609,12 @@ const EFFORT_BLURB = {
|
|
|
556
609
|
high: 'Thorough — covers edge cases, costs more tokens',
|
|
557
610
|
max: 'Most rigorous analysis available; slowest and priciest',
|
|
558
611
|
};
|
|
612
|
+
/** Stable, grouped command inventory. Filtering preserves this order while ranking exact matches. */
|
|
613
|
+
export function commandPaletteItems(commands = COMMANDS) {
|
|
614
|
+
return commands
|
|
615
|
+
.map(c => ({ value: c.cmd, label: c.cmd, desc: c.desc, group: commandGroup(c.cmd) }))
|
|
616
|
+
.sort((a, b) => COMMAND_GROUP_ORDER.indexOf(a.group) - COMMAND_GROUP_ORDER.indexOf(b.group));
|
|
617
|
+
}
|
|
559
618
|
// Bracketed paste. A terminal that has it enabled wraps pasted text in these markers, which is
|
|
560
619
|
// the only reliable way to tell a paste from very fast typing. Ink strips the leading escape of
|
|
561
620
|
// the opening marker before handing input to useInput, so the literal it sees is "[200~".
|
|
@@ -587,7 +646,6 @@ const MAX_DIFF_FILES = 4;
|
|
|
587
646
|
/** The tabs of the usage pane, in the order they are shown. */
|
|
588
647
|
const USAGE_TABS = ['settings', 'session', 'context', 'models', 'limits'];
|
|
589
648
|
const MIN_REPLY_WIDTH = 24; // below this a table cannot render legibly anyway
|
|
590
|
-
const PICKER_MIN_ROWS = 6;
|
|
591
649
|
const PICKER_MAX_ROWS = 16;
|
|
592
650
|
/**
|
|
593
651
|
* Number of choices to keep visible in a palette.
|
|
@@ -596,8 +654,14 @@ const PICKER_MAX_ROWS = 16;
|
|
|
596
654
|
* files, sessions, and providers equally easy to scan; on short terminals it yields space to
|
|
597
655
|
* the composer and the rest remains available through search and paging.
|
|
598
656
|
*/
|
|
599
|
-
export function pickerRowLimit(kind, itemCount, terminalRows) {
|
|
600
|
-
|
|
657
|
+
export function pickerRowLimit(kind, itemCount, terminalRows, reservedRows) {
|
|
658
|
+
// In ordinary scrollback this is not a fixed overlay: it can extend into the terminal's normal
|
|
659
|
+
// scroll region, so shrinking a command picker just because a pane has ten rows is needless
|
|
660
|
+
// friction. Only alternate-screen callers pass a reservation, because that is a fixed frame
|
|
661
|
+
// and overflow there makes Ink clear/repaint the entire screen.
|
|
662
|
+
const terminalBudget = reservedRows === undefined
|
|
663
|
+
? PICKER_MAX_ROWS
|
|
664
|
+
: Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - reservedRows);
|
|
601
665
|
// Keep kind in the signature: it documents that this is the common policy for *every* picker
|
|
602
666
|
// and lets a future picker opt into a different policy without duplicating this calculation.
|
|
603
667
|
void kind;
|
|
@@ -671,6 +735,23 @@ export function commandSuggestion(command, commands = COMMANDS.map(item => item.
|
|
|
671
735
|
}
|
|
672
736
|
return distance <= maxDistance ? best : undefined;
|
|
673
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Keep a mistyped command palette useful. A normal search never invents a result; only when it
|
|
740
|
+
* finds none do we surface one close, known command, clearly labelled as a recovery suggestion.
|
|
741
|
+
* Enter still requires the user to select it, so this cannot turn `/revertx` into an undo.
|
|
742
|
+
*/
|
|
743
|
+
export function commandPaletteMatches(items, query) {
|
|
744
|
+
const matches = filterPickerItems([...items], query);
|
|
745
|
+
if (matches.length > 0 || query.trim() === '')
|
|
746
|
+
return matches;
|
|
747
|
+
const suggestion = commandSuggestion('/' + query.trim().replace(/^\//, ''), items.map(item => item.value));
|
|
748
|
+
const match = suggestion ? items.find(item => item.value === suggestion) : undefined;
|
|
749
|
+
return match ? [{ ...match, desc: `${match.desc} · suggested for /${query.trim().replace(/^\//, '')}` }] : [];
|
|
750
|
+
}
|
|
751
|
+
/** The draft restored after an unknown-command typo; it is intentionally not executed for them. */
|
|
752
|
+
export function recoveredCommandDraft(suggestion, arg) {
|
|
753
|
+
return arg ? `${suggestion} ${arg}` : suggestion;
|
|
754
|
+
}
|
|
674
755
|
/** Keep a small, useful recall buffer without repeating the same submitted task. */
|
|
675
756
|
export function appendDraftHistory(history, draft, limit = 100) {
|
|
676
757
|
const entry = draft.trim();
|
|
@@ -686,10 +767,12 @@ export function appendDraftHistory(history, draft, limit = 100) {
|
|
|
686
767
|
* would otherwise renumber the rows beneath it in one of the two paths and not the other.
|
|
687
768
|
*/
|
|
688
769
|
export function askOptions(tool, prefix) {
|
|
689
|
-
const options = [
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
770
|
+
const options = [{ key: '1', label: 'allow once', rule: null, allow: true }];
|
|
771
|
+
// A native AST operation can follow references into arbitrary import sites, so a broad
|
|
772
|
+
// workspace-level grant would be far less specific than the operation just reviewed.
|
|
773
|
+
if (tool !== 'native_refactor') {
|
|
774
|
+
options.push({ key: '2', label: `always allow ${tool} in this workspace`, rule: { tool }, allow: true });
|
|
775
|
+
}
|
|
693
776
|
if (prefix) {
|
|
694
777
|
options.push({
|
|
695
778
|
key: '3',
|
|
@@ -744,7 +827,7 @@ export function humanBytes(bytes) {
|
|
|
744
827
|
*/
|
|
745
828
|
export function sessionTitle(meta) {
|
|
746
829
|
if (meta.name)
|
|
747
|
-
return meta.name;
|
|
830
|
+
return redactSecrets(meta.name);
|
|
748
831
|
const task = redactSecrets(meta.task ?? '').replace(/\s+/g, ' ').trim();
|
|
749
832
|
if (!task || task === '(chat session)')
|
|
750
833
|
return meta.id;
|
|
@@ -771,8 +854,10 @@ export function pickerCountText(selected, shown, total) {
|
|
|
771
854
|
* its row, and the constant 36 that was here cut most of them in half.
|
|
772
855
|
*/
|
|
773
856
|
export function pickerLabelWidth(labels, descs, width) {
|
|
774
|
-
|
|
775
|
-
|
|
857
|
+
// Terminal cells, not JS code units: one wide glyph in a model name or session title used to
|
|
858
|
+
// make a supposedly exact row spill past the palette edge.
|
|
859
|
+
const longestDesc = Math.max(0, ...descs.map(displayWidth));
|
|
860
|
+
const longestLabel = Math.max(18, ...labels.map(displayWidth));
|
|
776
861
|
return Math.max(18, Math.min(longestLabel, width - longestDesc - 4));
|
|
777
862
|
}
|
|
778
863
|
/**
|
|
@@ -787,14 +872,211 @@ export function pickerLabelWidth(labels, descs, width) {
|
|
|
787
872
|
* a new one should work without being registered twice. The dangerous ones are few and named.
|
|
788
873
|
*/
|
|
789
874
|
const COMMANDS_THAT_MUST_WAIT = new Set([
|
|
790
|
-
'/clear', '/reset', '/new', // would wipe the history
|
|
875
|
+
'/clear', '/reset', '/new', '/retry', // would wipe the history or begin a new turn
|
|
791
876
|
'/model', '/provider', '/connect', '/key', '/endpoint', '/baseurl', // rebuild the session
|
|
877
|
+
'/logout', // drops this session's runtime credential
|
|
792
878
|
'/resume', '/fork', // replace it
|
|
793
879
|
'/revert', // undo files the turn may still be writing
|
|
794
|
-
'/init', '/setup',
|
|
880
|
+
'/init', '/setup', '/checkpoint', '/rename', '/extract', '/move', '/ship',
|
|
795
881
|
]);
|
|
796
882
|
/** Commands that cannot do anything useful without an argument, so they are offered for editing. */
|
|
797
|
-
const COMMANDS_NEEDING_ARGUMENT = new Set(['/send', '/ask', '/aside']);
|
|
883
|
+
const COMMANDS_NEEDING_ARGUMENT = new Set(['/send', '/ask', '/aside', '/rename', '/extract', '/move']);
|
|
884
|
+
/**
|
|
885
|
+
* The project brief is deliberately a normal agent turn, rather than a separate hidden client.
|
|
886
|
+
* It therefore uses the connected model, honours workspace policy and checkpoints its write just
|
|
887
|
+
* like every other edit. `--force` is required when a maintainer's existing brief is present.
|
|
888
|
+
*/
|
|
889
|
+
export function initWorkspaceTask(force = false) {
|
|
890
|
+
return `Create ${force ? 'a refreshed' : 'a new'} KONECK.md in the repository root. Inspect the project first, then write a concise, accurate briefing for future coding agents. Include: overview, stack, layout, install/build/test/lint commands, conventions, important files, and practical gotchas. Do not invent facts. ${force ? 'Replace the existing KONECK.md after preserving any useful project-specific guidance.' : 'Do not overwrite an existing KONECK.md.'}`;
|
|
891
|
+
}
|
|
892
|
+
/** Human-readable MCP state without ever displaying configured environment values. */
|
|
893
|
+
export function mcpStatusText(configured, connected) {
|
|
894
|
+
if (configured.length === 0) {
|
|
895
|
+
return 'No MCP servers are configured. Add servers to `.koneck/mcp.json`, then start a new session.';
|
|
896
|
+
}
|
|
897
|
+
const lines = configured.map(name => {
|
|
898
|
+
const server = connected.find(candidate => candidate.name === name);
|
|
899
|
+
if (!server)
|
|
900
|
+
return `**OFFLINE** ${name} — could not connect when this session started`;
|
|
901
|
+
const names = server.tools.map(tool => tool.name);
|
|
902
|
+
const shown = names.slice(0, 12);
|
|
903
|
+
const suffix = names.length > shown.length ? `, +${names.length - shown.length} more` : '';
|
|
904
|
+
return `**ONLINE** ${name} — ${names.length} tool${names.length === 1 ? '' : 's'}${shown.length ? `\n ${shown.join(', ')}${suffix}` : ''}`;
|
|
905
|
+
});
|
|
906
|
+
return `**MCP integrations** — session capability snapshot\n\n${lines.join('\n\n')}\n\nMCP servers connect when a session starts. Use \`/reset\` after changing \`.koneck/mcp.json\`.`;
|
|
907
|
+
}
|
|
908
|
+
/** The review brief makes findings useful in a terminal, and keeps the default path read-only. */
|
|
909
|
+
export function reviewTask(kind, fix = false) {
|
|
910
|
+
if (kind === 'security') {
|
|
911
|
+
return 'Perform a read-only security review of the complete current working tree. Inspect staged, unstaged, and untracked changes with git and the relevant source. Look for injection, authorization failures, secret exposure, unsafe deserialization, path traversal, SSRF, XSS, CSRF, insecure defaults, and dependency or configuration risks. Report only actionable findings, ordered by severity, with file:line evidence, exploit path, and remediation. State clearly if no findings are supported. Do not edit files.';
|
|
912
|
+
}
|
|
913
|
+
return fix
|
|
914
|
+
? 'Review the complete current working tree for correctness, regressions, edge cases, maintainability, and missing tests. Inspect staged, unstaged, and untracked changes with git and the relevant source. Fix only high-confidence defects you can verify, then report each finding or fix with file:line evidence and the verification performed. Do not make unrelated refactors.'
|
|
915
|
+
: 'Perform a read-only review of the complete current working tree. Inspect staged, unstaged, and untracked changes with git and the relevant source. Find correctness bugs, regressions, edge cases, maintainability risks, and missing tests. Report only actionable findings, ordered by severity, with file:line evidence, impact, and a concrete recommendation. State clearly if no findings are supported. Do not edit files.';
|
|
916
|
+
}
|
|
917
|
+
/** The execution brief can be detailed without impersonating a user in the transcript. */
|
|
918
|
+
export function taskTranscriptLabel(task, generatedLabel) {
|
|
919
|
+
return generatedLabel ?? task;
|
|
920
|
+
}
|
|
921
|
+
/** The summary model gets useful context, never an accidental credential from the transcript. */
|
|
922
|
+
export function handoffSummaryInput(transcript) {
|
|
923
|
+
return redactSecrets(transcript);
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* `/aside` is a concurrent read-only investigation. Keep this allowlist deliberately smaller
|
|
927
|
+
* than the general read-risk class: `remember` mutates KONECK memory, and any unknown/MCP tool
|
|
928
|
+
* must be treated as consequential until its effect has been explicitly classified.
|
|
929
|
+
*/
|
|
930
|
+
const ASIDE_READ_TOOLS = new Set([
|
|
931
|
+
'read_files', 'list_dir', 'glob_search', 'grep_search', 'fetch_url', 'web_search',
|
|
932
|
+
'git_status', 'git_diff', 'git_log',
|
|
933
|
+
]);
|
|
934
|
+
export function asideAllowsTool(tool) {
|
|
935
|
+
return ASIDE_READ_TOOLS.has(tool);
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Reviews are promises of observation, not a request that the model happens to obey. Keep that
|
|
939
|
+
* promise at the approval boundary. This intentionally does not reuse `toolRisk(tool) ===
|
|
940
|
+
* 'read'`: `remember` and `plan` are classified that way for normal agent ergonomics but still
|
|
941
|
+
* mutate KONECK state. A review must leave both the workspace and the session unchanged.
|
|
942
|
+
*/
|
|
943
|
+
const REVIEW_READ_TOOLS = new Set([
|
|
944
|
+
'read_files', 'list_dir', 'glob_search', 'grep_search', 'git_status', 'git_diff', 'git_log',
|
|
945
|
+
]);
|
|
946
|
+
export function readOnlyReviewAllows(tool, args) {
|
|
947
|
+
if (REVIEW_READ_TOOLS.has(tool))
|
|
948
|
+
return true;
|
|
949
|
+
if (tool !== 'execute_command')
|
|
950
|
+
return false;
|
|
951
|
+
try {
|
|
952
|
+
return isReadOnlyCommand(String(JSON.parse(args).command ?? ''));
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/** A goal is compact enough to preserve context room, but rich enough to guide a long session. */
|
|
959
|
+
export function normalizeSessionGoal(value) {
|
|
960
|
+
return value.trim().replace(/\s+/g, ' ').slice(0, 600);
|
|
961
|
+
}
|
|
962
|
+
/** Copy for the deliberate second step before a whole-workspace rollback. */
|
|
963
|
+
export function workspaceRestoreConfirmation(id, label) {
|
|
964
|
+
return (`Workspace restore point ${id}\n ${label.slice(0, 80)}\n\n` +
|
|
965
|
+
'This will restore every changed tracked and non-ignored file to that saved workspace state. ' +
|
|
966
|
+
'KONECK will first create an undo checkpoint.\n\n' +
|
|
967
|
+
`Review with \`/diff\`, then run \`/revert ${id} --confirm\` to proceed.`);
|
|
968
|
+
}
|
|
969
|
+
/** Keep a large recovery report inspectable without turning it into a terminal-sized wall. */
|
|
970
|
+
export function checkpointPathSummary(paths, limit = 8) {
|
|
971
|
+
if (paths.length <= limit)
|
|
972
|
+
return paths.join(', ');
|
|
973
|
+
return `${paths.slice(0, Math.max(1, limit)).join(', ')}, … +${paths.length - Math.max(1, limit)} more`;
|
|
974
|
+
}
|
|
975
|
+
/** `/checkpoint list` is a safe discovery alias, never a snapshot called "list". */
|
|
976
|
+
export function isCheckpointListCommand(arg) {
|
|
977
|
+
return arg.trim().toLowerCase() === 'list';
|
|
978
|
+
}
|
|
979
|
+
/** Parses the compact quoted argument form used by native structural refactor commands. */
|
|
980
|
+
export function parseCommandArguments(input) {
|
|
981
|
+
const out = [];
|
|
982
|
+
let current = '';
|
|
983
|
+
let quote = null;
|
|
984
|
+
let escaped = false;
|
|
985
|
+
const flush = () => { if (current !== '') {
|
|
986
|
+
out.push(current);
|
|
987
|
+
current = '';
|
|
988
|
+
} };
|
|
989
|
+
for (const char of input.trim()) {
|
|
990
|
+
if (escaped) {
|
|
991
|
+
current += char;
|
|
992
|
+
escaped = false;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (char === '\\') {
|
|
996
|
+
escaped = true;
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
if (quote) {
|
|
1000
|
+
if (char === quote)
|
|
1001
|
+
quote = null;
|
|
1002
|
+
else
|
|
1003
|
+
current += char;
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
if (char === '"' || char === "'") {
|
|
1007
|
+
quote = char;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (/\s/.test(char)) {
|
|
1011
|
+
flush();
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
current += char;
|
|
1015
|
+
}
|
|
1016
|
+
if (escaped)
|
|
1017
|
+
current += '\\';
|
|
1018
|
+
if (quote)
|
|
1019
|
+
return null;
|
|
1020
|
+
flush();
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Native AST refactors can update arbitrary import sites. A restrictive policy must be enforced
|
|
1025
|
+
* before that fan-out, rather than pretending the few command arguments describe every write.
|
|
1026
|
+
*/
|
|
1027
|
+
export function nativeRefactorPolicyReason(policy) {
|
|
1028
|
+
if (!policy)
|
|
1029
|
+
return null;
|
|
1030
|
+
if ((policy.allowedWritePaths?.length ?? 0) > 0 || (policy.protectedPaths?.length ?? 0) > 0) {
|
|
1031
|
+
return 'workspace policy constrains write paths, but a native refactor may rewrite import sites beyond its explicit arguments';
|
|
1032
|
+
}
|
|
1033
|
+
if ((policy.requireApprovalFor ?? []).some(tool => ['write_file', 'apply_diff', 'search_replace', 'move_file', 'delete_file'].includes(tool))) {
|
|
1034
|
+
return 'workspace policy requires approval for writes; use a normal agent task so each write can be approved';
|
|
1035
|
+
}
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
/** A restore report must distinguish a completed operation from an already-matching snapshot. */
|
|
1039
|
+
export function workspaceRestoreWasNoop(result) {
|
|
1040
|
+
return result.restored.length + result.deleted.length + result.skipped.length === 0;
|
|
1041
|
+
}
|
|
1042
|
+
/** Whether Ink must stop for a confirmation instead of letting a tool run. */
|
|
1043
|
+
export function inkApprovalDecision(requireApproval, mode, tool, argsJson, rules, policyRequiresApproval = false) {
|
|
1044
|
+
const base = decide(mode, tool, argsJson, rules);
|
|
1045
|
+
if (base !== 'allow')
|
|
1046
|
+
return base;
|
|
1047
|
+
// A repository policy is an explicit boundary, not a suggestion a prior standing rule may
|
|
1048
|
+
// silently erase. It remains stronger than this session's convenience posture.
|
|
1049
|
+
if (policyRequiresApproval)
|
|
1050
|
+
return 'ask';
|
|
1051
|
+
if (!requireApproval)
|
|
1052
|
+
return 'allow';
|
|
1053
|
+
if (toolRisk(tool) === 'read')
|
|
1054
|
+
return 'allow';
|
|
1055
|
+
if (tool === 'execute_command') {
|
|
1056
|
+
try {
|
|
1057
|
+
if (isReadOnlyCommand(String(JSON.parse(argsJson).command ?? '')))
|
|
1058
|
+
return 'allow';
|
|
1059
|
+
}
|
|
1060
|
+
catch { /* malformed calls are confirmed */ }
|
|
1061
|
+
}
|
|
1062
|
+
return rules.some(rule => ruleMatches(rule, tool, argsJson)) ? 'allow' : 'ask';
|
|
1063
|
+
}
|
|
1064
|
+
/** A context report names the evidence for its denominator instead of pretending every model fits. */
|
|
1065
|
+
export function contextStatusText(model, sessionPromptTokens, configuredLimit, detected) {
|
|
1066
|
+
const known = detected?.limit ?? configuredLimit ?? contextWindowFor(model);
|
|
1067
|
+
const source = detected?.source === 'provider' ? 'reported by the connected provider'
|
|
1068
|
+
: detected ? detected.source
|
|
1069
|
+
: configuredLimit ? 'configured by you'
|
|
1070
|
+
: known ? 'KONECK model reference'
|
|
1071
|
+
: '';
|
|
1072
|
+
if (!known) {
|
|
1073
|
+
return `**Context window**\n\nModel: ${model}\nLimit: unknown — this provider did not report one and KONECK has no reliable reference.\n\nSet \`/config contextTokens <n>\` only when you know the serving limit. KONECK will otherwise avoid guessing.`;
|
|
1074
|
+
}
|
|
1075
|
+
const ratio = sessionPromptTokens > 0 ? sessionPromptTokens / known : 0;
|
|
1076
|
+
const headroom = Math.max(0, known - sessionPromptTokens);
|
|
1077
|
+
const pressure = ratio >= 1 ? `at least ${Math.round(ratio * 100)}%` : `${Math.round(ratio * 100)}%`;
|
|
1078
|
+
return `**Context window**\n\nModel: ${model}\nLimit: ${known.toLocaleString()} tokens (${source})\nSession prompt total: ${sessionPromptTokens.toLocaleString()} tokens\n\nThe session total is ${pressure} of one window; prompts are rebuilt each turn, so it is a pressure signal rather than the exact next-request size. Estimated one-window headroom: ${headroom.toLocaleString()} tokens.\n\nUse \`/usage context\` for the live context panel, or \`/config contextTokens <n>\` when you know a more accurate serving limit.`;
|
|
1079
|
+
}
|
|
798
1080
|
export function commandRunsWhileBusy(cmd) {
|
|
799
1081
|
return !COMMANDS_THAT_MUST_WAIT.has(cmd.toLowerCase());
|
|
800
1082
|
}
|
|
@@ -817,9 +1099,23 @@ Slash commands:
|
|
|
817
1099
|
/endpoint <url> Point this provider somewhere else, e.g.
|
|
818
1100
|
/endpoint http://192.168.1.50:11434/v1
|
|
819
1101
|
/key Enter an API key for the current provider
|
|
1102
|
+
/logout Forget Koneck-stored API credentials; environment keys are untouched
|
|
820
1103
|
/status Provider, model, mode, tokens, cost, CWD
|
|
1104
|
+
/permissions [on|off] Show or set approval for writes and commands in this session
|
|
1105
|
+
/worktree [on|off] Set isolated Git-worktree default for future CLI-run tasks
|
|
1106
|
+
/goal [text|clear] Keep one outcome in view for this session, or remove it
|
|
821
1107
|
/doctor Diagnose the active provider, workspace, and developer tools
|
|
1108
|
+
/context Show the model context window KONECK is planning around
|
|
1109
|
+
/mcp Show MCP integrations and the tools live in this session
|
|
1110
|
+
/init [--force] Create KONECK.md; --force refreshes an existing briefing
|
|
1111
|
+
/rename <old> <new> Native TypeScript rename; saves a restore point first
|
|
1112
|
+
/extract <file> <Class> Extract a class interface; saves a restore point first
|
|
1113
|
+
/move <symbol> <from> <to> Move a TypeScript declaration; saves a restore point first
|
|
1114
|
+
/checkpoint [name|list] Save a whole-workspace restore point, or list all restore points
|
|
822
1115
|
/clear /reset /new Clear conversation history
|
|
1116
|
+
/cancel Stop the current task; keeps the composer text
|
|
1117
|
+
/retry Run the last non-secret task again
|
|
1118
|
+
/queue [clear|drop n] Inspect, clear, or drop one queued prompt
|
|
823
1119
|
/model [name] Show or switch model
|
|
824
1120
|
/provider [name] Show or switch provider
|
|
825
1121
|
/mode [auto|plan|code] Show or set mode
|
|
@@ -831,14 +1127,21 @@ Slash commands:
|
|
|
831
1127
|
/cost Show session cost
|
|
832
1128
|
/memory Show .koneck/MEMORY.md
|
|
833
1129
|
/diff Show uncommitted git changes
|
|
1130
|
+
/changes Summarize staged, unstaged, and untracked changes
|
|
834
1131
|
/quality Run the detected lint and test checks
|
|
1132
|
+
/preflight Check patch integrity and detected quality gates before review/commit
|
|
1133
|
+
/ship Run preflight, then a read-only full-workspace review when it passes
|
|
835
1134
|
/audit [count] Show recent redacted tool activity
|
|
836
1135
|
/policy [init] Show or create workspace safety rules
|
|
1136
|
+
/trust [always|forget] Show, persist, or revoke this workspace trust grant
|
|
1137
|
+
/review [--fix] Review all workspace changes; --fix applies verified fixes only
|
|
1138
|
+
/code-review [--fix] Alias for /review
|
|
1139
|
+
/security-review Read-only security audit of all workspace changes
|
|
837
1140
|
/verify Run project test command
|
|
838
1141
|
/agents How parallel sub-agents are configured
|
|
839
1142
|
/subtask <task> Run a task in an isolated sub-agent
|
|
840
1143
|
/rewind Undo last exchange (chat history only)
|
|
841
|
-
/revert [list|<id>]
|
|
1144
|
+
/revert [list|<id>] Restore an edit checkpoint; add --preview to inspect first
|
|
842
1145
|
/save Save session to disk
|
|
843
1146
|
/sessions List recent saved sessions
|
|
844
1147
|
/exit /quit Save and exit
|
|
@@ -846,6 +1149,7 @@ Slash commands:
|
|
|
846
1149
|
Keyboard:
|
|
847
1150
|
Tab Toggle analytics panel
|
|
848
1151
|
Shift+Tab Cycle mode (auto → plan → code)
|
|
1152
|
+
Up/Down Recall prior submitted prompts in the composer
|
|
849
1153
|
Ctrl+C Cancel task / exit if idle
|
|
850
1154
|
`.trim();
|
|
851
1155
|
/**
|
|
@@ -868,6 +1172,9 @@ export function defaultAltScreen(stdout = process.stdout, env = process.env) {
|
|
|
868
1172
|
function App({ config: initialConfig, clearFrame }) {
|
|
869
1173
|
const { exit } = useApp();
|
|
870
1174
|
const [cfg, setCfg] = useState(initialConfig);
|
|
1175
|
+
// Tool approval callbacks belong to a session created on an earlier render. Keep the control
|
|
1176
|
+
// posture live so turning approval on during a turn governs its next consequential tool call.
|
|
1177
|
+
const cfgRef = useRef(initialConfig);
|
|
871
1178
|
// Live activity accumulates in a ref and is painted by the 100 ms ticker. Mutating a ref
|
|
872
1179
|
// instead of calling setState per token keeps a Raspberry Pi from re-rendering on every chunk.
|
|
873
1180
|
const sayRef = useRef(''); // narration not yet committed
|
|
@@ -1072,8 +1379,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1072
1379
|
* reads dozens of times a minute, and a question per call would teach anyone to hit yes without
|
|
1073
1380
|
* looking, which is worse than not asking at all.
|
|
1074
1381
|
*/
|
|
1075
|
-
async function approveTool(tool, args) {
|
|
1076
|
-
|
|
1382
|
+
async function approveTool(tool, args, policyRequiresApproval = false) {
|
|
1383
|
+
if (readOnlyReviewRef.current)
|
|
1384
|
+
return readOnlyReviewAllows(tool, args);
|
|
1385
|
+
const verdict = inkApprovalDecision(cfgRef.current.requireApproval, modeRef.current, tool, args, rulesRef.current, policyRequiresApproval);
|
|
1077
1386
|
if (verdict === 'allow')
|
|
1078
1387
|
return true;
|
|
1079
1388
|
if (verdict === 'deny')
|
|
@@ -1095,6 +1404,8 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1095
1404
|
showNextAsk();
|
|
1096
1405
|
});
|
|
1097
1406
|
}
|
|
1407
|
+
/** The endpoint's answer wins over a catalogue or name-based reference. */
|
|
1408
|
+
const [detectedContext, setDetectedContext] = useState(null);
|
|
1098
1409
|
const sessionOpts = {
|
|
1099
1410
|
approve: approveTool,
|
|
1100
1411
|
onPlan: (next) => {
|
|
@@ -1162,6 +1473,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1162
1473
|
lastActivityRef.current = Date.now();
|
|
1163
1474
|
},
|
|
1164
1475
|
onAgentProgress: (list) => { agentsRef.current = list; setAgents(list); },
|
|
1476
|
+
onWindowDetected: (limit, source) => setDetectedContext({ limit, source }),
|
|
1165
1477
|
onTokens: (s) => {
|
|
1166
1478
|
realTokRef.current = Math.max(0, s.completionTokens - tokBaseRef.current);
|
|
1167
1479
|
},
|
|
@@ -1176,11 +1488,25 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1176
1488
|
const draftHistoryRef = useRef([]);
|
|
1177
1489
|
const historyIndexRef = useRef(null);
|
|
1178
1490
|
const historyDraftRef = useRef('');
|
|
1491
|
+
/** Last agent task eligible for one-command recovery; never populated from secret-looking input. */
|
|
1492
|
+
const retryTaskRef = useRef(null);
|
|
1493
|
+
/** A command may start a detailed agent brief without putting implementation prose in user history. */
|
|
1494
|
+
const generatedTaskRef = useRef(null);
|
|
1495
|
+
/** Applies only while a generated review is in flight, never to the user's next task. */
|
|
1496
|
+
const readOnlyReviewRef = useRef(false);
|
|
1179
1497
|
/** Caret offset into `draft`. Kept beside it so every edit can place the caret deliberately. */
|
|
1180
1498
|
const [caret, setCaret] = useState(0);
|
|
1181
1499
|
const [busy, setBusy] = useState(false);
|
|
1182
1500
|
const [agentState, setAgentState] = useState('ready');
|
|
1183
1501
|
const [mode, setMode] = useState('auto');
|
|
1502
|
+
const [goal, setGoal] = useState(null);
|
|
1503
|
+
// A command can change the goal while a turn is finishing. The autosave callback from that
|
|
1504
|
+
// earlier turn must save the latest intent, not the state snapshot it originally captured.
|
|
1505
|
+
const goalRef = useRef(null);
|
|
1506
|
+
function setSessionGoal(next) {
|
|
1507
|
+
goalRef.current = next;
|
|
1508
|
+
setGoal(next);
|
|
1509
|
+
}
|
|
1184
1510
|
/**
|
|
1185
1511
|
* Permissions granted for this workspace, and the question currently on screen.
|
|
1186
1512
|
*
|
|
@@ -1456,7 +1782,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1456
1782
|
const [picker, setPicker] = useState(null);
|
|
1457
1783
|
const [pickIndex, setPickIndex] = useState(0);
|
|
1458
1784
|
const [pickQuery, setPickQuery] = useState('');
|
|
1459
|
-
const visiblePickerItems = React.useMemo(() => (picker ?
|
|
1785
|
+
const visiblePickerItems = React.useMemo(() => (picker ? (picker.kind === 'command'
|
|
1786
|
+
? commandPaletteMatches(picker.items, pickQuery)
|
|
1787
|
+
: filterPickerItems(picker.items, pickQuery)) : []), [picker, pickQuery]);
|
|
1460
1788
|
const [inboxTick, setInboxTick] = useState(0);
|
|
1461
1789
|
// One object, not three separate states. Ink throttles its writes with leading and trailing
|
|
1462
1790
|
// edges, so three setState calls in a tick let a single logical frame be written twice — which
|
|
@@ -1559,10 +1887,15 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1559
1887
|
for (const { from, payload } of queued) {
|
|
1560
1888
|
addRow({ role: 'steps', steps: [{ kind: 'say',
|
|
1561
1889
|
text: `Handoff from ${from.repoName} (${from.cwd})\n\n${payload.summary}` +
|
|
1890
|
+
(payload.goal ? `\n\nMission: ${payload.goal}` : '') +
|
|
1562
1891
|
(payload.changedFiles.length ? `\n\nChanged: ${payload.changedFiles.join(', ')}` : '') }] });
|
|
1563
1892
|
}
|
|
1564
1893
|
const combined = queued.map(q => `[Handoff from the KONECK session in ${q.from.repoName} (${q.from.cwd})]\n` +
|
|
1565
1894
|
`Context: ${q.payload.summary}\n` +
|
|
1895
|
+
(q.payload.goal ? `Sender's mission: ${q.payload.goal}\n` : '') +
|
|
1896
|
+
(q.payload.senderPosture
|
|
1897
|
+
? `Sender's posture (context only; keep this session's own controls): ${q.payload.senderPosture.mode} mode · ${q.payload.senderPosture.effort} effort · approval ${q.payload.senderPosture.requireApproval ? 'on' : 'off'}\n`
|
|
1898
|
+
: '') +
|
|
1566
1899
|
(q.payload.changedFiles.length ? `Files they changed: ${q.payload.changedFiles.join(', ')}\n` : '') +
|
|
1567
1900
|
`Your task: ${q.payload.instruction}`).join('\n\n');
|
|
1568
1901
|
submitDraft(combined);
|
|
@@ -1576,8 +1909,8 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1576
1909
|
busyStart.current = Date.now();
|
|
1577
1910
|
wordTimer.current = 0;
|
|
1578
1911
|
// The frame rate is the flicker rate. Every tick makes Ink update the live region, so network
|
|
1579
|
-
// activity must be sampled for a human reader rather than mirrored frame-for-frame.
|
|
1580
|
-
//
|
|
1912
|
+
// activity must be sampled for a human reader rather than mirrored frame-for-frame. Half a
|
|
1913
|
+
// second keeps streamed prose feeling live; an idle tool only needs a once-a-second
|
|
1581
1914
|
// heartbeat. This is particularly important in terminals that repaint an ANSI line update as
|
|
1582
1915
|
// a flash.
|
|
1583
1916
|
let timer;
|
|
@@ -1602,9 +1935,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1602
1935
|
};
|
|
1603
1936
|
const currentDelay = () => {
|
|
1604
1937
|
const idleFor = Date.now() - lastActivityRef.current;
|
|
1605
|
-
return idleFor
|
|
1938
|
+
return liveRefreshDelay(idleFor);
|
|
1606
1939
|
};
|
|
1607
|
-
timer = setTimeout(tick,
|
|
1940
|
+
timer = setTimeout(tick, liveRefreshDelay(0));
|
|
1608
1941
|
return () => clearTimeout(timer);
|
|
1609
1942
|
}, [busy]);
|
|
1610
1943
|
// A queued prompt runs the moment the agent is free again. Keyed on `busy` rather than done
|
|
@@ -1631,6 +1964,21 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1631
1964
|
useEffect(() => { void snapshotTree(cfg.cwd).then(t => { baseTreeRef.current = t; }); }, [cfg.cwd]);
|
|
1632
1965
|
const addRow = (row) => setRows(prev => [...prev, row]);
|
|
1633
1966
|
const addSystem = (text) => addRow({ role: 'system', text });
|
|
1967
|
+
/** Stops the in-flight turn without touching anything the developer is composing or queued. */
|
|
1968
|
+
function cancelCurrentTurn() {
|
|
1969
|
+
if (!busy || !abortRef.current)
|
|
1970
|
+
return false;
|
|
1971
|
+
abortRef.current.abort();
|
|
1972
|
+
// Anything still waiting on a permission answer is refused rather than left hanging: the
|
|
1973
|
+
// agents are being abandoned, and an unsettled promise keeps the pool alive after its work
|
|
1974
|
+
// has been cancelled.
|
|
1975
|
+
const stranded = askQueueRef.current.drain(false);
|
|
1976
|
+
if (stranded > 0) {
|
|
1977
|
+
setAsk(null);
|
|
1978
|
+
addSystem(`Cancelled with ${stranded} permission ${stranded === 1 ? 'request' : 'requests'} unanswered; refused.`);
|
|
1979
|
+
}
|
|
1980
|
+
return true;
|
|
1981
|
+
}
|
|
1634
1982
|
/** Walk submitted composer entries without losing whatever was being typed now. */
|
|
1635
1983
|
function recallDraft(direction) {
|
|
1636
1984
|
const history = draftHistoryRef.current;
|
|
@@ -1662,11 +2010,22 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1662
2010
|
setDraft(recalled);
|
|
1663
2011
|
setCaret(recalled.length);
|
|
1664
2012
|
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Starts a normal agent turn while preserving the short command the developer actually chose in
|
|
2015
|
+
* the transcript and recall history. This keeps /review and /init inspectable instead of
|
|
2016
|
+
* exposing their long internal instruction as a fake user message.
|
|
2017
|
+
*/
|
|
2018
|
+
function submitGeneratedTask(task, label, readOnly = false) {
|
|
2019
|
+
generatedTaskRef.current = { task, label, readOnly };
|
|
2020
|
+
submitDraft(task);
|
|
2021
|
+
}
|
|
1665
2022
|
async function getSession() {
|
|
1666
2023
|
return activeSession.p;
|
|
1667
2024
|
}
|
|
1668
2025
|
async function resetSession(newCfg) {
|
|
1669
2026
|
const c = newCfg ?? cfg;
|
|
2027
|
+
cfgRef.current = c;
|
|
2028
|
+
setDetectedContext(null);
|
|
1670
2029
|
activeSession.p = createAgentSession({ ...c, silent: true, ...sessionOpts });
|
|
1671
2030
|
if (newCfg)
|
|
1672
2031
|
setCfg(newCfg);
|
|
@@ -1728,6 +2087,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1728
2087
|
...(existing?.name ? { name: existing.name } : {}),
|
|
1729
2088
|
...(existing?.archived ? { archived: existing.archived } : {}),
|
|
1730
2089
|
...(existing?.forkedFrom ? { forkedFrom: existing.forkedFrom } : {}),
|
|
2090
|
+
...(goalRef.current ? { goal: goalRef.current } : {}),
|
|
2091
|
+
mode,
|
|
2092
|
+
effort,
|
|
2093
|
+
requireApproval: cfgRef.current.requireApproval,
|
|
1731
2094
|
id, task, provider: cfg.provider, model: cfg.model,
|
|
1732
2095
|
cwd: cfg.cwd, startedAt: startedAtRef.current,
|
|
1733
2096
|
turns: live.turns, totalTokens: live.totalTokens,
|
|
@@ -1746,12 +2109,12 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1746
2109
|
.filter(st => ['write_file', 'search_replace', 'apply_diff', 'move_file', 'delete_file'].includes(st.name))
|
|
1747
2110
|
.map(st => st.detail)
|
|
1748
2111
|
.filter(Boolean)))].slice(0, 12);
|
|
1749
|
-
const transcript = rows
|
|
2112
|
+
const transcript = handoffSummaryInput(rows
|
|
1750
2113
|
.filter(r => r.role === 'user' || r.role === 'agent')
|
|
1751
2114
|
.filter(r => !(r.role === 'user' && (r.text ?? '').startsWith('/')))
|
|
1752
2115
|
.slice(-8)
|
|
1753
2116
|
.map(r => `${r.role === 'user' ? 'User' : 'Agent'}: ${(r.text ?? '').slice(0, 400)}`)
|
|
1754
|
-
.join('\n');
|
|
2117
|
+
.join('\n'));
|
|
1755
2118
|
let summary = transcript.trim()
|
|
1756
2119
|
? transcript.slice(0, 600)
|
|
1757
2120
|
: 'The sending session has not done any work yet; this is a fresh instruction.';
|
|
@@ -1774,7 +2137,13 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1774
2137
|
}
|
|
1775
2138
|
catch { /* the raw tail is a serviceable fallback */ }
|
|
1776
2139
|
}
|
|
1777
|
-
return {
|
|
2140
|
+
return {
|
|
2141
|
+
summary,
|
|
2142
|
+
changedFiles: changed,
|
|
2143
|
+
instruction,
|
|
2144
|
+
...(goalRef.current ? { goal: goalRef.current } : {}),
|
|
2145
|
+
senderPosture: { mode: modeRef.current, effort, requireApproval: cfgRef.current.requireApproval },
|
|
2146
|
+
};
|
|
1778
2147
|
}
|
|
1779
2148
|
/**
|
|
1780
2149
|
* Replaces the live conversation with a saved one. The agent session is rebuilt from the
|
|
@@ -1784,6 +2153,25 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1784
2153
|
async function resumeSession(id) {
|
|
1785
2154
|
try {
|
|
1786
2155
|
const { meta, messages } = await loadSession(cfg.cwd, id);
|
|
2156
|
+
// A resume is a continuation, not just a message import. If it silently swaps in today's
|
|
2157
|
+
// model or loosens yesterday's careful mode, the copied transcript has a different meaning.
|
|
2158
|
+
const provider = meta.provider || cfg.provider;
|
|
2159
|
+
const restoredCfg = {
|
|
2160
|
+
...cfg,
|
|
2161
|
+
provider,
|
|
2162
|
+
model: meta.model || cfg.model,
|
|
2163
|
+
baseURL: endpointFor(provider).url,
|
|
2164
|
+
...(typeof meta.requireApproval === 'boolean' ? { requireApproval: meta.requireApproval } : {}),
|
|
2165
|
+
};
|
|
2166
|
+
cfgRef.current = restoredCfg;
|
|
2167
|
+
setCfg(restoredCfg);
|
|
2168
|
+
if (meta.mode && MODES.includes(meta.mode)) {
|
|
2169
|
+
modeRef.current = meta.mode;
|
|
2170
|
+
setMode(meta.mode);
|
|
2171
|
+
}
|
|
2172
|
+
if (meta.effort && ['low', 'medium', 'high', 'max'].includes(meta.effort)) {
|
|
2173
|
+
setEffort(meta.effort);
|
|
2174
|
+
}
|
|
1787
2175
|
// The plan comes back with the conversation. Without it the model reads its own history
|
|
1788
2176
|
// and writes a new plan from scratch, which is the forgetting this was built to stop.
|
|
1789
2177
|
const restoredPlan = meta.plan?.steps?.length
|
|
@@ -1794,9 +2182,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1794
2182
|
...(st.note ? { note: st.note } : {}),
|
|
1795
2183
|
})), updatedAt: Date.now() }
|
|
1796
2184
|
: undefined;
|
|
1797
|
-
activeSession.p = createAgentSession({ ...
|
|
2185
|
+
activeSession.p = createAgentSession({ ...restoredCfg, silent: true, ...sessionOpts }, messages, { turns: meta.turns, totalTokens: meta.totalTokens }, restoredPlan);
|
|
1798
2186
|
if (restoredPlan)
|
|
1799
2187
|
setPlan(restoredPlan);
|
|
2188
|
+
setSessionGoal(meta.goal ?? null);
|
|
1800
2189
|
setSessionId(id); // continue this session rather than forking a new one
|
|
1801
2190
|
resumedIdRef.current = id;
|
|
1802
2191
|
startedAtRef.current = meta.startedAt ?? startedAtRef.current;
|
|
@@ -1817,6 +2206,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1817
2206
|
addSystem(`Resumed ${id}\n` +
|
|
1818
2207
|
` ${meta.turns} turns, ${meta.totalTokens.toLocaleString()} tokens\n` +
|
|
1819
2208
|
` originally: ${meta.provider}/${meta.model}\n` +
|
|
2209
|
+
` posture: ${meta.mode ?? 'auto'} mode · ${meta.effort ?? 'medium'} effort · approval ${typeof meta.requireApproval === 'boolean' ? (meta.requireApproval ? 'on' : 'off') : 'current'}\n` +
|
|
1820
2210
|
` task: ${meta.task.slice(0, 90)}\n\n` +
|
|
1821
2211
|
`The agent has the full history; carry on where it left off.`);
|
|
1822
2212
|
}
|
|
@@ -1857,6 +2247,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1857
2247
|
case 'baseURL': return endpointFor(cfg.provider).url;
|
|
1858
2248
|
case 'effort': return effort;
|
|
1859
2249
|
case 'requireApproval': return cfg.requireApproval;
|
|
2250
|
+
case 'useWorktree': return cfg.useWorktree;
|
|
1860
2251
|
case 'maxTurns': return cfg.maxTurns;
|
|
1861
2252
|
case 'agentConcurrency': return cfg.agentConcurrency;
|
|
1862
2253
|
case 'maxWidth': return configMaxWidth;
|
|
@@ -1938,6 +2329,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1938
2329
|
}
|
|
1939
2330
|
case 'maxTurns':
|
|
1940
2331
|
case 'requireApproval':
|
|
2332
|
+
case 'useWorktree':
|
|
1941
2333
|
case 'agentConcurrency':
|
|
1942
2334
|
case 'budget': {
|
|
1943
2335
|
// These shape the session itself, so it is rebuilt on the new values.
|
|
@@ -1981,8 +2373,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1981
2373
|
const { runAgent } = await import('./engine.js');
|
|
1982
2374
|
const result = await runAgent(question, {
|
|
1983
2375
|
...cfg, ci: true, silent: true,
|
|
1984
|
-
//
|
|
1985
|
-
//
|
|
2376
|
+
// This is enforced at the engine approval boundary, not left as a prompt suggestion:
|
|
2377
|
+
// a concurrent side question cannot edit, run a command, persist memory, or call MCP.
|
|
2378
|
+
approve: async (tool) => asideAllowsTool(tool),
|
|
1986
2379
|
maxTurns: Math.min(cfg.maxTurns, 3),
|
|
1987
2380
|
});
|
|
1988
2381
|
const last = [...result.messages].reverse()
|
|
@@ -2224,7 +2617,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2224
2617
|
`Endpoint : ${endpoint}\n` +
|
|
2225
2618
|
`Model : ${cfg.model}\n` +
|
|
2226
2619
|
`Mode : ${modeSpec(mode).label} — ${modeSpec(mode).blurb}\n` +
|
|
2620
|
+
`Goal : ${goalRef.current ?? 'none'}\n` +
|
|
2227
2621
|
`Approval : ${cfg.requireApproval ? 'on' : 'off'}\n` +
|
|
2622
|
+
`Worktree : ${cfg.useWorktree ? 'default on for next CLI task' : 'off'}\n` +
|
|
2228
2623
|
// Said here because a sandbox nobody can confirm is a sandbox nobody should rely on,
|
|
2229
2624
|
// and on a machine with no way to confine commands the honest line is the important one.
|
|
2230
2625
|
`Sandbox : ${describeSandbox(resolveSandbox(cfg.sandbox, cfg.cwd, cfg.addDirs ?? []))}\n` +
|
|
@@ -2234,6 +2629,79 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2234
2629
|
`Cost : ${costStr}`);
|
|
2235
2630
|
return;
|
|
2236
2631
|
}
|
|
2632
|
+
case '/goal': {
|
|
2633
|
+
const raw = arg.trim();
|
|
2634
|
+
if (!raw) {
|
|
2635
|
+
addSystem(goalRef.current
|
|
2636
|
+
? `**Session goal**\n\n${goalRef.current}\n\nRun \`/goal clear\` to remove it.`
|
|
2637
|
+
: 'No session goal is set. Use `/goal <outcome>` to keep a long-running objective in view.');
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
if (raw.toLowerCase() === 'clear') {
|
|
2641
|
+
setSessionGoal(null);
|
|
2642
|
+
addSystem('Session goal cleared.');
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
const next = normalizeSessionGoal(raw);
|
|
2646
|
+
setSessionGoal(next);
|
|
2647
|
+
addSystem(`Session goal set:\n\n> ${next}\n\n` +
|
|
2648
|
+
'KONECK will include this outcome with future tasks in this session. It is saved with the session.');
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
case '/context': {
|
|
2652
|
+
addSystem(contextStatusText(cfg.model, stats.promptTokens, cfg.contextTokens, detectedContext));
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
case '/permissions': {
|
|
2656
|
+
const choice = arg.trim().toLowerCase();
|
|
2657
|
+
if (!choice) {
|
|
2658
|
+
addSystem(`**Approval posture**\n\n` +
|
|
2659
|
+
`Session approval: ${cfg.requireApproval ? 'on — KONECK asks before writes and non-read-only commands' : 'off — KONECK follows the selected mode and workspace policy'}\n` +
|
|
2660
|
+
`Mode: ${modeSpec(mode).label} — ${modeSpec(mode).blurb}\n\n` +
|
|
2661
|
+
'`/permissions on` keeps you in control of every consequential action.\n' +
|
|
2662
|
+
'`/permissions off` removes the extra session gate; workspace policy and plan mode still win.');
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
if (choice !== 'on' && choice !== 'off') {
|
|
2666
|
+
addSystem('Usage: `/permissions [on|off]`');
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
const next = choice === 'on';
|
|
2670
|
+
const merged = { ...cfg, requireApproval: next };
|
|
2671
|
+
cfgRef.current = merged;
|
|
2672
|
+
setCfg(merged);
|
|
2673
|
+
// A turn has a captured config. Rebuild only while idle; changing the promise mid-turn
|
|
2674
|
+
// would detach its callbacks. The approval callback reads the live cfg for this turn,
|
|
2675
|
+
// and the rebuilt session makes the new posture durable for every later turn.
|
|
2676
|
+
if (!busy)
|
|
2677
|
+
await resetSession(merged);
|
|
2678
|
+
addSystem(next
|
|
2679
|
+
? 'Approval on. KONECK will ask in this interface before writes and consequential commands. Read-only inspection stays uninterrupted.'
|
|
2680
|
+
: 'Approval off. KONECK follows the selected mode and workspace policy; plan mode and policy blocks still cannot be bypassed.');
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
case '/worktree': {
|
|
2684
|
+
const option = arg.trim().toLowerCase();
|
|
2685
|
+
if (option && option !== 'on' && option !== 'off') {
|
|
2686
|
+
addSystem('Usage: `/worktree [on|off]`');
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
if (!option) {
|
|
2690
|
+
addSystem(`Worktree isolation default: ${cfg.useWorktree ? 'on' : 'off'}\n` +
|
|
2691
|
+
'It applies when Koneck starts a new top-level CLI task. This active chat remains in its current workspace.');
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
const enabled = option === 'on';
|
|
2695
|
+
const saved = { ...(await loadKoneckConfig()), useWorktree: enabled };
|
|
2696
|
+
await saveKoneckConfig(saved);
|
|
2697
|
+
setStored(saved);
|
|
2698
|
+
const next = { ...cfg, useWorktree: enabled };
|
|
2699
|
+
cfgRef.current = next;
|
|
2700
|
+
setCfg(next);
|
|
2701
|
+
addSystem(`Worktree isolation default ${enabled ? 'on' : 'off'}. ` +
|
|
2702
|
+
'It will apply to the next top-level CLI task; this active chat stays in its current workspace.');
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2237
2705
|
case '/doctor': {
|
|
2238
2706
|
// A session can change provider, endpoint, or workspace after startup. Running the same
|
|
2239
2707
|
// checks here, against the *active* provider, means a diagnosis describes this session
|
|
@@ -2250,6 +2718,120 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2250
2718
|
}
|
|
2251
2719
|
return;
|
|
2252
2720
|
}
|
|
2721
|
+
case '/mcp': {
|
|
2722
|
+
const configured = await loadMCPConfig(cfg.cwd);
|
|
2723
|
+
const session = await activeSession.p.catch(() => null);
|
|
2724
|
+
addSystem(mcpStatusText(Object.keys(configured?.servers ?? {}), session?.mcpServers ?? []));
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
case '/init': {
|
|
2728
|
+
const option = arg.trim().toLowerCase();
|
|
2729
|
+
if (option && option !== '--force') {
|
|
2730
|
+
addSystem('Usage: `/init` creates KONECK.md. Use `/init --force` to refresh an existing file.');
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
const force = option === '--force';
|
|
2734
|
+
const briefing = path.join(cfg.cwd, 'KONECK.md');
|
|
2735
|
+
const exists = await fsp.access(briefing).then(() => true).catch(() => false);
|
|
2736
|
+
if (exists && !force) {
|
|
2737
|
+
addSystem('`KONECK.md` already exists. Its guidance is protected. Run `/init --force` to ask KONECK to refresh it.');
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
addSystem(exists
|
|
2741
|
+
? 'Refreshing `KONECK.md` with the current workspace context…'
|
|
2742
|
+
: 'Creating a workspace briefing in `KONECK.md`…');
|
|
2743
|
+
// Command palette selection temporarily owns `busy`; defer until it releases that small
|
|
2744
|
+
// command frame, so the generated task starts as a regular tracked agent turn.
|
|
2745
|
+
setTimeout(() => submitGeneratedTask(initWorkspaceTask(force), force ? '/init --force' : '/init'), 0);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
case '/logout': {
|
|
2749
|
+
const stored = await loadGlobalConfig();
|
|
2750
|
+
const hadStoredKey = Boolean(stored.apiKey);
|
|
2751
|
+
await saveGlobalConfig(withoutStoredApiKey(stored));
|
|
2752
|
+
// The runtime key may have come from `/key`, setup, or global config. Dropping it and
|
|
2753
|
+
// rebuilding lets a deliberate environment credential take over without retaining a
|
|
2754
|
+
// stale in-memory secret in the active session.
|
|
2755
|
+
const cleared = { ...cfg, apiKey: undefined };
|
|
2756
|
+
cfgRef.current = cleared;
|
|
2757
|
+
setCfg(cleared);
|
|
2758
|
+
await resetSession(cleared);
|
|
2759
|
+
addSystem(hadStoredKey
|
|
2760
|
+
? 'Koneck-stored API key cleared. Provider and model preferences were kept. Environment credentials were not changed.'
|
|
2761
|
+
: 'No Koneck-stored API key was present. Any environment credentials were left untouched; this session’s temporary key was cleared.');
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
case '/checkpoint': {
|
|
2765
|
+
const label = arg.trim();
|
|
2766
|
+
if (isCheckpointListCommand(label)) {
|
|
2767
|
+
await handleCommand('/revert', 'list');
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
try {
|
|
2771
|
+
const checkpoint = await createWorkspaceCheckpoint(cfg.cwd, label || 'manual restore point');
|
|
2772
|
+
addSystem(checkpoint
|
|
2773
|
+
? `Restore point saved: ${checkpoint.id}\n ${checkpoint.label}\n\nReview with \`/diff\`, then use \`/revert ${checkpoint.id} --confirm\` to restore this workspace state.`
|
|
2774
|
+
: 'A whole-workspace restore point needs a git repository. File edits made by KONECK are still checkpointed automatically.');
|
|
2775
|
+
}
|
|
2776
|
+
catch (e) {
|
|
2777
|
+
addSystem(`Could not create restore point: ${e instanceof Error ? e.message : String(e)}`);
|
|
2778
|
+
}
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
case '/rename':
|
|
2782
|
+
case '/extract':
|
|
2783
|
+
case '/move': {
|
|
2784
|
+
const parts = parseCommandArguments(arg);
|
|
2785
|
+
const expected = cmd === '/rename' ? 2 : cmd === '/extract' ? 2 : 3;
|
|
2786
|
+
const usage = cmd === '/rename' ? '/rename <old> <new>'
|
|
2787
|
+
: cmd === '/extract' ? '/extract <file> <Class>'
|
|
2788
|
+
: '/move <symbol> <from> <to>';
|
|
2789
|
+
if (!parts || parts.length !== expected) {
|
|
2790
|
+
addSystem(`Usage: \`${usage}\`\n\nUse quotes around a path containing spaces.`);
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
const policyReason = nativeRefactorPolicyReason(await loadPolicy(cfg.cwd));
|
|
2794
|
+
if (policyReason) {
|
|
2795
|
+
addSystem(`Native refactor blocked: ${policyReason}.\n\nUse a normal coding task instead; KONECK will enforce the policy for each proposed write.`);
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
const approved = await approveTool('native_refactor', JSON.stringify({ command: `${cmd} ${arg}` }));
|
|
2799
|
+
if (!approved) {
|
|
2800
|
+
addSystem(`Refused: ${cmd}. No files were changed.`);
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
const label = `before ${cmd} ${redactSecrets(arg).slice(0, 96)}`;
|
|
2804
|
+
let checkpoint = null;
|
|
2805
|
+
try {
|
|
2806
|
+
checkpoint = await createWorkspaceCheckpoint(cfg.cwd, label);
|
|
2807
|
+
}
|
|
2808
|
+
catch { /* Git is optional */ }
|
|
2809
|
+
addSystem(`Running native TypeScript refactor: ${cmd}…`);
|
|
2810
|
+
try {
|
|
2811
|
+
if (cmd === '/rename') {
|
|
2812
|
+
const result = await renameSymbol(parts[0], parts[1], cfg.cwd, { announce: false });
|
|
2813
|
+
addSystem(`Renamed \`${parts[0]}\` → \`${parts[1]}\` in ${result.filesChanged.length} file(s) (${result.occurrences} occurrence(s)).` +
|
|
2814
|
+
(checkpoint
|
|
2815
|
+
? `\n\nRestore point: \`${checkpoint.id}\` — undo with \`/revert ${checkpoint.id} --confirm\`.`
|
|
2816
|
+
: '\n\nNo Git restore point was available. Run `/preflight` before committing.'));
|
|
2817
|
+
}
|
|
2818
|
+
else if (cmd === '/extract') {
|
|
2819
|
+
await extractInterface(parts[0], parts[1], cfg.cwd, { announce: false });
|
|
2820
|
+
addSystem(`Extracted interface for \`${parts[1]}\` from \`${parts[0]}\`.` +
|
|
2821
|
+
(checkpoint ? `\n\nRestore point: \`${checkpoint.id}\` — run \`/preflight\`, or \`/revert ${checkpoint.id} --confirm\` to undo.` : '\n\nRun `/preflight` before committing.'));
|
|
2822
|
+
}
|
|
2823
|
+
else {
|
|
2824
|
+
await moveSymbol(parts[0], parts[1], parts[2], cfg.cwd, { announce: false });
|
|
2825
|
+
addSystem(`Moved \`${parts[0]}\` from \`${parts[1]}\` to \`${parts[2]}\` and updated imports.` +
|
|
2826
|
+
(checkpoint ? `\n\nRestore point: \`${checkpoint.id}\` — run \`/preflight\`, or \`/revert ${checkpoint.id} --confirm\` to undo.` : '\n\nRun `/preflight` before committing.'));
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
catch (e) {
|
|
2830
|
+
addSystem(`Native refactor failed: ${e instanceof Error ? e.message : String(e)}` +
|
|
2831
|
+
(checkpoint ? `\n\nNothing was automatically reverted. Your pre-refactor restore point is \`${checkpoint.id}\`.` : ''));
|
|
2832
|
+
}
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2253
2835
|
case '/clear':
|
|
2254
2836
|
case '/reset':
|
|
2255
2837
|
case '/new': {
|
|
@@ -2262,10 +2844,59 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2262
2844
|
startedAtRef.current = new Date().toISOString();
|
|
2263
2845
|
setRows([{ role: 'header' }]);
|
|
2264
2846
|
setPlan(null);
|
|
2847
|
+
setSessionGoal(null);
|
|
2265
2848
|
setStats({ turns: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 });
|
|
2266
2849
|
addSystem('Conversation cleared. The previous session is kept; /resume lists it.');
|
|
2267
2850
|
return;
|
|
2268
2851
|
}
|
|
2852
|
+
case '/cancel': {
|
|
2853
|
+
if (!cancelCurrentTurn())
|
|
2854
|
+
addSystem('No task is running.');
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
case '/retry': {
|
|
2858
|
+
const retry = retryTaskRef.current;
|
|
2859
|
+
if (!retry) {
|
|
2860
|
+
addSystem('Nothing safe to retry yet. Submit a non-secret task first.');
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
addSystem('Retrying the previous task…');
|
|
2864
|
+
// `/retry` itself is handled as a local command. Let its `finally` release the command
|
|
2865
|
+
// busy flag before starting the next agent turn, so the two cannot race each other.
|
|
2866
|
+
setTimeout(() => submitDraft(retry), 0);
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
case '/queue': {
|
|
2870
|
+
const requested = arg.trim();
|
|
2871
|
+
const drop = /^drop\s+(\d+)$/i.exec(requested);
|
|
2872
|
+
if (drop) {
|
|
2873
|
+
const index = Number(drop[1]) - 1;
|
|
2874
|
+
if (!Number.isInteger(index) || index < 0 || index >= queued.length) {
|
|
2875
|
+
addSystem(`There is no queued prompt #${drop[1]}. Run \`/queue\` to inspect the queue.`);
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
const [removed] = queued.slice(index, index + 1);
|
|
2879
|
+
setQueued(items => items.filter((_, itemIndex) => itemIndex !== index));
|
|
2880
|
+
addSystem(`Dropped queued prompt #${index + 1}: ${removed.replace(/\s+/g, ' ').slice(0, 180)}`);
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
if (/^(clear|drop)$/i.test(requested)) {
|
|
2884
|
+
const count = queued.length;
|
|
2885
|
+
setQueued([]);
|
|
2886
|
+
addSystem(count === 0
|
|
2887
|
+
? 'There are no queued prompts to clear.'
|
|
2888
|
+
: `Cleared ${count} queued ${count === 1 ? 'prompt' : 'prompts'}. The current task keeps running.`);
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
if (queued.length === 0) {
|
|
2892
|
+
addSystem('No prompts are queued. Type while a task runs and press enter to queue it.');
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
addSystem(`**Queued prompts** — ${queued.length}\n\n` +
|
|
2896
|
+
queued.map((prompt, index) => `${index + 1}. ${prompt.replace(/\s+/g, ' ').slice(0, 240)}`).join('\n') +
|
|
2897
|
+
'\n\n`/queue drop 2` removes one prompt. `/queue clear` drops all of them; the current task keeps running.');
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2269
2900
|
case '/model': {
|
|
2270
2901
|
if (!arg) {
|
|
2271
2902
|
const now = modelFor(cfg.provider);
|
|
@@ -2503,15 +3134,102 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2503
3134
|
}
|
|
2504
3135
|
case '/diff': {
|
|
2505
3136
|
try {
|
|
2506
|
-
const
|
|
2507
|
-
|
|
2508
|
-
|
|
3137
|
+
const [unstaged, staged, untracked] = await Promise.all([
|
|
3138
|
+
execa('git', ['diff', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3139
|
+
execa('git', ['diff', '--cached', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3140
|
+
untrackedPatches(cfg.cwd),
|
|
3141
|
+
]);
|
|
3142
|
+
if (unstaged.exitCode !== 0 || staged.exitCode !== 0) {
|
|
3143
|
+
addSystem('Not a git repository.');
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
3146
|
+
const sections = [
|
|
3147
|
+
(staged.all ?? '').trim() ? `**Staged**\n\n${(staged.all ?? '').trim()}` : '',
|
|
3148
|
+
(unstaged.all ?? '').trim() ? `**Unstaged**\n\n${(unstaged.all ?? '').trim()}` : '',
|
|
3149
|
+
untracked.patches.length ? `**Untracked**\n\n${untracked.patches.join('\n\n')}` : '',
|
|
3150
|
+
].filter(Boolean);
|
|
3151
|
+
const omissions = untracked.omitted.length
|
|
3152
|
+
? `\n\nUntracked files not rendered: ${untracked.omitted.join(', ')}`
|
|
3153
|
+
: '';
|
|
3154
|
+
addSystem(sections.length ? sections.join('\n\n') + omissions : 'No staged, unstaged, or renderable untracked changes. `/changes` shows the complete file list.' + omissions);
|
|
3155
|
+
}
|
|
3156
|
+
catch {
|
|
3157
|
+
addSystem('Not a git repository.');
|
|
3158
|
+
}
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
case '/changes': {
|
|
3162
|
+
try {
|
|
3163
|
+
const [status, unstaged, staged] = await Promise.all([
|
|
3164
|
+
execa('git', ['status', '--short'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3165
|
+
execa('git', ['diff', '--stat', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3166
|
+
execa('git', ['diff', '--cached', '--stat', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3167
|
+
]);
|
|
3168
|
+
const entries = (status.all ?? '').trim();
|
|
3169
|
+
if (status.exitCode !== 0 || unstaged.exitCode !== 0 || staged.exitCode !== 0) {
|
|
3170
|
+
addSystem('Not a git repository.');
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
if (!entries) {
|
|
3174
|
+
addSystem('Working tree is clean.');
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
const lines = ['**Working tree changes**', '', '```', entries, '```'];
|
|
3178
|
+
const stagedStat = (staged.all ?? '').trim();
|
|
3179
|
+
const unstagedStat = (unstaged.all ?? '').trim();
|
|
3180
|
+
if (stagedStat)
|
|
3181
|
+
lines.push('', '**Staged**', '```', stagedStat, '```');
|
|
3182
|
+
if (unstagedStat)
|
|
3183
|
+
lines.push('', '**Unstaged**', '```', unstagedStat, '```');
|
|
3184
|
+
lines.push('', 'Use `/diff` to inspect patches, `/quality` to verify, or `/revert` to undo KONECK checkpoints.');
|
|
3185
|
+
addSystem(lines.join('\n'));
|
|
2509
3186
|
}
|
|
2510
3187
|
catch {
|
|
2511
3188
|
addSystem('Not a git repository.');
|
|
2512
3189
|
}
|
|
2513
3190
|
return;
|
|
2514
3191
|
}
|
|
3192
|
+
case '/review':
|
|
3193
|
+
case '/code-review':
|
|
3194
|
+
case '/security-review': {
|
|
3195
|
+
const security = cmd === '/security-review';
|
|
3196
|
+
const option = arg.trim().toLowerCase();
|
|
3197
|
+
if ((security && option) || (!security && option && option !== '--fix')) {
|
|
3198
|
+
addSystem(security ? 'Usage: `/security-review`' : 'Usage: `/review [--fix]`');
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
try {
|
|
3202
|
+
const [status, patch] = await Promise.all([
|
|
3203
|
+
execa('git', ['status', '--short'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3204
|
+
// HEAD deliberately includes both staged and unstaged edits. The old legacy command
|
|
3205
|
+
// used plain `git diff`, so its review silently omitted work about to be committed.
|
|
3206
|
+
execa('git', ['diff', 'HEAD', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3207
|
+
]);
|
|
3208
|
+
if (status.exitCode !== 0 || patch.exitCode !== 0) {
|
|
3209
|
+
addSystem('Reviews need a git repository in this workspace.');
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
if (!(status.all ?? '').trim()) {
|
|
3213
|
+
addSystem('Working tree is clean — there is nothing to review.');
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
const fix = option === '--fix';
|
|
3217
|
+
addSystem(security
|
|
3218
|
+
? '**Security review** — inspecting all current workspace changes…'
|
|
3219
|
+
: fix
|
|
3220
|
+
? '**Review + verified fixes** — inspecting all current workspace changes…'
|
|
3221
|
+
: '**Code review** — inspecting all current workspace changes…');
|
|
3222
|
+
// As with /init, the palette's short command frame must finish before this becomes the
|
|
3223
|
+
// normal tracked agent turn. The agent reads the repository itself instead of receiving
|
|
3224
|
+
// an unbounded diff pasted into the prompt.
|
|
3225
|
+
const label = security ? '/security-review' : fix ? '/review --fix' : '/review';
|
|
3226
|
+
setTimeout(() => submitGeneratedTask(reviewTask(security ? 'security' : 'code', fix), label, !fix), 0);
|
|
3227
|
+
}
|
|
3228
|
+
catch {
|
|
3229
|
+
addSystem('Reviews need a git repository in this workspace.');
|
|
3230
|
+
}
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
2515
3233
|
case '/quality': {
|
|
2516
3234
|
// The engine uses this exact gate after a coding turn. Exposing it in chat gives the user
|
|
2517
3235
|
// the same independent proof on demand, without the legacy TUI writing over Ink's frame.
|
|
@@ -2540,6 +3258,40 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2540
3258
|
}
|
|
2541
3259
|
return;
|
|
2542
3260
|
}
|
|
3261
|
+
case '/preflight': {
|
|
3262
|
+
addSystem('**Preflight** — checking patch integrity and detected quality gates…');
|
|
3263
|
+
try {
|
|
3264
|
+
const result = await runPreflight(cfg.cwd);
|
|
3265
|
+
setRows(prev => prev.slice(0, -1));
|
|
3266
|
+
addSystem(preflightReport(result));
|
|
3267
|
+
}
|
|
3268
|
+
catch (e) {
|
|
3269
|
+
setRows(prev => prev.slice(0, -1));
|
|
3270
|
+
addSystem(`Preflight could not finish: ${e instanceof Error ? e.message : String(e)}`);
|
|
3271
|
+
}
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
case '/ship': {
|
|
3275
|
+
if (arg.trim()) {
|
|
3276
|
+
addSystem('Usage: `/ship` — runs preflight, then starts an enforced read-only review if the gate passes.');
|
|
3277
|
+
return;
|
|
3278
|
+
}
|
|
3279
|
+
addSystem('**Ship check** — running deterministic readiness checks…');
|
|
3280
|
+
try {
|
|
3281
|
+
const result = await runPreflight(cfg.cwd);
|
|
3282
|
+
setRows(prev => prev.slice(0, -1));
|
|
3283
|
+
addSystem(preflightReport(result));
|
|
3284
|
+
if (!isPreflightReady(result))
|
|
3285
|
+
return;
|
|
3286
|
+
addSystem('**Ship review** — readiness passed. Inspecting the complete workspace in enforced read-only mode…');
|
|
3287
|
+
setTimeout(() => submitGeneratedTask(reviewTask('code'), '/ship', true), 0);
|
|
3288
|
+
}
|
|
3289
|
+
catch (e) {
|
|
3290
|
+
setRows(prev => prev.slice(0, -1));
|
|
3291
|
+
addSystem(`Ship check could not finish: ${e instanceof Error ? e.message : String(e)}`);
|
|
3292
|
+
}
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
2543
3295
|
case '/audit': {
|
|
2544
3296
|
const requested = Number.parseInt(arg, 10);
|
|
2545
3297
|
const limit = Number.isFinite(requested) ? Math.max(1, Math.min(requested, 100)) : 20;
|
|
@@ -2581,6 +3333,28 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2581
3333
|
`Approval rules : ${policy.requireApprovalFor?.join(', ') || 'none'}`);
|
|
2582
3334
|
return;
|
|
2583
3335
|
}
|
|
3336
|
+
case '/trust': {
|
|
3337
|
+
const choice = arg.trim().toLowerCase();
|
|
3338
|
+
if (choice === 'always') {
|
|
3339
|
+
await trustWorkspace(cfg.cwd);
|
|
3340
|
+
addSystem(`This workspace is now trusted for future KONECK sessions:\n\`${cfg.cwd}\``);
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
if (choice === 'forget' || choice === 'revoke' || choice === 'remove') {
|
|
3344
|
+
const removed = await untrustWorkspace(cfg.cwd);
|
|
3345
|
+
addSystem(removed
|
|
3346
|
+
? `Removed the persistent trust grant for \`${cfg.cwd}\`. This open session remains active; KONECK will ask again next time.`
|
|
3347
|
+
: 'This workspace did not have a persistent trust grant.');
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
3350
|
+
const trusted = await isWorkspaceTrusted(cfg.cwd);
|
|
3351
|
+
addSystem(`**Workspace trust**\n\n` +
|
|
3352
|
+
`Path : \`${cfg.cwd}\`\n` +
|
|
3353
|
+
`Persistent : ${trusted ? 'yes' : 'no — this session may be a one-time grant'}\n\n` +
|
|
3354
|
+
'`/trust always` saves this grant for future sessions.\n' +
|
|
3355
|
+
'`/trust forget` removes a saved grant; this open session stays active.');
|
|
3356
|
+
return;
|
|
3357
|
+
}
|
|
2584
3358
|
case '/verify': {
|
|
2585
3359
|
const { detectProject } = await import('./project.js');
|
|
2586
3360
|
const project = await detectProject(cfg.cwd);
|
|
@@ -2765,6 +3539,13 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2765
3539
|
await resetSession(next);
|
|
2766
3540
|
addSystem(`Sub-agent concurrency is now ${resolveAgentConcurrency(next.agentConcurrency)}.`);
|
|
2767
3541
|
}
|
|
3542
|
+
if (key === 'useWorktree' && ['true', 'false', '1', '0', 'yes', 'no'].includes(val.toLowerCase())) {
|
|
3543
|
+
const enabled = ['true', '1', 'yes'].includes(val.toLowerCase());
|
|
3544
|
+
const next = { ...cfg, useWorktree: enabled };
|
|
3545
|
+
cfgRef.current = next;
|
|
3546
|
+
setCfg(next);
|
|
3547
|
+
addSystem(`Worktree isolation default ${enabled ? 'on' : 'off'} for future top-level CLI tasks; this active chat stays in its current workspace.`);
|
|
3548
|
+
}
|
|
2768
3549
|
return;
|
|
2769
3550
|
}
|
|
2770
3551
|
if (key) {
|
|
@@ -2777,6 +3558,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2777
3558
|
const descs = configKeyDescriptions();
|
|
2778
3559
|
const live = {
|
|
2779
3560
|
provider: cfg.provider, model: cfg.model, effort, requireApproval: cfg.requireApproval,
|
|
3561
|
+
useWorktree: cfg.useWorktree,
|
|
2780
3562
|
maxTurns: cfg.maxTurns, budget: cfg.budget, baseURL: cfg.baseURL,
|
|
2781
3563
|
maxWidth: configMaxWidth ?? `(not set - filling ${termCols} columns)`,
|
|
2782
3564
|
agentConcurrency: resolveAgentConcurrency(cfg.agentConcurrency),
|
|
@@ -2961,25 +3743,50 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2961
3743
|
addSystem('Checkpoints (newest first):\n' +
|
|
2962
3744
|
points.map(c => {
|
|
2963
3745
|
const when = new Date(c.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
2964
|
-
return ` ${c.id} ${when} ${c
|
|
3746
|
+
return ` ${c.id} ${when} ${checkpointScope(c)} ${c.label.slice(0, 48)}`;
|
|
2965
3747
|
}).join('\n') +
|
|
2966
3748
|
'\n\n/revert undo the most recent edit' +
|
|
2967
|
-
'\n/revert <id> undo a specific checkpoint'
|
|
3749
|
+
'\n/revert <id> undo a specific checkpoint' +
|
|
3750
|
+
'\n/revert <id> --preview inspect files that would change' +
|
|
3751
|
+
'\n/revert <id> --confirm restore a named workspace snapshot');
|
|
2968
3752
|
return;
|
|
2969
3753
|
}
|
|
2970
|
-
const
|
|
3754
|
+
const revertArgs = arg.trim().split(/\s+/).filter(Boolean);
|
|
3755
|
+
const confirmed = revertArgs.includes('--confirm');
|
|
3756
|
+
const preview = revertArgs.includes('--preview');
|
|
3757
|
+
const targetId = revertArgs.find(value => value !== '--confirm' && value !== '--preview');
|
|
3758
|
+
const target = targetId ? points.find(c => c.id === targetId) : points[0];
|
|
2971
3759
|
if (!target) {
|
|
2972
|
-
addSystem(`No checkpoint "${arg}". Use /revert list to see them.`);
|
|
3760
|
+
addSystem(`No checkpoint "${targetId ?? arg}". Use /revert list to see them.`);
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3763
|
+
if (preview) {
|
|
3764
|
+
const effect = await previewCheckpoint(cfg.cwd, target);
|
|
3765
|
+
const lines = [`Preview ${checkpointScope(target)} ${target.id} — "${target.label.slice(0, 60)}"`];
|
|
3766
|
+
lines.push(effect.changes.length
|
|
3767
|
+
? ` would change (${effect.changes.length}): ${checkpointPathSummary(effect.changes)}`
|
|
3768
|
+
: ' already matches this restore point; no user files would change');
|
|
3769
|
+
if (effect.skipped.length)
|
|
3770
|
+
lines.push(` cannot restore (${effect.skipped.length}): ${checkpointPathSummary(effect.skipped)}`);
|
|
3771
|
+
lines.push(`\nApply with /revert ${target.id}${target.tree ? ' --confirm' : ''}`);
|
|
3772
|
+
addSystem(lines.join('\n'));
|
|
3773
|
+
return;
|
|
3774
|
+
}
|
|
3775
|
+
if (target.tree && !confirmed) {
|
|
3776
|
+
addSystem(workspaceRestoreConfirmation(target.id, target.label));
|
|
2973
3777
|
return;
|
|
2974
3778
|
}
|
|
2975
3779
|
const r = await revertCheckpoint(cfg.cwd, target);
|
|
2976
|
-
const lines = [`
|
|
3780
|
+
const lines = [`Restored ${checkpointScope(target)} ${target.id} — "${target.label.slice(0, 60)}"`];
|
|
3781
|
+
if (target.tree && workspaceRestoreWasNoop(r)) {
|
|
3782
|
+
lines.push(' already matched the saved workspace state; no files changed');
|
|
3783
|
+
}
|
|
2977
3784
|
if (r.restored.length)
|
|
2978
|
-
lines.push(` restored
|
|
3785
|
+
lines.push(` restored (${r.restored.length}): ${checkpointPathSummary(r.restored)}`);
|
|
2979
3786
|
if (r.deleted.length)
|
|
2980
|
-
lines.push(` removed (did not exist before): ${r.deleted
|
|
3787
|
+
lines.push(` removed (${r.deleted.length}, did not exist before): ${checkpointPathSummary(r.deleted)}`);
|
|
2981
3788
|
if (r.skipped.length)
|
|
2982
|
-
lines.push(` skipped (
|
|
3789
|
+
lines.push(` skipped (${r.skipped.length}): ${checkpointPathSummary(r.skipped)}`);
|
|
2983
3790
|
if (r.undoId)
|
|
2984
3791
|
lines.push(`\nTo undo this revert: /revert ${r.undoId}`);
|
|
2985
3792
|
addSystem(lines.join('\n'));
|
|
@@ -3006,8 +3813,13 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3006
3813
|
{
|
|
3007
3814
|
const suggestion = commandSuggestion(cmd);
|
|
3008
3815
|
addSystem(suggestion
|
|
3009
|
-
? `Unknown command: ${cmd}. Did you mean \`${suggestion}
|
|
3816
|
+
? `Unknown command: ${cmd}. Did you mean \`${suggestion}\`? It is ready in the composer; press Enter to run it.`
|
|
3010
3817
|
: `Unknown command: ${cmd}. Type /help for the full list.`);
|
|
3818
|
+
if (suggestion) {
|
|
3819
|
+
const recovered = recoveredCommandDraft(suggestion, arg);
|
|
3820
|
+
setDraft(recovered);
|
|
3821
|
+
setCaret(recovered.length);
|
|
3822
|
+
}
|
|
3011
3823
|
}
|
|
3012
3824
|
}
|
|
3013
3825
|
}
|
|
@@ -3070,15 +3882,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3070
3882
|
// Esc abandons the turn in flight and hands the keyboard straight back. The composer is
|
|
3071
3883
|
// deliberately left alone: cancelling should not cost whatever was typed while waiting.
|
|
3072
3884
|
if (key.escape && busy && !picker && !keyPrompt && !updatePane && !usagePane) {
|
|
3073
|
-
|
|
3074
|
-
// Anything still waiting on a permission answer is refused rather than left hanging: the
|
|
3075
|
-
// agents are being abandoned, and an unsettled promise keeps the pool alive after the work
|
|
3076
|
-
// it was doing has been given up on.
|
|
3077
|
-
const stranded = askQueueRef.current.drain(false);
|
|
3078
|
-
if (stranded > 0) {
|
|
3079
|
-
setAsk(null);
|
|
3080
|
-
addSystem(`Cancelled with ${stranded} permission ${stranded === 1 ? 'request' : 'requests'} unanswered; refused.`);
|
|
3081
|
-
}
|
|
3885
|
+
cancelCurrentTurn();
|
|
3082
3886
|
return;
|
|
3083
3887
|
}
|
|
3084
3888
|
if (key.ctrl && input === 'c') {
|
|
@@ -3196,12 +4000,12 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3196
4000
|
return;
|
|
3197
4001
|
}
|
|
3198
4002
|
if (key.pageDown) {
|
|
3199
|
-
const jump = pickerRowLimit(picker.kind, list.length, termRows);
|
|
4003
|
+
const jump = pickerRowLimit(picker.kind, list.length, ...(altScreen ? [termRows, 13] : []));
|
|
3200
4004
|
setPickIndex(i => Math.min(i + jump, Math.max(0, list.length - 1)));
|
|
3201
4005
|
return;
|
|
3202
4006
|
}
|
|
3203
4007
|
if (key.pageUp) {
|
|
3204
|
-
const jump = pickerRowLimit(picker.kind, list.length, termRows);
|
|
4008
|
+
const jump = pickerRowLimit(picker.kind, list.length, ...(altScreen ? [termRows, 13] : []));
|
|
3205
4009
|
setPickIndex(i => Math.max(0, i - jump));
|
|
3206
4010
|
return;
|
|
3207
4011
|
}
|
|
@@ -3514,7 +4318,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3514
4318
|
if (input === '/' && draft === '') {
|
|
3515
4319
|
setDraft('/');
|
|
3516
4320
|
setCaret(1);
|
|
3517
|
-
openPicker('command',
|
|
4321
|
+
openPicker('command', commandPaletteItems());
|
|
3518
4322
|
return;
|
|
3519
4323
|
}
|
|
3520
4324
|
// A held key arrives as one chunk that Ink does not recognise as a key at all: five
|
|
@@ -3538,6 +4342,8 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3538
4342
|
return;
|
|
3539
4343
|
const next = draft.slice(0, caret) + text + draft.slice(caret);
|
|
3540
4344
|
const nextCaret = caret + text.length;
|
|
4345
|
+
// Once the recalled line is edited it is a new draft, not a history cursor position.
|
|
4346
|
+
historyIndexRef.current = null;
|
|
3541
4347
|
setDraft(next);
|
|
3542
4348
|
setCaret(nextCaret);
|
|
3543
4349
|
// A lone "@" at a word boundary offers workspace paths. Checking the draft as it will be,
|
|
@@ -3581,6 +4387,19 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3581
4387
|
const task = expandPastes(shown, pastesRef.current).trim();
|
|
3582
4388
|
if (!task)
|
|
3583
4389
|
return;
|
|
4390
|
+
const generated = generatedTaskRef.current?.task === task ? generatedTaskRef.current : null;
|
|
4391
|
+
if (generated)
|
|
4392
|
+
generatedTaskRef.current = null;
|
|
4393
|
+
// This flag is intentionally attached to one generated task rather than the session mode.
|
|
4394
|
+
// A review must be unable to mutate; the next user-authored task must retain their posture.
|
|
4395
|
+
const readOnlyReview = generated?.readOnly === true;
|
|
4396
|
+
// A recalled secret is just as dangerous as a saved one. Secret-looking input is sent only
|
|
4397
|
+
// when the user explicitly submits it, and never retained for Up-arrow recall.
|
|
4398
|
+
if (!containsSecret(task)) {
|
|
4399
|
+
draftHistoryRef.current = appendDraftHistory(draftHistoryRef.current, taskTranscriptLabel(shown, generated?.label));
|
|
4400
|
+
}
|
|
4401
|
+
historyIndexRef.current = null;
|
|
4402
|
+
historyDraftRef.current = '';
|
|
3584
4403
|
setDraft('');
|
|
3585
4404
|
setCaret(0);
|
|
3586
4405
|
pastesRef.current = [];
|
|
@@ -3595,7 +4414,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3595
4414
|
'written into the session log.');
|
|
3596
4415
|
}
|
|
3597
4416
|
const mentions = findMentions(task, pathKind);
|
|
3598
|
-
const forDisplay = pasteForDisplay(mentions.length ? shortenMentions(task, mentions) : task);
|
|
4417
|
+
const forDisplay = taskTranscriptLabel(pasteForDisplay(mentions.length ? shortenMentions(task, mentions) : task), generated?.label);
|
|
3599
4418
|
addRow({ role: 'user', text: forDisplay });
|
|
3600
4419
|
if (isSlashCommand(task)) {
|
|
3601
4420
|
const spaceIdx = task.indexOf(' ');
|
|
@@ -3605,6 +4424,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3605
4424
|
void handleCommand(cmd, arg).finally(() => setBusy(false));
|
|
3606
4425
|
return;
|
|
3607
4426
|
}
|
|
4427
|
+
if (!containsSecret(task))
|
|
4428
|
+
retryTaskRef.current = task;
|
|
4429
|
+
readOnlyReviewRef.current = readOnlyReview;
|
|
3608
4430
|
setBusy(true);
|
|
3609
4431
|
setAgentState('processing');
|
|
3610
4432
|
const started = Date.now();
|
|
@@ -3628,7 +4450,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3628
4450
|
const outTokBefore = stats.completionTokens;
|
|
3629
4451
|
try {
|
|
3630
4452
|
const active = await getSession();
|
|
3631
|
-
const
|
|
4453
|
+
const persistentGoal = goalRef.current;
|
|
4454
|
+
const prefix = (persistentGoal ? `[Persistent session goal: ${persistentGoal}]\n` : '') +
|
|
4455
|
+
modeSpec(mode).prefix +
|
|
4456
|
+
(effort !== 'medium' ? `[Effort: ${effort}]\n` : '');
|
|
3632
4457
|
const controller = new AbortController();
|
|
3633
4458
|
abortRef.current = controller;
|
|
3634
4459
|
await active.send(prefix + task, controller.signal);
|
|
@@ -3725,6 +4550,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3725
4550
|
}
|
|
3726
4551
|
finally {
|
|
3727
4552
|
abortRef.current = null;
|
|
4553
|
+
readOnlyReviewRef.current = false;
|
|
3728
4554
|
setBusy(false);
|
|
3729
4555
|
}
|
|
3730
4556
|
})();
|
|
@@ -3828,7 +4654,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3828
4654
|
});
|
|
3829
4655
|
return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 40), color: CYAN, title: `Plan ${done}/${steps.length}`, children: lines }) }, index));
|
|
3830
4656
|
})() : row.role === 'error' ? (() => {
|
|
3831
|
-
const lines = [row.text ?? '', '', 'Fix the above, then retry.
|
|
4657
|
+
const lines = [row.text ?? '', '', 'Fix the above, then `/retry` to rerun the task. `/status` shows the active provider and model.'];
|
|
3832
4658
|
return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 40), color: CRIMSON, title: "[!] ERROR", children: lines }) }, index));
|
|
3833
4659
|
})() : row.role === 'system' ? (() => {
|
|
3834
4660
|
const lines = (row.text ?? '').split('\n');
|
|
@@ -3973,10 +4799,11 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3973
4799
|
const window = modelCatalog.find(m => m.id === cfg.model)?.contextLength ?? contextWindowFor(cfg.model);
|
|
3974
4800
|
lines.push('**Context window**', '');
|
|
3975
4801
|
if (window) {
|
|
3976
|
-
//
|
|
3977
|
-
//
|
|
4802
|
+
// This is cumulative across the session, not a made-up claim about the next request.
|
|
4803
|
+
// It is still useful as a pressure signal: every long turn re-sends most of the
|
|
4804
|
+
// history, but only the provider can count the exact request after compaction.
|
|
3978
4805
|
const used = stats.promptTokens;
|
|
3979
|
-
lines.push(...meter(used / window, `${humanTokens(used)}
|
|
4806
|
+
lines.push(...meter(Math.min(1, used / window), `${humanTokens(used)} session prompt total · ${humanTokens(window)} window · ${cfg.model}`));
|
|
3980
4807
|
if (used / window > 0.7) {
|
|
3981
4808
|
lines.push('Close to the window. `/clear` starts fresh,');
|
|
3982
4809
|
lines.push('`/rewind` drops just the last exchange.');
|
|
@@ -3992,7 +4819,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3992
4819
|
lines.push('so there is nothing honest to draw a bar against.');
|
|
3993
4820
|
}
|
|
3994
4821
|
lines.push('');
|
|
3995
|
-
lines.push(`
|
|
4822
|
+
lines.push(`Session total: ${humanTokens(stats.promptTokens)} prompt · ${humanTokens(stats.completionTokens)} completion.`);
|
|
3996
4823
|
}
|
|
3997
4824
|
if (usagePane === 'models') {
|
|
3998
4825
|
const statuses = readModelStatus();
|
|
@@ -4102,7 +4929,11 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
4102
4929
|
return (_jsx(Box, { marginTop: 1, justifyContent: "flex-end", children: _jsx(Panel, { width: Math.min(barWidth, 62), color: AMBER, title: "KONECK \u00B7 updates & guide", children: lines }) }));
|
|
4103
4930
|
})(), picker && (() => {
|
|
4104
4931
|
const list = visiblePickerItems;
|
|
4105
|
-
|
|
4932
|
+
// In the alternate screen, the picker shares a fixed frame with a bordered composer and
|
|
4933
|
+
// footer. They consume six rows and the picker itself has six non-choice rows (margin,
|
|
4934
|
+
// border, title/search and pager), so reserve thirteen in total. Letting 16 choices into
|
|
4935
|
+
// a 24-row frame made Ink reflow the live region and visibly blink on every keypress.
|
|
4936
|
+
const pickerRows = pickerRowLimit(picker.kind, list.length, ...(altScreen ? [termRows, 13] : []));
|
|
4106
4937
|
// Keep the highlighted row inside the visible window as the selection moves.
|
|
4107
4938
|
const start = Math.max(0, Math.min(pickIndex - Math.floor(pickerRows / 2), list.length - pickerRows));
|
|
4108
4939
|
const shown = list.slice(Math.max(0, start), Math.max(0, start) + pickerRows);
|
|
@@ -4117,11 +4948,11 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
4117
4948
|
// The header is a title on the left and a count on the right, on one line. Estimating the
|
|
4118
4949
|
// right-hand side at a constant made "Reference a file or directory" wrap onto two rows.
|
|
4119
4950
|
const countText = pickerCountText(pickIndex, list.length, picker.items.length);
|
|
4120
|
-
const headWidth = title
|
|
4951
|
+
const headWidth = displayWidth(title) + displayWidth(countText) + 3;
|
|
4121
4952
|
// Measure the filtered list, not just its current scroll window. A palette whose right
|
|
4122
4953
|
// edge breathes as the highlight crosses a long description feels broken even when the
|
|
4123
4954
|
// selection itself is correct.
|
|
4124
|
-
const rowWidth = Math.max(headWidth, ...list.map(it => 38 + (it.current ? 10 : 0) + it.desc
|
|
4955
|
+
const rowWidth = Math.max(headWidth, ...list.map(it => 38 + (it.current ? 10 : 0) + displayWidth(it.desc) + displayWidth(it.group ?? '') + 3));
|
|
4125
4956
|
const boxWidth = Math.max(40, Math.min(barWidth, rowWidth + 4));
|
|
4126
4957
|
const width = boxWidth - 4;
|
|
4127
4958
|
// Room for the longest label the list actually holds, rather than a constant. A session
|
|
@@ -4135,8 +4966,8 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
4135
4966
|
const selected = absolute === pickIndex;
|
|
4136
4967
|
const header = item.group && item.group !== lastGroup ? item.group : null;
|
|
4137
4968
|
lastGroup = item.group;
|
|
4138
|
-
const label = item.label
|
|
4139
|
-
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? G.caret + ' ' : ' '}${
|
|
4969
|
+
const label = pickerLabel(item.label, labelWidth);
|
|
4970
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? G.caret + ' ' : ' '}${label}` +
|
|
4140
4971
|
`${item.current ? '(current) ' : ''}${header ? `${header} · ` : ''}${item.desc}`, width) }) }, item.value + absolute));
|
|
4141
4972
|
}), list.length > pickerRows && (_jsxs(Text, { color: DIM, children: ["\u2191\u2193 navigate \u00B7 pgup/pgdn jump \u00B7 enter select \u00B7 showing ", Math.max(0, start) + 1, "\u2013", Math.max(0, start) + shown.length, " of ", list.length] }))] }));
|
|
4142
4973
|
})(), 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" })] })] })), ask && (() => {
|