koneck 2.27.0 → 2.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ink-chat.js CHANGED
@@ -45,6 +45,7 @@ import { doctorChecks, doctorReport } from './doctor.js';
45
45
  import { runQualityGate } from './quality-gate.js';
46
46
  import { listAuditEvents } from './audit.js';
47
47
  import { initializePolicy, loadPolicy, POLICY_FILE } from './policy.js';
48
+ import { isWorkspaceTrusted, trustWorkspace, untrustWorkspace } from './workspace-trust.js';
48
49
  const CYAN = '#54D7E7';
49
50
  const GREEN = '#9BCB8F';
50
51
  const CRIMSON = '#E8878D';
@@ -130,6 +131,10 @@ function fitCells(text, width, ellipsis = false) {
130
131
  const head = sliceToWidth(text, Math.max(0, width - 3));
131
132
  return head.text + '.'.repeat(Math.min(3, width)) + ' '.repeat(Math.max(0, width - head.width - 3));
132
133
  }
134
+ /** A picker label uses all of its allocated column, only ellipsising at the actual boundary. */
135
+ export function pickerLabel(text, width) {
136
+ return fitCells(text, width, true);
137
+ }
133
138
  /**
134
139
  * Wraps text while keeping the shape it was written in.
135
140
  *
@@ -519,6 +524,9 @@ const COMMANDS = [
519
524
  { cmd: '/subtask', desc: 'Run a task in an isolated sub-agent' },
520
525
  { cmd: '/aside', desc: 'Ask a side question without interrupting the running task' },
521
526
  { cmd: '/clear', desc: 'Clear conversation history' },
527
+ { cmd: '/cancel', desc: 'Stop the current task and keep your composer text' },
528
+ { cmd: '/retry', desc: 'Run the last non-secret task again' },
529
+ { cmd: '/queue', desc: 'Inspect or clear prompts waiting behind the current task' },
522
530
  { cmd: '/new', desc: 'New session (alias of /clear)' },
523
531
  { cmd: '/reset', desc: 'Reset session (alias of /clear)' },
524
532
  { cmd: '/mode', desc: 'Set mode: auto, plan or code' },
@@ -537,9 +545,11 @@ const COMMANDS = [
537
545
  { cmd: '/fork', desc: 'Copy this session and continue on the copy' },
538
546
  { cmd: '/archive', desc: 'Keep a session but hide it from /resume' },
539
547
  { cmd: '/diff', desc: 'Show uncommitted git changes' },
548
+ { cmd: '/changes', desc: 'Summarize staged, unstaged, and untracked changes' },
540
549
  { cmd: '/quality', desc: 'Run the detected lint and test checks' },
541
550
  { cmd: '/audit', desc: 'Show recent redacted tool activity' },
542
551
  { cmd: '/policy', desc: 'Show or initialize workspace safety rules' },
552
+ { cmd: '/trust', desc: 'Show, persist, or revoke this workspace trust grant' },
543
553
  { cmd: '/verify', desc: 'Run the project test command' },
544
554
  { cmd: '/rewind', desc: 'Drop the last exchange from history' },
545
555
  { cmd: '/copy', desc: 'Copy the last response to the clipboard' },
@@ -623,6 +633,61 @@ export function filterPickerItems(items, query) {
623
633
  }
624
634
  return [...starts, ...contains];
625
635
  }
636
+ /** The smallest number of character edits that turns one command into another. */
637
+ function commandDistance(a, b) {
638
+ // A transposed neighbouring pair is the commonest command typo (`/modle`). Treat it as one
639
+ // edit before using the ordinary insertion/deletion/substitution matrix below.
640
+ if (a.length === b.length) {
641
+ const mismatches = [...a].map((char, index) => char === b[index] ? -1 : index).filter(index => index >= 0);
642
+ if (mismatches.length === 2 && mismatches[1] === mismatches[0] + 1
643
+ && a[mismatches[0]] === b[mismatches[1]]
644
+ && a[mismatches[1]] === b[mismatches[0]])
645
+ return 1;
646
+ }
647
+ const previous = Array.from({ length: b.length + 1 }, (_, i) => i);
648
+ for (let i = 1; i <= a.length; i++) {
649
+ const current = [i];
650
+ for (let j = 1; j <= b.length; j++) {
651
+ current[j] = a[i - 1] === b[j - 1]
652
+ ? previous[j - 1]
653
+ : 1 + Math.min(previous[j - 1], previous[j], current[j - 1]);
654
+ }
655
+ for (let j = 0; j <= b.length; j++)
656
+ previous[j] = current[j];
657
+ }
658
+ return previous[b.length];
659
+ }
660
+ /**
661
+ * Turns a near-miss slash command into a direct recovery path.
662
+ *
663
+ * We deliberately do not guess at distant names: a wrong suggestion steals attention, while
664
+ * catching one or two transposed/omitted characters makes command use feel forgiving.
665
+ */
666
+ export function commandSuggestion(command, commands = COMMANDS.map(item => item.cmd)) {
667
+ const wanted = command.trim().toLowerCase();
668
+ if (!wanted.startsWith('/'))
669
+ return undefined;
670
+ const maxDistance = wanted.length <= 5 ? 1 : wanted.length <= 9 ? 2 : 3;
671
+ let best;
672
+ let distance = Number.POSITIVE_INFINITY;
673
+ for (const candidate of commands) {
674
+ const d = commandDistance(wanted, candidate.toLowerCase());
675
+ // At the same edit distance, preserve as much of what the user typed as possible. For
676
+ // `/modle`, `/model` is more likely than the shorter `/mode` even though both are two edits.
677
+ if (d < distance || (d === distance && (!best || Math.abs(candidate.length - wanted.length) < Math.abs(best.length - wanted.length)))) {
678
+ best = candidate;
679
+ distance = d;
680
+ }
681
+ }
682
+ return distance <= maxDistance ? best : undefined;
683
+ }
684
+ /** Keep a small, useful recall buffer without repeating the same submitted task. */
685
+ export function appendDraftHistory(history, draft, limit = 100) {
686
+ const entry = draft.trim();
687
+ if (entry === '' || history.at(-1) === entry)
688
+ return [...history];
689
+ return [...history, entry].slice(-Math.max(1, limit));
690
+ }
626
691
  /**
627
692
  * The choices offered for one waiting tool call.
628
693
  *
@@ -716,8 +781,10 @@ export function pickerCountText(selected, shown, total) {
716
781
  * its row, and the constant 36 that was here cut most of them in half.
717
782
  */
718
783
  export function pickerLabelWidth(labels, descs, width) {
719
- const longestDesc = Math.max(0, ...descs.map(d => d.length));
720
- const longestLabel = Math.max(18, ...labels.map(l => l.length));
784
+ // Terminal cells, not JS code units: one wide glyph in a model name or session title used to
785
+ // make a supposedly exact row spill past the palette edge.
786
+ const longestDesc = Math.max(0, ...descs.map(displayWidth));
787
+ const longestLabel = Math.max(18, ...labels.map(displayWidth));
721
788
  return Math.max(18, Math.min(longestLabel, width - longestDesc - 4));
722
789
  }
723
790
  /**
@@ -732,7 +799,7 @@ export function pickerLabelWidth(labels, descs, width) {
732
799
  * a new one should work without being registered twice. The dangerous ones are few and named.
733
800
  */
734
801
  const COMMANDS_THAT_MUST_WAIT = new Set([
735
- '/clear', '/reset', '/new', // would wipe the history the turn is using
802
+ '/clear', '/reset', '/new', '/retry', // would wipe the history or begin a new turn
736
803
  '/model', '/provider', '/connect', '/key', '/endpoint', '/baseurl', // rebuild the session
737
804
  '/resume', '/fork', // replace it
738
805
  '/revert', // undo files the turn may still be writing
@@ -765,6 +832,9 @@ Slash commands:
765
832
  /status Provider, model, mode, tokens, cost, CWD
766
833
  /doctor Diagnose the active provider, workspace, and developer tools
767
834
  /clear /reset /new Clear conversation history
835
+ /cancel Stop the current task; keeps the composer text
836
+ /retry Run the last non-secret task again
837
+ /queue [clear|drop n] Inspect, clear, or drop one queued prompt
768
838
  /model [name] Show or switch model
769
839
  /provider [name] Show or switch provider
770
840
  /mode [auto|plan|code] Show or set mode
@@ -776,9 +846,11 @@ Slash commands:
776
846
  /cost Show session cost
777
847
  /memory Show .koneck/MEMORY.md
778
848
  /diff Show uncommitted git changes
849
+ /changes Summarize staged, unstaged, and untracked changes
779
850
  /quality Run the detected lint and test checks
780
851
  /audit [count] Show recent redacted tool activity
781
852
  /policy [init] Show or create workspace safety rules
853
+ /trust [always|forget] Show, persist, or revoke this workspace trust grant
782
854
  /verify Run project test command
783
855
  /agents How parallel sub-agents are configured
784
856
  /subtask <task> Run a task in an isolated sub-agent
@@ -791,6 +863,7 @@ Slash commands:
791
863
  Keyboard:
792
864
  Tab Toggle analytics panel
793
865
  Shift+Tab Cycle mode (auto → plan → code)
866
+ Up/Down Recall prior submitted prompts in the composer
794
867
  Ctrl+C Cancel task / exit if idle
795
868
  `.trim();
796
869
  /**
@@ -1116,6 +1189,13 @@ function App({ config: initialConfig, clearFrame }) {
1116
1189
  }))[0];
1117
1190
  const [rows, setRows] = useState([{ role: 'header' }]);
1118
1191
  const [draft, setDraft] = useState('');
1192
+ // Recall is deliberately session-local. It is for quick retries and refinements, not another
1193
+ // persistence channel for prompts (which may contain sensitive project context).
1194
+ const draftHistoryRef = useRef([]);
1195
+ const historyIndexRef = useRef(null);
1196
+ const historyDraftRef = useRef('');
1197
+ /** Last agent task eligible for one-command recovery; never populated from secret-looking input. */
1198
+ const retryTaskRef = useRef(null);
1119
1199
  /** Caret offset into `draft`. Kept beside it so every edit can place the caret deliberately. */
1120
1200
  const [caret, setCaret] = useState(0);
1121
1201
  const [busy, setBusy] = useState(false);
@@ -1571,6 +1651,52 @@ function App({ config: initialConfig, clearFrame }) {
1571
1651
  useEffect(() => { void snapshotTree(cfg.cwd).then(t => { baseTreeRef.current = t; }); }, [cfg.cwd]);
1572
1652
  const addRow = (row) => setRows(prev => [...prev, row]);
1573
1653
  const addSystem = (text) => addRow({ role: 'system', text });
1654
+ /** Stops the in-flight turn without touching anything the developer is composing or queued. */
1655
+ function cancelCurrentTurn() {
1656
+ if (!busy || !abortRef.current)
1657
+ return false;
1658
+ abortRef.current.abort();
1659
+ // Anything still waiting on a permission answer is refused rather than left hanging: the
1660
+ // agents are being abandoned, and an unsettled promise keeps the pool alive after its work
1661
+ // has been cancelled.
1662
+ const stranded = askQueueRef.current.drain(false);
1663
+ if (stranded > 0) {
1664
+ setAsk(null);
1665
+ addSystem(`Cancelled with ${stranded} permission ${stranded === 1 ? 'request' : 'requests'} unanswered; refused.`);
1666
+ }
1667
+ return true;
1668
+ }
1669
+ /** Walk submitted composer entries without losing whatever was being typed now. */
1670
+ function recallDraft(direction) {
1671
+ const history = draftHistoryRef.current;
1672
+ if (history.length === 0)
1673
+ return;
1674
+ const current = historyIndexRef.current;
1675
+ if (direction === -1) {
1676
+ if (current === null)
1677
+ historyDraftRef.current = draft;
1678
+ const next = current === null ? history.length - 1 : Math.max(0, current - 1);
1679
+ historyIndexRef.current = next;
1680
+ const recalled = history[next];
1681
+ setDraft(recalled);
1682
+ setCaret(recalled.length);
1683
+ return;
1684
+ }
1685
+ if (current === null)
1686
+ return;
1687
+ const next = current + 1;
1688
+ if (next >= history.length) {
1689
+ historyIndexRef.current = null;
1690
+ const restored = historyDraftRef.current;
1691
+ setDraft(restored);
1692
+ setCaret(restored.length);
1693
+ return;
1694
+ }
1695
+ historyIndexRef.current = next;
1696
+ const recalled = history[next];
1697
+ setDraft(recalled);
1698
+ setCaret(recalled.length);
1699
+ }
1574
1700
  async function getSession() {
1575
1701
  return activeSession.p;
1576
1702
  }
@@ -2149,7 +2275,7 @@ function App({ config: initialConfig, clearFrame }) {
2149
2275
  // rather than a stale global default.
2150
2276
  addSystem(`**KONECK Doctor** — checking ${cfg.provider} and this workspace…`);
2151
2277
  try {
2152
- const checks = await doctorChecks(cfg.cwd, cfg.provider);
2278
+ const checks = await doctorChecks(cfg.cwd, cfg.provider, cfg.baseURL, cfg.apiKey);
2153
2279
  setRows(prev => prev.slice(0, -1));
2154
2280
  addSystem(`**KONECK Doctor**\n\n${doctorReport(checks)}`);
2155
2281
  }
@@ -2175,6 +2301,54 @@ function App({ config: initialConfig, clearFrame }) {
2175
2301
  addSystem('Conversation cleared. The previous session is kept; /resume lists it.');
2176
2302
  return;
2177
2303
  }
2304
+ case '/cancel': {
2305
+ if (!cancelCurrentTurn())
2306
+ addSystem('No task is running.');
2307
+ return;
2308
+ }
2309
+ case '/retry': {
2310
+ const retry = retryTaskRef.current;
2311
+ if (!retry) {
2312
+ addSystem('Nothing safe to retry yet. Submit a non-secret task first.');
2313
+ return;
2314
+ }
2315
+ addSystem('Retrying the previous task…');
2316
+ // `/retry` itself is handled as a local command. Let its `finally` release the command
2317
+ // busy flag before starting the next agent turn, so the two cannot race each other.
2318
+ setTimeout(() => submitDraft(retry), 0);
2319
+ return;
2320
+ }
2321
+ case '/queue': {
2322
+ const requested = arg.trim();
2323
+ const drop = /^drop\s+(\d+)$/i.exec(requested);
2324
+ if (drop) {
2325
+ const index = Number(drop[1]) - 1;
2326
+ if (!Number.isInteger(index) || index < 0 || index >= queued.length) {
2327
+ addSystem(`There is no queued prompt #${drop[1]}. Run \`/queue\` to inspect the queue.`);
2328
+ return;
2329
+ }
2330
+ const [removed] = queued.slice(index, index + 1);
2331
+ setQueued(items => items.filter((_, itemIndex) => itemIndex !== index));
2332
+ addSystem(`Dropped queued prompt #${index + 1}: ${removed.replace(/\s+/g, ' ').slice(0, 180)}`);
2333
+ return;
2334
+ }
2335
+ if (/^(clear|drop)$/i.test(requested)) {
2336
+ const count = queued.length;
2337
+ setQueued([]);
2338
+ addSystem(count === 0
2339
+ ? 'There are no queued prompts to clear.'
2340
+ : `Cleared ${count} queued ${count === 1 ? 'prompt' : 'prompts'}. The current task keeps running.`);
2341
+ return;
2342
+ }
2343
+ if (queued.length === 0) {
2344
+ addSystem('No prompts are queued. Type while a task runs and press enter to queue it.');
2345
+ return;
2346
+ }
2347
+ addSystem(`**Queued prompts** — ${queued.length}\n\n` +
2348
+ queued.map((prompt, index) => `${index + 1}. ${prompt.replace(/\s+/g, ' ').slice(0, 240)}`).join('\n') +
2349
+ '\n\n`/queue drop 2` removes one prompt. `/queue clear` drops all of them; the current task keeps running.');
2350
+ return;
2351
+ }
2178
2352
  case '/model': {
2179
2353
  if (!arg) {
2180
2354
  const now = modelFor(cfg.provider);
@@ -2412,9 +2586,50 @@ function App({ config: initialConfig, clearFrame }) {
2412
2586
  }
2413
2587
  case '/diff': {
2414
2588
  try {
2415
- const result = await execa('git', ['diff'], { cwd: cfg.cwd, all: true, reject: false });
2416
- const out = result.all?.trim() ?? '';
2417
- addSystem(out || 'No uncommitted changes.');
2589
+ const [unstaged, staged] = await Promise.all([
2590
+ execa('git', ['diff', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
2591
+ execa('git', ['diff', '--cached', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
2592
+ ]);
2593
+ if (unstaged.exitCode !== 0 || staged.exitCode !== 0) {
2594
+ addSystem('Not a git repository.');
2595
+ return;
2596
+ }
2597
+ const sections = [
2598
+ (staged.all ?? '').trim() ? `**Staged**\n\n${(staged.all ?? '').trim()}` : '',
2599
+ (unstaged.all ?? '').trim() ? `**Unstaged**\n\n${(unstaged.all ?? '').trim()}` : '',
2600
+ ].filter(Boolean);
2601
+ addSystem(sections.length ? sections.join('\n\n') : 'No staged or unstaged changes. `/changes` also shows untracked files.');
2602
+ }
2603
+ catch {
2604
+ addSystem('Not a git repository.');
2605
+ }
2606
+ return;
2607
+ }
2608
+ case '/changes': {
2609
+ try {
2610
+ const [status, unstaged, staged] = await Promise.all([
2611
+ execa('git', ['status', '--short'], { cwd: cfg.cwd, all: true, reject: false }),
2612
+ execa('git', ['diff', '--stat', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
2613
+ execa('git', ['diff', '--cached', '--stat', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
2614
+ ]);
2615
+ const entries = (status.all ?? '').trim();
2616
+ if (status.exitCode !== 0 || unstaged.exitCode !== 0 || staged.exitCode !== 0) {
2617
+ addSystem('Not a git repository.');
2618
+ return;
2619
+ }
2620
+ if (!entries) {
2621
+ addSystem('Working tree is clean.');
2622
+ return;
2623
+ }
2624
+ const lines = ['**Working tree changes**', '', '```', entries, '```'];
2625
+ const stagedStat = (staged.all ?? '').trim();
2626
+ const unstagedStat = (unstaged.all ?? '').trim();
2627
+ if (stagedStat)
2628
+ lines.push('', '**Staged**', '```', stagedStat, '```');
2629
+ if (unstagedStat)
2630
+ lines.push('', '**Unstaged**', '```', unstagedStat, '```');
2631
+ lines.push('', 'Use `/diff` to inspect patches, `/quality` to verify, or `/revert` to undo KONECK checkpoints.');
2632
+ addSystem(lines.join('\n'));
2418
2633
  }
2419
2634
  catch {
2420
2635
  addSystem('Not a git repository.');
@@ -2490,6 +2705,28 @@ function App({ config: initialConfig, clearFrame }) {
2490
2705
  `Approval rules : ${policy.requireApprovalFor?.join(', ') || 'none'}`);
2491
2706
  return;
2492
2707
  }
2708
+ case '/trust': {
2709
+ const choice = arg.trim().toLowerCase();
2710
+ if (choice === 'always') {
2711
+ await trustWorkspace(cfg.cwd);
2712
+ addSystem(`This workspace is now trusted for future KONECK sessions:\n\`${cfg.cwd}\``);
2713
+ return;
2714
+ }
2715
+ if (choice === 'forget' || choice === 'revoke' || choice === 'remove') {
2716
+ const removed = await untrustWorkspace(cfg.cwd);
2717
+ addSystem(removed
2718
+ ? `Removed the persistent trust grant for \`${cfg.cwd}\`. This open session remains active; KONECK will ask again next time.`
2719
+ : 'This workspace did not have a persistent trust grant.');
2720
+ return;
2721
+ }
2722
+ const trusted = await isWorkspaceTrusted(cfg.cwd);
2723
+ addSystem(`**Workspace trust**\n\n` +
2724
+ `Path : \`${cfg.cwd}\`\n` +
2725
+ `Persistent : ${trusted ? 'yes' : 'no — this session may be a one-time grant'}\n\n` +
2726
+ '`/trust always` saves this grant for future sessions.\n' +
2727
+ '`/trust forget` removes a saved grant; this open session stays active.');
2728
+ return;
2729
+ }
2493
2730
  case '/verify': {
2494
2731
  const { detectProject } = await import('./project.js');
2495
2732
  const project = await detectProject(cfg.cwd);
@@ -2912,7 +3149,12 @@ function App({ config: initialConfig, clearFrame }) {
2912
3149
  return;
2913
3150
  }
2914
3151
  default:
2915
- addSystem(`Unknown command: ${cmd}. Type /help for the full list.`);
3152
+ {
3153
+ const suggestion = commandSuggestion(cmd);
3154
+ addSystem(suggestion
3155
+ ? `Unknown command: ${cmd}. Did you mean \`${suggestion}\`?`
3156
+ : `Unknown command: ${cmd}. Type /help for the full list.`);
3157
+ }
2916
3158
  }
2917
3159
  }
2918
3160
  /**
@@ -2974,15 +3216,7 @@ function App({ config: initialConfig, clearFrame }) {
2974
3216
  // Esc abandons the turn in flight and hands the keyboard straight back. The composer is
2975
3217
  // deliberately left alone: cancelling should not cost whatever was typed while waiting.
2976
3218
  if (key.escape && busy && !picker && !keyPrompt && !updatePane && !usagePane) {
2977
- abortRef.current?.abort();
2978
- // Anything still waiting on a permission answer is refused rather than left hanging: the
2979
- // agents are being abandoned, and an unsettled promise keeps the pool alive after the work
2980
- // it was doing has been given up on.
2981
- const stranded = askQueueRef.current.drain(false);
2982
- if (stranded > 0) {
2983
- setAsk(null);
2984
- addSystem(`Cancelled with ${stranded} permission ${stranded === 1 ? 'request' : 'requests'} unanswered; refused.`);
2985
- }
3219
+ cancelCurrentTurn();
2986
3220
  return;
2987
3221
  }
2988
3222
  if (key.ctrl && input === 'c') {
@@ -3358,6 +3592,14 @@ function App({ config: initialConfig, clearFrame }) {
3358
3592
  // The composer used to be append-and-backspace only, so fixing a typo near the start of a
3359
3593
  // long prompt meant deleting everything after it. Everything below is the readline set a
3360
3594
  // shell gives you, so an edit costs one keystroke rather than a retype.
3595
+ if (key.upArrow) {
3596
+ recallDraft(-1);
3597
+ return;
3598
+ }
3599
+ if (key.downArrow) {
3600
+ recallDraft(1);
3601
+ return;
3602
+ }
3361
3603
  if (key.leftArrow) {
3362
3604
  const step = 1 + takeBurst('left');
3363
3605
  setCaret(c => (key.ctrl || key.meta ? wordStart(draft, c) : Math.max(0, c - step)));
@@ -3434,6 +3676,8 @@ function App({ config: initialConfig, clearFrame }) {
3434
3676
  return;
3435
3677
  const next = draft.slice(0, caret) + text + draft.slice(caret);
3436
3678
  const nextCaret = caret + text.length;
3679
+ // Once the recalled line is edited it is a new draft, not a history cursor position.
3680
+ historyIndexRef.current = null;
3437
3681
  setDraft(next);
3438
3682
  setCaret(nextCaret);
3439
3683
  // A lone "@" at a word boundary offers workspace paths. Checking the draft as it will be,
@@ -3477,6 +3721,13 @@ function App({ config: initialConfig, clearFrame }) {
3477
3721
  const task = expandPastes(shown, pastesRef.current).trim();
3478
3722
  if (!task)
3479
3723
  return;
3724
+ // A recalled secret is just as dangerous as a saved one. Secret-looking input is sent only
3725
+ // when the user explicitly submits it, and never retained for Up-arrow recall.
3726
+ if (!containsSecret(task)) {
3727
+ draftHistoryRef.current = appendDraftHistory(draftHistoryRef.current, shown);
3728
+ }
3729
+ historyIndexRef.current = null;
3730
+ historyDraftRef.current = '';
3480
3731
  setDraft('');
3481
3732
  setCaret(0);
3482
3733
  pastesRef.current = [];
@@ -3501,6 +3752,8 @@ function App({ config: initialConfig, clearFrame }) {
3501
3752
  void handleCommand(cmd, arg).finally(() => setBusy(false));
3502
3753
  return;
3503
3754
  }
3755
+ if (!containsSecret(task))
3756
+ retryTaskRef.current = task;
3504
3757
  setBusy(true);
3505
3758
  setAgentState('processing');
3506
3759
  const started = Date.now();
@@ -3724,7 +3977,7 @@ function App({ config: initialConfig, clearFrame }) {
3724
3977
  });
3725
3978
  return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 40), color: CYAN, title: `Plan ${done}/${steps.length}`, children: lines }) }, index));
3726
3979
  })() : row.role === 'error' ? (() => {
3727
- const lines = [row.text ?? '', '', 'Fix the above, then retry. /status shows the active provider and model.'];
3980
+ const lines = [row.text ?? '', '', 'Fix the above, then `/retry` to rerun the task. `/status` shows the active provider and model.'];
3728
3981
  return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 40), color: CRIMSON, title: "[!] ERROR", children: lines }) }, index));
3729
3982
  })() : row.role === 'system' ? (() => {
3730
3983
  const lines = (row.text ?? '').split('\n');
@@ -4013,13 +4266,16 @@ function App({ config: initialConfig, clearFrame }) {
4013
4266
  // The header is a title on the left and a count on the right, on one line. Estimating the
4014
4267
  // right-hand side at a constant made "Reference a file or directory" wrap onto two rows.
4015
4268
  const countText = pickerCountText(pickIndex, list.length, picker.items.length);
4016
- const headWidth = title.length + countText.length + 3;
4017
- const rowWidth = Math.max(headWidth, ...shown.map(it => 38 + (it.current ? 10 : 0) + it.desc.length));
4269
+ const headWidth = displayWidth(title) + displayWidth(countText) + 3;
4270
+ // Measure the filtered list, not just its current scroll window. A palette whose right
4271
+ // edge breathes as the highlight crosses a long description feels broken even when the
4272
+ // selection itself is correct.
4273
+ const rowWidth = Math.max(headWidth, ...list.map(it => 38 + (it.current ? 10 : 0) + displayWidth(it.desc) + displayWidth(it.group ?? '') + 3));
4018
4274
  const boxWidth = Math.max(40, Math.min(barWidth, rowWidth + 4));
4019
4275
  const width = boxWidth - 4;
4020
4276
  // Room for the longest label the list actually holds, rather than a constant. A session
4021
4277
  // title is what identifies the row, and 36 cells cut most of them in half.
4022
- const labelWidth = pickerLabelWidth(shown.map(it => it.label), shown.map(it => it.desc), width);
4278
+ const labelWidth = pickerLabelWidth(list.map(it => it.label), list.map(it => it.desc), width);
4023
4279
  let lastGroup;
4024
4280
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: CYAN, paddingX: 1, marginTop: 1, width: boxWidth, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: CYAN, bold: true, children: title }), _jsx(Text, { color: DIM, children: countText })] }), _jsxs(Box, { children: [_jsxs(Text, { color: DIM, children: [G.search, " "] }), pickQuery === ''
4025
4281
  ? _jsx(Text, { color: DIM, children: "type to search" })
@@ -4028,9 +4284,9 @@ function App({ config: initialConfig, clearFrame }) {
4028
4284
  const selected = absolute === pickIndex;
4029
4285
  const header = item.group && item.group !== lastGroup ? item.group : null;
4030
4286
  lastGroup = item.group;
4031
- const label = item.label.length > 34 ? item.label.slice(0, 31) + '...' : item.label;
4032
- return (_jsxs(Box, { flexDirection: "column", children: [header && _jsx(Text, { color: DIM, children: header }), _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? G.caret + ' ' : ' '}${fitCells(label, labelWidth)}` +
4033
- `${item.current ? '(current) ' : ''}${item.desc}`, width) })] }, item.value + absolute));
4287
+ const label = pickerLabel(item.label, labelWidth);
4288
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { backgroundColor: selected ? CYAN : undefined, color: selected ? '#10222A' : INK, bold: selected, children: fitCells(`${selected ? G.caret + ' ' : ' '}${label}` +
4289
+ `${item.current ? '(current) ' : ''}${header ? `${header} · ` : ''}${item.desc}`, width) }) }, item.value + absolute));
4034
4290
  }), 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] }))] }));
4035
4291
  })(), 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 && (() => {
4036
4292
  let detail = '';