/ firmware / src / console / prompt.cpp
prompt.cpp
  1  #include "prompt.h"
  2  #include "ansi.h"
  3  #include "icons.h"
  4  #include <identity.h>
  5  #include "../networking/sntp.h"
  6  #include <config.h>
  7  
  8  #include <Arduino.h>
  9  #include <stdio.h>
 10  #include <string.h>
 11  
 12  namespace {
 13  
 14  uint16_t g_terminal_width = 0;
 15  
 16  // Probe terminal width using VT100 escape sequences:
 17  // 1. Save cursor position
 18  // 2. Move cursor to column 999 (far right — terminal clamps to last column)
 19  // 3. Query cursor position (DSR → CPR response: \x1b[row;colR)
 20  // 4. Restore cursor position
 21  // Returns 0 on failure (no response / timeout).
 22  uint16_t probe_terminal_width() {
 23    while (Serial.available()) Serial.read();
 24  
 25    Serial.print("\x1b[s\x1b[999C\x1b[6n\x1b[u");
 26    Serial.flush();
 27  
 28    char buf[16];
 29    size_t pos = 0;
 30    int timeout_ms = 500;
 31    const int poll_ms = 5;
 32  
 33    while (timeout_ms > 0 && pos < sizeof(buf) - 1) {
 34      if (Serial.available()) {
 35        char c = Serial.read();
 36        buf[pos++] = c;
 37        if (c == 'R') break;
 38      } else {
 39        delay(poll_ms);
 40        timeout_ms -= poll_ms;
 41      }
 42    }
 43    buf[pos] = '\0';
 44  
 45    // Parse \x1b[row;colR
 46    char *semi = strchr(buf, ';');
 47    if (!semi) return 0;
 48    uint16_t col = (uint16_t)atoi(semi + 1);
 49    return col > 0 ? col : 0;
 50  }
 51  
 52  // Prompt buffer — large enough for two-line powerline with ANSI escapes.
 53  // ANSI sequences are invisible but consume bytes. Each segment has ~20 bytes
 54  // of escape codes. With 8 segments + frame + fill, 1024 is comfortable.
 55  char g_prompt[2048];
 56  
 57  const char *cwd_glyph(const char *cwd) {
 58    if (strcmp(cwd, "/") == 0) return NF_FA_LOCK;
 59    if (strcmp(cwd, "~") == 0 || strncmp(cwd, "~/", 2) == 0) return NF_FA_HOME;
 60    return NF_FA_FOLDER_OPEN;
 61  }
 62  
 63  int visible_width(const char *s) {
 64    int w = 0;
 65    bool in_esc = false;
 66    while (*s) {
 67      if (*s == '\x1b') {
 68        in_esc = true;
 69        s++;
 70        continue;
 71      }
 72      if (in_esc) {
 73        if ((*s >= 'A' && *s <= 'Z') || (*s >= 'a' && *s <= 'z'))
 74          in_esc = false;
 75        s++;
 76        continue;
 77      }
 78      unsigned char c = (unsigned char)*s;
 79      if (c < 0x80) {
 80        w++;
 81        s++;
 82      } else if ((c & 0xE0) == 0xC0) {
 83        w++; s += 2;
 84      } else if ((c & 0xF0) == 0xE0) {
 85        w++; s += 3;
 86      } else if ((c & 0xF8) == 0xF0) {
 87        w++; s += 4;
 88      } else {
 89        s++;
 90      }
 91    }
 92    return w;
 93  }
 94  
 95  const char *last_path_component(const char *cwd) {
 96    const char *p = strrchr(cwd, '/');
 97    if (!p || *(p + 1) == '\0') return cwd;
 98    return p + 1;
 99  }
100  
101  } // namespace
102  
103  void console::prompt::detect_width() {
104    uint16_t w = probe_terminal_width();
105    if (w > 0) {
106      g_terminal_width = w;
107      Serial.printf("[console] terminal width: %u\n", w);
108    } else {
109      g_terminal_width = 80;
110      Serial.println("[console] width probe failed, defaulting to 80");
111    }
112  }
113  
114  void console::prompt::set_terminal_width(uint16_t w) {
115    g_terminal_width = w;
116  }
117  
118  uint16_t console::prompt::terminal_width() {
119    if (g_terminal_width == 0) g_terminal_width = 80;
120    return g_terminal_width;
121  }
122  
123  const char *console::prompt::build(const char *cwd) {
124    const char *display = last_path_component(cwd);
125    const char *glyph = cwd_glyph(cwd);
126    const char *hostname = services::identity::access_hostname();
127  
128    // Time string
129    char time_str[24];
130    if (networking::sntp::isSynced()) {
131      uint32_t epoch = networking::sntp::accessUTCEpoch();
132      uint32_t secs_of_day = epoch % 86400;
133      uint32_t hour24 = secs_of_day / 3600;
134      uint32_t minute = (secs_of_day % 3600) / 60;
135      uint32_t second = secs_of_day % 60;
136      uint32_t hour12;
137      const char *ampm;
138      if (hour24 == 0)       { hour12 = 12; ampm = "AM"; }
139      else if (hour24 < 12)  { hour12 = hour24; ampm = "AM"; }
140      else if (hour24 == 12) { hour12 = 12; ampm = "PM"; }
141      else                   { hour12 = hour24 - 12; ampm = "PM"; }
142      snprintf(time_str, sizeof(time_str), "%02lu:%02lu:%02lu %s",
143               (unsigned long)hour12, (unsigned long)minute,
144               (unsigned long)second, ampm);
145    } else {
146      unsigned long uptime = millis() / 1000;
147      snprintf(time_str, sizeof(time_str), "%lum%lus", uptime / 60, uptime % 60);
148    }
149  
150    // RAM
151    uint32_t heap_free = ESP.getFreeHeap();
152    uint32_t heap_total = ESP.getHeapSize();
153    uint32_t heap_pct = heap_total > 0 ? ((heap_total - heap_free) * 100) / heap_total : 0;
154    char ram_str[16];
155    if (heap_free >= 1024 * 1024)
156      snprintf(ram_str, sizeof(ram_str), "%.1fM", heap_free / (1024.0f * 1024.0f));
157    else
158      snprintf(ram_str, sizeof(ram_str), "%.1fK", heap_free / 1024.0f);
159    char ram_pct[8];
160    snprintf(ram_pct, sizeof(ram_pct), "%lu%%", (unsigned long)heap_pct);
161  
162    // Context
163    char context[80];
164    snprintf(context, sizeof(context), "%s@%s", CONFIG_SSH_USER, hostname);
165  
166    // Build left segment
167    char left[256];
168    snprintf(left, sizeof(left),
169      ANSI_DIM FRAME_TOP_LEFT ANSI_RESET
170      PROMPT_OS_BG PROMPT_OS_FG " " NF_FA_MICROCHIP " "
171      PROMPT_DIR_BG PROMPT_OS_BG_AS_FG NF_PLE_LEFT_HARD
172      PROMPT_DIR_BG PROMPT_DIR_FG " %s %s "
173      ANSI_RESET PROMPT_DIR_BG_AS_FG NF_PLE_LEFT_HARD ANSI_RESET,
174      glyph, display);
175  
176    // Build right segment
177    char right[384];
178    snprintf(right, sizeof(right),
179      PROMPT_CTX_BG_AS_FG NF_PLE_RIGHT_HARD
180      PROMPT_CTX_BG PROMPT_CTX_FG " %s "
181      PROMPT_RAM_BG PROMPT_RAM_FG NF_PLE_RIGHT_HARD " "
182      PROMPT_RAM_BG PROMPT_RAM_FG "%s " NF_MD_RAM " %s "
183      NF_PLE_RIGHT_SOFT " xtensa " NF_MD_ARCH " "
184      PROMPT_ARCH_BG PROMPT_CLOCK_BG_AS_FG NF_PLE_RIGHT_HARD
185      PROMPT_CLOCK_BG PROMPT_CLOCK_FG " %s " NF_FA_CLOCK " " ANSI_RESET
186      ANSI_DIM "\xe2\x94\x80\xe2\x95\xae" ANSI_RESET,
187      context, ram_pct, ram_str, time_str);
188  
189    int left_vis = visible_width(left);
190    int right_vis = visible_width(right);
191  
192    int fill = (int)g_terminal_width - left_vis - right_vis;
193    if (fill < 1) fill = 1;
194  
195    // Assemble directly into g_prompt — no intermediate fill buffer
196    int pos = snprintf(g_prompt, sizeof(g_prompt), "%s" ANSI_DIM, left);
197    for (int i = 0; i < fill && pos < (int)sizeof(g_prompt) - 4; i++) {
198      g_prompt[pos++] = '\xe2';
199      g_prompt[pos++] = '\x94';
200      g_prompt[pos++] = '\x80';
201    }
202    pos += snprintf(g_prompt + pos, sizeof(g_prompt) - pos,
203      ANSI_RESET "%s\r\n" ANSI_DIM FRAME_BOT_LEFT ANSI_RESET " ", right);
204  
205    return g_prompt;
206  }
207  
208  const char *console::prompt::build_motd(const char *remote_ip) {
209    static char motd[640];
210    const char *hostname = services::identity::access_hostname();
211    int pos = 0;
212  
213    if (remote_ip && networking::sntp::isSynced()) {
214      pos += snprintf(motd + pos, sizeof(motd) - pos,
215        "Last login: %s from %s\r\n",
216        networking::sntp::accessLocalTimeString(), remote_ip);
217    }
218  
219    pos += snprintf(motd + pos, sizeof(motd) - pos,
220      "\r\n"
221      "Welcome to %s!\r\n"
222      "\r\n"
223      "System information:     microfetch\r\n"
224      "Hardware sensors:       sensors\r\n"
225      "Network interfaces:     ip\r\n"
226      "Memory usage:           free\r\n"
227      "Disk usage:             df\r\n"
228      "Show all commands:      help\r\n"
229      "\r\n",
230      hostname);
231  
232    return motd;
233  }