Skip to content

Commit b199324

Browse files
committed
Merge branch 'feat/multiline-composer'
2 parents 8da27af + 5fee327 commit b199324

20 files changed

Lines changed: 3591 additions & 596 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"open": "^10.1.0",
6464
"ora": "^9.0.0",
6565
"react": "^18.2.0",
66+
"string-width": "^8.2.0",
6667
"terminal-link": "^3.0.0",
6768
"yaml": "^2.8.2",
6869
"zod": "^4.1.12"

src/modes/acp/adapter.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ export class AutohandAcpAdapter implements Agent {
226226
workspaceRoot,
227227
createdAt: Date.now(),
228228
abortController: new AbortController(),
229+
promptCount: 0,
229230
};
230231

231232
this.sessions.set(sessionId, state);
@@ -487,6 +488,29 @@ export class AutohandAcpAdapter implements Agent {
487488
return { stopReason: 'end_turn' };
488489
}
489490

491+
// Update session title on first prompt (so Zed shows it in recent sessions)
492+
session.promptCount++;
493+
if (session.promptCount === 1) {
494+
const title = instruction.trim().slice(0, 120);
495+
this.connection.sessionUpdate({
496+
sessionId: params.sessionId,
497+
update: {
498+
sessionUpdate: 'session_info_update',
499+
title,
500+
updatedAt: new Date().toISOString(),
501+
},
502+
});
503+
} else {
504+
// Update timestamp on subsequent prompts
505+
this.connection.sessionUpdate({
506+
sessionId: params.sessionId,
507+
update: {
508+
sessionUpdate: 'session_info_update',
509+
updatedAt: new Date().toISOString(),
510+
},
511+
});
512+
}
513+
490514
// Check if it's a slash command
491515
const trimmed = instruction.trim();
492516
if (trimmed.startsWith('/')) {

src/modes/acp/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ export interface AcpSessionState {
320320
workspaceRoot: string;
321321
createdAt: number;
322322
abortController: AbortController;
323+
/** Number of prompts processed in this session (used for title generation). */
324+
promptCount: number;
323325
}
324326

325327
// ============================================================================

src/ui/ink/AgentUI.tsx

Lines changed: 134 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
* Copyright 2025 Autohand AI LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
import React, { useState, useEffect, memo, useMemo } from 'react';
7-
import { Box, Text, useInput, useApp, Static } from 'ink';
6+
import React, { useState, useEffect, memo, useMemo, useRef, useCallback } from 'react';
7+
import { Box, Text, useInput, useApp, Static, type Key as InkKey } from 'ink';
88
import { StatusLine } from './StatusLine.js';
99
import { ToolOutputStatic, type ToolOutputEntry } from './ToolOutput.js';
1010
import { InputLine } from './InputLine.js';
1111
import { ThinkingOutput } from './ThinkingOutput.js';
1212
import { useTheme } from '../theme/ThemeContext.js';
1313
import { useTranslation } from '../i18n/index.js';
1414
import { getPlanModeManager } from '../../commands/plan.js';
15+
import { TextBuffer } from '../textBuffer.js';
16+
import { handleTextBufferKey, type KeyHandlerResult } from '../textBufferKeyHandler.js';
17+
import { getPromptBlockWidth, isShiftEnterResidualSequence } from '../inputPrompt.js';
1518

1619
export interface AgentUIState {
1720
isWorking: boolean;
@@ -40,6 +43,81 @@ export interface AgentUIProps {
4043
enableQueueInput?: boolean;
4144
}
4245

46+
interface TextBufferKeyInfo {
47+
name?: string;
48+
ctrl?: boolean;
49+
meta?: boolean;
50+
shift?: boolean;
51+
sequence?: string;
52+
}
53+
54+
const INK_TEXTBUFFER_VIEWPORT_HEIGHT = 10;
55+
56+
function getInkTextBufferViewportWidth(columns: number | undefined): number {
57+
return Math.max(1, getPromptBlockWidth(columns) - 4);
58+
}
59+
60+
function mapInkKeyToTextBufferKey(input: string, key: InkKey): TextBufferKeyInfo {
61+
let name: string | undefined;
62+
63+
if (key.leftArrow) {
64+
name = 'left';
65+
} else if (key.rightArrow) {
66+
name = 'right';
67+
} else if (key.upArrow) {
68+
name = 'up';
69+
} else if (key.downArrow) {
70+
name = 'down';
71+
} else if (key.return) {
72+
name = 'return';
73+
} else if (key.backspace) {
74+
name = 'backspace';
75+
} else if (key.delete) {
76+
name = 'delete';
77+
} else if (key.tab) {
78+
name = 'tab';
79+
} else if (key.ctrl && input === 'a') {
80+
name = 'a';
81+
} else if (key.ctrl && input === 'e') {
82+
name = 'e';
83+
}
84+
85+
return {
86+
name,
87+
ctrl: key.ctrl,
88+
meta: key.meta,
89+
shift: key.shift,
90+
sequence: input,
91+
};
92+
}
93+
94+
export function getTextBufferCursorOffset(buffer: TextBuffer): number {
95+
const lines = buffer.getLines();
96+
const row = buffer.getCursorRow();
97+
const col = buffer.getCursorCol();
98+
let offset = 0;
99+
100+
for (let i = 0; i < row; i++) {
101+
offset += lines[i]?.length ?? 0;
102+
offset += 1;
103+
}
104+
105+
return offset + col;
106+
}
107+
108+
export function handleInkTextBufferInput(
109+
buffer: TextBuffer,
110+
input: string,
111+
key: InkKey
112+
): KeyHandlerResult {
113+
if (isShiftEnterResidualSequence(input)) {
114+
buffer.insert('\n');
115+
return 'handled';
116+
}
117+
118+
return handleTextBufferKey(buffer, input, mapInkKeyToTextBufferKey(input, key));
119+
}
120+
43121
export function AgentUI({
44122
state,
45123
onInstruction,
@@ -51,11 +129,31 @@ export function AgentUI({
51129
const { exit } = useApp();
52130
const { colors } = useTheme();
53131
const { t } = useTranslation();
54-
// Initialize input from state.currentInput (preserved across pause/resume)
55132
const [input, setInput] = useState(state.currentInput || '');
133+
const [cursorOffset, setCursorOffset] = useState((state.currentInput || '').length);
56134
const [ctrlCCount, setCtrlCCount] = useState(0);
57135
const [planModeIndicator, setPlanModeIndicator] = useState('');
58136
const [planModeStatusKey, setPlanModeStatusKey] = useState('');
137+
const textBufferRef = useRef<TextBuffer>(
138+
new TextBuffer(
139+
getInkTextBufferViewportWidth(process.stdout.columns),
140+
INK_TEXTBUFFER_VIEWPORT_HEIGHT,
141+
state.currentInput || undefined
142+
)
143+
);
144+
145+
const syncInputFromBuffer = useCallback(() => {
146+
const buffer = textBufferRef.current;
147+
setInput(buffer.getText());
148+
setCursorOffset(getTextBufferCursorOffset(buffer));
149+
}, []);
150+
151+
const syncBufferViewport = useCallback(() => {
152+
textBufferRef.current.setViewport(
153+
getInkTextBufferViewportWidth(process.stdout.columns),
154+
INK_TEXTBUFFER_VIEWPORT_HEIGHT
155+
);
156+
}, []);
59157

60158
// Subscribe to plan mode changes
61159
useEffect(() => {
@@ -84,6 +182,18 @@ export function AgentUI({
84182
onInputChange?.(input);
85183
}, [input, onInputChange]);
86184

185+
useEffect(() => {
186+
syncBufferViewport();
187+
});
188+
189+
useEffect(() => {
190+
const buffer = textBufferRef.current;
191+
if (state.currentInput !== buffer.getText()) {
192+
buffer.setText(state.currentInput || '');
193+
syncInputFromBuffer();
194+
}
195+
}, [state.currentInput, syncInputFromBuffer]);
196+
87197
// Reset ctrl+c count after 2 seconds
88198
useEffect(() => {
89199
if (ctrlCCount > 0) {
@@ -93,6 +203,8 @@ export function AgentUI({
93203
}, [ctrlCCount]);
94204

95205
useInput((char, key) => {
206+
syncBufferViewport();
207+
96208
// Handle Shift+Tab for plan mode toggle
97209
if (key.tab && key.shift) {
98210
const planModeManager = getPlanModeManager();
@@ -122,25 +234,27 @@ export function AgentUI({
122234
return;
123235
}
124236

125-
// Handle Enter - queue instruction
126-
if (key.return && input.trim()) {
127-
onInstruction(input.trim());
128-
setInput('');
237+
if (key.tab) {
129238
return;
130239
}
131240

132-
// Handle Backspace
133-
if (key.backspace || key.delete) {
134-
setInput(prev => prev.slice(0, -1));
241+
const buffer = textBufferRef.current;
242+
const result = handleInkTextBufferInput(buffer, char, key);
243+
244+
if (result === 'submit') {
245+
const text = buffer.getText().trim();
246+
if (!text) {
247+
return;
248+
}
249+
onInstruction(text);
250+
buffer.setText('');
251+
syncInputFromBuffer();
135252
return;
136253
}
137254

138-
// Handle printable characters
139-
if (char && !key.ctrl && !key.meta) {
140-
const printable = char.replace(/[\x00-\x1F\x7F]/g, '');
141-
if (printable) {
142-
setInput(prev => prev + printable);
143-
}
255+
if (result === 'handled') {
256+
syncInputFromBuffer();
257+
return;
144258
}
145259
});
146260

@@ -185,6 +299,7 @@ export function AgentUI({
185299
completionStats={state.completionStats}
186300
enableQueueInput={enableQueueInput}
187301
input={input}
302+
cursorOffset={cursorOffset}
188303
ctrlCCount={ctrlCCount}
189304
contextPercent={state.contextPercent}
190305
/>
@@ -237,6 +352,7 @@ interface FixedBottomProps {
237352
completionStats: { elapsed: string; tokens: string } | null;
238353
enableQueueInput: boolean;
239354
input: string;
355+
cursorOffset: number;
240356
ctrlCCount: number;
241357
contextPercent?: number;
242358
}
@@ -250,6 +366,7 @@ const FixedBottom = memo(function FixedBottom({
250366
completionStats,
251367
enableQueueInput,
252368
input,
369+
cursorOffset,
253370
ctrlCCount,
254371
contextPercent
255372
}: FixedBottomProps) {
@@ -300,6 +417,7 @@ const FixedBottom = memo(function FixedBottom({
300417
{enableQueueInput && (
301418
<InputLine
302419
value={input}
420+
cursorOffset={cursorOffset}
303421
isActive={isWorking}
304422
/>
305423
)}

src/ui/ink/InputLine.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,21 @@
66
import React, { memo } from 'react';
77
import { Box, Text } from 'ink';
88
import { useTheme } from '../theme/ThemeContext.js';
9-
import { buildPromptRenderState, getPromptBlockWidth } from '../inputPrompt.js';
9+
import { buildMultiLineRenderState, getPromptBlockWidth } from '../inputPrompt.js';
1010
import { drawInputBottomBorder, drawInputTopBorder } from '../box.js';
1111

1212
export interface InputLineProps {
1313
value: string;
14+
cursorOffset: number;
1415
isActive: boolean;
1516
}
1617

17-
function InputLineComponent({ value, isActive }: InputLineProps) {
18+
function InputLineComponent({ value, cursorOffset, isActive }: InputLineProps) {
1819
const { colors } = useTheme();
1920
const width = getPromptBlockWidth(process.stdout.columns);
2021
const topBorder = drawInputTopBorder(width);
2122
const bottomBorder = drawInputBottomBorder(width);
22-
const { lineText } = buildPromptRenderState(value, value.length, width);
23+
const { lines } = buildMultiLineRenderState(value, cursorOffset, width);
2324

2425
// Keep space stable when queue input is inactive.
2526
if (!isActive) {
@@ -34,7 +35,9 @@ function InputLineComponent({ value, isActive }: InputLineProps) {
3435
return (
3536
<Box marginTop={1} flexDirection="column">
3637
<Text>{topBorder}</Text>
37-
<Text>{lineText}</Text>
38+
{lines.map((line, index) => (
39+
<Text key={index}>{line}</Text>
40+
))}
3841
<Text>{bottomBorder}</Text>
3942
</Box>
4043
);
@@ -44,5 +47,9 @@ function InputLineComponent({ value, isActive }: InputLineProps) {
4447
* Memoized InputLine - prevents unnecessary re-renders
4548
*/
4649
export const InputLine = memo(InputLineComponent, (prev, next) => {
47-
return prev.value === next.value && prev.isActive === next.isActive;
50+
return (
51+
prev.value === next.value &&
52+
prev.cursorOffset === next.cursorOffset &&
53+
prev.isActive === next.isActive
54+
);
4855
});

0 commit comments

Comments
 (0)