/* * main.cpp * MontaukOS Terminal - standalone Window Server app * Preserves the old desktop-integrated terminal layout and tab behavior. * * Two run modes: * - Windowed (default): runs as a Window Server client inside the desktop. * - Console (--console): takes over the whole framebuffer with no window * server, so it can stand in for desktop.elf as a console-only session * launched directly from login.elf. * Copyright (c) 2026 Daniel Hammer */ #include #include #include #include #include #include #include #include #include #include #include extern "C" { #include } using namespace gui; static constexpr int INIT_W = 646; static constexpr int INIT_H = 449; static constexpr int TERM_TAB_BAR_H = 34; static constexpr int TERM_MAX_TABS = 8; static constexpr int TERM_TAB_W = 130; static constexpr int TERM_TAB_GAP = 4; static constexpr int TERM_PLUS_W = 28; static constexpr int TERM_PLUS_PAD = 8; static constexpr int TERM_TAB_PAD = 8; struct TermTabs { TerminalState* tabs[TERM_MAX_TABS]; int tab_count; int active_tab; int prev_active_tab; uint32_t* last_pixels; }; static WsWindow g_win; static TermTabs g_tabs = {}; static bool g_should_exit = false; static bool g_force_redraw = true; static int g_last_win_w = 0; static int g_last_win_h = 0; static bool g_show_clock = false; // console (full-screen) mode draws a live clock static char g_clock_text[16] = {}; static montauk::abi::ProcInfo g_kill_procs[256]; static int g_kill_pids[256]; static int g_kill_pid_count = 0; static bool left_pressed(const montauk::abi::WinEvent& ev) { return (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); } static void term_request_redraw() { g_force_redraw = true; } static bool term_proc_alive(uint8_t state) { return state == 1 || state == 2 || state == 3; } static void term_collect_child_pids(int parent_pid, int proc_count) { for (int i = 0; i < proc_count; i++) { if (!term_proc_alive(g_kill_procs[i].state)) continue; if (g_kill_procs[i].parentPid != parent_pid) continue; int child_pid = g_kill_procs[i].pid; term_collect_child_pids(child_pid, proc_count); if (g_kill_pid_count < (int)(sizeof(g_kill_pids) / sizeof(g_kill_pids[0]))) g_kill_pids[g_kill_pid_count++] = child_pid; } } static void term_kill_process_tree(int root_pid) { if (root_pid <= 0) return; g_kill_pid_count = 0; int proc_count = montauk::proclist(g_kill_procs, (int)(sizeof(g_kill_procs) / sizeof(g_kill_procs[0]))); if (proc_count > 0) term_collect_child_pids(root_pid, proc_count); for (int i = 0; i < g_kill_pid_count; i++) montauk::kill(g_kill_pids[i]); montauk::kill(root_pid); } static void term_free_tab(TerminalState* ts) { if (!ts) return; if (ts->child_pid > 0) term_kill_process_tree(ts->child_pid); if (ts->cells) montauk::free(ts->cells); if (ts->alt_cells) montauk::free(ts->alt_cells); if (ts->render_cells) montauk::free(ts->render_cells); montauk::mfree(ts); } static TerminalState* term_create_tab(int cols, int rows) { TerminalState* ts = (TerminalState*)montauk::malloc(sizeof(TerminalState)); if (!ts) return nullptr; montauk::memset(ts, 0, sizeof(TerminalState)); terminal_init(ts, cols, rows); if (!ts->cells || !ts->alt_cells) { montauk::mfree(ts); return nullptr; } return ts; } static void term_close_tab(int idx) { if (idx < 0 || idx >= g_tabs.tab_count) return; term_free_tab(g_tabs.tabs[idx]); for (int i = idx; i < g_tabs.tab_count - 1; i++) g_tabs.tabs[i] = g_tabs.tabs[i + 1]; g_tabs.tab_count--; g_tabs.tabs[g_tabs.tab_count] = nullptr; if (g_tabs.tab_count == 0) { g_should_exit = true; return; } if (idx == g_tabs.active_tab) { if (g_tabs.active_tab > 0) g_tabs.active_tab--; } else if (g_tabs.active_tab > idx) { g_tabs.active_tab--; } if (g_tabs.active_tab >= g_tabs.tab_count) g_tabs.active_tab = g_tabs.tab_count - 1; terminal_invalidate_render_cache(g_tabs.tabs[g_tabs.active_tab]); g_tabs.tabs[g_tabs.active_tab]->dirty = true; term_request_redraw(); } static bool term_add_tab(int cols, int rows) { if (g_tabs.tab_count >= TERM_MAX_TABS) return false; TerminalState* ts = term_create_tab(cols, rows); if (!ts) return false; g_tabs.tabs[g_tabs.tab_count] = ts; g_tabs.active_tab = g_tabs.tab_count; g_tabs.tab_count++; term_request_redraw(); return true; } static bool term_poll_tabs() { bool changed = false; for (int i = g_tabs.tab_count - 1; i >= 0; i--) { if (!terminal_poll(g_tabs.tabs[i])) { term_close_tab(i); changed = true; if (g_should_exit) return true; } } if (g_tabs.tab_count > 0 && g_tabs.tabs[g_tabs.active_tab]->dirty) changed = true; return changed || g_force_redraw; } // Right-hand tab-bar furniture. In console mode a live clock sits at the far // right and the new-tab (+) button shifts left to make room; in windowed mode // there is no clock and the + keeps its original far-right position. struct TabBarRight { int clock_x; int plus_x; bool has_clock; }; static TabBarRight term_tabbar_right(int width) { TabBarRight r; r.has_clock = g_show_clock && g_clock_text[0]; int right_limit = width - TERM_PLUS_PAD; r.clock_x = right_limit; if (r.has_clock) { int cw = text_width(fonts::system_font, g_clock_text, fonts::UI_SIZE); r.clock_x = width - TERM_PLUS_PAD - cw; right_limit = r.clock_x - 12; } r.plus_x = right_limit - TERM_PLUS_W; return r; } // Refresh the cached clock string from the wall clock; returns true when the // displayed text changed (so the caller can request a repaint). static bool term_refresh_clock() { montauk::abi::DateTime dt; montauk::gettime(&dt); char buf[16]; snprintf(buf, sizeof(buf), "%02d:%02d:%02d", dt.Hour, dt.Minute, dt.Second); if (montauk::streq(buf, g_clock_text)) return false; montauk::strcpy(g_clock_text, buf); return true; } // Render the tab bar + active terminal into a tightly packed pixel buffer of // the given dimensions. Shared by the windowed (window surface) and console // (framebuffer-backed) run loops. static bool term_render_into(uint32_t* pixels, int width, int height) { if (!pixels || g_tabs.tab_count <= 0) return false; if (g_tabs.active_tab < 0 || g_tabs.active_tab >= g_tabs.tab_count) g_tabs.active_tab = 0; int term_h = height - TERM_TAB_BAR_H; if (term_h < 1) term_h = 1; int new_cols = width / mono_cell_width(); int new_rows = term_h / mono_cell_height(); if (new_cols < 1) new_cols = 1; if (new_rows < 1) new_rows = 1; TerminalState* ts = g_tabs.tabs[g_tabs.active_tab]; // Resize the cell grid when the visible cell layout changes: either the // surface pixel dimensions changed, or a zoom (font size) change altered // how many whole cells now fit. Skipping the zoom case leaves the grid at // its old row count, so at a larger font fewer rows fit on screen and the // bottom rows (cursor and prompt included) scroll off below the viewport; // resizing reflows the overflow into scrollback and keeps the cursor in // view. terminal_resize is a no-op per tab whose dimensions already match. if (width != g_last_win_w || term_h != g_last_win_h || new_cols != ts->cols || new_rows != ts->rows) { g_last_win_w = width; g_last_win_h = term_h; for (int i = 0; i < g_tabs.tab_count; i++) terminal_resize(g_tabs.tabs[i], new_cols, new_rows); } if (g_tabs.active_tab != g_tabs.prev_active_tab) { terminal_invalidate_render_cache(g_tabs.tabs[g_tabs.active_tab]); g_tabs.tabs[g_tabs.active_tab]->dirty = true; g_tabs.prev_active_tab = g_tabs.active_tab; } if (pixels != g_tabs.last_pixels) { g_tabs.tabs[g_tabs.active_tab]->dirty = true; g_tabs.last_pixels = pixels; } ts = g_tabs.tabs[g_tabs.active_tab]; if (!g_force_redraw && !ts->dirty) return false; Canvas c(pixels, width, height); Color bar_bg = Color::from_hex(0x1C1C1C); c.fill_rect(0, 0, width, TERM_TAB_BAR_H, bar_bg); int fh = system_font_height(); int tab_x = TERM_TAB_PAD; for (int i = 0; i < g_tabs.tab_count; i++) { bool active = (i == g_tabs.active_tab); char label[16]; snprintf(label, sizeof(label), "Tab %d", i + 1); if (active) { int ty = 5; int th = TERM_TAB_BAR_H - ty; c.fill_rounded_rect(tab_x, ty, TERM_TAB_W, th, 6, colors::TERM_BG); c.fill_rect(tab_x, TERM_TAB_BAR_H - 6, TERM_TAB_W, 6, colors::TERM_BG); int text_y = ty + (th - fh) / 2; c.text(tab_x + 12, text_y, label, Color::from_hex(0xE0E0E0)); c.text(tab_x + TERM_TAB_W - 20, text_y, "x", Color::from_hex(0x707070)); } else { int ty = 7; int th = 22; c.fill_rounded_rect(tab_x, ty, TERM_TAB_W, th, 5, Color::from_hex(0x262626)); int text_y = ty + (th - fh) / 2; c.text(tab_x + 12, text_y, label, Color::from_hex(0x6E6E6E)); c.text(tab_x + TERM_TAB_W - 20, text_y, "x", Color::from_hex(0x444444)); } tab_x += TERM_TAB_W + TERM_TAB_GAP; } TabBarRight right = term_tabbar_right(width); if (right.has_clock) { int text_y = (TERM_TAB_BAR_H - fh) / 2; c.text(right.clock_x, text_y, g_clock_text, Color::from_hex(0x9A9A9A)); } if (g_tabs.tab_count < TERM_MAX_TABS) { int plus_h = 22; int py = 7; int px = right.plus_x; c.fill_rounded_rect(px, py, TERM_PLUS_W, plus_h, 5, Color::from_hex(0x262626)); int pw_text = text_width(fonts::system_font, "+", fonts::UI_SIZE); c.text(px + (TERM_PLUS_W - pw_text) / 2, py + (plus_h - fh) / 2, "+", Color::from_hex(0x6E6E6E)); } uint32_t* term_pixels = pixels + TERM_TAB_BAR_H * width; terminal_render(ts, term_pixels, width, term_h); if (ts->scrollback_lines > 0 && !ts->alt_screen_active) { int cell_h = mono_cell_height(); int total_rows = ts->scrollback_lines + ts->rows; int total_h = total_rows * cell_h; int view_h = term_h; if (total_h > view_h) { Canvas sc(term_pixels, width, term_h); int sb_w = 4; int sb_x = width - sb_w - 2; int thumb_h = (view_h * view_h) / total_h; if (thumb_h < 20) thumb_h = 20; int scroll_from_top = ts->scrollback_lines - ts->view_offset; int max_scroll = ts->scrollback_lines; int thumb_y = (max_scroll > 0) ? (scroll_from_top * (view_h - thumb_h)) / max_scroll : 0; sc.fill_rect(sb_x, 0, sb_w, view_h, Color::from_rgb(0x33, 0x33, 0x33)); sc.fill_rect(sb_x, thumb_y, sb_w, thumb_h, Color::from_rgb(0x88, 0x88, 0x88)); } } g_force_redraw = false; return true; } static bool term_render() { return term_render_into(g_win.pixels, g_win.width, g_win.height); } // Mouse handling shared by both run modes. left_click is the press edge. static void term_handle_mouse_core(int mx, int my, int scroll, bool left_click, int width, int height) { if (g_tabs.tab_count <= 0) return; TerminalState* ts = g_tabs.tabs[g_tabs.active_tab]; if (scroll != 0 && !ts->alt_screen_active) { ts->view_offset += (scroll < 0) ? 3 : -3; if (ts->view_offset < 0) ts->view_offset = 0; if (ts->view_offset > ts->scrollback_lines) ts->view_offset = ts->scrollback_lines; ts->dirty = true; } if (!left_click) return; if (my >= TERM_TAB_BAR_H) return; int tab_x = TERM_TAB_PAD; for (int i = 0; i < g_tabs.tab_count; i++) { if (mx >= tab_x && mx < tab_x + TERM_TAB_W) { if (mx >= tab_x + TERM_TAB_W - 24) { term_close_tab(i); } else if (i != g_tabs.active_tab) { g_tabs.active_tab = i; g_tabs.tabs[i]->dirty = true; term_request_redraw(); } return; } tab_x += TERM_TAB_W + TERM_TAB_GAP; } int px = term_tabbar_right(width).plus_x; if (g_tabs.tab_count < TERM_MAX_TABS && mx >= px && mx < px + TERM_PLUS_W) { int cols = width / mono_cell_width(); int rows = (height - TERM_TAB_BAR_H) / mono_cell_height(); if (cols < 1) cols = 1; if (rows < 1) rows = 1; term_add_tab(cols, rows); } } static void term_handle_mouse(const montauk::abi::WinEvent& ev) { term_handle_mouse_core(ev.mouse.x, ev.mouse.y, ev.mouse.scroll, left_pressed(ev), g_win.width, g_win.height); } // Keyboard handling shared by both run modes. // User whose keyboard layout selection console mode follows. static char g_layout_user[32] = "default"; static void init_layout_user() { if (montauk::getuser(g_layout_user, sizeof(g_layout_user)) <= 0) montauk::strcpy(g_layout_user, "default"); } static void term_handle_key_core(const montauk::abi::KeyEvent& key, int width, int height) { if (g_tabs.tab_count <= 0) return; if (key.ctrl && key.pressed && key.scancode == 0x14) { if (g_tabs.tab_count < TERM_MAX_TABS) { int cols = width / mono_cell_width(); int rows = (height - TERM_TAB_BAR_H) / mono_cell_height(); if (cols < 1) cols = 1; if (rows < 1) rows = 1; term_add_tab(cols, rows); } return; } if (key.ctrl && key.pressed && key.scancode == 0x11) { term_close_tab(g_tabs.active_tab); return; } // Ctrl+Plus/Equal: zoom in if (key.ctrl && key.pressed && (key.ascii == '+' || key.ascii == '=')) { if (fonts::TERM_SIZE < 64) { fonts::TERM_SIZE += 2; for (int i = 0; i < g_tabs.tab_count; i++) g_tabs.tabs[i]->dirty = true; term_request_redraw(); } return; } // Ctrl+Minus: zoom out if (key.ctrl && key.pressed && key.ascii == '-') { if (fonts::TERM_SIZE > 8) { fonts::TERM_SIZE -= 2; for (int i = 0; i < g_tabs.tab_count; i++) g_tabs.tabs[i]->dirty = true; term_request_redraw(); } return; } terminal_handle_key(g_tabs.tabs[g_tabs.active_tab], key); } static void term_handle_key(const montauk::abi::KeyEvent& key) { term_handle_key_core(key, g_win.width, g_win.height); } static void term_cleanup() { for (int i = 0; i < g_tabs.tab_count; i++) term_free_tab(g_tabs.tabs[i]); g_tabs.tab_count = 0; } // ==== Windowed mode (Window Server client) ==== static void run_windowed() { if (!g_win.create("Terminal", INIT_W, INIT_H)) montauk::exit(1); // The kernel seeds newly created window surfaces with white so apps that // haven't drawn their first frame don't show as transparent/black. For // Terminal that produces a visible white flash while we run terminal_init // and the first TrueType render. Paint the dark background and present // immediately so the compositor sees terminal colors from frame one. { uint32_t bar_px = Color::from_hex(0x1C1C1C).to_pixel(); uint32_t bg_px = colors::TERM_BG.to_pixel(); int bar_pixels = TERM_TAB_BAR_H * g_win.width; if (bar_pixels > g_win.width * g_win.height) bar_pixels = g_win.width * g_win.height; for (int i = 0; i < bar_pixels; i++) g_win.pixels[i] = bar_px; for (int i = bar_pixels; i < g_win.width * g_win.height; i++) g_win.pixels[i] = bg_px; g_win.present(); } int term_h = g_win.height - TERM_TAB_BAR_H; g_last_win_w = g_win.width; g_last_win_h = term_h; int cols = g_win.width / mono_cell_width(); int rows = (g_win.height - TERM_TAB_BAR_H) / mono_cell_height(); if (cols < 1) cols = 1; if (rows < 1) rows = 1; g_tabs.tabs[0] = term_create_tab(cols, rows); if (!g_tabs.tabs[0]) { g_win.destroy(); montauk::exit(1); } g_tabs.tab_count = 1; g_tabs.active_tab = 0; g_tabs.prev_active_tab = -1; g_tabs.last_pixels = g_win.pixels; if (term_render()) g_win.present(); while (!g_should_exit) { bool redraw = false; montauk::abi::WinEvent ev; bool quit = false; int r = 0; while ((r = g_win.poll(&ev)) > 0) { redraw = true; if (ev.type == 3) { quit = true; break; } if (ev.type == 0) { term_handle_key(ev.key); } else if (ev.type == 1) { term_handle_mouse(ev); } else if (ev.type == 2 || ev.type == 4) { term_request_redraw(); if (g_tabs.tab_count > 0) g_tabs.tabs[g_tabs.active_tab]->dirty = true; } if (g_should_exit) { quit = true; break; } } if (r < 0 || quit) break; redraw |= term_poll_tabs(); if (g_should_exit) break; if (redraw && term_render()) g_win.present(); if (r == 0) montauk::sleep_ms(16); } term_cleanup(); g_win.destroy(); } // ==== Console mode (full-screen, no window server) ==== static void console_present(Framebuffer& fb, uint32_t* pixels, int sw, int mx, int my) { // The terminal renders into a tightly packed scene buffer; copy it across // (handling pitch), composite the mouse cursor, then flip. The full copy // each present keeps the cursor trail-free without per-tile damage tracking. fb.copy_from(pixels, sw * (int)sizeof(uint32_t)); draw_cursor(fb, mx, my); fb.flip(); } static void run_console() { Framebuffer fb; int sw = fb.width(); int sh = fb.height(); if (sw <= 0 || sh <= 0) montauk::exit(1); montauk::set_mouse_bounds(sw - 1, sh - 1); g_show_clock = true; term_refresh_clock(); uint32_t* pixels = (uint32_t*)montauk::alloc((uint64_t)sw * sh * sizeof(uint32_t)); if (!pixels) montauk::exit(1); // Paint the dark background immediately so the first frame is not a flash // of uninitialized memory while the shell spawns and the first render runs. { uint32_t bar_px = Color::from_hex(0x1C1C1C).to_pixel(); uint32_t bg_px = colors::TERM_BG.to_pixel(); int bar_pixels = TERM_TAB_BAR_H * sw; int total = sw * sh; if (bar_pixels > total) bar_pixels = total; for (int i = 0; i < bar_pixels; i++) pixels[i] = bar_px; for (int i = bar_pixels; i < total; i++) pixels[i] = bg_px; } int term_h = sh - TERM_TAB_BAR_H; g_last_win_w = sw; g_last_win_h = term_h; int cols = sw / mono_cell_width(); int rows = term_h / mono_cell_height(); if (cols < 1) cols = 1; if (rows < 1) rows = 1; init_layout_user(); g_tabs.tabs[0] = term_create_tab(cols, rows); if (!g_tabs.tabs[0]) montauk::exit(1); g_tabs.tab_count = 1; g_tabs.active_tab = 0; g_tabs.prev_active_tab = -1; g_tabs.last_pixels = pixels; montauk::abi::MouseState mouse = {}; montauk::mouse_state(&mouse); int prev_mx = mouse.x; int prev_my = mouse.y; uint8_t prev_buttons = mouse.buttons; term_render_into(pixels, sw, sh); console_present(fb, pixels, sw, mouse.x, mouse.y); uint64_t input_serial = montauk::input_wait(0, 0); while (!g_should_exit) { bool redraw = false; while (montauk::is_key_available()) { montauk::abi::KeyEvent key; montauk::getkey(&key); // Console mode reads the kernel buffer directly, so it does not // get the desktop's layout translation the windowed path does. montauk::keyboard::translate_direct(&key, g_layout_user); term_handle_key_core(key, sw, sh); redraw = true; if (g_should_exit) break; } if (g_should_exit) break; montauk::mouse_state(&mouse); bool moved = mouse.x != prev_mx || mouse.y != prev_my; bool left_click = (mouse.buttons & 1) && !(prev_buttons & 1); int scroll = mouse.scrollDelta; if (left_click || scroll != 0) term_handle_mouse_core(mouse.x, mouse.y, scroll, left_click, sw, sh); // A bare mouse move still needs a present so the cursor follows the // pointer, even when the terminal scene itself is unchanged. if (moved || left_click || scroll != 0) redraw = true; prev_mx = mouse.x; prev_my = mouse.y; prev_buttons = mouse.buttons; // Tick the clock: when the displayed time changes, force a tab-bar // repaint even if the terminal content itself is idle. if (term_refresh_clock()) term_request_redraw(); redraw |= term_poll_tabs(); if (g_should_exit) break; if (redraw) { term_render_into(pixels, sw, sh); console_present(fb, pixels, sw, mouse.x, mouse.y); } // Wake on input, but cap the wait so child (shell) output is drained // and rendered promptly even with no keyboard/mouse activity. input_serial = montauk::input_wait(input_serial, 16); } term_cleanup(); } // ==== Entry point ==== // Recognizes a leading "--console" token (optionally followed by a working // directory). Returns the number of bytes to skip past the flag, or 0 if the // arguments do not start with the flag. static int console_flag_skip(const char* args) { const char* flag = "--console"; int i = 0; while (flag[i]) { if (args[i] != flag[i]) return 0; i++; } if (args[i] != '\0' && args[i] != ' ') return 0; return i; } extern "C" void _start() { if (!fonts::init()) montauk::exit(1); char args[256] = {}; int arglen = montauk::getargs(args, sizeof(args)); bool console_mode = false; const char* workdir = args; if (arglen > 0) { int skip = console_flag_skip(args); if (skip > 0) { console_mode = true; workdir = args + skip; while (*workdir == ' ') workdir++; } } if (workdir && workdir[0]) montauk::chdir(workdir); if (console_mode) run_console(); else run_windowed(); montauk::exit(0); }