koneck 2.28.0 → 2.30.0-next.1

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