@researchcomputer/pista 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Pista
2
2
 
3
+ <p align="center">
4
+ <img src="./docs/assets/logo.png" alt="Pista" style="max-width: 128px;" />
5
+ </p>
6
+
3
7
  A full-featured terminal coding agent powered by [`@researchcomputer/agents-sdk`](https://www.npmjs.com/package/@researchcomputer/agents-sdk). Pista provides a rich Ink-based TUI with:
4
8
 
5
9
  - Live assistant streaming
@@ -63,16 +63,12 @@ export function handleAgentEvent(event, controls) {
63
63
  function summarizeAssistantMessage(message) {
64
64
  if (message.role !== 'assistant')
65
65
  return '';
66
- const text = message.content
67
- .map((item) => (item.type === 'text' ? item.text : ''))
68
- .join('');
66
+ const text = message.content.map((item) => (item.type === 'text' ? item.text : '')).join('');
69
67
  if (text.trim())
70
68
  return text.trim();
71
69
  if (message.errorMessage)
72
70
  return `[${message.stopReason}] ${message.errorMessage}`;
73
- const toolNames = message.content
74
- .map((item) => (item.type === 'toolCall' ? item.name : ''))
75
- .filter(Boolean);
71
+ const toolNames = message.content.map((item) => (item.type === 'toolCall' ? item.name : '')).filter(Boolean);
76
72
  return toolNames.length > 0 ? `[requested tools: ${toolNames.join(', ')}]` : '';
77
73
  }
78
74
  function summarizeToolResult(result) {
@@ -22,6 +22,7 @@ test('text deltas are forwarded through the buffered live assistant path', () =>
22
22
  type: 'text_delta',
23
23
  delta: 'hello',
24
24
  },
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
26
  }, createControls({
26
27
  appendLiveAssistantDelta: (delta) => {
27
28
  deltas.push(delta);
@@ -38,6 +39,7 @@ test('assistant message end logs the final text and clears the live buffer', ()
38
39
  role: 'assistant',
39
40
  content: [{ type: 'text', text: 'final answer' }],
40
41
  },
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
43
  }, createControls({
42
44
  appendLog: (kind, title, body) => {
43
45
  logs.push({ kind, title, body });
@@ -57,6 +59,7 @@ test('assistant message end falls back to buffered live text when needed', () =>
57
59
  role: 'assistant',
58
60
  content: [],
59
61
  },
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
63
  }, createControls({
61
64
  getLiveAssistantText: () => 'buffered answer',
62
65
  appendLog: (kind, title, body) => {
package/dist/app.js CHANGED
@@ -1,9 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import process from 'node:process';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
2
  import { randomUUID } from 'node:crypto';
4
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
4
  import { Box, Text, useApp, useInput } from 'ink';
6
- import { createCodingAgent, } from '@researchcomputer/agents-sdk';
5
+ import { useTerminalSize } from './hooks/use-terminal-size.js';
6
+ import { useComposer } from './hooks/use-composer.js';
7
+ import { useLiveAssistant } from './hooks/use-live-assistant.js';
8
+ import { createCodingAgent } from '@researchcomputer/agents-sdk';
7
9
  import { resolveModel, describeSelection } from './model.js';
8
10
  import { handleAgentEvent } from './agent-events.js';
9
11
  import { getSlashCommandSuggestions, handleSlashCommand } from './commands.js';
@@ -13,9 +15,11 @@ import { Header } from './components/header.js';
13
15
  import { LogRow } from './components/log-row.js';
14
16
  import { PickerPanel } from './components/picker.js';
15
17
  import { PromptPanel } from './components/prompt.js';
16
- import { SlashCommandMenu } from './components/slash-command-menu.js';
18
+ // SlashCommandMenu is rendered via ComposerPanel
19
+ import { StatusBar } from './components/status-bar.js';
20
+ import { ComposerPanel } from './components/composer-panel.js';
17
21
  import { AssistantMessage } from './components/assistant-message.js';
18
- import { Editor, editBuffer } from './components/editor.js';
22
+ import { editBuffer } from './components/editor.js';
19
23
  import { getTranscriptOffsetForEntry, getTranscriptWindow, stepTranscriptOffset } from './transcript.js';
20
24
  import { errorMessage, stringifyPreview, getStoredApiKey, saveApiKey, getApiKeyPath, getCliMemoryDir, getCliSessionsDir, loadStoredPreferences, mergeStoredPreferences, saveStoredPreferences, } from './utils.js';
21
25
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -31,19 +35,18 @@ export function App({ initialConfig }) {
31
35
  const pendingRequestRef = useRef(null);
32
36
  const permissionMemoryRef = useRef(new Map());
33
37
  const sessionIdRef = useRef(initialConfig.sessionId);
34
- const liveAssistantRef = useRef('');
35
- const liveAssistantFlushTimerRef = useRef(null);
36
38
  const mountedRef = useRef(true);
37
39
  const runningRef = useRef(false);
38
40
  const isRebuildingRef = useRef(false);
41
+ const btwSnapshotRef = useRef(null);
42
+ const btwLogCountRef = useRef(0);
43
+ const btwOneShotRef = useRef(false);
39
44
  const [config, setConfig] = useState(initialConfig);
40
45
  const [logs, setLogs] = useState([]);
41
- const [composer, setComposer] = useState({ value: '', cursor: 0 });
42
- const [composerHistory, setComposerHistory] = useState([]);
43
- const [composerHistoryIndex, setComposerHistoryIndex] = useState(null);
44
- const [composerDraftBeforeHistory, setComposerDraftBeforeHistory] = useState(null);
46
+ const composerHook = useComposer();
47
+ const { composer, setComposer } = composerHook;
48
+ const liveAssistant = useLiveAssistant();
45
49
  const [slashMenuSelectedIndex, setSlashMenuSelectedIndex] = useState(0);
46
- const [liveAssistant, setLiveAssistant] = useState('');
47
50
  const [status, setStatus] = useState('Idle');
48
51
  const [running, setRunning] = useState(false);
49
52
  const [spinnerFrame, setSpinnerFrame] = useState(0);
@@ -52,12 +55,21 @@ export function App({ initialConfig }) {
52
55
  const [promptState, setPromptState] = useState(null);
53
56
  const [pickerState, setPickerState] = useState(null);
54
57
  const [isRebuilding, setIsRebuilding] = useState(false);
58
+ const [btwActive, setBtwActive] = useState(false);
55
59
  const currentInputMode = promptState ? 'prompt' : pickerState ? 'picker' : 'composer';
56
- const terminalRows = process.stdout.rows || 24;
57
- useEffect(() => { agentNameRef.current = config.agentName; }, [config.agentName]);
58
- useEffect(() => { sessionIdRef.current = config.sessionId; }, [config.sessionId]);
59
- useEffect(() => { runningRef.current = running; }, [running]);
60
- useEffect(() => { isRebuildingRef.current = isRebuilding; }, [isRebuilding]);
60
+ const { columns: termWidth, rows: terminalRows } = useTerminalSize();
61
+ useEffect(() => {
62
+ agentNameRef.current = config.agentName;
63
+ }, [config.agentName]);
64
+ useEffect(() => {
65
+ sessionIdRef.current = config.sessionId;
66
+ }, [config.sessionId]);
67
+ useEffect(() => {
68
+ runningRef.current = running;
69
+ }, [running]);
70
+ useEffect(() => {
71
+ isRebuildingRef.current = isRebuilding;
72
+ }, [isRebuilding]);
61
73
  useEffect(() => {
62
74
  if (!running) {
63
75
  setSpinnerFrame(0);
@@ -65,62 +77,19 @@ export function App({ initialConfig }) {
65
77
  }
66
78
  const timer = setInterval(() => {
67
79
  setSpinnerFrame((current) => (current + 1) % SPINNER_FRAMES.length);
68
- }, 100);
80
+ }, 150);
69
81
  return () => clearInterval(timer);
70
82
  }, [running]);
71
83
  const appendLog = useCallback((kind, title, body) => {
72
- setLogs((current) => [
73
- ...current,
74
- { id: randomUUID(), kind, title, body, timestamp: Date.now() },
75
- ]);
76
- }, []);
77
- const flushLiveAssistant = useCallback(() => {
78
- if (liveAssistantFlushTimerRef.current) {
79
- clearTimeout(liveAssistantFlushTimerRef.current);
80
- liveAssistantFlushTimerRef.current = null;
81
- }
82
- if (!mountedRef.current)
83
- return;
84
- const next = liveAssistantRef.current;
85
- setLiveAssistant((current) => (current === next ? current : next));
86
- }, []);
87
- const scheduleLiveAssistantFlush = useCallback(() => {
88
- if (liveAssistantFlushTimerRef.current)
89
- return;
90
- liveAssistantFlushTimerRef.current = setTimeout(() => {
91
- liveAssistantFlushTimerRef.current = null;
92
- if (!mountedRef.current)
93
- return;
94
- const next = liveAssistantRef.current;
95
- setLiveAssistant((current) => (current === next ? current : next));
96
- }, 33);
97
- }, []);
98
- const appendLiveAssistantDelta = useCallback((delta) => {
99
- if (!delta)
100
- return;
101
- liveAssistantRef.current += delta;
102
- scheduleLiveAssistantFlush();
103
- }, [scheduleLiveAssistantFlush]);
104
- const clearLiveAssistant = useCallback(() => {
105
- if (liveAssistantFlushTimerRef.current) {
106
- clearTimeout(liveAssistantFlushTimerRef.current);
107
- liveAssistantFlushTimerRef.current = null;
108
- }
109
- liveAssistantRef.current = '';
110
- setLiveAssistant((current) => (current ? '' : current));
111
- }, []);
112
- const rememberComposerDraft = useCallback((value) => {
113
- setComposerHistory((current) => {
114
- const trimmed = value.trim();
115
- if (!trimmed)
116
- return current;
117
- const deduped = current.filter((entry) => entry !== value);
118
- return [...deduped, value].slice(-50);
119
- });
84
+ setLogs((current) => [...current, { id: randomUUID(), kind, title, body, timestamp: Date.now() }]);
120
85
  }, []);
121
86
  const visibleLogRowsFor = useCallback((prompt, picker, live, isRunning, slashMenuCount) => {
122
87
  const slashMenuRows = slashMenuCount > 0 ? Math.min(slashMenuCount, 6) * 2 + 3 : 0;
123
- const reservedRows = 8 + (prompt ? 4 : 0) + (picker ? Math.min(picker.items.length, 6) + 3 : 0) + (live || isRunning ? 3 : 0) + slashMenuRows;
88
+ const reservedRows = 8 +
89
+ (prompt ? 4 : 0) +
90
+ (picker ? Math.min(picker.items.length, 6) + 3 : 0) +
91
+ (live || isRunning ? 3 : 0) +
92
+ slashMenuRows;
124
93
  return Math.max(4, terminalRows - reservedRows);
125
94
  }, [terminalRows]);
126
95
  const jumpTranscriptTo = useCallback((target) => {
@@ -133,10 +102,10 @@ export function App({ initialConfig }) {
133
102
  return false;
134
103
  }
135
104
  const actualIndex = logs.length - 1 - targetIndex;
136
- const visibleLogRows = visibleLogRowsFor(promptState, pickerState, liveAssistant, running, 0);
105
+ const visibleLogRows = visibleLogRowsFor(promptState, pickerState, liveAssistant.text, running, 0);
137
106
  setLogScrollOffset(getTranscriptOffsetForEntry(logs.length, visibleLogRows, actualIndex));
138
107
  return true;
139
- }, [liveAssistant, logs, pickerState, promptState, running, visibleLogRowsFor]);
108
+ }, [liveAssistant.text, logs, pickerState, promptState, running, visibleLogRowsFor]);
140
109
  const openPrompt = useCallback((request) => {
141
110
  return new Promise((resolvePrompt) => {
142
111
  pendingRequestRef.current = { type: 'prompt', resolve: resolvePrompt };
@@ -176,7 +145,45 @@ export function App({ initialConfig }) {
176
145
  setStatus(runningRef.current ? 'Running' : isRebuildingRef.current ? 'Rebuilding agent' : 'Idle');
177
146
  pending?.resolve(value);
178
147
  }, []);
148
+ const enterBtw = useCallback((oneShot) => {
149
+ const agent = agentRef.current;
150
+ if (!agent) {
151
+ appendLog('error', 'Agent unavailable', 'The agent is not ready yet.');
152
+ return false;
153
+ }
154
+ if (btwSnapshotRef.current) {
155
+ appendLog('system', 'Already in btw mode', 'Press Esc to exit first.');
156
+ return false;
157
+ }
158
+ btwSnapshotRef.current = agent.snapshot();
159
+ btwLogCountRef.current = logs.length;
160
+ btwOneShotRef.current = oneShot;
161
+ setBtwActive(true);
162
+ appendLog('system', 'btw mode', oneShot ? 'One-shot btw — will revert after response.' : 'Entered btw mode. Press Esc to exit and revert.');
163
+ return true;
164
+ }, [appendLog, logs.length]);
165
+ const exitBtw = useCallback(() => {
166
+ const agent = agentRef.current;
167
+ const snapshot = btwSnapshotRef.current;
168
+ if (!agent || !snapshot)
169
+ return;
170
+ agent.restore(snapshot);
171
+ btwSnapshotRef.current = null;
172
+ btwOneShotRef.current = false;
173
+ setBtwActive(false);
174
+ liveAssistant.clear();
175
+ setPendingTools([]);
176
+ setLogs((current) => current.slice(0, btwLogCountRef.current));
177
+ setLogScrollOffset(0);
178
+ setStatus(runningRef.current ? 'Running' : 'Idle');
179
+ }, [liveAssistant]);
179
180
  const rebuildAgent = useCallback(async (nextConfig, note) => {
181
+ if (btwSnapshotRef.current) {
182
+ btwSnapshotRef.current = null;
183
+ btwOneShotRef.current = false;
184
+ setBtwActive(false);
185
+ setLogs((current) => current.slice(0, btwLogCountRef.current));
186
+ }
180
187
  const buildToken = ++buildTokenRef.current;
181
188
  if (nextConfig.sessionId !== sessionIdRef.current) {
182
189
  permissionMemoryRef.current.clear();
@@ -185,7 +192,7 @@ export function App({ initialConfig }) {
185
192
  setStatus('Rebuilding agent');
186
193
  setRunning(false);
187
194
  setPendingTools([]);
188
- clearLiveAssistant();
195
+ liveAssistant.clear();
189
196
  unsubscribeRef.current?.();
190
197
  unsubscribeRef.current = null;
191
198
  const previousAgent = agentRef.current;
@@ -244,9 +251,17 @@ export function App({ initialConfig }) {
244
251
  ].join('\n'),
245
252
  items: [
246
253
  { id: 'allow', label: 'Allow once', description: 'Run this tool call and continue the current turn.' },
247
- { id: 'allow-session', label: 'Always allow this session', description: 'Auto-approve future calls to this tool name in the current session.' },
254
+ {
255
+ id: 'allow-session',
256
+ label: 'Always allow this session',
257
+ description: 'Auto-approve future calls to this tool name in the current session.',
258
+ },
248
259
  { id: 'deny', label: 'Deny', description: 'Block this tool call and let the agent recover.' },
249
- { id: 'deny-session', label: 'Always deny this session', description: 'Auto-deny future calls to this tool name in the current session.' },
260
+ {
261
+ id: 'deny-session',
262
+ label: 'Always deny this session',
263
+ description: 'Auto-deny future calls to this tool name in the current session.',
264
+ },
250
265
  ],
251
266
  selectedId: 'allow',
252
267
  });
@@ -278,17 +293,18 @@ export function App({ initialConfig }) {
278
293
  unsubscribeRef.current = codingAgent.agent.subscribe((event) => {
279
294
  handleAgentEvent(event, {
280
295
  getAgentName: () => agentNameRef.current,
281
- getLiveAssistantText: () => {
282
- flushLiveAssistant();
283
- return liveAssistantRef.current;
284
- },
296
+ getLiveAssistantText: () => liveAssistant.getText(),
285
297
  appendLog,
286
- appendLiveAssistantDelta,
287
- clearLiveAssistant,
298
+ appendLiveAssistantDelta: liveAssistant.appendDelta,
299
+ clearLiveAssistant: liveAssistant.clear,
288
300
  setPendingTools,
289
301
  setRunning,
290
302
  setStatus,
291
303
  });
304
+ // Auto-exit btw mode after one-shot completes
305
+ if (event.type === 'agent_end' && btwSnapshotRef.current && btwOneShotRef.current) {
306
+ setTimeout(() => exitBtw(), 0);
307
+ }
292
308
  });
293
309
  appendLog('system', 'Agent ready', note);
294
310
  setStatus('Idle');
@@ -302,7 +318,7 @@ export function App({ initialConfig }) {
302
318
  setIsRebuilding(false);
303
319
  }
304
320
  }
305
- }, [appendLog, openPicker, openPrompt]);
321
+ }, [appendLog, exitBtw, liveAssistant, openPicker, openPrompt]);
306
322
  useEffect(() => {
307
323
  void rebuildAgent(initialConfig, [
308
324
  `Agent ${initialConfig.agentName}`,
@@ -315,16 +331,13 @@ export function App({ initialConfig }) {
315
331
  mountedRef.current = false;
316
332
  unsubscribeRef.current?.();
317
333
  unsubscribeRef.current = null;
318
- if (liveAssistantFlushTimerRef.current) {
319
- clearTimeout(liveAssistantFlushTimerRef.current);
320
- liveAssistantFlushTimerRef.current = null;
321
- }
334
+ liveAssistant.cleanup();
322
335
  if (agentRef.current) {
323
336
  void agentRef.current.dispose();
324
337
  agentRef.current = null;
325
338
  }
326
339
  };
327
- }, [clearLiveAssistant, initialConfig, rebuildAgent]);
340
+ }, [initialConfig, liveAssistant, rebuildAgent]);
328
341
  const commandContext = useMemo(() => ({
329
342
  config,
330
343
  running,
@@ -334,8 +347,14 @@ export function App({ initialConfig }) {
334
347
  rebuildAgent,
335
348
  setConfig: (updater) => setConfig(updater),
336
349
  resetAgent: () => {
350
+ if (btwSnapshotRef.current) {
351
+ btwSnapshotRef.current = null;
352
+ btwOneShotRef.current = false;
353
+ setBtwActive(false);
354
+ setLogs((current) => current.slice(0, btwLogCountRef.current));
355
+ }
337
356
  agentRef.current?.agent.reset();
338
- clearLiveAssistant();
357
+ liveAssistant.clear();
339
358
  setPendingTools([]);
340
359
  },
341
360
  abortAgent: () => {
@@ -348,7 +367,7 @@ export function App({ initialConfig }) {
348
367
  },
349
368
  clearLogs: () => {
350
369
  setLogs([]);
351
- clearLiveAssistant();
370
+ liveAssistant.clear();
352
371
  setLogScrollOffset(0);
353
372
  },
354
373
  getCostSummary: () => {
@@ -359,6 +378,8 @@ export function App({ initialConfig }) {
359
378
  return { tokens: total.tokens, cost: total.cost };
360
379
  },
361
380
  jumpTranscript: (target) => jumpTranscriptTo(target),
381
+ enterBtw,
382
+ isBtwActive: () => btwSnapshotRef.current !== null,
362
383
  promptAgent: async (message) => {
363
384
  const agent = agentRef.current;
364
385
  if (!agent) {
@@ -376,15 +397,25 @@ export function App({ initialConfig }) {
376
397
  }
377
398
  },
378
399
  exit,
379
- }), [appendLog, clearLiveAssistant, closeRequest, config, exit, jumpTranscriptTo, openPicker, openPrompt, rebuildAgent, running]);
400
+ }), [
401
+ appendLog,
402
+ enterBtw,
403
+ liveAssistant,
404
+ closeRequest,
405
+ config,
406
+ exit,
407
+ jumpTranscriptTo,
408
+ openPicker,
409
+ openPrompt,
410
+ rebuildAgent,
411
+ running,
412
+ ]);
380
413
  const submitComposer = useCallback(async () => {
381
414
  const trimmed = composer.value.trim();
382
415
  if (!trimmed)
383
416
  return;
384
- rememberComposerDraft(composer.value);
385
- setComposer({ value: '', cursor: 0 });
386
- setComposerHistoryIndex(null);
387
- setComposerDraftBeforeHistory(null);
417
+ composerHook.remember(composer.value);
418
+ composerHook.clear();
388
419
  setLogScrollOffset(0);
389
420
  if (trimmed.startsWith('/')) {
390
421
  await handleSlashCommand(trimmed, commandContext);
@@ -412,10 +443,12 @@ export function App({ initialConfig }) {
412
443
  appendLog('error', 'Agent error', errorMessage(error));
413
444
  setStatus('Error');
414
445
  }
415
- }, [appendLog, commandContext, composer.value, isRebuilding, rememberComposerDraft, running]);
446
+ }, [appendLog, commandContext, composer.value, composerHook, isRebuilding, running]);
416
447
  const slashCommandItems = useMemo(() => getSlashCommandSuggestions(composer.value, running), [composer.value, running]);
417
448
  const slashMenuVisible = currentInputMode === 'composer' && slashCommandItems.length > 0;
418
- const selectedSlashCommand = slashMenuVisible ? slashCommandItems[Math.min(slashMenuSelectedIndex, slashCommandItems.length - 1)] : null;
449
+ const selectedSlashCommand = slashMenuVisible
450
+ ? slashCommandItems[Math.min(slashMenuSelectedIndex, slashCommandItems.length - 1)]
451
+ : null;
419
452
  useEffect(() => {
420
453
  setSlashMenuSelectedIndex((current) => {
421
454
  if (slashCommandItems.length === 0)
@@ -423,11 +456,6 @@ export function App({ initialConfig }) {
423
456
  return Math.min(current, slashCommandItems.length - 1);
424
457
  });
425
458
  }, [slashCommandItems]);
426
- const applySlashCommandSelection = useCallback((insertText) => {
427
- setComposer({ value: insertText, cursor: insertText.length });
428
- setComposerHistoryIndex(null);
429
- setComposerDraftBeforeHistory(null);
430
- }, []);
431
459
  useInput((input, key) => {
432
460
  if (key.ctrl && input === 'c') {
433
461
  if (running && agentRef.current) {
@@ -486,16 +514,17 @@ export function App({ initialConfig }) {
486
514
  if (key.upArrow || key.downArrow || key.tab) {
487
515
  return;
488
516
  }
489
- setPromptState((current) => current ? editBuffer(current, input, { ...key, escape: key.escape }) : current);
517
+ setPromptState((current) => (current ? editBuffer(current, input, { ...key, escape: key.escape }) : current));
490
518
  return;
491
519
  }
492
520
  if (key.escape) {
493
- setComposer({ value: '', cursor: 0 });
494
- setComposerHistoryIndex(null);
495
- setComposerDraftBeforeHistory(null);
521
+ if (btwActive) {
522
+ exitBtw();
523
+ }
524
+ composerHook.clear();
496
525
  return;
497
526
  }
498
- const visibleLogRows = visibleLogRowsFor(promptState, pickerState, liveAssistant, running, slashMenuVisible ? slashCommandItems.length : 0);
527
+ const visibleLogRows = visibleLogRowsFor(promptState, pickerState, liveAssistant.text, running, slashMenuVisible ? slashCommandItems.length : 0);
499
528
  const scrollStep = Math.max(1, visibleLogRows - 2);
500
529
  if (key.pageUp) {
501
530
  setLogScrollOffset((current) => stepTranscriptOffset(current, scrollStep, logs.length, visibleLogRows));
@@ -522,62 +551,32 @@ export function App({ initialConfig }) {
522
551
  return;
523
552
  }
524
553
  if (slashMenuVisible && key.tab && selectedSlashCommand) {
525
- applySlashCommandSelection(selectedSlashCommand.insertText);
554
+ composerHook.applySelection(selectedSlashCommand.insertText);
526
555
  return;
527
556
  }
528
557
  if (key.ctrl && input === 'j') {
529
- setComposer((current) => {
530
- const nextValue = `${current.value.slice(0, current.cursor)}\n${current.value.slice(current.cursor)}`;
531
- return { value: nextValue, cursor: current.cursor + 1 };
532
- });
533
- setComposerHistoryIndex(null);
534
- setComposerDraftBeforeHistory(null);
558
+ composerHook.insertNewline();
535
559
  return;
536
560
  }
537
561
  if (key.return && key.shift) {
538
- setComposer((current) => {
539
- const nextValue = `${current.value.slice(0, current.cursor)}\n${current.value.slice(current.cursor)}`;
540
- return { value: nextValue, cursor: current.cursor + 1 };
541
- });
542
- setComposerHistoryIndex(null);
543
- setComposerDraftBeforeHistory(null);
562
+ composerHook.insertNewline();
544
563
  return;
545
564
  }
546
565
  if (key.upArrow) {
547
- if (composerHistory.length === 0)
548
- return;
549
- setComposerHistoryIndex((current) => {
550
- const nextIndex = current === null ? composerHistory.length - 1 : Math.max(0, current - 1);
551
- setComposerDraftBeforeHistory((draft) => draft ?? composer);
552
- const nextValue = composerHistory[nextIndex] ?? '';
553
- setComposer({ value: nextValue, cursor: nextValue.length });
554
- return nextIndex;
555
- });
566
+ composerHook.navigateHistory('up');
556
567
  return;
557
568
  }
558
569
  if (key.downArrow) {
559
- if (composerHistory.length === 0 || composerHistoryIndex === null)
560
- return;
561
- if (composerHistoryIndex >= composerHistory.length - 1) {
562
- setComposer(composerDraftBeforeHistory ?? { value: '', cursor: 0 });
563
- setComposerHistoryIndex(null);
564
- setComposerDraftBeforeHistory(null);
565
- return;
566
- }
567
- const nextIndex = composerHistoryIndex + 1;
568
- const nextValue = composerHistory[nextIndex] ?? '';
569
- setComposer({ value: nextValue, cursor: nextValue.length });
570
- setComposerHistoryIndex(nextIndex);
570
+ composerHook.navigateHistory('down');
571
571
  return;
572
572
  }
573
573
  if (key.return) {
574
574
  if (slashMenuVisible && selectedSlashCommand) {
575
575
  const trimmed = composer.value.trim();
576
576
  const hasArgs = /\s/.test(trimmed.slice(1));
577
- const needsCompletion = !hasArgs && (trimmed !== selectedSlashCommand.commandText ||
578
- selectedSlashCommand.insertText.endsWith(' '));
577
+ const needsCompletion = !hasArgs && (trimmed !== selectedSlashCommand.commandText || selectedSlashCommand.insertText.endsWith(' '));
579
578
  if (needsCompletion) {
580
- applySlashCommandSelection(selectedSlashCommand.insertText);
579
+ composerHook.applySelection(selectedSlashCommand.insertText);
581
580
  return;
582
581
  }
583
582
  }
@@ -590,13 +589,12 @@ export function App({ initialConfig }) {
590
589
  setComposer((current) => {
591
590
  const next = editBuffer(current, input, { ...key, escape: key.escape });
592
591
  if (next.value !== current.value || next.cursor !== current.cursor) {
593
- setComposerHistoryIndex(null);
594
- setComposerDraftBeforeHistory(null);
592
+ composerHook.resetHistoryNavigation();
595
593
  }
596
594
  return next;
597
595
  });
598
596
  });
599
- const visibleLogRows = visibleLogRowsFor(promptState, pickerState, liveAssistant, running, slashMenuVisible ? slashCommandItems.length : 0);
597
+ const visibleLogRows = visibleLogRowsFor(promptState, pickerState, liveAssistant.text, running, slashMenuVisible ? slashCommandItems.length : 0);
600
598
  const transcriptWindow = getTranscriptWindow(logs.length, visibleLogRows, logScrollOffset);
601
599
  const renderedLogs = logs.slice(transcriptWindow.start, transcriptWindow.end);
602
600
  const hasContentAbove = transcriptWindow.start > 0;
@@ -604,5 +602,5 @@ export function App({ initialConfig }) {
604
602
  const rangeText = logs.length > 0 ? `${transcriptWindow.start + 1}-${transcriptWindow.end} of ${logs.length}` : '';
605
603
  const transcriptTopRight = [rangeText, hasContentAbove ? '▲' : ''].filter(Boolean).join(' ') || undefined;
606
604
  const transcriptBottomRight = hasContentBelow ? `▼ ${transcriptWindow.offset} newer` : undefined;
607
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, minHeight: terminalRows, flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Header, { agentName: config.agentName, cwd: config.cwd, sessionId: config.sessionId, model: config.selection.model, apiStyle: config.selection.apiStyle, endpoint: config.selection.endpoint, permissionMode: config.permissionMode, thinkingLevel: config.thinkingLevel, status: status, pendingTools: pendingTools }) }), _jsx(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1, minHeight: 0, children: _jsx(Frame, { borderColor: "blue", topLeft: "Transcript", topRight: transcriptTopRight, bottomRight: transcriptBottomRight, children: _jsxs(Box, { flexDirection: "row", flexGrow: 1, minHeight: 0, children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [renderedLogs.length === 0 && !liveAssistant && !running ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "No messages yet." }), _jsx(Text, { dimColor: true, children: "Try asking for a repo change, or use /help to inspect commands." }), _jsx(Text, { dimColor: true, children: "Useful setup commands: /model, /endpoint, /permissions, /thinking, /session new" })] })) : null, renderedLogs.map((entry) => (_jsx(LogRow, { entry: entry }, entry.id))), liveAssistant ? (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Text, { color: "cyanBright", bold: true, children: ["\u25CF ", config.agentName] }), _jsx(Box, { paddingLeft: 2, children: _jsx(AssistantMessage, { content: liveAssistant, showSpinner: running, spinner: _jsx(Spinner, { frame: spinnerFrame }) }) })] })) : null, !liveAssistant && running ? (_jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: "cyanBright", bold: true, children: "\u25CF " }), _jsx(Text, { italic: true, dimColor: true, children: "Thinking " }), _jsx(Spinner, { frame: spinnerFrame })] })) : null] }), logs.length > visibleLogRows ? (_jsx(Scrollbar, { trackHeight: visibleLogRows, totalEntries: logs.length, visibleEntries: visibleLogRows, offset: logScrollOffset })) : null] }) }) }), _jsxs(Box, { flexShrink: 0, flexDirection: "column", marginTop: 1, children: [pickerState ? (_jsx(PickerPanel, { pickerState: pickerState })) : null, promptState ? (_jsx(PromptPanel, { promptState: promptState })) : null, !promptState && !pickerState && slashMenuVisible ? (_jsx(SlashCommandMenu, { items: slashCommandItems, selectedIndex: Math.min(slashMenuSelectedIndex, Math.max(0, slashCommandItems.length - 1)) })) : null, !promptState && !pickerState ? (_jsx(Frame, { borderColor: running ? 'yellow' : 'cyan', bottomLeft: slashMenuVisible ? 'Enter send · ⇧Enter newline · Tab complete · /help' : 'Enter send · ⇧Enter newline · /help', children: _jsxs(Box, { children: [_jsx(Text, { color: "greenBright", bold: true, children: '> ' }), _jsx(Editor, { value: composer.value, cursor: composer.cursor, placeholder: `Ask ${config.agentName} or type /help` })] }) })) : null, _jsxs(Box, { paddingX: 1, justifyContent: "space-between", marginBottom: 0, children: [_jsx(Text, { dimColor: true, children: running ? 'Ctrl+C abort' : 'Ctrl+C exit' }), _jsx(Box, { children: running ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { frame: spinnerFrame }), _jsxs(Text, { color: "yellowBright", children: [" ", status] })] })) : (_jsx(Text, { color: "greenBright", children: "\u25CF ready" })) })] })] })] }));
605
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, minHeight: terminalRows, flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Header, { agentName: config.agentName, cwd: config.cwd, sessionId: config.sessionId, model: config.selection.model, apiStyle: config.selection.apiStyle, endpoint: config.selection.endpoint, permissionMode: config.permissionMode, thinkingLevel: config.thinkingLevel, status: status, pendingTools: pendingTools, termWidth: termWidth }) }), _jsx(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1, minHeight: 0, children: _jsx(Frame, { borderColor: btwActive ? 'magenta' : 'blue', topLeft: btwActive ? 'Transcript (btw)' : 'Transcript', topRight: transcriptTopRight, bottomRight: transcriptBottomRight, termWidth: termWidth, children: _jsxs(Box, { flexDirection: "row", flexGrow: 1, minHeight: 0, children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [renderedLogs.length === 0 && !liveAssistant.text && !running ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "No messages yet." }), _jsx(Text, { dimColor: true, children: "Try asking for a repo change, or use /help to inspect commands." }), _jsx(Text, { dimColor: true, children: "Useful setup commands: /model, /endpoint, /permissions, /thinking, /session new" })] })) : null, renderedLogs.map((entry) => (_jsx(LogRow, { entry: entry, termWidth: termWidth }, entry.id))), liveAssistant.text ? (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Text, { color: "cyanBright", bold: true, children: ["\u25CF ", config.agentName] }), _jsx(Box, { paddingLeft: 2, children: _jsx(AssistantMessage, { content: liveAssistant.text, showSpinner: running, spinner: _jsx(Spinner, { frame: spinnerFrame }) }) })] })) : null, !liveAssistant.text && running ? (_jsxs(Box, { flexShrink: 0, children: [_jsxs(Text, { color: "cyanBright", bold: true, children: ["\u25CF", ' '] }), _jsxs(Text, { italic: true, dimColor: true, children: ["Thinking", ' '] }), _jsx(Spinner, { frame: spinnerFrame })] })) : null] }), logs.length > visibleLogRows ? (_jsx(Scrollbar, { trackHeight: visibleLogRows, totalEntries: logs.length, visibleEntries: visibleLogRows, offset: logScrollOffset })) : null] }) }) }), _jsxs(Box, { flexShrink: 0, flexDirection: "column", marginTop: 1, children: [pickerState ? _jsx(PickerPanel, { pickerState: pickerState }) : null, promptState ? _jsx(PromptPanel, { promptState: promptState }) : null, !promptState && !pickerState ? (_jsx(ComposerPanel, { value: composerHook.composer.value, cursor: composerHook.composer.cursor, agentName: config.agentName, running: running, btwActive: btwActive, slashMenuVisible: slashMenuVisible, slashCommandItems: slashCommandItems, slashMenuSelectedIndex: slashMenuSelectedIndex, termWidth: termWidth })) : null, _jsx(StatusBar, { running: running, status: status, spinnerFrame: spinnerFrame, btwActive: btwActive })] })] }));
608
606
  }