koneck 2.128.0 → 2.128.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ink-chat.js CHANGED
@@ -13,7 +13,8 @@ import { PROVIDERS, resolveProvider, getApiKey, addUserProvider, removeUserProvi
13
13
  import { normaliseEndpoint, withEndpoint, savedEndpoints, shortSource, resolveModelChoice, withModel } from './endpoints.js';
14
14
  import { loadProjectConfig, loadGlobalConfig, saveGlobalConfig, withoutStoredApiKey } from './config.js';
15
15
  import { rememberKey, forgetAllKeys } from './credentials.js';
16
- import { readClipboard, cleanPastedSecret } from './clipboard.js';
16
+ import { ImageDraft, pastedImagePath } from './image-draft.js';
17
+ import { readClipboard, readClipboardImage, startSelectionCopy, cleanPastedSecret } from './clipboard.js';
17
18
  import { paceFrom } from './pace.js';
18
19
  import { policyFrom, FAILOVER_OFF } from './failover.js';
19
20
  import { listCheckpoints, revertCheckpoint, previewCheckpoint, snapshotTree, filesTouchedBy, createWorkspaceCheckpoint, checkpointScope } from './checkpoint.js';
@@ -500,7 +501,7 @@ const COMMANDS = [
500
501
  { cmd: '/queue', desc: 'Inspect or clear prompts waiting behind the current task' },
501
502
  { cmd: '/new', desc: 'New session (alias of /clear)' },
502
503
  { cmd: '/reset', desc: 'Reset session (alias of /clear)' },
503
- { cmd: '/mode', desc: 'Set mode: auto, plan or code' },
504
+ { cmd: '/mode', desc: 'Set mode: auto, edits, careful or plan' },
504
505
  { cmd: '/effort', desc: 'Set reasoning effort: low, medium, high, max (picker)' },
505
506
  { cmd: '/config', desc: 'View or set persistent config' },
506
507
  { cmd: '/usage', desc: 'Usage, context, model status and limits (tabbed pane)' },
@@ -515,7 +516,7 @@ const COMMANDS = [
515
516
  { cmd: '/name', desc: 'Name this session, so /resume can find it by name' },
516
517
  { cmd: '/fork', desc: 'Copy this session and continue on the copy' },
517
518
  { cmd: '/archive', desc: 'Keep a session but hide it from /resume' },
518
- { cmd: '/diff', desc: 'Show uncommitted git changes' },
519
+ { cmd: '/diff [on|off]', desc: 'Show changes, or control the live diff panel' },
519
520
  { cmd: '/changes', desc: 'Summarize staged, unstaged, and untracked changes' },
520
521
  { cmd: '/quality', desc: 'Run the detected lint and test checks' },
521
522
  { cmd: '/preflight', desc: 'Run the deterministic ready-to-review gate' },
@@ -1019,6 +1020,11 @@ export function inkApprovalDecision(requireApproval, mode, tool, argsJson, rules
1019
1020
  // silently erase. It remains stronger than this session's convenience posture.
1020
1021
  if (policyRequiresApproval)
1021
1022
  return 'ask';
1023
+ // Auto is the explicit promise to carry a task through. A globally saved approval preference
1024
+ // must not quietly turn it into careful mode; changing into Auto is the newer, more specific
1025
+ // choice. Workspace policies above still win, and plan mode was already denied by `decide`.
1026
+ if (modeSpec(mode).name === 'auto')
1027
+ return 'allow';
1022
1028
  if (!requireApproval)
1023
1029
  return 'allow';
1024
1030
  if (toolRisk(tool) === 'read')
@@ -1095,7 +1101,7 @@ Slash commands:
1095
1101
  /queue [clear|drop n] Inspect, clear, or drop one queued prompt
1096
1102
  /model [name] Show or switch model
1097
1103
  /provider [name] Show or switch provider
1098
- /mode [auto|plan|code] Show or set mode
1104
+ /mode [auto|edits|careful|plan] Show or set mode
1099
1105
  /effort [low|med|high|max] Set reasoning effort
1100
1106
  /config [key] [value] View or set persistent config
1101
1107
  /aside <question> Ask a side question without interrupting the running task
@@ -1125,8 +1131,8 @@ Slash commands:
1125
1131
 
1126
1132
  Keyboard:
1127
1133
  Tab Toggle analytics panel
1128
- Shift+Tab Cycle mode (auto → plancode)
1129
- Up/Down Recall prior submitted prompts in the composer
1134
+ Shift+Tab Cycle mode (auto → editscareful → plan)
1135
+ Ctrl+P / Ctrl+N Recall prior submitted prompts in the composer
1130
1136
  Ctrl+C Cancel task / exit if idle
1131
1137
  `.trim();
1132
1138
  /**
@@ -1159,6 +1165,19 @@ export function looksLikeMouseReport(input) {
1159
1165
  return true;
1160
1166
  return false;
1161
1167
  }
1168
+ /**
1169
+ * Some terminals turn a wheel movement into an ordinary arrow key while using native scrollback.
1170
+ * Arrow keys must therefore never replace the current draft: scrolling should not recall a prompt.
1171
+ */
1172
+ export function draftHistoryDirection(input, key) {
1173
+ if (!key.ctrl)
1174
+ return undefined;
1175
+ if (input.toLowerCase() === 'p')
1176
+ return -1;
1177
+ if (input.toLowerCase() === 'n')
1178
+ return 1;
1179
+ return undefined;
1180
+ }
1162
1181
  export function defaultAltScreen(stdout = process.stdout, env = process.env,
1163
1182
  /**
1164
1183
  * Whether this terminal composites a redraw, from the probe in frame-sync.ts.
@@ -1486,6 +1505,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
1486
1505
  diffBaseRef.current.set(rel, before);
1487
1506
  }
1488
1507
  liveToolRef.current = { kind: 'tool', name, detail: toolDetail(name, args), startedAt: Date.now() };
1508
+ setRepaint(v => v + 1);
1489
1509
  }
1490
1510
  function finishTool(name, ok, ms, detail) {
1491
1511
  if (!ok)
@@ -1715,6 +1735,19 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
1715
1735
  const [showDiffPanel, setShowDiffPanel] = useState(false);
1716
1736
  const diffPanelTouchedRef = useRef(false);
1717
1737
  const [draft, setDraft] = useState('');
1738
+ const imageDraftRef = useRef(new ImageDraft());
1739
+ const [copyNotice, setCopyNotice] = useState('');
1740
+ useEffect(() => {
1741
+ if (!process.stdin.isTTY || cfg.autoCopy === false)
1742
+ return;
1743
+ return startSelectionCopy(count => setCopyNotice(`Copied ${count} chars to clipboard`));
1744
+ }, [cfg.autoCopy]);
1745
+ useEffect(() => {
1746
+ if (!copyNotice)
1747
+ return;
1748
+ const timer = setTimeout(() => setCopyNotice(''), 3000);
1749
+ return () => clearTimeout(timer);
1750
+ }, [copyNotice]);
1718
1751
  // Recall is deliberately session-local. It is for quick retries and refinements, not another
1719
1752
  // persistence channel for prompts (which may contain sensitive project context).
1720
1753
  const draftHistoryRef = useRef([]);
@@ -2315,9 +2348,6 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2315
2348
  setAnim({ spin: 0, shimmer: 0, elapsed: 0 });
2316
2349
  return;
2317
2350
  }
2318
- // Turned off: no frames at all, so nothing repaints unless something actually changes.
2319
- if (!animate)
2320
- return;
2321
2351
  busyStart.current = Date.now();
2322
2352
  wordTimer.current = 0;
2323
2353
  // One rate, whether or not the stream is saying anything. The spinner earns its place
@@ -2336,11 +2366,11 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2336
2366
  * how a Pi ends up lagging while the same build feels instant on a laptop. See frame-pace.ts.
2337
2367
  */
2338
2368
  const floor = floorFor(syncedFramesRef.current);
2339
- let interval = startingInterval(syncedFramesRef.current);
2369
+ let interval = animate ? startingInterval(syncedFramesRef.current) : 2_000;
2340
2370
  let asked = Date.now();
2341
2371
  const tick = () => {
2342
2372
  const now = Date.now();
2343
- interval = pacedInterval(interval, now - asked, floor);
2373
+ interval = animate ? pacedInterval(interval, now - asked, floor) : 2_000;
2344
2374
  /*
2345
2375
  * Nothing is repainted while somebody is typing.
2346
2376
  *
@@ -2364,10 +2394,10 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2364
2394
  // Elapsed is read from the clock rather than counted in ticks, so a frame the event loop
2365
2395
  // delivers late moves the animation on by what actually passed instead of falling behind.
2366
2396
  const elapsed = now - busyStart.current;
2367
- const { spin, shimmer } = animFrames(elapsed, SPINNER.length);
2397
+ const { spin, shimmer } = animate ? animFrames(elapsed, SPINNER.length) : { spin: 0, shimmer: 0 };
2368
2398
  setAnim({ spin, shimmer, elapsed });
2369
2399
  wordTimer.current += 1;
2370
- if (wordTimer.current % wordEvery === 0)
2400
+ if (animate && wordTimer.current % wordEvery === 0)
2371
2401
  setSpinWord(w => w + 1);
2372
2402
  asked = Date.now();
2373
2403
  timer = setTimeout(tick, interval);
@@ -2759,6 +2789,12 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
2759
2789
  case 'mouse':
2760
2790
  setMouse(value === true);
2761
2791
  break;
2792
+ case 'animate':
2793
+ setAnimate(value !== false);
2794
+ break;
2795
+ case 'autoCopy':
2796
+ setCfg(c => ({ ...c, autoCopy: value !== false }));
2797
+ break;
2762
2798
  case 'provider': {
2763
2799
  const name = typeof value === 'string' && value.trim() !== '' ? value.trim() : cfg.provider;
2764
2800
  const def = PROVIDERS[name.toLowerCase()];
@@ -3286,7 +3322,11 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3286
3322
  const choice = arg.trim().toLowerCase();
3287
3323
  if (!choice) {
3288
3324
  addSystem(`**Approval posture**\n\n` +
3289
- `Session approval: ${cfg.requireApproval ? 'on — KONECK asks before writes and non-read-only commands' : 'off — KONECK follows the selected mode and workspace policy'}\n` +
3325
+ `Session approval: ${mode === 'auto'
3326
+ ? 'Auto mode runs ordinary work without asking; workspace policy can still require approval'
3327
+ : cfg.requireApproval
3328
+ ? 'on — KONECK asks before writes and non-read-only commands'
3329
+ : 'off — KONECK follows the selected mode and workspace policy'}\n` +
3290
3330
  `Mode: ${modeSpec(mode).label} — ${modeSpec(mode).blurb}\n\n` +
3291
3331
  '`/permissions on` keeps you in control of every consequential action.\n' +
3292
3332
  '`/permissions off` removes the extra session gate; workspace policy and plan mode still win.');
@@ -3305,9 +3345,11 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3305
3345
  // and the rebuilt session makes the new posture durable for every later turn.
3306
3346
  if (!busy)
3307
3347
  await resetSession(merged);
3308
- addSystem(next
3309
- ? 'Approval on. KONECK will ask in this interface before writes and consequential commands. Read-only inspection stays uninterrupted.'
3310
- : 'Approval off. KONECK follows the selected mode and workspace policy; plan mode and policy blocks still cannot be bypassed.');
3348
+ addSystem(mode === 'auto'
3349
+ ? `Approval ${next ? 'on' : 'off'} saved. Auto mode still runs ordinary work without asking; workspace policy remains enforceable.`
3350
+ : next
3351
+ ? 'Approval on. KONECK will ask in this interface before writes and consequential commands. Read-only inspection stays uninterrupted.'
3352
+ : 'Approval off. KONECK follows the selected mode and workspace policy; plan mode and policy blocks still cannot be bypassed.');
3311
3353
  return;
3312
3354
  }
3313
3355
  case '/worktree': {
@@ -3677,7 +3719,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3677
3719
  ? 'It repaints a few times a second while a turn runs. On a terminal that does not '
3678
3720
  + 'composite a redraw that is visible as flicker. `/animation off` stops it — the '
3679
3721
  + 'elapsed time and the status bar still say what is happening.'
3680
- : 'Nothing repaints unless it changes. `/animation on` brings the spinner back.'));
3722
+ : 'The spinner is off. Activity and elapsed time refresh every two seconds while you are not typing. `/animation on` brings the spinner back.'));
3681
3723
  return;
3682
3724
  }
3683
3725
  const next = want === 'on';
@@ -3687,8 +3729,8 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3687
3729
  setStored(saved);
3688
3730
  addSystem(next
3689
3731
  ? 'Working animation **on**.'
3690
- : 'Working animation **off** — the screen stays still. The status bar still shows '
3691
- + 'progress, and elapsed time updates when something happens.');
3732
+ : 'Working animation **off**. The status bar still shows '
3733
+ + 'progress and elapsed time every two seconds, pausing while you type.');
3692
3734
  return;
3693
3735
  }
3694
3736
  case '/redraw': {
@@ -3744,8 +3786,11 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3744
3786
  case '/auto':
3745
3787
  case '/plan':
3746
3788
  case '/edits':
3789
+ case '/edit':
3790
+ case '/code':
3747
3791
  case '/careful': {
3748
- const target = cmd === '/mode' ? arg.trim() : cmd.slice(1);
3792
+ const requested = cmd === '/mode' ? arg.trim() : cmd.slice(1);
3793
+ const target = requested === 'edit' || requested === 'code' ? 'edits' : requested;
3749
3794
  const names = MODE_SPECS.map(m => m.name).join(', ');
3750
3795
  if (!target) {
3751
3796
  addSystem(`Current mode: ${modeSpec(mode).label} — ${modeSpec(mode).blurb}\n\n` +
@@ -3823,45 +3868,6 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3823
3868
  'Pick a model with a known rate via /models to see cost, or read it from your provider dashboard.');
3824
3869
  return;
3825
3870
  }
3826
- /*
3827
- * The live diff panel, on or off.
3828
- *
3829
- * Takes on/off as well as toggling, so it can be put in a shell alias or a hook, and says why
3830
- * nothing appeared when it cannot open — an inline session has no frame to put a column in,
3831
- * and a session that has changed nothing has nothing to draw.
3832
- */
3833
- case '/diff': {
3834
- const want = arg.trim() === 'off' ? false : arg.trim() === 'on' ? true : !showDiffPanel;
3835
- setShowDiffPanel(want);
3836
- diffPanelTouchedRef.current = true;
3837
- /*
3838
- * Opening the panel switches to the alternate screen, because it needs a frame.
3839
- *
3840
- * The default is now the terminal's own scrollback wherever a repaint would be visible, so
3841
- * asking for the panel used to be answered with "/altscreen on, then /diff" — two commands
3842
- * to reach one feature, for a reason that is KONECK's business rather than the person's.
3843
- * The panel is a fixed side-by-side layout and there is nowhere to put a second column in
3844
- * scrollback, so asking for it is asking for the frame. Closing it hands the scrollback
3845
- * back, and with it selectable text.
3846
- */
3847
- if (want && !altScreen)
3848
- setAltScreen(true);
3849
- if (!want && !(cfg.altScreen ?? false)
3850
- && !defaultAltScreen(process.stdout, process.env, syncedFramesRef.current)) {
3851
- setAltScreen(false);
3852
- }
3853
- const saved = { ...(await loadKoneckConfig()), diffPanel: want };
3854
- await saveKoneckConfig(saved);
3855
- setStored(saved);
3856
- addSystem(want && liveDiffs.length === 0
3857
- ? 'Diff panel on — it will appear beside the conversation with the first edit. '
3858
- + 'Saved as your default.'
3859
- : want
3860
- ? 'Diff panel open beside the conversation, following every edit as it lands. '
3861
- + '/diff to hide it.'
3862
- : 'Diff panel hidden. /diff to bring it back.');
3863
- return;
3864
- }
3865
3871
  case '/altscreen':
3866
3872
  case '/fullscreen': {
3867
3873
  const next = arg.trim() === 'off' ? false : arg.trim() === 'on' ? true : !altScreen;
@@ -3916,6 +3922,21 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3916
3922
  }
3917
3923
  return;
3918
3924
  }
3925
+ const { readAll } = await import('./ledger.js');
3926
+ const notes = await readAll(cfg.cwd);
3927
+ // Older releases shortened an entire shell pipeline to its first two words. That turned
3928
+ // `cd project && slow-test` into a bogus claim that changing directory was slow. Keep the
3929
+ // old line on disk for auditability, but do not present it as knowledge or send it back to
3930
+ // a model.
3931
+ const usableNotes = notes.filter(n => !(n.kind === 'timing'
3932
+ && n.commands?.some(command => /^cd\s+[^;&|]+$/.test(command))));
3933
+ if (usableNotes.length) {
3934
+ addSystem('Saved project notes (.koneck/ledger.jsonl):\n'
3935
+ + usableNotes.slice(-20).map(n => '• ' + n.text).join('\n'));
3936
+ }
3937
+ if (usableNotes.length !== notes.length) {
3938
+ addSystem(`${notes.length - usableNotes.length} old timing note${notes.length - usableNotes.length === 1 ? '' : 's'} ignored: it measured only a directory change, not the command that followed it.`);
3939
+ }
3919
3940
  const mem = await loadMemory(cfg.cwd).catch(() => null);
3920
3941
  addSystem(mem?.trim()
3921
3942
  ? mem + '\n\n(/memory clear removes these notes)'
@@ -3923,6 +3944,25 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
3923
3944
  return;
3924
3945
  }
3925
3946
  case '/diff': {
3947
+ const panel = arg.trim().toLowerCase();
3948
+ if (panel === 'on' || panel === 'off') {
3949
+ const want = panel === 'on';
3950
+ setShowDiffPanel(want);
3951
+ diffPanelTouchedRef.current = true;
3952
+ if (want && !altScreen)
3953
+ setAltScreen(true);
3954
+ if (!want && !(cfg.altScreen ?? false)
3955
+ && !defaultAltScreen(process.stdout, process.env, syncedFramesRef.current)) {
3956
+ setAltScreen(false);
3957
+ }
3958
+ const saved = { ...(await loadKoneckConfig()), diffPanel: want };
3959
+ await saveKoneckConfig(saved);
3960
+ setStored(saved);
3961
+ addSystem(want
3962
+ ? 'Live diff panel on. It will appear beside the conversation with the first edit.'
3963
+ : 'Live diff panel hidden.');
3964
+ return;
3965
+ }
3926
3966
  try {
3927
3967
  const [unstaged, staged, untracked] = await Promise.all([
3928
3968
  execa('git', ['diff', '--no-ext-diff'], { cwd: cfg.cwd, all: true, reject: false }),
@@ -4337,6 +4377,10 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
4337
4377
  const n = Number(val);
4338
4378
  setConfigMaxWidth(Number.isFinite(n) && n >= 40 ? n : undefined);
4339
4379
  }
4380
+ if (key === 'autoCopy')
4381
+ setCfg(c => ({ ...c, autoCopy: ['true', '1', 'yes'].includes(val.toLowerCase()) }));
4382
+ if (key === 'animate')
4383
+ setAnimate(['true', '1', 'yes'].includes(val.toLowerCase()));
4340
4384
  if (key === 'mouse') {
4341
4385
  setMouse(['true', '1', 'yes'].includes(val.toLowerCase()));
4342
4386
  }
@@ -4667,6 +4711,13 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
4667
4711
  setKeyPrompt(p => (p ? { ...p, value: p.value + clean.replace(/\s+$/, '') } : p));
4668
4712
  return;
4669
4713
  }
4714
+ const imagePath = pastedImagePath(clean, cfg.cwd);
4715
+ if (imagePath) {
4716
+ const token = imageDraftRef.current.add(imagePath);
4717
+ setDraft(d => d.slice(0, caret) + token + d.slice(caret));
4718
+ setCaret(c => c + token.length);
4719
+ return;
4720
+ }
4670
4721
  if (!clean.includes('\n') && clean.length <= 200) {
4671
4722
  setDraft(d => d.slice(0, caret) + clean + d.slice(caret));
4672
4723
  setCaret(c => c + clean.length);
@@ -5109,6 +5160,21 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5109
5160
  });
5110
5161
  return;
5111
5162
  }
5163
+ if (key.ctrl && input.toLowerCase() === 'v') {
5164
+ void (async () => {
5165
+ const image = await readClipboardImage();
5166
+ if (image.path) {
5167
+ filePaste(image.path);
5168
+ return;
5169
+ }
5170
+ const text = await readClipboard();
5171
+ if (text.ok && text.text)
5172
+ filePaste(text.text);
5173
+ else
5174
+ addSystem(image.reason || text.reason || 'The clipboard is empty.');
5175
+ })();
5176
+ return;
5177
+ }
5112
5178
  // Only submission waits for the agent. Editing stays live throughout, so a thought had
5113
5179
  // while it works can be typed as it arrives rather than held until the turn ends — and
5114
5180
  // cancelling with esc then hands the keyboard back to a composer that still has it.
@@ -5137,7 +5203,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5137
5203
  }
5138
5204
  addSystem(`${cmd} changes the session, so it waits until this turn finishes.`);
5139
5205
  }
5140
- setQueued(q => [...q, expandPastes(text, pastesRef.current)]);
5206
+ setQueued(q => [...q, imageDraftRef.current.expand(expandPastes(text, pastesRef.current))]);
5141
5207
  setDraft('');
5142
5208
  setCaret(0);
5143
5209
  pastesRef.current = [];
@@ -5151,12 +5217,9 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5151
5217
  // The composer used to be append-and-backspace only, so fixing a typo near the start of a
5152
5218
  // long prompt meant deleting everything after it. Everything below is the readline set a
5153
5219
  // shell gives you, so an edit costs one keystroke rather than a retype.
5154
- if (key.upArrow) {
5155
- recallDraft(-1);
5156
- return;
5157
- }
5158
- if (key.downArrow) {
5159
- recallDraft(1);
5220
+ const historyDirection = draftHistoryDirection(input, key);
5221
+ if (historyDirection) {
5222
+ recallDraft(historyDirection);
5160
5223
  return;
5161
5224
  }
5162
5225
  if (key.leftArrow) {
@@ -5233,6 +5296,10 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5233
5296
  const text = input.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
5234
5297
  if (text === '')
5235
5298
  return;
5299
+ if (text.length > 1 && pastedImagePath(text, cfg.cwd)) {
5300
+ filePaste(text);
5301
+ return;
5302
+ }
5236
5303
  const next = draft.slice(0, caret) + text + draft.slice(caret);
5237
5304
  const nextCaret = caret + text.length;
5238
5305
  // Once the recalled line is edited it is a new draft, not a history cursor position.
@@ -5277,7 +5344,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5277
5344
  const shown = raw.trim();
5278
5345
  if (!shown)
5279
5346
  return;
5280
- const task = expandPastes(shown, pastesRef.current).trim();
5347
+ const task = imageDraftRef.current.expand(expandPastes(shown, pastesRef.current)).trim();
5281
5348
  if (!task)
5282
5349
  return;
5283
5350
  const generated = generatedTaskRef.current?.task === task ? generatedTaskRef.current : null;
@@ -5307,7 +5374,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5307
5374
  'written into the session log.');
5308
5375
  }
5309
5376
  const mentions = findMentions(task, pathKind);
5310
- const forDisplay = taskTranscriptLabel(pasteForDisplay(mentions.length ? shortenMentions(task, mentions) : task), generated?.label);
5377
+ const forDisplay = taskTranscriptLabel(shown.includes('[Image #') ? shown : pasteForDisplay(mentions.length ? shortenMentions(task, mentions) : task), generated?.label);
5311
5378
  addRow({ role: 'user', text: forDisplay });
5312
5379
  if (isSlashCommand(task)) {
5313
5380
  const spaceIdx = task.indexOf(' ');
@@ -5498,11 +5565,18 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5498
5565
  * Only on the alternate screen: in inline mode the transcript is committed to the terminal's own
5499
5566
  * scrollback full-width, and there is no frame to put a second column in.
5500
5567
  */
5568
+ // An inline terminal commits output to scrollback. Repainting a side column there makes every
5569
+ // status update expensive and produces the apparent "starts fast, then lags" failure on long
5570
+ // sessions. The compact status line remains live in both modes; the full panel belongs to the
5571
+ // fixed alternate-screen frame where Ink can update it in place.
5572
+ const activityPanelOpen = busy && altScreen && uiWidth >= 100 && termRows >= 24
5573
+ && (diffPanelTouchedRef.current ? showDiffPanel : cfg.diffPanel !== false);
5501
5574
  const diffPanelOpen = showDiffPanel && altScreen && liveDiffs.length > 0;
5575
+ const sidePanelOpen = activityPanelOpen || diffPanelOpen;
5502
5576
  // Read by renderStep, which is defined above this line, so it goes through a ref rather than
5503
5577
  // depending on declaration order.
5504
5578
  diffPanelOpenRef.current = diffPanelOpen;
5505
- const diffPanelWidth = diffPanelOpen
5579
+ const diffPanelWidth = sidePanelOpen
5506
5580
  ? Math.max(34, Math.min(Math.floor(uiWidth * 0.46), 96))
5507
5581
  : 0;
5508
5582
  const contentWidth = uiWidth - diffPanelWidth;
@@ -5625,7 +5699,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5625
5699
  // Both halves matter. Clipping the bottom would hide what just happened; anchoring
5626
5700
  // always to the bottom is what put the banner there.
5627
5701
  _jsx(Box, { flexGrow: 0, flexShrink: 1, flexDirection: "column", justifyContent: "flex-end", overflowY: "hidden", children: visibleRows.map((row, i) => (_jsx(Box, { flexDirection: "column", flexShrink: 0, children: renderRow(row, i) }, i))) })), altScreen && scrollBack > 0 && (_jsx(Text, { color: AMBER, children: `${G.caret} scrolled back ${scrollBack} rows — PageDown, or just type, to follow again` })), showAnalytics && (_jsxs(Box, { borderStyle: "round", borderColor: AMBER, paddingX: 1, marginBottom: 1, flexDirection: "column", width: barWidth, children: [_jsx(Text, { color: AMBER, bold: true, children: "\u25C6 Session Analytics" }), _jsxs(Text, { color: MUTED, children: ["Turns : ", _jsx(Text, { color: INK, children: stats.turns })] }), _jsxs(Text, { color: MUTED, children: ["Prompt tok : ", _jsx(Text, { color: INK, children: stats.promptTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Output tok : ", _jsx(Text, { color: INK, children: stats.completionTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Total tok : ", _jsx(Text, { color: INK, children: stats.totalTokens.toLocaleString() })] }), _jsxs(Text, { color: MUTED, children: ["Model : ", _jsx(Text, { color: INK, children: modelShort }), " Provider: ", _jsx(Text, { color: INK, children: cfg.provider })] }), _jsx(Text, { color: DIM, children: "Tab to close" })] })), _jsx(Static, { items: altScreen ? [] : rows, children: (row, index) => renderRow(row, index) }), _jsx(Box, { flexDirection: "column", flexShrink: 0, children: busy && (agentState === 'processing' || agentState === 'syncing') && (_jsxs(Box, { flexDirection: "column", children: [!ask && sayRef.current.trim() !== '' && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: CYAN, bold: true, children: replyStartedRef.current ? ' ' : G.brand }), _jsx(Box, { flexDirection: "column", children: tailLines(sayRef.current, liveReplyLines(termRows), replyWidth - 3)
5628
- .split('\n').map((line, i) => _jsx(Text, { color: INK, children: line }, i)) })] })), liveToolRef.current && renderStep(liveToolRef.current, 1), queued.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: AMBER, children: "\u25AA" }), _jsxs(Text, { color: MUTED, children: [queued.length, " queued, will run when this finishes \u00B7 /aside to ask without waiting"] })] }), queued.map((q, i) => (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: fitCells(q.split('\n')[0] ?? '', Math.max(20, barWidth - 6), true) }) }, i)))] })), agents.length > 0 && (() => {
5702
+ .split('\n').map((line, i) => _jsx(Text, { color: INK, children: line }, i)) })] })), liveToolRef.current && renderStep(liveToolRef.current, 1), queued.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: AMBER, children: "\u25AA" }), _jsxs(Text, { color: MUTED, children: [queued.length, " queued, will run when this finishes \u00B7 /aside to ask without waiting"] })] }), queued.map((q, i) => (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: fitCells(q.split('\n')[0] ?? '', Math.max(20, barWidth - 6), true) }) }, i)))] })), !activityPanelOpen && agents.length > 0 && (() => {
5629
5703
  const done = agents.filter(a => a.status !== 'running').length;
5630
5704
  const failed = agents.filter(a => a.status === 'failed').length;
5631
5705
  const tokens = agents.reduce((n, a) => n + a.tokens, 0);
@@ -5642,7 +5716,7 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5642
5716
  const task = a.task.length > taskWidth ? a.task.slice(0, taskWidth - 3) + '...' : a.task;
5643
5717
  return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { color: color, children: glyph }), _jsx(Text, { color: DIM, children: String(a.index + 1).padStart(2) }), _jsx(Text, { color: a.status === 'running' ? INK : MUTED, children: fitCells(task, taskWidth) }), _jsx(Text, { color: DIM, children: humanTokens(a.tokens).padStart(6) }), _jsx(Text, { color: DIM, children: fmtElapsed(took).padStart(6) })] }, a.index));
5644
5718
  })] }));
5645
- })(), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: GREEN, children: [SPINNER[spinFrame], " "] }), _jsx(Shimmer, { text: workWord({
5719
+ })(), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: GREEN, children: [animate ? SPINNER[spinFrame] : '●', " "] }), _jsx(Shimmer, { text: workWord({
5646
5720
  elapsedMs, tools: turnToolsRef.current,
5647
5721
  tokens: liveTokens, recovered: turnRecoveredRef.current,
5648
5722
  }, spinWord)[0], frame: shimmerFrame, base: MUTED }), _jsx(Text, { color: MUTED, children: "\u2026 " }), _jsxs(Text, { color: DIM, children: ["(", fmtElapsed(elapsedMs), " | ", liveTokens > 0
@@ -5650,9 +5724,10 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5650
5724
  // A gateway routing to several backends sends content-free frames while it
5651
5725
  // finds one. Saying "waiting for the first token" through that reads as a
5652
5726
  // hang, when in fact the request was accepted and is being worked on.
5653
- : providerFramesRef.current > 0 ? `${cfg.provider} is holding the line, no output yet`
5654
- : elapsedMs > 8_000 ? `no response from ${cfg.provider} yet`
5655
- : 'starting', thinkingRef.current.chars > 0 && Date.now() - thinkingRef.current.at < 2_000
5727
+ : liveToolRef.current ? 'running ' + liveToolRef.current.name
5728
+ : providerFramesRef.current > 0 ? `${cfg.provider} is holding the line, no output yet`
5729
+ : elapsedMs > 8_000 ? `no response from ${cfg.provider} yet`
5730
+ : 'starting', thinkingRef.current.chars > 0 && Date.now() - thinkingRef.current.at < 2_000
5656
5731
  ? ` | thinking, ${fmtTokens(Math.round(thinkingRef.current.chars / 4))} reasoning tokens`
5657
5732
  : '', ")"] })] }) }), showThinking && !ask && thinkingTextRef.current.trim() !== '' && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: thinkingTail(thinkingTextRef.current, THINKING_LINES, barWidth - 6).map((line, i) => (_jsx(Text, { color: DIM, italic: true, children: line }, i))) })), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: DIM, children: "esc to cancel and keep your input \u00B7 ctrl+c to exit" }) })] })) }), usagePane && (() => {
5658
5733
  const width = Math.min(barWidth, usagePane === 'settings' ? 96 : 66);
@@ -5972,11 +6047,13 @@ export function App({ config: initialConfig, clearFrame, synchronizedFrames = fa
5972
6047
  : '*type the number, or arrows and enter · esc refuses*',
5973
6048
  ];
5974
6049
  return (_jsx(Box, { marginTop: 1, children: _jsx(Panel, { width: panelWidth(lines, barWidth, 46), color: AMBER, title: `Permission — ${modeSpec(mode).label} mode`, children: lines }) }));
5975
- })()] }), diffPanelOpen && (_jsx(DiffPanel, { files: liveDiffs, width: diffPanelWidth, height: Math.max(4, frameRows - 6) }))] }), _jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? DIM : CYAN, paddingX: 1, width: barWidth, flexShrink: 0, children: [_jsxs(Text, { color: CYAN, bold: true, children: [G.caret, " "] }), _jsx(Text, { color: INK, children: draft.slice(0, caret) }), busy
6050
+ })()] }), sidePanelOpen && (_jsxs(Box, { flexDirection: "column", width: diffPanelWidth, flexShrink: 0, children: [activityPanelOpen && (_jsxs(Box, { borderStyle: "round", borderColor: CYAN, flexDirection: "column", paddingX: 1, height: Math.min(18, Math.max(8, termRows - 7)), overflowY: "hidden", children: [_jsxs(Text, { color: CYAN, bold: true, children: ["Activity \u00B7 ", fmtElapsed(elapsedMs)] }), _jsx(Text, { color: AMBER, wrap: "truncate", children: ask ? 'Waiting for your approval' : liveToolRef.current
6051
+ ? 'Running ' + liveToolRef.current.name : thinkingRef.current.chars > 0
6052
+ ? 'Waiting for model · reasoning received' : 'Waiting for model' }), liveToolRef.current && _jsx(Text, { color: INK, wrap: "truncate", children: liveToolRef.current.detail }), liveToolRef.current && _jsxs(Text, { color: DIM, children: ["Running for ", fmtElapsed(Date.now() - liveToolRef.current.startedAt)] }), _jsx(Text, { color: MUTED, wrap: "truncate", children: liveToolRef.current?.output || 'Status updates every 2s; esc to interrupt' }), plan && _jsx(Text, { color: MUTED, wrap: "truncate", children: plan.steps.find(s => s.state !== 'done')?.text || 'Plan complete' }), agents.length > 0 && _jsxs(Text, { color: CYAN, bold: true, children: ["Agents \u00B7 ", agents.filter(a => a.status !== 'running').length, "/", agents.length, " finished"] }), agents.map(a => _jsx(Box, { flexDirection: "column", flexShrink: 0, children: _jsxs(Text, { color: a.status === 'failed' ? CRIMSON : a.status === 'done' ? GREEN : AMBER, wrap: "truncate", children: [a.index + 1, ". ", a.status === 'running' ? (a.activity === 'Queued' ? 'queued' : 'working') : a.status, " \u00B7 ", a.activity, " \u00B7 ", a.task] }) }, a.index))] })), diffPanelOpen && _jsx(DiffPanel, { files: liveDiffs, width: diffPanelWidth, height: Math.max(4, frameRows - (activityPanelOpen ? 24 : 6)) })] }))] }), _jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? DIM : CYAN, paddingX: 1, width: uiWidth - 5, flexShrink: 0, children: [_jsxs(Text, { color: CYAN, bold: true, children: [G.caret, " "] }), _jsx(Text, { color: INK, children: draft.slice(0, caret) }), busy
5976
6053
  ? _jsx(Text, { color: INK, children: draft.slice(caret) })
5977
6054
  : caret < draft.length
5978
6055
  ? _jsxs(_Fragment, { children: [_jsx(Text, { backgroundColor: CYAN, color: "#10222A", children: draft[caret] }), _jsx(Text, { color: INK, children: draft.slice(caret + 1) })] })
5979
- : _jsx(Text, { color: CYAN, children: "\u2588" })] }), _jsx(Box, { marginTop: 1, paddingX: 1, flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", backgroundColor: "#24343B", children: [repaint % 2 === 1 ? '\u200b' : '', _jsx(Text, { color: MUTED, children: "TAB " }), _jsx(Text, { color: INK, children: "Analytics" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: CYAN, children: "/HELP" }), _jsx(Text, { color: INK, children: " Commands" }), _jsx(Text, { color: MUTED, children: " / SHIFT+TAB " }), _jsx(Text, { color: INK, children: "Mode" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: statusColor, children: statusLabel.toLowerCase() }), plan && planSummary(plan) && (_jsxs(_Fragment, { children: [_jsx(Text, { color: MUTED, children: " / PLAN: " }), _jsx(Text, { color: CYAN, children: planSummary(plan) })] })), _jsx(Text, { color: MUTED, children: " / MODE: " }), _jsx(Text, { color: mode === 'auto' ? INK : AMBER, children: modeSpec(mode).label }), _jsx(Text, { color: MUTED, children: " / EFFORT: " }), _jsx(Text, { color: effort === 'medium' ? INK : AMBER, children: effort }), mouse && (_jsxs(_Fragment, { children: [_jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: AMBER, children: "WHEEL: on \u2014 shift to select \u00B7 /mouse off" })] })), _jsx(Text, { color: MUTED, children: " / MODEL: " }), _jsx(Text, { color: INK, children: modelShort }), _jsx(Text, { color: MUTED, children: " / v" }), _jsx(Text, { color: updatePane?.behind ? AMBER : INK, children: VERSION }), updatePane?.behind && _jsx(Text, { color: AMBER, children: " \u2191" }), pasteCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: MUTED, children: " / " }), _jsxs(Text, { color: AMBER, children: [pasteCount, " paste", pasteCount === 1 ? '' : 's', " held, sent in full"] })] }))] }) })] }));
6056
+ : _jsx(Text, { color: CYAN, children: "\u2588" })] }), copyNotice && _jsx(Text, { color: CYAN, children: copyNotice }), _jsx(Box, { marginTop: 1, paddingX: 1, flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", backgroundColor: "#24343B", children: [repaint % 2 === 1 ? '\u200b' : '', _jsx(Text, { color: MUTED, children: "TAB " }), _jsx(Text, { color: INK, children: "Analytics" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: CYAN, children: "/HELP" }), _jsx(Text, { color: INK, children: " Commands" }), _jsx(Text, { color: MUTED, children: " / SHIFT+TAB " }), _jsx(Text, { color: INK, children: "Mode" }), _jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: statusColor, children: statusLabel.toLowerCase() }), plan && planSummary(plan) && (_jsxs(_Fragment, { children: [_jsx(Text, { color: MUTED, children: " / PLAN: " }), _jsx(Text, { color: CYAN, children: planSummary(plan) })] })), _jsx(Text, { color: MUTED, children: " / MODE: " }), _jsx(Text, { color: mode === 'auto' ? INK : AMBER, children: modeSpec(mode).label }), _jsx(Text, { color: MUTED, children: " / EFFORT: " }), _jsx(Text, { color: effort === 'medium' ? INK : AMBER, children: effort }), mouse && (_jsxs(_Fragment, { children: [_jsx(Text, { color: MUTED, children: " / " }), _jsx(Text, { color: AMBER, children: "WHEEL: on \u2014 shift to select \u00B7 /mouse off" })] })), _jsx(Text, { color: MUTED, children: " / MODEL: " }), _jsx(Text, { color: INK, children: modelShort }), _jsx(Text, { color: MUTED, children: " / v" }), _jsx(Text, { color: updatePane?.behind ? AMBER : INK, children: VERSION }), updatePane?.behind && _jsx(Text, { color: AMBER, children: " \u2191" }), pasteCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: MUTED, children: " / " }), _jsxs(Text, { color: AMBER, children: [pasteCount, " paste", pasteCount === 1 ? '' : 's', " held, sent in full"] })] }))] }) })] }));
5980
6057
  }
5981
6058
  export async function runInkChatMode(config) {
5982
6059
  // Nothing is written before the first frame. Filling the screen to push the prompt to the foot