Skip to content

Commit 49cb795

Browse files
committed
feat(cli): support multiline input and paste preview
Add Shift+Enter newline handling and bracketed paste support. Keep large pasted input in the buffer while showing a compact line-count preview.
1 parent 2345a15 commit 49cb795

6 files changed

Lines changed: 288 additions & 12 deletions

File tree

‎NanoAgent.CLI/Commands/ReplCommands/HelpCommandHandler.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public Task<ReplCommandResult> ExecuteAsync(
3434
"/thinking [on|off] - Show or set simple thinking mode.\n" +
3535
"/undo - Roll back the most recent tracked file edit transaction.\n" +
3636
"/use <model> - Switch the active model for subsequent prompts.\n\n" +
37-
"Multiline input: enter \"\"\" on its own line, then finish with \"\"\" on its own line.\n\n" +
37+
"Multiline input: press Shift+Enter to insert a new line, then Enter to send.\n\n" +
3838
"Start with --profile build, --profile plan, or --profile review to choose the initial session profile. Use --thinking <on|off> to choose initial thinking mode, or use /profile <name> and /thinking <on|off> inside an active session.\n" +
3939
"Invoke subagents for one turn with @general or @explore; primary agents can also delegate focused work with agent_delegate.";
4040

‎NanoAgent.CLI/Presentation/Program.Rendering.cs‎

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,14 @@ private static string BuildInputMarkup(AppState state)
267267
return state.ActiveModal.BuildInputMarkup();
268268
}
269269

270+
if (TryBuildLargeInputPasteMarkup(
271+
state.Input.ToString(),
272+
state.IsBusy || state.IsStreaming,
273+
out string largePasteMarkup))
274+
{
275+
return largePasteMarkup;
276+
}
277+
270278
return BuildInputLineMarkup(
271279
state.Input.ToString(),
272280
state.IsBusy || state.IsStreaming);
@@ -292,8 +300,8 @@ private static string BuildFooterMarkup(AppState state)
292300
}
293301

294302
return BuildFooterLineMarkup(
295-
"Enter: send | Wheel/PgUp/PgDn: scroll | Esc/Ctrl+C: quit | /help",
296-
"[grey]Enter: send[/] [grey]|[/] [grey]Wheel/PgUp/PgDn: scroll[/] [grey]|[/] [grey]Esc/Ctrl+C: quit[/] [grey]|[/] [grey]/help[/]",
303+
"Enter: send | Shift+Enter: newline | Wheel/PgUp/PgDn: scroll | Esc/Ctrl+C: quit | /help",
304+
"[grey]Enter: send[/] [grey]|[/] [grey]Shift+Enter: newline[/] [grey]|[/] [grey]Wheel/PgUp/PgDn: scroll[/] [grey]|[/] [grey]Esc/Ctrl+C: quit[/] [grey]|[/] [grey]/help[/]",
297305
BuildCompletionNote(state));
298306
}
299307

@@ -386,14 +394,58 @@ private static int GetInputPanelSize(AppState state)
386394
return 4;
387395
}
388396

389-
int bodyLineCount = WrapInputText(
390-
state.Input.ToString(),
391-
GetInputFirstLineTextWidth(state.IsBusy || state.IsStreaming),
392-
GetInputContinuationLineTextWidth()).Count;
397+
string input = state.Input.ToString();
398+
int bodyLineCount = GetInputLogicalLineCount(input) > MultilinePastePreviewLineThreshold
399+
? 1
400+
: WrapInputText(
401+
input,
402+
GetInputFirstLineTextWidth(state.IsBusy || state.IsStreaming),
403+
GetInputContinuationLineTextWidth()).Count;
393404

394405
return Math.Max(3, bodyLineCount + 2);
395406
}
396407

408+
private static bool TryBuildLargeInputPasteMarkup(
409+
string input,
410+
bool isBusy,
411+
out string markup)
412+
{
413+
int lineCount = GetInputLogicalLineCount(input);
414+
if (lineCount <= MultilinePastePreviewLineThreshold)
415+
{
416+
markup = string.Empty;
417+
return false;
418+
}
419+
420+
string lineLabel = lineCount == 1 ? "line is" : "lines are";
421+
markup = BuildInputLineMarkup(
422+
$"{lineCount} {lineLabel} pasted",
423+
isBusy);
424+
return true;
425+
}
426+
427+
private static int GetInputLogicalLineCount(string input)
428+
{
429+
if (string.IsNullOrEmpty(input))
430+
{
431+
return 1;
432+
}
433+
434+
string normalizedInput = input
435+
.Replace("\r\n", "\n", StringComparison.Ordinal)
436+
.Replace('\r', '\n');
437+
string countableInput = normalizedInput.EndsWith('\n')
438+
? normalizedInput[..^1]
439+
: normalizedInput;
440+
441+
if (countableInput.Length == 0)
442+
{
443+
return 1;
444+
}
445+
446+
return countableInput.Count(static character => character == '\n') + 1;
447+
}
448+
397449
private static int GetInputFirstLineTextWidth(bool isBusy)
398450
{
399451
const string promptPlain = "> ";
@@ -433,14 +485,30 @@ private static IReadOnlyList<string> WrapInputText(
433485
}
434486

435487
List<string> lines = [];
436-
int offset = 0;
437488
int width = Math.Max(1, firstLineWidth);
489+
string[] logicalLines = normalizedInput
490+
.Replace("\r\n", "\n", StringComparison.Ordinal)
491+
.Replace('\r', '\n')
492+
.Split('\n');
438493

439-
while (offset < normalizedInput.Length)
494+
foreach (string logicalLine in logicalLines)
440495
{
441-
int length = Math.Min(width, normalizedInput.Length - offset);
442-
lines.Add(normalizedInput.Substring(offset, length));
443-
offset += length;
496+
if (logicalLine.Length == 0)
497+
{
498+
lines.Add(string.Empty);
499+
width = Math.Max(1, continuationLineWidth);
500+
continue;
501+
}
502+
503+
int offset = 0;
504+
while (offset < logicalLine.Length)
505+
{
506+
int length = Math.Min(width, logicalLine.Length - offset);
507+
lines.Add(logicalLine.Substring(offset, length));
508+
offset += length;
509+
width = Math.Max(1, continuationLineWidth);
510+
}
511+
444512
width = Math.Max(1, continuationLineWidth);
445513
}
446514

‎NanoAgent.CLI/Program.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ public static partial class Program
1414
private const int InputCursorColumnWidth = 1;
1515
private const int MessageScrollbarColumnWidth = 2;
1616
private const int MouseWheelScrollLineCount = 3;
17+
private const int MultilinePastePreviewLineThreshold = 3;
18+
private const int PasteContinuationReadTimeoutMilliseconds = 40;
1719
private const int TerminalSequenceReadTimeoutMilliseconds = 25;
1820
private const string RepositoryUrl = "github.com/rizwan3d/NanoAgent";
1921
private const string EnableAlternateScreenSequence = "\u001b[?1049h";
2022
private const string DisableAlternateScreenSequence = "\u001b[?1049l";
23+
private const string EnableBracketedPasteSequence = "\u001b[?2004h";
24+
private const string DisableBracketedPasteSequence = "\u001b[?2004l";
2125
private const string EnableWheelScrollingSequence = "\u001b[?1007h";
2226
private const string DisableWheelScrollingSequence = "\u001b[?1007l";
2327
private const string DisableMouseTrackingSequence = "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l";

‎NanoAgent.CLI/State/AppState.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public AppState(IUiBridge uiBridge, INanoAgentBackend backend)
2828

2929
public StringBuilder Input { get; } = new();
3030

31+
public bool SkipNextInputLineFeed { get; set; }
32+
3133
public bool IsBusy { get; set; }
3234

3335
public bool IsReady { get; set; }

‎NanoAgent.CLI/Terminal/Program.Input.cs‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ public static partial class Program
66
{
77
private static void HandleInput(AppState state)
88
{
9+
bool appendedInputInBatch = false;
10+
int pastedLineBreaksInBatch = 0;
11+
912
while (Console.KeyAvailable)
1013
{
1114
ConsoleKeyInfo key = Console.ReadKey(intercept: true);
@@ -22,6 +25,11 @@ private static void HandleInput(AppState state)
2225
return;
2326
}
2427

28+
if (TrySkipLineFeedAfterCarriageReturn(state, key))
29+
{
30+
continue;
31+
}
32+
2533
if (key.Key == ConsoleKey.C &&
2634
key.Modifiers.HasFlag(ConsoleModifiers.Control))
2735
{
@@ -52,17 +60,45 @@ private static void HandleInput(AppState state)
5260

5361
if (IsEnterKey(key))
5462
{
63+
if (IsMultilineEnterKey(key))
64+
{
65+
AppendInputLineBreak(state, key);
66+
appendedInputInBatch = true;
67+
continue;
68+
}
69+
70+
if (IsLikelyPastedLineBreak(key, appendedInputInBatch, pastedLineBreaksInBatch))
71+
{
72+
AppendInputLineBreak(state, key);
73+
appendedInputInBatch = true;
74+
pastedLineBreaksInBatch++;
75+
continue;
76+
}
77+
5578
SubmitInput(state);
5679
return;
5780
}
5881

5982
if (!char.IsControl(key.KeyChar))
6083
{
6184
state.Input.Append(key.KeyChar);
85+
state.SkipNextInputLineFeed = false;
86+
appendedInputInBatch = true;
6287
}
6388
}
6489
}
6590

91+
private static bool TrySkipLineFeedAfterCarriageReturn(AppState state, ConsoleKeyInfo key)
92+
{
93+
if (!state.SkipNextInputLineFeed)
94+
{
95+
return false;
96+
}
97+
98+
state.SkipNextInputLineFeed = false;
99+
return key.KeyChar == '\n';
100+
}
101+
66102
private static bool IsBackspaceKey(ConsoleKeyInfo key)
67103
{
68104
return key.Key == ConsoleKey.Backspace ||
@@ -75,6 +111,26 @@ private static bool IsEnterKey(ConsoleKeyInfo key)
75111
key.KeyChar is '\r' or '\n';
76112
}
77113

114+
private static bool IsMultilineEnterKey(ConsoleKeyInfo key)
115+
{
116+
return IsEnterKey(key) &&
117+
(key.Modifiers.HasFlag(ConsoleModifiers.Shift) ||
118+
key.Modifiers.HasFlag(ConsoleModifiers.Control) ||
119+
IsShiftKeyPressed() ||
120+
IsControlKeyPressed());
121+
}
122+
123+
private static bool IsLikelyPastedLineBreak(
124+
ConsoleKeyInfo key,
125+
bool appendedInputInBatch,
126+
int pastedLineBreaksInBatch)
127+
{
128+
return IsEnterKey(key) &&
129+
(HasBufferedInputAfterDelay(PasteContinuationReadTimeoutMilliseconds) ||
130+
pastedLineBreaksInBatch > 0 ||
131+
(appendedInputInBatch && key.KeyChar == '\n'));
132+
}
133+
78134
private static bool IsEscapeKey(ConsoleKeyInfo key)
79135
{
80136
return key.Key == ConsoleKey.Escape ||
@@ -242,6 +298,12 @@ private static void ConsumeSs3Input(AppState state)
242298

243299
private static void HandleCsiKeySequence(AppState state, string sequence)
244300
{
301+
if (sequence == "200~")
302+
{
303+
ConsumeBracketedPasteInput(state);
304+
return;
305+
}
306+
245307
if (TryDispatchModalTerminalKeySequence(state, sequence))
246308
{
247309
return;
@@ -252,6 +314,12 @@ private static void HandleCsiKeySequence(AppState state, string sequence)
252314

253315
private static void HandleTerminalKeySequence(AppState state, string sequence)
254316
{
317+
if (IsMultilineEnterTerminalSequence(sequence))
318+
{
319+
AppendInputLineBreak(state);
320+
return;
321+
}
322+
255323
switch (sequence)
256324
{
257325
case "A":
@@ -282,6 +350,96 @@ private static void HandleTerminalKeySequence(AppState state, string sequence)
282350
}
283351
}
284352

353+
private static bool IsMultilineEnterTerminalSequence(string sequence)
354+
{
355+
return sequence is "13;2u" or "13;4u" or "13;5u" or "13;6u" or "13;7u" or "13;8u";
356+
}
357+
358+
private static void AppendInputLineBreak(AppState state)
359+
{
360+
AppendInputLineBreak(state, default);
361+
}
362+
363+
private static void AppendInputLineBreak(AppState state, ConsoleKeyInfo key)
364+
{
365+
if (state.ActiveModal is null)
366+
{
367+
state.Input.Append('\n');
368+
}
369+
370+
state.SkipNextInputLineFeed = key.KeyChar == '\r';
371+
}
372+
373+
private static void AppendInputText(AppState state, string text)
374+
{
375+
if (state.ActiveModal is not null || string.IsNullOrEmpty(text))
376+
{
377+
return;
378+
}
379+
380+
string normalized = text
381+
.Replace("\r\n", "\n", StringComparison.Ordinal)
382+
.Replace('\r', '\n');
383+
384+
state.Input.Append(normalized);
385+
state.SkipNextInputLineFeed = false;
386+
}
387+
388+
private static void ConsumeBracketedPasteInput(AppState state)
389+
{
390+
StringBuilder pastedText = new();
391+
392+
while (TryReadBufferedKey(out ConsoleKeyInfo key))
393+
{
394+
if (key.KeyChar == '\u001b' &&
395+
TryConsumeBracketedPasteTerminator(pastedText))
396+
{
397+
AppendInputText(state, pastedText.ToString());
398+
return;
399+
}
400+
401+
pastedText.Append(key.KeyChar);
402+
}
403+
404+
AppendInputText(state, pastedText.ToString());
405+
}
406+
407+
private static bool TryConsumeBracketedPasteTerminator(StringBuilder pastedText)
408+
{
409+
if (!TryReadBufferedKey(out ConsoleKeyInfo prefixKey))
410+
{
411+
pastedText.Append('\u001b');
412+
return false;
413+
}
414+
415+
if (prefixKey.KeyChar != '[')
416+
{
417+
pastedText.Append('\u001b');
418+
pastedText.Append(prefixKey.KeyChar);
419+
return false;
420+
}
421+
422+
StringBuilder sequence = new();
423+
while (TryReadBufferedKey(out ConsoleKeyInfo sequenceKey))
424+
{
425+
sequence.Append(sequenceKey.KeyChar);
426+
if (IsAnsiFinalByte(sequenceKey.KeyChar))
427+
{
428+
break;
429+
}
430+
}
431+
432+
if (sequence.ToString() == "201~")
433+
{
434+
return true;
435+
}
436+
437+
pastedText.Append('\u001b');
438+
pastedText.Append('[');
439+
pastedText.Append(sequence);
440+
return false;
441+
}
442+
285443
private static bool TryDispatchModalTerminalKeySequence(AppState state, string sequence)
286444
{
287445
if (state.ActiveModal is null)
@@ -332,6 +490,23 @@ private static bool TryReadBufferedKey(out ConsoleKeyInfo key)
332490
return true;
333491
}
334492

493+
private static bool HasBufferedInputAfterDelay(int timeoutMilliseconds)
494+
{
495+
DateTimeOffset deadline = DateTimeOffset.UtcNow.AddMilliseconds(timeoutMilliseconds);
496+
497+
while (!Console.KeyAvailable)
498+
{
499+
if (DateTimeOffset.UtcNow >= deadline)
500+
{
501+
return false;
502+
}
503+
504+
Thread.Sleep(1);
505+
}
506+
507+
return true;
508+
}
509+
335510
private static bool IsAnsiFinalByte(char character)
336511
{
337512
return character is >= '@' and <= '~';

0 commit comments

Comments
 (0)