koneck 2.29.0 → 2.30.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +83 -1
- package/dist/ink-chat.d.ts.map +1 -1
- package/dist/ink-chat.js +772 -53
- 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/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,9 +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';
|
|
48
50
|
import { isWorkspaceTrusted, trustWorkspace, untrustWorkspace } from './workspace-trust.js';
|
|
51
|
+
import { loadMCPConfig } from './mcp.js';
|
|
52
|
+
import { renameSymbol, extractInterface, moveSymbol } from './refactor.js';
|
|
49
53
|
const CYAN = '#54D7E7';
|
|
50
54
|
const GREEN = '#9BCB8F';
|
|
51
55
|
const CRIMSON = '#E8878D';
|
|
@@ -263,6 +267,10 @@ export function liveReplyLines(termRows) {
|
|
|
263
267
|
const rows = termRows && termRows > 0 ? termRows : 24;
|
|
264
268
|
return rows < 12 ? 1 : 2;
|
|
265
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
|
+
}
|
|
266
274
|
const SPINNER = G.spinner;
|
|
267
275
|
/**
|
|
268
276
|
* A highlight that sweeps left to right through the word, so the label itself looks like it is
|
|
@@ -512,11 +520,22 @@ const COMMANDS = [
|
|
|
512
520
|
{ cmd: '/endpoint', desc: "Show or change this provider's URL, e.g. a remote Ollama" },
|
|
513
521
|
{ cmd: '/redraw', desc: 'Repaint the terminal — same as ctrl+L' },
|
|
514
522
|
{ cmd: '/key', desc: 'Enter an API key for the current provider' },
|
|
523
|
+
{ cmd: '/logout', desc: 'Forget Koneck-stored API credentials; keep preferences' },
|
|
515
524
|
{ cmd: '/model', desc: 'Show or set the model by name' },
|
|
516
525
|
{ cmd: '/provider', desc: 'Show or switch provider' },
|
|
517
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' },
|
|
518
530
|
{ cmd: '/doctor', desc: 'Check the active provider, workspace, and developer tools' },
|
|
519
|
-
{ 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' },
|
|
520
539
|
{ cmd: '/sessions', desc: 'List running KONECK sessions and their state' },
|
|
521
540
|
{ cmd: '/send', desc: 'Hand work to another session: /send <repo> <instruction>' },
|
|
522
541
|
{ cmd: '/ask', desc: 'Ask another session and wait: /ask <repo> <question>' },
|
|
@@ -547,9 +566,14 @@ const COMMANDS = [
|
|
|
547
566
|
{ cmd: '/diff', desc: 'Show uncommitted git changes' },
|
|
548
567
|
{ cmd: '/changes', desc: 'Summarize staged, unstaged, and untracked changes' },
|
|
549
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' },
|
|
550
571
|
{ cmd: '/audit', desc: 'Show recent redacted tool activity' },
|
|
551
572
|
{ cmd: '/policy', desc: 'Show or initialize workspace safety rules' },
|
|
552
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' },
|
|
553
577
|
{ cmd: '/verify', desc: 'Run the project test command' },
|
|
554
578
|
{ cmd: '/rewind', desc: 'Drop the last exchange from history' },
|
|
555
579
|
{ cmd: '/copy', desc: 'Copy the last response to the clipboard' },
|
|
@@ -559,6 +583,25 @@ const COMMANDS = [
|
|
|
559
583
|
{ cmd: '/exit', desc: 'Save and exit' },
|
|
560
584
|
{ cmd: '/quit', desc: 'Save and exit (alias of /exit)' },
|
|
561
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'];
|
|
562
605
|
/** How hard the model is asked to think. Shown in the /effort picker and the footer. */
|
|
563
606
|
const EFFORT_BLURB = {
|
|
564
607
|
low: 'Brief answers, fewest tokens, fastest turnaround',
|
|
@@ -566,6 +609,12 @@ const EFFORT_BLURB = {
|
|
|
566
609
|
high: 'Thorough — covers edge cases, costs more tokens',
|
|
567
610
|
max: 'Most rigorous analysis available; slowest and priciest',
|
|
568
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
|
+
}
|
|
569
618
|
// Bracketed paste. A terminal that has it enabled wraps pasted text in these markers, which is
|
|
570
619
|
// the only reliable way to tell a paste from very fast typing. Ink strips the leading escape of
|
|
571
620
|
// the opening marker before handing input to useInput, so the literal it sees is "[200~".
|
|
@@ -597,7 +646,6 @@ const MAX_DIFF_FILES = 4;
|
|
|
597
646
|
/** The tabs of the usage pane, in the order they are shown. */
|
|
598
647
|
const USAGE_TABS = ['settings', 'session', 'context', 'models', 'limits'];
|
|
599
648
|
const MIN_REPLY_WIDTH = 24; // below this a table cannot render legibly anyway
|
|
600
|
-
const PICKER_MIN_ROWS = 6;
|
|
601
649
|
const PICKER_MAX_ROWS = 16;
|
|
602
650
|
/**
|
|
603
651
|
* Number of choices to keep visible in a palette.
|
|
@@ -606,8 +654,14 @@ const PICKER_MAX_ROWS = 16;
|
|
|
606
654
|
* files, sessions, and providers equally easy to scan; on short terminals it yields space to
|
|
607
655
|
* the composer and the rest remains available through search and paging.
|
|
608
656
|
*/
|
|
609
|
-
export function pickerRowLimit(kind, itemCount, terminalRows) {
|
|
610
|
-
|
|
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);
|
|
611
665
|
// Keep kind in the signature: it documents that this is the common policy for *every* picker
|
|
612
666
|
// and lets a future picker opt into a different policy without duplicating this calculation.
|
|
613
667
|
void kind;
|
|
@@ -681,6 +735,23 @@ export function commandSuggestion(command, commands = COMMANDS.map(item => item.
|
|
|
681
735
|
}
|
|
682
736
|
return distance <= maxDistance ? best : undefined;
|
|
683
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
|
+
}
|
|
684
755
|
/** Keep a small, useful recall buffer without repeating the same submitted task. */
|
|
685
756
|
export function appendDraftHistory(history, draft, limit = 100) {
|
|
686
757
|
const entry = draft.trim();
|
|
@@ -696,10 +767,12 @@ export function appendDraftHistory(history, draft, limit = 100) {
|
|
|
696
767
|
* would otherwise renumber the rows beneath it in one of the two paths and not the other.
|
|
697
768
|
*/
|
|
698
769
|
export function askOptions(tool, prefix) {
|
|
699
|
-
const options = [
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
+
}
|
|
703
776
|
if (prefix) {
|
|
704
777
|
options.push({
|
|
705
778
|
key: '3',
|
|
@@ -754,7 +827,7 @@ export function humanBytes(bytes) {
|
|
|
754
827
|
*/
|
|
755
828
|
export function sessionTitle(meta) {
|
|
756
829
|
if (meta.name)
|
|
757
|
-
return meta.name;
|
|
830
|
+
return redactSecrets(meta.name);
|
|
758
831
|
const task = redactSecrets(meta.task ?? '').replace(/\s+/g, ' ').trim();
|
|
759
832
|
if (!task || task === '(chat session)')
|
|
760
833
|
return meta.id;
|
|
@@ -801,12 +874,209 @@ export function pickerLabelWidth(labels, descs, width) {
|
|
|
801
874
|
const COMMANDS_THAT_MUST_WAIT = new Set([
|
|
802
875
|
'/clear', '/reset', '/new', '/retry', // would wipe the history or begin a new turn
|
|
803
876
|
'/model', '/provider', '/connect', '/key', '/endpoint', '/baseurl', // rebuild the session
|
|
877
|
+
'/logout', // drops this session's runtime credential
|
|
804
878
|
'/resume', '/fork', // replace it
|
|
805
879
|
'/revert', // undo files the turn may still be writing
|
|
806
|
-
'/init', '/setup',
|
|
880
|
+
'/init', '/setup', '/checkpoint', '/rename', '/extract', '/move', '/ship',
|
|
807
881
|
]);
|
|
808
882
|
/** Commands that cannot do anything useful without an argument, so they are offered for editing. */
|
|
809
|
-
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
|
+
}
|
|
810
1080
|
export function commandRunsWhileBusy(cmd) {
|
|
811
1081
|
return !COMMANDS_THAT_MUST_WAIT.has(cmd.toLowerCase());
|
|
812
1082
|
}
|
|
@@ -829,8 +1099,19 @@ Slash commands:
|
|
|
829
1099
|
/endpoint <url> Point this provider somewhere else, e.g.
|
|
830
1100
|
/endpoint http://192.168.1.50:11434/v1
|
|
831
1101
|
/key Enter an API key for the current provider
|
|
1102
|
+
/logout Forget Koneck-stored API credentials; environment keys are untouched
|
|
832
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
|
|
833
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
|
|
834
1115
|
/clear /reset /new Clear conversation history
|
|
835
1116
|
/cancel Stop the current task; keeps the composer text
|
|
836
1117
|
/retry Run the last non-secret task again
|
|
@@ -848,14 +1129,19 @@ Slash commands:
|
|
|
848
1129
|
/diff Show uncommitted git changes
|
|
849
1130
|
/changes Summarize staged, unstaged, and untracked changes
|
|
850
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
|
|
851
1134
|
/audit [count] Show recent redacted tool activity
|
|
852
1135
|
/policy [init] Show or create workspace safety rules
|
|
853
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
|
|
854
1140
|
/verify Run project test command
|
|
855
1141
|
/agents How parallel sub-agents are configured
|
|
856
1142
|
/subtask <task> Run a task in an isolated sub-agent
|
|
857
1143
|
/rewind Undo last exchange (chat history only)
|
|
858
|
-
/revert [list|<id>]
|
|
1144
|
+
/revert [list|<id>] Restore an edit checkpoint; add --preview to inspect first
|
|
859
1145
|
/save Save session to disk
|
|
860
1146
|
/sessions List recent saved sessions
|
|
861
1147
|
/exit /quit Save and exit
|
|
@@ -872,6 +1158,30 @@ Keyboard:
|
|
|
872
1158
|
* Only on a real terminal. A pipe has no cursor to move and no rows to fill, and writing the
|
|
873
1159
|
* enter-alternate-screen sequence into a log file is how a captured session becomes unreadable.
|
|
874
1160
|
*/
|
|
1161
|
+
/**
|
|
1162
|
+
* Whether a chunk of terminal input is a mouse report rather than typing.
|
|
1163
|
+
*
|
|
1164
|
+
* Two encodings. SGR (`ESC [ < button ; col ; row M`) is what KONECK asks for, and the older X10
|
|
1165
|
+
* form (`ESC [ M` followed by three raw bytes) is what a terminal may send anyway — a session that
|
|
1166
|
+
* had tracking enabled by something else, or one that ignores the SGR request. Both are recognised
|
|
1167
|
+
* with and without the leading escape, because the escape can arrive in a previous read.
|
|
1168
|
+
*
|
|
1169
|
+
* Deliberately generous: a false positive swallows one keystroke, and a false negative pastes
|
|
1170
|
+
* "[<0;30;38M" into whatever you were writing.
|
|
1171
|
+
*/
|
|
1172
|
+
export function looksLikeMouseReport(input) {
|
|
1173
|
+
if (!input)
|
|
1174
|
+
return false;
|
|
1175
|
+
const ESC = String.fromCharCode(27);
|
|
1176
|
+
// SGR: the form KONECK enables. Matched anywhere, since a burst can carry several.
|
|
1177
|
+
if (input.includes(ESC + '[<') || input.includes('[<')) {
|
|
1178
|
+
return /(?:\u001b)?\[<\d+;\d+;\d+[Mm]/.test(input);
|
|
1179
|
+
}
|
|
1180
|
+
// X10: three bytes after ESC [ M, any of which can be unprintable.
|
|
1181
|
+
if (input.includes(ESC + '[M') || input.startsWith('[M'))
|
|
1182
|
+
return true;
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
875
1185
|
export function defaultAltScreen(stdout = process.stdout, env = process.env) {
|
|
876
1186
|
if (!stdout.isTTY)
|
|
877
1187
|
return false;
|
|
@@ -886,6 +1196,9 @@ export function defaultAltScreen(stdout = process.stdout, env = process.env) {
|
|
|
886
1196
|
function App({ config: initialConfig, clearFrame }) {
|
|
887
1197
|
const { exit } = useApp();
|
|
888
1198
|
const [cfg, setCfg] = useState(initialConfig);
|
|
1199
|
+
// Tool approval callbacks belong to a session created on an earlier render. Keep the control
|
|
1200
|
+
// posture live so turning approval on during a turn governs its next consequential tool call.
|
|
1201
|
+
const cfgRef = useRef(initialConfig);
|
|
889
1202
|
// Live activity accumulates in a ref and is painted by the 100 ms ticker. Mutating a ref
|
|
890
1203
|
// instead of calling setState per token keeps a Raspberry Pi from re-rendering on every chunk.
|
|
891
1204
|
const sayRef = useRef(''); // narration not yet committed
|
|
@@ -1090,8 +1403,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1090
1403
|
* reads dozens of times a minute, and a question per call would teach anyone to hit yes without
|
|
1091
1404
|
* looking, which is worse than not asking at all.
|
|
1092
1405
|
*/
|
|
1093
|
-
async function approveTool(tool, args) {
|
|
1094
|
-
|
|
1406
|
+
async function approveTool(tool, args, policyRequiresApproval = false) {
|
|
1407
|
+
if (readOnlyReviewRef.current)
|
|
1408
|
+
return readOnlyReviewAllows(tool, args);
|
|
1409
|
+
const verdict = inkApprovalDecision(cfgRef.current.requireApproval, modeRef.current, tool, args, rulesRef.current, policyRequiresApproval);
|
|
1095
1410
|
if (verdict === 'allow')
|
|
1096
1411
|
return true;
|
|
1097
1412
|
if (verdict === 'deny')
|
|
@@ -1113,6 +1428,8 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1113
1428
|
showNextAsk();
|
|
1114
1429
|
});
|
|
1115
1430
|
}
|
|
1431
|
+
/** The endpoint's answer wins over a catalogue or name-based reference. */
|
|
1432
|
+
const [detectedContext, setDetectedContext] = useState(null);
|
|
1116
1433
|
const sessionOpts = {
|
|
1117
1434
|
approve: approveTool,
|
|
1118
1435
|
onPlan: (next) => {
|
|
@@ -1180,6 +1497,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1180
1497
|
lastActivityRef.current = Date.now();
|
|
1181
1498
|
},
|
|
1182
1499
|
onAgentProgress: (list) => { agentsRef.current = list; setAgents(list); },
|
|
1500
|
+
onWindowDetected: (limit, source) => setDetectedContext({ limit, source }),
|
|
1183
1501
|
onTokens: (s) => {
|
|
1184
1502
|
realTokRef.current = Math.max(0, s.completionTokens - tokBaseRef.current);
|
|
1185
1503
|
},
|
|
@@ -1196,11 +1514,23 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1196
1514
|
const historyDraftRef = useRef('');
|
|
1197
1515
|
/** Last agent task eligible for one-command recovery; never populated from secret-looking input. */
|
|
1198
1516
|
const retryTaskRef = useRef(null);
|
|
1517
|
+
/** A command may start a detailed agent brief without putting implementation prose in user history. */
|
|
1518
|
+
const generatedTaskRef = useRef(null);
|
|
1519
|
+
/** Applies only while a generated review is in flight, never to the user's next task. */
|
|
1520
|
+
const readOnlyReviewRef = useRef(false);
|
|
1199
1521
|
/** Caret offset into `draft`. Kept beside it so every edit can place the caret deliberately. */
|
|
1200
1522
|
const [caret, setCaret] = useState(0);
|
|
1201
1523
|
const [busy, setBusy] = useState(false);
|
|
1202
1524
|
const [agentState, setAgentState] = useState('ready');
|
|
1203
1525
|
const [mode, setMode] = useState('auto');
|
|
1526
|
+
const [goal, setGoal] = useState(null);
|
|
1527
|
+
// A command can change the goal while a turn is finishing. The autosave callback from that
|
|
1528
|
+
// earlier turn must save the latest intent, not the state snapshot it originally captured.
|
|
1529
|
+
const goalRef = useRef(null);
|
|
1530
|
+
function setSessionGoal(next) {
|
|
1531
|
+
goalRef.current = next;
|
|
1532
|
+
setGoal(next);
|
|
1533
|
+
}
|
|
1204
1534
|
/**
|
|
1205
1535
|
* Permissions granted for this workspace, and the question currently on screen.
|
|
1206
1536
|
*
|
|
@@ -1476,7 +1806,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1476
1806
|
const [picker, setPicker] = useState(null);
|
|
1477
1807
|
const [pickIndex, setPickIndex] = useState(0);
|
|
1478
1808
|
const [pickQuery, setPickQuery] = useState('');
|
|
1479
|
-
const visiblePickerItems = React.useMemo(() => (picker ?
|
|
1809
|
+
const visiblePickerItems = React.useMemo(() => (picker ? (picker.kind === 'command'
|
|
1810
|
+
? commandPaletteMatches(picker.items, pickQuery)
|
|
1811
|
+
: filterPickerItems(picker.items, pickQuery)) : []), [picker, pickQuery]);
|
|
1480
1812
|
const [inboxTick, setInboxTick] = useState(0);
|
|
1481
1813
|
// One object, not three separate states. Ink throttles its writes with leading and trailing
|
|
1482
1814
|
// edges, so three setState calls in a tick let a single logical frame be written twice — which
|
|
@@ -1579,10 +1911,15 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1579
1911
|
for (const { from, payload } of queued) {
|
|
1580
1912
|
addRow({ role: 'steps', steps: [{ kind: 'say',
|
|
1581
1913
|
text: `Handoff from ${from.repoName} (${from.cwd})\n\n${payload.summary}` +
|
|
1914
|
+
(payload.goal ? `\n\nMission: ${payload.goal}` : '') +
|
|
1582
1915
|
(payload.changedFiles.length ? `\n\nChanged: ${payload.changedFiles.join(', ')}` : '') }] });
|
|
1583
1916
|
}
|
|
1584
1917
|
const combined = queued.map(q => `[Handoff from the KONECK session in ${q.from.repoName} (${q.from.cwd})]\n` +
|
|
1585
1918
|
`Context: ${q.payload.summary}\n` +
|
|
1919
|
+
(q.payload.goal ? `Sender's mission: ${q.payload.goal}\n` : '') +
|
|
1920
|
+
(q.payload.senderPosture
|
|
1921
|
+
? `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`
|
|
1922
|
+
: '') +
|
|
1586
1923
|
(q.payload.changedFiles.length ? `Files they changed: ${q.payload.changedFiles.join(', ')}\n` : '') +
|
|
1587
1924
|
`Your task: ${q.payload.instruction}`).join('\n\n');
|
|
1588
1925
|
submitDraft(combined);
|
|
@@ -1596,8 +1933,8 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1596
1933
|
busyStart.current = Date.now();
|
|
1597
1934
|
wordTimer.current = 0;
|
|
1598
1935
|
// The frame rate is the flicker rate. Every tick makes Ink update the live region, so network
|
|
1599
|
-
// activity must be sampled for a human reader rather than mirrored frame-for-frame.
|
|
1600
|
-
//
|
|
1936
|
+
// activity must be sampled for a human reader rather than mirrored frame-for-frame. Half a
|
|
1937
|
+
// second keeps streamed prose feeling live; an idle tool only needs a once-a-second
|
|
1601
1938
|
// heartbeat. This is particularly important in terminals that repaint an ANSI line update as
|
|
1602
1939
|
// a flash.
|
|
1603
1940
|
let timer;
|
|
@@ -1622,9 +1959,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1622
1959
|
};
|
|
1623
1960
|
const currentDelay = () => {
|
|
1624
1961
|
const idleFor = Date.now() - lastActivityRef.current;
|
|
1625
|
-
return idleFor
|
|
1962
|
+
return liveRefreshDelay(idleFor);
|
|
1626
1963
|
};
|
|
1627
|
-
timer = setTimeout(tick,
|
|
1964
|
+
timer = setTimeout(tick, liveRefreshDelay(0));
|
|
1628
1965
|
return () => clearTimeout(timer);
|
|
1629
1966
|
}, [busy]);
|
|
1630
1967
|
// A queued prompt runs the moment the agent is free again. Keyed on `busy` rather than done
|
|
@@ -1697,11 +2034,22 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1697
2034
|
setDraft(recalled);
|
|
1698
2035
|
setCaret(recalled.length);
|
|
1699
2036
|
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Starts a normal agent turn while preserving the short command the developer actually chose in
|
|
2039
|
+
* the transcript and recall history. This keeps /review and /init inspectable instead of
|
|
2040
|
+
* exposing their long internal instruction as a fake user message.
|
|
2041
|
+
*/
|
|
2042
|
+
function submitGeneratedTask(task, label, readOnly = false) {
|
|
2043
|
+
generatedTaskRef.current = { task, label, readOnly };
|
|
2044
|
+
submitDraft(task);
|
|
2045
|
+
}
|
|
1700
2046
|
async function getSession() {
|
|
1701
2047
|
return activeSession.p;
|
|
1702
2048
|
}
|
|
1703
2049
|
async function resetSession(newCfg) {
|
|
1704
2050
|
const c = newCfg ?? cfg;
|
|
2051
|
+
cfgRef.current = c;
|
|
2052
|
+
setDetectedContext(null);
|
|
1705
2053
|
activeSession.p = createAgentSession({ ...c, silent: true, ...sessionOpts });
|
|
1706
2054
|
if (newCfg)
|
|
1707
2055
|
setCfg(newCfg);
|
|
@@ -1763,6 +2111,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1763
2111
|
...(existing?.name ? { name: existing.name } : {}),
|
|
1764
2112
|
...(existing?.archived ? { archived: existing.archived } : {}),
|
|
1765
2113
|
...(existing?.forkedFrom ? { forkedFrom: existing.forkedFrom } : {}),
|
|
2114
|
+
...(goalRef.current ? { goal: goalRef.current } : {}),
|
|
2115
|
+
mode,
|
|
2116
|
+
effort,
|
|
2117
|
+
requireApproval: cfgRef.current.requireApproval,
|
|
1766
2118
|
id, task, provider: cfg.provider, model: cfg.model,
|
|
1767
2119
|
cwd: cfg.cwd, startedAt: startedAtRef.current,
|
|
1768
2120
|
turns: live.turns, totalTokens: live.totalTokens,
|
|
@@ -1781,12 +2133,12 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1781
2133
|
.filter(st => ['write_file', 'search_replace', 'apply_diff', 'move_file', 'delete_file'].includes(st.name))
|
|
1782
2134
|
.map(st => st.detail)
|
|
1783
2135
|
.filter(Boolean)))].slice(0, 12);
|
|
1784
|
-
const transcript = rows
|
|
2136
|
+
const transcript = handoffSummaryInput(rows
|
|
1785
2137
|
.filter(r => r.role === 'user' || r.role === 'agent')
|
|
1786
2138
|
.filter(r => !(r.role === 'user' && (r.text ?? '').startsWith('/')))
|
|
1787
2139
|
.slice(-8)
|
|
1788
2140
|
.map(r => `${r.role === 'user' ? 'User' : 'Agent'}: ${(r.text ?? '').slice(0, 400)}`)
|
|
1789
|
-
.join('\n');
|
|
2141
|
+
.join('\n'));
|
|
1790
2142
|
let summary = transcript.trim()
|
|
1791
2143
|
? transcript.slice(0, 600)
|
|
1792
2144
|
: 'The sending session has not done any work yet; this is a fresh instruction.';
|
|
@@ -1809,7 +2161,13 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1809
2161
|
}
|
|
1810
2162
|
catch { /* the raw tail is a serviceable fallback */ }
|
|
1811
2163
|
}
|
|
1812
|
-
return {
|
|
2164
|
+
return {
|
|
2165
|
+
summary,
|
|
2166
|
+
changedFiles: changed,
|
|
2167
|
+
instruction,
|
|
2168
|
+
...(goalRef.current ? { goal: goalRef.current } : {}),
|
|
2169
|
+
senderPosture: { mode: modeRef.current, effort, requireApproval: cfgRef.current.requireApproval },
|
|
2170
|
+
};
|
|
1813
2171
|
}
|
|
1814
2172
|
/**
|
|
1815
2173
|
* Replaces the live conversation with a saved one. The agent session is rebuilt from the
|
|
@@ -1819,6 +2177,25 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1819
2177
|
async function resumeSession(id) {
|
|
1820
2178
|
try {
|
|
1821
2179
|
const { meta, messages } = await loadSession(cfg.cwd, id);
|
|
2180
|
+
// A resume is a continuation, not just a message import. If it silently swaps in today's
|
|
2181
|
+
// model or loosens yesterday's careful mode, the copied transcript has a different meaning.
|
|
2182
|
+
const provider = meta.provider || cfg.provider;
|
|
2183
|
+
const restoredCfg = {
|
|
2184
|
+
...cfg,
|
|
2185
|
+
provider,
|
|
2186
|
+
model: meta.model || cfg.model,
|
|
2187
|
+
baseURL: endpointFor(provider).url,
|
|
2188
|
+
...(typeof meta.requireApproval === 'boolean' ? { requireApproval: meta.requireApproval } : {}),
|
|
2189
|
+
};
|
|
2190
|
+
cfgRef.current = restoredCfg;
|
|
2191
|
+
setCfg(restoredCfg);
|
|
2192
|
+
if (meta.mode && MODES.includes(meta.mode)) {
|
|
2193
|
+
modeRef.current = meta.mode;
|
|
2194
|
+
setMode(meta.mode);
|
|
2195
|
+
}
|
|
2196
|
+
if (meta.effort && ['low', 'medium', 'high', 'max'].includes(meta.effort)) {
|
|
2197
|
+
setEffort(meta.effort);
|
|
2198
|
+
}
|
|
1822
2199
|
// The plan comes back with the conversation. Without it the model reads its own history
|
|
1823
2200
|
// and writes a new plan from scratch, which is the forgetting this was built to stop.
|
|
1824
2201
|
const restoredPlan = meta.plan?.steps?.length
|
|
@@ -1829,9 +2206,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1829
2206
|
...(st.note ? { note: st.note } : {}),
|
|
1830
2207
|
})), updatedAt: Date.now() }
|
|
1831
2208
|
: undefined;
|
|
1832
|
-
activeSession.p = createAgentSession({ ...
|
|
2209
|
+
activeSession.p = createAgentSession({ ...restoredCfg, silent: true, ...sessionOpts }, messages, { turns: meta.turns, totalTokens: meta.totalTokens }, restoredPlan);
|
|
1833
2210
|
if (restoredPlan)
|
|
1834
2211
|
setPlan(restoredPlan);
|
|
2212
|
+
setSessionGoal(meta.goal ?? null);
|
|
1835
2213
|
setSessionId(id); // continue this session rather than forking a new one
|
|
1836
2214
|
resumedIdRef.current = id;
|
|
1837
2215
|
startedAtRef.current = meta.startedAt ?? startedAtRef.current;
|
|
@@ -1852,6 +2230,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1852
2230
|
addSystem(`Resumed ${id}\n` +
|
|
1853
2231
|
` ${meta.turns} turns, ${meta.totalTokens.toLocaleString()} tokens\n` +
|
|
1854
2232
|
` originally: ${meta.provider}/${meta.model}\n` +
|
|
2233
|
+
` posture: ${meta.mode ?? 'auto'} mode · ${meta.effort ?? 'medium'} effort · approval ${typeof meta.requireApproval === 'boolean' ? (meta.requireApproval ? 'on' : 'off') : 'current'}\n` +
|
|
1855
2234
|
` task: ${meta.task.slice(0, 90)}\n\n` +
|
|
1856
2235
|
`The agent has the full history; carry on where it left off.`);
|
|
1857
2236
|
}
|
|
@@ -1892,6 +2271,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1892
2271
|
case 'baseURL': return endpointFor(cfg.provider).url;
|
|
1893
2272
|
case 'effort': return effort;
|
|
1894
2273
|
case 'requireApproval': return cfg.requireApproval;
|
|
2274
|
+
case 'useWorktree': return cfg.useWorktree;
|
|
1895
2275
|
case 'maxTurns': return cfg.maxTurns;
|
|
1896
2276
|
case 'agentConcurrency': return cfg.agentConcurrency;
|
|
1897
2277
|
case 'maxWidth': return configMaxWidth;
|
|
@@ -1973,6 +2353,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
1973
2353
|
}
|
|
1974
2354
|
case 'maxTurns':
|
|
1975
2355
|
case 'requireApproval':
|
|
2356
|
+
case 'useWorktree':
|
|
1976
2357
|
case 'agentConcurrency':
|
|
1977
2358
|
case 'budget': {
|
|
1978
2359
|
// These shape the session itself, so it is rebuilt on the new values.
|
|
@@ -2016,8 +2397,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2016
2397
|
const { runAgent } = await import('./engine.js');
|
|
2017
2398
|
const result = await runAgent(question, {
|
|
2018
2399
|
...cfg, ci: true, silent: true,
|
|
2019
|
-
//
|
|
2020
|
-
//
|
|
2400
|
+
// This is enforced at the engine approval boundary, not left as a prompt suggestion:
|
|
2401
|
+
// a concurrent side question cannot edit, run a command, persist memory, or call MCP.
|
|
2402
|
+
approve: async (tool) => asideAllowsTool(tool),
|
|
2021
2403
|
maxTurns: Math.min(cfg.maxTurns, 3),
|
|
2022
2404
|
});
|
|
2023
2405
|
const last = [...result.messages].reverse()
|
|
@@ -2259,7 +2641,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2259
2641
|
`Endpoint : ${endpoint}\n` +
|
|
2260
2642
|
`Model : ${cfg.model}\n` +
|
|
2261
2643
|
`Mode : ${modeSpec(mode).label} — ${modeSpec(mode).blurb}\n` +
|
|
2644
|
+
`Goal : ${goalRef.current ?? 'none'}\n` +
|
|
2262
2645
|
`Approval : ${cfg.requireApproval ? 'on' : 'off'}\n` +
|
|
2646
|
+
`Worktree : ${cfg.useWorktree ? 'default on for next CLI task' : 'off'}\n` +
|
|
2263
2647
|
// Said here because a sandbox nobody can confirm is a sandbox nobody should rely on,
|
|
2264
2648
|
// and on a machine with no way to confine commands the honest line is the important one.
|
|
2265
2649
|
`Sandbox : ${describeSandbox(resolveSandbox(cfg.sandbox, cfg.cwd, cfg.addDirs ?? []))}\n` +
|
|
@@ -2269,6 +2653,79 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2269
2653
|
`Cost : ${costStr}`);
|
|
2270
2654
|
return;
|
|
2271
2655
|
}
|
|
2656
|
+
case '/goal': {
|
|
2657
|
+
const raw = arg.trim();
|
|
2658
|
+
if (!raw) {
|
|
2659
|
+
addSystem(goalRef.current
|
|
2660
|
+
? `**Session goal**\n\n${goalRef.current}\n\nRun \`/goal clear\` to remove it.`
|
|
2661
|
+
: 'No session goal is set. Use `/goal <outcome>` to keep a long-running objective in view.');
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
if (raw.toLowerCase() === 'clear') {
|
|
2665
|
+
setSessionGoal(null);
|
|
2666
|
+
addSystem('Session goal cleared.');
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
const next = normalizeSessionGoal(raw);
|
|
2670
|
+
setSessionGoal(next);
|
|
2671
|
+
addSystem(`Session goal set:\n\n> ${next}\n\n` +
|
|
2672
|
+
'KONECK will include this outcome with future tasks in this session. It is saved with the session.');
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
case '/context': {
|
|
2676
|
+
addSystem(contextStatusText(cfg.model, stats.promptTokens, cfg.contextTokens, detectedContext));
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
case '/permissions': {
|
|
2680
|
+
const choice = arg.trim().toLowerCase();
|
|
2681
|
+
if (!choice) {
|
|
2682
|
+
addSystem(`**Approval posture**\n\n` +
|
|
2683
|
+
`Session approval: ${cfg.requireApproval ? 'on — KONECK asks before writes and non-read-only commands' : 'off — KONECK follows the selected mode and workspace policy'}\n` +
|
|
2684
|
+
`Mode: ${modeSpec(mode).label} — ${modeSpec(mode).blurb}\n\n` +
|
|
2685
|
+
'`/permissions on` keeps you in control of every consequential action.\n' +
|
|
2686
|
+
'`/permissions off` removes the extra session gate; workspace policy and plan mode still win.');
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
if (choice !== 'on' && choice !== 'off') {
|
|
2690
|
+
addSystem('Usage: `/permissions [on|off]`');
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
const next = choice === 'on';
|
|
2694
|
+
const merged = { ...cfg, requireApproval: next };
|
|
2695
|
+
cfgRef.current = merged;
|
|
2696
|
+
setCfg(merged);
|
|
2697
|
+
// A turn has a captured config. Rebuild only while idle; changing the promise mid-turn
|
|
2698
|
+
// would detach its callbacks. The approval callback reads the live cfg for this turn,
|
|
2699
|
+
// and the rebuilt session makes the new posture durable for every later turn.
|
|
2700
|
+
if (!busy)
|
|
2701
|
+
await resetSession(merged);
|
|
2702
|
+
addSystem(next
|
|
2703
|
+
? 'Approval on. KONECK will ask in this interface before writes and consequential commands. Read-only inspection stays uninterrupted.'
|
|
2704
|
+
: 'Approval off. KONECK follows the selected mode and workspace policy; plan mode and policy blocks still cannot be bypassed.');
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2707
|
+
case '/worktree': {
|
|
2708
|
+
const option = arg.trim().toLowerCase();
|
|
2709
|
+
if (option && option !== 'on' && option !== 'off') {
|
|
2710
|
+
addSystem('Usage: `/worktree [on|off]`');
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
if (!option) {
|
|
2714
|
+
addSystem(`Worktree isolation default: ${cfg.useWorktree ? 'on' : 'off'}\n` +
|
|
2715
|
+
'It applies when Koneck starts a new top-level CLI task. This active chat remains in its current workspace.');
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
const enabled = option === 'on';
|
|
2719
|
+
const saved = { ...(await loadKoneckConfig()), useWorktree: enabled };
|
|
2720
|
+
await saveKoneckConfig(saved);
|
|
2721
|
+
setStored(saved);
|
|
2722
|
+
const next = { ...cfg, useWorktree: enabled };
|
|
2723
|
+
cfgRef.current = next;
|
|
2724
|
+
setCfg(next);
|
|
2725
|
+
addSystem(`Worktree isolation default ${enabled ? 'on' : 'off'}. ` +
|
|
2726
|
+
'It will apply to the next top-level CLI task; this active chat stays in its current workspace.');
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2272
2729
|
case '/doctor': {
|
|
2273
2730
|
// A session can change provider, endpoint, or workspace after startup. Running the same
|
|
2274
2731
|
// checks here, against the *active* provider, means a diagnosis describes this session
|
|
@@ -2285,6 +2742,120 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2285
2742
|
}
|
|
2286
2743
|
return;
|
|
2287
2744
|
}
|
|
2745
|
+
case '/mcp': {
|
|
2746
|
+
const configured = await loadMCPConfig(cfg.cwd);
|
|
2747
|
+
const session = await activeSession.p.catch(() => null);
|
|
2748
|
+
addSystem(mcpStatusText(Object.keys(configured?.servers ?? {}), session?.mcpServers ?? []));
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
case '/init': {
|
|
2752
|
+
const option = arg.trim().toLowerCase();
|
|
2753
|
+
if (option && option !== '--force') {
|
|
2754
|
+
addSystem('Usage: `/init` creates KONECK.md. Use `/init --force` to refresh an existing file.');
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
const force = option === '--force';
|
|
2758
|
+
const briefing = path.join(cfg.cwd, 'KONECK.md');
|
|
2759
|
+
const exists = await fsp.access(briefing).then(() => true).catch(() => false);
|
|
2760
|
+
if (exists && !force) {
|
|
2761
|
+
addSystem('`KONECK.md` already exists. Its guidance is protected. Run `/init --force` to ask KONECK to refresh it.');
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
addSystem(exists
|
|
2765
|
+
? 'Refreshing `KONECK.md` with the current workspace context…'
|
|
2766
|
+
: 'Creating a workspace briefing in `KONECK.md`…');
|
|
2767
|
+
// Command palette selection temporarily owns `busy`; defer until it releases that small
|
|
2768
|
+
// command frame, so the generated task starts as a regular tracked agent turn.
|
|
2769
|
+
setTimeout(() => submitGeneratedTask(initWorkspaceTask(force), force ? '/init --force' : '/init'), 0);
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
case '/logout': {
|
|
2773
|
+
const stored = await loadGlobalConfig();
|
|
2774
|
+
const hadStoredKey = Boolean(stored.apiKey);
|
|
2775
|
+
await saveGlobalConfig(withoutStoredApiKey(stored));
|
|
2776
|
+
// The runtime key may have come from `/key`, setup, or global config. Dropping it and
|
|
2777
|
+
// rebuilding lets a deliberate environment credential take over without retaining a
|
|
2778
|
+
// stale in-memory secret in the active session.
|
|
2779
|
+
const cleared = { ...cfg, apiKey: undefined };
|
|
2780
|
+
cfgRef.current = cleared;
|
|
2781
|
+
setCfg(cleared);
|
|
2782
|
+
await resetSession(cleared);
|
|
2783
|
+
addSystem(hadStoredKey
|
|
2784
|
+
? 'Koneck-stored API key cleared. Provider and model preferences were kept. Environment credentials were not changed.'
|
|
2785
|
+
: 'No Koneck-stored API key was present. Any environment credentials were left untouched; this session’s temporary key was cleared.');
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
case '/checkpoint': {
|
|
2789
|
+
const label = arg.trim();
|
|
2790
|
+
if (isCheckpointListCommand(label)) {
|
|
2791
|
+
await handleCommand('/revert', 'list');
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
try {
|
|
2795
|
+
const checkpoint = await createWorkspaceCheckpoint(cfg.cwd, label || 'manual restore point');
|
|
2796
|
+
addSystem(checkpoint
|
|
2797
|
+
? `Restore point saved: ${checkpoint.id}\n ${checkpoint.label}\n\nReview with \`/diff\`, then use \`/revert ${checkpoint.id} --confirm\` to restore this workspace state.`
|
|
2798
|
+
: 'A whole-workspace restore point needs a git repository. File edits made by KONECK are still checkpointed automatically.');
|
|
2799
|
+
}
|
|
2800
|
+
catch (e) {
|
|
2801
|
+
addSystem(`Could not create restore point: ${e instanceof Error ? e.message : String(e)}`);
|
|
2802
|
+
}
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
case '/rename':
|
|
2806
|
+
case '/extract':
|
|
2807
|
+
case '/move': {
|
|
2808
|
+
const parts = parseCommandArguments(arg);
|
|
2809
|
+
const expected = cmd === '/rename' ? 2 : cmd === '/extract' ? 2 : 3;
|
|
2810
|
+
const usage = cmd === '/rename' ? '/rename <old> <new>'
|
|
2811
|
+
: cmd === '/extract' ? '/extract <file> <Class>'
|
|
2812
|
+
: '/move <symbol> <from> <to>';
|
|
2813
|
+
if (!parts || parts.length !== expected) {
|
|
2814
|
+
addSystem(`Usage: \`${usage}\`\n\nUse quotes around a path containing spaces.`);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
const policyReason = nativeRefactorPolicyReason(await loadPolicy(cfg.cwd));
|
|
2818
|
+
if (policyReason) {
|
|
2819
|
+
addSystem(`Native refactor blocked: ${policyReason}.\n\nUse a normal coding task instead; KONECK will enforce the policy for each proposed write.`);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
const approved = await approveTool('native_refactor', JSON.stringify({ command: `${cmd} ${arg}` }));
|
|
2823
|
+
if (!approved) {
|
|
2824
|
+
addSystem(`Refused: ${cmd}. No files were changed.`);
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
const label = `before ${cmd} ${redactSecrets(arg).slice(0, 96)}`;
|
|
2828
|
+
let checkpoint = null;
|
|
2829
|
+
try {
|
|
2830
|
+
checkpoint = await createWorkspaceCheckpoint(cfg.cwd, label);
|
|
2831
|
+
}
|
|
2832
|
+
catch { /* Git is optional */ }
|
|
2833
|
+
addSystem(`Running native TypeScript refactor: ${cmd}…`);
|
|
2834
|
+
try {
|
|
2835
|
+
if (cmd === '/rename') {
|
|
2836
|
+
const result = await renameSymbol(parts[0], parts[1], cfg.cwd, { announce: false });
|
|
2837
|
+
addSystem(`Renamed \`${parts[0]}\` → \`${parts[1]}\` in ${result.filesChanged.length} file(s) (${result.occurrences} occurrence(s)).` +
|
|
2838
|
+
(checkpoint
|
|
2839
|
+
? `\n\nRestore point: \`${checkpoint.id}\` — undo with \`/revert ${checkpoint.id} --confirm\`.`
|
|
2840
|
+
: '\n\nNo Git restore point was available. Run `/preflight` before committing.'));
|
|
2841
|
+
}
|
|
2842
|
+
else if (cmd === '/extract') {
|
|
2843
|
+
await extractInterface(parts[0], parts[1], cfg.cwd, { announce: false });
|
|
2844
|
+
addSystem(`Extracted interface for \`${parts[1]}\` from \`${parts[0]}\`.` +
|
|
2845
|
+
(checkpoint ? `\n\nRestore point: \`${checkpoint.id}\` — run \`/preflight\`, or \`/revert ${checkpoint.id} --confirm\` to undo.` : '\n\nRun `/preflight` before committing.'));
|
|
2846
|
+
}
|
|
2847
|
+
else {
|
|
2848
|
+
await moveSymbol(parts[0], parts[1], parts[2], cfg.cwd, { announce: false });
|
|
2849
|
+
addSystem(`Moved \`${parts[0]}\` from \`${parts[1]}\` to \`${parts[2]}\` and updated imports.` +
|
|
2850
|
+
(checkpoint ? `\n\nRestore point: \`${checkpoint.id}\` — run \`/preflight\`, or \`/revert ${checkpoint.id} --confirm\` to undo.` : '\n\nRun `/preflight` before committing.'));
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
catch (e) {
|
|
2854
|
+
addSystem(`Native refactor failed: ${e instanceof Error ? e.message : String(e)}` +
|
|
2855
|
+
(checkpoint ? `\n\nNothing was automatically reverted. Your pre-refactor restore point is \`${checkpoint.id}\`.` : ''));
|
|
2856
|
+
}
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2288
2859
|
case '/clear':
|
|
2289
2860
|
case '/reset':
|
|
2290
2861
|
case '/new': {
|
|
@@ -2297,6 +2868,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2297
2868
|
startedAtRef.current = new Date().toISOString();
|
|
2298
2869
|
setRows([{ role: 'header' }]);
|
|
2299
2870
|
setPlan(null);
|
|
2871
|
+
setSessionGoal(null);
|
|
2300
2872
|
setStats({ turns: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 });
|
|
2301
2873
|
addSystem('Conversation cleared. The previous session is kept; /resume lists it.');
|
|
2302
2874
|
return;
|
|
@@ -2586,9 +3158,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2586
3158
|
}
|
|
2587
3159
|
case '/diff': {
|
|
2588
3160
|
try {
|
|
2589
|
-
const [unstaged, staged] = await Promise.all([
|
|
3161
|
+
const [unstaged, staged, untracked] = await Promise.all([
|
|
2590
3162
|
execa('git', ['diff', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
2591
3163
|
execa('git', ['diff', '--cached', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3164
|
+
untrackedPatches(cfg.cwd),
|
|
2592
3165
|
]);
|
|
2593
3166
|
if (unstaged.exitCode !== 0 || staged.exitCode !== 0) {
|
|
2594
3167
|
addSystem('Not a git repository.');
|
|
@@ -2597,8 +3170,12 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2597
3170
|
const sections = [
|
|
2598
3171
|
(staged.all ?? '').trim() ? `**Staged**\n\n${(staged.all ?? '').trim()}` : '',
|
|
2599
3172
|
(unstaged.all ?? '').trim() ? `**Unstaged**\n\n${(unstaged.all ?? '').trim()}` : '',
|
|
3173
|
+
untracked.patches.length ? `**Untracked**\n\n${untracked.patches.join('\n\n')}` : '',
|
|
2600
3174
|
].filter(Boolean);
|
|
2601
|
-
|
|
3175
|
+
const omissions = untracked.omitted.length
|
|
3176
|
+
? `\n\nUntracked files not rendered: ${untracked.omitted.join(', ')}`
|
|
3177
|
+
: '';
|
|
3178
|
+
addSystem(sections.length ? sections.join('\n\n') + omissions : 'No staged, unstaged, or renderable untracked changes. `/changes` shows the complete file list.' + omissions);
|
|
2602
3179
|
}
|
|
2603
3180
|
catch {
|
|
2604
3181
|
addSystem('Not a git repository.');
|
|
@@ -2636,6 +3213,47 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2636
3213
|
}
|
|
2637
3214
|
return;
|
|
2638
3215
|
}
|
|
3216
|
+
case '/review':
|
|
3217
|
+
case '/code-review':
|
|
3218
|
+
case '/security-review': {
|
|
3219
|
+
const security = cmd === '/security-review';
|
|
3220
|
+
const option = arg.trim().toLowerCase();
|
|
3221
|
+
if ((security && option) || (!security && option && option !== '--fix')) {
|
|
3222
|
+
addSystem(security ? 'Usage: `/security-review`' : 'Usage: `/review [--fix]`');
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
try {
|
|
3226
|
+
const [status, patch] = await Promise.all([
|
|
3227
|
+
execa('git', ['status', '--short'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3228
|
+
// HEAD deliberately includes both staged and unstaged edits. The old legacy command
|
|
3229
|
+
// used plain `git diff`, so its review silently omitted work about to be committed.
|
|
3230
|
+
execa('git', ['diff', 'HEAD', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
|
|
3231
|
+
]);
|
|
3232
|
+
if (status.exitCode !== 0 || patch.exitCode !== 0) {
|
|
3233
|
+
addSystem('Reviews need a git repository in this workspace.');
|
|
3234
|
+
return;
|
|
3235
|
+
}
|
|
3236
|
+
if (!(status.all ?? '').trim()) {
|
|
3237
|
+
addSystem('Working tree is clean — there is nothing to review.');
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3240
|
+
const fix = option === '--fix';
|
|
3241
|
+
addSystem(security
|
|
3242
|
+
? '**Security review** — inspecting all current workspace changes…'
|
|
3243
|
+
: fix
|
|
3244
|
+
? '**Review + verified fixes** — inspecting all current workspace changes…'
|
|
3245
|
+
: '**Code review** — inspecting all current workspace changes…');
|
|
3246
|
+
// As with /init, the palette's short command frame must finish before this becomes the
|
|
3247
|
+
// normal tracked agent turn. The agent reads the repository itself instead of receiving
|
|
3248
|
+
// an unbounded diff pasted into the prompt.
|
|
3249
|
+
const label = security ? '/security-review' : fix ? '/review --fix' : '/review';
|
|
3250
|
+
setTimeout(() => submitGeneratedTask(reviewTask(security ? 'security' : 'code', fix), label, !fix), 0);
|
|
3251
|
+
}
|
|
3252
|
+
catch {
|
|
3253
|
+
addSystem('Reviews need a git repository in this workspace.');
|
|
3254
|
+
}
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
2639
3257
|
case '/quality': {
|
|
2640
3258
|
// The engine uses this exact gate after a coding turn. Exposing it in chat gives the user
|
|
2641
3259
|
// the same independent proof on demand, without the legacy TUI writing over Ink's frame.
|
|
@@ -2664,6 +3282,40 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2664
3282
|
}
|
|
2665
3283
|
return;
|
|
2666
3284
|
}
|
|
3285
|
+
case '/preflight': {
|
|
3286
|
+
addSystem('**Preflight** — checking patch integrity and detected quality gates…');
|
|
3287
|
+
try {
|
|
3288
|
+
const result = await runPreflight(cfg.cwd);
|
|
3289
|
+
setRows(prev => prev.slice(0, -1));
|
|
3290
|
+
addSystem(preflightReport(result));
|
|
3291
|
+
}
|
|
3292
|
+
catch (e) {
|
|
3293
|
+
setRows(prev => prev.slice(0, -1));
|
|
3294
|
+
addSystem(`Preflight could not finish: ${e instanceof Error ? e.message : String(e)}`);
|
|
3295
|
+
}
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
case '/ship': {
|
|
3299
|
+
if (arg.trim()) {
|
|
3300
|
+
addSystem('Usage: `/ship` — runs preflight, then starts an enforced read-only review if the gate passes.');
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
addSystem('**Ship check** — running deterministic readiness checks…');
|
|
3304
|
+
try {
|
|
3305
|
+
const result = await runPreflight(cfg.cwd);
|
|
3306
|
+
setRows(prev => prev.slice(0, -1));
|
|
3307
|
+
addSystem(preflightReport(result));
|
|
3308
|
+
if (!isPreflightReady(result))
|
|
3309
|
+
return;
|
|
3310
|
+
addSystem('**Ship review** — readiness passed. Inspecting the complete workspace in enforced read-only mode…');
|
|
3311
|
+
setTimeout(() => submitGeneratedTask(reviewTask('code'), '/ship', true), 0);
|
|
3312
|
+
}
|
|
3313
|
+
catch (e) {
|
|
3314
|
+
setRows(prev => prev.slice(0, -1));
|
|
3315
|
+
addSystem(`Ship check could not finish: ${e instanceof Error ? e.message : String(e)}`);
|
|
3316
|
+
}
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
2667
3319
|
case '/audit': {
|
|
2668
3320
|
const requested = Number.parseInt(arg, 10);
|
|
2669
3321
|
const limit = Number.isFinite(requested) ? Math.max(1, Math.min(requested, 100)) : 20;
|
|
@@ -2911,6 +3563,13 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2911
3563
|
await resetSession(next);
|
|
2912
3564
|
addSystem(`Sub-agent concurrency is now ${resolveAgentConcurrency(next.agentConcurrency)}.`);
|
|
2913
3565
|
}
|
|
3566
|
+
if (key === 'useWorktree' && ['true', 'false', '1', '0', 'yes', 'no'].includes(val.toLowerCase())) {
|
|
3567
|
+
const enabled = ['true', '1', 'yes'].includes(val.toLowerCase());
|
|
3568
|
+
const next = { ...cfg, useWorktree: enabled };
|
|
3569
|
+
cfgRef.current = next;
|
|
3570
|
+
setCfg(next);
|
|
3571
|
+
addSystem(`Worktree isolation default ${enabled ? 'on' : 'off'} for future top-level CLI tasks; this active chat stays in its current workspace.`);
|
|
3572
|
+
}
|
|
2914
3573
|
return;
|
|
2915
3574
|
}
|
|
2916
3575
|
if (key) {
|
|
@@ -2923,6 +3582,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
2923
3582
|
const descs = configKeyDescriptions();
|
|
2924
3583
|
const live = {
|
|
2925
3584
|
provider: cfg.provider, model: cfg.model, effort, requireApproval: cfg.requireApproval,
|
|
3585
|
+
useWorktree: cfg.useWorktree,
|
|
2926
3586
|
maxTurns: cfg.maxTurns, budget: cfg.budget, baseURL: cfg.baseURL,
|
|
2927
3587
|
maxWidth: configMaxWidth ?? `(not set - filling ${termCols} columns)`,
|
|
2928
3588
|
agentConcurrency: resolveAgentConcurrency(cfg.agentConcurrency),
|
|
@@ -3107,25 +3767,50 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3107
3767
|
addSystem('Checkpoints (newest first):\n' +
|
|
3108
3768
|
points.map(c => {
|
|
3109
3769
|
const when = new Date(c.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
3110
|
-
return ` ${c.id} ${when} ${c
|
|
3770
|
+
return ` ${c.id} ${when} ${checkpointScope(c)} ${c.label.slice(0, 48)}`;
|
|
3111
3771
|
}).join('\n') +
|
|
3112
3772
|
'\n\n/revert undo the most recent edit' +
|
|
3113
|
-
'\n/revert <id> undo a specific checkpoint'
|
|
3773
|
+
'\n/revert <id> undo a specific checkpoint' +
|
|
3774
|
+
'\n/revert <id> --preview inspect files that would change' +
|
|
3775
|
+
'\n/revert <id> --confirm restore a named workspace snapshot');
|
|
3114
3776
|
return;
|
|
3115
3777
|
}
|
|
3116
|
-
const
|
|
3778
|
+
const revertArgs = arg.trim().split(/\s+/).filter(Boolean);
|
|
3779
|
+
const confirmed = revertArgs.includes('--confirm');
|
|
3780
|
+
const preview = revertArgs.includes('--preview');
|
|
3781
|
+
const targetId = revertArgs.find(value => value !== '--confirm' && value !== '--preview');
|
|
3782
|
+
const target = targetId ? points.find(c => c.id === targetId) : points[0];
|
|
3117
3783
|
if (!target) {
|
|
3118
|
-
addSystem(`No checkpoint "${arg}". Use /revert list to see them.`);
|
|
3784
|
+
addSystem(`No checkpoint "${targetId ?? arg}". Use /revert list to see them.`);
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
if (preview) {
|
|
3788
|
+
const effect = await previewCheckpoint(cfg.cwd, target);
|
|
3789
|
+
const lines = [`Preview ${checkpointScope(target)} ${target.id} — "${target.label.slice(0, 60)}"`];
|
|
3790
|
+
lines.push(effect.changes.length
|
|
3791
|
+
? ` would change (${effect.changes.length}): ${checkpointPathSummary(effect.changes)}`
|
|
3792
|
+
: ' already matches this restore point; no user files would change');
|
|
3793
|
+
if (effect.skipped.length)
|
|
3794
|
+
lines.push(` cannot restore (${effect.skipped.length}): ${checkpointPathSummary(effect.skipped)}`);
|
|
3795
|
+
lines.push(`\nApply with /revert ${target.id}${target.tree ? ' --confirm' : ''}`);
|
|
3796
|
+
addSystem(lines.join('\n'));
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
if (target.tree && !confirmed) {
|
|
3800
|
+
addSystem(workspaceRestoreConfirmation(target.id, target.label));
|
|
3119
3801
|
return;
|
|
3120
3802
|
}
|
|
3121
3803
|
const r = await revertCheckpoint(cfg.cwd, target);
|
|
3122
|
-
const lines = [`
|
|
3804
|
+
const lines = [`Restored ${checkpointScope(target)} ${target.id} — "${target.label.slice(0, 60)}"`];
|
|
3805
|
+
if (target.tree && workspaceRestoreWasNoop(r)) {
|
|
3806
|
+
lines.push(' already matched the saved workspace state; no files changed');
|
|
3807
|
+
}
|
|
3123
3808
|
if (r.restored.length)
|
|
3124
|
-
lines.push(` restored
|
|
3809
|
+
lines.push(` restored (${r.restored.length}): ${checkpointPathSummary(r.restored)}`);
|
|
3125
3810
|
if (r.deleted.length)
|
|
3126
|
-
lines.push(` removed (did not exist before): ${r.deleted
|
|
3811
|
+
lines.push(` removed (${r.deleted.length}, did not exist before): ${checkpointPathSummary(r.deleted)}`);
|
|
3127
3812
|
if (r.skipped.length)
|
|
3128
|
-
lines.push(` skipped (
|
|
3813
|
+
lines.push(` skipped (${r.skipped.length}): ${checkpointPathSummary(r.skipped)}`);
|
|
3129
3814
|
if (r.undoId)
|
|
3130
3815
|
lines.push(`\nTo undo this revert: /revert ${r.undoId}`);
|
|
3131
3816
|
addSystem(lines.join('\n'));
|
|
@@ -3152,8 +3837,13 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3152
3837
|
{
|
|
3153
3838
|
const suggestion = commandSuggestion(cmd);
|
|
3154
3839
|
addSystem(suggestion
|
|
3155
|
-
? `Unknown command: ${cmd}. Did you mean \`${suggestion}
|
|
3840
|
+
? `Unknown command: ${cmd}. Did you mean \`${suggestion}\`? It is ready in the composer; press Enter to run it.`
|
|
3156
3841
|
: `Unknown command: ${cmd}. Type /help for the full list.`);
|
|
3842
|
+
if (suggestion) {
|
|
3843
|
+
const recovered = recoveredCommandDraft(suggestion, arg);
|
|
3844
|
+
setDraft(recovered);
|
|
3845
|
+
setCaret(recovered.length);
|
|
3846
|
+
}
|
|
3157
3847
|
}
|
|
3158
3848
|
}
|
|
3159
3849
|
}
|
|
@@ -3188,6 +3878,19 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3188
3878
|
setCaret(c => c + token.length);
|
|
3189
3879
|
}
|
|
3190
3880
|
useInput((input, key) => {
|
|
3881
|
+
// ── Mouse reports are not typing ────────────────────────────────────────
|
|
3882
|
+
//
|
|
3883
|
+
// Ink hands any escape sequence it does not recognise to the application as `input`, and a
|
|
3884
|
+
// wheel notch or a click is such a sequence. Reported as clicking or scrolling pasting
|
|
3885
|
+
// "[<0;30;38M[<64;37;39M…" into the composer — which is exactly what happened, because every
|
|
3886
|
+
// branch below that appends `input` to the draft did so.
|
|
3887
|
+
//
|
|
3888
|
+
// The previous attempt at this only excused mouse reports from the rule that returns the view
|
|
3889
|
+
// to the tail. That fixed the scroll and left the sequence being typed, and the test I wrote
|
|
3890
|
+
// checked only that the scroll happened. Swallowed here instead: the wheel is read from stdin
|
|
3891
|
+
// directly, so nothing below needs mouse data at all.
|
|
3892
|
+
if (looksLikeMouseReport(input))
|
|
3893
|
+
return;
|
|
3191
3894
|
// ── Bracketed paste ─────────────────────────────────────────────────────
|
|
3192
3895
|
// This runs before every other branch. A paste carries newlines, and any handler that treats
|
|
3193
3896
|
// a newline as "submit" would fire partway through one.
|
|
@@ -3260,9 +3963,9 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3260
3963
|
// wheel notch arrived here as a keystroke and snapped the view straight back to the bottom:
|
|
3261
3964
|
// the scroll worked and was undone in the same frame, which looked exactly like the wheel
|
|
3262
3965
|
// doing nothing at all.
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3966
|
+
// Mouse reports never reach here — they are swallowed at the top of this handler — so any
|
|
3967
|
+
// input at this point really is somebody typing.
|
|
3968
|
+
if (scrollBack > 0 && (key.return || (input && !key.ctrl && !key.meta)))
|
|
3266
3969
|
setScrollBack(0);
|
|
3267
3970
|
}
|
|
3268
3971
|
// Asked and waiting. Nothing else can be typed until it is answered, because whatever the
|
|
@@ -3334,12 +4037,12 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3334
4037
|
return;
|
|
3335
4038
|
}
|
|
3336
4039
|
if (key.pageDown) {
|
|
3337
|
-
const jump = pickerRowLimit(picker.kind, list.length, termRows);
|
|
4040
|
+
const jump = pickerRowLimit(picker.kind, list.length, ...(altScreen ? [termRows, 13] : []));
|
|
3338
4041
|
setPickIndex(i => Math.min(i + jump, Math.max(0, list.length - 1)));
|
|
3339
4042
|
return;
|
|
3340
4043
|
}
|
|
3341
4044
|
if (key.pageUp) {
|
|
3342
|
-
const jump = pickerRowLimit(picker.kind, list.length, termRows);
|
|
4045
|
+
const jump = pickerRowLimit(picker.kind, list.length, ...(altScreen ? [termRows, 13] : []));
|
|
3343
4046
|
setPickIndex(i => Math.max(0, i - jump));
|
|
3344
4047
|
return;
|
|
3345
4048
|
}
|
|
@@ -3652,7 +4355,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3652
4355
|
if (input === '/' && draft === '') {
|
|
3653
4356
|
setDraft('/');
|
|
3654
4357
|
setCaret(1);
|
|
3655
|
-
openPicker('command',
|
|
4358
|
+
openPicker('command', commandPaletteItems());
|
|
3656
4359
|
return;
|
|
3657
4360
|
}
|
|
3658
4361
|
// A held key arrives as one chunk that Ink does not recognise as a key at all: five
|
|
@@ -3721,10 +4424,16 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3721
4424
|
const task = expandPastes(shown, pastesRef.current).trim();
|
|
3722
4425
|
if (!task)
|
|
3723
4426
|
return;
|
|
4427
|
+
const generated = generatedTaskRef.current?.task === task ? generatedTaskRef.current : null;
|
|
4428
|
+
if (generated)
|
|
4429
|
+
generatedTaskRef.current = null;
|
|
4430
|
+
// This flag is intentionally attached to one generated task rather than the session mode.
|
|
4431
|
+
// A review must be unable to mutate; the next user-authored task must retain their posture.
|
|
4432
|
+
const readOnlyReview = generated?.readOnly === true;
|
|
3724
4433
|
// A recalled secret is just as dangerous as a saved one. Secret-looking input is sent only
|
|
3725
4434
|
// when the user explicitly submits it, and never retained for Up-arrow recall.
|
|
3726
4435
|
if (!containsSecret(task)) {
|
|
3727
|
-
draftHistoryRef.current = appendDraftHistory(draftHistoryRef.current, shown);
|
|
4436
|
+
draftHistoryRef.current = appendDraftHistory(draftHistoryRef.current, taskTranscriptLabel(shown, generated?.label));
|
|
3728
4437
|
}
|
|
3729
4438
|
historyIndexRef.current = null;
|
|
3730
4439
|
historyDraftRef.current = '';
|
|
@@ -3742,7 +4451,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3742
4451
|
'written into the session log.');
|
|
3743
4452
|
}
|
|
3744
4453
|
const mentions = findMentions(task, pathKind);
|
|
3745
|
-
const forDisplay = pasteForDisplay(mentions.length ? shortenMentions(task, mentions) : task);
|
|
4454
|
+
const forDisplay = taskTranscriptLabel(pasteForDisplay(mentions.length ? shortenMentions(task, mentions) : task), generated?.label);
|
|
3746
4455
|
addRow({ role: 'user', text: forDisplay });
|
|
3747
4456
|
if (isSlashCommand(task)) {
|
|
3748
4457
|
const spaceIdx = task.indexOf(' ');
|
|
@@ -3754,6 +4463,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3754
4463
|
}
|
|
3755
4464
|
if (!containsSecret(task))
|
|
3756
4465
|
retryTaskRef.current = task;
|
|
4466
|
+
readOnlyReviewRef.current = readOnlyReview;
|
|
3757
4467
|
setBusy(true);
|
|
3758
4468
|
setAgentState('processing');
|
|
3759
4469
|
const started = Date.now();
|
|
@@ -3777,7 +4487,10 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3777
4487
|
const outTokBefore = stats.completionTokens;
|
|
3778
4488
|
try {
|
|
3779
4489
|
const active = await getSession();
|
|
3780
|
-
const
|
|
4490
|
+
const persistentGoal = goalRef.current;
|
|
4491
|
+
const prefix = (persistentGoal ? `[Persistent session goal: ${persistentGoal}]\n` : '') +
|
|
4492
|
+
modeSpec(mode).prefix +
|
|
4493
|
+
(effort !== 'medium' ? `[Effort: ${effort}]\n` : '');
|
|
3781
4494
|
const controller = new AbortController();
|
|
3782
4495
|
abortRef.current = controller;
|
|
3783
4496
|
await active.send(prefix + task, controller.signal);
|
|
@@ -3874,6 +4587,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
3874
4587
|
}
|
|
3875
4588
|
finally {
|
|
3876
4589
|
abortRef.current = null;
|
|
4590
|
+
readOnlyReviewRef.current = false;
|
|
3877
4591
|
setBusy(false);
|
|
3878
4592
|
}
|
|
3879
4593
|
})();
|
|
@@ -4122,10 +4836,11 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
4122
4836
|
const window = modelCatalog.find(m => m.id === cfg.model)?.contextLength ?? contextWindowFor(cfg.model);
|
|
4123
4837
|
lines.push('**Context window**', '');
|
|
4124
4838
|
if (window) {
|
|
4125
|
-
//
|
|
4126
|
-
//
|
|
4839
|
+
// This is cumulative across the session, not a made-up claim about the next request.
|
|
4840
|
+
// It is still useful as a pressure signal: every long turn re-sends most of the
|
|
4841
|
+
// history, but only the provider can count the exact request after compaction.
|
|
4127
4842
|
const used = stats.promptTokens;
|
|
4128
|
-
lines.push(...meter(used / window, `${humanTokens(used)}
|
|
4843
|
+
lines.push(...meter(Math.min(1, used / window), `${humanTokens(used)} session prompt total · ${humanTokens(window)} window · ${cfg.model}`));
|
|
4129
4844
|
if (used / window > 0.7) {
|
|
4130
4845
|
lines.push('Close to the window. `/clear` starts fresh,');
|
|
4131
4846
|
lines.push('`/rewind` drops just the last exchange.');
|
|
@@ -4141,7 +4856,7 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
4141
4856
|
lines.push('so there is nothing honest to draw a bar against.');
|
|
4142
4857
|
}
|
|
4143
4858
|
lines.push('');
|
|
4144
|
-
lines.push(`
|
|
4859
|
+
lines.push(`Session total: ${humanTokens(stats.promptTokens)} prompt · ${humanTokens(stats.completionTokens)} completion.`);
|
|
4145
4860
|
}
|
|
4146
4861
|
if (usagePane === 'models') {
|
|
4147
4862
|
const statuses = readModelStatus();
|
|
@@ -4251,7 +4966,11 @@ function App({ config: initialConfig, clearFrame }) {
|
|
|
4251
4966
|
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 }) }));
|
|
4252
4967
|
})(), picker && (() => {
|
|
4253
4968
|
const list = visiblePickerItems;
|
|
4254
|
-
|
|
4969
|
+
// In the alternate screen, the picker shares a fixed frame with a bordered composer and
|
|
4970
|
+
// footer. They consume six rows and the picker itself has six non-choice rows (margin,
|
|
4971
|
+
// border, title/search and pager), so reserve thirteen in total. Letting 16 choices into
|
|
4972
|
+
// a 24-row frame made Ink reflow the live region and visibly blink on every keypress.
|
|
4973
|
+
const pickerRows = pickerRowLimit(picker.kind, list.length, ...(altScreen ? [termRows, 13] : []));
|
|
4255
4974
|
// Keep the highlighted row inside the visible window as the selection moves.
|
|
4256
4975
|
const start = Math.max(0, Math.min(pickIndex - Math.floor(pickerRows / 2), list.length - pickerRows));
|
|
4257
4976
|
const shown = list.slice(Math.max(0, start), Math.max(0, start) + pickerRows);
|