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.
Files changed (62) hide show
  1. package/README.md +32 -0
  2. package/dist/checkpoint.d.ts +20 -0
  3. package/dist/checkpoint.d.ts.map +1 -1
  4. package/dist/checkpoint.js +169 -10
  5. package/dist/checkpoint.js.map +1 -1
  6. package/dist/completion-notices.d.ts +8 -0
  7. package/dist/completion-notices.d.ts.map +1 -0
  8. package/dist/completion-notices.js +14 -0
  9. package/dist/completion-notices.js.map +1 -0
  10. package/dist/config-store.d.ts +2 -0
  11. package/dist/config-store.d.ts.map +1 -1
  12. package/dist/config-store.js +6 -2
  13. package/dist/config-store.js.map +1 -1
  14. package/dist/config.d.ts +9 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +28 -1
  17. package/dist/config.js.map +1 -1
  18. package/dist/engine.d.ts +9 -12
  19. package/dist/engine.d.ts.map +1 -1
  20. package/dist/engine.js +48 -26
  21. package/dist/engine.js.map +1 -1
  22. package/dist/git-diff.d.ts +16 -0
  23. package/dist/git-diff.d.ts.map +1 -0
  24. package/dist/git-diff.js +56 -0
  25. package/dist/git-diff.js.map +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +36 -28
  28. package/dist/index.js.map +1 -1
  29. package/dist/ink-chat.d.ts +83 -1
  30. package/dist/ink-chat.d.ts.map +1 -1
  31. package/dist/ink-chat.js +772 -53
  32. package/dist/ink-chat.js.map +1 -1
  33. package/dist/permissions.d.ts.map +1 -1
  34. package/dist/permissions.js +67 -9
  35. package/dist/permissions.js.map +1 -1
  36. package/dist/preflight.d.ts +24 -0
  37. package/dist/preflight.d.ts.map +1 -0
  38. package/dist/preflight.js +87 -0
  39. package/dist/preflight.js.map +1 -0
  40. package/dist/refactor.d.ts +7 -3
  41. package/dist/refactor.d.ts.map +1 -1
  42. package/dist/refactor.js +122 -35
  43. package/dist/refactor.js.map +1 -1
  44. package/dist/run-limits.d.ts +9 -0
  45. package/dist/run-limits.d.ts.map +1 -0
  46. package/dist/run-limits.js +13 -0
  47. package/dist/run-limits.js.map +1 -0
  48. package/dist/session-bus.d.ts +14 -0
  49. package/dist/session-bus.d.ts.map +1 -1
  50. package/dist/session-bus.js +24 -3
  51. package/dist/session-bus.js.map +1 -1
  52. package/dist/session.d.ts +20 -0
  53. package/dist/session.d.ts.map +1 -1
  54. package/dist/session.js +27 -7
  55. package/dist/session.js.map +1 -1
  56. package/dist/types.d.ts +7 -1
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/worktree.d.ts +2 -0
  59. package/dist/worktree.d.ts.map +1 -1
  60. package/dist/worktree.js +68 -4
  61. package/dist/worktree.js.map +1 -1
  62. 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: '/revert', desc: 'Undo file edits KONECK made' },
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
- const terminalBudget = Math.max(PICKER_MIN_ROWS, terminalRows - 8);
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
- { key: '1', label: 'allow once', rule: null, allow: true },
701
- { key: '2', label: `always allow ${tool} in this workspace`, rule: { tool }, allow: true },
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>] Undo file edits KONECK made — restores from a checkpoint
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
- const verdict = decide(modeRef.current, tool, args, rulesRef.current);
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 ? filterPickerItems(picker.items, pickQuery) : []), [picker, pickQuery]);
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. A third
1600
- // of a second keeps streamed prose feeling live; an idle tool only needs a once-a-second
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 < 2_000 ? 350 : 1_000;
1962
+ return liveRefreshDelay(idleFor);
1626
1963
  };
1627
- timer = setTimeout(tick, 350);
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 { summary, changedFiles: changed, instruction };
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({ ...cfg, silent: true, ...sessionOpts }, messages, { turns: meta.turns, totalTokens: meta.totalTokens }, restoredPlan);
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
- // A side question answers from what it is told; it must not edit files behind the
2020
- // running task's back, so it is given no reason to and kept to a couple of turns.
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
- addSystem(sections.length ? sections.join('\n\n') : 'No staged or unstaged changes. `/changes` also shows untracked files.');
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.files.length} file(s) ${c.label.slice(0, 48)}`;
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 target = arg ? points.find(c => c.id === arg) : points[0];
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 = [`Reverted checkpoint ${target.id} — "${target.label.slice(0, 60)}"`];
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: ${r.restored.join(', ')}`);
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.join(', ')}`);
3811
+ lines.push(` removed (${r.deleted.length}, did not exist before): ${checkpointPathSummary(r.deleted)}`);
3127
3812
  if (r.skipped.length)
3128
- lines.push(` skipped (too large to snapshot): ${r.skipped.join(', ')}`);
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
- const isMouseReport = input.includes('\u001b[<') || input.includes('[<');
3264
- if (scrollBack > 0 && !isMouseReport
3265
- && (key.return || (input && !key.ctrl && !key.meta)))
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', COMMANDS.map(c => ({ value: c.cmd, label: c.cmd, desc: c.desc })));
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 prefix = modeSpec(mode).prefix + (effort !== 'medium' ? `[Effort: ${effort}]\n` : '');
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
- // Prompt tokens on the last turn approximate what is resident: history plus system
4126
- // prompt plus tool results, which is exactly what fills a window.
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)} of ${humanTokens(window)} · ${cfg.model}`));
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(`Last turn sent ${humanTokens(stats.promptTokens)} and received ${humanTokens(stats.completionTokens)}.`);
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
- const pickerRows = pickerRowLimit(picker.kind, list.length, termRows);
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);