///|
/// VNode Chat - Simple chat UI with Claude API
///|
using @vnode {type TuiNode, column, row, text}
// =============================================================================
// Message Types
// =============================================================================
///|
enum MessageRole {
User
Assistant
}
///|
struct Message {
role : MessageRole
content : String
}
// =============================================================================
// Streaming State
// =============================================================================
///|
struct StreamingState {
mut text : String
mut is_streaming : Bool
}
///|
fn StreamingState::new() -> StreamingState {
{ text: "", is_streaming: false }
}
///|
fn StreamingState::clear(self : StreamingState) -> Unit {
self.text = ""
self.is_streaming = false
}
///|
fn StreamingState::append(self : StreamingState, chunk : String) -> Unit {
self.text = self.text + chunk
}
///|
fn StreamingState::start_realtime(self : StreamingState) -> Bool {
if self.is_streaming {
return false
}
self.text = ""
self.is_streaming = true
true
}
///|
fn StreamingState::complete(self : StreamingState) -> Unit {
self.is_streaming = false
}
///|
fn StreamingState::take(self : StreamingState) -> String {
let result = self.text
self.clear()
result
}
// =============================================================================
// Input Queue
// =============================================================================
///|
struct InputQueue {
queue : Array[String]
mut processing : Bool
}
///|
fn InputQueue::new() -> InputQueue {
{ queue: [], processing: false }
}
///|
fn InputQueue::enqueue(self : InputQueue, text : String) -> Unit {
self.queue.push(text)
}
///|
fn InputQueue::dequeue_all(self : InputQueue) -> Array[String] {
let result = self.queue.copy()
self.queue.clear()
result
}
///|
fn InputQueue::is_busy(self : InputQueue) -> Bool {
self.processing
}
///|
fn InputQueue::is_empty(self : InputQueue) -> Bool {
self.queue.is_empty()
}
///|
fn InputQueue::start_processing(self : InputQueue) -> Unit {
self.processing = true
}
///|
fn InputQueue::finish_processing(self : InputQueue) -> Unit {
self.processing = false
}
///|
fn InputQueue::length(self : InputQueue) -> Int {
self.queue.length()
}
// =============================================================================
// Text Wrapping
// =============================================================================
///|
fn wrap_text(text : String, max_width : Int) -> Array[String] {
let lines : Array[String] = []
let current_line = StringBuilder::new()
let mut current_width = 0
for c in text {
if c == '\n' {
lines.push(current_line.to_string())
current_line.reset()
current_width = 0
} else {
let char_width = @core.char_display_width(c)
if current_width + char_width > max_width {
lines.push(current_line.to_string())
current_line.reset()
current_width = 0
}
current_line.write_char(c)
current_width = current_width + char_width
}
}
if current_width > 0 {
lines.push(current_line.to_string())
}
if lines.is_empty() {
lines.push("")
}
lines
}
///|
/// Truncate text to fit within max_width (display width)
fn truncate_text(text : String, max_width : Int) -> String {
let buf = StringBuilder::new()
let mut current_width = 0
for c in text {
let char_width = @core.char_display_width(c)
if current_width + char_width > max_width {
break
}
buf.write_char(c)
current_width = current_width + char_width
}
buf.to_string()
}
// =============================================================================
// Main
// =============================================================================
///|
fn main {
let (initial_width, initial_height) = @tui.get_terminal_size()
// Mutable terminal size (updated on resize)
let width = Ref::new(initial_width)
let height = Ref::new(initial_height)
// State
let messages : Array[Message] = []
let input_editor = @editor.EditorState::new_with_kind(
"",
3,
@editor.HighlightKind::Plain,
)
let preview_editor : Ref[@editor.EditorState] = Ref::new(
@editor.EditorState::new_with_kind("", 10, @editor.HighlightKind::Plain),
)
let editing = @signals.signal(false)
let preview_editing = @signals.signal(false)
let scroll_offset = @signals.signal(0)
let streaming = StreamingState::new()
let input_queue = InputQueue::new()
let (app, cleanup) = @vnode.init_vnode_terminal(
initial_width,
initial_height,
@tui.print_raw,
mouse=true,
)
// Sidebar state
let sidebar_visible = @signals.signal(true)
let sidebar_focused = @signals.signal(false)
let file_tree = FilerState::new(@ai.js_get_cwd())
file_tree.refresh()
// File watcher callback (forward reference, set later)
let on_file_change : Ref[() -> Unit] = Ref::new(fn() { () })
// Start file watcher
@ai.js_watch_directory(file_tree.current_dir, fn() { (on_file_change.val)() })
// File preview state
let preview_visible = @signals.signal(false)
// Layout constants
let input_rows = @signals.signal(3)
let input_min_rows = 1
let input_max_rows = 15
let divider_rows = 1
let footer_rows = 1
let sidebar_width = @signals.signal(28)
let sidebar_min_width = 20
let sidebar_max_width = 60
fn get_chat_width() -> Int {
if sidebar_visible.get() {
// Border is already part of sidebar_width (filer renders content + border)
width.val - sidebar_width.get()
} else {
width.val
}
}
fn message_area_height() -> Int {
height.val - input_rows.get() - divider_rows - footer_rows - 1
}
// Sync preview content to preview editor
fn sync_preview_editor() -> Unit {
if file_tree.preview_file.length() > 0 && !(file_tree.preview_is_diff) {
let kind = @editor.HighlightKind::from_filename(file_tree.preview_file)
let area_height = message_area_height()
// Create new editor state with file content and proper highlighting
preview_editor.val = @editor.EditorState::new_with_kind(
file_tree.preview_content,
area_height,
kind,
)
}
}
// AI client (Claude or OpenRouter)
let ai_client : @ai.BoxedAIClient? = @ai.create_client_from_env(
system_prompt="You are a helpful assistant. Keep responses concise.",
)
// Build message lines for display
fn build_message_lines() -> Array[String] {
let lines : Array[String] = []
let chat_width = get_chat_width()
for msg in messages {
let prefix = match msg.role {
User => "> "
Assistant => " "
}
// Wrap long lines
let content_lines = wrap_text(msg.content, chat_width - 4)
for i, line in content_lines {
if i == 0 {
lines.push(prefix + line)
} else {
lines.push(" " + line)
}
}
}
// Add streaming content if any
if streaming.is_streaming && streaming.text.length() > 0 {
let streaming_lines = wrap_text(streaming.text, chat_width - 4)
for i, line in streaming_lines {
if i == 0 {
lines.push(" " + line)
} else {
lines.push(" " + line)
}
}
}
lines
}
// Calculate max scroll offset
fn max_scroll_offset() -> Int {
let lines = build_message_lines()
let max = lines.length() - message_area_height()
if max < 0 {
0
} else {
max
}
}
// Auto-scroll to bottom
fn scroll_to_bottom() -> Unit {
scroll_offset.set(max_scroll_offset())
}
fn render_message_line(line : String, idx : Int) -> TuiNode {
let is_user = line.has_prefix("> ")
let fg = if is_user { "cyan" } else { "white" }
let chat_width = get_chat_width()
// Ensure line doesn't exceed chat width
let display_line = truncate_text(line, chat_width - 1)
row(
id="msg-" + idx.to_string(),
min_width=chat_width.to_double(),
height=1.0,
[text(display_line, fg~)],
)
}
fn render_messages() -> TuiNode {
let all_lines = build_message_lines()
let total_lines = all_lines.length()
let offset = scroll_offset.get()
let visible_lines : Array[TuiNode] = []
let area_height = message_area_height()
let chat_width = get_chat_width()
// Bottom-align: add empty lines at top if content is shorter than area
let empty_lines_at_top = if total_lines < area_height {
area_height - total_lines
} else {
0
}
for i = 0; i < area_height; i = i + 1 {
if i < empty_lines_at_top {
// Empty line for bottom-alignment
visible_lines.push(row(height=1.0, []))
} else {
let line_idx = offset + (i - empty_lines_at_top)
if line_idx < total_lines {
visible_lines.push(render_message_line(all_lines[line_idx], line_idx))
} else {
visible_lines.push(row(height=1.0, []))
}
}
}
column(
min_width=chat_width.to_double(),
height=area_height.to_double(),
visible_lines,
)
}
fn render_divider() -> TuiNode {
let color = if editing.get() { "rgb(100,150,255)" } else { "rgb(60,60,60)" }
let chat_width = get_chat_width()
row(height=1.0, min_width=chat_width.to_double(), [
text("\u{2500}".repeat(chat_width), fg=color),
])
}
fn render_input() -> TuiNode {
let current = input_editor.get_text()
let chat_width = get_chat_width()
let rows = input_rows.get()
if editing.get() {
// Use editor component without header/footer for input
// Wrap in column with explicit height to ensure consistent layout
column(height=rows.to_double(), min_width=chat_width.to_double(), [
@editor.render_editor(
input_editor,
chat_width,
rows,
show_header=false,
show_footer=false,
),
])
} else {
// Show placeholder when not editing
let display_text = if current.length() == 0 {
"Type a message... (press Enter to start)"
} else {
current
}
let fg = if current.length() == 0 { "rgb(100,100,100)" } else { "white" }
let truncated_display = truncate_text(" " + display_text, chat_width - 1)
column(height=rows.to_double(), min_width=chat_width.to_double(), [
row(id="input-line", min_width=chat_width.to_double(), [
text(truncated_display, fg~),
]),
])
}
}
fn render_footer() -> TuiNode {
let queue_info = if input_queue.is_busy() {
let count = input_queue.length()
if count > 0 {
" [Q:" + count.to_string() + "]"
} else {
" [...]"
}
} else {
""
}
let chat_width = get_chat_width()
let msg_count = " [" + messages.length().to_string() + "]"
let sidebar_hint = if preview_editing.get() {
"^S:save Esc:back"
} else if sidebar_visible.get() {
if sidebar_focused.get() {
let git_hint = if file_tree.is_git_repo { " g:git" } else { "" }
"Tab:chat \u{2190}\u{2192}:nav" + git_hint
} else {
"Tab:tree ^L/H:resize"
}
} else {
"b:tree"
}
let rows_hint = if !(editing.get()) &&
!(sidebar_focused.get()) &&
!(preview_visible.get()) {
" j/k:rows"
} else {
""
}
let status = sidebar_hint +
rows_hint +
" | \u{2191}\u{2193}:scroll | ^C:quit" +
msg_count +
queue_info
row(height=1.0, min_width=chat_width.to_double(), [
text(" " + truncate_text(status, chat_width - 2), fg="rgb(80,80,80)"),
])
}
fn render_main_content() -> TuiNode {
let chat_width = get_chat_width()
let area_height = message_area_height()
if preview_visible.get() && file_tree.preview_file.length() > 0 {
// Show file preview using editor
// Wrap in column with explicit height to ensure consistent layout
let filename = @ai.js_path_basename(file_tree.preview_file)
column(height=area_height.to_double(), min_width=chat_width.to_double(), [
@editor.render_editor(
preview_editor.val,
chat_width,
area_height,
show_footer=false,
title=filename,
),
])
} else {
// Show messages
render_messages()
}
}
fn render_chat_area() -> TuiNode {
let chat_width = get_chat_width()
column(width=chat_width.to_double(), height=(height.val - 1).to_double(), [
render_main_content(),
render_divider(),
render_input(),
render_footer(),
])
}
fn render_ui() -> TuiNode {
if sidebar_visible.get() {
let filer_height = height.val - 2
let sw = sidebar_width.get()
// Always show sidebar + chat area (preview is shown in message area)
row(width=width.val.to_double(), height=(height.val - 1).to_double(), [
render_filer(file_tree, sw, filer_height, sidebar_focused.get()),
render_chat_area(),
])
} else {
render_chat_area()
}
}
fn do_render() -> Unit {
let output = app.render_frame(render_ui())
@tui.print_raw(output)
// Position cursor
if preview_editing.get() {
// Cursor in preview editor
let chat_width = get_chat_width()
let area_height = message_area_height()
let editor_x = if sidebar_visible.get() { sidebar_width.get() } else { 0 }
let editor_y = 0
let (cursor_row, cursor_col) = @editor.editor_cursor_position(
preview_editor.val,
chat_width,
area_height,
show_footer=false,
)
@tui.print_raw(
@render.ansi_move_to(editor_y + cursor_row, editor_x + cursor_col) +
@render.ansi_show_cursor(),
)
} else if editing.get() {
// Cursor in input editor
let chat_width = get_chat_width()
let editor_x = if sidebar_visible.get() { sidebar_width.get() } else { 0 }
let editor_y = message_area_height() + divider_rows
let (cursor_row, cursor_col) = @editor.editor_cursor_position(
input_editor,
chat_width,
input_rows.get(),
show_header=false,
show_footer=false,
)
@tui.print_raw(
@render.ansi_move_to(editor_y + cursor_row, editor_x + cursor_col) +
@render.ansi_show_cursor(),
)
} else {
@tui.print_raw(@render.ansi_hide_cursor())
}
}
// Handle terminal resize
fn handle_resize(new_width : Int, new_height : Int) -> Unit {
width.val = new_width
height.val = new_height
app.resize(new_width, new_height)
do_render()
}
// Set up file watcher callback
on_file_change.val = fn() {
// Skip refresh if user is actively editing to avoid cursor reset
if preview_editing.get() || editing.get() {
return
}
file_tree.refresh()
if preview_visible.get() && file_tree.preview_file.length() > 0 {
file_tree.load_selected_preview()
sync_preview_editor()
}
app.clear_prev_buffer()
do_render()
}
// Restart file watcher for new directory
fn restart_watcher() -> Unit {
@ai.js_stop_watch()
@ai.js_watch_directory(file_tree.current_dir, fn() {
(on_file_change.val)()
})
}
fn do_quit() -> Unit {
streaming.clear()
@ai.js_stop_watch() // Stop file watcher
cleanup()
println("Goodbye!")
}
// Forward reference for process_queue
let process_queue_ref : Ref[() -> Bool] = Ref::new(fn() { false })
// Add a message
fn add_message(role : MessageRole, content : String) -> Unit {
messages.push({ role, content })
scroll_to_bottom()
}
// Complete streaming and add to messages
fn finish_streaming() -> Unit {
let text = streaming.take()
if text.length() > 0 {
add_message(Assistant, text)
}
scroll_to_bottom()
app.clear_prev_buffer()
}
// Convert messages for AI
fn convert_messages_for_ai() -> Array[@ai.Message] {
let max_history = 20
let start_idx = if messages.length() > max_history {
messages.length() - max_history
} else {
0
}
let ai_messages : Array[@ai.Message] = []
for i = start_idx; i < messages.length(); i = i + 1 {
let msg = messages[i]
let role : @ai.Role = match msg.role {
User => @ai.Role::User
Assistant => @ai.Role::Assistant
}
ai_messages.push({ role, content: msg.content })
}
ai_messages
}
// Send to AI
fn send_to_ai() -> Unit {
match ai_client {
Some(client) => {
input_queue.start_processing()
scroll_to_bottom()
ignore(streaming.start_realtime())
streaming.append("Thinking...")
do_render()
let ai_messages = convert_messages_for_ai()
let first_chunk = Ref::new(true)
client.stream_with_tools(
ai_messages,
fn(chunk) {
if first_chunk.val {
first_chunk.val = false
streaming.clear()
ignore(streaming.start_realtime())
}
streaming.append(chunk)
scroll_to_bottom()
do_render()
},
fn(_tool_id, tool_name, input_json) {
@ai.execute_tool(tool_name, input_json)
},
fn() {
finish_streaming()
input_queue.finish_processing()
let started_new = (process_queue_ref.val)()
if !(started_new) {
do_render()
}
},
fn(error) {
streaming.append("\n\n[Error: " + error + "]")
streaming.complete()
finish_streaming()
input_queue.finish_processing()
do_render()
},
)
}
None => {
// Fallback when no API key
input_queue.start_processing()
scroll_to_bottom()
ignore(streaming.start_realtime())
let last_msg = if messages.length() > 0 {
messages[messages.length() - 1].content
} else {
""
}
streaming.append(
"[No API key set]\nYou said: " +
last_msg +
"\n\nSet ANTHROPIC_API_KEY or OPENROUTER_API_KEY to use AI.",
)
streaming.complete()
finish_streaming()
input_queue.finish_processing()
do_render()
}
}
}
// Process queued inputs
fn process_queue() -> Bool {
if input_queue.is_busy() || input_queue.is_empty() {
return false
}
let queued = input_queue.dequeue_all()
if queued.length() > 0 {
for msg in queued {
add_message(User, msg)
}
send_to_ai()
return true
}
false
}
process_queue_ref.val = process_queue
// Compact conversation by summarizing past messages
fn do_compact() -> Unit {
if messages.length() < 4 {
add_message(Assistant, "[Compact: Not enough messages to summarize]")
do_render()
return
}
match ai_client {
Some(client) => {
input_queue.start_processing()
ignore(streaming.start_realtime())
streaming.append("[Compacting conversation...]")
do_render()
// Build summary prompt
let conversation_text = StringBuilder::new()
for msg in messages {
let prefix = match msg.role {
User => "User: "
Assistant => "Assistant: "
}
conversation_text.write_string(prefix)
conversation_text.write_string(msg.content)
conversation_text.write_string("\n\n")
}
let summary_prompt : Array[@ai.Message] = [
{
role: @ai.Role::User,
content: "Summarize this conversation concisely, preserving key context, decisions, and any code/technical details that would be needed to continue the conversation. Format as a brief narrative:\n\n" +
conversation_text.to_string(),
},
]
let summary_result = Ref::new("")
client.stream(
summary_prompt,
fn(chunk) {
summary_result.val = summary_result.val + chunk
streaming.clear()
ignore(streaming.start_realtime())
streaming.append("[Compacting...]\n" + summary_result.val)
do_render()
},
fn() {
// Replace messages with summary
messages.clear()
messages.push({
role: Assistant,
content: "[Conversation Summary]\n" + summary_result.val,
})
streaming.clear()
input_queue.finish_processing()
scroll_offset.set(0)
app.clear_prev_buffer()
do_render()
},
fn(error) {
streaming.clear()
add_message(Assistant, "[Compact failed: " + error + "]")
input_queue.finish_processing()
do_render()
},
)
}
None => {
add_message(Assistant, "[Compact requires API key]")
do_render()
}
}
}
// Mutually recursive input handling
letrec restore_tui_and_send: () -> Unit = fn() {
let raw = input_editor.get_text()
let text = raw.trim(chars=" \t\n\r").to_string()
if text.length() > 0 {
input_editor.set_text("")
// Handle slash commands
if text == "/compact" {
@tui.print_raw(@render.ansi_full_reset())
app.clear_prev_buffer()
editing.set(false)
do_compact()
@io.start_keypress_listener(handle_key)
return
}
if text == "/clear" {
messages.clear()
scroll_offset.set(0)
app.clear_prev_buffer()
@tui.print_raw(@render.ansi_full_reset())
editing.set(false)
do_render()
@io.start_keypress_listener(handle_key)
return
}
if text == "/help" {
add_message(
Assistant,
"Available commands:\n /compact - Summarize conversation to save context\n /clear - Clear all messages\n /help - Show this help",
)
@tui.print_raw(@render.ansi_full_reset())
app.clear_prev_buffer()
editing.set(false)
do_render()
@io.start_keypress_listener(handle_key)
return
}
// Normal message
if input_queue.is_busy() {
input_queue.enqueue(text)
} else {
add_message(User, text)
send_to_ai()
}
}
@tui.print_raw(@render.ansi_full_reset())
app.clear_prev_buffer()
editing.set(false)
do_render()
@io.start_keypress_listener(handle_key)
}
and start_editing: () -> Unit = fn() {
@io.stop_keypress_listener()
editing.set(true)
preview_visible.set(false) // Hide file preview when entering edit mode
@tui.enable_raw_mode()
do_render()
@io.start_keypress_listener(handle_edit_key)
}
and handle_edit_key: (String) -> Unit = fn(key) {
if key.length() == 0 {
return
}
// Handle multi-character printable input (IME)
if key.length() > 1 &&
key[0].to_int() != 0x1b &&
@tui.is_printable_string(key) {
input_editor.replace_selection(key)
do_render()
return
}
let event = @tui.parse_input(key)
if event.is_ctrl_c() {
do_quit()
return
}
match event {
@tui.InputEvent::Key(key_event) =>
match key_event {
// Escape: go back to sidebar (keep input)
@tui.KeyEvent::Special(@tui.SpecialKey::Escape, _) => {
@io.stop_keypress_listener()
editing.set(false)
sidebar_focused.set(true)
@tui.print_raw(@render.ansi_full_reset())
app.clear_prev_buffer()
do_render()
@io.start_keypress_listener(handle_key)
}
// Enter without modifier: send message
@tui.KeyEvent::Special(@tui.SpecialKey::Enter, @tui.KeyModifier::None) =>
restore_tui_and_send()
// Other keys: delegate to editor
_ => {
@editor.handle_key_event(input_editor, key_event)
do_render()
}
}
_ => ()
}
}
and start_preview_editing: () -> Unit = fn() {
@io.stop_keypress_listener()
preview_editing.set(true)
@tui.enable_raw_mode()
do_render()
@io.start_keypress_listener(handle_preview_edit_key)
}
and handle_preview_edit_key: (String) -> Unit = fn(key) {
if key.length() == 0 {
return
}
// Handle multi-character printable input (IME)
if key.length() > 1 &&
key[0].to_int() != 0x1b &&
@tui.is_printable_string(key) {
preview_editor.val.replace_selection(key)
do_render()
return
}
let event = @tui.parse_input(key)
if event.is_ctrl_c() {
do_quit()
return
}
match event {
@tui.InputEvent::Key(key_event) =>
match key_event {
// Escape: go back to sidebar
@tui.KeyEvent::Special(@tui.SpecialKey::Escape, _) => {
@io.stop_keypress_listener()
preview_editing.set(false)
sidebar_focused.set(true)
@tui.print_raw(@render.ansi_full_reset())
app.clear_prev_buffer()
do_render()
@io.start_keypress_listener(handle_key)
}
// Ctrl+S: save file
@tui.KeyEvent::Char('s', @tui.KeyModifier::Ctrl) =>
if file_tree.preview_file.length() > 0 {
let content = preview_editor.val.get_text()
let _ = @ai.js_write_file(file_tree.preview_file, content)
preview_editor.val.mark_clean()
file_tree.refresh()
app.clear_prev_buffer()
do_render()
}
// Other keys: delegate to preview editor
_ => {
@editor.handle_key_event(preview_editor.val, key_event)
do_render()
}
}
_ => ()
}
}
and handle_key: (String) -> Unit = fn(key) {
if key.length() == 0 {
return
}
let event = @tui.parse_input(key)
if event.is_ctrl_c() {
do_quit()
return
}
match event {
@tui.InputEvent::Key(key_event) =>
match key_event {
// Tab: toggle focus between sidebar and chat
@tui.KeyEvent::Special(@tui.SpecialKey::Tab, _) =>
if sidebar_visible.get() {
let new_focused = !(sidebar_focused.get())
sidebar_focused.set(new_focused)
// Load preview when focusing on sidebar
if new_focused {
file_tree.load_selected_preview()
sync_preview_editor()
if file_tree.preview_file.length() > 0 {
preview_visible.set(true)
}
app.clear_prev_buffer()
}
do_render()
}
// Escape: close preview, unfocus sidebar, or hide sidebar
@tui.KeyEvent::Special(@tui.SpecialKey::Escape, _) =>
if preview_visible.get() {
// Close preview first
file_tree.clear_preview()
preview_visible.set(false)
app.clear_prev_buffer()
do_render()
} else if sidebar_focused.get() {
sidebar_focused.set(false)
do_render()
} else if sidebar_visible.get() {
sidebar_visible.set(false)
app.clear_prev_buffer()
do_render()
}
// Backslash or 'b': toggle sidebar visibility
@tui.KeyEvent::Char('b', @tui.KeyModifier::None)
| @tui.KeyEvent::Char('\\', @tui.KeyModifier::None) => {
sidebar_visible.set(!(sidebar_visible.get()))
app.clear_prev_buffer()
do_render()
}
// 'g': toggle git-only mode in sidebar
@tui.KeyEvent::Char('g', @tui.KeyModifier::None) =>
if sidebar_focused.get() && file_tree.is_git_repo {
file_tree.toggle_git_mode()
app.clear_prev_buffer()
do_render()
}
// 'j': scroll preview down or expand input area
@tui.KeyEvent::Char('j', @tui.KeyModifier::None) =>
if preview_visible.get() && file_tree.preview_file.length() > 0 {
let preview_height = height.val - 2 - 1 // filer_height - header
file_tree.scroll_preview(1, preview_height)
do_render()
} else if !(sidebar_focused.get()) {
// Expand input area
let new_rows = input_rows.get() + 1
if new_rows <= input_max_rows {
input_rows.set(new_rows)
input_editor.set_height(new_rows)
app.clear_prev_buffer()
do_render()
}
}
// 'k': scroll preview up or shrink input area
@tui.KeyEvent::Char('k', @tui.KeyModifier::None) =>
if preview_visible.get() && file_tree.preview_file.length() > 0 {
let preview_height = height.val - 2 - 1 // filer_height - header
file_tree.scroll_preview(-1, preview_height)
do_render()
} else if !(sidebar_focused.get()) {
// Shrink input area
let new_rows = input_rows.get() - 1
if new_rows >= input_min_rows {
input_rows.set(new_rows)
input_editor.set_height(new_rows)
app.clear_prev_buffer()
do_render()
}
}
// Ctrl+Enter: focus input form directly (from sidebar)
@tui.KeyEvent::Special(@tui.SpecialKey::Enter, @tui.KeyModifier::Ctrl) =>
if sidebar_focused.get() {
sidebar_focused.set(false)
start_editing()
} else {
start_editing()
}
// Enter: enter directory or select file (in sidebar), or start editing
@tui.KeyEvent::Special(@tui.SpecialKey::Enter, @tui.KeyModifier::None) =>
if sidebar_focused.get() {
// Enter directory or select file
match file_tree.enter_selected() {
Some(file_path) => {
// Insert file path into input
input_editor.replace_selection(file_path)
sidebar_focused.set(false)
app.clear_prev_buffer()
start_editing()
}
None => {
// Directory entered, restart watcher and refresh
restart_watcher()
app.clear_prev_buffer()
do_render()
}
}
} else {
start_editing()
}
@tui.KeyEvent::Char(_, @tui.KeyModifier::None) =>
if sidebar_focused.get() {
// In sidebar, ignore character input
()
} else {
start_editing()
}
@tui.KeyEvent::Special(@tui.SpecialKey::Up, _) =>
if sidebar_focused.get() {
file_tree.move_selection(-1)
// Scroll if needed
if file_tree.selected_idx < file_tree.scroll_offset {
file_tree.scroll_offset = file_tree.selected_idx
}
// Auto-load preview for selected file
file_tree.load_selected_preview()
sync_preview_editor()
if file_tree.preview_file.length() > 0 {
preview_visible.set(true)
}
app.clear_prev_buffer()
do_render()
} else {
let offset = scroll_offset.get()
if offset > 0 {
scroll_offset.set(offset - 1)
}
do_render()
}
@tui.KeyEvent::Special(@tui.SpecialKey::Down, _) =>
if sidebar_focused.get() {
file_tree.move_selection(1)
// Scroll if needed
let visible_height = height.val - 4
if file_tree.selected_idx >=
file_tree.scroll_offset + visible_height {
file_tree.scroll_offset = file_tree.selected_idx -
visible_height +
1
}
// Auto-load preview for selected file
file_tree.load_selected_preview()
sync_preview_editor()
if file_tree.preview_file.length() > 0 {
preview_visible.set(true)
}
app.clear_prev_buffer()
do_render()
} else {
let offset = scroll_offset.get()
let max = max_scroll_offset()
if offset < max {
scroll_offset.set(offset + 1)
}
do_render()
}
@tui.KeyEvent::Special(@tui.SpecialKey::Backspace, _) =>
// Backspace in sidebar: go to parent directory
if sidebar_focused.get() {
file_tree.go_up()
restart_watcher() // Restart watcher for new directory
app.clear_prev_buffer()
do_render()
}
// Left arrow: collapse directory or go to parent (when sidebar focused)
@tui.KeyEvent::Special(@tui.SpecialKey::Left, _) =>
if sidebar_focused.get() {
file_tree.collapse_selected()
file_tree.load_selected_preview()
sync_preview_editor()
if file_tree.preview_file.length() > 0 {
preview_visible.set(true)
} else {
preview_visible.set(false)
}
app.clear_prev_buffer()
do_render()
}
// Right arrow: expand directory, select file, or move to preview editor (when sidebar focused)
@tui.KeyEvent::Special(@tui.SpecialKey::Right, _) =>
if sidebar_focused.get() {
// If preview is already visible (file selected), move to preview editor
if preview_visible.get() && file_tree.preview_file.length() > 0 {
sidebar_focused.set(false)
start_preview_editing()
} else {
match file_tree.toggle_selected() {
Some(file_path) => {
// File selected - load preview
file_tree.load_preview(file_path)
sync_preview_editor()
preview_visible.set(true)
app.clear_prev_buffer()
do_render()
}
None => {
// Directory toggled
file_tree.load_selected_preview()
sync_preview_editor()
if file_tree.preview_file.length() > 0 {
preview_visible.set(true)
} else {
preview_visible.set(false)
}
app.clear_prev_buffer()
do_render()
}
}
}
}
// Ctrl-L: increase sidebar width (when not focused)
@tui.KeyEvent::Char('l', @tui.KeyModifier::Ctrl) =>
if sidebar_visible.get() && !(sidebar_focused.get()) {
let new_width = sidebar_width.get() + 4
if new_width <= sidebar_max_width {
sidebar_width.set(new_width)
app.clear_prev_buffer()
do_render()
}
}
// Ctrl-H: decrease sidebar width (when not focused)
@tui.KeyEvent::Char('h', @tui.KeyModifier::Ctrl) =>
if sidebar_visible.get() && !(sidebar_focused.get()) {
let new_width = sidebar_width.get() - 4
if new_width >= sidebar_min_width {
sidebar_width.set(new_width)
app.clear_prev_buffer()
do_render()
}
}
_ => ()
}
@tui.InputEvent::Mouse(mouse_event) =>
// Only handle Press events (not Release to avoid double-trigger)
match mouse_event.event_type {
@tui.MouseEventType::Press =>
// Handle mouse click/scroll in sidebar
if sidebar_visible.get() {
match mouse_event.button {
@tui.MouseButton::Left =>
if mouse_event.x < sidebar_width.get() {
// Click in sidebar area
sidebar_focused.set(true)
// Terminal coordinates are 1-indexed, convert to 0-indexed
let row = mouse_event.y - 1
match file_tree.handle_click(row) {
Some(file_path) => {
// File was clicked - load preview
file_tree.load_preview(file_path)
sync_preview_editor()
preview_visible.set(true)
app.clear_prev_buffer()
do_render()
}
None => {
// Directory navigation or toggle happened
file_tree.load_selected_preview()
sync_preview_editor()
if file_tree.preview_file.length() > 0 {
preview_visible.set(true)
} else {
preview_visible.set(false)
}
app.clear_prev_buffer()
do_render()
}
}
} else {
// Click in chat/preview area
sidebar_focused.set(false)
do_render()
}
@tui.MouseButton::ScrollUp => {
// Scroll up in preview if visible and in preview area
let sw = sidebar_width.get()
if preview_visible.get() &&
file_tree.preview_file.length() > 0 &&
mouse_event.x >= sw {
let preview_height = height.val - 2 - 1
file_tree.scroll_preview(-3, preview_height)
do_render()
} else if mouse_event.x < sw {
// Scroll in file tree
if file_tree.scroll_offset > 0 {
file_tree.scroll_offset = file_tree.scroll_offset - 3
if file_tree.scroll_offset < 0 {
file_tree.scroll_offset = 0
}
do_render()
}
}
}
@tui.MouseButton::ScrollDown => {
// Scroll down in preview if visible and in preview area
let sw = sidebar_width.get()
if preview_visible.get() &&
file_tree.preview_file.length() > 0 &&
mouse_event.x >= sw {
let preview_height = height.val - 2 - 1
file_tree.scroll_preview(3, preview_height)
do_render()
} else if mouse_event.x < sw {
// Scroll in file tree
let visible_height = height.val - 4
let max_offset = if file_tree.visible_items.length() >
visible_height {
file_tree.visible_items.length() - visible_height
} else {
0
}
file_tree.scroll_offset = file_tree.scroll_offset + 3
if file_tree.scroll_offset > max_offset {
file_tree.scroll_offset = max_offset
}
do_render()
}
}
_ => ()
}
}
_ => ()
}
_ => ()
}
}
// Initialize
@io.start_resize_listener(handle_resize)
do_render()
@io.start_keypress_listener(handle_key)
}